Research Chat: Web-Chatoberfläche für den Research-Agent (DeepSeek v4 Flash + Playwright, Docker/Podman)
This commit is contained in:
commit
a0243d494c
23 files changed
+2253
No files matched your search
@@ -0,0 +1,6 @@
|
|||||||
|
node_modules
|
||||||
|
data
|
||||||
|
dashboards
|
||||||
|
.env
|
||||||
|
.git
|
||||||
|
*.log
|
||||||
@@ -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=
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules/
|
||||||
|
data/
|
||||||
|
dashboards/*.html
|
||||||
|
.env
|
||||||
+18
@@ -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"]
|
||||||
@@ -0,0 +1,186 @@
|
|||||||
|
# Research Agent – Web-Chat-Oberfläche
|
||||||
|
|
||||||
|
Browser-Chatoberfläche für den Research-Agent: DeepSeek v4 Flash recherchiert per Playwright im
|
||||||
|
Internet, verifiziert alle Links und generiert HTML-Dashboards – ohne dass Opencode lokal laufen muss.
|
||||||
|
|
||||||
|
## Funktionen
|
||||||
|
|
||||||
|
- 💬 Chat-Oberfläche mit Live-Fortschritt (SSE): Suchen, Seitenbesuche, Link-Verifizierung in Echtzeit
|
||||||
|
- 🔎 Volle Research-Funktion: DuckDuckGo-Suche, Seiten lesen, Links verifizieren, HTML-Dashboard generieren
|
||||||
|
- 🔔 Web-Push-Benachrichtigung, wenn die Recherche fertig ist (auch wenn der Browser zu ist)
|
||||||
|
- 📱 PWA: installierbar auf Desktop & Handy (Chrome/Android; iOS 16.4+ für Push)
|
||||||
|
- 👤 Eine gemeinsame Anmeldung (Name + Passwort), Sessions sind dem Namen zugeordnet
|
||||||
|
- 🔗 Sessions als read-only Teil-Link ohne Login teilbar
|
||||||
|
- 📦 SQLite-Speicherung, keine externe Datenbank nötig
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd webapp
|
||||||
|
npm install
|
||||||
|
cp .env.example .env
|
||||||
|
# .env editieren: DEEPSEEK_API_KEY und APP_PASSWORD setzen (Pflicht)
|
||||||
|
npx playwright install chromium # falls Chromium fehlt
|
||||||
|
npm start # läuft auf Port 3000
|
||||||
|
```
|
||||||
|
|
||||||
|
Fehlende Werte wie `SESSION_SECRET` und die VAPID-Keys werden beim ersten Start automatisch
|
||||||
|
erzeugt und in die `.env` geschrieben.
|
||||||
|
|
||||||
|
## Konfiguration (.env)
|
||||||
|
|
||||||
|
| Variable | Pflicht | Beschreibung |
|
||||||
|
|---|---|---|
|
||||||
|
| `DEEPSEEK_API_KEY` | ja | Key von https://platform.deepseek.com/api_keys |
|
||||||
|
| `APP_PASSWORD` | ja | Gemeinsames Login-Passwort |
|
||||||
|
| `PORT` | nein | Standard 3000 |
|
||||||
|
| `MODEL` | nein | Standard `deepseek-v4-flash` |
|
||||||
|
| `THINKING` | nein | `enabled` (Standard) oder `disabled` |
|
||||||
|
| `DEEPSEEK_BASE_URL` | nein | Für Tests/Proxies überschreibbar |
|
||||||
|
|
||||||
|
## Deployment mit Docker/Podman (empfohlen)
|
||||||
|
|
||||||
|
Alles läuft in einem Container (Node + Playwright-Chromium). Auf dem Server reicht:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone <repo-url> researchChat
|
||||||
|
cd researchChat
|
||||||
|
cp .env.example .env
|
||||||
|
# .env editieren: DEEPSEEK_API_KEY und APP_PASSWORD setzen
|
||||||
|
```
|
||||||
|
|
||||||
|
**Mit Docker:**
|
||||||
|
```bash
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
**Mit Podman (Bazzite/Fedora Atomic):**
|
||||||
|
```bash
|
||||||
|
podman compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
Beim ersten Start wird das Image gebaut (npm install + Chromium-Download, dauert ein paar Minuten).
|
||||||
|
Danach läuft die App auf Port 3000. Daten bleiben über die Volumes erhalten:
|
||||||
|
|
||||||
|
| Host-Verzeichnis | Inhalt |
|
||||||
|
|---|---|
|
||||||
|
| `./data/` | SQLite-Datenbank (Sessions, Nachrichten, Push-Abos) |
|
||||||
|
| `./dashboards/` | Generierte HTML-Dashboards |
|
||||||
|
| `./.env` | Konfiguration (generierte Secrets werden ergänzt) |
|
||||||
|
|
||||||
|
Nützliche Befehle:
|
||||||
|
```bash
|
||||||
|
podman compose logs -f # Logs ansehen
|
||||||
|
podman compose restart # Neustart
|
||||||
|
podman compose pull # neues Image holen (nach Rebuild)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Alternativ: Direkt mit Node (ohne Container)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
npx playwright install --with-deps chromium # Chromium + Systemdeps
|
||||||
|
npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
## Optional: NGINX davor (Reverse Proxy + IP-Prüfung + HTTPS)
|
||||||
|
|
||||||
|
### systemd-Unit (`/etc/systemd/system/research-web.service`, falls ohne Docker)
|
||||||
|
|
||||||
|
```ini
|
||||||
|
[Unit]
|
||||||
|
Description=Research Agent WebApp
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
WorkingDirectory=/var/home/nico/dev/researchChat
|
||||||
|
ExecStart=/usr/bin/node server.js
|
||||||
|
Restart=always
|
||||||
|
RestartSec=3
|
||||||
|
Environment=NODE_ENV=production
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
|
```
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo systemctl enable --now research-web
|
||||||
|
```
|
||||||
|
|
||||||
|
**Mit Docker/Podman** entfällt die systemd-Unit — `restart: unless-stopped` in der
|
||||||
|
docker-compose.yaml übernimmt das. Der NGINX-Block bleibt der gleiche:
|
||||||
|
|
||||||
|
### NGINX (Reverse Proxy + IP-Prüfung + HTTPS)
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
server {
|
||||||
|
listen 443 ssl;
|
||||||
|
server_name research.deinedomain.de;
|
||||||
|
|
||||||
|
# SSL-Zertifikat (certbot --nginx)
|
||||||
|
ssl_certificate /etc/letsencrypt/live/research.deinedomain.de/fullchain.pem;
|
||||||
|
ssl_certificate_key /etc/letsencrypt/live/research.deinedomain.de/privkey.pem;
|
||||||
|
|
||||||
|
# IP-Prüfung: nur deine IPs kommen rein
|
||||||
|
allow 123.45.67.89; # deine Heim-IP
|
||||||
|
allow 10.0.0.0/8; # ggf. Heimnetz/VPN
|
||||||
|
# deny all; # Kommentar entfernen, wenn IP-Check aktiv sein soll
|
||||||
|
# (für /share/ entfernen, damit Teil-Links extern funktionieren)
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://localhost:3000;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
proxy_set_header Upgrade $http_upgrade; # SSE/EventSource
|
||||||
|
proxy_set_header Connection "upgrade";
|
||||||
|
proxy_buffering off; # wichtig für Live-Streaming
|
||||||
|
proxy_read_timeout 3600s;
|
||||||
|
proxy_send_timeout 3600s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Tipp: Möchte man Teil-Links (`/share/...`) auch für Personen außerhalb der eigenen IP
|
||||||
|
freigeben, kann man die `allow/deny`-Regeln nur für `location /share/` weglassen
|
||||||
|
(und `location /` mit deny schützen) – die Teil-Ansicht ist read-only und benötigt keinen Login.
|
||||||
|
|
||||||
|
## Teilen
|
||||||
|
|
||||||
|
In jeder Session gibt es „🔗 Teilen-Link kopieren“. Der Link zeigt den Chat-Verlauf und das
|
||||||
|
Dashboard read-only, ganz ohne Login.
|
||||||
|
|
||||||
|
## Projektstruktur
|
||||||
|
|
||||||
|
```
|
||||||
|
researchChat/
|
||||||
|
├── Dockerfile # Node + Playwright-Chromium im Container
|
||||||
|
├── docker-compose.yaml # Ein-Befehl-Deployment (Docker & Podman)
|
||||||
|
├── server.js # Express-App: Auth, APIs, SSE, Web Push, statische Dateien
|
||||||
|
├── lib/
|
||||||
|
│ ├── config.js # .env-Laden, Secret/VAPID-Autogenerierung
|
||||||
|
│ ├── db.js # SQLite (node:sqlite, keine nativen Module)
|
||||||
|
│ ├── tools.js # Playwright-Tools: search, browse, verify, save_dashboard
|
||||||
|
│ ├── research.js # DeepSeek Tool-Calling-Loop (Agent)
|
||||||
|
│ └── queue.js # FIFO-Job-Queue (1 Recherche gleichzeitig)
|
||||||
|
├── public/ # Frontend (Vanilla JS + Tailwind CDN)
|
||||||
|
│ ├── index.html # Chat-UI
|
||||||
|
│ ├── login.html # Anmeldung
|
||||||
|
│ ├── share.html # Read-only Teil-Ansicht
|
||||||
|
│ ├── app.js
|
||||||
|
│ ├── sw.js # Service Worker (Push + Notifications)
|
||||||
|
│ └── manifest.json # PWA-Manifest
|
||||||
|
├── dashboards/ # generierte HTML-Dashboards (Host-Volume)
|
||||||
|
├── data/research.db # SQLite-Datenbank (Host-Volume)
|
||||||
|
└── scripts/make-icons.js
|
||||||
|
```
|
||||||
|
|
||||||
|
## Hinweise
|
||||||
|
|
||||||
|
- Recherchen dauern mehrere Minuten – die Fortschrittsleiste zeigt den aktuellen Schritt
|
||||||
|
- Nur **eine** Recherche läuft gleichzeitig; weitere Anfragen zeigen „Recherche läuft…“
|
||||||
|
- DuckDuckGo blockiert nach ~4-5 Suchen: Der Agent wechselt automatisch auf Fallback-Suchmaschinen
|
||||||
|
- Der Browser-Context bleibt pro Server-Lauf erhalten (weniger CAPTCHAs durch stabile Session-Cookies)
|
||||||
|
- API-Kosten: DeepSeek v4 Flash kostet ~$0,44/M Input- und ~$0,66/M Output-Token – eine Recherche kostet Cent-Beträge
|
||||||
|
- Auf SELinux-Systemen (Bazzite/Fedora Atomic) sind die Volume-Mounts mit `:Z` markiert – Docker ignoriert das Flag, dort reicht die Compose-Datei unverändert
|
||||||
@@ -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
|
||||||
@@ -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 };
|
||||||
@@ -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),
|
||||||
|
};
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
const { EventEmitter } = require('node:events');
|
||||||
|
|
||||||
|
const events = new EventEmitter();
|
||||||
|
events.setMaxListeners(100);
|
||||||
|
|
||||||
|
let queue = [];
|
||||||
|
let running = false;
|
||||||
|
|
||||||
|
/** Einen Job anmelden. Läuft sofort, wenn nichts anderes läuft, sonst wartet er. */
|
||||||
|
function enqueue(job) {
|
||||||
|
queue.push(job);
|
||||||
|
events.emit('job-queued', job.sessionId);
|
||||||
|
processNext();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Job aus der Warteschlange holen und ausführen. */
|
||||||
|
async function processNext() {
|
||||||
|
if (running || queue.length === 0) return;
|
||||||
|
const job = queue.shift();
|
||||||
|
running = true;
|
||||||
|
try {
|
||||||
|
await job.run();
|
||||||
|
} catch (e) {
|
||||||
|
job.onError?.(e);
|
||||||
|
} finally {
|
||||||
|
running = false;
|
||||||
|
events.emit('job-finished', job.sessionId);
|
||||||
|
processNext();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function queuedBefore(sessionId) {
|
||||||
|
return queue.findIndex((j) => j.sessionId === sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRunning(sessionId) {
|
||||||
|
return running;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelQueued(sessionId) {
|
||||||
|
queue = queue.filter((j) => j.sessionId !== sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function state() {
|
||||||
|
return { running, queued: queue.map((j) => j.sessionId) };
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { events, enqueue, queuedBefore, isRunning, cancelQueued, state };
|
||||||
+341
@@ -0,0 +1,341 @@
|
|||||||
|
const tools = require('./tools');
|
||||||
|
|
||||||
|
const SYSTEM_PROMPT = `Du bist ein professioneller Web-Researcher und Wissensberater. Deine Aufgabe: Nimm eine Recherche-Frage an, recherchiere gründlich mit den bereitgestellten Tools, verifiziere ALLE Links und erstelle ein schönes HTML-Dashboard. Du bist KEIN Link-Sammler, sondern Analyst, Berater und Horizonterweiterer.
|
||||||
|
|
||||||
|
## WICHTIG: Denke weiter als die Frage!
|
||||||
|
- Erweitere das Thema: Was ist das tiefere Bedürfnis? Welche verwandten Aspekte, überraschenden Perspektiven und Hintergrundwissen wären wertvoll?
|
||||||
|
- Bringe dem Benutzer Dinge bei, von denen er noch nicht wusste, dass er sie wissen will.
|
||||||
|
|
||||||
|
## Workflow
|
||||||
|
1. PLANE 5-8 Suchanfragen, breiter als die wörtliche Frage: 2-3 direkte, 2-3 erweiterte, 1-2 überraschende Perspektiven. Suche darf auf Englisch sein (liefert mehr Ergebnisse), das Dashboard wird auf Deutsch.
|
||||||
|
2. SUCH mit dem search-Tool über DuckDuckGo (die HTML-Version wird automatisch genutzt). WICHTIG: Warte IMMER 3-5 Sekunden zwischen Suchanfragen. Maximal 4-5 DuckDuckGo-Suchen, danach Fallbacks:
|
||||||
|
- Startpage: browse("https://www.startpage.com/do/dsearch?query=SUCHE")
|
||||||
|
- Google: browse("https://www.google.com/search?q=SUCHE")
|
||||||
|
- Direkt auf bekannte Seiten gehen (Wikipedia, offizielle Projektseiten)
|
||||||
|
3. BESUCHE die 3-5 relevantesten Ergebnisse pro Suche mit dem browse-Tool. Merke dir die EXAKTE URL der Seite, auf der du die Information findest (niemals generische Homepages verlinken!).
|
||||||
|
4. VERIFIZIERE JEDEN Link, der ins Dashboard kommt, mit dem verify-Tool (Seite lädt UND Inhalt passt zum Kontext). Nur verifizierte Links kommen ins Dashboard! Wenn ein Link nicht passt, suche den richtigen nach.
|
||||||
|
5. STRUKTURIERE die Ergebnisse in logische Kategorien (nach Plattform, Typ, Reife oder Einfachheit).
|
||||||
|
6. ANALYSIERE: Schreibe eine ausführliche Einleitung mit 6 Absätzen: (1) Überblick, (2) Horizonterweiterung - was du außerdem herausgefunden hast, (3) Einschätzung der Optionen mit Vor-/Nachteilen, (4) konkrete Empfehlung, (5) Fahrplan / erste Schritte, (6) Fallstricke und Hinweise.
|
||||||
|
7. GENERIERE das Dashboard mit dem save_dashboard-Tool (Detailstruktur unten) und fasse dann in deiner finalen Antwort das Wichtigste zusammen.
|
||||||
|
|
||||||
|
## Dashboard-Struktur (HTML, komplette Standalone-Datei)
|
||||||
|
- <html lang="de">, <script src="https://cdn.tailwindcss.com"></script> im Head, responsives Design mit Dark-Mode-Unterstützung (dark: Klassen)
|
||||||
|
- Header: Titel + Datum + Anzahl verifizierter Links
|
||||||
|
- Einleitung & Analyse (3-5 Absätze, visuell abgesetzt mit eigenem Styling/Badge "Analyse & Empfehlung")
|
||||||
|
- Kategorien mit Karten: Titel mit Link (target="_blank" rel="noopener noreferrer"), grüner "Verifiziert"-Badge, Beschreibung (2-3 Sätze), Feature-Liste, "Besuchen"-Button
|
||||||
|
- Footer: verwendete Suchbegriffe, geprüfte vs. verifizierte Quellen, Zeitstempel
|
||||||
|
- ALLES auf Deutsch!
|
||||||
|
|
||||||
|
## Regeln
|
||||||
|
- JEDER Link im Dashboard MUSS mit verify() verifiziert sein - füge bei den Karten einen "✓ Verifiziert"-Badge hinzu
|
||||||
|
- Ziele auf 8-15 verifizierte Ressourcen pro Thema
|
||||||
|
- Bei Login-Walls: old.reddit.com statt reddit.com, nitter.net statt twitter.com; wenn eine Seite blockiert, notiere das und nimm einen anderen Link
|
||||||
|
- Die finale Antwort ist eine deutsche Zusammenfassung (max. 300 Wörter): wichtigste Erkenntnisse + Link zum Dashboard. Details gehören ins Dashboard, nicht in die Antwort.
|
||||||
|
- Bei Folge-Nachrichten in einer bestehenden Session: Recherchiere die neue Anforderung und überschreibe das bestehende Dashboard mit dem save_dashboard-Tool, indem du DENSELBEN Dateinamen wie zuvor verwendest.
|
||||||
|
- Du darfst KEINE anderen URLs erfinden. Nutze ausschließlich Ergebnisse aus search/browse/verify.`;
|
||||||
|
|
||||||
|
const TOOL_DEFINITIONS = [
|
||||||
|
{
|
||||||
|
type: 'function',
|
||||||
|
function: {
|
||||||
|
name: 'search',
|
||||||
|
description:
|
||||||
|
'Durchsucht das Web über DuckDuckGo (HTML-Version). Liefert die Top-10 Ergebnisse mit Titel, URL und Kurzbeschreibung. Warte zwischen Suchen 3-5 Sekunden und nutze maximal 4-5 DuckDuckGo-Suchen pro Session, danach Fallback-Suchmaschinen über browse().',
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
query: { type: 'string', description: 'Suchanfrage, z.B. "fediverse instagram alternative pixelfed"' },
|
||||||
|
},
|
||||||
|
required: ['query'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'function',
|
||||||
|
function: {
|
||||||
|
name: 'browse',
|
||||||
|
description:
|
||||||
|
'Besucht eine URL und liest den sichtbaren Text der Seite (Titel + Inhalt). Nutze das zum Lesen von Artikeln, Projektseiten und Fallback-Suchmaschinen (Startpage: https://www.startpage.com/do/dsearch?query=..., Google: https://www.google.com/search?q=...).',
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
url: { type: 'string', description: 'Die exakte URL, die besucht werden soll' },
|
||||||
|
},
|
||||||
|
required: ['url'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'function',
|
||||||
|
function: {
|
||||||
|
name: 'verify',
|
||||||
|
description:
|
||||||
|
'Verifiziert einen Link: Prüft, ob die Seite erfolgreich lädt und echten Inhalt hat (keine 404-/Fehlerseite). JEDER Link, der ins Dashboard kommt, MUSS mit diesem Tool verifiziert werden.',
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
url: { type: 'string', description: 'Die exakte URL, die verifiziert werden soll' },
|
||||||
|
},
|
||||||
|
required: ['url'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'function',
|
||||||
|
function: {
|
||||||
|
name: 'save_dashboard',
|
||||||
|
description:
|
||||||
|
'Speichert das fertige HTML-Dashboard als Datei auf dem Server und liefert die URL zurück. Übergib die KOMPLETTE standalone HTML-Datei (mit Tailwind CDN, deutsch, verifizierte Links mit Badges, Einleitung mit Analyse). Nutze bei Folge-Nachrichten den bisherigen Dateinamen, um das Dashboard zu aktualisieren.',
|
||||||
|
parameters: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
title: { type: 'string', description: 'Titel des Dashboards, z.B. "Fediverse-Alternativen zu Instagram"' },
|
||||||
|
filename: { type: 'string', description: 'Dateiname ohne Endung, z.B. "fediverse-alternativen". Für Updates denselben Namen wie vorher verwenden.' },
|
||||||
|
html: { type: 'string', description: 'Die komplette standalone HTML-Datei des Dashboards' },
|
||||||
|
},
|
||||||
|
required: ['title', 'filename', 'html'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Menschlich lesbare Zusammenfassung eines Tool-Ergebnisses für den Chat-Verlauf. */
|
||||||
|
function summarizeTool(toolName, output) {
|
||||||
|
let data;
|
||||||
|
try {
|
||||||
|
data = JSON.parse(output);
|
||||||
|
} catch {
|
||||||
|
return output;
|
||||||
|
}
|
||||||
|
switch (toolName) {
|
||||||
|
case 'search':
|
||||||
|
return `Suche ergab ${data.results?.length ?? 0} Ergebnisse: ${
|
||||||
|
(data.results || []).slice(0, 5).map((r) => `${r.title} (${r.url})`).join(', ') || (data.note || '')
|
||||||
|
}`;
|
||||||
|
case 'browse':
|
||||||
|
return `Seite: ${data.title || data.url || ''} – ${String(data.text || '').slice(0, 500)}`;
|
||||||
|
case 'verify':
|
||||||
|
return `Link ${data.ok ? 'VERIFIZIERT ✓' : 'FEHLGESCHLAGEN ✗'}: ${data.url}${data.reason ? ' – ' + data.reason : ''}`;
|
||||||
|
case 'save_dashboard':
|
||||||
|
return `Dashboard gespeichert: ${data.url || ''}`;
|
||||||
|
default:
|
||||||
|
return String(output).slice(0, 1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_ITERATIONS = 40;
|
||||||
|
|
||||||
|
async function chatCompletion(config, messages) {
|
||||||
|
const body = {
|
||||||
|
model: config.model,
|
||||||
|
messages,
|
||||||
|
tools: TOOL_DEFINITIONS,
|
||||||
|
stream: true,
|
||||||
|
max_tokens: 32000,
|
||||||
|
stream_options: { include_usage: true },
|
||||||
|
};
|
||||||
|
if (config.thinking !== 'disabled') {
|
||||||
|
body.thinking = { type: 'enabled' };
|
||||||
|
body.reasoning_effort = 'medium';
|
||||||
|
}
|
||||||
|
|
||||||
|
const resp = await fetch(`${config.baseUrl || 'https://api.deepseek.com'}/chat/completions`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
Authorization: `Bearer ${config.apiKey}`,
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!resp.ok) {
|
||||||
|
const err = await resp.text().catch(() => '');
|
||||||
|
throw new Error(`DeepSeek API Fehler ${resp.status}: ${err.slice(0, 500)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const reader = resp.body.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let buffer = '';
|
||||||
|
const delta = { content: '', reasoning: '', toolCalls: null, finishReason: null, usage: null };
|
||||||
|
let toolCallIndex = -1;
|
||||||
|
|
||||||
|
const feed = (line) => {
|
||||||
|
if (!line.startsWith('data:')) return;
|
||||||
|
const payload = line.slice(5).trim();
|
||||||
|
if (payload === '[DONE]') return;
|
||||||
|
let json;
|
||||||
|
try {
|
||||||
|
json = JSON.parse(payload);
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (json.usage) delta.usage = json.usage;
|
||||||
|
const choice = json.choices?.[0];
|
||||||
|
if (!choice) return;
|
||||||
|
const d = choice.delta || {};
|
||||||
|
if (d.reasoning_content) delta.reasoning += d.reasoning_content;
|
||||||
|
if (d.content) delta.content += d.content;
|
||||||
|
if (d.tool_calls) {
|
||||||
|
for (const tc of d.tool_calls) {
|
||||||
|
if (tc.index != null) {
|
||||||
|
if (tc.index > toolCallIndex) {
|
||||||
|
toolCallIndex = tc.index;
|
||||||
|
if (!delta.toolCalls) delta.toolCalls = [];
|
||||||
|
delta.toolCalls.push({ id: '', type: 'function', function: { name: '', arguments: '' } });
|
||||||
|
}
|
||||||
|
const cur = delta.toolCalls[tc.index];
|
||||||
|
if (tc.id) cur.id = tc.id;
|
||||||
|
if (tc.function?.name) cur.function.name += tc.function.name;
|
||||||
|
if (tc.function?.arguments) cur.function.arguments += tc.function.arguments;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (choice.finish_reason) delta.finishReason = choice.finish_reason;
|
||||||
|
};
|
||||||
|
|
||||||
|
for (;;) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
buffer += decoder.decode(value, { stream: true });
|
||||||
|
const lines = buffer.split('\n');
|
||||||
|
buffer = lines.pop();
|
||||||
|
for (const line of lines) {
|
||||||
|
if (line.trim()) feed(line);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (buffer.trim()) feed(buffer);
|
||||||
|
|
||||||
|
return {
|
||||||
|
content: delta.content,
|
||||||
|
reasoning: delta.reasoning,
|
||||||
|
toolCalls: delta.toolCalls,
|
||||||
|
finishReason: delta.finishReason,
|
||||||
|
usage: delta.usage,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Führt den Research-Job für eine Session aus.
|
||||||
|
* @param {object} config {apiKey, model, thinking}
|
||||||
|
* @param {object} session Session aus DB
|
||||||
|
* @param {string} userMessageText Text der neuen Benutzernachricht
|
||||||
|
* @param {object} emit Event-Helfer: { token(t), thinking(t), progress(icon, text), step(msg), done(obj), error(msg) }
|
||||||
|
* @param {object} db Datenbankmodul
|
||||||
|
*/
|
||||||
|
async function runResearchJob(config, session, userMessageText, emit, db) {
|
||||||
|
let dashboardUrl = session.dashboard || null;
|
||||||
|
let dashboardFile = dashboardUrl ? dashboardUrl.split('/').pop().replace('.html', '') : null;
|
||||||
|
|
||||||
|
const handlers = {
|
||||||
|
async search({ query }) {
|
||||||
|
emit.progress('🔍', `Suche: ${query}`);
|
||||||
|
const result = await tools.search(query, (m) => emit.step(m));
|
||||||
|
if (result.blocked) emit.progress('⚠️', 'DuckDuckGo blockiert – Fallback-Strategie nutzen');
|
||||||
|
return JSON.stringify(result);
|
||||||
|
},
|
||||||
|
async browse({ url }) {
|
||||||
|
emit.progress('🌐', `Besuche: ${url}`);
|
||||||
|
const result = await tools.browse(url, (m) => emit.step(m));
|
||||||
|
return JSON.stringify(result);
|
||||||
|
},
|
||||||
|
async verify({ url }) {
|
||||||
|
emit.progress('✅', `Verifiziere: ${url}`);
|
||||||
|
const result = await tools.verify(url, (m) => emit.step(m));
|
||||||
|
return JSON.stringify(result);
|
||||||
|
},
|
||||||
|
async save_dashboard({ title, filename, html }) {
|
||||||
|
emit.progress('📊', 'Dashboard wird gespeichert…');
|
||||||
|
const saved = tools.saveDashboard(session.id, filename || title, html, (m) => emit.step(m));
|
||||||
|
dashboardUrl = saved.url;
|
||||||
|
dashboardFile = saved.file.replace('.html', '');
|
||||||
|
db.setSessionDashboard(session.id, saved.url);
|
||||||
|
emit.progress('📊', `Dashboard gespeichert: ${saved.file}`);
|
||||||
|
return JSON.stringify(saved);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const apiMessages = db.toApiMessages(session.id);
|
||||||
|
const text = userMessageText;
|
||||||
|
const usage = { promptTokens: 0, completionTokens: 0, cacheHitTokens: 0, cacheMissTokens: 0 };
|
||||||
|
emit.progress('🤖', 'Research startet…');
|
||||||
|
|
||||||
|
const trackUsage = (u) => {
|
||||||
|
if (!u) return;
|
||||||
|
usage.promptTokens += u.prompt_tokens || 0;
|
||||||
|
usage.completionTokens += u.completion_tokens || 0;
|
||||||
|
usage.cacheHitTokens += u.prompt_cache_hit_tokens || 0;
|
||||||
|
usage.cacheMissTokens += u.prompt_cache_miss_tokens || 0;
|
||||||
|
if (!usage.cacheHitTokens && !usage.cacheMissTokens) {
|
||||||
|
usage.cacheMissTokens = u.prompt_tokens || 0;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const finishJob = (finalText, dashboardUrl) => {
|
||||||
|
db.setSessionUsage(session.id, usage);
|
||||||
|
db.setSessionStatus(session.id, 'done');
|
||||||
|
if (dashboardUrl) db.setSessionDashboard(session.id, dashboardUrl);
|
||||||
|
const costEstimate = db.computeCost(usage);
|
||||||
|
emit.done({ text: finalText, dashboardUrl, usage, costEstimate });
|
||||||
|
return { text: finalText, dashboardUrl, usage, costEstimate };
|
||||||
|
};
|
||||||
|
|
||||||
|
for (let iter = 0; iter < MAX_ITERATIONS; iter++) {
|
||||||
|
emit.progress('🤖', iter === 0 ? 'Analysiere Anfrage und plane Suche…' : 'Weitere Analyse…');
|
||||||
|
const result = await chatCompletion(config, apiMessages);
|
||||||
|
trackUsage(result.usage);
|
||||||
|
|
||||||
|
if (result.reasoning) emit.thinking(result.reasoning);
|
||||||
|
|
||||||
|
if (result.toolCalls && result.toolCalls.length > 0) {
|
||||||
|
const assistantMsg = {
|
||||||
|
role: 'assistant',
|
||||||
|
content: result.content || null,
|
||||||
|
tool_calls: result.toolCalls.map((tc) => ({
|
||||||
|
id: tc.id,
|
||||||
|
type: 'function',
|
||||||
|
function: { name: tc.function.name, arguments: tc.function.arguments },
|
||||||
|
})),
|
||||||
|
};
|
||||||
|
db.createMessage(session.id, { role: 'assistant', content: assistantMsg.content, toolCalls: assistantMsg.tool_calls });
|
||||||
|
apiMessages.push(assistantMsg);
|
||||||
|
|
||||||
|
for (const tc of result.toolCalls) {
|
||||||
|
const fn = handlers[tc.function.name];
|
||||||
|
let output;
|
||||||
|
if (!fn) {
|
||||||
|
output = JSON.stringify({ error: `Unbekanntes Tool: ${tc.function.name}` });
|
||||||
|
} else {
|
||||||
|
try {
|
||||||
|
const args = JSON.parse(tc.function.arguments || '{}');
|
||||||
|
output = await fn(args);
|
||||||
|
} catch (e) {
|
||||||
|
output = JSON.stringify({ error: `Tool-Fehler: ${e.message.slice(0, 300)}` });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
db.createMessage(session.id, {
|
||||||
|
role: 'tool',
|
||||||
|
toolName: tc.function.name,
|
||||||
|
toolCallId: tc.id,
|
||||||
|
content: summarizeTool(tc.function.name, output).slice(0, 8000),
|
||||||
|
});
|
||||||
|
apiMessages.push({ role: 'tool', tool_call_id: tc.id, content: output.slice(0, 8000) });
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const finalText = result.content || (dashboardUrl ? 'Recherche abgeschlossen – siehe Dashboard.' : '');
|
||||||
|
db.createMessage(session.id, { role: 'assistant', content: finalText });
|
||||||
|
return finishJob(finalText, dashboardUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
db.createMessage(session.id, {
|
||||||
|
role: 'assistant',
|
||||||
|
content: 'Maximale Iterationen erreicht – Recherche abgebrochen. Bitte neue Session starten.',
|
||||||
|
});
|
||||||
|
db.setSessionUsage(session.id, usage);
|
||||||
|
db.setSessionStatus(session.id, 'done');
|
||||||
|
emit.error('Maximale Iterationen erreicht – Recherche abgebrochen.');
|
||||||
|
return { text: '', dashboardUrl, usage, costEstimate: db.computeCost(usage) };
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { runResearchJob, SYSTEM_PROMPT, TOOL_DEFINITIONS };
|
||||||
+154
@@ -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 };
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"name": "research-chat",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Web-Chatoberfläche für den Research-Agent (DeepSeek v4 Flash + Playwright)",
|
||||||
|
"main": "server.js",
|
||||||
|
"type": "commonjs",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node server.js",
|
||||||
|
"icons": "node scripts/make-icons.js"
|
||||||
|
},
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"express": "^4.19.2",
|
||||||
|
"playwright": "1.62.1",
|
||||||
|
"web-push": "^3.6.7"
|
||||||
|
}
|
||||||
|
}
|
||||||
+503
@@ -0,0 +1,503 @@
|
|||||||
|
const state = {
|
||||||
|
me: null,
|
||||||
|
sessions: [],
|
||||||
|
current: null,
|
||||||
|
stream: null,
|
||||||
|
streamingEl: null,
|
||||||
|
activityShown: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const $ = (id) => document.getElementById(id);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Hilfsfunktionen
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function escapeHtml(s) {
|
||||||
|
return String(s ?? '')
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
|
||||||
|
function mdLite(text) {
|
||||||
|
let s = escapeHtml(text);
|
||||||
|
s = s.replace(/`([^`]+)`/g, '<code class="px-1 py-0.5 rounded bg-slate-700 text-amber-200 text-xs">$1</code>');
|
||||||
|
s = s.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
|
||||||
|
s = s.replace(/\*([^*]+)\*/g, '<em>$1</em>');
|
||||||
|
s = s.replace(/\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer" class="text-blue-400 underline break-all">$1</a>');
|
||||||
|
s = s.replace(/https?:\/\/[^\s<]+/g, '<a href="$&" target="_blank" rel="noopener noreferrer" class="text-blue-400 underline break-all">$&</a>');
|
||||||
|
return s.replace(/\n/g, '<br>');
|
||||||
|
}
|
||||||
|
|
||||||
|
function toolLabel(msg) {
|
||||||
|
const icons = { search: '🔍', browse: '🌐', verify: '✅', save_dashboard: '📊' };
|
||||||
|
const icon = icons[msg.tool_name] || '🔧';
|
||||||
|
const text = msg.tool_name === 'search' ? `Suche: ${msg.content}` : msg.content;
|
||||||
|
return { icon, text: text?.slice(0, 200) };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Session-Liste / Sidebar
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async function loadSessions() {
|
||||||
|
const res = await fetch('/api/sessions');
|
||||||
|
state.sessions = await res.json();
|
||||||
|
renderSessions();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSessions() {
|
||||||
|
const list = $('sessionList');
|
||||||
|
list.innerHTML = '';
|
||||||
|
for (const s of state.sessions) {
|
||||||
|
const active = state.current?.id === s.id;
|
||||||
|
const dot =
|
||||||
|
s.job === 'running' || s.job === 'queued'
|
||||||
|
? '<span class="w-2 h-2 rounded-full bg-amber-400 animate-pulse inline-block mr-2"></span>'
|
||||||
|
: s.status === 'done'
|
||||||
|
? '<span class="w-2 h-2 rounded-full bg-emerald-400 inline-block mr-2"></span>'
|
||||||
|
: '<span class="w-2 h-2 rounded-full bg-slate-500 inline-block mr-2"></span>';
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.className = `w-full text-left px-3 py-2 rounded-lg text-sm flex items-start gap-1 ${
|
||||||
|
active ? 'bg-blue-600/20 text-blue-300' : 'hover:bg-slate-800'
|
||||||
|
}`;
|
||||||
|
btn.innerHTML = `${dot}<span class="truncate">${escapeHtml(s.title)}</span>`;
|
||||||
|
btn.onclick = () => openSession(s.id);
|
||||||
|
list.appendChild(btn);
|
||||||
|
}
|
||||||
|
const qs = state.sessions.find((s) => s.job === 'running' || s.job === 'queued');
|
||||||
|
$('queueStatus').classList.toggle('hidden', !qs);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Chat-Rendering
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function addUserMessage(text) {
|
||||||
|
$('emptyState')?.remove();
|
||||||
|
const el = document.createElement('div');
|
||||||
|
el.className = 'flex justify-end';
|
||||||
|
el.innerHTML = `<div class="max-w-[80%] px-4 py-2.5 rounded-2xl bg-blue-600 text-white whitespace-pre-wrap break-words">${mdLite(text)}</div>`;
|
||||||
|
$('chat').appendChild(el);
|
||||||
|
scrollDown();
|
||||||
|
}
|
||||||
|
|
||||||
|
function addAssistantBubble(text, streaming = false) {
|
||||||
|
$('emptyState')?.remove();
|
||||||
|
const el = document.createElement('div');
|
||||||
|
el.className = 'flex';
|
||||||
|
el.innerHTML = `<div class="max-w-[85%] px-4 py-2.5 rounded-2xl bg-slate-200 dark:bg-slate-800 whitespace-pre-wrap break-words ${
|
||||||
|
streaming ? 'streaming' : ''
|
||||||
|
}"></div>`;
|
||||||
|
const body = el.firstChild;
|
||||||
|
body.innerHTML = mdLite(text);
|
||||||
|
$('chat').appendChild(el);
|
||||||
|
scrollDown();
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addToolLine(icon, text) {
|
||||||
|
$('emptyState')?.remove();
|
||||||
|
const el = document.createElement('div');
|
||||||
|
el.className = 'flex justify-start';
|
||||||
|
el.innerHTML = `<div class="text-xs text-slate-400 px-1 flex items-center gap-1"><span>${icon}</span><span class="break-all">${escapeHtml(text)}</span></div>`;
|
||||||
|
$('chat').appendChild(el);
|
||||||
|
scrollDown();
|
||||||
|
}
|
||||||
|
|
||||||
|
function addDashboardCard(session) {
|
||||||
|
const url = session.dashboard;
|
||||||
|
if (!url) return;
|
||||||
|
const el = document.createElement('div');
|
||||||
|
el.className = 'flex';
|
||||||
|
el.innerHTML = `
|
||||||
|
<div class="max-w-[85%] w-full dark:bg-slate-800 bg-slate-200 rounded-2xl p-4 border dark:border-slate-700 border-slate-300">
|
||||||
|
<div class="text-sm font-semibold mb-2">📊 Dashboard bereit</div>
|
||||||
|
<a href="${url}" target="_blank" rel="noopener noreferrer"
|
||||||
|
class="inline-block px-4 py-2 rounded-lg bg-emerald-600 hover:bg-emerald-700 text-white text-sm font-semibold">Dashboard öffnen ↗</a>
|
||||||
|
<a href="${url}" download class="inline-block ml-2 px-4 py-2 rounded-lg bg-slate-600 hover:bg-slate-500 text-white text-sm">Download</a>
|
||||||
|
</div>`;
|
||||||
|
$('chat').appendChild(el);
|
||||||
|
scrollDown();
|
||||||
|
}
|
||||||
|
|
||||||
|
function addUsageLine(promptTokens, completionTokens, cost) {
|
||||||
|
const el = document.createElement('div');
|
||||||
|
el.className = 'flex justify-end';
|
||||||
|
el.innerHTML = `<div class="text-xs text-slate-500 dark:text-slate-500 px-1">🧮 ${formatTokens(promptTokens)} Eingabe · ${formatTokens(
|
||||||
|
completionTokens
|
||||||
|
)} Ausgabe · ≈ ${Number(cost || 0).toLocaleString('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 4 })} USD</div>`;
|
||||||
|
$('chat').appendChild(el);
|
||||||
|
scrollDown();
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTokens(n) {
|
||||||
|
return Number(n || 0).toLocaleString('de-DE');
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMessages(messages, session) {
|
||||||
|
$('chat').innerHTML = '';
|
||||||
|
if (!messages.length) {
|
||||||
|
const empty = document.createElement('div');
|
||||||
|
empty.className =
|
||||||
|
'h-full flex flex-col items-center justify-center text-center text-slate-400';
|
||||||
|
empty.innerHTML = `
|
||||||
|
<div class="text-5xl mb-4">🔎</div>
|
||||||
|
<h2 class="text-lg font-semibold text-slate-500 dark:text-slate-300">Worüber soll ich recherchieren?</h2>
|
||||||
|
<p class="text-sm max-w-md mt-2">
|
||||||
|
Ich suche gründlich im Internet, verifiziere alle Quellen und baue dir ein
|
||||||
|
Dashboard mit Analyse, Empfehlungen und Handlungsplan.
|
||||||
|
</p>`;
|
||||||
|
$('chat').appendChild(empty);
|
||||||
|
}
|
||||||
|
addDashboardCard(session);
|
||||||
|
for (const m of messages) {
|
||||||
|
if (m.role === 'user') addUserMessage(m.content);
|
||||||
|
else if (m.role === 'assistant') {
|
||||||
|
if (m.content) addAssistantBubble(m.content);
|
||||||
|
} else if (m.role === 'tool') {
|
||||||
|
const { icon, text } = toolLabel(m);
|
||||||
|
addToolLine(icon, text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (session.cost_estimate > 0) {
|
||||||
|
addUsageLine(session.prompt_tokens, session.completion_tokens, session.cost_estimate);
|
||||||
|
}
|
||||||
|
scrollDown();
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrollDown() {
|
||||||
|
const chat = $('chat');
|
||||||
|
chat.scrollTop = chat.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Activity-Bar (Job-Fortschritt)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function showActivity() {
|
||||||
|
$('activityBar').classList.remove('hidden');
|
||||||
|
$('activityLog').classList.add('hidden');
|
||||||
|
$('toggleLog').textContent = 'Details zeigen';
|
||||||
|
state.activityShown = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setActivity(text, icon = '') {
|
||||||
|
$('activityText').textContent = `${icon} ${text}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function logActivity(text) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = text;
|
||||||
|
$('activityLog').appendChild(div);
|
||||||
|
$('activityLog').scrollTop = $('activityLog').scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideActivity() {
|
||||||
|
$('activityBar').classList.add('hidden');
|
||||||
|
state.activityShown = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$('toggleLog').onclick = () => {
|
||||||
|
const log = $('activityLog');
|
||||||
|
const hidden = log.classList.toggle('hidden');
|
||||||
|
$('toggleLog').textContent = hidden ? 'Details zeigen' : 'Details verbergen';
|
||||||
|
};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// SSE-Stream
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function connectStream(sessionId) {
|
||||||
|
state.stream?.close();
|
||||||
|
const es = new EventSource(`/api/sessions/${sessionId}/stream`);
|
||||||
|
state.stream = es;
|
||||||
|
|
||||||
|
es.addEventListener('status', (e) => {
|
||||||
|
const d = JSON.parse(e.data);
|
||||||
|
if (d.sessionStatus === 'running' || d.jobStatus === 'queued') showActivity();
|
||||||
|
if (d.sessionStatus === 'done') {
|
||||||
|
setTimeout(hideActivity, 1500);
|
||||||
|
loadSessions();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
es.addEventListener('progress', (e) => {
|
||||||
|
const d = JSON.parse(e.data);
|
||||||
|
showActivity();
|
||||||
|
setActivity(d.text, d.icon);
|
||||||
|
});
|
||||||
|
|
||||||
|
es.addEventListener('step', (e) => {
|
||||||
|
logActivity(JSON.parse(e.data).text);
|
||||||
|
});
|
||||||
|
|
||||||
|
es.addEventListener('thinking', (e) => {
|
||||||
|
const d = JSON.parse(e.data);
|
||||||
|
setActivity('Denke nach…', '🧠');
|
||||||
|
logActivity(d.text.slice(0, 300));
|
||||||
|
});
|
||||||
|
|
||||||
|
es.addEventListener('token', (e) => {
|
||||||
|
const d = JSON.parse(e.data);
|
||||||
|
showActivity();
|
||||||
|
setActivity('Schreibe Antwort…', '✍️');
|
||||||
|
if (!state.streamingEl) {
|
||||||
|
state.streamingEl = addAssistantBubble('', true);
|
||||||
|
}
|
||||||
|
state.streamingEl.innerHTML = mdLite(state.streamingEl.textContent + d.text);
|
||||||
|
scrollDown();
|
||||||
|
});
|
||||||
|
|
||||||
|
es.addEventListener('done', async (e) => {
|
||||||
|
const d = JSON.parse(e.data);
|
||||||
|
if (state.streamingEl) {
|
||||||
|
state.streamingEl.innerHTML = mdLite(d.text || state.streamingEl.textContent);
|
||||||
|
state.streamingEl = null;
|
||||||
|
} else if (d.text) {
|
||||||
|
addAssistantBubble(d.text);
|
||||||
|
}
|
||||||
|
hideActivity();
|
||||||
|
if (d.usage) {
|
||||||
|
addUsageLine(d.usage.promptTokens, d.usage.completionTokens, d.costEstimate);
|
||||||
|
}
|
||||||
|
if (d.dashboardUrl) {
|
||||||
|
const card = document.createElement('div');
|
||||||
|
card.className = 'flex';
|
||||||
|
card.innerHTML = `
|
||||||
|
<div class="max-w-[85%] w-full dark:bg-slate-800 bg-slate-200 rounded-2xl p-4 border dark:border-slate-700 border-slate-300">
|
||||||
|
<div class="text-sm font-semibold mb-2">📊 Dashboard bereit</div>
|
||||||
|
<a href="${d.dashboardUrl}" target="_blank" rel="noopener noreferrer"
|
||||||
|
class="inline-block px-4 py-2 rounded-lg bg-emerald-600 hover:bg-emerald-700 text-white text-sm font-semibold">Dashboard öffnen ↗</a>
|
||||||
|
<a href="${d.dashboardUrl}" download class="inline-block ml-2 px-4 py-2 rounded-lg bg-slate-600 hover:bg-slate-500 text-white text-sm">Download</a>
|
||||||
|
</div>`;
|
||||||
|
$('chat').appendChild(card);
|
||||||
|
scrollDown();
|
||||||
|
notify(`"${sessionTitle()}" – Dashboard ist bereit`);
|
||||||
|
}
|
||||||
|
loadSessions();
|
||||||
|
$('sendBtn').disabled = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
es.addEventListener('error', (e) => {
|
||||||
|
if (!e.data) return;
|
||||||
|
const d = JSON.parse(e.data);
|
||||||
|
addToolLine('⚠️', d.text || 'Ein Fehler ist aufgetreten');
|
||||||
|
hideActivity();
|
||||||
|
$('sendBtn').disabled = false;
|
||||||
|
loadSessions();
|
||||||
|
});
|
||||||
|
|
||||||
|
es.onerror = () => {
|
||||||
|
setTimeout(() => {
|
||||||
|
if (state.current) connectStream(state.current.id);
|
||||||
|
}, 4000);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function sessionTitle() {
|
||||||
|
return state.current?.title || 'Recherche';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Benachrichtigungen + Push
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function notify(body) {
|
||||||
|
if ('Notification' in window && Notification.permission === 'granted') {
|
||||||
|
try {
|
||||||
|
new Notification('✅ Recherche fertig', { body, icon: '/icons/icon-192.png' });
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function enableNotifications() {
|
||||||
|
if (!('Notification' in window) || !('serviceWorker' in navigator)) return;
|
||||||
|
if (Notification.permission === 'denied') return;
|
||||||
|
if (Notification.permission !== 'granted') {
|
||||||
|
try {
|
||||||
|
await Notification.requestPermission();
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const reg = await navigator.serviceWorker.ready;
|
||||||
|
const { key } = await (await fetch('/api/push/vapid-key')).json();
|
||||||
|
const sub = await reg.pushManager.subscribe({
|
||||||
|
userVisibleOnly: true,
|
||||||
|
applicationServerKey: key,
|
||||||
|
});
|
||||||
|
await fetch('/api/push/subscribe', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(sub.toJSON()),
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Push-Subscription fehlgeschlagen:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Session-Verwaltung
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async function openSession(id) {
|
||||||
|
state.stream?.close();
|
||||||
|
state.streamingEl = null;
|
||||||
|
hideActivity();
|
||||||
|
const res = await fetch(`/api/sessions/${id}`);
|
||||||
|
const data = await res.json();
|
||||||
|
state.current = data.session;
|
||||||
|
renderMessages(data.messages, data.session);
|
||||||
|
$('mobileTitle').textContent = data.session.title;
|
||||||
|
renderSessions();
|
||||||
|
const q = state.sessions.find((s) => s.id === id);
|
||||||
|
if (data.session.status === 'running' || q?.job === 'running' || q?.job === 'queued') {
|
||||||
|
showActivity();
|
||||||
|
}
|
||||||
|
updateShareHint();
|
||||||
|
connectStream(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function newSession() {
|
||||||
|
const res = await fetch('/api/sessions', { method: 'POST' });
|
||||||
|
const session = await res.json();
|
||||||
|
await loadSessions();
|
||||||
|
await openSession(session.id);
|
||||||
|
$('input').focus();
|
||||||
|
}
|
||||||
|
|
||||||
|
function copyText(text, btn, label) {
|
||||||
|
try {
|
||||||
|
navigator.clipboard.writeText(text).then(
|
||||||
|
() => {
|
||||||
|
btn.textContent = '✅ Link kopiert!';
|
||||||
|
setTimeout(() => (btn.textContent = label), 2500);
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
window.prompt(label, text);
|
||||||
|
btn.textContent = label;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
window.prompt(label, text);
|
||||||
|
btn.textContent = label;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateShareHint() {
|
||||||
|
$('shareHint').innerHTML = '';
|
||||||
|
if (!state.current) return;
|
||||||
|
|
||||||
|
const btn = document.createElement('button');
|
||||||
|
btn.className = 'text-blue-400 hover:underline text-xs';
|
||||||
|
btn.textContent = '🔗 Chat teilen';
|
||||||
|
btn.onclick = async () => {
|
||||||
|
const res = await fetch(`/api/sessions/${state.current.id}/share`);
|
||||||
|
if (!res.ok) {
|
||||||
|
btn.textContent = '⚠️ Fehler beim Teilen';
|
||||||
|
setTimeout(() => (btn.textContent = '🔗 Chat teilen'), 2500);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { fullUrl } = await res.json();
|
||||||
|
copyText(fullUrl, btn, '🔗 Chat teilen');
|
||||||
|
};
|
||||||
|
$('shareHint').appendChild(btn);
|
||||||
|
|
||||||
|
if (state.current.dashboard) {
|
||||||
|
$('shareHint').appendChild(document.createTextNode(' · '));
|
||||||
|
const dashBtn = document.createElement('button');
|
||||||
|
dashBtn.className = 'text-emerald-400 hover:underline text-xs';
|
||||||
|
dashBtn.textContent = '📊 Dashboard teilen';
|
||||||
|
dashBtn.onclick = async () => {
|
||||||
|
const full = `${location.origin}${state.current.dashboard}`;
|
||||||
|
copyText(full, dashBtn, '📊 Dashboard teilen');
|
||||||
|
};
|
||||||
|
$('shareHint').appendChild(dashBtn);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Senden
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async function sendMessage(text) {
|
||||||
|
if (!state.current) await newSession();
|
||||||
|
$('sendBtn').disabled = true;
|
||||||
|
addUserMessage(text);
|
||||||
|
const res = await fetch(`/api/sessions/${state.current.id}/messages`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ text }),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
addToolLine('⚠️', data.error || 'Senden fehlgeschlagen');
|
||||||
|
$('sendBtn').disabled = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
enableNotifications();
|
||||||
|
showActivity();
|
||||||
|
setActivity('In Warteschlange…', '⏳');
|
||||||
|
await loadSessions();
|
||||||
|
const s = state.sessions.find((x) => x.id === state.current.id);
|
||||||
|
if (s?.title) state.current.title = s.title;
|
||||||
|
connectStream(state.current.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Init
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
window.addEventListener('load', async () => {
|
||||||
|
const meRes = await fetch('/api/me');
|
||||||
|
if (!meRes.ok) return (location.href = '/login.html');
|
||||||
|
state.me = await meRes.json();
|
||||||
|
$('userName').textContent = `👤 ${state.me.name}`;
|
||||||
|
|
||||||
|
if ('serviceWorker' in navigator) {
|
||||||
|
navigator.serviceWorker.register('/sw.js').catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
await loadSessions();
|
||||||
|
const params = new URLSearchParams(location.search);
|
||||||
|
const target = params.get('session') || state.sessions.find((s) => s.status === 'running')?.id || state.sessions[0]?.id;
|
||||||
|
if (target) await openSession(target);
|
||||||
|
|
||||||
|
if (!target) {
|
||||||
|
const s = await (await fetch('/api/sessions', { method: 'POST' })).json();
|
||||||
|
await openSession(s.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$('chatForm').addEventListener('submit', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const text = $('input').value.trim();
|
||||||
|
if (!text) return;
|
||||||
|
$('input').value = '';
|
||||||
|
$('input').style.height = 'auto';
|
||||||
|
sendMessage(text);
|
||||||
|
});
|
||||||
|
|
||||||
|
$('input').addEventListener('input', () => {
|
||||||
|
$('input').style.height = 'auto';
|
||||||
|
$('input').style.height = Math.min($('input').scrollHeight, 160) + 'px';
|
||||||
|
});
|
||||||
|
|
||||||
|
$('newSessionBtn').onclick = newSession;
|
||||||
|
$('menuBtn').onclick = () => $('sidebar').classList.toggle('hidden');
|
||||||
|
$('logoutBtn').onclick = async () => {
|
||||||
|
await fetch('/api/logout', { method: 'POST' });
|
||||||
|
location.href = '/login.html';
|
||||||
|
};
|
||||||
|
|
||||||
|
// Enter sendet, Shift+Enter = Zeilenumbruch
|
||||||
|
$('input').addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
$('chatForm').requestSubmit();
|
||||||
|
}
|
||||||
|
});
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 2.0 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
@@ -0,0 +1,86 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Research Agent</title>
|
||||||
|
<link rel="manifest" href="/manifest.json">
|
||||||
|
<link rel="icon" href="/icons/icon-192.png">
|
||||||
|
<meta name="theme-color" content="#0f172a">
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<script>
|
||||||
|
tailwind.config = { darkMode: 'class' };
|
||||||
|
document.documentElement.classList.add('dark');
|
||||||
|
</script>
|
||||||
|
</head>
|
||||||
|
<body class="dark:bg-slate-950 bg-slate-100 dark:text-slate-100 text-slate-900 h-screen flex overflow-hidden">
|
||||||
|
|
||||||
|
<!-- Sidebar -->
|
||||||
|
<aside id="sidebar" class="w-64 shrink-0 dark:bg-slate-900 bg-white border-r dark:border-slate-800 border-slate-200 flex flex-col hidden md:flex">
|
||||||
|
<div class="p-4 border-b dark:border-slate-800 border-slate-200 flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="text-xl">🔎</span>
|
||||||
|
<span class="font-bold">Research Agent</span>
|
||||||
|
</div>
|
||||||
|
<button id="logoutBtn" class="text-sm text-slate-400 hover:text-red-400" title="Abmelden">⎋</button>
|
||||||
|
</div>
|
||||||
|
<button id="newSessionBtn"
|
||||||
|
class="m-4 py-2 rounded-lg bg-blue-600 hover:bg-blue-700 text-white font-semibold text-sm">
|
||||||
|
+ Neue Recherche
|
||||||
|
</button>
|
||||||
|
<div id="sessionList" class="flex-1 overflow-y-auto px-2 space-y-1"></div>
|
||||||
|
<div class="p-3 text-xs text-slate-400 border-t dark:border-slate-800 border-slate-200">
|
||||||
|
<div id="userName" class="font-medium text-slate-500 dark:text-slate-400 mb-1"></div>
|
||||||
|
<div id="queueStatus" class="hidden"><span class="inline-block w-2 h-2 bg-amber-400 rounded-full mr-1 animate-pulse"></span>Recherche läuft…</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<!-- Main -->
|
||||||
|
<main class="flex-1 flex flex-col min-w-0">
|
||||||
|
<!-- Mobile header -->
|
||||||
|
<div class="md:hidden flex items-center gap-2 p-3 border-b dark:border-slate-800 border-slate-200 bg-white dark:bg-slate-900">
|
||||||
|
<button id="menuBtn" class="px-2 py-1 rounded bg-slate-200 dark:bg-slate-800">☰</button>
|
||||||
|
<span class="font-bold truncate flex-1" id="mobileTitle">Research Agent</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Activity bar -->
|
||||||
|
<div id="activityBar" class="hidden px-4 py-2 text-sm border-b dark:border-slate-800 border-slate-200 bg-slate-50 dark:bg-slate-900">
|
||||||
|
<div id="activityLine" class="flex items-center gap-2">
|
||||||
|
<span class="inline-block w-3 h-3 border-2 border-blue-500 border-t-transparent rounded-full animate-spin"></span>
|
||||||
|
<span id="activityText">Starte…</span>
|
||||||
|
</div>
|
||||||
|
<div id="activityLog" class="hidden mt-1 max-h-32 overflow-y-auto text-xs text-slate-400 space-y-0.5"></div>
|
||||||
|
<button id="toggleLog" class="text-xs text-blue-400 mt-1">Details zeigen</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Chat -->
|
||||||
|
<div id="chat" class="flex-1 overflow-y-auto p-4 space-y-4">
|
||||||
|
<div id="emptyState" class="h-full flex flex-col items-center justify-center text-center text-slate-400">
|
||||||
|
<div class="text-5xl mb-4">🔎</div>
|
||||||
|
<h2 class="text-lg font-semibold text-slate-500 dark:text-slate-300">Worüber soll ich recherchieren?</h2>
|
||||||
|
<p class="text-sm max-w-md mt-2">
|
||||||
|
Ich suche gründlich im Internet, verifiziere alle Quellen und baue dir ein
|
||||||
|
Dashboard mit Analyse, Empfehlungen und Handlungsplan.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Input -->
|
||||||
|
<div class="p-4 border-t dark:border-slate-800 border-slate-200 bg-white dark:bg-slate-900">
|
||||||
|
<form id="chatForm" class="flex gap-2 items-end">
|
||||||
|
<textarea id="input" rows="1" placeholder="Thema eingeben… z.B. „Beste Self-Hosted Social-Media-Alternativen 2026“"
|
||||||
|
class="flex-1 resize-none px-3 py-2.5 rounded-xl border dark:bg-slate-800 dark:border-slate-700 border-slate-300 focus:outline-none focus:ring-2 focus:ring-blue-500 max-h-40"></textarea>
|
||||||
|
<button id="sendBtn" type="submit"
|
||||||
|
class="px-4 py-2.5 rounded-xl bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white font-semibold">
|
||||||
|
Senden
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<p class="text-xs text-slate-400 mt-2" id="shareHint"></p>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script src="/app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Research Agent – Anmeldung</title>
|
||||||
|
<link rel="manifest" href="/manifest.json">
|
||||||
|
<link rel="icon" href="/icons/icon-192.png">
|
||||||
|
<meta name="theme-color" content="#0f172a">
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<script>
|
||||||
|
tailwind.config = { darkMode: 'class' };
|
||||||
|
document.documentElement.classList.add('dark');
|
||||||
|
</script>
|
||||||
|
</head>
|
||||||
|
<body class="dark:bg-slate-950 bg-slate-100 dark:text-slate-100 text-slate-900 min-h-screen flex items-center justify-center p-4">
|
||||||
|
<div class="w-full max-w-sm">
|
||||||
|
<div class="dark:bg-slate-900 bg-white rounded-2xl shadow-xl p-8 border dark:border-slate-800 border-slate-200">
|
||||||
|
<div class="text-center mb-6">
|
||||||
|
<div class="text-4xl mb-2">🔎</div>
|
||||||
|
<h1 class="text-xl font-bold">Research Agent</h1>
|
||||||
|
<p class="text-sm text-slate-500 dark:text-slate-400 mt-1">Dein Web-Researcher, überall erreichbar</p>
|
||||||
|
</div>
|
||||||
|
<form id="loginForm" class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium mb-1" for="name">Dein Name</label>
|
||||||
|
<input id="name" type="text" required placeholder="z.B. Nico" autocomplete="name"
|
||||||
|
class="w-full px-3 py-2 rounded-lg border dark:bg-slate-800 dark:border-slate-700 border-slate-300 focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium mb-1" for="password">Passwort</label>
|
||||||
|
<input id="password" type="password" required autocomplete="current-password"
|
||||||
|
class="w-full px-3 py-2 rounded-lg border dark:bg-slate-800 dark:border-slate-700 border-slate-300 focus:outline-none focus:ring-2 focus:ring-blue-500">
|
||||||
|
</div>
|
||||||
|
<p id="error" class="hidden text-sm text-red-500"></p>
|
||||||
|
<button type="submit"
|
||||||
|
class="w-full py-2.5 rounded-lg bg-blue-600 hover:bg-blue-700 text-white font-semibold transition">
|
||||||
|
Anmelden
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<p class="text-xs text-center text-slate-400 mt-6">
|
||||||
|
Läuft auf deinem eigenen Server · DeepSeek v4 Flash
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script>
|
||||||
|
const form = document.getElementById('loginForm');
|
||||||
|
const error = document.getElementById('error');
|
||||||
|
const nameField = document.getElementById('name');
|
||||||
|
const passwordField = document.getElementById('password');
|
||||||
|
form.addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
error.classList.add('hidden');
|
||||||
|
const res = await fetch('/api/login', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ name: nameField.value, password: passwordField.value }),
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
location.href = '/';
|
||||||
|
} else {
|
||||||
|
const data = await res.json().catch(() => ({}));
|
||||||
|
error.textContent = data.error || 'Anmeldung fehlgeschlagen';
|
||||||
|
error.classList.remove('hidden');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if ('serviceWorker' in navigator) {
|
||||||
|
navigator.serviceWorker.register('/sw.js').catch(() => {});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -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" }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Geteilte Recherche</title>
|
||||||
|
<link rel="icon" href="/icons/icon-192.png">
|
||||||
|
<meta name="theme-color" content="#0f172a">
|
||||||
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
|
<script>
|
||||||
|
tailwind.config = { darkMode: 'class' };
|
||||||
|
document.documentElement.classList.add('dark');
|
||||||
|
</script>
|
||||||
|
</head>
|
||||||
|
<body class="dark:bg-slate-950 bg-slate-100 dark:text-slate-100 text-slate-900 min-h-screen">
|
||||||
|
<div class="max-w-3xl mx-auto p-4 md:p-8">
|
||||||
|
<header class="flex items-center justify-between mb-6">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="text-2xl">🔎</span>
|
||||||
|
<span class="font-bold">Research Agent</span>
|
||||||
|
<span class="text-xs px-2 py-0.5 rounded-full bg-slate-700 text-slate-300">geteilt</span>
|
||||||
|
</div>
|
||||||
|
<span id="dateLabel" class="text-sm text-slate-400"></span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div id="loading" class="text-center py-16 text-slate-400">Lade Recherche…</div>
|
||||||
|
|
||||||
|
<div id="content" class="hidden space-y-4"></div>
|
||||||
|
|
||||||
|
<footer class="mt-8 text-center text-xs text-slate-500">
|
||||||
|
<p>Generiert mit dem Research Agent · DeepSeek v4 Flash</p>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const content = document.getElementById('content');
|
||||||
|
const loading = document.getElementById('loading');
|
||||||
|
|
||||||
|
const escapeHtml = (s) => String(s ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||||
|
const mdLite = (text) => {
|
||||||
|
let s = escapeHtml(text);
|
||||||
|
s = s.replace(/`([^`]+)`/g, '<code class="px-1 py-0.5 rounded bg-slate-700 text-amber-200 text-xs">$1</code>');
|
||||||
|
s = s.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
|
||||||
|
s = s.replace(/\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer" class="text-blue-400 underline break-all">$1</a>');
|
||||||
|
s = s.replace(/https?:\/\/[^\s<]+/g, '<a href="$&" target="_blank" rel="noopener noreferrer" class="text-blue-400 underline break-all">$&</a>');
|
||||||
|
return s.replace(/\n/g, '<br>');
|
||||||
|
};
|
||||||
|
|
||||||
|
const toolLabel = (m) => {
|
||||||
|
const icons = { search: '🔍', browse: '🌐', verify: '✅', save_dashboard: '📊' };
|
||||||
|
const icon = icons[m.tool_name] || '🔧';
|
||||||
|
const text = m.tool_name === 'search' ? `Suche: ${m.content}` : m.content;
|
||||||
|
return { icon, text: (text || '').slice(0, 200) };
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderDashboardCard = (url) => `
|
||||||
|
<div class="dark:bg-slate-800 bg-white rounded-2xl border dark:border-slate-700 border-slate-200 overflow-hidden">
|
||||||
|
<div class="p-4 flex flex-wrap items-center gap-3">
|
||||||
|
<div class="flex-1 min-w-[200px]">
|
||||||
|
<div class="text-sm font-semibold">📊 Dashboard</div>
|
||||||
|
<div class="text-xs text-slate-400">Analyse, verifizierte Quellen, Empfehlungen & Handlungsplan</div>
|
||||||
|
</div>
|
||||||
|
<a href="${url}" target="_blank" rel="noopener noreferrer"
|
||||||
|
class="px-4 py-2 rounded-lg bg-emerald-600 hover:bg-emerald-700 text-white text-sm font-semibold">Dashboard öffnen ↗</a>
|
||||||
|
<a href="${url}" download
|
||||||
|
class="px-4 py-2 rounded-lg bg-slate-600 hover:bg-slate-500 text-white text-sm">Download</a>
|
||||||
|
</div>
|
||||||
|
<iframe src="${url}" class="w-full border-t dark:border-slate-700 border-slate-200" style="height: 65vh; min-height: 400px;" loading="lazy"></iframe>
|
||||||
|
</div>`;
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
const token = location.pathname.split('/').pop();
|
||||||
|
const res = await fetch(`/api/share/${token}`);
|
||||||
|
if (!res.ok) {
|
||||||
|
loading.textContent = '⚠️ Dieser Link ist ungültig oder wurde gelöscht.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { session, messages } = await res.json();
|
||||||
|
loading.classList.add('hidden');
|
||||||
|
content.classList.remove('hidden');
|
||||||
|
|
||||||
|
document.title = `${session.title} – Research Agent`;
|
||||||
|
document.getElementById('dateLabel').textContent = new Date(session.created_at).toLocaleDateString('de-DE');
|
||||||
|
|
||||||
|
content.innerHTML = `
|
||||||
|
<h1 class="text-2xl font-bold">${escapeHtml(session.title)}</h1>
|
||||||
|
${session.dashboard ? renderDashboardCard(session.dashboard) : ''}`;
|
||||||
|
|
||||||
|
const chat = document.createElement('div');
|
||||||
|
chat.className = 'space-y-3 mt-2';
|
||||||
|
for (const m of messages) {
|
||||||
|
if (m.role === 'user') {
|
||||||
|
chat.innerHTML += `<div class="flex justify-end"><div class="max-w-[80%] px-4 py-2.5 rounded-2xl bg-blue-600 text-white whitespace-pre-wrap break-words">${mdLite(m.content)}</div></div>`;
|
||||||
|
} else if (m.role === 'assistant') {
|
||||||
|
if (m.content)
|
||||||
|
chat.innerHTML += `<div class="flex"><div class="max-w-[85%] px-4 py-2.5 rounded-2xl bg-slate-200 dark:bg-slate-800 whitespace-pre-wrap break-words">${mdLite(m.content)}</div></div>`;
|
||||||
|
} else if (m.role === 'tool') {
|
||||||
|
const { icon, text } = toolLabel(m);
|
||||||
|
chat.innerHTML += `<div class="flex"><div class="text-xs text-slate-400 px-1 flex items-center gap-1"><span>${icon}</span><span class="break-all">${escapeHtml(text)}</span></div></div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
content.appendChild(chat);
|
||||||
|
}
|
||||||
|
|
||||||
|
load();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -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);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
const path = require('path');
|
||||||
|
const fs = require('fs');
|
||||||
|
const { chromium } = require('playwright');
|
||||||
|
|
||||||
|
const OUT = path.join(__dirname, '..', 'public', 'icons');
|
||||||
|
|
||||||
|
const SVG = (bg) => `<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
|
||||||
|
<rect width="512" height="512" rx="96" fill="${bg}"/>
|
||||||
|
<g transform="translate(96,96) scale(1.3)" fill="none" stroke="#fff" stroke-width="26" stroke-linecap="round">
|
||||||
|
<circle cx="100" cy="100" r="62"/>
|
||||||
|
<line x1="146" y1="146" x2="230" y2="230"/>
|
||||||
|
<circle cx="230" cy="230" r="14" fill="#fff" stroke="none"/>
|
||||||
|
</g>
|
||||||
|
<g fill="#fff" font-family="Arial" font-weight="bold" font-size="120" text-anchor="middle">
|
||||||
|
<text x="256" y="410">R</text>
|
||||||
|
</g>
|
||||||
|
</svg>`;
|
||||||
|
|
||||||
|
async function renderSvgToPng(svg, file, size) {
|
||||||
|
const browser = await chromium.launch();
|
||||||
|
const page = await browser.newPage({ viewport: { width: size, height: size } });
|
||||||
|
await page.setContent(svg);
|
||||||
|
await page.screenshot({ path: path.join(OUT, file), clip: { x: 0, y: 0, width: size, height: size } });
|
||||||
|
await browser.close();
|
||||||
|
console.log('Erzeugt:', file);
|
||||||
|
}
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
fs.mkdirSync(OUT, { recursive: true });
|
||||||
|
await renderSvgToPng(SVG('#2563eb'), 'icon-192.png', 192);
|
||||||
|
await renderSvgToPng(SVG('#2563eb'), 'icon-512.png', 512);
|
||||||
|
await renderSvgToPng(SVG('#1e3a8a'), 'icon-maskable-512.png', 512);
|
||||||
|
})();
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
Reference in new issue
Block a user