Compare commits

..
2 Commits
Author SHA1 Message Date
nico 0b51bb6142 Fix: Tools-Modul nicht mehr durch lokale Variable überschatten
In runResearchJob wurde die Variable 'tools' für die Tool-Definitionen
wiederverwendet und das importierte Tools-Modul überschattet – dadurch
schlugen alle Tool-Aufrufe (search, browse, save_dashboard, ...) mit
'Tool-Fehler' fehl. Umbenannt in toolDefs.
2026-08-27 19:44:52 +02:00
nico 87eb7029f1 Multi-Modell + Vision: OpenRouter (GLM 5.3 Flash), Bild-Upload, Tour-Feature, Dashboard-Fixes
- Modell-Registry (DeepSeek direkt + OpenRouter z-ai/glm-5.3-flash), Modellwahl pro Session, Reasoning-Effort max (env-konfigurierbar)
- Bild-Upload für Vision-Modelle (User) + Agent-screenshot()-Tool (nur Vision)
- Tour-Planung: geocode + save_tour mit GPX-Export und Karten-Sektion
- Fix: System-Prompt wird jetzt wirklich an die API gesendet, inkl. aktuellem Dashboard-Dateinamen für Folge-Updates
- Fix: History-Sanitizer gegen 400-Fehler (nie mitten in Tool-Gruppen kürzen)
- Fix: reasoning_content-Roundtrip (Pflicht bei DeepSeek mit tools)
- Fix: Dashboards ohne Browser-Cache ausliefern
2026-08-27 17:43:13 +02:00
10 changed files with 847 additions and 74 deletions

No files matched your search

+13 -1
View File
@@ -2,6 +2,10 @@
# 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
@@ -12,11 +16,19 @@ SESSION_SECRET=
PORT=3000 PORT=3000
# DeepSeek Modell # DeepSeek Modell
MODEL=deepseek-v4-flash MODEL=deepseek-v4-flash
# Thinking-Mode des Modells: enabled | disabled # Thinking-Mode des Modells: enabled | disabled (gilt für DeepSeek)
THINKING=enabled 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-Schlüsselpaar für Web Push (wird automatisch generiert)
VAPID_PUBLIC_KEY= VAPID_PUBLIC_KEY=
VAPID_PRIVATE_KEY= VAPID_PRIVATE_KEY=
# Login-Schutz: Fehlversuche pro IP bevor gesperrt wird, und Sperrdauer # Login-Schutz: Fehlversuche pro IP bevor gesperrt wird, und Sperrdauer
LOGIN_MAX_ATTEMPTS=5 LOGIN_MAX_ATTEMPTS=5
LOGIN_LOCK_MINUTES=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
+2
View File
@@ -1,4 +1,6 @@
node_modules/ node_modules/
data/ data/
dashboards/*.html dashboards/*.html
dashboards/*.gpx
dashboards/*.tour.json
.env .env
+56 -1
View File
@@ -51,15 +51,70 @@ 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-v4-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),
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',
loginMaxAttempts: Number(process.env.LOGIN_MAX_ATTEMPTS || env.LOGIN_MAX_ATTEMPTS || 5), loginMaxAttempts: Number(process.env.LOGIN_MAX_ATTEMPTS || env.LOGIN_MAX_ATTEMPTS || 5),
loginLockMinutes: Number(process.env.LOGIN_LOCK_MINUTES || env.LOGIN_LOCK_MINUTES || 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')), sessionSecret: ensure('SESSION_SECRET', () => crypto.randomBytes(32).toString('hex')),
vapidPublicKey: vapid.publicKey, vapidPublicKey: vapid.publicKey,
vapidPrivateKey: vapid.privateKey, 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 };
+95 -19
View File
@@ -49,14 +49,15 @@ 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 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 }; const PRICES = { inputCacheHit: 0.014, inputCacheMiss: 0.44, output: 1.32 };
function computeCost(u) { function computeCost(u, prices = PRICES) {
return ( return (
((u.cacheHitTokens || 0) * PRICES.inputCacheHit + ((u.cacheHitTokens || 0) * prices.inputCacheHit +
(u.cacheMissTokens || 0) * PRICES.inputCacheMiss + (u.cacheMissTokens || 0) * prices.inputCacheMiss +
(u.completionTokens || 0) * PRICES.output) / (u.completionTokens || 0) * prices.output) /
1e6 1e6
); );
} }
@@ -72,12 +73,23 @@ function computeCost(u) {
add('cache_hit_tokens', 'INTEGER DEFAULT 0'); add('cache_hit_tokens', 'INTEGER DEFAULT 0');
add('cache_miss_tokens', 'INTEGER DEFAULT 0'); add('cache_miss_tokens', 'INTEGER DEFAULT 0');
add('cost_estimate', 'REAL 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 = { const stmts = {
createSession: db.prepare( createSession: db.prepare(
`INSERT INTO sessions (id, title, owner_name, share_token, status, created_at, updated_at) `INSERT INTO sessions (id, title, owner_name, share_token, status, model, created_at, updated_at)
VALUES (?, ?, ?, ?, 'idle', ?, ?)` VALUES (?, ?, ?, ?, 'idle', ?, ?, ?)`
), ),
getSession: db.prepare(`SELECT * FROM sessions WHERE id = ?`), getSession: db.prepare(`SELECT * FROM sessions WHERE id = ?`),
getSessionByShare: db.prepare(`SELECT * FROM sessions WHERE share_token = ?`), 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 = ?`), setSessionTitle: db.prepare(`UPDATE sessions SET title = ?, updated_at = ? WHERE id = ?`),
setSessionDashboard: db.prepare(`UPDATE sessions SET dashboard = ?, 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( addUsage: db.prepare(
`UPDATE sessions SET prompt_tokens = prompt_tokens + ?, completion_tokens = completion_tokens + ?, `UPDATE sessions SET prompt_tokens = prompt_tokens + ?, completion_tokens = completion_tokens + ?,
cache_hit_tokens = cache_hit_tokens + ?, cache_miss_tokens = cache_miss_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 = ?`), deleteSession: db.prepare(`DELETE FROM sessions WHERE id = ?`),
insertMessage: db.prepare( insertMessage: db.prepare(
`INSERT INTO messages (session_id, role, content, tool_name, tool_call_id, tool_calls, created_at) `INSERT INTO messages (session_id, role, content, tool_name, tool_call_id, tool_calls, reasoning, image, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)` VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
), ),
listMessages: db.prepare(`SELECT * FROM messages WHERE session_id = ? ORDER BY id ASC`), 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`), 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`), listPushSubs: db.prepare(`SELECT * FROM push_subscriptions`),
}; };
function createSession(ownerName, title = 'Neue Recherche') { function createSession(ownerName, title = 'Neue Recherche', model = null) {
const id = uid(); 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); 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( stmts.insertMessage.run(
sessionId, sessionId,
role, role,
@@ -127,6 +140,8 @@ function createMessage(sessionId, { role, content, toolName, toolCallId, toolCal
toolName ?? null, toolName ?? null,
toolCallId ?? null, toolCallId ?? null,
toolCalls ? JSON.stringify(toolCalls) : null, toolCalls ? JSON.stringify(toolCalls) : null,
reasoning ?? null,
image ?? null,
now() now()
); );
return stmts.lastMessage.get(sessionId); 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. */ const API_HISTORY_MAX = 60;
function toApiMessages(sessionId) {
/**
* 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 msgs = getSessionMessages(sessionId);
const out = []; const out = [];
for (const m of msgs) { for (const m of msgs) {
if (m.role === 'user') out.push({ role: 'user', content: m.content }); if (m.role === 'user') {
else if (m.role === 'assistant') { 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' }; const msg = { role: 'assistant' };
if (m.tool_calls) msg.tool_calls = m.tool_calls; if (m.tool_calls) msg.tool_calls = m.tool_calls;
if (m.content) msg.content = m.content; if (m.content) msg.content = m.content;
else if (!m.tool_calls) msg.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); out.push(msg);
} else if (m.role === 'tool') { } else if (m.role === 'tool') {
out.push({ role: 'tool', tool_call_id: m.tool_call_id, content: m.content ?? '' }); out.push({ role: 'tool', tool_call_id: m.tool_call_id, content: m.content ?? '' });
} }
} }
return out.slice(-60); return sanitizeHistory(out);
} }
module.exports = { module.exports = {
@@ -168,13 +243,14 @@ module.exports = {
setSessionStatus: (id, status) => stmts.setSessionStatus.run(status, now(), id), setSessionStatus: (id, status) => stmts.setSessionStatus.run(status, now(), id),
setSessionTitle: (id, title) => stmts.setSessionTitle.run(title, now(), id), setSessionTitle: (id, title) => stmts.setSessionTitle.run(title, now(), id),
setSessionDashboard: (id, dashboard) => stmts.setSessionDashboard.run(dashboard, 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( stmts.addUsage.run(
u.promptTokens || 0, u.promptTokens || 0,
u.completionTokens || 0, u.completionTokens || 0,
u.cacheHitTokens || 0, u.cacheHitTokens || 0,
u.cacheMissTokens || 0, u.cacheMissTokens || 0,
computeCost(u), computeCost(u, prices),
id id
), ),
computeCost, computeCost,
+233 -32
View File
@@ -1,4 +1,5 @@
const tools = require('./tools'); 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. 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. 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. 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: <link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"> und <script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script> (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: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> &copy; <a href="https://carto.com/attributions">CARTO</a>'
}).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) ## Dashboard-Struktur (HTML, komplette Standalone-Datei)
- <html lang="de">, <script src="https://cdn.tailwindcss.com"></script> im Head, responsives Design mit Dark-Mode-Unterstützung (dark: Klassen) - <html lang="de">, <script src="https://cdn.tailwindcss.com"></script> im Head, responsives Design mit Dark-Mode-Unterstützung (dark: Klassen)
- Header: Titel + Datum + Anzahl verifizierter Links - Header: Titel + Datum + Anzahl verifizierter Links
@@ -28,6 +52,7 @@ const SYSTEM_PROMPT = `Du bist ein professioneller Web-Researcher und Wissensber
## Regeln ## Regeln
- JEDER Link im Dashboard MUSS mit verify() verifiziert sein - füge bei den Karten einen "✓ Verifiziert"-Badge hinzu - 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 - 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 - 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. - 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. */ /** Menschlich lesbare Zusammenfassung eines Tool-Ergebnisses für den Chat-Verlauf. */
function summarizeTool(toolName, output) { function summarizeTool(toolName, output) {
let data; let data;
@@ -118,6 +209,18 @@ function summarizeTool(toolName, output) {
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':
return `Dashboard gespeichert: ${data.url || ''}`; 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: default:
return String(output).slice(0, 1000); return String(output).slice(0, 1000);
} }
@@ -125,32 +228,80 @@ function summarizeTool(toolName, output) {
const MAX_ITERATIONS = 40; const MAX_ITERATIONS = 40;
async function chatCompletion(config, messages) { /** Baut den System-Prompt inkl. aktuellem Dashboard-Stand und Vision-Hinweisen. */
const body = { function buildSystemPrompt(session, model) {
model: config.model, let prompt = SYSTEM_PROMPT;
messages, if (session.dashboard) {
tools: TOOL_DEFINITIONS, const file = session.dashboard.split('/').pop().replace(/\.html$/, '');
stream: true, prompt += `
max_tokens: 32000,
stream_options: { include_usage: true }, ## Aktueller Stand dieser Session
}; Für diese Session existiert bereits ein Dashboard: ${session.dashboard}
if (config.thinking !== 'disabled') { 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).
body.thinking = { type: 'enabled' }; 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.`;
body.reasoning_effort = 'medium'; }
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;
} }
const resp = await fetch(`${config.baseUrl || 'https://api.deepseek.com'}/chat/completions`, { /** Erkennt Änderungswünsche am Dashboard in der Nutzernachricht. */
method: 'POST', function isChangeRequest(text) {
headers: { 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: model.id,
messages: [{ role: 'system', content: systemPrompt }, ...messages],
tools,
stream: true,
max_tokens: model.maxTokens || config.maxTokens || 64000,
stream_options: { include_usage: true },
};
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 = model.reasoningEffort?.() || 'high';
}
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', 'Content-Type': 'application/json',
Authorization: `Bearer ${config.apiKey}`, Authorization: `Bearer ${config.apiKey}`,
}, };
const resp = await fetch(`${baseUrl}/chat/completions`, {
method: 'POST',
headers,
body: JSON.stringify(body), body: JSON.stringify(body),
}); });
if (!resp.ok) { if (!resp.ok) {
const err = await resp.text().catch(() => ''); 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(); const reader = resp.body.getReader();
@@ -173,7 +324,9 @@ async function chatCompletion(config, messages) {
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
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) {
@@ -224,7 +377,11 @@ async function chatCompletion(config, messages) {
*/ */
async function runResearchJob(config, session, userMessageText, emit, db) { async function runResearchJob(config, session, userMessageText, emit, db) {
let dashboardUrl = session.dashboard || null; 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 toolDefs = model.vision ? [...TOOL_DEFINITIONS, SCREENSHOT_TOOL] : TOOL_DEFINITIONS;
const prices = model.prices;
const handlers = { const handlers = {
async search({ query }) { 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)); const result = await tools.browse(url, (m) => emit.step(m));
return JSON.stringify(result); 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 }) { async verify({ url }) {
emit.progress('✅', `Verifiziere: ${url}`); emit.progress('✅', `Verifiziere: ${url}`);
const result = await tools.verify(url, (m) => emit.step(m)); 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…'); emit.progress('📊', 'Dashboard wird gespeichert…');
const saved = tools.saveDashboard(session.id, filename || title, html, (m) => emit.step(m)); const saved = tools.saveDashboard(session.id, filename || title, html, (m) => emit.step(m));
dashboardUrl = saved.url; dashboardUrl = saved.url;
dashboardFile = saved.file.replace('.html', ''); dashboardSaved = true;
db.setSessionDashboard(session.id, saved.url); db.setSessionDashboard(session.id, saved.url);
emit.progress('📊', `Dashboard gespeichert: ${saved.file}`); emit.progress('📊', `Dashboard gespeichert: ${saved.file}`);
return JSON.stringify(saved); 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 text = userMessageText;
const usage = { promptTokens: 0, completionTokens: 0, cacheHitTokens: 0, cacheMissTokens: 0 }; const usage = { promptTokens: 0, completionTokens: 0, cacheHitTokens: 0, cacheMissTokens: 0 };
emit.progress('🤖', 'Research startet…'); emit.progress('🤖', `Research startet mit ${model.label}…`);
const trackUsage = (u) => { const trackUsage = (u) => {
if (!u) return; if (!u) return;
@@ -271,17 +442,17 @@ async function runResearchJob(config, session, userMessageText, emit, db) {
}; };
const finishJob = (finalText, dashboardUrl) => { const finishJob = (finalText, dashboardUrl) => {
db.setSessionUsage(session.id, usage); db.setSessionUsage(session.id, usage, prices);
db.setSessionStatus(session.id, 'done'); db.setSessionStatus(session.id, 'done');
if (dashboardUrl) db.setSessionDashboard(session.id, dashboardUrl); if (dashboardUrl) db.setSessionDashboard(session.id, dashboardUrl);
const costEstimate = db.computeCost(usage); const costEstimate = db.computeCost(usage, prices);
emit.done({ text: finalText, dashboardUrl, usage, costEstimate }); emit.done({ text: finalText, dashboardUrl, usage, costEstimate, model: model.id });
return { text: finalText, dashboardUrl, usage, costEstimate }; return { text: finalText, dashboardUrl, usage, costEstimate, model: model.id };
}; };
for (let iter = 0; iter < MAX_ITERATIONS; iter++) { for (let iter = 0; iter < MAX_ITERATIONS; iter++) {
emit.progress('🤖', iter === 0 ? 'Analysiere Anfrage und plane Suche…' : 'Weitere Analyse…'); 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, toolDefs);
trackUsage(result.usage); trackUsage(result.usage);
if (result.reasoning) emit.thinking(result.reasoning); 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 }, 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); apiMessages.push(assistantMsg);
for (const tc of result.toolCalls) { 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)}` }); output = JSON.stringify({ error: `Tool-Fehler: ${e.message.slice(0, 300)}` });
} }
} }
const outStr = typeof output === 'string' ? output : JSON.stringify(output);
db.createMessage(session.id, { db.createMessage(session.id, {
role: 'tool', role: 'tool',
toolName: tc.function.name, toolName: tc.function.name,
toolCallId: tc.id, 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; continue;
} }
const finalText = result.content || (dashboardUrl ? 'Recherche abgeschlossen – siehe Dashboard.' : ''); 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); return finishJob(finalText, dashboardUrl);
} }
@@ -332,10 +533,10 @@ async function runResearchJob(config, session, userMessageText, emit, db) {
role: 'assistant', role: 'assistant',
content: 'Maximale Iterationen erreicht – Recherche abgebrochen. Bitte neue Session starten.', 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'); db.setSessionStatus(session.id, 'done');
emit.error('Maximale Iterationen erreicht – Recherche abgebrochen.'); 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 }; module.exports = { runResearchJob, SYSTEM_PROMPT, TOOL_DEFINITIONS };
+224 -1
View File
@@ -144,6 +144,229 @@ function saveDashboard(sessionId, filename, html, log) {
return { file, url: `/dashboards/${file}` }; 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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}
/** 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) =>
` <wpt lat="${s.lat.toFixed(6)}" lon="${s.lon.toFixed(6)}">\n` +
` <name>${xmlEscape(i + 1)}. ${xmlEscape(s.name)}</name>\n` +
(s.description ? ` <desc>${xmlEscape(s.description)}</desc>\n` : '') +
` </wpt>`
)
.join('\n');
const rtepts = clean
.map((s) => ` <rtept lat="${s.lat.toFixed(6)}" lon="${s.lon.toFixed(6)}"><name>${xmlEscape(s.name)}</name></rtept>`)
.join('\n');
const trkpts = (route.fullCoordinates || route.coordinates)
.map(([lon, lat]) => ` <trkpt lat="${lat.toFixed(6)}" lon="${lon.toFixed(6)}"></trkpt>`)
.join('\n');
const gpx = `<?xml version="1.0" encoding="UTF-8"?>
<gpx version="1.1" creator="ResearchChat" xmlns="http://www.topografix.com/GPX/1/1">
<metadata>
<name>${xmlEscape(title || 'Tour')}</name>
</metadata>
${wpts}
<rte>
<name>${xmlEscape(title || 'Tour')}</name>
${rtepts}
</rte>
<trk>
<name>${xmlEscape(title || 'Tour')} (Route)</name>
<trkseg>
${trkpts}
</trkseg>
</trk>
</gpx>
`;
const safeName =
(filename || title || 'tour').toLowerCase().replace(/[^a-z0-9äöü-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 60) ||
'tour';
const base = `${sessionId}-${safeName.replace(/-tour$/, '')}-tour`;
fs.mkdirSync(DASHBOARD_DIR, { recursive: true });
fs.writeFileSync(path.join(DASHBOARD_DIR, `${base}.gpx`), gpx, 'utf8');
const { fullCoordinates, ...routeForJson } = route;
fs.writeFileSync(
path.join(DASHBOARD_DIR, `${base}.tour.json`),
JSON.stringify({
title: title || 'Tour',
stops: clean,
route: routeForJson,
}),
'utf8'
);
const fmt = (lat, lon) => `${lat.toFixed(6)},${lon.toFixed(6)}`;
const googleMapsUrl = `https://www.google.com/maps/dir/${clean.map((s) => fmt(s.lat, s.lon)).join('/')}/`;
const stopsWithNav = clean.map((s) => ({
...s,
navUrl: `https://www.google.com/maps/dir/?api=1&destination=${fmt(s.lat, s.lon)}&travelmode=walking`,
}));
log?.(
`Tour gespeichert: ${base} (${clean.length} Stopps, ${route.distanceKm} km, ${route.durationMin} min, ${route.source})`
);
return {
ok: true,
title: title || 'Tour',
gpxUrl: `/dashboards/${base}.gpx`,
tourJsonUrl: `/dashboards/${base}.tour.json`,
googleMapsUrl,
stops: stopsWithNav,
distanceKm: route.distanceKm,
durationMin: route.durationMin,
routeSource: route.source,
osmAndHint: 'GPX-Datei herunterladen und in OsmAnd importieren (Menü → Meine Orte → Importieren).',
};
}
async function closeBrowser() { async function closeBrowser() {
if (browser) { if (browser) {
await browser.close().catch(() => {}); await browser.close().catch(() => {});
@@ -151,4 +374,4 @@ async function closeBrowser() {
} }
} }
module.exports = { search, browse, verify, saveDashboard, closeBrowser }; module.exports = { search, browse, verify, saveDashboard, screenshot, geocode, saveTour, closeBrowser };
+141 -9
View File
@@ -5,6 +5,9 @@ const state = {
stream: null, stream: null,
streamingEl: null, streamingEl: null,
activityShown: false, activityShown: false,
models: [],
modelById: {},
pendingImage: null,
}; };
const $ = (id) => document.getElementById(id); const $ = (id) => document.getElementById(id);
@@ -32,7 +35,7 @@ function mdLite(text) {
} }
function toolLabel(msg) { function toolLabel(msg) {
const icons = { search: '🔍', browse: '🌐', verify: '✅', save_dashboard: '📊' }; const icons = { search: '🔍', browse: '🌐', verify: '✅', save_dashboard: '📊', screenshot: '📷' };
const icon = icons[msg.tool_name] || '🔧'; const icon = icons[msg.tool_name] || '🔧';
const text = msg.tool_name === 'search' ? `Suche: ${msg.content}` : msg.content; const text = msg.tool_name === 'search' ? `Suche: ${msg.content}` : msg.content;
return { icon, text: text?.slice(0, 200) }; return { icon, text: text?.slice(0, 200) };
@@ -75,11 +78,13 @@ function renderSessions() {
// Chat-Rendering // Chat-Rendering
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
function addUserMessage(text) { function addUserMessage(text, image) {
$('emptyState')?.remove(); $('emptyState')?.remove();
const el = document.createElement('div'); const el = document.createElement('div');
el.className = 'flex justify-end'; el.className = 'flex justify-end';
el.innerHTML = `<div class="max-w-[80%] px-4 py-2.5 rounded-2xl bg-blue-600 text-white whitespace-pre-wrap break-words">${mdLite(text)}</div>`; el.innerHTML = `<div class="max-w-[80%] px-4 py-2.5 rounded-2xl bg-blue-600 text-white whitespace-pre-wrap break-words">${
image ? `<img src="${image}" alt="Angehängtes Bild" class="rounded-lg mb-2 max-h-48">` : ''
}${mdLite(text)}</div>`;
$('chat').appendChild(el); $('chat').appendChild(el);
scrollDown(); scrollDown();
} }
@@ -154,7 +159,7 @@ function renderMessages(messages, session) {
} }
addDashboardCard(session); addDashboardCard(session);
for (const m of messages) { for (const m of messages) {
if (m.role === 'user') addUserMessage(m.content); if (m.role === 'user') addUserMessage(m.content, m.image || null);
else if (m.role === 'assistant') { else if (m.role === 'assistant') {
if (m.content) addAssistantBubble(m.content); if (m.content) addAssistantBubble(m.content);
} else if (m.role === 'tool') { } else if (m.role === 'tool') {
@@ -348,11 +353,15 @@ async function openSession(id) {
state.stream?.close(); state.stream?.close();
state.streamingEl = null; state.streamingEl = null;
hideActivity(); hideActivity();
clearImage();
const res = await fetch(`/api/sessions/${id}`); const res = await fetch(`/api/sessions/${id}`);
const data = await res.json(); const data = await res.json();
state.current = data.session; state.current = data.session;
if (!state.current.model) state.current.model = state.models[0]?.id || null;
renderMessages(data.messages, data.session); renderMessages(data.messages, data.session);
$('mobileTitle').textContent = data.session.title; $('mobileTitle').textContent = data.session.title;
setSelectModel(state.current.model);
updateModelUI();
renderSessions(); renderSessions();
const q = state.sessions.find((s) => s.id === id); const q = state.sessions.find((s) => s.id === id);
if (data.session.status === 'running' || q?.job === 'running' || q?.job === 'queued') { if (data.session.status === 'running' || q?.job === 'running' || q?.job === 'queued') {
@@ -370,6 +379,114 @@ async function newSession() {
$('input').focus(); $('input').focus();
} }
// ---------------------------------------------------------------------------
// Modell-Auswahl
// ---------------------------------------------------------------------------
async function loadModels() {
const res = await fetch('/api/models');
const data = await res.json();
state.models = data.models || [];
state.modelById = Object.fromEntries(state.models.map((m) => [m.id, m]));
const opts = state.models.map((m) => `<option value="${escapeHtml(m.id)}">${escapeHtml(m.label)}</option>`).join('');
$('modelSelectDesktop').innerHTML = opts;
$('modelSelectMobile').innerHTML = opts;
return data.default || null;
}
function currentModel() {
return state.modelById[state.current?.model] || state.models[0] || null;
}
function setSelectModel(id) {
$('modelSelectDesktop').value = id || '';
$('modelSelectMobile').value = id || '';
}
async function changeModel(id) {
if (!state.current || state.current.model === id) return;
const res = await fetch(`/api/sessions/${state.current.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ model: id }),
});
if (!res.ok) {
const d = await res.json().catch(() => ({}));
addToolLine('⚠️', d.error || 'Modellwechsel fehlgeschlagen');
setSelectModel(state.current.model);
return;
}
state.current.model = id;
const s = state.sessions.find((x) => x.id === state.current.id);
if (s) s.model = id;
updateModelUI();
}
function updateModelUI() {
const m = currentModel();
const vision = !!m?.vision;
const btn = $('imageBtn');
btn.disabled = !vision;
btn.classList.toggle('opacity-40', !vision);
const visionLabel = state.models.find((x) => x.vision)?.label || 'einem Vision-Modell';
btn.title = vision ? 'Bild anhängen' : `Bilderkennung nur mit Vision-Modell möglich (z.B. ${visionLabel})`;
if (!vision && state.pendingImage) clearImage();
}
// ---------------------------------------------------------------------------
// Bild-Upload (Vision)
// ---------------------------------------------------------------------------
function fileToDataUrl(file, maxDim = 1280, quality = 0.82) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
const img = new Image();
img.onload = () => {
const scale = Math.min(1, maxDim / Math.max(img.width, img.height));
const canvas = document.createElement('canvas');
canvas.width = Math.max(1, Math.round(img.width * scale));
canvas.height = Math.max(1, Math.round(img.height * scale));
const ctx = canvas.getContext('2d');
ctx.fillStyle = '#fff';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
resolve(canvas.toDataURL('image/jpeg', quality));
};
img.onerror = () => reject(new Error('Bild konnte nicht gelesen werden'));
img.src = reader.result;
};
reader.onerror = () => reject(new Error('Datei konnte nicht gelesen werden'));
reader.readAsDataURL(file);
});
}
function showImagePreview(dataUrl) {
state.pendingImage = dataUrl;
$('imagePreviewImg').src = dataUrl;
$('imagePreview').classList.remove('hidden');
}
function clearImage() {
state.pendingImage = null;
$('imageInput').value = '';
$('imagePreview').classList.add('hidden');
}
async function handleImageFile(file) {
if (!file) return;
try {
const dataUrl = await fileToDataUrl(file);
if (dataUrl.length > 3500000) {
addToolLine('⚠️', 'Bild zu groß – bitte ein kleineres Foto wählen.');
return;
}
showImagePreview(dataUrl);
} catch (e) {
addToolLine('⚠️', e.message || 'Bild konnte nicht verarbeitet werden');
}
}
function copyText(text, btn, label) { function copyText(text, btn, label) {
try { try {
navigator.clipboard.writeText(text).then( navigator.clipboard.writeText(text).then(
@@ -424,14 +541,14 @@ function updateShareHint() {
// Senden // Senden
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
async function sendMessage(text) { async function sendMessage(text, image) {
if (!state.current) await newSession(); if (!state.current) await newSession();
$('sendBtn').disabled = true; $('sendBtn').disabled = true;
addUserMessage(text); addUserMessage(text, image || null);
const res = await fetch(`/api/sessions/${state.current.id}/messages`, { const res = await fetch(`/api/sessions/${state.current.id}/messages`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text }), body: JSON.stringify(image ? { text, image } : { text }),
}); });
if (!res.ok) { if (!res.ok) {
const data = await res.json().catch(() => ({})); const data = await res.json().catch(() => ({}));
@@ -439,6 +556,7 @@ async function sendMessage(text) {
$('sendBtn').disabled = false; $('sendBtn').disabled = false;
return; return;
} }
clearImage();
enableNotifications(); enableNotifications();
showActivity(); showActivity();
setActivity('In Warteschlange…', '⏳'); setActivity('In Warteschlange…', '⏳');
@@ -462,6 +580,7 @@ window.addEventListener('load', async () => {
navigator.serviceWorker.register('/sw.js').catch(() => {}); navigator.serviceWorker.register('/sw.js').catch(() => {});
} }
await loadModels();
await loadSessions(); await loadSessions();
const params = new URLSearchParams(location.search); const params = new URLSearchParams(location.search);
const target = params.get('session') || state.sessions.find((s) => s.status === 'running')?.id || state.sessions[0]?.id; const target = params.get('session') || state.sessions.find((s) => s.status === 'running')?.id || state.sessions[0]?.id;
@@ -476,10 +595,11 @@ window.addEventListener('load', async () => {
$('chatForm').addEventListener('submit', (e) => { $('chatForm').addEventListener('submit', (e) => {
e.preventDefault(); e.preventDefault();
const text = $('input').value.trim(); const text = $('input').value.trim();
if (!text) return; const image = state.pendingImage;
if (!text && !image) return;
$('input').value = ''; $('input').value = '';
$('input').style.height = 'auto'; $('input').style.height = 'auto';
sendMessage(text); sendMessage(text, image);
}); });
$('input').addEventListener('input', () => { $('input').addEventListener('input', () => {
@@ -494,6 +614,18 @@ $('logoutBtn').onclick = async () => {
location.href = '/login.html'; location.href = '/login.html';
}; };
$('modelSelectDesktop').addEventListener('change', (e) => changeModel(e.target.value));
$('modelSelectMobile').addEventListener('change', (e) => changeModel(e.target.value));
$('imageBtn').onclick = () => {
if (!$('imageBtn').disabled) $('imageInput').click();
};
$('imageInput').addEventListener('change', (e) => {
handleImageFile(e.target.files?.[0]);
e.target.value = '';
});
$('imageRemoveBtn').onclick = clearImage;
// Enter sendet, Shift+Enter = Zeilenumbruch // Enter sendet, Shift+Enter = Zeilenumbruch
$('input').addEventListener('keydown', (e) => { $('input').addEventListener('keydown', (e) => {
if (e.key === 'Enter' && !e.shiftKey) { if (e.key === 'Enter' && !e.shiftKey) {
+16 -1
View File
@@ -27,9 +27,14 @@
<button id="logoutBtn" class="text-sm text-slate-400 hover:text-red-400" title="Abmelden">⎋</button> <button id="logoutBtn" class="text-sm text-slate-400 hover:text-red-400" title="Abmelden">⎋</button>
</div> </div>
<button id="newSessionBtn" <button id="newSessionBtn"
class="m-4 py-2 rounded-lg bg-blue-600 hover:bg-blue-700 text-white font-semibold text-sm"> class="m-4 mb-2 py-2 rounded-lg bg-blue-600 hover:bg-blue-700 text-white font-semibold text-sm">
+ Neue Recherche + Neue Recherche
</button> </button>
<div class="mx-4 mb-2">
<label class="text-xs text-slate-400 block mb-1" for="modelSelectDesktop">Modell</label>
<select id="modelSelectDesktop"
class="w-full px-2 py-1.5 rounded-lg border text-sm dark:bg-slate-800 dark:border-slate-700 border-slate-300 focus:outline-none focus:ring-2 focus:ring-blue-500"></select>
</div>
<div id="sessionList" class="flex-1 overflow-y-auto px-2 space-y-1"></div> <div id="sessionList" class="flex-1 overflow-y-auto px-2 space-y-1"></div>
<div class="p-3 text-xs text-slate-400 border-t dark:border-slate-800 border-slate-200"> <div class="p-3 text-xs text-slate-400 border-t dark:border-slate-800 border-slate-200">
<div id="userName" class="font-medium text-slate-500 dark:text-slate-400 mb-1"></div> <div id="userName" class="font-medium text-slate-500 dark:text-slate-400 mb-1"></div>
@@ -43,6 +48,8 @@
<div class="md:hidden flex items-center gap-2 p-3 border-b dark:border-slate-800 border-slate-200 bg-white dark:bg-slate-900"> <div class="md:hidden flex items-center gap-2 p-3 border-b dark:border-slate-800 border-slate-200 bg-white dark:bg-slate-900">
<button id="menuBtn" class="px-2 py-1 rounded bg-slate-200 dark:bg-slate-800">☰</button> <button id="menuBtn" class="px-2 py-1 rounded bg-slate-200 dark:bg-slate-800">☰</button>
<span class="font-bold truncate flex-1" id="mobileTitle">Research Agent</span> <span class="font-bold truncate flex-1" id="mobileTitle">Research Agent</span>
<select id="modelSelectMobile"
class="px-2 py-1 rounded-lg border text-xs dark:bg-slate-800 dark:border-slate-700 border-slate-300 focus:outline-none"></select>
</div> </div>
<!-- Activity bar --> <!-- Activity bar -->
@@ -69,7 +76,15 @@
<!-- Input --> <!-- Input -->
<div class="p-4 border-t dark:border-slate-800 border-slate-200 bg-white dark:bg-slate-900"> <div class="p-4 border-t dark:border-slate-800 border-slate-200 bg-white dark:bg-slate-900">
<div id="imagePreview" class="hidden mb-2 relative">
<img id="imagePreviewImg" src="" alt="Vorschau" class="max-h-40 rounded-xl border dark:border-slate-700 border-slate-300">
<button id="imageRemoveBtn" type="button" title="Bild entfernen"
class="absolute -top-2 -left-2 w-6 h-6 rounded-full bg-red-600 text-white text-xs font-bold">✕</button>
</div>
<form id="chatForm" class="flex gap-2 items-end"> <form id="chatForm" class="flex gap-2 items-end">
<input type="file" id="imageInput" accept="image/jpeg,image/png,image/webp" class="hidden">
<button id="imageBtn" type="button" title="Bild anhängen (nur Vision-Modelle)"
class="px-3 py-2.5 rounded-xl border dark:border-slate-700 border-slate-300 dark:bg-slate-800 hover:dark:bg-slate-700 text-lg leading-none">📷</button>
<textarea id="input" rows="1" placeholder="Thema eingeben… z.B. „Beste Self-Hosted Social-Media-Alternativen 2026“" <textarea id="input" rows="1" placeholder="Thema eingeben… z.B. „Beste Self-Hosted Social-Media-Alternativen 2026“"
class="flex-1 resize-none px-3 py-2.5 rounded-xl border dark:bg-slate-800 dark:border-slate-700 border-slate-300 focus:outline-none focus:ring-2 focus:ring-blue-500 max-h-40"></textarea> class="flex-1 resize-none px-3 py-2.5 rounded-xl border dark:bg-slate-800 dark:border-slate-700 border-slate-300 focus:outline-none focus:ring-2 focus:ring-blue-500 max-h-40"></textarea>
<button id="sendBtn" type="submit" <button id="sendBtn" type="submit"
+5 -3
View File
@@ -28,7 +28,7 @@
<div id="content" class="hidden space-y-4"></div> <div id="content" class="hidden space-y-4"></div>
<footer class="mt-8 text-center text-xs text-slate-500"> <footer class="mt-8 text-center text-xs text-slate-500">
<p>Generiert mit dem Research Agent · DeepSeek v4 Flash</p> <p>Generiert mit dem Research Agent · DeepSeek V4 Flash / GLM 5.3 Flash</p>
</footer> </footer>
</div> </div>
@@ -47,7 +47,7 @@
}; };
const toolLabel = (m) => { const toolLabel = (m) => {
const icons = { search: '🔍', browse: '🌐', verify: '✅', save_dashboard: '📊' }; const icons = { search: '🔍', browse: '🌐', verify: '✅', save_dashboard: '📊', screenshot: '📷' };
const icon = icons[m.tool_name] || '🔧'; const icon = icons[m.tool_name] || '🔧';
const text = m.tool_name === 'search' ? `Suche: ${m.content}` : m.content; const text = m.tool_name === 'search' ? `Suche: ${m.content}` : m.content;
return { icon, text: (text || '').slice(0, 200) }; return { icon, text: (text || '').slice(0, 200) };
@@ -90,7 +90,9 @@
chat.className = 'space-y-3 mt-2'; chat.className = 'space-y-3 mt-2';
for (const m of messages) { for (const m of messages) {
if (m.role === 'user') { if (m.role === 'user') {
chat.innerHTML += `<div class="flex justify-end"><div class="max-w-[80%] px-4 py-2.5 rounded-2xl bg-blue-600 text-white whitespace-pre-wrap break-words">${mdLite(m.content)}</div></div>`; chat.innerHTML += `<div class="flex justify-end"><div class="max-w-[80%] px-4 py-2.5 rounded-2xl bg-blue-600 text-white whitespace-pre-wrap break-words">${
m.image ? `<img src="${m.image}" alt="Angehängtes Bild" class="rounded-lg mb-2 max-h-48">` : ''
}${mdLite(m.content)}</div></div>`;
} else if (m.role === 'assistant') { } else if (m.role === 'assistant') {
if (m.content) if (m.content)
chat.innerHTML += `<div class="flex"><div class="max-w-[85%] px-4 py-2.5 rounded-2xl bg-slate-200 dark:bg-slate-800 whitespace-pre-wrap break-words">${mdLite(m.content)}</div></div>`; chat.innerHTML += `<div class="flex"><div class="max-w-[85%] px-4 py-2.5 rounded-2xl bg-slate-200 dark:bg-slate-800 whitespace-pre-wrap break-words">${mdLite(m.content)}</div></div>`;
+62 -7
View File
@@ -4,7 +4,7 @@ const crypto = require('crypto');
const express = require('express'); const express = require('express');
const webpush = require('web-push'); const webpush = require('web-push');
const { config } = require('./lib/config'); const { config, getModel, getModels } = require('./lib/config');
const db = require('./lib/db'); const db = require('./lib/db');
const queue = require('./lib/queue'); const queue = require('./lib/queue');
const { runResearchJob } = require('./lib/research'); const { runResearchJob } = require('./lib/research');
@@ -159,10 +159,36 @@ app.get('/api/sessions', requireAuth, (req, res) => {
}); });
app.post('/api/sessions', requireAuth, (req, res) => { app.post('/api/sessions', requireAuth, (req, res) => {
const session = db.createSession(req.user.n); const session = db.createSession(req.user.n, 'Neue Recherche', getModel(config.model).id);
res.json(session); res.json(session);
}); });
app.patch('/api/sessions/:id', requireAuth, (req, res) => {
const session = db.getSession(req.params.id);
if (!session || session.owner_name !== req.user.n) {
return res.status(404).json({ error: 'Session nicht gefunden' });
}
const modelId = String(req.body?.model || '');
if (!getModels().some((m) => m.id === modelId)) {
return res.status(400).json({ error: 'Unbekanntes Modell' });
}
db.setSessionModel(session.id, modelId);
res.json(db.getSession(session.id));
});
app.get('/api/models', requireAuth, (req, res) => {
res.json({
default: getModel(config.model).id,
models: getModels().map((m) => ({
id: m.id,
label: m.label,
provider: m.provider,
vision: m.vision,
costNote: m.costNote || null,
})),
});
});
app.get('/api/sessions/:id', requireAuth, (req, res) => { app.get('/api/sessions/:id', requireAuth, (req, res) => {
const session = db.getSession(req.params.id); const session = db.getSession(req.params.id);
if (!session || (session.owner_name !== req.user.n && !session.share_token)) { if (!session || (session.owner_name !== req.user.n && !session.share_token)) {
@@ -204,7 +230,22 @@ app.post('/api/sessions/:id/messages', requireAuth, (req, res) => {
const session = db.getSession(req.params.id); const session = db.getSession(req.params.id);
if (!session) return res.status(404).json({ error: 'Session nicht gefunden' }); if (!session) return res.status(404).json({ error: 'Session nicht gefunden' });
const text = String(req.body?.text || '').trim(); const text = String(req.body?.text || '').trim();
if (!text) return res.status(400).json({ error: 'Leere Nachricht' }); const image = typeof req.body?.image === 'string' && req.body.image ? req.body.image : null;
if (!text && !image) return res.status(400).json({ error: 'Leere Nachricht' });
if (image) {
if (!/^data:image\/(jpeg|png|webp);base64,/.test(image)) {
return res.status(400).json({ error: 'Ungültiges Bild (JPEG/PNG/WebP als Data-URL erwartet)' });
}
if (image.length > 4000000) {
return res.status(400).json({ error: 'Bild zu groß (max. ~3 MB)' });
}
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).`,
});
}
}
if (session.status === 'running' || session.status === 'queued') { if (session.status === 'running' || session.status === 'queued') {
return res.status(409).json({ error: 'Für diese Session läuft bereits ein Job.' }); return res.status(409).json({ error: 'Für diese Session läuft bereits ein Job.' });
} }
@@ -212,7 +253,7 @@ app.post('/api/sessions/:id/messages', requireAuth, (req, res) => {
return res.status(409).json({ error: 'Eine andere Recherche läuft gerade – bitte warten.' }); return res.status(409).json({ error: 'Eine andere Recherche läuft gerade – bitte warten.' });
} }
db.createMessage(session.id, { role: 'user', content: text }); db.createMessage(session.id, { role: 'user', content: text, image });
if (session.title === 'Neue Recherche') { if (session.title === 'Neue Recherche') {
db.setSessionTitle(session.id, text.slice(0, 70)); db.setSessionTitle(session.id, text.slice(0, 70));
} }
@@ -238,7 +279,7 @@ app.post('/api/sessions/:id/messages', requireAuth, (req, res) => {
}; };
await runResearchJob( await runResearchJob(
{ apiKey: config.apiKey, model: config.model, thinking: config.thinking, baseUrl: config.baseUrl }, config,
db.getSession(session.id), db.getSession(session.id),
text, text,
emit, emit,
@@ -338,7 +379,18 @@ function sendPush(session, result) {
// Statische Dateien & Dashboard-Dateien // Statische Dateien & Dashboard-Dateien
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
app.use('/dashboards', express.static(path.join(__dirname, 'dashboards'), { maxAge: '1h' })); app.use(
'/dashboards',
express.static(path.join(__dirname, 'dashboards'), {
// Kein Caching: Dashboards werden bei Folge-Nachrichten überschrieben,
// der Browser soll immer die aktuelle Version sehen.
maxAge: 0,
setHeaders: (res, filePath) => {
if (filePath.endsWith('.gpx')) res.setHeader('Content-Type', 'application/gpx+xml');
if (filePath.endsWith('.tour.json')) res.setHeader('Content-Type', 'application/json');
},
})
);
app.use('/manifest.json', express.static(path.join(__dirname, 'public', 'manifest.json'))); app.use('/manifest.json', express.static(path.join(__dirname, 'public', 'manifest.json')));
app.use('/sw.js', express.static(path.join(__dirname, 'public', 'sw.js'))); app.use('/sw.js', express.static(path.join(__dirname, 'public', 'sw.js')));
app.use('/icons', express.static(path.join(__dirname, 'public', 'icons'))); app.use('/icons', express.static(path.join(__dirname, 'public', 'icons')));
@@ -361,7 +413,10 @@ app.get('/', (req, res) => {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
if (!config.apiKey) { if (!config.apiKey) {
console.error('WARNUNG: DEEPSEEK_API_KEY fehlt in .env – Recherche wird 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.');