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
This commit is contained in:
nico committed 2026-10-05 12:55:31 +02:00
1 parent 12f01b86d6
commit b740ce1316
9 files changed
+127 -79

No files matched your search

+4 -11
View File
@@ -2,10 +2,6 @@
# DeepSeek API Key (https://platform.deepseek.com/api_keys) # DeepSeek API Key (https://platform.deepseek.com/api_keys)
DEEPSEEK_API_KEY=sk-xxxx 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) # Gemeinsames Login-Passwort für die App (hinter NGINX mit IP-Prüfung)
APP_PASSWORD=ein-sicheres-passwort APP_PASSWORD=ein-sicheres-passwort
@@ -14,17 +10,14 @@ APP_PASSWORD=ein-sicheres-passwort
SESSION_SECRET= SESSION_SECRET=
# Port der App (NGINX proxied auf diesen; in Docker per EXPOSE/PORTS) # Port der App (NGINX proxied auf diesen; in Docker per EXPOSE/PORTS)
PORT=3000 PORT=3000
# DeepSeek Modell # DeepSeek Modell (V4.1 Flash, inkl. Vision/Bilderkennung)
MODEL=deepseek-v4-flash MODEL=deepseek-flash
# Thinking-Mode des Modells: enabled | disabled (gilt für DeepSeek) # Thinking-Mode des Modells: enabled | disabled (gilt für DeepSeek)
THINKING=enabled THINKING=enabled
# Reasoning-Tiefe je Modell: low | high | max # Reasoning-Tiefe: 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.
DEEPSEEK_REASONING_EFFORT=max DEEPSEEK_REASONING_EFFORT=max
GLM_REASONING_EFFORT=high
# Maximale Antwort-Tokens: nur relevant für eigene Modelle aus der Env (MODEL). # 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 MAX_TOKENS=64000
# VAPID-Schlüsselpaar für Web Push (wird automatisch generiert) # VAPID-Schlüsselpaar für Web Push (wird automatisch generiert)
VAPID_PUBLIC_KEY= VAPID_PUBLIC_KEY=
+5
View File
@@ -3,4 +3,9 @@ data/
dashboards/*.html dashboards/*.html
dashboards/*.gpx dashboards/*.gpx
dashboards/*.tour.json dashboards/*.tour.json
dashboards/*.jpg
dashboards/*.jpeg
dashboards/*.png
dashboards/*.webp
dashboards/*.gif
.env .env
+4 -3
View File
@@ -1,12 +1,13 @@
# Research Agent – Web-Chat-Oberfläche # 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. Internet, verifiziert alle Links und generiert HTML-Dashboards – ohne dass Opencode lokal laufen muss.
## Funktionen ## Funktionen
- 💬 Chat-Oberfläche mit Live-Fortschritt (SSE): Suchen, Seitenbesuche, Link-Verifizierung in Echtzeit - 💬 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 - 🔎 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) - 🔔 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) - 📱 PWA: installierbar auf Desktop & Handy (Chrome/Android; iOS 16.4+ für Push)
- 👤 Eine gemeinsame Anmeldung (Name + Passwort), Sessions sind dem Namen zugeordnet - 👤 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 | | `DEEPSEEK_API_KEY` | ja | Key von https://platform.deepseek.com/api_keys |
| `APP_PASSWORD` | ja | Gemeinsames Login-Passwort | | `APP_PASSWORD` | ja | Gemeinsames Login-Passwort |
| `PORT` | nein | Standard 3000 | | `PORT` | nein | Standard 3000 |
| `MODEL` | nein | Standard `deepseek-v4-flash` | | `MODEL` | nein | Standard `deepseek-flash` |
| `THINKING` | nein | `enabled` (Standard) oder `disabled` | | `THINKING` | nein | `enabled` (Standard) oder `disabled` |
| `DEEPSEEK_BASE_URL` | nein | Für Tests/Proxies überschreibbar | | `DEEPSEEK_BASE_URL` | nein | Für Tests/Proxies überschreibbar |
| `LOGIN_MAX_ATTEMPTS` | nein | Fehlversuche pro IP vor Sperre (Standard 5) | | `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…“ - 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 - 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) - 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 - Auf SELinux-Systemen (Bazzite/Fedora Atomic) sind die Volume-Mounts mit `:Z` markiert – Docker ignoriert das Flag, dort reicht die Compose-Datei unverändert
+9 -24
View File
@@ -51,9 +51,8 @@ const vapid = ensureVapidPair();
const config = { const config = {
port: Number(process.env.PORT || env.PORT || 3000), port: Number(process.env.PORT || env.PORT || 3000),
apiKey: process.env.DEEPSEEK_API_KEY || env.DEEPSEEK_API_KEY || '', 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 || '', 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', thinking: process.env.THINKING || env.THINKING || 'enabled',
maxTokens: Number(process.env.MAX_TOKENS || env.MAX_TOKENS || 64000), maxTokens: Number(process.env.MAX_TOKENS || env.MAX_TOKENS || 64000),
baseUrl: process.env.DEEPSEEK_BASE_URL || env.DEEPSEEK_BASE_URL || 'https://api.deepseek.com', 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; 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 = [ const MODELS = [
{ {
id: 'deepseek-v4-flash', id: 'deepseek-flash',
label: 'DeepSeek V4 Flash', label: 'DeepSeek V4.1 Flash',
provider: 'deepseek', provider: 'deepseek',
vision: false, vision: true,
// Max. Output laut Doku: 384K – DeepSeek hat 3x so viel Headroom wie GLM // Max. Output laut Doku: 384K
maxTokens: 300000, maxTokens: 300000,
reasoningEffort: () => envOr('DEEPSEEK_REASONING_EFFORT', 'max'), reasoningEffort: () => envOr('DEEPSEEK_REASONING_EFFORT', 'max'),
// Liefer-Runde des Dashboards: Denken komplett aus, Budget gehört dem HTML // Liefer-Runde des Dashboards: Denken komplett aus, Budget gehört dem HTML
deliveryEffort: 'none', deliveryEffort: 'none',
prices: { inputCacheHit: 0.014, inputCacheMiss: 0.44, output: 1.32 }, // Peak-Preise laut Docs (USD pro 1M Tokens; Off-Peak ist halb so teuer)
}, prices: { inputCacheHit: 0.006, inputCacheMiss: 0.3, output: 1.2 },
{
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)',
}, },
]; ];
@@ -114,7 +99,7 @@ function getModels() {
vision: false, vision: false,
maxTokens: config.maxTokens, maxTokens: config.maxTokens,
reasoningEffort: () => envOr('DEEPSEEK_REASONING_EFFORT', 'max'), 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 },
}, },
]; ];
} }
+8 -3
View File
@@ -49,9 +49,8 @@ const now = () => new Date().toISOString();
const uid = () => crypto.randomBytes(12).toString('hex'); const uid = () => crypto.randomBytes(12).toString('hex');
const shareToken = () => crypto.randomBytes(16).toString('hex'); const shareToken = () => crypto.randomBytes(16).toString('hex');
// DeepSeek v4 Flash Preise (USD pro 1M Tokens, Peak) – obere Schätzung. // DeepSeek V4.1 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.006, inputCacheMiss: 0.3, output: 1.2 };
const PRICES = { inputCacheHit: 0.014, inputCacheMiss: 0.44, output: 1.32 };
function computeCost(u, prices = PRICES) { function computeCost(u, prices = PRICES) {
return ( return (
@@ -86,6 +85,12 @@ function computeCost(u, prices = PRICES) {
add('image', '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 = { const stmts = {
createSession: db.prepare( createSession: db.prepare(
`INSERT INTO sessions (id, title, owner_name, share_token, status, model, created_at, updated_at) `INSERT INTO sessions (id, title, owner_name, share_token, status, model, created_at, updated_at)
+19 -28
View File
@@ -80,7 +80,7 @@ const TOOL_DEFINITIONS = [
function: { function: {
name: 'browse', name: 'browse',
description: 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: { parameters: {
type: 'object', type: 'object',
properties: { properties: {
@@ -175,7 +175,7 @@ const SCREENSHOT_TOOL = {
function: { function: {
name: 'screenshot', name: 'screenshot',
description: 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: { parameters: {
type: 'object', type: 'object',
properties: { properties: {
@@ -204,7 +204,9 @@ function summarizeTool(toolName, output) {
(data.results || []).slice(0, 5).map((r) => `${r.title} (${r.url})`).join(', ') || (data.note || '') (data.results || []).slice(0, 5).map((r) => `${r.title} (${r.url})`).join(', ') || (data.note || '')
}`; }`;
case 'browse': 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': case 'verify':
return `Link ${data.ok ? 'VERIFIZIERT ✓' : 'FEHLGESCHLAGEN ✗'}: ${data.url}${data.reason ? ' – ' + data.reason : ''}`; return `Link ${data.ok ? 'VERIFIZIERT ✓' : 'FEHLGESCHLAGEN ✗'}: ${data.url}${data.reason ? ' – ' + data.reason : ''}`;
case 'save_dashboard': case 'save_dashboard':
@@ -228,7 +230,7 @@ function summarizeTool(toolName, output) {
const MAX_ITERATIONS = 40; 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; const REQUEST_TIMEOUT_MS = 10 * 60 * 1000;
// Leere Modell-Antworten (kein Inhalt, keine Tool-Calls) einmal wiederholen // 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) { if (model.vision) {
prompt += ` prompt += `
## Bilderkennung (Vision) ## Bilderkennung & visuelle Produktsuche (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. - 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.
- Das hochgeladene Bild liegt dir als Data-URL im Verlauf vor – du kannst es bei Bedarf direkt ins Dashboard einbetten. - 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.
- 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.`; - 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; return prompt;
} }
@@ -292,15 +297,9 @@ async function chatCompletion(config, messages, systemPrompt, model, tools, sign
stream_options: { include_usage: true }, stream_options: { include_usage: true },
}; };
const isOpenRouter = model.provider === 'openrouter'; const baseUrl = config.baseUrl || 'https://api.deepseek.com';
const baseUrl = isOpenRouter
? 'https://openrouter.ai/api/v1'
: config.baseUrl || 'https://api.deepseek.com';
if (isOpenRouter) { if (effortOverride === 'none' || config.thinking === 'disabled') {
// GLM: Reasoning ist Pflicht; Effort über OpenRouter-Param "reasoning"
body.reasoning = { effort: effortOverride || model.reasoningEffort?.() || 'high' };
} else if (effortOverride === 'none' || config.thinking === 'disabled') {
// DeepSeek: Denken pro Request abschaltbar (Liefer-Runde des Dashboards) // DeepSeek: Denken pro Request abschaltbar (Liefer-Runde des Dashboards)
body.thinking = { type: 'disabled' }; body.thinking = { type: 'disabled' };
} else { } else {
@@ -308,14 +307,7 @@ async function chatCompletion(config, messages, systemPrompt, model, tools, sign
body.reasoning_effort = effortOverride || model.reasoningEffort?.() || 'max'; body.reasoning_effort = effortOverride || model.reasoningEffort?.() || 'max';
} }
const headers = isOpenRouter const headers = {
? {
'Content-Type': 'application/json',
Authorization: `Bearer ${config.openrouterApiKey}`,
'X-Title': 'Research Chat',
'HTTP-Referer': 'https://github.com/nico/researchchat',
}
: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
Authorization: `Bearer ${config.apiKey}`, Authorization: `Bearer ${config.apiKey}`,
}; };
@@ -363,9 +355,8 @@ async function chatCompletion(config, messages, systemPrompt, model, tools, sign
const choice = json.choices?.[0]; const choice = json.choices?.[0];
if (!choice) return; if (!choice) return;
const d = choice.delta || {}; 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; 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.content) delta.content += d.content;
if (d.tool_calls) { if (d.tool_calls) {
for (const tc of 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 }) { async browse({ url }) {
emit.progress('🌐', `Besuche: ${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); return JSON.stringify(result);
}, },
async screenshot({ url, full_page }) { 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 // Neuer Weg: nur vorbereiten, HTML kommt in der finalen Antwort
pendingDashboard = { title: title || 'Dashboard', filename: filename || null }; pendingDashboard = { title: title || 'Dashboard', filename: filename || null };
deliveryEffort = model.deliveryEffort || (model.provider === 'openrouter' ? 'low' : 'none'); deliveryEffort = model.deliveryEffort || 'none';
emit.progress( 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…` `Dashboard vorbereitet (${(filename || title || 'dashboard').toLowerCase().replace(/[^a-z0-9äöü-]+/g, '-')}.html) – sende jetzt das komplette HTML in deiner finalen Antwort als \`\`\`html\`\`\`-Codeblock…`
+76 -5
View File
@@ -1,5 +1,6 @@
const path = require('path'); const path = require('path');
const fs = require('fs'); const fs = require('fs');
const crypto = require('crypto');
const { chromium } = require('playwright'); const { chromium } = require('playwright');
const DASHBOARD_DIR = path.join(__dirname, '..', 'dashboards'); const DASHBOARD_DIR = path.join(__dirname, '..', 'dashboards');
@@ -92,8 +93,76 @@ async function search(query, log) {
return results; return results;
} }
/** Seite besuchen und lesbaren Text extrahieren. */ const IMAGE_TYPES = { 'image/jpeg': 'jpg', 'image/png': 'png', 'image/webp': 'webp', 'image/gif': 'gif' };
async function browse(url, log) { 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) => { return withPage(url, async (page) => {
const nav = await goto(page, url); const nav = await goto(page, url);
if (!nav.ok) return { nav, error: 'Seite konnte nicht geladen werden' }; 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 data = await page.evaluate(() => {
const title = document.title || ''; const title = document.title || '';
const text = (document.body ? document.body.innerText : '').replace(/\s+\n/g, '\n'); 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}`); const extracted = await page.evaluate(collectImages);
return { nav, title: data.title, text: data.text }; 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 };
}); });
} }
+1 -1
View File
@@ -28,7 +28,7 @@
<div id="content" class="hidden space-y-4"></div> <div id="content" class="hidden space-y-4"></div>
<footer class="mt-8 text-center text-xs text-slate-500"> <footer class="mt-8 text-center text-xs text-slate-500">
<p>Generiert mit dem Research Agent · DeepSeek V4 Flash / GLM 5.3 Flash</p> <p>Generiert mit dem Research Agent · DeepSeek V4.1 Flash</p>
</footer> </footer>
</div> </div>
+1 -4
View File
@@ -269,7 +269,7 @@ app.post('/api/sessions/:id/messages', requireAuth, (req, res) => {
const model = getModel(session.model); const model = getModel(session.model);
if (!model.vision) { if (!model.vision) {
return res.status(400).json({ 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) { if (!config.apiKey) {
console.error('WARNUNG: DEEPSEEK_API_KEY fehlt in .env – DeepSeek-Modelle werden 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) { if (!config.appPassword) {
console.error('WARNUNG: APP_PASSWORD fehlt in .env – Login ohne Passwort-Check.'); console.error('WARNUNG: APP_PASSWORD fehlt in .env – Login ohne Passwort-Check.');
} }