diff --git a/.env.example b/.env.example
index 18775cf..82c7266 100644
--- a/.env.example
+++ b/.env.example
@@ -2,6 +2,10 @@
# 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
@@ -12,11 +16,19 @@ SESSION_SECRET=
PORT=3000
# DeepSeek Modell
MODEL=deepseek-v4-flash
-# Thinking-Mode des Modells: enabled | disabled
+# Thinking-Mode des Modells: enabled | disabled (gilt für DeepSeek)
THINKING=enabled
+# Reasoning-Tiefe je Modell: low | high | max (GLM: Pflicht, max Default)
+DEEPSEEK_REASONING_EFFORT=max
+GLM_REASONING_EFFORT=max
+# Maximale Antwort-Tokens pro API-Aufruf (Dashboards sind große HTML-Dateien)
+MAX_TOKENS=64000
# VAPID-Schlüsselpaar für Web Push (wird automatisch generiert)
VAPID_PUBLIC_KEY=
VAPID_PRIVATE_KEY=
# Login-Schutz: Fehlversuche pro IP bevor gesperrt wird, und Sperrdauer
LOGIN_MAX_ATTEMPTS=5
LOGIN_LOCK_MINUTES=5
+# Tour-Feature: E-Mail für Nominatim (Fair-Use, optional) und OSRM-Server
+NOMINATIM_EMAIL=
+OSRM_URL=https://router.project-osrm.org
diff --git a/.gitignore b/.gitignore
index ac8be86..4318c64 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,4 +1,6 @@
node_modules/
data/
dashboards/*.html
+dashboards/*.gpx
+dashboards/*.tour.json
.env
diff --git a/lib/config.js b/lib/config.js
index a89a135..4292ed2 100644
--- a/lib/config.js
+++ b/lib/config.js
@@ -51,15 +51,70 @@ 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',
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',
loginMaxAttempts: Number(process.env.LOGIN_MAX_ATTEMPTS || env.LOGIN_MAX_ATTEMPTS || 5),
loginLockMinutes: Number(process.env.LOGIN_LOCK_MINUTES || env.LOGIN_LOCK_MINUTES || 5),
+ nominatimEmail: process.env.NOMINATIM_EMAIL || env.NOMINATIM_EMAIL || '',
+ osrmUrl: process.env.OSRM_URL || env.OSRM_URL || 'https://router.project-osrm.org',
sessionSecret: ensure('SESSION_SECRET', () => crypto.randomBytes(32).toString('hex')),
vapidPublicKey: vapid.publicKey,
vapidPrivateKey: vapid.privateKey,
};
-module.exports = { config };
+const envOr = (key, def) => process.env[key] || env[key] || def;
+
+// ---------------------------------------------------------------------------
+// Modell-Registry: DeepSeek direkt + OpenRouter-Modelle (z.B. GLM 5.3 Flash)
+// ---------------------------------------------------------------------------
+
+const MODELS = [
+ {
+ id: 'deepseek-v4-flash',
+ label: 'DeepSeek V4 Flash',
+ provider: 'deepseek',
+ vision: false,
+ maxTokens: 64000,
+ reasoningEffort: () => envOr('DEEPSEEK_REASONING_EFFORT', 'max'),
+ 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,
+ maxTokens: 64000,
+ reasoningEffort: () => envOr('GLM_REASONING_EFFORT', 'max'),
+ prices: { inputCacheHit: 0.015, inputCacheMiss: 0.075, output: 0.25 },
+ costNote: 'Schätzung (OpenRouter, Rabatt bis Sep 2026)',
+ },
+];
+
+/** Registry inkl. benutzerdefiniertem Modell aus der Env (MODEL), falls nicht bekannt. */
+function getModels() {
+ if (MODELS.some((m) => m.id === config.model)) return MODELS;
+ return [
+ ...MODELS,
+ {
+ id: config.model,
+ label: config.model,
+ provider: 'deepseek',
+ vision: false,
+ maxTokens: config.maxTokens,
+ reasoningEffort: () => envOr('DEEPSEEK_REASONING_EFFORT', 'max'),
+ prices: { inputCacheHit: 0.014, inputCacheMiss: 0.44, output: 1.32 },
+ },
+ ];
+}
+
+/** Modell per ID auflösen (Fallback: Default-Modell aus Env). */
+function getModel(id) {
+ const models = getModels();
+ return models.find((m) => m.id === id) || models.find((m) => m.id === config.model) || models[0];
+}
+
+module.exports = { config, MODELS, getModels, getModel };
diff --git a/lib/db.js b/lib/db.js
index 7358591..f1925d4 100644
--- a/lib/db.js
+++ b/lib/db.js
@@ -49,14 +49,15 @@ 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
+// 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 };
-function computeCost(u) {
+function computeCost(u, prices = PRICES) {
return (
- ((u.cacheHitTokens || 0) * PRICES.inputCacheHit +
- (u.cacheMissTokens || 0) * PRICES.inputCacheMiss +
- (u.completionTokens || 0) * PRICES.output) /
+ ((u.cacheHitTokens || 0) * prices.inputCacheHit +
+ (u.cacheMissTokens || 0) * prices.inputCacheMiss +
+ (u.completionTokens || 0) * prices.output) /
1e6
);
}
@@ -72,12 +73,23 @@ function computeCost(u) {
add('cache_hit_tokens', 'INTEGER DEFAULT 0');
add('cache_miss_tokens', 'INTEGER DEFAULT 0');
add('cost_estimate', 'REAL DEFAULT 0');
+ add('model', 'TEXT');
+}
+
+// Migration: Messages-Spalten ergänzen (Reasoning-Roundtrip + Bild-Upload)
+{
+ const cols = db.prepare(`PRAGMA table_info(messages)`).all().map((c) => c.name);
+ const add = (name, def) => {
+ if (!cols.includes(name)) db.exec(`ALTER TABLE messages ADD COLUMN ${name} ${def}`);
+ };
+ add('reasoning', 'TEXT');
+ add('image', 'TEXT');
}
const stmts = {
createSession: db.prepare(
- `INSERT INTO sessions (id, title, owner_name, share_token, status, created_at, updated_at)
- VALUES (?, ?, ?, ?, 'idle', ?, ?)`
+ `INSERT INTO sessions (id, title, owner_name, share_token, status, model, created_at, updated_at)
+ VALUES (?, ?, ?, ?, 'idle', ?, ?, ?)`
),
getSession: db.prepare(`SELECT * FROM sessions WHERE id = ?`),
getSessionByShare: db.prepare(`SELECT * FROM sessions WHERE share_token = ?`),
@@ -90,6 +102,7 @@ const stmts = {
),
setSessionTitle: db.prepare(`UPDATE sessions SET title = ?, updated_at = ? WHERE id = ?`),
setSessionDashboard: db.prepare(`UPDATE sessions SET dashboard = ?, updated_at = ? WHERE id = ?`),
+ setSessionModel: db.prepare(`UPDATE sessions SET model = ?, updated_at = ? WHERE id = ?`),
addUsage: db.prepare(
`UPDATE sessions SET prompt_tokens = prompt_tokens + ?, completion_tokens = completion_tokens + ?,
cache_hit_tokens = cache_hit_tokens + ?, cache_miss_tokens = cache_miss_tokens + ?,
@@ -98,8 +111,8 @@ const stmts = {
deleteSession: db.prepare(`DELETE FROM sessions WHERE id = ?`),
insertMessage: db.prepare(
- `INSERT INTO messages (session_id, role, content, tool_name, tool_call_id, tool_calls, created_at)
- VALUES (?, ?, ?, ?, ?, ?, ?)`
+ `INSERT INTO messages (session_id, role, content, tool_name, tool_call_id, tool_calls, reasoning, image, created_at)
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
),
listMessages: db.prepare(`SELECT * FROM messages WHERE session_id = ? ORDER BY id ASC`),
lastMessage: db.prepare(`SELECT * FROM messages WHERE session_id = ? ORDER BY id DESC LIMIT 1`),
@@ -113,13 +126,13 @@ const stmts = {
listPushSubs: db.prepare(`SELECT * FROM push_subscriptions`),
};
-function createSession(ownerName, title = 'Neue Recherche') {
+function createSession(ownerName, title = 'Neue Recherche', model = null) {
const id = uid();
- stmts.createSession.run(id, title, ownerName, shareToken(), now(), now());
+ stmts.createSession.run(id, title, ownerName, shareToken(), model, now(), now());
return stmts.getSession.get(id);
}
-function createMessage(sessionId, { role, content, toolName, toolCallId, toolCalls }) {
+function createMessage(sessionId, { role, content, toolName, toolCallId, toolCalls, reasoning, image }) {
stmts.insertMessage.run(
sessionId,
role,
@@ -127,6 +140,8 @@ function createMessage(sessionId, { role, content, toolName, toolCallId, toolCal
toolName ?? null,
toolCallId ?? null,
toolCalls ? JSON.stringify(toolCalls) : null,
+ reasoning ?? null,
+ image ?? null,
now()
);
return stmts.lastMessage.get(sessionId);
@@ -139,23 +154,83 @@ function getSessionMessages(sessionId) {
}));
}
-/** Baut aus dem Chat-Verlauf das Messages-Array für die DeepSeek-API. */
-function toApiMessages(sessionId) {
+const API_HISTORY_MAX = 60;
+
+/**
+ * Bereinigt den Verlauf für die API: entfernt unvollständige Tool-Gruppen
+ * (assistant-Tool-Call ohne passende tool-Antwort, z.B. nach einem abgebrochenen
+ * Job) und kürzt auf die letzten API_HISTORY_MAX Nachrichten, ohne jemals mitten
+ * in einer Tool-Gruppe zu schneiden. Verwaiste tool-Nachrichten lehnt die
+ * DeepSeek-API sonst mit 400 ab ("Messages with role 'tool' must be a response
+ * to a preceding message with 'tool_calls'").
+ */
+function sanitizeHistory(out, max = API_HISTORY_MAX) {
+ const resolved = new Set();
+ const withCalls = [];
+ const owner = new Array(out.length).fill(-1);
+ let cur = -1;
+ for (let i = 0; i < out.length; i++) {
+ const m = out[i];
+ if (m.role === 'assistant' && m.tool_calls) {
+ withCalls.push(i);
+ cur = i;
+ } else if (m.role === 'tool') {
+ owner[i] = cur;
+ if (m.tool_call_id) resolved.add(m.tool_call_id);
+ }
+ }
+
+ const incomplete = new Set(
+ withCalls.filter((i) => (out[i].tool_calls || []).some((tc) => !resolved.has(tc.id)))
+ );
+
+ const kept = [];
+ for (let i = 0; i < out.length; i++) {
+ if (incomplete.has(i)) continue;
+ if (out[i].role === 'tool' && incomplete.has(owner[i])) continue;
+ kept.push(out[i]);
+ }
+
+ if (kept.length <= max) return kept;
+ let start = kept.length - max;
+ while (start < kept.length && kept[start].role === 'tool') start++;
+ return kept.slice(start);
+}
+
+/**
+ * Baut aus dem Chat-Verlauf das Messages-Array für die Modell-API.
+ * @param {object} opts { provider, vision } – steuert Bild-Content (Vision-Modelle)
+ * und den reasoning_content-Roundtrip (Pflicht bei DeepSeek mit tools).
+ */
+function toApiMessages(sessionId, opts = {}) {
+ const { provider = 'deepseek', vision = false } = opts;
const msgs = getSessionMessages(sessionId);
const out = [];
for (const m of msgs) {
- if (m.role === 'user') out.push({ role: 'user', content: m.content });
- else if (m.role === 'assistant') {
+ if (m.role === 'user') {
+ const msg = { role: 'user' };
+ if (m.image && vision) {
+ msg.content = [
+ { type: 'text', text: m.content || '' },
+ { type: 'image_url', image_url: { url: m.image } },
+ ];
+ } else {
+ msg.content = m.content;
+ }
+ out.push(msg);
+ } else if (m.role === 'assistant') {
const msg = { role: 'assistant' };
if (m.tool_calls) msg.tool_calls = m.tool_calls;
if (m.content) msg.content = m.content;
else if (!m.tool_calls) msg.content = '';
+ // DeepSeek verlangt bei tools-Requests den Roundtrip des Reasoning-Texts
+ if (provider === 'deepseek' && m.reasoning) msg.reasoning_content = m.reasoning;
out.push(msg);
} else if (m.role === 'tool') {
out.push({ role: 'tool', tool_call_id: m.tool_call_id, content: m.content ?? '' });
}
}
- return out.slice(-60);
+ return sanitizeHistory(out);
}
module.exports = {
@@ -168,13 +243,14 @@ module.exports = {
setSessionStatus: (id, status) => stmts.setSessionStatus.run(status, now(), id),
setSessionTitle: (id, title) => stmts.setSessionTitle.run(title, now(), id),
setSessionDashboard: (id, dashboard) => stmts.setSessionDashboard.run(dashboard, now(), id),
- setSessionUsage: (id, u) =>
+ setSessionModel: (id, model) => stmts.setSessionModel.run(model, now(), id),
+ setSessionUsage: (id, u, prices) =>
stmts.addUsage.run(
u.promptTokens || 0,
u.completionTokens || 0,
u.cacheHitTokens || 0,
u.cacheMissTokens || 0,
- computeCost(u),
+ computeCost(u, prices),
id
),
computeCost,
diff --git a/lib/research.js b/lib/research.js
index 33120c3..5695acd 100644
--- a/lib/research.js
+++ b/lib/research.js
@@ -1,4 +1,5 @@
const tools = require('./tools');
+const { getModel } = require('./config');
const SYSTEM_PROMPT = `Du bist ein professioneller Web-Researcher und Wissensberater. Deine Aufgabe: Nimm eine Recherche-Frage an, recherchiere gründlich mit den bereitgestellten Tools, verifiziere ALLE Links und erstelle ein schönes HTML-Dashboard. Du bist KEIN Link-Sammler, sondern Analyst, Berater und Horizonterweiterer.
@@ -18,6 +19,29 @@ const SYSTEM_PROMPT = `Du bist ein professioneller Web-Researcher und Wissensber
6. ANALYSIERE: Schreibe eine ausführliche Einleitung mit 6 Absätzen: (1) Überblick, (2) Horizonterweiterung - was du außerdem herausgefunden hast, (3) Einschätzung der Optionen mit Vor-/Nachteilen, (4) konkrete Empfehlung, (5) Fahrplan / erste Schritte, (6) Fallstricke und Hinweise.
7. GENERIERE das Dashboard mit dem save_dashboard-Tool (Detailstruktur unten) und fasse dann in deiner finalen Antwort das Wichtigste zusammen.
+## Tour-Planung (PFLICHT bei Tour-Anfragen!)
+Erkennst du den Wunsch nach einer Route/Tour/Städtebesichtigung (z.B. "plane eine Tour", "Route durch", "Städtebummel", "was sollte ich in X sehen"), gilt:
+- **Du MUSST die Tools geocode und save_tour verwenden.** Erfinde NIEMALS Koordinaten, Distanzen oder Gehzeiten selbst!
+- Ablauf: (1) Orte wie gewohnt recherchieren (verifizierte Quellen), (2) sinnvolle Besuchsreihenfolge festlegen, (3) JEDEN Stopp mit geocode geokodieren, (4) save_tour mit allen Stopps aufrufen (Name, lat, lon, Beschreibung), (5) Distanz/Gehzeit AUS DEM TOOL-ERGEBNIS übernehmen, (6) Karten-Sektion ins Dashboard bauen, (7) in der finalen Antwort GPX-Download und Google-Maps-Link nennen.
+
+### "🗺️ Tour-Karte"-Sektion im Dashboard (nur wenn save_tour gelaufen ist)
+- Leaflet via CDN einbinden: und (zusätzlich zu Tailwind CDN)
+- Karten-Div mit fester Höhe (z.B. style="height: 450px")
+- Tiles: KEIN API-KEY NÖTIG! Nutze AUSSCHLIESSLICH exakt diese CARTO-Konfiguration (kostenlos, ohne Key, kein Referer-Zwang):
+ L.tileLayer('https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png', {
+ subdomains: 'abcd',
+ maxZoom: 20,
+ attribution: '© OpenStreetMap © CARTO'
+ }).addTo(map);
+- VERBOTEN sind Anbieter mit API-Key (Mapbox, MapTiler, Thunderforest, Google Maps, Here) und tile.openstreetmap.org – CARTO ist die einzige erlaubte Kartenquelle. Falls du irgendwo einen "API key" sehen solltest, ist es falsch!
+- Die Stopps (mit lat/lon) INLINE als JavaScript-Objekt ins HTML schreiben
+- Die Routen-Geometrie per fetch() aus der tour.json laden (URL hast du vom save_tour-Ergebnis). Fallback, wenn fetch fehlschlägt: Polylinie direkt durch die Stopps zeichnen
+- Nummerierte Marker (CircleMarker mit Zahl), Polylinie (blau), map.fitBounds(...) über alle Punkte
+- Buttons über der Karte: "📍 Route in Google Maps öffnen" (googleMapsUrl aus dem save_tour-Ergebnis, target=_blank) und "🗺️ GPX für OsmAnd herunterladen" (gpxUrl, mit download-Attribut)
+- Unter der Karte die Stopp-Liste: Nummer, Name, Beschreibung, "Hier hin navigieren"-Link (navUrl pro Stopp)
+- Header-Zeile mit Distanz (km) und Gehzeit (min), wenn vorhanden
+- Hinweis: "GPX in OsmAnd importieren: Datei herunterladen → Menü → Meine Orte → Importieren"
+
## Dashboard-Struktur (HTML, komplette Standalone-Datei)
- , im Head, responsives Design mit Dark-Mode-Unterstützung (dark: Klassen)
- Header: Titel + Datum + Anzahl verifizierter Links
@@ -28,6 +52,7 @@ const SYSTEM_PROMPT = `Du bist ein professioneller Web-Researcher und Wissensber
## Regeln
- JEDER Link im Dashboard MUSS mit verify() verifiziert sein - füge bei den Karten einen "✓ Verifiziert"-Badge hinzu
+- Erfinde NIEMALS Koordinaten, Distanzen oder Gehzeiten – bei Touren kommen die ausschließlich aus geocode() und save_tour()
- Ziele auf 8-15 verifizierte Ressourcen pro Thema
- Bei Login-Walls: old.reddit.com statt reddit.com, nitter.net statt twitter.com; wenn eine Seite blockiert, notiere das und nimm einen anderen Link
- Die finale Antwort ist eine deutsche Zusammenfassung (max. 300 Wörter): wichtigste Erkenntnisse + Link zum Dashboard. Details gehören ins Dashboard, nicht in die Antwort.
@@ -97,8 +122,74 @@ const TOOL_DEFINITIONS = [
},
},
},
+ {
+ type: 'function',
+ function: {
+ name: 'geocode',
+ description:
+ 'Wandelt einen Ortsnamen in Koordinaten um (Nominatim/OpenStreetMap, kostenlos). Nutze das für jeden Stopp einer Tour, z.B. "Brandenburger Tor Berlin". Liefert lat/lon. Maximal 1 Request pro Sekunde.',
+ parameters: {
+ type: 'object',
+ properties: {
+ query: { type: 'string', description: 'Ortsangabe, z.B. "Brandenburger Tor Berlin" oder bereits Koordinaten "52.5163, 13.3777"' },
+ },
+ required: ['query'],
+ },
+ },
+ },
+ {
+ type: 'function',
+ function: {
+ name: 'save_tour',
+ description:
+ 'Erstellt aus einer Liste von Stopps (Name + Koordinaten + Beschreibung) eine komplette Tour: GPX-Datei für OsmAnd, tour.json für die Karte im Dashboard, Google-Maps-Routen-URL mit allen Stopps und Fuß-Navigation pro Stopp. Die Route wird über OSRM als echte Geh-Route berechnet (Fallback: Luftlinie). IMMER vorher jeden Stopp mit geocode() geokodieren!',
+ parameters: {
+ type: 'object',
+ properties: {
+ title: { type: 'string', description: 'Titel der Tour, z.B. "Tagestour Prag"' },
+ filename: { type: 'string', description: 'Dateiname ohne Endung, z.B. "prag-tagestour"' },
+ stops: {
+ type: 'array',
+ description: 'Stopps in Besuchsreihenfolge (Start bis Ende)',
+ items: {
+ type: 'object',
+ properties: {
+ name: { type: 'string', description: 'Name des Ortes' },
+ lat: { type: 'number', description: 'Breitengrad' },
+ lon: { type: 'number', description: 'Längengrad' },
+ description: { type: 'string', description: 'Kurze Beschreibung (2-3 Sätze)' },
+ },
+ required: ['name', 'lat', 'lon'],
+ },
+ },
+ },
+ required: ['title', 'filename', 'stops'],
+ },
+ },
+ },
];
+/** Nur für Vision-Modelle: Seite fotografieren und visuell beurteilen. */
+const SCREENSHOT_TOOL = {
+ type: 'function',
+ 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.',
+ parameters: {
+ type: 'object',
+ properties: {
+ url: { type: 'string', description: 'Die exakte URL der Seite' },
+ full_page: {
+ type: 'boolean',
+ description: 'Volle Seitenlänge erfassen (Standard: nur sichtbarer Bereich). Bei langen Seiten zu groß – dort besser weglassen.',
+ },
+ },
+ required: ['url'],
+ },
+ },
+};
+
/** Menschlich lesbare Zusammenfassung eines Tool-Ergebnisses für den Chat-Verlauf. */
function summarizeTool(toolName, output) {
let data;
@@ -118,6 +209,18 @@ function summarizeTool(toolName, output) {
return `Link ${data.ok ? 'VERIFIZIERT ✓' : 'FEHLGESCHLAGEN ✗'}: ${data.url}${data.reason ? ' – ' + data.reason : ''}`;
case 'save_dashboard':
return `Dashboard gespeichert: ${data.url || ''}`;
+ case 'geocode':
+ return data.error
+ ? `Geocoding FEHLGESCHLAGEN: ${data.error}`
+ : `Geocodiert: ${data.name} -> ${data.lat}, ${data.lon}`;
+ case 'save_tour':
+ return data.error
+ ? `Tour-Fehler: ${data.error}`
+ : `Tour gespeichert: ${data.title || ''} (${data.stops?.length || 0} Stopps, ${data.distanceKm} km, ${data.durationMin} min). GPX: ${data.gpxUrl || ''}`;
+ case 'screenshot':
+ return data.error
+ ? `Screenshot FEHLGESCHLAGEN: ${data.error}`
+ : `Screenshot erstellt: ${data.title || data.url || ''} (Bild liegt im Kontext des Modells, nicht in der History)`;
default:
return String(output).slice(0, 1000);
}
@@ -125,32 +228,80 @@ function summarizeTool(toolName, output) {
const MAX_ITERATIONS = 40;
-async function chatCompletion(config, messages) {
+/** Baut den System-Prompt inkl. aktuellem Dashboard-Stand und Vision-Hinweisen. */
+function buildSystemPrompt(session, model) {
+ let prompt = SYSTEM_PROMPT;
+ if (session.dashboard) {
+ const file = session.dashboard.split('/').pop().replace(/\.html$/, '');
+ prompt += `
+
+## Aktueller Stand dieser Session
+Für diese Session existiert bereits ein Dashboard: ${session.dashboard}
+Wenn der Nutzer Änderungen, Ergänzungen oder Erweiterungen wünscht, MUSST du das save_dashboard-Tool aufrufen und das KOMPLETTE aktualisierte HTML mit EXAKT diesem Dateinamen übergeben: "${file}" (ohne .html).
+Eine bloße Erklärung oder Beschreibung der Änderungen im Chat ist KEINE Umsetzung – erst wenn save_dashboard erfolgreich gelaufen ist, fasst du die Änderungen im Chat zusammen.`;
+ }
+ 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.`;
+ }
+ return prompt;
+}
+
+/** Erkennt Änderungswünsche am Dashboard in der Nutzernachricht. */
+function isChangeRequest(text) {
+ return /dashboard|anpassen|anpass|ändern|änder|ergänz|erweiter|aktualisier|update|umbau|hinzufüg|hinzufueg|modifizier|vervollständig|vervollstaendig/i.test(
+ text
+ );
+}
+
+async function chatCompletion(config, messages, systemPrompt, model, tools) {
const body = {
- model: config.model,
- messages,
- tools: TOOL_DEFINITIONS,
+ model: model.id,
+ messages: [{ role: 'system', content: systemPrompt }, ...messages],
+ tools,
stream: true,
- max_tokens: 32000,
+ max_tokens: model.maxTokens || config.maxTokens || 64000,
stream_options: { include_usage: true },
};
- if (config.thinking !== 'disabled') {
+
+ const isOpenRouter = model.provider === 'openrouter';
+ const baseUrl = isOpenRouter
+ ? 'https://openrouter.ai/api/v1'
+ : config.baseUrl || 'https://api.deepseek.com';
+
+ if (isOpenRouter) {
+ // GLM: Reasoning ist Pflicht; Effort über OpenRouter-Param "reasoning"
+ body.reasoning = { effort: model.reasoningEffort?.() || 'max' };
+ } else if (config.thinking !== 'disabled') {
body.thinking = { type: 'enabled' };
- body.reasoning_effort = 'medium';
+ body.reasoning_effort = model.reasoningEffort?.() || 'high';
}
- const resp = await fetch(`${config.baseUrl || 'https://api.deepseek.com'}/chat/completions`, {
+ 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 resp = await fetch(`${baseUrl}/chat/completions`, {
method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- Authorization: `Bearer ${config.apiKey}`,
- },
+ headers,
body: JSON.stringify(body),
});
if (!resp.ok) {
const err = await resp.text().catch(() => '');
- throw new Error(`DeepSeek API Fehler ${resp.status}: ${err.slice(0, 500)}`);
+ throw new Error(`Modell-API Fehler ${resp.status} (${model.id}): ${err.slice(0, 500)}`);
}
const reader = resp.body.getReader();
@@ -173,7 +324,9 @@ async function chatCompletion(config, messages) {
const choice = json.choices?.[0];
if (!choice) return;
const d = choice.delta || {};
+ // DeepSeek liefert reasoning_content, OpenRouter/GLM reasoning
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) {
@@ -224,7 +377,11 @@ async function chatCompletion(config, messages) {
*/
async function runResearchJob(config, session, userMessageText, emit, db) {
let dashboardUrl = session.dashboard || null;
- let dashboardFile = dashboardUrl ? dashboardUrl.split('/').pop().replace('.html', '') : null;
+ let dashboardSaved = false;
+
+ const model = getModel(session.model || config.model);
+ const tools = model.vision ? [...TOOL_DEFINITIONS, SCREENSHOT_TOOL] : TOOL_DEFINITIONS;
+ const prices = model.prices;
const handlers = {
async search({ query }) {
@@ -238,6 +395,10 @@ async function runResearchJob(config, session, userMessageText, emit, db) {
const result = await tools.browse(url, (m) => emit.step(m));
return JSON.stringify(result);
},
+ async screenshot({ url, full_page }) {
+ emit.progress('📷', `Screenshot: ${url}`);
+ return tools.screenshot(url, !!full_page, (m) => emit.step(m));
+ },
async verify({ url }) {
emit.progress('✅', `Verifiziere: ${url}`);
const result = await tools.verify(url, (m) => emit.step(m));
@@ -247,17 +408,27 @@ async function runResearchJob(config, session, userMessageText, emit, db) {
emit.progress('📊', 'Dashboard wird gespeichert…');
const saved = tools.saveDashboard(session.id, filename || title, html, (m) => emit.step(m));
dashboardUrl = saved.url;
- dashboardFile = saved.file.replace('.html', '');
+ dashboardSaved = true;
db.setSessionDashboard(session.id, saved.url);
emit.progress('📊', `Dashboard gespeichert: ${saved.file}`);
return JSON.stringify(saved);
},
+ async geocode({ query }) {
+ emit.progress('📍', `Geocode: ${query}`);
+ const result = await tools.geocode(query, config, (m) => emit.step(m));
+ return JSON.stringify(result);
+ },
+ async save_tour({ title, filename, stops }) {
+ emit.progress('🗺️', `Tour wird erstellt: ${title || ''}`);
+ const result = await tools.saveTour({ stops, title, filename, sessionId: session.id }, config, (m) => emit.step(m));
+ return JSON.stringify(result);
+ },
};
- const apiMessages = db.toApiMessages(session.id);
+ const apiMessages = db.toApiMessages(session.id, { provider: model.provider, vision: model.vision });
const text = userMessageText;
const usage = { promptTokens: 0, completionTokens: 0, cacheHitTokens: 0, cacheMissTokens: 0 };
- emit.progress('🤖', 'Research startet…');
+ emit.progress('🤖', `Research startet mit ${model.label}…`);
const trackUsage = (u) => {
if (!u) return;
@@ -271,17 +442,17 @@ async function runResearchJob(config, session, userMessageText, emit, db) {
};
const finishJob = (finalText, dashboardUrl) => {
- db.setSessionUsage(session.id, usage);
+ db.setSessionUsage(session.id, usage, prices);
db.setSessionStatus(session.id, 'done');
if (dashboardUrl) db.setSessionDashboard(session.id, dashboardUrl);
- const costEstimate = db.computeCost(usage);
- emit.done({ text: finalText, dashboardUrl, usage, costEstimate });
- return { text: finalText, dashboardUrl, usage, costEstimate };
+ const costEstimate = db.computeCost(usage, prices);
+ emit.done({ text: finalText, dashboardUrl, usage, costEstimate, model: model.id });
+ return { text: finalText, dashboardUrl, usage, costEstimate, model: model.id };
};
for (let iter = 0; iter < MAX_ITERATIONS; iter++) {
emit.progress('🤖', iter === 0 ? 'Analysiere Anfrage und plane Suche…' : 'Weitere Analyse…');
- const result = await chatCompletion(config, apiMessages);
+ const result = await chatCompletion(config, apiMessages, buildSystemPrompt(session, model), model, tools);
trackUsage(result.usage);
if (result.reasoning) emit.thinking(result.reasoning);
@@ -296,7 +467,14 @@ async function runResearchJob(config, session, userMessageText, emit, db) {
function: { name: tc.function.name, arguments: tc.function.arguments },
})),
};
- db.createMessage(session.id, { role: 'assistant', content: assistantMsg.content, toolCalls: assistantMsg.tool_calls });
+ // DeepSeek: Reasoning-Text muss bei tools-Requests mit zurückgegeben werden
+ if (model.provider === 'deepseek' && result.reasoning) assistantMsg.reasoning_content = result.reasoning;
+ db.createMessage(session.id, {
+ role: 'assistant',
+ content: assistantMsg.content,
+ toolCalls: assistantMsg.tool_calls,
+ reasoning: result.reasoning || null,
+ });
apiMessages.push(assistantMsg);
for (const tc of result.toolCalls) {
@@ -312,19 +490,42 @@ async function runResearchJob(config, session, userMessageText, emit, db) {
output = JSON.stringify({ error: `Tool-Fehler: ${e.message.slice(0, 300)}` });
}
}
+ const outStr = typeof output === 'string' ? output : JSON.stringify(output);
db.createMessage(session.id, {
role: 'tool',
toolName: tc.function.name,
toolCallId: tc.id,
- content: summarizeTool(tc.function.name, output).slice(0, 8000),
+ content: summarizeTool(tc.function.name, outStr).slice(0, 8000),
});
- apiMessages.push({ role: 'tool', tool_call_id: tc.id, content: output.slice(0, 8000) });
+ if (output && typeof output === 'object' && output.image) {
+ // Screenshot: Bild nur im laufenden Verlauf, DB bekommt die Zusammenfassung
+ apiMessages.push({
+ role: 'tool',
+ tool_call_id: tc.id,
+ content: [
+ { type: 'text', text: (output.text || output.title || '').slice(0, 2000) },
+ { type: 'image_url', image_url: { url: output.image } },
+ ],
+ });
+ } else {
+ apiMessages.push({ role: 'tool', tool_call_id: tc.id, content: outStr.slice(0, 8000) });
+ }
}
continue;
}
const finalText = result.content || (dashboardUrl ? 'Recherche abgeschlossen – siehe Dashboard.' : '');
- db.createMessage(session.id, { role: 'assistant', content: finalText });
+ if (!dashboardSaved && dashboardUrl && isChangeRequest(userMessageText)) {
+ emit.progress(
+ '⚠️',
+ 'Achtung: Das Dashboard wurde nicht aktualisiert – der Agent hat save_dashboard nicht aufgerufen.'
+ );
+ }
+ db.createMessage(session.id, {
+ role: 'assistant',
+ content: finalText,
+ reasoning: result.reasoning || null,
+ });
return finishJob(finalText, dashboardUrl);
}
@@ -332,10 +533,10 @@ async function runResearchJob(config, session, userMessageText, emit, db) {
role: 'assistant',
content: 'Maximale Iterationen erreicht – Recherche abgebrochen. Bitte neue Session starten.',
});
- db.setSessionUsage(session.id, usage);
+ db.setSessionUsage(session.id, usage, prices);
db.setSessionStatus(session.id, 'done');
emit.error('Maximale Iterationen erreicht – Recherche abgebrochen.');
- return { text: '', dashboardUrl, usage, costEstimate: db.computeCost(usage) };
+ return { text: '', dashboardUrl, usage, costEstimate: db.computeCost(usage, prices), model: model.id };
}
module.exports = { runResearchJob, SYSTEM_PROMPT, TOOL_DEFINITIONS };
diff --git a/lib/tools.js b/lib/tools.js
index 87d582c..b3ffb14 100644
--- a/lib/tools.js
+++ b/lib/tools.js
@@ -144,6 +144,229 @@ function saveDashboard(sessionId, filename, html, log) {
return { file, url: `/dashboards/${file}` };
}
+/** Screenshot einer Seite (JPEG, als Data-URL) – für Vision-Modelle. */
+async function screenshot(url, fullPage = false, log) {
+ return withPage(url, async (page) => {
+ const nav = await goto(page, url);
+ if (!nav.ok) return { error: `Seite konnte nicht geladen werden (${nav.status || 'Timeout'})` };
+ const title = await page.title().catch(() => '');
+ let useFull = !!fullPage;
+ if (useFull) {
+ const h = await page.evaluate(() => document.body?.scrollHeight || 0).catch(() => 0);
+ if (h > 6000) useFull = false;
+ }
+ const buf = await page.screenshot({ fullPage: useFull, type: 'jpeg', quality: 70 });
+ log?.(`Screenshot: ${url}${useFull ? ' (voll)' : ''} – ${title || ''}`);
+ return {
+ title: title || nav.finalUrl || url,
+ url: nav.finalUrl || url,
+ image: `data:image/jpeg;base64,${buf.toString('base64')}`,
+ };
+ });
+}
+
+// ---------------------------------------------------------------------------
+// Tour-Feature: Geocoding (Nominatim) + Routing (OSRM) + GPX-Export
+// ---------------------------------------------------------------------------
+
+const NOMINATIM_UA = 'ResearchChat/1.0 (Kontakt: research@localhost)';
+let lastGeocodeTime = 0;
+
+/** Koordinaten-Paare in OSRM-Format: lon,lat (ACHTUNG Reihenfolge). */
+const toOsrm = (stops) => stops.map((s) => `${s.lon},${s.lat}`).join(';');
+
+/** GPX-/XML-sichere Zeichen escapen. */
+function xmlEscape(s) {
+ return String(s ?? '')
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''');
+}
+
+/** Koordinaten-Angabe erkennen: "52.5163, 13.3777" oder "52.5163 13.3777". */
+function parseCoords(query) {
+ const m = String(query).match(/(-?\d{1,3}(?:\.\d+)?)\s*[,;\s]\s*(-?\d{1,3}(?:\.\d+)?)/);
+ if (!m) return null;
+ const lat = parseFloat(m[1]);
+ const lon = parseFloat(m[2]);
+ if (Math.abs(lat) <= 90 && Math.abs(lon) <= 180) return { lat, lon };
+ return null;
+}
+
+/**
+ * Ort über Nominatim (OpenStreetMap) in Koordinaten umwandeln.
+ * Kostenlos, Fair-Use: max. 1 Request/Sekunde.
+ */
+async function geocode(query, config, log) {
+ const coords = parseCoords(query);
+ if (coords) {
+ log?.(`Geocode "${query}" -> Koordinaten direkt übernommen`);
+ return { ...coords, name: query, displayName: query, direct: true };
+ }
+
+ const wait = Math.max(0, lastGeocodeTime + 1100 - Date.now());
+ if (wait > 0) await new Promise((r) => setTimeout(r, wait));
+ lastGeocodeTime = Date.now();
+
+ const url = new URL('https://nominatim.openstreetmap.org/search');
+ url.searchParams.set('q', query);
+ url.searchParams.set('format', 'json');
+ url.searchParams.set('limit', '1');
+ url.searchParams.set('accept-language', 'de');
+ const headers = {
+ 'User-Agent': config?.nominatimEmail ? `ResearchChat/1.0 (${config.nominatimEmail})` : NOMINATIM_UA,
+ };
+
+ try {
+ const resp = await fetch(url, { headers, timeout: 15000 });
+ if (!resp.ok) return { error: `Nominatim Fehler ${resp.status}` };
+ const results = await resp.json();
+ if (!results.length) return { error: `Kein Ort gefunden: "${query}"` };
+ const r = results[0];
+ log?.(`Geocode "${query}" -> ${r.lat}, ${r.lon}`);
+ return {
+ name: r.name || query,
+ lat: parseFloat(r.lat),
+ lon: parseFloat(r.lon),
+ displayName: r.display_name,
+ };
+ } catch (e) {
+ return { error: `Nominatim nicht erreichbar: ${e.message.slice(0, 120)}` };
+ }
+}
+
+/** Geometrie entschlacken (max. 500 Punkte für das Dashboard). */
+function decimate(coords, max = 500) {
+ if (coords.length <= max) return coords;
+ const step = coords.length / max;
+ const out = [];
+ for (let i = 0; i < coords.length; i += step) out.push(coords[Math.floor(i)]);
+ out.push(coords[coords.length - 1]);
+ return out;
+}
+
+/**
+ * Tour speichern: OSRM-Fuß-Route durch alle Stopps, GPX-Datei für OsmAnd,
+ * tour.json für die Dashboard-Karte, Google-Maps-URLs.
+ */
+async function saveTour({ stops, title, filename, sessionId }, config, log) {
+ if (!Array.isArray(stops) || stops.length < 2) {
+ return { error: 'Mindestens 2 Stopps nötig (Name, lat, lon)' };
+ }
+ const clean = stops
+ .filter((s) => s && s.name && Number.isFinite(Number(s.lat)) && Number.isFinite(Number(s.lon)))
+ .map((s) => ({
+ name: String(s.name).slice(0, 100),
+ lat: Number(s.lat),
+ lon: Number(s.lon),
+ description: String(s.description || '').slice(0, 300),
+ }));
+ if (clean.length < 2) return { error: 'Stopps ungültig (Name + lat + lon erforderlich)' };
+
+ // OSRM-Fuß-Route (Fallback: Luftlinie)
+ let route = { coordinates: clean.map((s) => [s.lon, s.lat]), distanceKm: 0, durationMin: 0, source: 'line' };
+ if (clean.length <= 25) {
+ try {
+ const osrmUrl = `${config?.osrmUrl || 'https://router.project-osrm.org'}/route/v1/foot/${toOsrm(clean)}?overview=full&geometries=geojson`;
+ const resp = await fetch(osrmUrl, { timeout: 20000 });
+ if (resp.ok) {
+ const data = await resp.json();
+ const r = data.routes?.[0];
+ if (r?.geometry?.coordinates?.length) {
+ route = {
+ coordinates: decimate(r.geometry.coordinates), // für die Karte im Dashboard
+ fullCoordinates: r.geometry.coordinates, // für die GPX-Datei (OsmAnd)
+ distanceKm: Math.round((r.distance / 1000) * 10) / 10,
+ durationMin: Math.round(r.duration / 60),
+ source: 'osrm',
+ };
+ }
+ } else {
+ log?.(`OSRM antwortet ${resp.status} – Fallback: Luftlinie`);
+ }
+ } catch (e) {
+ log?.(`OSRM nicht erreichbar – Fallback: Luftlinie (${e.message.slice(0, 80)})`);
+ }
+ }
+
+ // GPX 1.1 bauen
+ const wpts = clean
+ .map(
+ (s, i) =>
+ `