commit a0243d494c7010af44c49f58d71908b4aed59317 Author: nico Date: Wed Aug 26 16:34:03 2026 +0200 Research Chat: Web-Chatoberfläche für den Research-Agent (DeepSeek v4 Flash + Playwright, Docker/Podman) diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..00421f3 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +node_modules +data +dashboards +.env +.git +*.log diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..21bf746 --- /dev/null +++ b/.env.example @@ -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= diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ac8be86 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +data/ +dashboards/*.html +.env diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..cdfd043 --- /dev/null +++ b/Dockerfile @@ -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"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..18c88f2 --- /dev/null +++ b/README.md @@ -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 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 diff --git a/docker-compose.yaml b/docker-compose.yaml new file mode 100644 index 0000000..8cfd885 --- /dev/null +++ b/docker-compose.yaml @@ -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 diff --git a/lib/config.js b/lib/config.js new file mode 100644 index 0000000..fe3f8bd --- /dev/null +++ b/lib/config.js @@ -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 }; diff --git a/lib/db.js b/lib/db.js new file mode 100644 index 0000000..7358591 --- /dev/null +++ b/lib/db.js @@ -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), +}; diff --git a/lib/queue.js b/lib/queue.js new file mode 100644 index 0000000..9d9f311 --- /dev/null +++ b/lib/queue.js @@ -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 }; diff --git a/lib/research.js b/lib/research.js new file mode 100644 index 0000000..33120c3 --- /dev/null +++ b/lib/research.js @@ -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) +- , 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 }; diff --git a/lib/tools.js b/lib/tools.js new file mode 100644 index 0000000..87d582c --- /dev/null +++ b/lib/tools.js @@ -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 }; diff --git a/package.json b/package.json new file mode 100644 index 0000000..7793591 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/public/app.js b/public/app.js new file mode 100644 index 0000000..9d3c0dd --- /dev/null +++ b/public/app.js @@ -0,0 +1,503 @@ +const state = { + me: null, + sessions: [], + current: null, + stream: null, + streamingEl: null, + activityShown: false, +}; + +const $ = (id) => document.getElementById(id); + +// --------------------------------------------------------------------------- +// Hilfsfunktionen +// --------------------------------------------------------------------------- + +function escapeHtml(s) { + return String(s ?? '') + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"'); +} + +function mdLite(text) { + let s = escapeHtml(text); + s = s.replace(/`([^`]+)`/g, '$1'); + s = s.replace(/\*\*([^*]+)\*\*/g, '$1'); + s = s.replace(/\*([^*]+)\*/g, '$1'); + s = s.replace(/\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g, '$1'); + s = s.replace(/https?:\/\/[^\s<]+/g, '$&'); + return s.replace(/\n/g, '
'); +} + +function toolLabel(msg) { + const icons = { search: '🔍', browse: '🌐', verify: '✅', save_dashboard: '📊' }; + const icon = icons[msg.tool_name] || '🔧'; + const text = msg.tool_name === 'search' ? `Suche: ${msg.content}` : msg.content; + return { icon, text: text?.slice(0, 200) }; +} + +// --------------------------------------------------------------------------- +// Session-Liste / Sidebar +// --------------------------------------------------------------------------- + +async function loadSessions() { + const res = await fetch('/api/sessions'); + state.sessions = await res.json(); + renderSessions(); +} + +function renderSessions() { + const list = $('sessionList'); + list.innerHTML = ''; + for (const s of state.sessions) { + const active = state.current?.id === s.id; + const dot = + s.job === 'running' || s.job === 'queued' + ? '' + : s.status === 'done' + ? '' + : ''; + const btn = document.createElement('button'); + btn.className = `w-full text-left px-3 py-2 rounded-lg text-sm flex items-start gap-1 ${ + active ? 'bg-blue-600/20 text-blue-300' : 'hover:bg-slate-800' + }`; + btn.innerHTML = `${dot}${escapeHtml(s.title)}`; + btn.onclick = () => openSession(s.id); + list.appendChild(btn); + } + const qs = state.sessions.find((s) => s.job === 'running' || s.job === 'queued'); + $('queueStatus').classList.toggle('hidden', !qs); +} + +// --------------------------------------------------------------------------- +// Chat-Rendering +// --------------------------------------------------------------------------- + +function addUserMessage(text) { + $('emptyState')?.remove(); + const el = document.createElement('div'); + el.className = 'flex justify-end'; + el.innerHTML = `
${mdLite(text)}
`; + $('chat').appendChild(el); + scrollDown(); +} + +function addAssistantBubble(text, streaming = false) { + $('emptyState')?.remove(); + const el = document.createElement('div'); + el.className = 'flex'; + el.innerHTML = `
`; + 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 = `
${icon}${escapeHtml(text)}
`; + $('chat').appendChild(el); + scrollDown(); +} + +function addDashboardCard(session) { + const url = session.dashboard; + if (!url) return; + const el = document.createElement('div'); + el.className = 'flex'; + el.innerHTML = ` +
+
📊 Dashboard bereit
+ Dashboard öffnen ↗ + Download +
`; + $('chat').appendChild(el); + scrollDown(); +} + +function addUsageLine(promptTokens, completionTokens, cost) { + const el = document.createElement('div'); + el.className = 'flex justify-end'; + el.innerHTML = `
🧮 ${formatTokens(promptTokens)} Eingabe · ${formatTokens( + completionTokens + )} Ausgabe · ≈ ${Number(cost || 0).toLocaleString('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 4 })} USD
`; + $('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 = ` +
🔎
+

Worüber soll ich recherchieren?

+

+ Ich suche gründlich im Internet, verifiziere alle Quellen und baue dir ein + Dashboard mit Analyse, Empfehlungen und Handlungsplan. +

`; + $('chat').appendChild(empty); + } + addDashboardCard(session); + for (const m of messages) { + if (m.role === 'user') addUserMessage(m.content); + else if (m.role === 'assistant') { + if (m.content) addAssistantBubble(m.content); + } else if (m.role === 'tool') { + const { icon, text } = toolLabel(m); + addToolLine(icon, text); + } + } + if (session.cost_estimate > 0) { + addUsageLine(session.prompt_tokens, session.completion_tokens, session.cost_estimate); + } + scrollDown(); +} + +function scrollDown() { + const chat = $('chat'); + chat.scrollTop = chat.scrollHeight; +} + +// --------------------------------------------------------------------------- +// Activity-Bar (Job-Fortschritt) +// --------------------------------------------------------------------------- + +function showActivity() { + $('activityBar').classList.remove('hidden'); + $('activityLog').classList.add('hidden'); + $('toggleLog').textContent = 'Details zeigen'; + state.activityShown = true; +} + +function setActivity(text, icon = '') { + $('activityText').textContent = `${icon} ${text}`; +} + +function logActivity(text) { + const div = document.createElement('div'); + div.textContent = text; + $('activityLog').appendChild(div); + $('activityLog').scrollTop = $('activityLog').scrollHeight; +} + +function hideActivity() { + $('activityBar').classList.add('hidden'); + state.activityShown = false; +} + +$('toggleLog').onclick = () => { + const log = $('activityLog'); + const hidden = log.classList.toggle('hidden'); + $('toggleLog').textContent = hidden ? 'Details zeigen' : 'Details verbergen'; +}; + +// --------------------------------------------------------------------------- +// SSE-Stream +// --------------------------------------------------------------------------- + +function connectStream(sessionId) { + state.stream?.close(); + const es = new EventSource(`/api/sessions/${sessionId}/stream`); + state.stream = es; + + es.addEventListener('status', (e) => { + const d = JSON.parse(e.data); + if (d.sessionStatus === 'running' || d.jobStatus === 'queued') showActivity(); + if (d.sessionStatus === 'done') { + setTimeout(hideActivity, 1500); + loadSessions(); + } + }); + + es.addEventListener('progress', (e) => { + const d = JSON.parse(e.data); + showActivity(); + setActivity(d.text, d.icon); + }); + + es.addEventListener('step', (e) => { + logActivity(JSON.parse(e.data).text); + }); + + es.addEventListener('thinking', (e) => { + const d = JSON.parse(e.data); + setActivity('Denke nach…', '🧠'); + logActivity(d.text.slice(0, 300)); + }); + + es.addEventListener('token', (e) => { + const d = JSON.parse(e.data); + showActivity(); + setActivity('Schreibe Antwort…', '✍️'); + if (!state.streamingEl) { + state.streamingEl = addAssistantBubble('', true); + } + state.streamingEl.innerHTML = mdLite(state.streamingEl.textContent + d.text); + scrollDown(); + }); + + es.addEventListener('done', async (e) => { + const d = JSON.parse(e.data); + if (state.streamingEl) { + state.streamingEl.innerHTML = mdLite(d.text || state.streamingEl.textContent); + state.streamingEl = null; + } else if (d.text) { + addAssistantBubble(d.text); + } + hideActivity(); + if (d.usage) { + addUsageLine(d.usage.promptTokens, d.usage.completionTokens, d.costEstimate); + } + if (d.dashboardUrl) { + const card = document.createElement('div'); + card.className = 'flex'; + card.innerHTML = ` +
+
📊 Dashboard bereit
+ Dashboard öffnen ↗ + Download +
`; + $('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(); + } +}); diff --git a/public/icons/icon-192.png b/public/icons/icon-192.png new file mode 100644 index 0000000..613fa48 Binary files /dev/null and b/public/icons/icon-192.png differ diff --git a/public/icons/icon-512.png b/public/icons/icon-512.png new file mode 100644 index 0000000..7cda395 Binary files /dev/null and b/public/icons/icon-512.png differ diff --git a/public/icons/icon-maskable-512.png b/public/icons/icon-maskable-512.png new file mode 100644 index 0000000..a92788f Binary files /dev/null and b/public/icons/icon-maskable-512.png differ diff --git a/public/index.html b/public/index.html new file mode 100644 index 0000000..c9be64c --- /dev/null +++ b/public/index.html @@ -0,0 +1,86 @@ + + + + + + Research Agent + + + + + + + + + + + + + + +
+ +
+ + Research Agent +
+ + + + + +
+
+
🔎
+

Worüber soll ich recherchieren?

+

+ Ich suche gründlich im Internet, verifiziere alle Quellen und baue dir ein + Dashboard mit Analyse, Empfehlungen und Handlungsplan. +

+
+
+ + +
+
+ + +
+

+
+
+ + + + diff --git a/public/login.html b/public/login.html new file mode 100644 index 0000000..3e1bfbb --- /dev/null +++ b/public/login.html @@ -0,0 +1,72 @@ + + + + + + Research Agent – Anmeldung + + + + + + + +
+
+
+
🔎
+

Research Agent

+

Dein Web-Researcher, überall erreichbar

+
+
+
+ + +
+
+ + +
+ + +
+

+ Läuft auf deinem eigenen Server · DeepSeek v4 Flash +

+
+
+ + + diff --git a/public/manifest.json b/public/manifest.json new file mode 100644 index 0000000..093720e --- /dev/null +++ b/public/manifest.json @@ -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" } + ] +} diff --git a/public/share.html b/public/share.html new file mode 100644 index 0000000..ad562a0 --- /dev/null +++ b/public/share.html @@ -0,0 +1,108 @@ + + + + + + Geteilte Recherche + + + + + + +
+
+
+ 🔎 + Research Agent + geteilt +
+ +
+ +
Lade Recherche…
+ + + +
+

Generiert mit dem Research Agent · DeepSeek v4 Flash

+
+
+ + + + diff --git a/public/sw.js b/public/sw.js new file mode 100644 index 0000000..05b1baa --- /dev/null +++ b/public/sw.js @@ -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); + }) + ); +}); diff --git a/scripts/make-icons.js b/scripts/make-icons.js new file mode 100644 index 0000000..bb6e09a --- /dev/null +++ b/scripts/make-icons.js @@ -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) => ` + + + + + + + + R + +`; + +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); +})(); diff --git a/server.js b/server.js new file mode 100644 index 0000000..43d1e93 --- /dev/null +++ b/server.js @@ -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); +});