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 (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=
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
@@ -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 },
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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.');
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in new issue
Block a user