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
This commit is contained in:
nico committed 2026-08-27 17:43:13 +02:00
1 parent eee59363d4
commit 87eb7029f1
10 files changed
+843 -70

No files matched your search

+224 -1
View File
@@ -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, '&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() {
if (browser) {
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 };