From 87eb7029f17d7ef408e9d2e5ce189567f90270dc Mon Sep 17 00:00:00 2001 From: nico Date: Thu, 27 Aug 2026 17:43:13 +0200 Subject: [PATCH] Multi-Modell + Vision: OpenRouter (GLM 5.3 Flash), Bild-Upload, Tour-Feature, Dashboard-Fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- .env.example | 14 ++- .gitignore | 2 + lib/config.js | 57 +++++++++- lib/db.js | 114 ++++++++++++++++---- lib/research.js | 257 +++++++++++++++++++++++++++++++++++++++++----- lib/tools.js | 225 +++++++++++++++++++++++++++++++++++++++- public/app.js | 150 +++++++++++++++++++++++++-- public/index.html | 17 ++- public/share.html | 8 +- server.js | 69 +++++++++++-- 10 files changed, 843 insertions(+), 70 deletions(-) diff --git a/.env.example b/.env.example index 18775cf..82c7266 100644 --- a/.env.example +++ b/.env.example @@ -2,6 +2,10 @@ # DeepSeek API Key (https://platform.deepseek.com/api_keys) DEEPSEEK_API_KEY=sk-xxxx +# OpenRouter API Key für zusätzliche Modelle (https://openrouter.ai/keys), +# z.B. GLM 5.3 Flash (z-ai/glm-5.3-flash, Vision/Bilderkennung) +OPENROUTER_API_KEY=sk-or-xxxx + # Gemeinsames Login-Passwort für die App (hinter NGINX mit IP-Prüfung) APP_PASSWORD=ein-sicheres-passwort @@ -12,11 +16,19 @@ SESSION_SECRET= PORT=3000 # DeepSeek Modell MODEL=deepseek-v4-flash -# Thinking-Mode des Modells: enabled | disabled +# Thinking-Mode des Modells: enabled | disabled (gilt für DeepSeek) THINKING=enabled +# Reasoning-Tiefe je Modell: low | high | max (GLM: Pflicht, max Default) +DEEPSEEK_REASONING_EFFORT=max +GLM_REASONING_EFFORT=max +# Maximale Antwort-Tokens pro API-Aufruf (Dashboards sind große HTML-Dateien) +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 diff --git a/.gitignore b/.gitignore index ac8be86..4318c64 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,6 @@ node_modules/ data/ dashboards/*.html +dashboards/*.gpx +dashboards/*.tour.json .env diff --git a/lib/config.js b/lib/config.js index a89a135..4292ed2 100644 --- a/lib/config.js +++ b/lib/config.js @@ -51,15 +51,70 @@ const vapid = ensureVapidPair(); const config = { port: Number(process.env.PORT || env.PORT || 3000), apiKey: process.env.DEEPSEEK_API_KEY || env.DEEPSEEK_API_KEY || '', + openrouterApiKey: process.env.OPENROUTER_API_KEY || env.OPENROUTER_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', + 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 direkt + OpenRouter-Modelle (z.B. GLM 5.3 Flash) +// --------------------------------------------------------------------------- + +const MODELS = [ + { + id: 'deepseek-v4-flash', + label: 'DeepSeek V4 Flash', + provider: 'deepseek', + vision: false, + maxTokens: 64000, + reasoningEffort: () => envOr('DEEPSEEK_REASONING_EFFORT', 'max'), + prices: { inputCacheHit: 0.014, inputCacheMiss: 0.44, output: 1.32 }, + }, + { + id: 'z-ai/glm-5.3-flash', + label: 'GLM 5.3 Flash (OpenRouter)', + provider: 'openrouter', + vision: true, + maxTokens: 64000, + reasoningEffort: () => envOr('GLM_REASONING_EFFORT', 'max'), + prices: { inputCacheHit: 0.015, inputCacheMiss: 0.075, output: 0.25 }, + costNote: 'Schätzung (OpenRouter, Rabatt bis Sep 2026)', + }, +]; + +/** 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.014, inputCacheMiss: 0.44, output: 1.32 }, + }, + ]; +} + +/** 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 }; diff --git a/lib/db.js b/lib/db.js index 7358591..f1925d4 100644 --- a/lib/db.js +++ b/lib/db.js @@ -49,14 +49,15 @@ 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 +// DeepSeek v4 Flash Preise (USD pro 1M Tokens, Peak) – obere Schätzung. +// Andere Modelle (z.B. OpenRouter) legen eigene Preise in der Registry an. const PRICES = { inputCacheHit: 0.014, inputCacheMiss: 0.44, output: 1.32 }; -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 +73,23 @@ 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'); } 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 +102,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,8 +111,8 @@ 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`), lastMessage: db.prepare(`SELECT * FROM messages WHERE session_id = ? ORDER BY id DESC LIMIT 1`), @@ -113,13 +126,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 +140,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 +154,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,13 +243,14 @@ 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, diff --git a/lib/research.js b/lib/research.js index 33120c3..5695acd 100644 --- a/lib/research.js +++ b/lib/research.js @@ -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. @@ -18,6 +19,29 @@ const SYSTEM_PROMPT = `Du bist ein professioneller Web-Researcher und Wissensber 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. +## 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: und (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: '© OpenStreetMap © CARTO' + }).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) - , im Head, responsives Design mit Dark-Mode-Unterstützung (dark: Klassen) - Header: Titel + Datum + Anzahl verifizierter Links @@ -28,6 +52,7 @@ 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. @@ -97,8 +122,74 @@ const TOOL_DEFINITIONS = [ }, }, }, + { + 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). Maximal 2-3 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; @@ -118,6 +209,18 @@ function summarizeTool(toolName, output) { return `Link ${data.ok ? 'VERIFIZIERT ✓' : 'FEHLGESCHLAGEN ✗'}: ${data.url}${data.reason ? ' – ' + data.reason : ''}`; case 'save_dashboard': return `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 +228,80 @@ function summarizeTool(toolName, output) { const MAX_ITERATIONS = 40; -async function chatCompletion(config, messages) { +/** 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 aufrufen und das KOMPLETTE aktualisierte HTML mit EXAKT diesem Dateinamen übergeben: "${file}" (ohne .html). +Eine bloße Erklärung oder Beschreibung der Änderungen im Chat ist KEINE Umsetzung – erst wenn save_dashboard erfolgreich gelaufen ist, fasst du die Änderungen im Chat zusammen.`; + } + if (model.vision) { + prompt += ` + +## Bilderkennung (Vision) +- Wenn der Nutzer ein Bild hochgeladen hat (z.B. ein Foto eines Schuhs): Analysiere es SORGFÄLTIG (Marke, Farbe, Material, Form, Zustand, erkennbare Details) und suche gezielt nach ähnlichen Produkten und Angeboten. Beschreibe im Dashboard, worauf du beim Vergleich geachtet hast. +- Das hochgeladene Bild liegt dir als Data-URL im Verlauf vor – du kannst es bei Bedarf direkt ins Dashboard einbetten. +- Nutze das screenshot-Tool, um bildlastige Seiten (z.B. Anzeigen-Listen, Produktfotos) visuell zu beurteilen, wenn die Text-Extraktion nicht ausreicht. Maximal 2-3 Screenshots pro Session.`; + } + return prompt; +} + +/** 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 + ); +} + +async function chatCompletion(config, messages, systemPrompt, model, tools) { const body = { - model: config.model, - messages, - tools: TOOL_DEFINITIONS, + model: model.id, + messages: [{ role: 'system', content: systemPrompt }, ...messages], + tools, stream: true, - max_tokens: 32000, + max_tokens: model.maxTokens || config.maxTokens || 64000, stream_options: { include_usage: true }, }; - if (config.thinking !== 'disabled') { + + const isOpenRouter = model.provider === 'openrouter'; + const baseUrl = isOpenRouter + ? 'https://openrouter.ai/api/v1' + : config.baseUrl || 'https://api.deepseek.com'; + + if (isOpenRouter) { + // GLM: Reasoning ist Pflicht; Effort über OpenRouter-Param "reasoning" + body.reasoning = { effort: model.reasoningEffort?.() || 'max' }; + } else if (config.thinking !== 'disabled') { body.thinking = { type: 'enabled' }; - body.reasoning_effort = 'medium'; + body.reasoning_effort = model.reasoningEffort?.() || 'high'; } - const resp = await fetch(`${config.baseUrl || 'https://api.deepseek.com'}/chat/completions`, { + const headers = isOpenRouter + ? { + 'Content-Type': 'application/json', + Authorization: `Bearer ${config.openrouterApiKey}`, + 'X-Title': 'Research Chat', + 'HTTP-Referer': 'https://github.com/nico/researchchat', + } + : { + 'Content-Type': 'application/json', + Authorization: `Bearer ${config.apiKey}`, + }; + + const resp = await fetch(`${baseUrl}/chat/completions`, { method: 'POST', - headers: { - 'Content-Type': 'application/json', - Authorization: `Bearer ${config.apiKey}`, - }, + headers, 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)}`); + throw new Error(`Modell-API Fehler ${resp.status} (${model.id}): ${err.slice(0, 500)}`); } const reader = resp.body.getReader(); @@ -173,7 +324,9 @@ async function chatCompletion(config, messages) { const choice = json.choices?.[0]; if (!choice) return; const d = choice.delta || {}; + // DeepSeek liefert reasoning_content, OpenRouter/GLM reasoning if (d.reasoning_content) delta.reasoning += d.reasoning_content; + else if (d.reasoning) delta.reasoning += d.reasoning; if (d.content) delta.content += d.content; if (d.tool_calls) { for (const tc of d.tool_calls) { @@ -224,7 +377,11 @@ async function chatCompletion(config, messages) { */ async function runResearchJob(config, session, userMessageText, emit, db) { let dashboardUrl = session.dashboard || null; - let dashboardFile = dashboardUrl ? dashboardUrl.split('/').pop().replace('.html', '') : null; + let dashboardSaved = false; + + const model = getModel(session.model || config.model); + const tools = model.vision ? [...TOOL_DEFINITIONS, SCREENSHOT_TOOL] : TOOL_DEFINITIONS; + const prices = model.prices; const handlers = { async search({ query }) { @@ -238,6 +395,10 @@ async function runResearchJob(config, session, userMessageText, emit, db) { const result = await tools.browse(url, (m) => emit.step(m)); 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)); @@ -247,17 +408,27 @@ async function runResearchJob(config, session, userMessageText, emit, db) { 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); }, + 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…'); + emit.progress('🤖', `Research startet mit ${model.label}…`); const trackUsage = (u) => { if (!u) return; @@ -271,17 +442,17 @@ 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 }; }; 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); + const result = await chatCompletion(config, apiMessages, buildSystemPrompt(session, model), model, tools); trackUsage(result.usage); if (result.reasoning) emit.thinking(result.reasoning); @@ -296,7 +467,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 +490,42 @@ 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) }); + } } continue; } const finalText = result.content || (dashboardUrl ? 'Recherche abgeschlossen – siehe Dashboard.' : ''); - db.createMessage(session.id, { role: 'assistant', content: finalText }); + 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 +533,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 }; diff --git a/lib/tools.js b/lib/tools.js index 87d582c..b3ffb14 100644 --- a/lib/tools.js +++ b/lib/tools.js @@ -144,6 +144,229 @@ function saveDashboard(sessionId, filename, html, log) { return { file, url: `/dashboards/${file}` }; } +/** 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, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +/** 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) => + ` \n` + + ` ${xmlEscape(i + 1)}. ${xmlEscape(s.name)}\n` + + (s.description ? ` ${xmlEscape(s.description)}\n` : '') + + ` ` + ) + .join('\n'); + const rtepts = clean + .map((s) => ` ${xmlEscape(s.name)}`) + .join('\n'); + const trkpts = (route.fullCoordinates || route.coordinates) + .map(([lon, lat]) => ` `) + .join('\n'); + const gpx = ` + + + ${xmlEscape(title || 'Tour')} + +${wpts} + + ${xmlEscape(title || 'Tour')} +${rtepts} + + + ${xmlEscape(title || 'Tour')} (Route) + +${trkpts} + + + +`; + + 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 +374,4 @@ async function closeBrowser() { } } -module.exports = { search, browse, verify, saveDashboard, closeBrowser }; +module.exports = { search, browse, verify, saveDashboard, screenshot, geocode, saveTour, closeBrowser }; diff --git a/public/app.js b/public/app.js index 9d3c0dd..beeb4ea 100644 --- a/public/app.js +++ b/public/app.js @@ -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) }; @@ -75,11 +78,13 @@ function renderSessions() { // Chat-Rendering // --------------------------------------------------------------------------- -function addUserMessage(text) { +function addUserMessage(text, image) { $('emptyState')?.remove(); const el = document.createElement('div'); el.className = 'flex justify-end'; - el.innerHTML = `
${mdLite(text)}
`; + el.innerHTML = `
${ + image ? `Angehängtes Bild` : '' + }${mdLite(text)}
`; $('chat').appendChild(el); scrollDown(); } @@ -154,7 +159,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') { @@ -348,11 +353,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 +379,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) => ``).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 +541,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 +556,7 @@ async function sendMessage(text) { $('sendBtn').disabled = false; return; } + clearImage(); enableNotifications(); showActivity(); setActivity('In Warteschlange…', '⏳'); @@ -462,6 +580,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 +595,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 +614,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) { diff --git a/public/index.html b/public/index.html index c9be64c..983ae58 100644 --- a/public/index.html +++ b/public/index.html @@ -27,9 +27,14 @@ +
+ + +
@@ -43,6 +48,8 @@
Research Agent +
@@ -69,7 +76,15 @@
+
+ +
-

Generiert mit dem Research Agent · DeepSeek v4 Flash

+

Generiert mit dem Research Agent · DeepSeek V4 Flash / GLM 5.3 Flash

@@ -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 += `
${mdLite(m.content)}
`; + chat.innerHTML += `
${ + m.image ? `Angehängtes Bild` : '' + }${mdLite(m.content)}
`; } else if (m.role === 'assistant') { if (m.content) chat.innerHTML += `
${mdLite(m.content)}
`; diff --git a/server.js b/server.js index ecdd0aa..834b01e 100644 --- a/server.js +++ b/server.js @@ -4,7 +4,7 @@ const crypto = require('crypto'); 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'); @@ -159,10 +159,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 || session.owner_name !== req.user.n) { + 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)) { @@ -204,7 +230,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 – bitte auf ein Modell mit Bilderkennung wechseln (z.B. GLM 5.3 Flash).`, + }); + } + } if (session.status === 'running' || session.status === 'queued') { return res.status(409).json({ error: 'Für diese Session läuft bereits ein Job.' }); } @@ -212,7 +253,7 @@ 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)); } @@ -238,7 +279,7 @@ app.post('/api/sessions/:id/messages', requireAuth, (req, res) => { }; await runResearchJob( - { apiKey: config.apiKey, model: config.model, thinking: config.thinking, baseUrl: config.baseUrl }, + config, db.getSession(session.id), text, emit, @@ -338,7 +379,18 @@ function sendPush(session, result) { // Statische Dateien & Dashboard-Dateien // --------------------------------------------------------------------------- -app.use('/dashboards', express.static(path.join(__dirname, 'dashboards'), { maxAge: '1h' })); +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'))); @@ -361,7 +413,10 @@ app.get('/', (req, res) => { // --------------------------------------------------------------------------- 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.openrouterApiKey) { + console.error('WARNUNG: OPENROUTER_API_KEY fehlt in .env – OpenRouter-Modelle (z.B. GLM 5.3 Flash) werden nicht funktionieren.'); } if (!config.appPassword) { console.error('WARNUNG: APP_PASSWORD fehlt in .env – Login ohne Passwort-Check.');