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:
1 parent
eee59363d4
commit
87eb7029f1
10 files changed
+843
-70
No files matched your search
+224
-1
@@ -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, '"')
|
||||
.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) =>
|
||||
` <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 };
|
||||
Reference in new issue
Block a user