From a0243d494c7010af44c49f58d71908b4aed59317 Mon Sep 17 00:00:00 2001 From: nico Date: Wed, 26 Aug 2026 16:34:03 +0200 Subject: [PATCH] =?UTF-8?q?Research=20Chat:=20Web-Chatoberfl=C3=A4che=20f?= =?UTF-8?q?=C3=BCr=20den=20Research-Agent=20(DeepSeek=20v4=20Flash=20+=20P?= =?UTF-8?q?laywright,=20Docker/Podman)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .dockerignore | 6 + .env.example | 19 ++ .gitignore | 4 + Dockerfile | 18 ++ README.md | 186 +++++++++++ docker-compose.yaml | 24 ++ lib/config.js | 63 ++++ lib/db.js | 194 +++++++++++ lib/queue.js | 48 +++ lib/research.js | 341 +++++++++++++++++++ lib/tools.js | 154 +++++++++ package.json | 17 + public/app.js | 503 +++++++++++++++++++++++++++++ public/icons/icon-192.png | Bin 0 -> 2068 bytes public/icons/icon-512.png | Bin 0 -> 12969 bytes public/icons/icon-maskable-512.png | Bin 0 -> 13310 bytes public/index.html | 86 +++++ public/login.html | 72 +++++ public/manifest.json | 16 + public/share.html | 108 +++++++ public/sw.js | 33 ++ scripts/make-icons.js | 33 ++ server.js | 328 +++++++++++++++++++ 23 files changed, 2253 insertions(+) create mode 100644 .dockerignore create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Dockerfile create mode 100644 README.md create mode 100644 docker-compose.yaml create mode 100644 lib/config.js create mode 100644 lib/db.js create mode 100644 lib/queue.js create mode 100644 lib/research.js create mode 100644 lib/tools.js create mode 100644 package.json create mode 100644 public/app.js create mode 100644 public/icons/icon-192.png create mode 100644 public/icons/icon-512.png create mode 100644 public/icons/icon-maskable-512.png create mode 100644 public/index.html create mode 100644 public/login.html create mode 100644 public/manifest.json create mode 100644 public/share.html create mode 100644 public/sw.js create mode 100644 scripts/make-icons.js create mode 100644 server.js 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 0000000000000000000000000000000000000000..613fa480c92b13bc509ea42cbf617be3e1deef67 GIT binary patch literal 2068 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 literal 0 HcmV?d00001 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); +});