fox/resources/js/fox-hud.js

1914 lines
86 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

// Fox HUD — Alpine.js Komponente. ES-Modul, gebundlet via app.js.
import { WIEN_UBAHN, WIEN_STOPS } from './fox-data.js';
document.addEventListener('alpine:init', () => {
Alpine.data('foxHud', (cities, homeCity) => ({
cities,
homeCity,
time: '',
dateLabel: '',
weatherLabel: '— · —',
mapVisible: false,
edgeGlow: false,
micActive: false,
map: null,
mapLoaded: false,
currentCenter: [16.3738, 48.2082],
currentLatLng: { lat: 48.2082, lng: 16.3738 },
stats: { cpu: 0, ram_gb: 0 },
ollamaOk: null,
openaiOk: false,
reverbOk: false,
// Phrasen-Pools: Fox soll nicht jedesmal den gleichen Satz sagen.
phrases: {
retrying: [
'Sekunde, der Server hat kurz geblinzelt - ich versuchs nochmal.',
'Hmm, einer von beiden hat gerade geschlafen. Moment.',
'Komische Pause - ich frag nochmal nach.',
'Kurz, das Netz war zickig. Zweiter Anlauf.',
'Moment, das hat geruckelt. Probier ich nochmal.',
],
routeFailed: [
'Ich komm grad nicht an die Route ran. Probier es in einer Minute nochmal.',
'Der Routendienst ist gerade weg vom Fenster. Gib mir gleich nochmal die Chance.',
'Mehrfach probiert - keine Verbindung. Eine Minute später nochmal?',
'Aktuell kein Durchkommen zum Kartendienst. Versuchs gleich nochmal.',
],
recommendIntro: [
'Probier doch',
'Ich würd dir',
'Schau mal',
'Magst du',
'Mein Tipp:',
],
confirmYes: [
'Geht klar.',
'Mach ich.',
'Passt.',
'Auf gehts.',
'Schon dran.',
],
confirmNo: [
'Okay, alles klar.',
'Verstehe, lass uns das.',
'Passt, dann nicht.',
'Alles gut.',
],
},
pick(arr) { return arr[Math.floor(Math.random() * arr.length)]; },
routeInfoVisible: false,
routeInfoLabel: '',
routeStepsVisible: false,
routeSteps: [],
routeStepsTarget: '',
placeInfo: null,
placeInfoVisible: false,
placeInfoExpanded: false,
placeInfoLoading: false,
placeDesk: { visible: false, data: null, liftedCard: null },
statusVisible: false,
statusLines: [],
statusScanning: false,
cityName: null,
cityChoices: [],
cityChoicesVisible: false,
cityChoiceQuery: '',
model: 'qwen2.5:32b',
transitOn: false,
stopsOn: false,
mediaRecorder: null,
micChunks: [],
poiList: [],
poiLabel: '',
poiLoading: false,
isSpeaking: false,
brainVisible: false,
brainStats: { nodes: 0, edges: 0, pending: 0 },
_brainInit: false,
codePanel: {
visible: false,
title: 'fox@terminal',
description: '',
code: '',
language: 'php',
files: [],
activeFile: 0,
typing: false,
typingText: 'Fox schreibt...',
},
codeCopied: false,
init() {
this.tick();
setInterval(() => this.tick(), 1000);
// HUD-State ständig synchron mit Livewire halten
setInterval(() => this.updateHudState(), 1200);
// GPS-Position holen (HTTPS-Pflicht - läuft jetzt via fox.pxo.at).
// Wird bei Routenanfragen als Origin genutzt statt Map-Mitte.
if (navigator.geolocation) {
navigator.geolocation.getCurrentPosition(
(pos) => {
this._userLocation = { lat: pos.coords.latitude, lng: pos.coords.longitude };
console.log('[Fox HUD] GPS:', this._userLocation);
},
(err) => console.log('[Fox HUD] GPS abgelehnt/fehler:', err.message),
{ enableHighAccuracy: false, timeout: 8000, maximumAge: 300000 },
);
}
window.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
if (this.brainVisible) { this.closeBrain(); return; }
if (this.codePanel?.visible) { this.closeCodePanel(); return; }
}
});
window.addEventListener('mousemove', (e) => {
if (this.$refs.cur) {
this.$refs.cur.style.left = e.clientX + 'px';
this.$refs.cur.style.top = e.clientY + 'px';
this.$refs.curDot.style.left = e.clientX + 'px';
this.$refs.curDot.style.top = e.clientY + 'px';
}
});
// Echo-Bridges: Listener referenzieren window-Globals,
// bei Re-Mount zeigt die Variable auf die aktuelle Instanz.
window._foxAction = (data) => this.onAction(data);
window._foxHudCloseBrain = () => this.closeBrain();
window._foxStats = (data) => {
this.stats = {
cpu: data?.cpu ?? 0,
ram_gb: Math.round((data?.memory?.used_mb ?? 0) / 1024),
};
this.ollamaOk = !!data?.ollama?.online;
this.openaiOk = !!data?.openai?.online;
};
window._foxWeather = (data) => {
if (data?.temperature !== null && data?.temperature !== undefined) {
this.weatherLabel = `${data.city.toUpperCase()} · ${data.temperature.toString().replace('.', ',')}°C`;
}
};
window._foxMessage = (data) => {
if (data?.role === 'assistant') {
this.flashEdge();
if (data?.content) this.notify(data.content);
}
if (data?.role === 'tool' && data?.metadata?.url) {
window.open(data.metadata.url, '_blank', 'noopener');
this.notify(`${data.content || 'Datei geöffnet'}`);
}
};
// Bridge: bootstrap.js feuert window-Event wenn die globale Audio
// läuft (LLM-Antwort). Wir schalten dann den Speaking-State auf true.
window.addEventListener('fox-speaking', (e) => { this.isSpeaking = !!e.detail; });
this.$watch('isSpeaking', (val) => {
const el = this.$el;
const targets = [
el.querySelector('.fox-arc-top'),
el.querySelector('.fox-arc-mid'),
el.querySelector('.fox-arc-bot'),
...Array.from(el.querySelectorAll('.fox-outer-ring circle')),
];
if (val) {
this._stopArcRandom();
this._smoothRate(targets, 10, 600);
} else {
this._smoothRate(targets, 1, 1000);
setTimeout(() => this._startArcRandom(), 1100);
}
});
this.$watch('placeDesk.visible', (val) => {
document.body.classList.toggle('desk-visible', val);
});
this.$nextTick(() => this._startArcRandom());
this.initMap();
this.subscribeEcho();
document.addEventListener('livewire:morphed', () => {
if (this.mapVisible) {
document.body.classList.add('map-visible');
if (this.map) this.$nextTick(() => { try { this.map.resize(); } catch (e) {} });
}
this.updateHudState();
});
},
subscribeEcho(retries = 20) {
if (window._foxHudEchoInit) {
this.reverbOk = true;
return;
}
if (!window.Echo) {
if (retries > 0) {
setTimeout(() => this.subscribeEcho(retries - 1), 200);
} else {
console.warn('[Fox HUD] window.Echo nicht verfügbar');
}
return;
}
window._foxHudEchoInit = true;
console.log('[Fox HUD] Echo gefunden, abonniere fox-channel');
const ch = window.Echo.channel('fox');
ch.listen('.fox.action', (p) => { console.log('[Fox HUD] fox.action:', p); window._foxAction?.(p); });
ch.listen('.message.created', (p) => window._foxMessage?.(p));
ch.listen('.stats.system', (p) => window._foxStats?.(p));
ch.listen('.stats.weather', (p) => window._foxWeather?.(p));
this.reverbOk = true;
},
initMap() {
if (typeof maplibregl === 'undefined') {
console.error('[Fox HUD] maplibregl nicht geladen');
return;
}
if (window._foxHudMap) {
console.log('[Fox HUD] Map existiert bereits - reuse');
this.map = window._foxHudMap;
this.mapLoaded = window._foxHudMapLoaded === true;
if (!this.mapLoaded) {
this.map.once('load', () => { this.mapLoaded = true; window._foxHudMapLoaded = true; });
}
return;
}
const target = document.getElementById('map');
if (!target) {
setTimeout(() => this.initMap(), 100);
return;
}
try {
this.map = new maplibregl.Map({
container: target,
style: 'https://tiles.openfreemap.org/styles/dark',
center: this.currentCenter,
zoom: 2, pitch: 0, bearing: 0,
antialias: true,
attributionControl: false,
fadeDuration: 150,
});
window._foxHudMap = this.map;
this.map.on('load', () => {
this.mapLoaded = true;
window._foxHudMapLoaded = true;
console.log('[Fox HUD] Map ready, size:', target.offsetWidth, 'x', target.offsetHeight);
});
this.map.on('error', (e) => console.warn('[Fox HUD] Map error:', e?.error?.message || e));
} catch (err) {
console.error('[Fox HUD] Map-Init-Fehler:', err);
}
},
tick() {
const now = new Date();
this.time = now.toLocaleTimeString('de-AT', { hour12: false });
const day = now.toLocaleDateString('de-AT', { weekday: 'short' }).toUpperCase().replace('.', '');
const date = now.toLocaleDateString('de-AT', { day: '2-digit', month: 'short', year: 'numeric' });
this.dateLabel = `${day} ${date.toUpperCase()}`;
},
stateLabel() {
if (this.poiLoading) return 'Sucht';
const s = this.$wire.status;
return ({ thinking: 'Denkt nach', speaking: 'Spricht', listening: 'Hört' }[s]) || 'Bereit';
},
async onAction(payload) {
console.log('[Fox HUD] onAction:', payload);
if (!payload) return;
// User hat etwas getippt → ausstehende Followup-Frage abbrechen
this.cancelFollowup();
// Dedup: IntentDetector feuert sofort, das LLM ~5s später oft mit gleicher
// Action+City. Ohne Guard würden wir doppelt sprechen.
const dedupKey = [
payload.action,
payload.city || '',
payload.category || '',
payload.cuisine || '',
payload.auto_recommend ? 'rec' : '',
payload.to || '',
].join(':');
const now = Date.now();
if (this._lastActionKey === dedupKey && now - (this._lastActionAt || 0) < 12000) {
console.log('[Fox HUD] dedup: skip', dedupKey);
return;
}
this._lastActionKey = dedupKey;
this._lastActionAt = now;
// URL in neuem Tab öffnen (z.B. generiertes Template)
if (payload.action === 'open_url' && payload.url) {
if (payload.text) { this.say(payload.text); this.notify(payload.text); }
setTimeout(() => window.open(payload.url, '_blank', 'noopener'), 800);
return;
}
// Sofort-Ankündigung ("Einen Moment, ich schau...") - nur sprechen
if (payload.action === 'announce') {
if (payload.text) {
this.notify(payload.text);
this.say(payload.text);
}
return;
}
// Chain: nach Hauptaktion noch eine Folgeaktion ausführen (z.B. "schließen UND erinner...")
if (payload.chain && Array.isArray(payload.chain)) {
const chain = payload.chain;
delete payload.chain;
setTimeout(() => chain.forEach((next, i) => setTimeout(() => this.onAction(next), i * 250)), 400);
}
// Dynamische Map: unbekannte Stadt via Google Geocoding lookup
if (payload.action === 'map_dynamic' && payload.query) {
this.lookupCity(payload.query);
return;
}
// Reminder / Timer - mit menschlich klingenden Erinnerungs-Sätzen
if (payload.action === 'reminder' && payload.delay_seconds) {
if (payload.text) this.say(payload.text);
this.notify(`${payload.what} · ${this.formatDelay(payload.delay_seconds)}`);
setTimeout(() => {
this.flashEdge();
this.notify(`${payload.what}`);
this.say(this.buildReminderSentence(payload.what));
}, payload.delay_seconds * 1000);
return;
}
// Brain-Visualisierung öffnen
if (payload.action === 'brain') {
if (payload.text) this.notify(payload.text);
await this.openBrain();
return;
}
// System-Status / Diagnose mit AI-Animation
if (payload.action === 'system_status') {
this.runSystemStatus();
return;
}
// "Karte zu / schließen" → Map verstecken, Fox in die Mitte
if (payload.action === 'close_map') {
this.closeMap();
this.removeRouteLayer();
if (payload.text) this.say(payload.text);
else this.say(this.pick(['Karte zu.', 'Erledigt.', 'Geschlossen.', 'Passt.']));
return;
}
if (payload.action === 'close_poi') {
this.clearPois();
if (payload.text) this.say(payload.text);
else this.say('POI-Liste weg.');
return;
}
if (payload.action === 'close_transit') {
this.disableTransit?.();
if (payload.text) this.say(payload.text);
else this.say('Liniennetz aus.');
return;
}
if (payload.action === 'close_status') {
this.statusVisible = false;
this.statusLines = [];
if (payload.text) this.say(payload.text);
else this.say('Diagnose zu.');
return;
}
// "Wie schaut der erste Bezirk aus" / "Erzähl mir was über X" → Detail-Panel mit Bild
if (payload.action === 'place_info' && payload.query) {
this.showPlaceInfo(payload.query);
return;
}
// "Zeig mir mehr" → ausführlichere Anzeige zur letzten Place-Info
if (payload.action === 'place_more') {
if (this.placeDesk.visible) { this.placeDesk.liftedCard = 'summary'; return; }
this.expandPlaceInfo();
return;
}
if (payload.action === 'desk_card') {
if (payload.card) {
if (this.placeDesk.visible) {
this.placeDesk.liftedCard = payload.card;
} else if (this._lastRecommend) {
this._pendingDeskCard = payload.card;
this.showPlaceInfo('__LAST_RECOMMEND__');
}
}
return;
}
if (payload.action === 'desk_down') {
this.placeDesk.liftedCard = null;
return;
}
// "Wie lange brauche ich" → letzte Route nochmal vorlesen, kein neuer Call.
if (payload.action === 'route_info') {
this.announceLastRoute();
return;
}
// Route: GPS bevorzugen → sonst Map-Mitte → sonst Heimat.
// Spezialfall "__LAST_RECOMMEND__": Ziel ist die zuletzt empfohlene Stelle.
if (payload.action === 'route' && payload.to) {
const origin = payload.from
|| (this._userLocation
? `${this._userLocation.lat},${this._userLocation.lng}`
: (this.mapVisible
? `${this.currentLatLng.lat},${this.currentLatLng.lng}`
: (this.homeCity ? `${this.homeCity.lat},${this.homeCity.lng}` : null)));
if (!origin) {
this.say('Ich brauche einen Startpunkt - sag mir woher.');
return;
}
let dest = payload.to;
if (dest === '__LAST_RECOMMEND__') {
if (this._lastRecommend) {
dest = `${this._lastRecommend.lat},${this._lastRecommend.lng}`;
// KEINE vorab-Ansage - sonst doppelter Ton mit dem Result-Spruch.
} else {
this.say('Ich weiß nicht wohin du meinst - sag mir konkret den Namen.');
return;
}
} else if (dest === '__LAST_DESTINATION__') {
// Mode-Wechsel ("zu fuß", "mit ubahn") - gleiches Ziel, neuer Mode.
if (this._lastRoute?.destination) {
dest = this._lastRoute.destination;
} else if (this._lastRecommend) {
dest = `${this._lastRecommend.lat},${this._lastRecommend.lng}`;
} else {
this.say('Ich habe noch keine Route. Sag mir wohin.');
return;
}
}
// payload.text wird bewusst NICHT vorab gesprochen - die requestRoute-Antwort
// mit km/min ist informativer und kommt 1-2s später.
this.requestRoute(origin, dest, payload.mode || 'driving');
return;
}
// Empfehlung: zoom auf den genannten Ort aus der aktuellen POI-Liste,
// sonst Geocoding via Google damit auch halluzinierte Namen aufgelöst werden.
if (payload.action === 'recommend' && payload.place) {
const target = this.findPoiByName(payload.place);
if (target) {
this.flyToPoi(target);
this._lastRecommend = { name: target.name, lat: target.lat, lng: target.lng, address: target.address };
this.notify(`${target.name}`);
if (payload.text) this.say(payload.text);
} else {
console.warn('[Fox HUD] recommend: place nicht in poiList → Geocoding-Versuch:', payload.place);
this.geocodeAndZoom(payload.place, payload.text);
}
return;
}
// Code-Antwort → Terminal Panel
if (payload.action === 'code') {
this.showCodePanel({
description: payload.text || '',
code: payload.code || '',
language: payload.language || 'php',
files: payload.files || [],
});
return;
}
// Rechenergebnis
if (payload.action === 'calculate') {
if (payload.result) this.notify('= ' + payload.result);
if (payload.text) this.notify(payload.text);
return;
}
// Spotify
if (payload.action === 'spotify_play') { window.foxBridge?.macCommand('spotify_play'); if (payload.text) this.notify(payload.text); return; }
if (payload.action === 'spotify_pause') { window.foxBridge?.macCommand('spotify_pause'); if (payload.text) this.notify(payload.text); return; }
if (payload.action === 'spotify_next') { window.foxBridge?.macCommand('spotify_next'); if (payload.text) this.notify(payload.text); return; }
if (payload.action === 'spotify_prev') { window.foxBridge?.macCommand('spotify_prev'); if (payload.text) this.notify(payload.text); return; }
// Desktop Actions
if (payload.action === 'mail_open') { window.foxBridge?.macCommand('mail_open'); if (payload.text) this.notify(payload.text); return; }
if (payload.action === 'volume_up') { window.foxBridge?.macCommand('volume_up'); if (payload.text) this.notify(payload.text); return; }
if (payload.action === 'volume_down') { window.foxBridge?.macCommand('volume_down'); if (payload.text) this.notify(payload.text); return; }
if (payload.action === 'screenshot') { window.foxBridge?.macCommand('screenshot'); if (payload.text) this.notify(payload.text); return; }
if (payload.action === 'save_desktop') { window.foxBridge?.macCommand('save_desktop'); if (payload.text) this.notify(payload.text); return; }
if (payload.action === 'open_app' && payload.app) {
window.foxBridge?.macCommand('app:' + payload.app);
if (payload.text) this.notify(payload.text);
return;
}
if (['map','food','weather','transit','poi'].includes(payload.action)) {
let city = payload.data;
if (!city && payload.city) {
city = this.cities[payload.city.toLowerCase().trim()];
if (!city) console.warn('[Fox HUD] Stadt unbekannt:', payload.city);
}
if (!city && payload.action === 'poi') {
if (this.mapVisible) {
city = { lng: this.currentLatLng.lng, lat: this.currentLatLng.lat, zoom: 14, name: 'Hier', bearing: 0 };
} else if (this.homeCity) {
city = this.homeCity;
}
}
// POI: Karte NICHT automatisch öffnen — nur auf explizite Karten-Anfrage
if (city && payload.action !== 'poi') {
this.flyTo(city);
if (payload.action === 'transit') {
setTimeout(() => this.enableTransit(), 4500);
}
}
if (payload.action === 'poi' && payload.category) {
const label = payload.label || payload.category;
const autoRec = !!payload.auto_recommend;
const searchLat = city?.lat || this.currentLatLng.lat;
const searchLng = city?.lng || this.currentLatLng.lng;
if (!autoRec) this.say(`Ich suche ${label}.`);
setTimeout(() => {
this.searchPois(payload.category, label, searchLat, searchLng, 2000, payload.cuisine || null, autoRec);
}, 100);
}
// Map/Weather/Transit haben keinen eigenen Spruch - dann sagen wir den Reply-Text
if (payload.text && ['map','weather','transit'].includes(payload.action)) {
this.say(payload.text);
}
}
if (payload.text) this.notify(payload.text);
},
flyTo(city) {
if (!city || typeof city.lng !== 'number') return;
console.log('[Fox HUD] flyTo:', city.name, city);
if (!this.map) this.initMap();
this.mapVisible = true;
this.cityName = city.name || null;
this.currentLatLng = { lat: city.lat, lng: city.lng };
this.currentCenter = [city.lng, city.lat];
this.spawnZoomRings();
this.notify(`${city.name}`);
// Map-Layer hart sichtbar setzen - Body-Klasse PLUS direkte Styles,
// damit jede Visibility-Reset-Variante (Alpine, Livewire-Morph) abgefangen ist.
document.body.classList.add('map-visible');
const ml = document.getElementById('map-layer');
if (ml) {
ml.style.display = 'block';
ml.style.opacity = '1';
ml.style.pointerEvents = 'auto';
ml.style.visibility = 'visible';
ml.classList.add('show');
}
document.getElementById('fox-center')?.classList.add('hide');
document.getElementById('fox-mini')?.classList.add('show');
// 200ms warten damit der Browser die Layout-Änderungen committed hat,
// dann erst resize() (sonst rechnet MapLibre auf alter Container-Größe).
setTimeout(() => {
if (!this.map) return;
try { this.map.resize(); } catch (e) {}
this.map.flyTo({
center: [city.lng, city.lat],
zoom: city.zoom,
pitch: 50,
bearing: city.bearing || 0,
duration: 8000,
curve: 2.4,
speed: 0.3,
screenSpeed: 0.6,
essential: true,
});
}, 200);
this.updateHudState();
},
closeMap() {
// Alles aufräumen - egal in welchem Zustand wir sind.
this.clearPois();
this.removeRouteLayer();
this.routeStepsVisible = false;
this.routeInfoVisible = false;
this.statusVisible = false;
this.statusLines = [];
this.placeInfoVisible = false;
this.placeInfo = null;
this.placeDesk = { visible: false, data: null, liftedCard: null };
document.body.classList.remove('map-visible', 'poi-visible', 'route-visible');
this.mapVisible = false;
this.cancelFollowup();
this._pendingConfirm = null;
// Inline-Styles vom flyTo zurücksetzen - sonst überschreiben sie CSS
// und der Map-Layer bleibt sichtbar trotz body-Klasse weg.
const ml = document.getElementById('map-layer');
if (ml) {
ml.style.opacity = '0';
ml.style.pointerEvents = 'none';
ml.classList.remove('show');
// display/visibility erst nach Fade-Out wegnehmen
setTimeout(() => {
if (!this.mapVisible) {
ml.style.display = 'none';
ml.style.visibility = 'hidden';
}
}, 850);
}
if (this.map) {
try {
this.map.flyTo({ zoom: 2, pitch: 0, bearing: 0, duration: 1800, curve: 2.0, essential: true });
} catch (e) { /* egal */ }
}
this.updateHudState();
},
spawnZoomRings() {
const c = this.$root.querySelector('#zoom-rings');
if (!c) return;
for (let i = 0; i < 3; i++) {
setTimeout(() => {
const r = document.createElement('div');
r.className = 'zring';
c.appendChild(r);
setTimeout(() => r.remove(), 1700);
}, i * 400);
}
},
_smoothRateAnim(anim, target, ms) {
const dur = anim.effect?.getComputedTiming()?.duration ?? 0;
const ct = anim.currentTime ?? 0;
if (dur > 0) {
// Going backward: bump currentTime up by 100 cycles so it never hits 0.
// Adding exact multiples of duration is visually imperceptible (periodic anim).
if (target < 0 && ct < dur * 20) anim.currentTime = ct + dur * 100;
// Already slipped negative: reset to mid-cycle.
else if (ct < 0) anim.currentTime = dur * 0.5;
}
const from = anim.playbackRate;
const t0 = performance.now();
const step = (now) => {
const p = Math.min((now - t0) / ms, 1);
const e = p < 0.5 ? 2*p*p : 1 - Math.pow(-2*p + 2, 2) / 2;
anim.playbackRate = from + (target - from) * e;
if (p < 1) requestAnimationFrame(step);
};
requestAnimationFrame(step);
},
_smoothRate(elements, targetRate, ms) {
elements.forEach(el => {
if (!el) return;
el.getAnimations().forEach(a => this._smoothRateAnim(a, targetRate, ms));
});
},
_startArcRandom() {
this._arcRnd = true;
const el = this.$el;
const schedule = (sel, minMs, maxMs) => {
if (!this._arcRnd) return;
setTimeout(() => {
if (!this._arcRnd) return;
el.querySelector(sel)?.getAnimations().forEach(a => {
this._smoothRateAnim(a, Math.random() > 0.5 ? 1 : -1, 500 + Math.random() * 600);
});
schedule(sel, minMs, maxMs);
}, minMs + Math.random() * (maxMs - minMs));
};
schedule('.fox-arc-top', 500, 1800);
setTimeout(() => schedule('.fox-arc-bot', 500, 1800), 300);
setTimeout(() => schedule('.fox-arc-mid', 500, 1800), 600);
},
_stopArcRandom() { this._arcRnd = false; },
closePlaceDesk() {
this.placeDesk = { visible: false, data: null, liftedCard: null };
},
toggleDeskCard(type) { this.placeDesk.liftedCard = (this.placeDesk.liftedCard === type) ? null : type; },
putDeskCardDown() { this.placeDesk.liftedCard = null; },
flashEdge() { this.edgeGlow = true; setTimeout(() => { this.edgeGlow = false; }, 1500); },
notify(text) {
const c = this.$refs.notif;
if (!c) return;
const el = document.createElement('div');
el.className = 'notif';
el.textContent = text.length > 180 ? text.slice(0, 180) + '…' : text;
c.appendChild(el);
setTimeout(() => { el.style.opacity = 0; setTimeout(() => el.remove(), 400); }, 6000);
},
showCodePanel(data) {
this.codePanel.visible = true;
this.codePanel.typing = true;
this.codePanel.typingText = 'Fox schreibt...';
this.codePanel.description = data.description || '';
this.codePanel.language = data.language || 'php';
this.codePanel.title = `fox@terminal — ${data.language || 'code'}`;
this.codePanel.files = data.files || [];
this.codePanel.activeFile = 0;
this.codeCopied = false;
setTimeout(() => {
this.codePanel.typing = false;
this.codePanel.code = data.code || '';
}, 800);
},
closeCodePanel() {
this.codePanel.visible = false;
this.codePanel.code = '';
this.codePanel.files = [];
this.codeCopied = false;
},
copyCode() {
const code = this.codePanel.files.length > 1
? (this.codePanel.files[this.codePanel.activeFile]?.code || this.codePanel.code)
: this.codePanel.code;
navigator.clipboard.writeText(code).then(() => {
this.codeCopied = true;
setTimeout(() => { this.codeCopied = false; }, 2000);
});
},
selectFile(index) {
this.codePanel.activeFile = index;
this.codePanel.code = this.codePanel.files[index]?.code || '';
this.codePanel.language = this.codePanel.files[index]?.language || 'php';
},
async openBrain() {
this.brainVisible = true;
document.body.classList.add('brain-visible');
const info = document.getElementById('brain-info');
if (info) info.style.display = 'block';
document.getElementById('brain-popup') && (document.getElementById('brain-popup').style.display = 'none');
await new Promise(r => setTimeout(r, 350));
if (!this._brainInit && window.initFoxBrain) {
this._brainInit = true;
try {
await window.initFoxBrain('/fox/brain/graph', (stats) => {
this.brainStats = stats;
const n = document.getElementById('bi-nodes');
const e = document.getElementById('bi-edges');
const p = document.getElementById('bi-pending');
if (n) n.textContent = stats.nodes;
if (e) e.textContent = stats.edges;
if (p) p.textContent = stats.pending;
});
} catch(err) {
console.error('[FoxBrain] init failed:', err);
this._brainInit = false;
}
} else if (!window.initFoxBrain) {
console.error('[FoxBrain] window.initFoxBrain not defined');
}
},
closeBrain() {
this.brainVisible = false;
document.body.classList.remove('brain-visible');
const info = document.getElementById('brain-info');
if (info) info.style.display = 'none';
document.getElementById('brain-popup') && (document.getElementById('brain-popup').style.display = 'none');
if (window.destroyFoxBrain) { window.destroyFoxBrain(); this._brainInit = false; }
},
escapeHtml(text) {
return String(text)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
},
// ─── POI-Suche ─────────────────────────────────────────
async searchPois(category, label, lat, lng, radius = 2000, cuisine = null, autoRecommend = false) {
this.notify(`Suche ${label || category}`);
await this.fadeOutPois();
this.poiList = [];
this.poiLabel = label || category;
this.poiLoading = true;
document.body.classList.add('poi-visible');
const filterMap = {
restaurant: '["amenity"="restaurant"]',
cafe: '["amenity"="cafe"]',
bar: '["amenity"~"^(bar|pub|nightclub)$"]',
fast_food: '["amenity"="fast_food"]',
pharmacy: '["amenity"="pharmacy"]',
hospital: '["amenity"~"^(hospital|clinic)$"]',
doctor: '["amenity"="doctors"]',
bank: '["amenity"~"^(bank|atm)$"]',
fuel: '["amenity"="fuel"]',
supermarket: '["shop"~"^(supermarket|convenience)$"]',
hotel: '["tourism"~"^(hotel|hostel|guest_house)$"]',
government: '["amenity"~"^(townhall|courthouse|public_building)$"]',
police: '["amenity"="police"]',
parking: '["amenity"="parking"]',
};
const baseFilter = filterMap[category] || filterMap.restaurant;
// Mehrere Overpass-Mirrors - der erste der binnen 8s antwortet, gewinnt.
const MIRRORS = [
'https://overpass-api.de/api/interpreter',
'https://overpass.kumi.systems/api/interpreter',
'https://overpass.openstreetmap.fr/api/interpreter',
'https://overpass.private.coffee/api/interpreter',
];
const buildQuery = (cf, r) => `[out:json][timeout:8];(node${baseFilter}${cf}(around:${r},${lat},${lng}););out body 50;`;
const tryOverpass = async (q) => {
for (const url of MIRRORS) {
try {
const ctrl = new AbortController();
const t = setTimeout(() => ctrl.abort(), 8500);
const res = await fetch(url, {
method: 'POST',
body: 'data=' + encodeURIComponent(q),
signal: ctrl.signal,
});
clearTimeout(t);
if (!res.ok) { console.warn('[Fox HUD] Overpass', url, 'HTTP', res.status); continue; }
const data = await res.json();
return (data.elements || [])
.filter(e => e.tags?.name)
.slice(0, 50)
.map(e => ({
id: 'poi-' + e.id, name: e.tags.name,
address: this.formatAddress(e.tags),
category, lat: e.lat, lng: e.lon,
}));
} catch (e) {
console.warn('[Fox HUD] Overpass', url, 'failed:', e.name);
// weiter zum nächsten Mirror
}
}
return null; // alle Mirrors tot
};
// Letzter Rettungsanker: Google Places via Backend.
const tryGooglePlaces = async (r) => {
try {
const res = await fetch('/fox/places', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name=csrf-token]').content,
},
body: JSON.stringify({ category, lat, lng, radius: r, cuisine, label }),
});
if (!res.ok) return null;
const data = await res.json();
return (data.pois || []).map(p => ({ ...p, category }));
} catch (e) {
console.warn('[Fox HUD] Google Places fallback failed:', e);
return null;
}
};
try {
let pois = null;
// 1. Overpass mit Cuisine-Filter
pois = await tryOverpass(buildQuery(cuisine ? `["cuisine"~"${cuisine}"]` : '', radius));
// 2. Overpass ohne Cuisine
if ((!pois || pois.length === 0) && cuisine) pois = await tryOverpass(buildQuery('', radius));
// 3. Overpass größerer Radius
if (!pois || pois.length === 0) pois = await tryOverpass(buildQuery('', radius * 3));
// 4. Google Places (Cloud) - garantierter Treffer wenn Daten vorhanden
if (!pois || pois.length === 0) {
console.log('[Fox HUD] Overpass tot oder leer, fallback auf Google Places');
this.notify(`Suche länger als gewohnt - hole Daten direkt`);
pois = await tryGooglePlaces(radius);
}
this.poiLoading = false;
if (!pois || pois.length === 0) {
this.notify(`Keine ${label} im Umkreis gefunden`);
this.say(`Ich habe leider keine ${label} in der Nähe gefunden. Probier es mit einem größeren Suchradius.`);
return;
}
this.poiList = pois;
if (this.mapVisible && this.map) this.renderPoisOnMapStaggered(pois);
this.notify(`${pois.length} ${label} geladen`);
this.persistPoiContext(category, label, pois);
if (autoRecommend) {
// Ersten Treffer (Google sortiert nach Relevanz, OSM nach Distanz) als Empfehlung
const pick = pois.sort((a, b) => (b.rating || 0) - (a.rating || 0))[0] || pois[0];
setTimeout(() => {
this.flyToPoi(pick);
this._lastRecommend = { name: pick.name, lat: pick.lat, lng: pick.lng, address: pick.address };
this.notify(`${pick.name}`);
const ratingNote = pick.rating ? ` - hat ${String(pick.rating).replace('.', ',')} Sterne` : '';
const intro = this.pick(this.phrases.recommendIntro);
this.say(`${intro} ${pick.name}${pick.address ? ' in der ' + pick.address : ''}${ratingNote}.`);
// Detail-Desk automatisch öffnen mit Google Places Daten
const infoQuery = pick.name + (this.homeCity?.name ? ', ' + this.homeCity.name : '');
setTimeout(() => this.showPlaceInfo(infoQuery), 2000);
// Followup nur einmal pro Stunde - nicht jedesmal nervig.
const lastAsk = this._followupLastAskAt || 0;
if (Date.now() - lastAsk > 60 * 60 * 1000) {
this.queueFollowup(
'Soll ich dir die Route dahin rechnen?',
6500,
() => this.requestRoute(
this._userLocation ? `${this._userLocation.lat},${this._userLocation.lng}` : `${this.currentLatLng.lat},${this.currentLatLng.lng}`,
`${pick.lat},${pick.lng}`,
'driving',
),
);
}
}, 1500);
} else {
this.say(`Ich habe ${pois.length} ${label} gefunden.`);
}
} catch (err) {
console.warn('[Fox HUD] searchPois unrecoverable:', err);
this.poiLoading = false;
this.notify('Alle Datenquellen offline');
this.say('Sowohl OpenStreetMap als auch Google sind grad nicht erreichbar. Versuch es in einer Minute nochmal.');
}
},
// Letzte POI-Treffer ans Backend (Cache) - damit Fox sie als
// Kontext fürs LLM nutzen kann ("empfehl mir eins").
async persistPoiContext(category, label, pois) {
try {
await fetch('/fox/poi-context', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name=csrf-token]').content,
},
body: JSON.stringify({
category, label,
city: this.poiLabel || '',
pois: pois.map(p => ({
name: p.name, address: p.address || '',
lat: p.lat, lng: p.lng,
})),
}),
});
} catch (e) {
console.warn('[Fox HUD] poi-context POST fehlgeschlagen:', e);
}
},
async say(text) {
if (!text) return;
// Generation-Token: wenn ein neuerer say() startet, verwirft das den älteren
// auch wenn dessen Fetch erst jetzt zurückkommt.
this._sayGen = (this._sayGen || 0) + 1;
const myGen = this._sayGen;
try {
if (this._sayAudio) { this._sayAudio.pause(); this._sayAudio.src = ''; this._sayAudio = null; }
if (this._sayAbort) this._sayAbort.abort();
this._sayAbort = new AbortController();
const res = await fetch('/fox/tts', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name=csrf-token]').content,
},
body: JSON.stringify({ text }),
signal: this._sayAbort.signal,
});
if (myGen !== this._sayGen) return; // veraltet
if (!res.ok) return console.warn('[Fox HUD] /fox/tts HTTP', res.status);
const data = await res.json();
if (myGen !== this._sayGen) return; // veraltet
if (!data?.audio_url) return;
const audio = new Audio(data.audio_url);
audio.addEventListener('play', () => { if (myGen === this._sayGen) this.isSpeaking = true; });
audio.addEventListener('ended', () => { if (myGen === this._sayGen) this.isSpeaking = false; });
audio.addEventListener('pause', () => { if (myGen === this._sayGen) this.isSpeaking = false; });
audio.addEventListener('error', () => { if (myGen === this._sayGen) this.isSpeaking = false; });
this._sayAudio = audio;
audio.play().catch(err => { this.isSpeaking = false; console.warn('[Fox HUD] audio:', err.name); });
} catch (e) {
if (e.name !== 'AbortError') console.warn('[Fox HUD] say():', e);
}
},
async fadeOutPois() {
if (this.poiList.length === 0 && !this.map?.getLayer('pois-layer')) return;
if (this.map?.getLayer('pois-layer')) {
this.map.setPaintProperty('pois-layer', 'circle-opacity-transition', { duration: 300 });
this.map.setPaintProperty('pois-glow', 'circle-opacity-transition', { duration: 300 });
this.map.setPaintProperty('pois-layer', 'circle-opacity', 0);
this.map.setPaintProperty('pois-glow', 'circle-opacity', 0);
}
document.querySelectorAll('.poi-item').forEach(el => el.classList.add('fading-out'));
await new Promise(r => setTimeout(r, 320));
this.removePoiLayer();
},
formatAddress(tags) {
const parts = [];
if (tags['addr:street']) parts.push(tags['addr:street'] + (tags['addr:housenumber'] ? ' ' + tags['addr:housenumber'] : ''));
if (tags['addr:postcode']) parts.push(tags['addr:postcode']);
if (tags.cuisine) parts.push('· ' + tags.cuisine);
return parts.join(' · ') || (tags.amenity || tags.shop || tags.tourism || '');
},
renderPoisOnMapStaggered(pois) {
if (!this.map) return;
this.removePoiLayer();
this.map.addSource('pois', { type: 'geojson', data: { type: 'FeatureCollection', features: [] } });
this.map.addLayer({
id: 'pois-glow', type: 'circle', source: 'pois',
paint: {
'circle-radius': 14, 'circle-color': '#f59e0b',
'circle-opacity': 0.25, 'circle-blur': 0.6,
'circle-opacity-transition': { duration: 350 },
},
});
this.map.addLayer({
id: 'pois-layer', type: 'circle', source: 'pois',
paint: {
'circle-radius': 6, 'circle-color': '#f59e0b',
'circle-stroke-width': 1.5,
'circle-stroke-color': 'rgba(255,255,255,0.9)',
'circle-opacity-transition': { duration: 350 },
},
});
this.map.on('click', 'pois-layer', e => {
const f = e.features[0];
new maplibregl.Popup({ closeButton: false, offset: 14 })
.setLngLat(f.geometry.coordinates)
.setHTML(`<b>${f.properties.name}</b>${f.properties.address ? '<br>' + f.properties.address : ''}`)
.addTo(this.map);
});
const features = [];
pois.forEach((p, i) => {
setTimeout(() => {
features.push({
type: 'Feature',
properties: { id: p.id, name: p.name, address: p.address },
geometry: { type: 'Point', coordinates: [p.lng, p.lat] },
});
if (this.map?.getSource('pois')) {
this.map.getSource('pois').setData({ type: 'FeatureCollection', features: features.slice() });
}
}, i * 60);
});
},
removePoiLayer() {
['pois-layer', 'pois-glow'].forEach(id => {
if (this.map?.getLayer(id)) this.map.removeLayer(id);
});
if (this.map?.getSource('pois')) this.map.removeSource('pois');
},
// Findet einen POI in der aktuellen Liste über fuzzy name match.
findPoiByName(name) {
if (!name || !this.poiList?.length) return null;
const norm = (s) => (s || '').toLowerCase()
.normalize('NFD').replace(/[̀-ͯ]/g, '') // diakritika weg
.replace(/[''`´]/g, "'")
.replace(/[^a-z0-9 ]/g, ' ')
.replace(/\s+/g, ' ').trim();
const t = norm(name);
if (!t) return null;
// 1. Exact normalized
let hit = this.poiList.find(p => norm(p.name) === t);
if (hit) return hit;
// 2. Substring beider Richtungen
hit = this.poiList.find(p => norm(p.name).includes(t) || t.includes(norm(p.name)));
if (hit) return hit;
// 3. Token-Overlap: mind. 1 nicht-trivialer Begriff stimmt überein
const tokens = new Set(t.split(' ').filter(w => w.length > 2));
if (tokens.size) {
hit = this.poiList.find(p => {
const pTokens = norm(p.name).split(' ').filter(w => w.length > 2);
return pTokens.some(pt => tokens.has(pt));
});
if (hit) return hit;
}
// 4. Diagnose-Output: zeig was wir gesucht und was wir hatten
console.warn('[Fox HUD] kein Treffer für "'+name+'" in poiList - Kandidaten:',
this.poiList.slice(0, 8).map(p => p.name));
return null;
},
// Menschliche Erinnerungssätze - variiert + bezieht sich auf Inhalt
buildReminderSentence(what) {
if (!what || what === 'Erinnerung') {
return this.pick([
'Hey, dein Timer ist abgelaufen.',
'Zeit ist um.',
'Der Wecker geht.',
]);
}
const w = what.toLowerCase();
// Kontextabhängige Templates
if (/ess|hunger|nahrung|frühstück|mittag|abendessen|snack/.test(w)) {
return this.pick([
`Hey, du wolltest doch was essen — ${what}.`,
`Magenknurren-Alarm: ${what}.`,
`Vergiss nicht zu essen, du wolltest ${what}.`,
]);
}
if (/wäsche|waschen|wasch|trockner|bügeln/.test(w)) {
return this.pick([
`Du wolltest doch ${what}, jetzt wäre der richtige Moment.`,
`Wäsche-Reminder: ${what}.`,
]);
}
if (/wasser|trinken|kaffee|tee/.test(w)) {
return this.pick([
`Vergiss nicht zu trinken — ${what}.`,
`Kurzer Hinweis: ${what} steht an.`,
]);
}
if (/pause|aufstehen|bewegen|sport/.test(w)) {
return this.pick([
`Zeit für ${what}.`,
`Hey, kleine ${what} würde dir guttun.`,
]);
}
if (/anruf|telefon|call|meeting|termin/.test(w)) {
return this.pick([
`Termin-Erinnerung: ${what}.`,
`${what} steht an.`,
]);
}
return this.pick([
`Du wolltest doch ${what} — jetzt ist der Moment.`,
`Hey, ${what} steht an.`,
`Erinnerung: ${what}. Du hast mich darum gebeten.`,
`Vergiss nicht: ${what}.`,
]);
},
formatDelay(secs) {
if (secs < 60) return secs + 's';
if (secs < 3600) return Math.round(secs / 60) + 'min';
return Math.round(secs / 3600) + 'h';
},
async runSystemStatus() {
this.statusVisible = true;
this.statusScanning = true;
this.statusLines = [];
this.say(this.pick([
'Diagnose läuft.',
'System-Check eingeleitet.',
'Scanne Subsysteme.',
'Status-Audit gestartet.',
]));
const append = async (label, value, ok = true, delay = 180) => {
await new Promise(r => setTimeout(r, delay));
this.statusLines.push({ label, value, ok });
};
await append('PROBE', 'Initializing diagnostic sweep…', true, 80);
let data = null;
try {
const res = await fetch('/fox/status');
if (res.ok) data = await res.json();
} catch (e) { /* ignore */ }
if (!data) {
this.statusScanning = false;
await append('FATAL', 'Status-Endpoint unreachable', false);
return;
}
const sys = data.system, svc = data.services, fox = data.fox;
await append('SYS·CPU', sys.cpu + '%', sys.cpu < 90);
await append('SYS·RAM', (sys.memory.used_mb || 0) + ' MB used', (sys.memory.percent || 0) < 92);
await append('SYS·DISK', (sys.disk.percent || 0) + '%', (sys.disk.percent || 0) < 92);
await append('SYS·LOAD', (sys.load['1m'] || 0).toFixed(2), true);
await append('SYS·UPTIME', sys.uptime.human, true);
await append('LLM·OLLAMA', svc.ollama.online ? 'ONLINE · ' + svc.ollama.models + ' models' : 'OFFLINE', svc.ollama.online);
await append('LLM·OPENAI', svc.openai.online ? 'KEY OK' : 'NO KEY', svc.openai.online);
await append('SVC·REVERB', svc.reverb.online ? 'CONNECTED' : 'DOWN', svc.reverb.online);
await append('GEO·DIRECT', svc.google_directions.online ? 'OK' : 'FAIL', svc.google_directions.online);
await append('GEO·PLACES', svc.google_places.online ? 'OK' : 'FAIL', svc.google_places.online);
await append('GEO·GEOCODE', svc.google_geocoding.online ? 'OK' : 'FAIL', svc.google_geocoding.online);
await append('SVC·SEARXNG', svc.searxng.online ? 'READY' : 'OFFLINE', svc.searxng.online);
await append('FOX·VERSION', fox.version, true);
await append('FOX·VOICE', fox.mode + ' / ' + fox.voice, true);
if (fox.last_action) {
await append('FOX·LAST', fox.last_action.label + ' (' + fox.last_action.count + ')', true);
}
await append('QUEUE', svc.queue.len + ' jobs pending', svc.queue.len < 50);
this.statusScanning = false;
await new Promise(r => setTimeout(r, 200));
const downCount = this.statusLines.filter(l => !l.ok).length;
const summary = downCount === 0
? this.pick(['Alle Systeme grün.', 'Diagnose: alles im Lot.', 'Check abgeschlossen, alles läuft.'])
: (downCount + ' Subsystem' + (downCount === 1 ? '' : 'e') + ' offline. Schau dir die roten Zeilen an.');
this.say(summary);
await append('READY', summary, downCount === 0, 150);
},
closeStatus() {
this.statusVisible = false;
this.statusLines = [];
},
async showPlaceInfo(query) {
// Referenz auf letzte Empfehlung auflösen
if (query === '__LAST_RECOMMEND__') {
if (!this._lastRecommend) {
this.say(this.pick([
'Ich weiß nicht worauf du dich beziehst - frag nach was Konkretem.',
'Sag mir den Namen, dann zeig ich dir Infos.',
'Wozu genau? Sag mir den Ort.',
]));
return;
}
query = this._lastRecommend.name + (this.homeCity?.name ? ' ' + this.homeCity.name : '');
}
this.placeInfoLoading = true;
this.placeInfoVisible = true;
this.placeInfoExpanded = false;
this.placeInfo = { name: query, loading: true };
try {
const res = await fetch('/fox/place-info', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name=csrf-token]').content,
},
body: JSON.stringify({
query,
bias_lat: this._userLocation?.lat || this.currentLatLng.lat,
bias_lng: this._userLocation?.lng || this.currentLatLng.lng,
}),
});
if (!res.ok) {
this.placeInfoLoading = false;
this.placeInfo = null;
this.placeInfoVisible = false;
this.say(`Zu ${query} hab ich nichts gefunden. Probier einen anderen Begriff.`);
return;
}
const data = await res.json();
this.placeInfoLoading = false;
this.placeInfo = data; // keep compat
this._lastRecommend = { name: data.name, lat: data.lat, lng: data.lng, address: data.address };
this.placeDesk = { visible: true, data, liftedCard: this._pendingDeskCard || null };
this._pendingDeskCard = null;
this.placeInfoVisible = false;
// Sprachzusammenfassung
const parts = [];
parts.push(data.name);
if (data.summary) parts.push(data.summary);
else if (data.address) parts.push('in ' + data.address.split(',')[0]);
if (data.rating) parts.push(`${String(data.rating).replace('.', ',')} Sterne bei ${data.rating_count || 0} Bewertungen`);
this.say(parts.join('. ').slice(0, 280));
} catch (e) {
console.warn('[Fox HUD] showPlaceInfo:', e);
this.placeInfoLoading = false;
this.placeInfoVisible = false;
this.say('Da hat was nicht funktioniert. Versuch es nochmal.');
}
},
expandPlaceInfo() {
if (!this.placeInfo) {
this.say('Hab nichts wozu ich mehr zeigen könnte. Frag mich erstmal nach was.');
return;
}
this.placeInfoExpanded = true;
this.say(this.pick([
'Hier sind alle Details die ich habe.',
'Aufgeklappt - schau dir das an.',
'Mehr gibts hier.',
]));
},
closePlaceInfo() {
this.placeInfoVisible = false;
this.placeInfo = null;
this.placeInfoExpanded = false;
},
// City-Lookup via Google Geocoding - 1 Treffer: flyTo. Mehrere: Chip-Auswahl.
async lookupCity(query) {
this.notify(`Suche ${query}`);
try {
const res = await fetch('/fox/cities', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name=csrf-token]').content,
},
body: JSON.stringify({ query }),
});
if (!res.ok) {
this.say(`Den Ort ${query} kann ich nicht finden.`);
return;
}
const data = await res.json();
const results = data.results || [];
if (results.length === 0) {
this.say(`${query} hab ich nicht gefunden. Versuch einen anderen Namen.`);
return;
}
if (results.length === 1) {
this.flyToDynamicCity(results[0]);
return;
}
// Mehrere Treffer → Chips-Auswahl + Voice-Frage
this.cityChoices = results;
this.cityChoiceQuery = query;
this.cityChoicesVisible = true;
const opts = results.slice(0, 3).map(r => r.country || r.region || r.formatted).join(', ');
this.say(`${query} gibt es mehrfach. Welches: ${opts}? Klick einfach.`);
} catch (e) {
console.warn('[Fox HUD] lookupCity', e);
this.say('Hat nicht geklappt, versuch es nochmal.');
}
},
flyToDynamicCity(c) {
const city = {
name: c.name + (c.country ? ', ' + c.country : ''),
lat: c.lat, lng: c.lng,
zoom: 13, bearing: 0,
};
this.cityChoicesVisible = false;
this.cityChoices = [];
this.flyTo(city);
this.notify(`${city.name}`);
this.say(this.pick([
`Hier ist ${c.name}${c.country ? ' in ' + c.country : ''}.`,
`Schau, ${c.name}.`,
`${c.name} für dich.`,
]));
},
cancelCityChoice() {
this.cityChoicesVisible = false;
this.cityChoices = [];
},
// HUD-Zustand an Livewire pushen — Fox kennt damit was offen ist
updateHudState() {
const state = {
map_visible: !!this.mapVisible,
current_city: this.cityName || null,
poi_panel_open: (this.poiList?.length || 0) > 0,
poi_label: this.poiLabel || null,
poi_count: this.poiList?.length || 0,
transit_on: !!this.transitOn,
stops_on: !!this.stopsOn,
route_visible: !!this.routeStepsVisible,
route_target: this._lastRoute?.target || null,
status_visible: !!this.statusVisible,
place_info_visible: !!this.placeInfoVisible,
place_info_name: this.placeInfo?.name || null,
};
try { this.$wire?.set('hudState', state, false); } catch (_) {}
},
async geocodeAndZoom(query, sayText) {
try {
const cityHint = this.homeCity?.name || '';
const res = await fetch('/fox/geocode', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name=csrf-token]').content,
},
body: JSON.stringify({
query: query + (cityHint ? ' ' + cityHint : ''),
bias_lat: this.currentLatLng.lat,
bias_lng: this.currentLatLng.lng,
}),
});
if (!res.ok) {
if (sayText) { this.notify(sayText); this.say(sayText); }
this.notify(`Konnte "${query}" nicht finden.`);
return;
}
const data = await res.json();
if (data?.lat && data?.lng) {
const target = { name: query, lat: data.lat, lng: data.lng, address: data.address };
this.flyToPoi(target);
this._lastRecommend = target;
this.notify(`${query}`);
if (sayText) this.say(sayText);
} else {
if (sayText) { this.notify(sayText); this.say(sayText); }
}
} catch (e) {
console.warn('[Fox HUD] geocodeAndZoom failed:', e);
if (sayText) this.say(sayText);
}
},
flyToPoi(p) {
if (!this.map) return;
this.map.flyTo({ center: [p.lng, p.lat], zoom: 17, pitch: 55, duration: 2000, essential: true });
new maplibregl.Popup({ closeButton: false, offset: 14 })
.setLngLat([p.lng, p.lat])
.setHTML(`<b>${p.name}</b>${p.address ? '<br>' + p.address : ''}`)
.addTo(this.map);
},
// ─── Routenplanung (Google Directions via /fox/route) ─────────
async requestRoute(origin, destination, mode) {
document.body.classList.add('map-visible');
this.mapVisible = true;
if (!this.map) this.initMap();
const tryFetch = async () => {
const ctrl = new AbortController();
const t = setTimeout(() => ctrl.abort(), 12000);
try {
const res = await fetch('/fox/route', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name=csrf-token]').content,
},
body: JSON.stringify({ origin, destination, mode }),
signal: ctrl.signal,
});
clearTimeout(t);
return res;
} catch (e) {
clearTimeout(t);
return null;
}
};
let res = await tryFetch();
let data = null;
let attempt = 1;
while ((!res || !res.ok) && attempt < 3) {
console.warn('[Fox HUD] route fail (attempt '+attempt+'), retry');
if (attempt === 1) this.say(this.pick(this.phrases.retrying));
await new Promise(r => setTimeout(r, 700));
attempt++;
res = await tryFetch();
}
if (!res || !res.ok) {
const err = res ? await res.json().catch(() => ({})) : { error: 'timeout' };
console.warn('[Fox HUD] route final fail', err);
this.notify('Keine Route nach mehreren Versuchen.');
this.say(this.pick(this.phrases.routeFailed));
return;
}
try {
data = await res.json();
this.drawRoute(data);
const target = (data.end_address || destination).split(',')[0];
const modeLabel = { driving: 'Auto', walking: 'zu Fuß', bicycling: 'Rad', transit: 'ÖPNV' }[mode] || '';
// Persistente Anzeige speichern - kann via "wie lange brauche ich" wieder abgefragt werden.
// origin + destination merken damit Mode-Wechsel ("zu fuß"/"mit ubahn") neu rechnen kann.
this._lastRoute = {
target, mode, modeLabel,
distance_text: data.distance_text,
duration_text: data.duration_text,
origin, destination,
};
this.routeInfoLabel = `${target} · ${data.distance_text} · ${data.duration_text}${modeLabel ? ' · ' + modeLabel : ''}`;
this.routeInfoVisible = true;
// Schritt-für-Schritt-Liste rechts statt POI-Liste
this.routeSteps = data.steps || [];
this.routeStepsTarget = target;
this.routeStepsVisible = this.routeSteps.length > 0;
if (this.routeStepsVisible) {
document.body.classList.remove('poi-visible');
document.body.classList.add('route-visible');
}
const intros = [
`Bis ${target}: ${data.distance_text}, ${data.duration_text}.`,
`${target} - ${data.distance_text} und ${data.duration_text}.`,
`${data.duration_text} brauchst du, ${data.distance_text} sinds bis ${target}.`,
`Schau, ${target} ist ${data.distance_text} weg, ${data.duration_text}.`,
];
const msg = this.pick(intros);
this.notify(msg);
this.say(msg);
} catch (e) {
console.warn('[Fox HUD] requestRoute error:', e);
this.say('Hm, die Route ist da aber ich kann sie nicht zeichnen. Lade nochmal die Seite.');
}
},
// Followup-Frage nach kurzer Pause - bricht ab wenn User selbst tippt/spricht.
// Wenn User danach mit "ja" antwortet, läuft onConfirm() statt LLM.
queueFollowup(text, delayMs, onConfirm = null) {
if (this._followupTimer) clearTimeout(this._followupTimer);
this._followupTimer = setTimeout(() => {
if (this._followupTimer) {
this.say(text);
this.notify(text);
this._followupLastAskAt = Date.now();
this._pendingConfirm = onConfirm;
// Bestätigungsfenster: 30s
setTimeout(() => { this._pendingConfirm = null; }, 30000);
}
}, delayMs);
},
cancelFollowup() {
if (this._followupTimer) {
clearTimeout(this._followupTimer);
this._followupTimer = null;
}
},
// "ja"-Antwort auf Fox-Frage abfangen - sonst bekommt der LLM "ja bitte" ohne Kontext
// und macht eine neue Empfehlung statt die Route zu zeigen.
tryConsumeConfirmation(text) {
if (!this._pendingConfirm) return false;
const t = (text || '').toLowerCase().trim();
const yes = /^(ja|jo|jep|yep|yes|klar|sicher|gerne|natürlich|natuerlich|bitte|ja bitte|ja gerne|ok|okay|mach|machs|los)\b/.test(t);
const no = /^(nein|nö|noe|ne|nope|no|lass|nicht|spar|später|nachher)\b/.test(t);
if (yes) {
const action = this._pendingConfirm;
this._pendingConfirm = null;
action?.();
return true;
}
if (no) {
this._pendingConfirm = null;
this.say(this.pick(this.phrases.confirmNo));
return true;
}
return false;
},
// "Wie lange brauche ich" / "Wie weit ist es" → letzte Route nochmal ansagen
announceLastRoute() {
if (!this._lastRoute) {
this.say(this.pick([
'Noch keine Route da. Sag mir wohin.',
'Wir haben noch keinen Weg geplant. Wo soll es hin?',
'Ich weiß noch nicht wohin - sag mirs einfach.',
]));
return true;
}
const r = this._lastRoute;
const ml = r.modeLabel ? ' mit ' + r.modeLabel : '';
const msg = this.pick([
`${r.duration_text} brauchst du, ${r.distance_text} bis ${r.target}${ml}.`,
`Bis ${r.target} sind es ${r.distance_text}, dauert ${r.duration_text}${ml}.`,
`${r.distance_text} und ${r.duration_text} bis ${r.target}${ml}.`,
]);
this.notify(msg);
this.say(msg);
return true;
},
drawRoute(data) {
if (!this.map) return;
const apply = () => {
this.removeRouteLayer();
this.map.addSource('route', { type: 'geojson', data: data.geojson });
this.map.addLayer({
id: 'route-bg', type: 'line', source: 'route',
paint: { 'line-color': '#0e7490', 'line-width': 8, 'line-opacity': 0.55, 'line-blur': 1 },
});
this.map.addLayer({
id: 'route-line', type: 'line', source: 'route',
layout: { 'line-cap': 'round', 'line-join': 'round' },
paint: { 'line-color': '#22d3ee', 'line-width': 4, 'line-opacity': 0.95 },
});
if (data.start_location) {
this._routeMarkers = this._routeMarkers || [];
const start = new maplibregl.Marker({ color: '#10b981' })
.setLngLat([data.start_location.lng, data.start_location.lat])
.addTo(this.map);
const end = new maplibregl.Marker({ color: '#ef4444' })
.setLngLat([data.end_location.lng, data.end_location.lat])
.addTo(this.map);
this._routeMarkers.push(start, end);
}
if (data.bounds) {
this.map.fitBounds([
[data.bounds.southwest.lng, data.bounds.southwest.lat],
[data.bounds.northeast.lng, data.bounds.northeast.lat],
], { padding: 80, duration: 1500 });
}
};
if (this.mapLoaded) apply(); else this.map.once('load', apply);
},
removeRouteLayer() {
['route-line','route-bg'].forEach(id => {
if (this.map?.getLayer(id)) this.map.removeLayer(id);
});
if (this.map?.getSource('route')) this.map.removeSource('route');
(this._routeMarkers || []).forEach(m => m.remove());
this._routeMarkers = [];
this.routeInfoVisible = false;
this.routeStepsVisible = false;
this.routeSteps = [];
document.body.classList.remove('route-visible');
},
clearPois() {
this.poiList = [];
this.poiLabel = '';
document.body.classList.remove('poi-visible');
this.removePoiLayer();
this.updateHudState();
},
// ─── Transit Layer ─────────────────────────────────────
toggleTransit() { this.transitOn ? this.disableTransit() : this.enableTransit(); },
enableTransit() {
if (!this.map || !this.mapLoaded) return;
this.transitOn = true;
this.loadTransitForLocation(this.currentLatLng.lat, this.currentLatLng.lng);
},
disableTransit() { this.transitOn = false; this.removeTransitLayers(); this.updateHudState(); },
loadTransitForLocation(lat, lng) {
const isWien = Math.abs(lat - 48.2082) < 0.15 && Math.abs(lng - 16.3738) < 0.2;
if (isWien) {
this.addTransitGeoJSON(WIEN_UBAHN);
this.notify('Wien U-Bahn — U1 U2 U3 U4 U6');
return;
}
this.notify('Lade Transit-Daten …');
const query = `[out:json][timeout:25];
(relation["route"="subway"](${lat-0.3},${lng-0.5},${lat+0.3},${lng+0.5});
relation["route"="tram"](${lat-0.2},${lng-0.4},${lat+0.2},${lng+0.4}););
out geom;`;
fetch('https://overpass-api.de/api/interpreter', { method: 'POST', body: 'data=' + encodeURIComponent(query) })
.then(r => r.json())
.then(data => {
const geojson = this.overpassToGeoJSON(data);
if (geojson.features.length === 0) return this.notify('Keine Transit-Daten gefunden');
this.addTransitGeoJSON(geojson);
this.notify(`${geojson.features.length} Linien geladen`);
})
.catch(() => this.notify('Transit-Daten nicht verfügbar'));
},
overpassToGeoJSON(data) {
const colors = { subway: '#6366f1', tram: '#f59e0b', rail: '#10b981', light_rail: '#06b6d4' };
return {
type: 'FeatureCollection',
features: (data.relations || []).map(rel => {
const coords = [];
(rel.members || []).forEach(m => {
if (m.type === 'way' && m.geometry) m.geometry.forEach(pt => coords.push([pt.lon, pt.lat]));
});
if (coords.length < 2) return null;
return {
type: 'Feature',
properties: {
line: rel.tags?.ref || rel.tags?.name || '?',
color: colors[rel.tags?.route] || '#6366f1',
name: rel.tags?.name || '',
},
geometry: { type: 'LineString', coordinates: coords },
};
}).filter(Boolean),
};
},
addTransitGeoJSON(geojson) {
this.removeTransitLayers();
this.map.addSource('transit', { type: 'geojson', data: geojson });
this.map.addLayer({
id: 'transit-bg', type: 'line', source: 'transit',
layout: { 'line-join': 'round', 'line-cap': 'round' },
paint: { 'line-color': ['get', 'color'], 'line-width': 10, 'line-opacity': 0.2, 'line-blur': 5 },
});
this.map.addLayer({
id: 'transit-lines', type: 'line', source: 'transit',
layout: { 'line-join': 'round', 'line-cap': 'round' },
paint: { 'line-color': ['get', 'color'], 'line-width': 3.5, 'line-opacity': 0.92 },
});
},
removeTransitLayers() {
['transit-lines', 'transit-bg'].forEach(id => {
if (this.map?.getLayer(id)) this.map.removeLayer(id);
});
if (this.map?.getSource('transit')) this.map.removeSource('transit');
},
// ─── Stops ─────────────────────────────────────────────
toggleStops() { this.stopsOn ? this.disableStops() : this.enableStops(); },
enableStops() {
if (!this.map || !this.mapLoaded) return;
this.stopsOn = true;
const isWien = Math.abs(this.currentLatLng.lat - 48.2082) < 0.15;
if (!isWien) {
this.notify('Haltestellen aktuell nur für Wien');
this.stopsOn = false;
return;
}
this.addStopsLayer();
},
disableStops() {
this.stopsOn = false;
if (this.map?.getLayer('stops-layer')) this.map.removeLayer('stops-layer');
if (this.map?.getSource('stops')) this.map.removeSource('stops');
},
addStopsLayer() {
const geojson = {
type: 'FeatureCollection',
features: WIEN_STOPS.map(s => ({
type: 'Feature',
properties: { name: s.name, line: s.line, color: s.color },
geometry: { type: 'Point', coordinates: [s.lng, s.lat] },
})),
};
if (this.map.getSource('stops')) this.map.removeSource('stops');
this.map.addSource('stops', { type: 'geojson', data: geojson });
this.map.addLayer({
id: 'stops-layer', type: 'circle', source: 'stops',
paint: {
'circle-radius': 6,
'circle-color': ['get', 'color'],
'circle-stroke-width': 2,
'circle-stroke-color': 'rgba(255,255,255,0.9)',
},
});
this.map.on('click', 'stops-layer', e => {
const p = e.features[0].properties;
new maplibregl.Popup({ closeButton: false })
.setLngLat(e.features[0].geometry.coordinates)
.setHTML(`<b>${p.name}</b><br>${p.line}`)
.addTo(this.map);
});
},
// ─── Input + Mikro ─────────────────────────────────────
onKey(e) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
// Wenn Fox grad eine Frage stellte und User "ja"/"nein" tippt → ohne LLM ausführen
const text = (this.$wire.input || '').trim();
if (this.tryConsumeConfirmation(text)) {
this.$wire.set('input', '');
return;
}
this.$wire.send();
}
},
resz(el) {
el.style.height = 'auto';
el.style.height = Math.min(el.scrollHeight, 120) + 'px';
},
async toggleMic() { this.micActive ? this.stopMic() : await this.startMic(); },
async startMic() {
// 1) Fox sofort still: laufenden TTS abbrechen, sonst nimmt das Mic Fox selbst auf
if (this._sayAudio) { try { this._sayAudio.pause(); this._sayAudio.src = ''; } catch (_) {} this._sayAudio = null; }
if (this._sayAbort) { try { this._sayAbort.abort(); } catch (_) {} }
this._sayGen = (this._sayGen || 0) + 1;
this.isSpeaking = false;
// 2) Bootstrap-Audio (LLM-Antwort) auch killen
try { window.__foxAudio?.pause(); } catch (_) {}
// 3) Erst NACH kurzer Pause Mic starten - sonst hört Mic letzte 200ms TTS
await new Promise(r => setTimeout(r, 250));
try {
const stream = await navigator.mediaDevices.getUserMedia({
audio: {
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true,
},
});
this.micChunks = [];
this.mediaRecorder = new MediaRecorder(stream);
this.mediaRecorder.ondataavailable = (e) => this.micChunks.push(e.data);
this.mediaRecorder.onstop = () => this.uploadMic();
this.mediaRecorder.start();
this.micActive = true;
this.notify('🎤 Aufnahme läuft - klick zum Stoppen');
} catch (err) {
console.warn('Mikrofon nicht verfügbar:', err);
this.notify('Mikrofon nicht verfügbar: ' + err.message);
}
},
stopMic() {
if (!this.mediaRecorder) return;
this.mediaRecorder.stop();
this.mediaRecorder.stream.getTracks().forEach(t => t.stop());
this.mediaRecorder = null;
this.micActive = false;
},
async uploadMic() {
if (!this.micChunks.length) {
this.notify('Keine Aufnahme erfasst.');
return;
}
const blob = new Blob(this.micChunks, { type: 'audio/webm' });
if (blob.size < 1000) {
this.notify('Aufnahme zu kurz - länger drücken.');
return;
}
const fd = new FormData();
fd.append('audio', blob, 'fox-input.webm');
this.notify('Transkribiere …');
this.say(this.pick([
'Moment, ich höre.',
'Sekunde, verstehe das.',
'Hör mal kurz...',
]));
try {
const res = await fetch('/fox/voice', {
method: 'POST',
headers: { 'X-CSRF-TOKEN': document.querySelector('meta[name=csrf-token]').content },
body: fd,
});
if (!res.ok) {
let detail = '';
try {
const j = await res.json();
detail = j.detail || j.error || JSON.stringify(j).slice(0, 120);
} catch (_) {
detail = await res.text().catch(() => '');
}
console.warn('[Fox HUD] /fox/voice HTTP', res.status, detail);
this.notify(`Transkription HTTP ${res.status}: ${detail.slice(0, 80)}`);
this.say('Ich konnte dich nicht verstehen. Probier nochmal.');
return;
}
const data = await res.json();
if (!data?.text) {
console.warn('[Fox HUD] /fox/voice empty', data);
this.notify('Transkription leer');
this.say('Ich hab nichts gehört. Sprich lauter.');
return;
}
this.notify(`🎙 "${data.text}" — sende in 1.5s. Tippen bricht ab.`);
this.$wire.set('input', data.text);
// 1.5s warten - wenn User in dem Fenster tippt (input ändert sich), abbrechen
this._micConfirmTimer = setTimeout(async () => {
if ((this.$wire.input || '').trim() === data.text.trim()) {
await this.$nextTick();
this.$wire.send();
} else {
this.notify('Send abgebrochen, ändere und drück Enter.');
}
this._micConfirmTimer = null;
}, 1500);
} catch (err) {
console.warn('[Fox HUD] Voice upload error:', err);
this.notify('Upload fehlgeschlagen: ' + err.message);
this.say('Hat nicht geklappt. Probier nochmal.');
}
},
}));
});