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
+130 -82

No files matched your search

+9 -24
View File
@@ -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 },
},
];
}
+8 -3
View File
@@ -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)
+22 -31
View File
@@ -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,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…`
+76 -5
View File
@@ -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 };
});
}