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:
1 parent
12f01b86d6
commit
b740ce1316
9 files changed
+127
-79
No files matched your search
+4
-11
@@ -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=
|
||||
|
||||
@@ -3,4 +3,9 @@ data/
|
||||
dashboards/*.html
|
||||
dashboards/*.gpx
|
||||
dashboards/*.tour.json
|
||||
dashboards/*.jpg
|
||||
dashboards/*.jpeg
|
||||
dashboards/*.png
|
||||
dashboards/*.webp
|
||||
dashboards/*.gif
|
||||
.env
|
||||
@@ -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
|
||||
+9
-24
@@ -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 },
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
+19
-28
@@ -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: <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;
|
||||
}
|
||||
@@ -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,14 +307,7 @@ 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',
|
||||
}
|
||||
: {
|
||||
const headers = {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${config.apiKey}`,
|
||||
};
|
||||
@@ -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…`
|
||||
|
||||
+76
-5
@@ -1,5 +1,6 @@
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const crypto = require('crypto');
|
||||
const { chromium } = require('playwright');
|
||||
|
||||
const DASHBOARD_DIR = path.join(__dirname, '..', 'dashboards');
|
||||
@@ -92,8 +93,76 @@ async function search(query, log) {
|
||||
return results;
|
||||
}
|
||||
|
||||
/** Seite besuchen und lesbaren Text extrahieren. */
|
||||
async function browse(url, log) {
|
||||
const IMAGE_TYPES = { 'image/jpeg': 'jpg', 'image/png': 'png', 'image/webp': 'webp', 'image/gif': 'gif' };
|
||||
const MAX_IMAGE_BYTES = 4 * 1024 * 1024;
|
||||
const MAX_TOTAL_IMAGE_BYTES = 12 * 1024 * 1024;
|
||||
const MAX_IMAGES = 8;
|
||||
|
||||
/**
|
||||
* Extrahiert Produktbild-URLs (nur absolute http(s), ausreichend groß, dedupliziert).
|
||||
* Läuft im Browser-Kontext der besuchten Seite.
|
||||
*/
|
||||
function collectImages() {
|
||||
const images = [];
|
||||
const seen = new Set();
|
||||
for (const img of document.querySelectorAll('img')) {
|
||||
const src = (img.currentSrc || img.src || '').split('#')[0];
|
||||
if (!/^https?:/i.test(src) || seen.has(src)) continue;
|
||||
const nw = img.naturalWidth || 0;
|
||||
const nh = img.naturalHeight || 0;
|
||||
const rect = img.getBoundingClientRect();
|
||||
const w = nw || rect.width || img.width || 0;
|
||||
const h = nh || rect.height || img.height || 0;
|
||||
if (w < 120 || h < 120) continue;
|
||||
seen.add(src);
|
||||
images.push({ url: src, alt: (img.alt || '').trim().slice(0, 120) });
|
||||
if (images.length >= 12) break;
|
||||
}
|
||||
return images;
|
||||
}
|
||||
|
||||
/**
|
||||
* Lädt extrahierte Bilder lokal in den Dashboards-Ordner, damit das Dashboard
|
||||
* sie unabhängig von Hotlink-Schutz und ablaufenden CDN-URLs einbetten kann.
|
||||
* Dateiname: <sessionId>-img-<hash>.<ext> – wird beim Session-Löschen mit entfernt.
|
||||
*/
|
||||
async function cacheImages(page, images, sessionId, log) {
|
||||
if (!sessionId) return images;
|
||||
fs.mkdirSync(DASHBOARD_DIR, { recursive: true });
|
||||
const existingFiles = new Set(fs.readdirSync(DASHBOARD_DIR));
|
||||
const out = [];
|
||||
let total = 0;
|
||||
for (const img of images) {
|
||||
if (out.length >= MAX_IMAGES || total >= MAX_TOTAL_IMAGE_BYTES) break;
|
||||
try {
|
||||
const hash = crypto.createHash('sha1').update(img.url).digest('hex').slice(0, 12);
|
||||
const known = [...existingFiles].find((f) => f.startsWith(`${sessionId}-img-${hash}.`));
|
||||
if (known) {
|
||||
out.push({ url: `/dashboards/${known}`, alt: img.alt });
|
||||
continue;
|
||||
}
|
||||
const resp = await page.request.get(img.url, {
|
||||
timeout: 15000,
|
||||
headers: { Referer: page.url() },
|
||||
});
|
||||
const type = String(resp.headers()['content-type'] || '').split(';')[0].trim().toLowerCase();
|
||||
const ext = IMAGE_TYPES[type];
|
||||
if (!ext || !resp.ok()) continue;
|
||||
const buf = await resp.body();
|
||||
if (buf.length < 512 || buf.length > MAX_IMAGE_BYTES || total + buf.length > MAX_TOTAL_IMAGE_BYTES) continue;
|
||||
const file = `${sessionId}-img-${hash}.${ext}`;
|
||||
fs.writeFileSync(path.join(DASHBOARD_DIR, file), buf);
|
||||
existingFiles.add(file);
|
||||
total += buf.length;
|
||||
out.push({ url: `/dashboards/${file}`, alt: img.alt });
|
||||
} catch {}
|
||||
}
|
||||
log?.(`Produktbilder lokal gespeichert: ${out.length}`);
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Seite besuchen und lesbaren Text + Produktbilder (lokal gespeichert) extrahieren. */
|
||||
async function browse(url, log, opts = {}) {
|
||||
return withPage(url, async (page) => {
|
||||
const nav = await goto(page, url);
|
||||
if (!nav.ok) return { nav, error: 'Seite konnte nicht geladen werden' };
|
||||
@@ -101,10 +170,12 @@ async function browse(url, log) {
|
||||
const data = await page.evaluate(() => {
|
||||
const title = document.title || '';
|
||||
const text = (document.body ? document.body.innerText : '').replace(/\s+\n/g, '\n');
|
||||
return { title, text: text.slice(0, 15000) };
|
||||
return { title, text: text.slice(0, 6000) };
|
||||
});
|
||||
log?.(`Besuche ${url} -> ${data.title}`);
|
||||
return { nav, title: data.title, text: data.text };
|
||||
const extracted = await page.evaluate(collectImages);
|
||||
const images = await cacheImages(page, extracted, opts.sessionId, log);
|
||||
log?.(`Besuche ${url} -> ${data.title} (${images.length} Bilder lokal)`);
|
||||
return { nav, title: data.title, images, text: data.text };
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -28,7 +28,7 @@
|
||||
<div id="content" class="hidden space-y-4"></div>
|
||||
|
||||
<footer class="mt-8 text-center text-xs text-slate-500">
|
||||
<p>Generiert mit dem Research Agent · DeepSeek V4 Flash / GLM 5.3 Flash</p>
|
||||
<p>Generiert mit dem Research Agent · DeepSeek V4.1 Flash</p>
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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.');
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user