diff --git a/.env.example b/.env.example index d9bc307..de5746a 100644 --- a/.env.example +++ b/.env.example @@ -2,10 +2,6 @@ # 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 @@ -14,17 +10,14 @@ 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 +# 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 je Modell: low | high | max -# GLM-Default 'high': effort 'max' verbrennt fast das ganze Token-Budget mit -# Denken – bei langen Recherchen bleibt dann nichts fürs Dashboard-HTML übrig. +# Reasoning-Tiefe: low | high | max DEEPSEEK_REASONING_EFFORT=max -GLM_REASONING_EFFORT=high # Maximale Antwort-Tokens: nur relevant für eigene Modelle aus der Env (MODEL). -# Die eingebauten Modelle (DeepSeek, GLM) nutzen fest 131072 aus der Registry. +# 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= diff --git a/.gitignore b/.gitignore index 4318c64..1f21c07 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,9 @@ data/ dashboards/*.html dashboards/*.gpx dashboards/*.tour.json +dashboards/*.jpg +dashboards/*.jpeg +dashboards/*.png +dashboards/*.webp +dashboards/*.gif .env diff --git a/README.md b/README.md index a070e73..da60ebf 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/lib/config.js b/lib/config.js index 3856d7a..c13319d 100644 --- a/lib/config.js +++ b/lib/config.js @@ -51,9 +51,8 @@ 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', + 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', @@ -69,36 +68,22 @@ const config = { const envOr = (key, def) => process.env[key] || env[key] || def; // --------------------------------------------------------------------------- -// Modell-Registry: DeepSeek direkt + OpenRouter-Modelle (z.B. GLM 5.3 Flash) +// Modell-Registry: DeepSeek V4.1 Flash (Text + Vision/Bilderkennung) // --------------------------------------------------------------------------- const MODELS = [ { - id: 'deepseek-v4-flash', - label: 'DeepSeek V4 Flash', + id: 'deepseek-flash', + label: 'DeepSeek V4.1 Flash', provider: 'deepseek', - vision: false, - // Max. Output laut Doku: 384K – DeepSeek hat 3x so viel Headroom wie GLM + 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', - 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, - // Max. Completion laut OpenRouter: 131072 - maxTokens: 131072, - // Research-Runden: 'high' (effort 'max' verbrennt das ganze Budget mit - // Denken; für lange Recherchen bleibt dann nichts fürs große HTML übrig) - reasoningEffort: () => envOr('GLM_REASONING_EFFORT', 'high'), - // Liefer-Runde des Dashboards: minimales Denken (GLM: Reasoning Pflicht) - deliveryEffort: 'low', - prices: { inputCacheHit: 0.015, inputCacheMiss: 0.075, output: 0.25 }, - costNote: 'Schätzung (OpenRouter, Rabatt bis Sep 2026)', + // Peak-Preise laut Docs (USD pro 1M Tokens; Off-Peak ist halb so teuer) + prices: { inputCacheHit: 0.006, inputCacheMiss: 0.3, output: 1.2 }, }, ]; @@ -114,7 +99,7 @@ function getModels() { vision: false, maxTokens: config.maxTokens, reasoningEffort: () => envOr('DEEPSEEK_REASONING_EFFORT', 'max'), - prices: { inputCacheHit: 0.014, inputCacheMiss: 0.44, output: 1.32 }, + prices: { inputCacheHit: 0.006, inputCacheMiss: 0.3, output: 1.2 }, }, ]; } diff --git a/lib/db.js b/lib/db.js index c624ac9..4387f64 100644 --- a/lib/db.js +++ b/lib/db.js @@ -49,9 +49,8 @@ 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. -// Andere Modelle (z.B. OpenRouter) legen eigene Preise in der Registry an. -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, prices = PRICES) { return ( @@ -86,6 +85,12 @@ function computeCost(u, prices = PRICES) { 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, model, created_at, updated_at) diff --git a/lib/research.js b/lib/research.js index 4d4d16d..457aa7c 100644 --- a/lib/research.js +++ b/lib/research.js @@ -80,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: { @@ -175,7 +175,7 @@ const SCREENSHOT_TOOL = { 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.', + '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: { @@ -204,7 +204,9 @@ 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': @@ -228,7 +230,7 @@ function summarizeTool(toolName, output) { const MAX_ITERATIONS = 40; -// Abbruch, wenn der API-Stream länger hängt (z.B. OpenRouter ohne Keep-Alive) +// 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 @@ -252,10 +254,13 @@ Eine bloße Erklärung oder Beschreibung der Änderungen im Chat ist KEINE Umset 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.`; +## 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: .... 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; } @@ -292,15 +297,9 @@ async function chatCompletion(config, messages, systemPrompt, model, tools, sign stream_options: { include_usage: true }, }; - const isOpenRouter = model.provider === 'openrouter'; - const baseUrl = isOpenRouter - ? 'https://openrouter.ai/api/v1' - : config.baseUrl || 'https://api.deepseek.com'; + const baseUrl = config.baseUrl || 'https://api.deepseek.com'; - if (isOpenRouter) { - // GLM: Reasoning ist Pflicht; Effort über OpenRouter-Param "reasoning" - body.reasoning = { effort: effortOverride || model.reasoningEffort?.() || 'high' }; - } else if (effortOverride === 'none' || config.thinking === 'disabled') { + if (effortOverride === 'none' || config.thinking === 'disabled') { // DeepSeek: Denken pro Request abschaltbar (Liefer-Runde des Dashboards) body.thinking = { type: 'disabled' }; } else { @@ -308,17 +307,10 @@ async function chatCompletion(config, messages, systemPrompt, model, tools, sign body.reasoning_effort = effortOverride || model.reasoningEffort?.() || 'max'; } - 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 headers = { + 'Content-Type': 'application/json', + Authorization: `Bearer ${config.apiKey}`, + }; const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); @@ -363,9 +355,8 @@ async function chatCompletion(config, messages, systemPrompt, model, tools, sign const choice = json.choices?.[0]; if (!choice) return; const d = choice.delta || {}; - // DeepSeek liefert reasoning_content, OpenRouter/GLM reasoning + // DeepSeek liefert den Denk-Text als reasoning_content 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) { @@ -467,7 +458,7 @@ async function runResearchJob(config, session, userMessageText, emit, db, signal }, 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 }) { @@ -492,7 +483,7 @@ async function runResearchJob(config, session, userMessageText, emit, db, signal } // Neuer Weg: nur vorbereiten, HTML kommt in der finalen Antwort pendingDashboard = { title: title || 'Dashboard', filename: filename || null }; - deliveryEffort = model.deliveryEffort || (model.provider === 'openrouter' ? 'low' : 'none'); + 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…` diff --git a/lib/tools.js b/lib/tools.js index c0f17f1..5daaa34 100644 --- a/lib/tools.js +++ b/lib/tools.js @@ -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: -img-. – 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 }; }); } diff --git a/public/share.html b/public/share.html index a2a1511..d13981c 100644 --- a/public/share.html +++ b/public/share.html @@ -28,7 +28,7 @@ diff --git a/server.js b/server.js index d99390f..1edfb40 100644 --- a/server.js +++ b/server.js @@ -269,7 +269,7 @@ app.post('/api/sessions/:id/messages', requireAuth, (req, res) => { 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).`, + error: `Das Modell "${model.label}" kann keine Bilder verarbeiten.`, }); } } @@ -529,9 +529,6 @@ if (staleCount > 0) { if (!config.apiKey) { 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.'); }