Compare commits

..
6 Commits
Author SHA1 Message Date
nico b740ce1316 DeepSeek V4.1 Flash: native Bilderkennung, GLM/OpenRouter entfernt
- deepseek-flash als einziges Modell mit Vision, Preise aktualisiert
- OpenRouter/GLM samt Code, Config und Env-Variablen entfernt
- Legacy-Modell-IDs in Sessions werden automatisch migriert
- browse lädt Produktbilder lokal nach dashboards/ (Dedupe + Cleanup)
- Vision-Prompt: Workflow für visuelle Produktsuche, max. ~5 Screenshots
2026-10-05 12:55:31 +02:00
nico 12f01b86d6 Checklisten-Häkchen in Dashboards speichern (localStorage)
Beim Ausliefern von /dashboards/*.html wird ein Skript injiziert, das den
Zustand der Checklisten-Checkboxen im localStorage speichert (Key =
Label-Text, pro Dashboard-URL) und beim Laden wiederherstellt, inkl.
update()-Aufruf für Zähler/Progress-Bar und 'Liste zurücksetzen'-Button.
Funktioniert in allen bestehenden und künftigen Dashboards; Marker
verhindert Doppel-Injektion, path.basename verhindert Traversal.
2026-08-28 12:28:58 +02:00
nico ab4f8e8570 Stale-Job-Reset, Ownership-Angleichung, robuste Dashboard-Erzeugung
- Server-Neustart setzt hängengebliebene Jobs (running/queued) zurück
  (markStaleJobs) mit Hinweis-Nachricht; /abort räumt auch verwaiste Sessions auf
- Löschen/Abbrechen/Modellwechsel ohne Owner-Beschränkung (konsistent zur
  offenen Sichtbarkeit im Ein-Nutzer-System)
- Dashboard-HTML wird nicht mehr per Tool-Aufruf übertragen, sondern in der
  finalen Antwort als ```html```-Codeblock gestreamt (GLM scheiterte
  reproduzierbar am riesigen Tool-Call); Extraktion mit klaren Fehlern bei
  fehlendem/unvollständigem Codeblock
- Reasoning-Effort pro Runde: volle Tiefe in Research-Runden, minimales Denken
  in der Dashboard-Liefer-Runde (DeepSeek: thinking aus, GLM: low) –
  Budget gehört dem HTML
- GLM-Reasoning-Default high (max verbrennt das Completion-Budget),
  DeepSeek max_tokens auf 300k
2026-08-28 09:17:13 +02:00
nico 8a2bf46b09 Recherche abbrechen + Chats löschen (mit Aufräumen) + Truncation-Fix
- Manueller Abbruch: POST /api/sessions/:id/abort (nur Owner), AbortController
  für laufende Jobs (auch mitten im API-Stream), cancelQueued für wartende,
  ⛔-Button in der Activity-Bar, 'abgebrochen'-Event + History-Eintrag
- Chats löschen: 🗑-Button in der Sidebar mit Bestätigung, Ownership-Check
  (vorher konnte jeder fremde Chats löschen), löscht jetzt auch Messages
  und Dashboard-Dateien (HTML/GPX/tour.json) mit
- Truncation-Fix: finish_reason 'length' wird erkannt (sichtbarer Fehler
  statt leerer Abschluss), max_tokens auf 131072 (GLM-Max), 10-Min-Timeout
  gegen hängende Streams
2026-08-28 07:17:06 +02:00
nico 0b51bb6142 Fix: Tools-Modul nicht mehr durch lokale Variable überschatten
In runResearchJob wurde die Variable 'tools' für die Tool-Definitionen
wiederverwendet und das importierte Tools-Modul überschattet – dadurch
schlugen alle Tool-Aufrufe (search, browse, save_dashboard, ...) mit
'Tool-Fehler' fehl. Umbenannt in toolDefs.
2026-08-27 19:44:52 +02:00
nico 87eb7029f1 Multi-Modell + Vision: OpenRouter (GLM 5.3 Flash), Bild-Upload, Tour-Feature, Dashboard-Fixes
- Modell-Registry (DeepSeek direkt + OpenRouter z-ai/glm-5.3-flash), Modellwahl pro Session, Reasoning-Effort max (env-konfigurierbar)
- Bild-Upload für Vision-Modelle (User) + Agent-screenshot()-Tool (nur Vision)
- Tour-Planung: geocode + save_tour mit GPX-Export und Karten-Sektion
- Fix: System-Prompt wird jetzt wirklich an die API gesendet, inkl. aktuellem Dashboard-Dateinamen für Folge-Updates
- Fix: History-Sanitizer gegen 400-Fehler (nie mitten in Tool-Gruppen kürzen)
- Fix: reasoning_content-Roundtrip (Pflicht bei DeepSeek mit tools)
- Fix: Dashboards ohne Browser-Cache ausliefern
2026-08-27 17:43:13 +02:00
12 changed files with 1336 additions and 105 deletions

No files matched your search

+11 -3
View File
@@ -10,13 +10,21 @@ APP_PASSWORD=ein-sicheres-passwort
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
# DeepSeek Modell (V4.1 Flash, inkl. Vision/Bilderkennung)
MODEL=deepseek-flash
# Thinking-Mode des Modells: enabled | disabled (gilt für DeepSeek)
THINKING=enabled
# Reasoning-Tiefe: low | high | max
DEEPSEEK_REASONING_EFFORT=max
# Maximale Antwort-Tokens: nur relevant für eigene Modelle aus der Env (MODEL).
# Das eingebaute Modell (DeepSeek V4.1 Flash) nutzt fest 300000 aus der Registry.
MAX_TOKENS=64000
# VAPID-Schlüsselpaar für Web Push (wird automatisch generiert)
VAPID_PUBLIC_KEY=
VAPID_PRIVATE_KEY=
# Login-Schutz: Fehlversuche pro IP bevor gesperrt wird, und Sperrdauer
LOGIN_MAX_ATTEMPTS=5
LOGIN_LOCK_MINUTES=5
# Tour-Feature: E-Mail für Nominatim (Fair-Use, optional) und OSRM-Server
NOMINATIM_EMAIL=
OSRM_URL=https://router.project-osrm.org
+7
View File
@@ -1,4 +1,11 @@
node_modules/
data/
dashboards/*.html
dashboards/*.gpx
dashboards/*.tour.json
dashboards/*.jpg
dashboards/*.jpeg
dashboards/*.png
dashboards/*.webp
dashboards/*.gif
.env
+4 -3
View File
@@ -1,12 +1,13 @@
# Research Agent – Web-Chat-Oberfläche
Browser-Chatoberfläche für den Research-Agent: DeepSeek v4 Flash recherchiert per Playwright im
Browser-Chatoberfläche für den Research-Agent: DeepSeek V4.1 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
- 📷 Bild-Upload & Vision: Fotos analysieren, ähnliche Produkte finden und mit lokal gespeicherten Bildern ins Dashboard einbetten
- 🔔 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
@@ -34,7 +35,7 @@ erzeugt und in die `.env` geschrieben.
| `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` |
| `MODEL` | nein | Standard `deepseek-flash` |
| `THINKING` | nein | `enabled` (Standard) oder `disabled` |
| `DEEPSEEK_BASE_URL` | nein | Für Tests/Proxies überschreibbar |
| `LOGIN_MAX_ATTEMPTS` | nein | Fehlversuche pro IP vor Sperre (Standard 5) |
@@ -191,5 +192,5 @@ researchChat/
- 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
- API-Kosten: DeepSeek V4.1 Flash kostet ~$0,30/M Input- (Cache-Miss) und ~$1,20/M Output-Token (Peak; Off-Peak halb so teuer) – 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
+50 -2
View File
@@ -52,14 +52,62 @@ 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',
model: process.env.MODEL || env.MODEL || 'deepseek-flash',
thinking: process.env.THINKING || env.THINKING || 'enabled',
maxTokens: Number(process.env.MAX_TOKENS || env.MAX_TOKENS || 64000),
baseUrl: process.env.DEEPSEEK_BASE_URL || env.DEEPSEEK_BASE_URL || 'https://api.deepseek.com',
loginMaxAttempts: Number(process.env.LOGIN_MAX_ATTEMPTS || env.LOGIN_MAX_ATTEMPTS || 5),
loginLockMinutes: Number(process.env.LOGIN_LOCK_MINUTES || env.LOGIN_LOCK_MINUTES || 5),
nominatimEmail: process.env.NOMINATIM_EMAIL || env.NOMINATIM_EMAIL || '',
osrmUrl: process.env.OSRM_URL || env.OSRM_URL || 'https://router.project-osrm.org',
sessionSecret: ensure('SESSION_SECRET', () => crypto.randomBytes(32).toString('hex')),
vapidPublicKey: vapid.publicKey,
vapidPrivateKey: vapid.privateKey,
};
module.exports = { config };
const envOr = (key, def) => process.env[key] || env[key] || def;
// ---------------------------------------------------------------------------
// Modell-Registry: DeepSeek V4.1 Flash (Text + Vision/Bilderkennung)
// ---------------------------------------------------------------------------
const MODELS = [
{
id: 'deepseek-flash',
label: 'DeepSeek V4.1 Flash',
provider: 'deepseek',
vision: true,
// Max. Output laut Doku: 384K
maxTokens: 300000,
reasoningEffort: () => envOr('DEEPSEEK_REASONING_EFFORT', 'max'),
// Liefer-Runde des Dashboards: Denken komplett aus, Budget gehört dem HTML
deliveryEffort: 'none',
// Peak-Preise laut Docs (USD pro 1M Tokens; Off-Peak ist halb so teuer)
prices: { inputCacheHit: 0.006, inputCacheMiss: 0.3, output: 1.2 },
},
];
/** Registry inkl. benutzerdefiniertem Modell aus der Env (MODEL), falls nicht bekannt. */
function getModels() {
if (MODELS.some((m) => m.id === config.model)) return MODELS;
return [
...MODELS,
{
id: config.model,
label: config.model,
provider: 'deepseek',
vision: false,
maxTokens: config.maxTokens,
reasoningEffort: () => envOr('DEEPSEEK_REASONING_EFFORT', 'max'),
prices: { inputCacheHit: 0.006, inputCacheMiss: 0.3, output: 1.2 },
},
];
}
/** Modell per ID auflösen (Fallback: Default-Modell aus Env). */
function getModel(id) {
const models = getModels();
return models.find((m) => m.id === id) || models.find((m) => m.id === config.model) || models[0];
}
module.exports = { config, MODELS, getModels, getModel };
+129 -21
View File
@@ -49,14 +49,14 @@ 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 };
// DeepSeek V4.1 Flash Preise (USD pro 1M Tokens, Peak) – obere Schätzung.
const PRICES = { inputCacheHit: 0.006, inputCacheMiss: 0.3, output: 1.2 };
function computeCost(u) {
function computeCost(u, prices = PRICES) {
return (
((u.cacheHitTokens || 0) * PRICES.inputCacheHit +
(u.cacheMissTokens || 0) * PRICES.inputCacheMiss +
(u.completionTokens || 0) * PRICES.output) /
((u.cacheHitTokens || 0) * prices.inputCacheHit +
(u.cacheMissTokens || 0) * prices.inputCacheMiss +
(u.completionTokens || 0) * prices.output) /
1e6
);
}
@@ -72,12 +72,29 @@ function computeCost(u) {
add('cache_hit_tokens', 'INTEGER DEFAULT 0');
add('cache_miss_tokens', 'INTEGER DEFAULT 0');
add('cost_estimate', 'REAL DEFAULT 0');
add('model', 'TEXT');
}
// Migration: Messages-Spalten ergänzen (Reasoning-Roundtrip + Bild-Upload)
{
const cols = db.prepare(`PRAGMA table_info(messages)`).all().map((c) => c.name);
const add = (name, def) => {
if (!cols.includes(name)) db.exec(`ALTER TABLE messages ADD COLUMN ${name} ${def}`);
};
add('reasoning', 'TEXT');
add('image', 'TEXT');
}
// Migration: Legacy-/OpenRouter-Modell-IDs auf den aktuellen Namen umstellen
db.prepare(
`UPDATE sessions SET model = 'deepseek-flash'
WHERE model IN ('deepseek-v4-flash', 'deepseek-v4-flash-vision-exp', 'z-ai/glm-5.3-flash')`
).run();
const stmts = {
createSession: db.prepare(
`INSERT INTO sessions (id, title, owner_name, share_token, status, created_at, updated_at)
VALUES (?, ?, ?, ?, 'idle', ?, ?)`
`INSERT INTO sessions (id, title, owner_name, share_token, status, model, created_at, updated_at)
VALUES (?, ?, ?, ?, 'idle', ?, ?, ?)`
),
getSession: db.prepare(`SELECT * FROM sessions WHERE id = ?`),
getSessionByShare: db.prepare(`SELECT * FROM sessions WHERE share_token = ?`),
@@ -90,6 +107,7 @@ const stmts = {
),
setSessionTitle: db.prepare(`UPDATE sessions SET title = ?, updated_at = ? WHERE id = ?`),
setSessionDashboard: db.prepare(`UPDATE sessions SET dashboard = ?, updated_at = ? WHERE id = ?`),
setSessionModel: db.prepare(`UPDATE sessions SET model = ?, 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 + ?,
@@ -98,10 +116,11 @@ const stmts = {
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 (?, ?, ?, ?, ?, ?, ?)`
`INSERT INTO messages (session_id, role, content, tool_name, tool_call_id, tool_calls, reasoning, image, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
),
listMessages: db.prepare(`SELECT * FROM messages WHERE session_id = ? ORDER BY id ASC`),
listStaleSessions: db.prepare(`SELECT id FROM sessions WHERE status IN ('running', 'queued')`),
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 = ?`),
@@ -113,13 +132,13 @@ const stmts = {
listPushSubs: db.prepare(`SELECT * FROM push_subscriptions`),
};
function createSession(ownerName, title = 'Neue Recherche') {
function createSession(ownerName, title = 'Neue Recherche', model = null) {
const id = uid();
stmts.createSession.run(id, title, ownerName, shareToken(), now(), now());
stmts.createSession.run(id, title, ownerName, shareToken(), model, now(), now());
return stmts.getSession.get(id);
}
function createMessage(sessionId, { role, content, toolName, toolCallId, toolCalls }) {
function createMessage(sessionId, { role, content, toolName, toolCallId, toolCalls, reasoning, image }) {
stmts.insertMessage.run(
sessionId,
role,
@@ -127,6 +146,8 @@ function createMessage(sessionId, { role, content, toolName, toolCallId, toolCal
toolName ?? null,
toolCallId ?? null,
toolCalls ? JSON.stringify(toolCalls) : null,
reasoning ?? null,
image ?? null,
now()
);
return stmts.lastMessage.get(sessionId);
@@ -139,23 +160,83 @@ function getSessionMessages(sessionId) {
}));
}
/** Baut aus dem Chat-Verlauf das Messages-Array für die DeepSeek-API. */
function toApiMessages(sessionId) {
const API_HISTORY_MAX = 60;
/**
* Bereinigt den Verlauf für die API: entfernt unvollständige Tool-Gruppen
* (assistant-Tool-Call ohne passende tool-Antwort, z.B. nach einem abgebrochenen
* Job) und kürzt auf die letzten API_HISTORY_MAX Nachrichten, ohne jemals mitten
* in einer Tool-Gruppe zu schneiden. Verwaiste tool-Nachrichten lehnt die
* DeepSeek-API sonst mit 400 ab ("Messages with role 'tool' must be a response
* to a preceding message with 'tool_calls'").
*/
function sanitizeHistory(out, max = API_HISTORY_MAX) {
const resolved = new Set();
const withCalls = [];
const owner = new Array(out.length).fill(-1);
let cur = -1;
for (let i = 0; i < out.length; i++) {
const m = out[i];
if (m.role === 'assistant' && m.tool_calls) {
withCalls.push(i);
cur = i;
} else if (m.role === 'tool') {
owner[i] = cur;
if (m.tool_call_id) resolved.add(m.tool_call_id);
}
}
const incomplete = new Set(
withCalls.filter((i) => (out[i].tool_calls || []).some((tc) => !resolved.has(tc.id)))
);
const kept = [];
for (let i = 0; i < out.length; i++) {
if (incomplete.has(i)) continue;
if (out[i].role === 'tool' && incomplete.has(owner[i])) continue;
kept.push(out[i]);
}
if (kept.length <= max) return kept;
let start = kept.length - max;
while (start < kept.length && kept[start].role === 'tool') start++;
return kept.slice(start);
}
/**
* Baut aus dem Chat-Verlauf das Messages-Array für die Modell-API.
* @param {object} opts { provider, vision } – steuert Bild-Content (Vision-Modelle)
* und den reasoning_content-Roundtrip (Pflicht bei DeepSeek mit tools).
*/
function toApiMessages(sessionId, opts = {}) {
const { provider = 'deepseek', vision = false } = opts;
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') {
if (m.role === 'user') {
const msg = { role: 'user' };
if (m.image && vision) {
msg.content = [
{ type: 'text', text: m.content || '' },
{ type: 'image_url', image_url: { url: m.image } },
];
} else {
msg.content = m.content;
}
out.push(msg);
} 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 = '';
// DeepSeek verlangt bei tools-Requests den Roundtrip des Reasoning-Texts
if (provider === 'deepseek' && m.reasoning) msg.reasoning_content = m.reasoning;
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);
return sanitizeHistory(out);
}
module.exports = {
@@ -168,17 +249,44 @@ module.exports = {
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) =>
setSessionModel: (id, model) => stmts.setSessionModel.run(model, now(), id),
setSessionUsage: (id, u, prices) =>
stmts.addUsage.run(
u.promptTokens || 0,
u.completionTokens || 0,
u.cacheHitTokens || 0,
u.cacheMissTokens || 0,
computeCost(u),
computeCost(u, prices),
id
),
computeCost,
deleteSession: (id) => stmts.deleteSession.run(id),
deleteSession: (id) => {
stmts.deleteMessages.run(id);
stmts.deleteSession.run(id);
},
/**
* Setzt nach einem Server-Neustart alle Sessions zurück, die im alten
* Prozess noch als 'running'/'queued' festhingen (Jobs existieren im
* Speicher nicht mehr). Gibt die Anzahl zurück.
*/
markStaleJobs() {
const stale = stmts.listStaleSessions.all();
for (const s of stale) {
stmts.setSessionStatus.run('done', now(), s.id);
stmts.insertMessage.run(
s.id,
'assistant',
'⛔ Recherche wurde durch einen Server-Neustart abgebrochen.',
null,
null,
null,
null,
null,
now()
);
}
return stale.length;
},
getSessionMessages,
toApiMessages,
upsertPushSub(userName, endpoint, keys) {
+28 -1
View File
@@ -6,6 +6,33 @@ events.setMaxListeners(100);
let queue = [];
let running = false;
// Abort-Controller für laufende Jobs (für manuellen Abbruch)
const controllers = new Map();
/** Abort-Controller für eine Session registrieren (einmal pro Job). */
function registerController(sessionId) {
const controller = new AbortController();
controllers.set(sessionId, controller);
return controller;
}
/** Laufenden Job einer Session abbrechen. */
function abortRunning(sessionId) {
const controller = controllers.get(sessionId);
if (controller) controller.abort();
controllers.delete(sessionId);
}
/** Läuft für diese Session gerade ein Job im Speicher? */
function hasController(sessionId) {
return controllers.has(sessionId);
}
/** Controller nach Job-Ende entfernen. */
function clearController(sessionId) {
controllers.delete(sessionId);
}
/** Einen Job anmelden. Läuft sofort, wenn nichts anderes läuft, sonst wartet er. */
function enqueue(job) {
queue.push(job);
@@ -45,4 +72,4 @@ function state() {
return { running, queued: queue.map((j) => j.sessionId) };
}
module.exports = { events, enqueue, queuedBefore, isRunning, cancelQueued, state };
module.exports = { events, enqueue, queuedBefore, isRunning, cancelQueued, state, registerController, abortRunning, hasController, clearController };
+391 -44
View File
@@ -1,4 +1,5 @@
const tools = require('./tools');
const { getModel } = require('./config');
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.
@@ -16,7 +17,30 @@ const SYSTEM_PROMPT = `Du bist ein professioneller Web-Researcher und Wissensber
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.
7. GENERIERE das Dashboard: Rufe das save_dashboard-Tool mit Titel und Dateinamen auf – OHNE HTML (das Tool bestätigt nur und setzt den Dateinamen). Danach ist deine FINALE Antwort das KOMPLETTE Dashboard-HTML in einem Markdown-Codeblock \`\`\`html ... \`\`\` (Detailstruktur unten). Ein kurzer Einleitungssatz vor dem Codeblock ist erlaubt, danach endet deine Antwort. Kein weiterer Tool-Aufruf nötig.
## Tour-Planung (PFLICHT bei Tour-Anfragen!)
Erkennst du den Wunsch nach einer Route/Tour/Städtebesichtigung (z.B. "plane eine Tour", "Route durch", "Städtebummel", "was sollte ich in X sehen"), gilt:
- **Du MUSST die Tools geocode und save_tour verwenden.** Erfinde NIEMALS Koordinaten, Distanzen oder Gehzeiten selbst!
- Ablauf: (1) Orte wie gewohnt recherchieren (verifizierte Quellen), (2) sinnvolle Besuchsreihenfolge festlegen, (3) JEDEN Stopp mit geocode geokodieren, (4) save_tour mit allen Stopps aufrufen (Name, lat, lon, Beschreibung), (5) Distanz/Gehzeit AUS DEM TOOL-ERGEBNIS übernehmen, (6) Karten-Sektion ins Dashboard bauen, (7) in der finalen Antwort GPX-Download und Google-Maps-Link nennen.
### "🗺️ Tour-Karte"-Sektion im Dashboard (nur wenn save_tour gelaufen ist)
- Leaflet via CDN einbinden: <link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"> und <script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script> (zusätzlich zu Tailwind CDN)
- Karten-Div mit fester Höhe (z.B. style="height: 450px")
- Tiles: KEIN API-KEY NÖTIG! Nutze AUSSCHLIESSLICH exakt diese CARTO-Konfiguration (kostenlos, ohne Key, kein Referer-Zwang):
L.tileLayer('https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png', {
subdomains: 'abcd',
maxZoom: 20,
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> &copy; <a href="https://carto.com/attributions">CARTO</a>'
}).addTo(map);
- VERBOTEN sind Anbieter mit API-Key (Mapbox, MapTiler, Thunderforest, Google Maps, Here) und tile.openstreetmap.org – CARTO ist die einzige erlaubte Kartenquelle. Falls du irgendwo einen "API key" sehen solltest, ist es falsch!
- Die Stopps (mit lat/lon) INLINE als JavaScript-Objekt ins HTML schreiben
- Die Routen-Geometrie per fetch() aus der tour.json laden (URL hast du vom save_tour-Ergebnis). Fallback, wenn fetch fehlschlägt: Polylinie direkt durch die Stopps zeichnen
- Nummerierte Marker (CircleMarker mit Zahl), Polylinie (blau), map.fitBounds(...) über alle Punkte
- Buttons über der Karte: "📍 Route in Google Maps öffnen" (googleMapsUrl aus dem save_tour-Ergebnis, target=_blank) und "🗺️ GPX für OsmAnd herunterladen" (gpxUrl, mit download-Attribut)
- Unter der Karte die Stopp-Liste: Nummer, Name, Beschreibung, "Hier hin navigieren"-Link (navUrl pro Stopp)
- Header-Zeile mit Distanz (km) und Gehzeit (min), wenn vorhanden
- Hinweis: "GPX in OsmAnd importieren: Datei herunterladen → Menü → Meine Orte → Importieren"
## 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)
@@ -28,10 +52,11 @@ const SYSTEM_PROMPT = `Du bist ein professioneller Web-Researcher und Wissensber
## Regeln
- JEDER Link im Dashboard MUSS mit verify() verifiziert sein - füge bei den Karten einen "✓ Verifiziert"-Badge hinzu
- Erfinde NIEMALS Koordinaten, Distanzen oder Gehzeiten – bei Touren kommen die ausschließlich aus geocode() und save_tour()
- 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.
- Deine finale Antwort nach dem save_dashboard-Tool-Aufruf ist das komplette Dashboard-HTML in einem \`\`\`html\`\`\`-Codeblock (eine Text-Zusammenfassung ist dann NICHT nötig – Details gehören ins Dashboard).
- Bei Folge-Nachrichten in einer bestehenden Session: Recherchiere die neue Anforderung und überschreibe das bestehende Dashboard, indem du save_dashboard mit DENSELBEN Dateinamen wie zuvor aufrufst (OHNE HTML) und das komplette aktualisierte HTML in deiner finalen Antwort im \`\`\`html\`\`\`-Codeblock sendest.
- Du darfst KEINE anderen URLs erfinden. Nutze ausschließlich Ergebnisse aus search/browse/verify.`;
const TOOL_DEFINITIONS = [
@@ -55,7 +80,7 @@ const TOOL_DEFINITIONS = [
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=...).',
'Besucht eine URL, liest den sichtbaren Text und lädt Produktbilder der Seite lokal herunter. Das Feld `images` enthält die fertigen Bild-URLs (z.B. /dashboards/...) fürs Dashboard. 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: {
@@ -85,20 +110,86 @@ const TOOL_DEFINITIONS = [
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.',
'Bereitet das Speichern des HTML-Dashboards vor. Übergib NUR Titel und Dateinamen (OHNE HTML). Danach sendest du das KOMPLETTE HTML in deiner finalen Antwort in einem ```html```-Codeblock. Bei Folge-Nachrichten denselben Dateinamen wie zuvor verwenden, 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' },
html: { type: 'string', description: 'Optional: komplettes HTML direkt übergeben – nur für kleine Dashboards; bevorzugt ist der Weg über die finale Antwort (```html```-Codeblock).' },
},
required: ['title', 'filename', 'html'],
required: ['title', 'filename'],
},
},
},
{
type: 'function',
function: {
name: 'geocode',
description:
'Wandelt einen Ortsnamen in Koordinaten um (Nominatim/OpenStreetMap, kostenlos). Nutze das für jeden Stopp einer Tour, z.B. "Brandenburger Tor Berlin". Liefert lat/lon. Maximal 1 Request pro Sekunde.',
parameters: {
type: 'object',
properties: {
query: { type: 'string', description: 'Ortsangabe, z.B. "Brandenburger Tor Berlin" oder bereits Koordinaten "52.5163, 13.3777"' },
},
required: ['query'],
},
},
},
{
type: 'function',
function: {
name: 'save_tour',
description:
'Erstellt aus einer Liste von Stopps (Name + Koordinaten + Beschreibung) eine komplette Tour: GPX-Datei für OsmAnd, tour.json für die Karte im Dashboard, Google-Maps-Routen-URL mit allen Stopps und Fuß-Navigation pro Stopp. Die Route wird über OSRM als echte Geh-Route berechnet (Fallback: Luftlinie). IMMER vorher jeden Stopp mit geocode() geokodieren!',
parameters: {
type: 'object',
properties: {
title: { type: 'string', description: 'Titel der Tour, z.B. "Tagestour Prag"' },
filename: { type: 'string', description: 'Dateiname ohne Endung, z.B. "prag-tagestour"' },
stops: {
type: 'array',
description: 'Stopps in Besuchsreihenfolge (Start bis Ende)',
items: {
type: 'object',
properties: {
name: { type: 'string', description: 'Name des Ortes' },
lat: { type: 'number', description: 'Breitengrad' },
lon: { type: 'number', description: 'Längengrad' },
description: { type: 'string', description: 'Kurze Beschreibung (2-3 Sätze)' },
},
required: ['name', 'lat', 'lon'],
},
},
},
required: ['title', 'filename', 'stops'],
},
},
},
];
/** Nur für Vision-Modelle: Seite fotografieren und visuell beurteilen. */
const SCREENSHOT_TOOL = {
type: 'function',
function: {
name: 'screenshot',
description:
'Macht einen Screenshot einer Seite (JPEG) und liefert das Bild in den Kontext. Nutze das bei bildlastigen Seiten, wenn der Text nicht ausreicht (z.B. Anzeigen-Listen, Produktfotos, Infografiken) oder um Produkte visuell zu vergleichen (Farbe, Form). Maximal ~5 Screenshots pro Session.',
parameters: {
type: 'object',
properties: {
url: { type: 'string', description: 'Die exakte URL der Seite' },
full_page: {
type: 'boolean',
description: 'Volle Seitenlänge erfassen (Standard: nur sichtbarer Bereich). Bei langen Seiten zu groß – dort besser weglassen.',
},
},
required: ['url'],
},
},
};
/** Menschlich lesbare Zusammenfassung eines Tool-Ergebnisses für den Chat-Verlauf. */
function summarizeTool(toolName, output) {
let data;
@@ -113,11 +204,25 @@ function summarizeTool(toolName, output) {
(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)}`;
return `Seite: ${data.title || data.url || ''} – ${String(data.text || '').slice(0, 500)}${
data.images?.length ? ` [${data.images.length} Bilder extrahiert]` : ''
}`;
case 'verify':
return `Link ${data.ok ? 'VERIFIZIERT ✓' : 'FEHLGESCHLAGEN ✗'}: ${data.url}${data.reason ? ' – ' + data.reason : ''}`;
case 'save_dashboard':
return `Dashboard gespeichert: ${data.url || ''}`;
return data.note ? `Dashboard vorbereitet – HTML folgt in der finalen Antwort` : `Dashboard gespeichert: ${data.url || ''}`;
case 'geocode':
return data.error
? `Geocoding FEHLGESCHLAGEN: ${data.error}`
: `Geocodiert: ${data.name} -> ${data.lat}, ${data.lon}`;
case 'save_tour':
return data.error
? `Tour-Fehler: ${data.error}`
: `Tour gespeichert: ${data.title || ''} (${data.stops?.length || 0} Stopps, ${data.distanceKm} km, ${data.durationMin} min). GPX: ${data.gpxUrl || ''}`;
case 'screenshot':
return data.error
? `Screenshot FEHLGESCHLAGEN: ${data.error}`
: `Screenshot erstellt: ${data.title || data.url || ''} (Bild liegt im Kontext des Modells, nicht in der History)`;
default:
return String(output).slice(0, 1000);
}
@@ -125,32 +230,109 @@ function summarizeTool(toolName, output) {
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';
// Abbruch, wenn der API-Stream länger hängt (z.B. Upstream ohne Keep-Alive)
const REQUEST_TIMEOUT_MS = 10 * 60 * 1000;
// Leere Modell-Antworten (kein Inhalt, keine Tool-Calls) einmal wiederholen
const MAX_EMPTY_RETRIES = 1;
/** Wird geworfen, wenn der Nutzer die Recherche manuell abbricht. */
class JobAbortedError extends Error {}
/** Baut den System-Prompt inkl. aktuellem Dashboard-Stand und Vision-Hinweisen. */
function buildSystemPrompt(session, model) {
let prompt = SYSTEM_PROMPT;
if (session.dashboard) {
const file = session.dashboard.split('/').pop().replace(/\.html$/, '');
prompt += `
## Aktueller Stand dieser Session
Für diese Session existiert bereits ein Dashboard: ${session.dashboard}
Wenn der Nutzer Änderungen, Ergänzungen oder Erweiterungen wünscht, MUSST du das save_dashboard-Tool mit EXAKT diesem Dateinamen aufrufen: "${file}" (ohne .html) – OHNE HTML im Aufruf. Das KOMPLETTE aktualisierte HTML sendest du danach in deiner finalen Antwort in einem \`\`\`html\`\`\`-Codeblock.
Eine bloße Erklärung oder Beschreibung der Änderungen im Chat ist KEINE Umsetzung – erst wenn der Codeblock gespeichert wurde, ist die Aufgabe erfüllt.`;
}
if (model.vision) {
prompt += `
## Bilderkennung & visuelle Produktsuche (Vision)
- Hat der Nutzer ein Bild hochgeladen (z.B. einen Schuh): Analysiere es SORGFÄLTIG – Gattung/Modelltyp (z.B. Sneaker, Loafer, Stiefelette), Farben (Haupt-, Neben- und Sohlenfarbe), Material (Leder, Mesh, Wildleder), Form/Details (Sohle, Schnürung, Logo, Muster), Marke und Zustand.
- Leite daraus 4-8 Suchbegriffe ab, Deutsch UND Englisch (z.B. "weißer minimalistischer leder-sneaker" / "white minimalist leather sneaker"), und suche gezielt nach ähnlichen Produkten und Angeboten. Priorität: Farbe, Form und Modelltyp.
- Das browse-Tool lädt Produktbilder der Seite automatisch lokal herunter und liefert im Feld \`images\` fertige Dashboard-URLs (z.B. /dashboards/session-img-abc123.jpg). Bette sie direkt ein: <img src="..." alt="..." loading="lazy">. Nur diese URLs verwenden – niemals erfinden.
- Baue eine Sektion "Visuelle Treffer" ins Dashboard: pro Treffer Produktbild, Name, ggf. Preis, verifizierter Link und eine kurze Begründung, warum er passt (Farbe/Form/Material).
- Nutze das screenshot-Tool, um bildlastige Seiten (Anzeigen-Listen, Produktseiten) visuell zu vergleichen, wenn der Text nicht ausreicht – achte auf Farbton, Silhouette und Sohlenform. Maximal ~5 Screenshots pro Session; priorisiere die aussichtsreichsten Treffer.
- Das hochgeladene Bild liegt dir als Data-URL im Verlauf vor – du kannst es als Referenz oben im Dashboard einbetten und beschreiben, worauf du beim Vergleich geachtet hast.`;
}
return prompt;
}
const resp = await fetch(`${config.baseUrl || 'https://api.deepseek.com'}/chat/completions`, {
method: 'POST',
headers: {
/** Erkennt Änderungswünsche am Dashboard in der Nutzernachricht. */
function isChangeRequest(text) {
return /dashboard|anpassen|anpass|ändern|änder|ergänz|erweiter|aktualisier|update|umbau|hinzufüg|hinzufueg|modifizier|vervollständig|vervollstaendig/i.test(
text
);
}
/** HTML aus der finalen Antwort extrahieren (```html```-Codeblock oder direktes HTML). */
function extractHtmlBlock(content) {
const s = String(content || '');
const m = s.match(/```html\s*([\s\S]*?)```/i);
if (m && m[1].trim()) return m[1].trim();
const t = s.trim();
if (/^<!DOCTYPE html/i.test(t) || /^<html[\s>]/i.test(t)) return t;
return null;
}
/** Codeblöcke aus der finalen Antwort entfernen (für die Chat-Anzeige). */
function stripHtmlBlock(content) {
return String(content || '').replace(/```html\s*[\s\S]*?```/gi, '');
}
async function chatCompletion(config, messages, systemPrompt, model, tools, signal, effortOverride) {
const body = {
model: model.id,
messages: [{ role: 'system', content: systemPrompt }, ...messages],
tools,
stream: true,
max_tokens: model.maxTokens || config.maxTokens || 64000,
stream_options: { include_usage: true },
};
const baseUrl = config.baseUrl || 'https://api.deepseek.com';
if (effortOverride === 'none' || config.thinking === 'disabled') {
// DeepSeek: Denken pro Request abschaltbar (Liefer-Runde des Dashboards)
body.thinking = { type: 'disabled' };
} else {
body.thinking = { type: 'enabled' };
body.reasoning_effort = effortOverride || model.reasoningEffort?.() || 'max';
}
const headers = {
'Content-Type': 'application/json',
Authorization: `Bearer ${config.apiKey}`,
},
};
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS);
const fetchSignal = signal ? AbortSignal.any([signal, controller.signal]) : controller.signal;
let resp;
try {
resp = await fetch(`${baseUrl}/chat/completions`, {
method: 'POST',
headers,
body: JSON.stringify(body),
signal: fetchSignal,
});
} catch (e) {
clearTimeout(timer);
if (signal?.aborted) throw new JobAbortedError();
throw new Error(`Modell-API nicht erreichbar (${model.id}): ${e.message.slice(0, 200)}`);
}
clearTimeout(timer);
if (!resp.ok) {
const err = await resp.text().catch(() => '');
throw new Error(`DeepSeek API Fehler ${resp.status}: ${err.slice(0, 500)}`);
throw new Error(`Modell-API Fehler ${resp.status} (${model.id}): ${err.slice(0, 500)}`);
}
const reader = resp.body.getReader();
@@ -173,6 +355,7 @@ async function chatCompletion(config, messages) {
const choice = json.choices?.[0];
if (!choice) return;
const d = choice.delta || {};
// DeepSeek liefert den Denk-Text als reasoning_content
if (d.reasoning_content) delta.reasoning += d.reasoning_content;
if (d.content) delta.content += d.content;
if (d.tool_calls) {
@@ -191,6 +374,7 @@ async function chatCompletion(config, messages) {
}
}
if (choice.finish_reason) delta.finishReason = choice.finish_reason;
else if (d.finish_reason) delta.finishReason = d.finish_reason;
};
for (;;) {
@@ -205,6 +389,36 @@ async function chatCompletion(config, messages) {
}
if (buffer.trim()) feed(buffer);
// Stream ohne Abschlussstatus: unvollständig beendet (Upstream-Fehler)
if (!delta.finishReason && !delta.content && !delta.toolCalls) {
throw new Error(`Modell ${model.id}: Stream unvollständig beendet (keine Antwort erhalten). Bitte erneut versuchen.`);
}
// Antwort abgeschnitten (max_tokens erreicht): Wenn weder Inhalt noch
// vollständige Tool-Calls vorhanden sind, endet die Recherche sonst
// stillschweigend ohne Ergebnis. Dann lieber sichtbar abbrechen.
if (delta.finishReason === 'length') {
const hasCalls = Array.isArray(delta.toolCalls) && delta.toolCalls.length > 0;
const callsComplete = !hasCalls || delta.toolCalls.every((tc) => {
try {
JSON.parse(tc.function.arguments || '{}');
return true;
} catch {
return false;
}
});
if (!hasCalls && !delta.content) {
throw new Error(
`Modell-Antwort abgeschnitten (Token-Limit bei ${model.id} erreicht) – Ergebnis unvollständig. Bitte erneut versuchen oder ein anderes Modell wählen.`
);
}
if (hasCalls && !callsComplete) {
throw new Error(
`Tool-Aufruf abgeschnitten (Token-Limit bei ${model.id} erreicht) – Recherche wird abgebrochen. Bitte erneut versuchen.`
);
}
}
return {
content: delta.content,
reasoning: delta.reasoning,
@@ -222,9 +436,18 @@ async function chatCompletion(config, messages) {
* @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) {
async function runResearchJob(config, session, userMessageText, emit, db, signal) {
let dashboardUrl = session.dashboard || null;
let dashboardFile = dashboardUrl ? dashboardUrl.split('/').pop().replace('.html', '') : null;
let dashboardSaved = false;
let pendingDashboard = null;
// Sobald das Dashboard vorbereitet ist, läuft die nächste Runde mit minimalem
// Denken – das Budget gehört dann dem kompletten HTML (siehe deliveryEffort).
let deliveryEffort = null;
const model = getModel(session.model || config.model);
const toolDefs = model.vision ? [...TOOL_DEFINITIONS, SCREENSHOT_TOOL] : TOOL_DEFINITIONS;
const prices = model.prices;
const isAborted = () => signal?.aborted === true;
const handlers = {
async search({ query }) {
@@ -235,29 +458,58 @@ async function runResearchJob(config, session, userMessageText, emit, db) {
},
async browse({ url }) {
emit.progress('🌐', `Besuche: ${url}`);
const result = await tools.browse(url, (m) => emit.step(m));
const result = await tools.browse(url, (m) => emit.step(m), { sessionId: session.id });
return JSON.stringify(result);
},
async screenshot({ url, full_page }) {
emit.progress('📷', `Screenshot: ${url}`);
return tools.screenshot(url, !!full_page, (m) => emit.step(m));
},
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 }) {
if (html && String(html).trim()) {
// Alter Weg: HTML direkt im Tool-Aufruf (nur kleine Dashboards)
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', '');
dashboardSaved = true;
db.setSessionDashboard(session.id, saved.url);
emit.progress('📊', `Dashboard gespeichert: ${saved.file}`);
return JSON.stringify(saved);
}
// Neuer Weg: nur vorbereiten, HTML kommt in der finalen Antwort
pendingDashboard = { title: title || 'Dashboard', filename: filename || null };
deliveryEffort = model.deliveryEffort || 'none';
emit.progress(
'📊',
`Dashboard vorbereitet (${(filename || title || 'dashboard').toLowerCase().replace(/[^a-z0-9äöü-]+/g, '-')}.html) – sende jetzt das komplette HTML in deiner finalen Antwort als \`\`\`html\`\`\`-Codeblock…`
);
return JSON.stringify({
ok: true,
note: 'Dashboard vorbereitet. Sende jetzt das komplette HTML in deiner finalen Antwort in einem ```html```-Codeblock. Kein weiterer Tool-Aufruf nötig.',
});
},
async geocode({ query }) {
emit.progress('📍', `Geocode: ${query}`);
const result = await tools.geocode(query, config, (m) => emit.step(m));
return JSON.stringify(result);
},
async save_tour({ title, filename, stops }) {
emit.progress('🗺️', `Tour wird erstellt: ${title || ''}`);
const result = await tools.saveTour({ stops, title, filename, sessionId: session.id }, config, (m) => emit.step(m));
return JSON.stringify(result);
},
};
const apiMessages = db.toApiMessages(session.id);
const apiMessages = db.toApiMessages(session.id, { provider: model.provider, vision: model.vision });
const text = userMessageText;
const usage = { promptTokens: 0, completionTokens: 0, cacheHitTokens: 0, cacheMissTokens: 0 };
emit.progress('🤖', 'Research startet…');
let emptyRetries = 0;
emit.progress('🤖', `Research startet mit ${model.label}…`);
const trackUsage = (u) => {
if (!u) return;
@@ -271,17 +523,33 @@ async function runResearchJob(config, session, userMessageText, emit, db) {
};
const finishJob = (finalText, dashboardUrl) => {
db.setSessionUsage(session.id, usage);
db.setSessionUsage(session.id, usage, prices);
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 };
const costEstimate = db.computeCost(usage, prices);
emit.done({ text: finalText, dashboardUrl, usage, costEstimate, model: model.id });
return { text: finalText, dashboardUrl, usage, costEstimate, model: model.id };
};
const finishAborted = () => {
db.createMessage(session.id, { role: 'assistant', content: '⛔ Recherche abgebrochen.' });
db.setSessionUsage(session.id, usage, prices);
db.setSessionStatus(session.id, 'done');
const costEstimate = db.computeCost(usage, prices);
emit.aborted?.();
return { text: '⛔ Recherche abgebrochen.', dashboardUrl, usage, costEstimate, model: model.id, aborted: true };
};
for (let iter = 0; iter < MAX_ITERATIONS; iter++) {
if (isAborted()) return finishAborted();
emit.progress('🤖', iter === 0 ? 'Analysiere Anfrage und plane Suche…' : 'Weitere Analyse…');
const result = await chatCompletion(config, apiMessages);
let result;
try {
result = await chatCompletion(config, apiMessages, buildSystemPrompt(session, model), model, toolDefs, signal, deliveryEffort);
} catch (e) {
if (isAborted() || e instanceof JobAbortedError) return finishAborted();
throw e;
}
trackUsage(result.usage);
if (result.reasoning) emit.thinking(result.reasoning);
@@ -296,7 +564,14 @@ async function runResearchJob(config, session, userMessageText, emit, db) {
function: { name: tc.function.name, arguments: tc.function.arguments },
})),
};
db.createMessage(session.id, { role: 'assistant', content: assistantMsg.content, toolCalls: assistantMsg.tool_calls });
// DeepSeek: Reasoning-Text muss bei tools-Requests mit zurückgegeben werden
if (model.provider === 'deepseek' && result.reasoning) assistantMsg.reasoning_content = result.reasoning;
db.createMessage(session.id, {
role: 'assistant',
content: assistantMsg.content,
toolCalls: assistantMsg.tool_calls,
reasoning: result.reasoning || null,
});
apiMessages.push(assistantMsg);
for (const tc of result.toolCalls) {
@@ -312,19 +587,91 @@ async function runResearchJob(config, session, userMessageText, emit, db) {
output = JSON.stringify({ error: `Tool-Fehler: ${e.message.slice(0, 300)}` });
}
}
const outStr = typeof output === 'string' ? output : JSON.stringify(output);
db.createMessage(session.id, {
role: 'tool',
toolName: tc.function.name,
toolCallId: tc.id,
content: summarizeTool(tc.function.name, output).slice(0, 8000),
content: summarizeTool(tc.function.name, outStr).slice(0, 8000),
});
apiMessages.push({ role: 'tool', tool_call_id: tc.id, content: output.slice(0, 8000) });
if (output && typeof output === 'object' && output.image) {
// Screenshot: Bild nur im laufenden Verlauf, DB bekommt die Zusammenfassung
apiMessages.push({
role: 'tool',
tool_call_id: tc.id,
content: [
{ type: 'text', text: (output.text || output.title || '').slice(0, 2000) },
{ type: 'image_url', image_url: { url: output.image } },
],
});
} else {
apiMessages.push({ role: 'tool', tool_call_id: tc.id, content: outStr.slice(0, 8000) });
}
}
if (isAborted()) return finishAborted();
continue;
}
const finalText = result.content || (dashboardUrl ? 'Recherche abgeschlossen – siehe Dashboard.' : '');
db.createMessage(session.id, { role: 'assistant', content: finalText });
// Leere Antwort: Modell-Glitch oder abgebrochener Stream
if (!result.content) {
if (!pendingDashboard && !dashboardUrl && emptyRetries < MAX_EMPTY_RETRIES) {
emptyRetries++;
emit.progress('🔄', 'Leere Antwort erhalten – versuche es erneut…');
continue;
}
if (pendingDashboard) {
throw new Error(
`Modell ${model.id} hat das Dashboard-HTML nicht geliefert (leere Antwort). Bitte erneut versuchen.`
);
}
if (!dashboardUrl) {
const hint = result.reasoning
? ` Letzter Gedanke: „${String(result.reasoning).slice(-200)}“`
: '';
throw new Error(
`Modell ${model.id} hat keine Antwort geliefert.${hint} Bitte erneut versuchen oder ein anderes Modell wählen.`
);
}
}
// Dashboard vorbereitet → HTML aus der finalen Antwort extrahieren und speichern
if (pendingDashboard && result.content) {
const html = extractHtmlBlock(result.content);
if (!html) {
if (/```html/i.test(result.content)) {
throw new Error(
`Modell ${model.id}: Dashboard-HTML unvollständig (Codeblock nicht geschlossen) – möglicherweise wurde das Token-Limit erreicht. Bitte erneut versuchen oder ein kleineres Dashboard anfragen.`
);
}
throw new Error(
`Modell ${model.id} hat das Dashboard-HTML nicht geliefert (kein \`\`\`html\`\`\`-Codeblock in der Antwort). Bitte erneut versuchen.`
);
}
const saved = tools.saveDashboard(
session.id,
pendingDashboard.filename || pendingDashboard.title,
html,
(m) => emit.step(m)
);
dashboardUrl = saved.url;
dashboardSaved = true;
db.setSessionDashboard(session.id, saved.url);
emit.progress('📊', `Dashboard gespeichert: ${saved.file}`);
result.content = stripHtmlBlock(result.content).trim() || `Fertig! Dein Dashboard ist online: ${saved.url}`;
}
const finalText = result.content || (dashboardUrl ? 'Recherche abgeschlossen – siehe Dashboard.' : 'Recherche abgeschlossen.');
if (!dashboardSaved && dashboardUrl && isChangeRequest(userMessageText)) {
emit.progress(
'⚠️',
'Achtung: Das Dashboard wurde nicht aktualisiert – der Agent hat save_dashboard nicht aufgerufen.'
);
}
db.createMessage(session.id, {
role: 'assistant',
content: finalText,
reasoning: result.reasoning || null,
});
return finishJob(finalText, dashboardUrl);
}
@@ -332,10 +679,10 @@ async function runResearchJob(config, session, userMessageText, emit, db) {
role: 'assistant',
content: 'Maximale Iterationen erreicht – Recherche abgebrochen. Bitte neue Session starten.',
});
db.setSessionUsage(session.id, usage);
db.setSessionUsage(session.id, usage, prices);
db.setSessionStatus(session.id, 'done');
emit.error('Maximale Iterationen erreicht – Recherche abgebrochen.');
return { text: '', dashboardUrl, usage, costEstimate: db.computeCost(usage) };
return { text: '', dashboardUrl, usage, costEstimate: db.computeCost(usage, prices), model: model.id };
}
module.exports = { runResearchJob, SYSTEM_PROMPT, TOOL_DEFINITIONS };
+317 -6
View File
@@ -1,5 +1,6 @@
const path = require('path');
const fs = require('fs');
const crypto = require('crypto');
const { chromium } = require('playwright');
const DASHBOARD_DIR = path.join(__dirname, '..', 'dashboards');
@@ -92,8 +93,76 @@ async function search(query, log) {
return results;
}
/** Seite besuchen und lesbaren Text extrahieren. */
async function browse(url, log) {
const IMAGE_TYPES = { 'image/jpeg': 'jpg', 'image/png': 'png', 'image/webp': 'webp', 'image/gif': 'gif' };
const MAX_IMAGE_BYTES = 4 * 1024 * 1024;
const MAX_TOTAL_IMAGE_BYTES = 12 * 1024 * 1024;
const MAX_IMAGES = 8;
/**
* Extrahiert Produktbild-URLs (nur absolute http(s), ausreichend groß, dedupliziert).
* Läuft im Browser-Kontext der besuchten Seite.
*/
function collectImages() {
const images = [];
const seen = new Set();
for (const img of document.querySelectorAll('img')) {
const src = (img.currentSrc || img.src || '').split('#')[0];
if (!/^https?:/i.test(src) || seen.has(src)) continue;
const nw = img.naturalWidth || 0;
const nh = img.naturalHeight || 0;
const rect = img.getBoundingClientRect();
const w = nw || rect.width || img.width || 0;
const h = nh || rect.height || img.height || 0;
if (w < 120 || h < 120) continue;
seen.add(src);
images.push({ url: src, alt: (img.alt || '').trim().slice(0, 120) });
if (images.length >= 12) break;
}
return images;
}
/**
* Lädt extrahierte Bilder lokal in den Dashboards-Ordner, damit das Dashboard
* sie unabhängig von Hotlink-Schutz und ablaufenden CDN-URLs einbetten kann.
* Dateiname: <sessionId>-img-<hash>.<ext> – wird beim Session-Löschen mit entfernt.
*/
async function cacheImages(page, images, sessionId, log) {
if (!sessionId) return images;
fs.mkdirSync(DASHBOARD_DIR, { recursive: true });
const existingFiles = new Set(fs.readdirSync(DASHBOARD_DIR));
const out = [];
let total = 0;
for (const img of images) {
if (out.length >= MAX_IMAGES || total >= MAX_TOTAL_IMAGE_BYTES) break;
try {
const hash = crypto.createHash('sha1').update(img.url).digest('hex').slice(0, 12);
const known = [...existingFiles].find((f) => f.startsWith(`${sessionId}-img-${hash}.`));
if (known) {
out.push({ url: `/dashboards/${known}`, alt: img.alt });
continue;
}
const resp = await page.request.get(img.url, {
timeout: 15000,
headers: { Referer: page.url() },
});
const type = String(resp.headers()['content-type'] || '').split(';')[0].trim().toLowerCase();
const ext = IMAGE_TYPES[type];
if (!ext || !resp.ok()) continue;
const buf = await resp.body();
if (buf.length < 512 || buf.length > MAX_IMAGE_BYTES || total + buf.length > MAX_TOTAL_IMAGE_BYTES) continue;
const file = `${sessionId}-img-${hash}.${ext}`;
fs.writeFileSync(path.join(DASHBOARD_DIR, file), buf);
existingFiles.add(file);
total += buf.length;
out.push({ url: `/dashboards/${file}`, alt: img.alt });
} catch {}
}
log?.(`Produktbilder lokal gespeichert: ${out.length}`);
return out;
}
/** Seite besuchen und lesbaren Text + Produktbilder (lokal gespeichert) extrahieren. */
async function browse(url, log, opts = {}) {
return withPage(url, async (page) => {
const nav = await goto(page, url);
if (!nav.ok) return { nav, error: 'Seite konnte nicht geladen werden' };
@@ -101,10 +170,12 @@ async function browse(url, log) {
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) };
return { title, text: text.slice(0, 6000) };
});
log?.(`Besuche ${url} -> ${data.title}`);
return { nav, title: data.title, text: data.text };
const extracted = await page.evaluate(collectImages);
const images = await cacheImages(page, extracted, opts.sessionId, log);
log?.(`Besuche ${url} -> ${data.title} (${images.length} Bilder lokal)`);
return { nav, title: data.title, images, text: data.text };
});
}
@@ -144,6 +215,246 @@ function saveDashboard(sessionId, filename, html, log) {
return { file, url: `/dashboards/${file}` };
}
/** Alle Dashboard-Dateien einer Session (HTML/GPX/tour.json) löschen. */
function deleteSessionFiles(sessionId) {
let entries;
try {
entries = fs.readdirSync(DASHBOARD_DIR);
} catch {
return;
}
for (const f of entries) {
if (f.startsWith(`${sessionId}-`)) {
try {
fs.unlinkSync(path.join(DASHBOARD_DIR, f));
} catch {}
}
}
}
/** Screenshot einer Seite (JPEG, als Data-URL) – für Vision-Modelle. */
async function screenshot(url, fullPage = false, log) {
return withPage(url, async (page) => {
const nav = await goto(page, url);
if (!nav.ok) return { error: `Seite konnte nicht geladen werden (${nav.status || 'Timeout'})` };
const title = await page.title().catch(() => '');
let useFull = !!fullPage;
if (useFull) {
const h = await page.evaluate(() => document.body?.scrollHeight || 0).catch(() => 0);
if (h > 6000) useFull = false;
}
const buf = await page.screenshot({ fullPage: useFull, type: 'jpeg', quality: 70 });
log?.(`Screenshot: ${url}${useFull ? ' (voll)' : ''} – ${title || ''}`);
return {
title: title || nav.finalUrl || url,
url: nav.finalUrl || url,
image: `data:image/jpeg;base64,${buf.toString('base64')}`,
};
});
}
// ---------------------------------------------------------------------------
// Tour-Feature: Geocoding (Nominatim) + Routing (OSRM) + GPX-Export
// ---------------------------------------------------------------------------
const NOMINATIM_UA = 'ResearchChat/1.0 (Kontakt: research@localhost)';
let lastGeocodeTime = 0;
/** Koordinaten-Paare in OSRM-Format: lon,lat (ACHTUNG Reihenfolge). */
const toOsrm = (stops) => stops.map((s) => `${s.lon},${s.lat}`).join(';');
/** GPX-/XML-sichere Zeichen escapen. */
function xmlEscape(s) {
return String(s ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}
/** Koordinaten-Angabe erkennen: "52.5163, 13.3777" oder "52.5163 13.3777". */
function parseCoords(query) {
const m = String(query).match(/(-?\d{1,3}(?:\.\d+)?)\s*[,;\s]\s*(-?\d{1,3}(?:\.\d+)?)/);
if (!m) return null;
const lat = parseFloat(m[1]);
const lon = parseFloat(m[2]);
if (Math.abs(lat) <= 90 && Math.abs(lon) <= 180) return { lat, lon };
return null;
}
/**
* Ort über Nominatim (OpenStreetMap) in Koordinaten umwandeln.
* Kostenlos, Fair-Use: max. 1 Request/Sekunde.
*/
async function geocode(query, config, log) {
const coords = parseCoords(query);
if (coords) {
log?.(`Geocode "${query}" -> Koordinaten direkt übernommen`);
return { ...coords, name: query, displayName: query, direct: true };
}
const wait = Math.max(0, lastGeocodeTime + 1100 - Date.now());
if (wait > 0) await new Promise((r) => setTimeout(r, wait));
lastGeocodeTime = Date.now();
const url = new URL('https://nominatim.openstreetmap.org/search');
url.searchParams.set('q', query);
url.searchParams.set('format', 'json');
url.searchParams.set('limit', '1');
url.searchParams.set('accept-language', 'de');
const headers = {
'User-Agent': config?.nominatimEmail ? `ResearchChat/1.0 (${config.nominatimEmail})` : NOMINATIM_UA,
};
try {
const resp = await fetch(url, { headers, timeout: 15000 });
if (!resp.ok) return { error: `Nominatim Fehler ${resp.status}` };
const results = await resp.json();
if (!results.length) return { error: `Kein Ort gefunden: "${query}"` };
const r = results[0];
log?.(`Geocode "${query}" -> ${r.lat}, ${r.lon}`);
return {
name: r.name || query,
lat: parseFloat(r.lat),
lon: parseFloat(r.lon),
displayName: r.display_name,
};
} catch (e) {
return { error: `Nominatim nicht erreichbar: ${e.message.slice(0, 120)}` };
}
}
/** Geometrie entschlacken (max. 500 Punkte für das Dashboard). */
function decimate(coords, max = 500) {
if (coords.length <= max) return coords;
const step = coords.length / max;
const out = [];
for (let i = 0; i < coords.length; i += step) out.push(coords[Math.floor(i)]);
out.push(coords[coords.length - 1]);
return out;
}
/**
* Tour speichern: OSRM-Fuß-Route durch alle Stopps, GPX-Datei für OsmAnd,
* tour.json für die Dashboard-Karte, Google-Maps-URLs.
*/
async function saveTour({ stops, title, filename, sessionId }, config, log) {
if (!Array.isArray(stops) || stops.length < 2) {
return { error: 'Mindestens 2 Stopps nötig (Name, lat, lon)' };
}
const clean = stops
.filter((s) => s && s.name && Number.isFinite(Number(s.lat)) && Number.isFinite(Number(s.lon)))
.map((s) => ({
name: String(s.name).slice(0, 100),
lat: Number(s.lat),
lon: Number(s.lon),
description: String(s.description || '').slice(0, 300),
}));
if (clean.length < 2) return { error: 'Stopps ungültig (Name + lat + lon erforderlich)' };
// OSRM-Fuß-Route (Fallback: Luftlinie)
let route = { coordinates: clean.map((s) => [s.lon, s.lat]), distanceKm: 0, durationMin: 0, source: 'line' };
if (clean.length <= 25) {
try {
const osrmUrl = `${config?.osrmUrl || 'https://router.project-osrm.org'}/route/v1/foot/${toOsrm(clean)}?overview=full&geometries=geojson`;
const resp = await fetch(osrmUrl, { timeout: 20000 });
if (resp.ok) {
const data = await resp.json();
const r = data.routes?.[0];
if (r?.geometry?.coordinates?.length) {
route = {
coordinates: decimate(r.geometry.coordinates), // für die Karte im Dashboard
fullCoordinates: r.geometry.coordinates, // für die GPX-Datei (OsmAnd)
distanceKm: Math.round((r.distance / 1000) * 10) / 10,
durationMin: Math.round(r.duration / 60),
source: 'osrm',
};
}
} else {
log?.(`OSRM antwortet ${resp.status} – Fallback: Luftlinie`);
}
} catch (e) {
log?.(`OSRM nicht erreichbar – Fallback: Luftlinie (${e.message.slice(0, 80)})`);
}
}
// GPX 1.1 bauen
const wpts = clean
.map(
(s, i) =>
` <wpt lat="${s.lat.toFixed(6)}" lon="${s.lon.toFixed(6)}">\n` +
` <name>${xmlEscape(i + 1)}. ${xmlEscape(s.name)}</name>\n` +
(s.description ? ` <desc>${xmlEscape(s.description)}</desc>\n` : '') +
` </wpt>`
)
.join('\n');
const rtepts = clean
.map((s) => ` <rtept lat="${s.lat.toFixed(6)}" lon="${s.lon.toFixed(6)}"><name>${xmlEscape(s.name)}</name></rtept>`)
.join('\n');
const trkpts = (route.fullCoordinates || route.coordinates)
.map(([lon, lat]) => ` <trkpt lat="${lat.toFixed(6)}" lon="${lon.toFixed(6)}"></trkpt>`)
.join('\n');
const gpx = `<?xml version="1.0" encoding="UTF-8"?>
<gpx version="1.1" creator="ResearchChat" xmlns="http://www.topografix.com/GPX/1/1">
<metadata>
<name>${xmlEscape(title || 'Tour')}</name>
</metadata>
${wpts}
<rte>
<name>${xmlEscape(title || 'Tour')}</name>
${rtepts}
</rte>
<trk>
<name>${xmlEscape(title || 'Tour')} (Route)</name>
<trkseg>
${trkpts}
</trkseg>
</trk>
</gpx>
`;
const safeName =
(filename || title || 'tour').toLowerCase().replace(/[^a-z0-9äöü-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 60) ||
'tour';
const base = `${sessionId}-${safeName.replace(/-tour$/, '')}-tour`;
fs.mkdirSync(DASHBOARD_DIR, { recursive: true });
fs.writeFileSync(path.join(DASHBOARD_DIR, `${base}.gpx`), gpx, 'utf8');
const { fullCoordinates, ...routeForJson } = route;
fs.writeFileSync(
path.join(DASHBOARD_DIR, `${base}.tour.json`),
JSON.stringify({
title: title || 'Tour',
stops: clean,
route: routeForJson,
}),
'utf8'
);
const fmt = (lat, lon) => `${lat.toFixed(6)},${lon.toFixed(6)}`;
const googleMapsUrl = `https://www.google.com/maps/dir/${clean.map((s) => fmt(s.lat, s.lon)).join('/')}/`;
const stopsWithNav = clean.map((s) => ({
...s,
navUrl: `https://www.google.com/maps/dir/?api=1&destination=${fmt(s.lat, s.lon)}&travelmode=walking`,
}));
log?.(
`Tour gespeichert: ${base} (${clean.length} Stopps, ${route.distanceKm} km, ${route.durationMin} min, ${route.source})`
);
return {
ok: true,
title: title || 'Tour',
gpxUrl: `/dashboards/${base}.gpx`,
tourJsonUrl: `/dashboards/${base}.tour.json`,
googleMapsUrl,
stops: stopsWithNav,
distanceKm: route.distanceKm,
durationMin: route.durationMin,
routeSource: route.source,
osmAndHint: 'GPX-Datei herunterladen und in OsmAnd importieren (Menü → Meine Orte → Importieren).',
};
}
async function closeBrowser() {
if (browser) {
await browser.close().catch(() => {});
@@ -151,4 +462,4 @@ async function closeBrowser() {
}
}
module.exports = { search, browse, verify, saveDashboard, closeBrowser };
module.exports = { search, browse, verify, saveDashboard, screenshot, geocode, saveTour, deleteSessionFiles, closeBrowser };
+200 -11
View File
@@ -5,6 +5,9 @@ const state = {
stream: null,
streamingEl: null,
activityShown: false,
models: [],
modelById: {},
pendingImage: null,
};
const $ = (id) => document.getElementById(id);
@@ -32,7 +35,7 @@ function mdLite(text) {
}
function toolLabel(msg) {
const icons = { search: '🔍', browse: '🌐', verify: '✅', save_dashboard: '📊' };
const icons = { search: '🔍', browse: '🌐', verify: '✅', save_dashboard: '📊', screenshot: '📷' };
const icon = icons[msg.tool_name] || '🔧';
const text = msg.tool_name === 'search' ? `Suche: ${msg.content}` : msg.content;
return { icon, text: text?.slice(0, 200) };
@@ -59,27 +62,63 @@ function renderSessions() {
: 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 row = document.createElement('div');
row.className = 'group relative';
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.innerHTML = `${dot}<span class="truncate flex-1">${escapeHtml(s.title)}</span>`;
btn.onclick = () => openSession(s.id);
list.appendChild(btn);
const del = document.createElement('button');
del.className =
'absolute right-1 top-1/2 -translate-y-1/2 w-6 h-6 rounded text-xs text-slate-500 hover:text-red-400 hover:bg-slate-700 opacity-0 group-hover:opacity-100';
del.textContent = '🗑';
del.title = 'Chat löschen';
del.onclick = (e) => {
e.stopPropagation();
deleteSession(s.id);
};
row.append(btn, del);
list.appendChild(row);
}
const qs = state.sessions.find((s) => s.job === 'running' || s.job === 'queued');
$('queueStatus').classList.toggle('hidden', !qs);
}
async function deleteSession(id) {
const s = state.sessions.find((x) => x.id === id);
if (!confirm(`Chat „${s?.title || '…'}“ wirklich löschen? Das zugehörige Dashboard wird mit gelöscht.`)) return;
const res = await fetch(`/api/sessions/${id}`, { method: 'DELETE' });
if (!res.ok) {
addToolLine('⚠️', 'Löschen fehlgeschlagen');
return;
}
if (state.current?.id === id) {
state.stream?.close();
state.stream = null;
state.streamingEl = null;
state.current = null;
hideActivity();
const next = state.sessions.find((x) => x.id !== id);
if (next) await openSession(next.id);
else await newSession();
} else {
await loadSessions();
}
}
// ---------------------------------------------------------------------------
// Chat-Rendering
// ---------------------------------------------------------------------------
function addUserMessage(text) {
function addUserMessage(text, image) {
$('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>`;
el.innerHTML = `<div class="max-w-[80%] px-4 py-2.5 rounded-2xl bg-blue-600 text-white whitespace-pre-wrap break-words">${
image ? `<img src="${image}" alt="Angehängtes Bild" class="rounded-lg mb-2 max-h-48">` : ''
}${mdLite(text)}</div>`;
$('chat').appendChild(el);
scrollDown();
}
@@ -154,7 +193,7 @@ function renderMessages(messages, session) {
}
addDashboardCard(session);
for (const m of messages) {
if (m.role === 'user') addUserMessage(m.content);
if (m.role === 'user') addUserMessage(m.content, m.image || null);
else if (m.role === 'assistant') {
if (m.content) addAssistantBubble(m.content);
} else if (m.role === 'tool') {
@@ -182,6 +221,8 @@ function showActivity() {
$('activityLog').classList.add('hidden');
$('toggleLog').textContent = 'Details zeigen';
state.activityShown = true;
$('abortBtn').classList.remove('hidden');
$('abortBtn').disabled = false;
}
function setActivity(text, icon = '') {
@@ -197,15 +238,29 @@ function logActivity(text) {
function hideActivity() {
$('activityBar').classList.add('hidden');
$('abortBtn').classList.add('hidden');
state.activityShown = false;
}
async function abortResearch() {
if (!state.current) return;
$('abortBtn').disabled = true;
setActivity('Breche Recherche ab…', '⛔');
try {
await fetch(`/api/sessions/${state.current.id}/abort`, { method: 'POST' });
} catch {
$('abortBtn').disabled = false;
}
}
$('toggleLog').onclick = () => {
const log = $('activityLog');
const hidden = log.classList.toggle('hidden');
$('toggleLog').textContent = hidden ? 'Details zeigen' : 'Details verbergen';
};
$('abortBtn').onclick = abortResearch;
// ---------------------------------------------------------------------------
// SSE-Stream
// ---------------------------------------------------------------------------
@@ -281,6 +336,13 @@ function connectStream(sessionId) {
$('sendBtn').disabled = false;
});
es.addEventListener('aborted', () => {
addToolLine('⛔', 'Recherche abgebrochen.');
hideActivity();
loadSessions();
$('sendBtn').disabled = false;
});
es.addEventListener('error', (e) => {
if (!e.data) return;
const d = JSON.parse(e.data);
@@ -348,11 +410,15 @@ async function openSession(id) {
state.stream?.close();
state.streamingEl = null;
hideActivity();
clearImage();
const res = await fetch(`/api/sessions/${id}`);
const data = await res.json();
state.current = data.session;
if (!state.current.model) state.current.model = state.models[0]?.id || null;
renderMessages(data.messages, data.session);
$('mobileTitle').textContent = data.session.title;
setSelectModel(state.current.model);
updateModelUI();
renderSessions();
const q = state.sessions.find((s) => s.id === id);
if (data.session.status === 'running' || q?.job === 'running' || q?.job === 'queued') {
@@ -370,6 +436,114 @@ async function newSession() {
$('input').focus();
}
// ---------------------------------------------------------------------------
// Modell-Auswahl
// ---------------------------------------------------------------------------
async function loadModels() {
const res = await fetch('/api/models');
const data = await res.json();
state.models = data.models || [];
state.modelById = Object.fromEntries(state.models.map((m) => [m.id, m]));
const opts = state.models.map((m) => `<option value="${escapeHtml(m.id)}">${escapeHtml(m.label)}</option>`).join('');
$('modelSelectDesktop').innerHTML = opts;
$('modelSelectMobile').innerHTML = opts;
return data.default || null;
}
function currentModel() {
return state.modelById[state.current?.model] || state.models[0] || null;
}
function setSelectModel(id) {
$('modelSelectDesktop').value = id || '';
$('modelSelectMobile').value = id || '';
}
async function changeModel(id) {
if (!state.current || state.current.model === id) return;
const res = await fetch(`/api/sessions/${state.current.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model: id }),
});
if (!res.ok) {
const d = await res.json().catch(() => ({}));
addToolLine('⚠️', d.error || 'Modellwechsel fehlgeschlagen');
setSelectModel(state.current.model);
return;
}
state.current.model = id;
const s = state.sessions.find((x) => x.id === state.current.id);
if (s) s.model = id;
updateModelUI();
}
function updateModelUI() {
const m = currentModel();
const vision = !!m?.vision;
const btn = $('imageBtn');
btn.disabled = !vision;
btn.classList.toggle('opacity-40', !vision);
const visionLabel = state.models.find((x) => x.vision)?.label || 'einem Vision-Modell';
btn.title = vision ? 'Bild anhängen' : `Bilderkennung nur mit Vision-Modell möglich (z.B. ${visionLabel})`;
if (!vision && state.pendingImage) clearImage();
}
// ---------------------------------------------------------------------------
// Bild-Upload (Vision)
// ---------------------------------------------------------------------------
function fileToDataUrl(file, maxDim = 1280, quality = 0.82) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
const img = new Image();
img.onload = () => {
const scale = Math.min(1, maxDim / Math.max(img.width, img.height));
const canvas = document.createElement('canvas');
canvas.width = Math.max(1, Math.round(img.width * scale));
canvas.height = Math.max(1, Math.round(img.height * scale));
const ctx = canvas.getContext('2d');
ctx.fillStyle = '#fff';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
resolve(canvas.toDataURL('image/jpeg', quality));
};
img.onerror = () => reject(new Error('Bild konnte nicht gelesen werden'));
img.src = reader.result;
};
reader.onerror = () => reject(new Error('Datei konnte nicht gelesen werden'));
reader.readAsDataURL(file);
});
}
function showImagePreview(dataUrl) {
state.pendingImage = dataUrl;
$('imagePreviewImg').src = dataUrl;
$('imagePreview').classList.remove('hidden');
}
function clearImage() {
state.pendingImage = null;
$('imageInput').value = '';
$('imagePreview').classList.add('hidden');
}
async function handleImageFile(file) {
if (!file) return;
try {
const dataUrl = await fileToDataUrl(file);
if (dataUrl.length > 3500000) {
addToolLine('⚠️', 'Bild zu groß – bitte ein kleineres Foto wählen.');
return;
}
showImagePreview(dataUrl);
} catch (e) {
addToolLine('⚠️', e.message || 'Bild konnte nicht verarbeitet werden');
}
}
function copyText(text, btn, label) {
try {
navigator.clipboard.writeText(text).then(
@@ -424,14 +598,14 @@ function updateShareHint() {
// Senden
// ---------------------------------------------------------------------------
async function sendMessage(text) {
async function sendMessage(text, image) {
if (!state.current) await newSession();
$('sendBtn').disabled = true;
addUserMessage(text);
addUserMessage(text, image || null);
const res = await fetch(`/api/sessions/${state.current.id}/messages`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text }),
body: JSON.stringify(image ? { text, image } : { text }),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
@@ -439,6 +613,7 @@ async function sendMessage(text) {
$('sendBtn').disabled = false;
return;
}
clearImage();
enableNotifications();
showActivity();
setActivity('In Warteschlange…', '⏳');
@@ -462,6 +637,7 @@ window.addEventListener('load', async () => {
navigator.serviceWorker.register('/sw.js').catch(() => {});
}
await loadModels();
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;
@@ -476,10 +652,11 @@ window.addEventListener('load', async () => {
$('chatForm').addEventListener('submit', (e) => {
e.preventDefault();
const text = $('input').value.trim();
if (!text) return;
const image = state.pendingImage;
if (!text && !image) return;
$('input').value = '';
$('input').style.height = 'auto';
sendMessage(text);
sendMessage(text, image);
});
$('input').addEventListener('input', () => {
@@ -494,6 +671,18 @@ $('logoutBtn').onclick = async () => {
location.href = '/login.html';
};
$('modelSelectDesktop').addEventListener('change', (e) => changeModel(e.target.value));
$('modelSelectMobile').addEventListener('change', (e) => changeModel(e.target.value));
$('imageBtn').onclick = () => {
if (!$('imageBtn').disabled) $('imageInput').click();
};
$('imageInput').addEventListener('change', (e) => {
handleImageFile(e.target.files?.[0]);
e.target.value = '';
});
$('imageRemoveBtn').onclick = clearImage;
// Enter sendet, Shift+Enter = Zeilenumbruch
$('input').addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
+18 -1
View File
@@ -27,9 +27,14 @@
<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">
class="m-4 mb-2 py-2 rounded-lg bg-blue-600 hover:bg-blue-700 text-white font-semibold text-sm">
+ Neue Recherche
</button>
<div class="mx-4 mb-2">
<label class="text-xs text-slate-400 block mb-1" for="modelSelectDesktop">Modell</label>
<select id="modelSelectDesktop"
class="w-full px-2 py-1.5 rounded-lg border text-sm dark:bg-slate-800 dark:border-slate-700 border-slate-300 focus:outline-none focus:ring-2 focus:ring-blue-500"></select>
</div>
<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>
@@ -43,6 +48,8 @@
<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>
<select id="modelSelectMobile"
class="px-2 py-1 rounded-lg border text-xs dark:bg-slate-800 dark:border-slate-700 border-slate-300 focus:outline-none"></select>
</div>
<!-- Activity bar -->
@@ -50,6 +57,8 @@
<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>
<button id="abortBtn" type="button" title="Recherche abbrechen"
class="ml-auto hidden text-xs px-2 py-1 rounded-lg bg-red-600/15 text-red-400 hover:bg-red-600/30">⛔ Abbrechen</button>
</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>
@@ -69,7 +78,15 @@
<!-- Input -->
<div class="p-4 border-t dark:border-slate-800 border-slate-200 bg-white dark:bg-slate-900">
<div id="imagePreview" class="hidden mb-2 relative">
<img id="imagePreviewImg" src="" alt="Vorschau" class="max-h-40 rounded-xl border dark:border-slate-700 border-slate-300">
<button id="imageRemoveBtn" type="button" title="Bild entfernen"
class="absolute -top-2 -left-2 w-6 h-6 rounded-full bg-red-600 text-white text-xs font-bold">✕</button>
</div>
<form id="chatForm" class="flex gap-2 items-end">
<input type="file" id="imageInput" accept="image/jpeg,image/png,image/webp" class="hidden">
<button id="imageBtn" type="button" title="Bild anhängen (nur Vision-Modelle)"
class="px-3 py-2.5 rounded-xl border dark:border-slate-700 border-slate-300 dark:bg-slate-800 hover:dark:bg-slate-700 text-lg leading-none">📷</button>
<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"
+5 -3
View File
@@ -28,7 +28,7 @@
<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>
<p>Generiert mit dem Research Agent · DeepSeek V4.1 Flash</p>
</footer>
</div>
@@ -47,7 +47,7 @@
};
const toolLabel = (m) => {
const icons = { search: '🔍', browse: '🌐', verify: '✅', save_dashboard: '📊' };
const icons = { search: '🔍', browse: '🌐', verify: '✅', save_dashboard: '📊', screenshot: '📷' };
const icon = icons[m.tool_name] || '🔧';
const text = m.tool_name === 'search' ? `Suche: ${m.content}` : m.content;
return { icon, text: (text || '').slice(0, 200) };
@@ -90,7 +90,9 @@
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>`;
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">${
m.image ? `<img src="${m.image}" alt="Angehängtes Bild" class="rounded-lg mb-2 max-h-48">` : ''
}${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>`;
+176 -10
View File
@@ -1,14 +1,15 @@
const path = require('path');
const http = require('http');
const crypto = require('crypto');
const fs = require('fs');
const express = require('express');
const webpush = require('web-push');
const { config } = require('./lib/config');
const { config, getModel, getModels } = require('./lib/config');
const db = require('./lib/db');
const queue = require('./lib/queue');
const { runResearchJob } = require('./lib/research');
const { closeBrowser } = require('./lib/tools');
const { closeBrowser, deleteSessionFiles } = require('./lib/tools');
const app = express();
app.disable('x-powered-by');
@@ -159,10 +160,36 @@ app.get('/api/sessions', requireAuth, (req, res) => {
});
app.post('/api/sessions', requireAuth, (req, res) => {
const session = db.createSession(req.user.n);
const session = db.createSession(req.user.n, 'Neue Recherche', getModel(config.model).id);
res.json(session);
});
app.patch('/api/sessions/:id', requireAuth, (req, res) => {
const session = db.getSession(req.params.id);
if (!session) {
return res.status(404).json({ error: 'Session nicht gefunden' });
}
const modelId = String(req.body?.model || '');
if (!getModels().some((m) => m.id === modelId)) {
return res.status(400).json({ error: 'Unbekanntes Modell' });
}
db.setSessionModel(session.id, modelId);
res.json(db.getSession(session.id));
});
app.get('/api/models', requireAuth, (req, res) => {
res.json({
default: getModel(config.model).id,
models: getModels().map((m) => ({
id: m.id,
label: m.label,
provider: m.provider,
vision: m.vision,
costNote: m.costNote || null,
})),
});
});
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)) {
@@ -180,9 +207,35 @@ app.get('/api/sessions/:id/share', requireAuth, (req, res) => {
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' });
if (!session) {
return res.status(404).json({ error: 'Session nicht gefunden' });
}
queue.cancelQueued(session.id);
queue.abortRunning(session.id);
db.deleteSession(session.id);
deleteSessionFiles(session.id);
res.json({ ok: true });
});
app.post('/api/sessions/:id/abort', requireAuth, (req, res) => {
const session = db.getSession(req.params.id);
if (!session) {
return res.status(404).json({ error: 'Session nicht gefunden' });
}
if (queue.queuedBefore(session.id) >= 0) {
// Job wartet noch in der Queue → direkt entfernen und aufräumen
queue.cancelQueued(session.id);
} else if (queue.hasController(session.id)) {
// Laufender Job → Controller auslösen, der Job räumt selbst auf
queue.abortRunning(session.id);
return res.json({ ok: true });
}
// Wartender Job oder veralteter Job (z.B. nach Server-Neustart, ohne
// laufenden Prozess): Zustand und UI direkt zurücksetzen
db.createMessage(session.id, { role: 'assistant', content: '⛔ Recherche abgebrochen.' });
db.setSessionStatus(session.id, 'done');
emitToSession(session.id, 'aborted', {});
emitToSession(session.id, 'status', { sessionStatus: 'done', jobStatus: 'aborted' });
res.json({ ok: true });
});
@@ -204,7 +257,22 @@ 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' });
const image = typeof req.body?.image === 'string' && req.body.image ? req.body.image : null;
if (!text && !image) return res.status(400).json({ error: 'Leere Nachricht' });
if (image) {
if (!/^data:image\/(jpeg|png|webp);base64,/.test(image)) {
return res.status(400).json({ error: 'Ungültiges Bild (JPEG/PNG/WebP als Data-URL erwartet)' });
}
if (image.length > 4000000) {
return res.status(400).json({ error: 'Bild zu groß (max. ~3 MB)' });
}
const model = getModel(session.model);
if (!model.vision) {
return res.status(400).json({
error: `Das Modell "${model.label}" kann keine Bilder verarbeiten.`,
});
}
}
if (session.status === 'running' || session.status === 'queued') {
return res.status(409).json({ error: 'Für diese Session läuft bereits ein Job.' });
}
@@ -212,15 +280,18 @@ app.post('/api/sessions/:id/messages', requireAuth, (req, res) => {
return res.status(409).json({ error: 'Eine andere Recherche läuft gerade – bitte warten.' });
}
db.createMessage(session.id, { role: 'user', content: text });
db.createMessage(session.id, { role: 'user', content: text, image });
if (session.title === 'Neue Recherche') {
db.setSessionTitle(session.id, text.slice(0, 70));
}
db.setSessionStatus(session.id, 'queued');
const controller = queue.registerController(session.id);
const job = {
sessionId: session.id,
async run() {
try {
db.setSessionStatus(session.id, 'running');
emitToSession(session.id, 'status', { sessionStatus: 'running', jobStatus: 'running' });
@@ -234,16 +305,24 @@ app.post('/api/sessions/:id/messages', requireAuth, (req, res) => {
emitToSession(session.id, 'status', { sessionStatus: 'done', jobStatus: 'done' });
sendPush(session, result);
},
aborted: () => {
emitToSession(session.id, 'aborted', {});
emitToSession(session.id, 'status', { sessionStatus: 'done', jobStatus: 'aborted' });
},
error: (msg) => emitToSession(session.id, 'error', { text: msg }),
};
await runResearchJob(
{ apiKey: config.apiKey, model: config.model, thinking: config.thinking, baseUrl: config.baseUrl },
config,
db.getSession(session.id),
text,
emit,
db
db,
controller.signal
);
} finally {
queue.clearController(session.id);
}
},
async onError(e) {
console.error('Job-Fehler:', e);
@@ -338,7 +417,87 @@ function sendPush(session, result) {
// Statische Dateien & Dashboard-Dateien
// ---------------------------------------------------------------------------
app.use('/dashboards', express.static(path.join(__dirname, 'dashboards'), { maxAge: '1h' }));
// Wird in jedes ausgelieferte Dashboard-HTML injiziert: speichert den Zustand
// der Checklisten-Häkchen im localStorage und stellt ihn beim Laden wieder her
// (inkl. „Liste zurücksetzen"-Button). Key = Label-Text der Checkbox, damit
// der Stand auch bei Layout-Änderungen stabil bleibt.
const CHECKLIST_SCRIPT = `<script>
(function () {
var KEY = 'rc-checks:' + location.pathname;
var boxes = document.querySelectorAll('input[type=checkbox]');
if (!boxes.length) return;
var saved = {};
try { saved = JSON.parse(localStorage.getItem(KEY) || '{}'); } catch (e) {}
function labelFor(box, i) {
var li = box.closest('li');
var t = li ? li.querySelector('.check-text') : null;
var text = (t || li || {}).textContent || '';
return text.trim() || 'item-' + i;
}
boxes.forEach(function (box, i) {
var k = labelFor(box, i);
if (saved[k] !== undefined) box.checked = !!saved[k];
box.addEventListener('change', function () {
saved[k] = box.checked;
try { localStorage.setItem(KEY, JSON.stringify(saved)); } catch (e) {}
});
});
function refresh() {
if (typeof update === 'function') update();
}
// „Liste zurücksetzen"-Button am Anfang der ersten Checklisten-Section
var firstBox = boxes[0];
var section = firstBox.closest('section') || firstBox.closest('div') || document.body;
var btn = document.createElement('button');
btn.type = 'button';
btn.textContent = '↺ Liste zurücksetzen';
btn.style.cssText =
'margin:0 0 12px;padding:6px 14px;font-size:12px;font-weight:600;border-radius:8px;' +
'border:1px solid rgba(100,116,139,.4);background:transparent;color:inherit;cursor:pointer;';
btn.addEventListener('click', function () {
if (!confirm('Alle Häkchen dieser Liste zurücksetzen?')) return;
try { localStorage.removeItem(KEY); } catch (e) {}
saved = {};
boxes.forEach(function (b) { b.checked = false; });
refresh();
});
section.insertBefore(btn, section.firstChild);
refresh();
})();
</script>`;
const CHECKLIST_MARKER = 'rc-checks';
app.get('/dashboards/:name.html', (req, res, next) => {
const name = path.basename(req.params.name);
const file = path.join(__dirname, 'dashboards', `${name}.html`);
fs.readFile(file, 'utf8', (err, html) => {
if (err) return next();
if (!html.includes(CHECKLIST_MARKER)) {
html = html.replace(/<\/body>/i, `${CHECKLIST_SCRIPT}\n</body>`);
}
res.setHeader('Cache-Control', 'no-cache');
res.type('html').send(html);
});
});
app.use(
'/dashboards',
express.static(path.join(__dirname, 'dashboards'), {
// Kein Caching: Dashboards werden bei Folge-Nachrichten überschrieben,
// der Browser soll immer die aktuelle Version sehen.
maxAge: 0,
setHeaders: (res, filePath) => {
if (filePath.endsWith('.gpx')) res.setHeader('Content-Type', 'application/gpx+xml');
if (filePath.endsWith('.tour.json')) res.setHeader('Content-Type', 'application/json');
},
})
);
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')));
@@ -360,8 +519,15 @@ app.get('/', (req, res) => {
// ---------------------------------------------------------------------------
// Nach einem Server-Neustart hängengebliebene Jobs (running/queued in der DB)
// zurücksetzen – die Jobs existieren im Speicher nicht mehr.
const staleCount = db.markStaleJobs();
if (staleCount > 0) {
console.log(`INFO: ${staleCount} verwaiste Recherche(n) nach Neustart zurückgesetzt.`);
}
if (!config.apiKey) {
console.error('WARNUNG: DEEPSEEK_API_KEY fehlt in .env – Recherche wird nicht funktionieren.');
console.error('WARNUNG: DEEPSEEK_API_KEY fehlt in .env – DeepSeek-Modelle werden nicht funktionieren.');
}
if (!config.appPassword) {
console.error('WARNUNG: APP_PASSWORD fehlt in .env – Login ohne Passwort-Check.');