// 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, '&') .replace(//g, '>') .replace(/"/g, '"'); }, // ─── 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(`${f.properties.name}${f.properties.address ? '
' + 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(`${p.name}${p.address ? '
' + 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(`${p.name}
${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.'); } }, })); });