Fox initial commit

main
boban 2026-05-11 20:36:07 +02:00
commit e3ba405548
195 changed files with 33846 additions and 0 deletions

106
.env.example Normal file
View File

@ -0,0 +1,106 @@
APP_NAME=Fox
APP_ENV=local
APP_KEY=
APP_DEBUG=true
APP_TIMEZONE=Europe/Berlin
APP_URL=http://10.10.90.175
APP_LOCALE=de
APP_FALLBACK_LOCALE=en
LOG_CHANNEL=stack
LOG_LEVEL=debug
DB_CONNECTION=pgsql
DB_HOST=postgres
DB_PORT=5432
DB_DATABASE=fox
DB_USERNAME=fox
DB_PASSWORD=fox
CACHE_STORE=redis
QUEUE_CONNECTION=redis
SESSION_DRIVER=redis
SESSION_LIFETIME=120
REDIS_HOST=redis
REDIS_PASSWORD=null
REDIS_PORT=6379
REDIS_CLIENT=phpredis
BROADCAST_CONNECTION=reverb
REVERB_APP_ID=fox
REVERB_APP_KEY=fox-key
REVERB_APP_SECRET=fox-secret
# REVERB_HOST = Hostname zu dem PHP/Backend connectet, um Events zu publishen.
# Innerhalb des Docker-Netzes erreicht "reverb" den Reverb-Container.
# (Der Reverb-Server selbst bindet weiterhin via --host=0.0.0.0 in docker-compose.)
REVERB_HOST=reverb
REVERB_PORT=8080
REVERB_SCHEME=http
# Vite macht KEINE Shell-Interpolation - Wert literal angeben.
VITE_REVERB_APP_KEY=fox-key
VITE_REVERB_HOST=10.10.90.175
VITE_REVERB_PORT=8080
VITE_REVERB_SCHEME=http
# Prism / LLM
PRISM_PROVIDER=ollama
OLLAMA_URL=http://ollama:11434
OLLAMA_MODEL=llama3.1:8b
OLLAMA_EMBEDDING_MODEL=nomic-embed-text
# Speech
WHISPER_CONTAINER=fox-whisper
WHISPER_MODEL=/models/ggml-base.bin
# TTS-Engine: openai (tts-1, ~300ms, multilingual) | elevenlabs | xtts (Mac) | piper (Container, fallback)
# Bei Fehler wird automatisch die nächste Engine in der Kette versucht.
TTS_ENGINE=openai
TTS_LANGUAGE=de
# OpenAI TTS (tts-1: $0.015 / 1k Zeichen, tts-1-hd: $0.030 / 1k Zeichen)
# Voices: onyx | echo | alloy | fable | nova | shimmer
OPENAI_API_KEY=
OPENAI_TTS_MODEL=tts-1
OPENAI_TTS_VOICE=onyx
OPENAI_TTS_SPEED=1.0
OPENAI_TIMEOUT=15
# ElevenLabs (10k Zeichen/Monat kostenlos)
ELEVENLABS_API_KEY=
ELEVENLABS_VOICE_ID=KDqku3FJfbImX6HKQdWA
ELEVENLABS_MODEL=eleven_multilingual_v2
ELEVENLABS_STABILITY=0.5
ELEVENLABS_SIMILARITY=0.75
ELEVENLABS_STYLE=0.3
ELEVENLABS_SPEAKER_BOOST=true
ELEVENLABS_TIMEOUT=10
# XTTS v2 auf dem Mac (M1 Max) - Fallback wenn ElevenLabs nicht erreichbar
XTTS_URL=http://10.10.80.189:8766
XTTS_SPEAKER=Ana Florence
XTTS_TIMEOUT=30
# Piper (Fallback, läuft im Container)
PIPER_VOICE=de_DE-thorsten-high
PIPER_LENGTH_SCALE=1.05
PIPER_SENTENCE_SILENCE=0.3
PIPER_NOISE_SCALE=0.667
PIPER_NOISE_W=0.8
# Tools
SEARXNG_URL=http://searxng:8080
# Google Maps Platform (Directions + Geocoding API)
# https://console.cloud.google.com → APIs & Services → Credentials
GOOGLE_MAPS_API_KEY=
# Fox proactive engine - 0 = aus, sonst Minuten zwischen Checks (z.B. 30)
FOX_PROACTIVE_INTERVAL_MINUTES=0
# HUD Standort für Wetter (Open-Meteo, kein API-Key benötigt)
FOX_LAT=48.2082
FOX_LON=16.3738
FOX_CITY=Wien

23
.gitignore vendored Normal file
View File

@ -0,0 +1,23 @@
/.phpunit.cache
/node_modules
/public/build
/public/hot
/public/storage
/storage/*.key
/storage/app/audio/*
!/storage/app/audio/.gitkeep
/vendor
.env
.env.backup
.env.production
.phpactor.json
.phpunit.result.cache
Homestead.json
Homestead.yaml
auth.json
npm-debug.log
yarn-error.log
/.fleet
/.idea
/.vscode
.DS_Store

129
Makefile Normal file
View File

@ -0,0 +1,129 @@
SHELL := /bin/bash
DC := docker compose
APP := $(DC) exec app
APP_RUN := $(DC) run --rm app
OLLAMA_MODEL ?= llama3.1:8b
EMBED_MODEL ?= nomic-embed-text
.PHONY: help install up down restart shell logs ps build pull rebuild \
composer migrate seed fresh tinker queue-restart npm-build npm-dev \
ollama-pull whisper-model piper-voice
help:
@echo "Fox - Make targets:"
@echo " make install - First-time setup: build, vendor, key, migrate, models"
@echo " make up - Start all containers"
@echo " make down - Stop all containers"
@echo " make restart - Restart everything"
@echo " make shell - Open shell in app container"
@echo " make logs - Tail container logs"
@echo " make ps - List containers"
@echo " make build - (Re)build images"
@echo " make rebuild - Force rebuild without cache"
@echo " make migrate - Run database migrations"
@echo " make fresh - Reset DB and migrate from scratch"
@echo " make tinker - Open Tinker REPL"
@echo " make ollama-pull - Pull the configured Ollama model"
@echo " make whisper-model - Download whisper.cpp ggml model"
@echo " make piper-voice - Download piper TTS voice (de_DE-thorsten-medium)"
@echo " make npm-build - Build frontend assets"
@echo " make npm-dev - Run Vite in dev mode"
install:
@if [ ! -f .env ]; then cp .env.example .env; echo ".env erstellt aus .env.example"; fi
$(DC) build
$(DC) up -d postgres redis ollama
@echo "Warte auf Postgres..."
@until $(DC) exec -T postgres pg_isready -U fox >/dev/null 2>&1; do sleep 1; done
$(APP_RUN) composer install --no-interaction --prefer-dist --optimize-autoloader
$(APP_RUN) php artisan key:generate --force
$(APP_RUN) php artisan storage:link || true
$(APP_RUN) php artisan migrate --force
$(MAKE) ollama-pull
$(MAKE) whisper-model
$(MAKE) piper-voice
$(APP_RUN) sh -c 'command -v node >/dev/null 2>&1 || (apt-get update && apt-get install -y --no-install-recommends nodejs npm)'
$(APP_RUN) npm install
$(APP_RUN) npm run build
$(DC) up -d
@echo ""
@echo "Fox ist erreichbar unter http://10.10.90.175"
@echo "Reverb (WebSocket) auf Port 8080"
up:
$(DC) up -d
down:
$(DC) down
restart: down up
shell:
$(APP) bash
logs:
$(DC) logs -f --tail=100
ps:
$(DC) ps
build:
$(DC) build
rebuild:
$(DC) build --no-cache
composer:
$(APP_RUN) composer $(filter-out $@,$(MAKECMDGOALS))
migrate:
$(APP_RUN) php artisan migrate
fresh:
$(APP_RUN) php artisan migrate:fresh --force
tinker:
$(APP) php artisan tinker
queue-restart:
$(APP) php artisan queue:restart
npm-build:
$(DC) run --rm vite npm run build
@$(DC) exec -T --user root app rm -f public/hot 2>/dev/null || true
@$(DC) exec -T --user root app sh -c 'chown -R www-data:www-data public/build 2>/dev/null || true'
@echo "Production-Assets gebaut, public/hot entfernt."
# Wechsel auf Production-Build: Vite stoppen + frisch builden + hot-File weg.
prod: npm-dev-stop npm-build
npm-dev:
$(DC) --profile dev up vite
npm-dev-stop:
@$(DC) --profile dev stop vite 2>/dev/null || true
@$(DC) exec -T --user root app rm -f public/hot 2>/dev/null || true
@echo "Vite gestoppt, public/hot entfernt - Production-Build ist aktiv."
ollama-pull:
@echo "Pulle Ollama-Modell $(OLLAMA_MODEL)..."
$(DC) exec -T ollama ollama pull $(OLLAMA_MODEL)
@echo "Pulle Embedding-Modell $(EMBED_MODEL)..."
$(DC) exec -T ollama ollama pull $(EMBED_MODEL)
whisper-model:
@echo "Lade whisper.cpp ggml-base Modell..."
$(DC) exec -T whisper sh -c 'cd /models && [ -f ggml-base.bin ] || wget -q https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-base.bin'
piper-voice:
mkdir -p storage/app/voices
wget -q -O storage/app/voices/de_DE-thorsten-medium.onnx \
https://huggingface.co/rhasspy/piper-voices/resolve/main/de/de_DE/thorsten/medium/de_DE-thorsten-medium.onnx
wget -q -O storage/app/voices/de_DE-thorsten-medium.onnx.json \
https://huggingface.co/rhasspy/piper-voices/resolve/main/de/de_DE/thorsten/medium/de_DE-thorsten-medium.onnx.json
@echo "Piper Voice heruntergeladen."
%:
@:

89
README.md Normal file
View File

@ -0,0 +1,89 @@
# Fox
Proaktiver KI-Assistent. Laravel 12 · Livewire 3 (class-based) · Tailwind v4 · Reverb · Postgres + pgvector · Redis · Ollama (llama3.1:8b) · whisper.cpp · piper.
## Quickstart (Debian 13, nur Docker installiert)
```bash
git clone <repo> fox && cd fox
docker compose up -d
make install
```
Fox ist dann erreichbar unter <http://10.10.90.175>.
`make install` baut die Images, fährt Postgres/Redis/Ollama hoch, installiert Composer- und npm-Pakete, generiert den App-Key, führt die Migrations aus, pullt das Ollama-Modell und das whisper.cpp ggml Modell und baut die Frontend-Assets.
## Architektur
```
docker-compose.yml ─┬─ app (PHP-FPM 8.3)
├─ nginx
├─ postgres (pgvector/pg16)
├─ redis
├─ ollama (llama3.1:8b + nomic-embed-text)
├─ queue worker
├─ scheduler
├─ reverb (WebSockets, Port 8080)
├─ whisper (whisper.cpp Sidecar)
└─ searxng (Meta-Suchmaschine)
```
### Code-Struktur
| Pfad | Was |
| --- | --- |
| `app/Services/FoxAgent.php` | Agent-Loop: think → plan → act → reflect |
| `app/Services/SpeechService.php` | whisper.cpp + piper Bridge |
| `app/Services/MemoryService.php` | pgvector Embeddings via Ollama |
| `app/Tools/WebSearchTool.php` | SearXNG (lokale Meta-Suche) |
| `app/Tools/BuildTemplateTool.php` | HTML-Page erzeugen + Broadcast |
| `app/Tools/FileManagerTool.php` | Sandbox-Filesystem |
| `app/Jobs/FoxProactiveJob.php` | Alle 5 Min: LLM entscheidet, ob Fox spricht |
| `app/Events/FoxMessage.php` | Broadcast → Livewire |
| `app/Livewire/Chat.php` | Haupt-Chat (PTT, Feed) |
| `app/Livewire/Goals.php` | Ziele |
| `app/Livewire/Modals/NewGoal.php` | Modal für neues Ziel |
Routen liegen in `routes/web.php` und zeigen direkt auf Livewire-Klassen — Ausnahme: `POST /fox/voice` für Audio-Upload (`FoxVoiceController`).
### Tailwind v4
Farben kommen aus `resources/css/app.css` via `@theme`**keine `tailwind.config.js`**. Nutzbar als `bg-primary`, `bg-surface`, `bg-glass`
### Livewire 3
`config/livewire.php` ist auf class-based Layout gestellt (`class_namespace = App\Livewire`, `view_path = resources/views/livewire`). Klassen und Blades sind getrennt.
## Make-Targets
```
make install First-time Setup
make up | down Container starten / stoppen
make shell Shell im app-Container
make logs Container-Logs folgen
make migrate DB-Migrations
make fresh DB komplett zurücksetzen
make tinker Tinker REPL
make ollama-pull Ollama-Modell pullen
make whisper-model whisper.cpp ggml-Modell laden
make npm-build Frontend-Assets bauen
```
## Proaktive Engine
`bootstrap/app.php` registriert den Scheduler: alle `FOX_PROACTIVE_INTERVAL_MINUTES` (Default 5) wird `FoxProactiveJob` queued.
Der Job ruft `FoxAgent::shouldISpeak()`, das via Prism-Structured-Output ein `ProactiveDecision { shouldSpeak, message, priority }` zurückbekommt und bei Bedarf eine proaktive Nachricht broadcastet.
## Voice
Push-to-Talk via **Strg+Leertaste** oder gedrückt halten des Mikro-Buttons. Audio wird per `multipart/form-data` an `/fox/voice` geschickt → `ffmpeg` konvertiert nach 16kHz wav → whisper.cpp transkribiert → FoxAgent antwortet → piper synthetisiert die Antwort → Browser spielt den .wav-Stream automatisch ab.
## ENV-Variablen
Siehe `.env.example`. Wichtig:
- `OLLAMA_MODEL=llama3.1:8b`
- `OLLAMA_EMBEDDING_MODEL=nomic-embed-text` (768-dim Vector — passt zur Migration)
- `SEARXNG_URL=http://searxng:8080` (Default — Service läuft im selben Netz)
- `REVERB_*` für WebSockets (Default-Werte funktionieren lokal)

View File

@ -0,0 +1,62 @@
<?php
namespace App\Console\Commands;
use App\Events\SystemStatsBroadcast;
use App\Events\UptimeBroadcast;
use App\Events\WeatherBroadcast;
use App\Services\SystemStatsService;
use App\Services\UptimeService;
use App\Services\WeatherService;
use Illuminate\Console\Command;
/**
* Single producer für alle HUD-Tick-Broadcasts. Vermeidet Polling im Browser.
*/
class BroadcastSystemStats extends Command
{
protected $signature = 'fox:broadcast-stats
{--interval=5 : Sekunden zwischen System-Stats-Pushes}
{--uptime-every=6 : Wie viele Ticks zwischen Uptime-Pushes (Default: alle 30s bei interval=5)}
{--weather-every=120 : Wie viele Ticks zwischen Weather-Pushes (Default: alle 10min bei interval=5)}';
protected $description = 'Broadcastet HUD-Daten (Stats, Uptime, Weather) periodisch via Reverb.';
public function handle(
SystemStatsService $stats,
UptimeService $uptime,
WeatherService $weather,
): int {
$interval = max(1, (int) $this->option('interval'));
$uptimeEvery = max(1, (int) $this->option('uptime-every'));
$weatherEvery = max(1, (int) $this->option('weather-every'));
$this->info("HUD-Broadcaster läuft - stats:{$interval}s uptime:".($interval * $uptimeEvery)."s weather:".($interval * $weatherEvery).'s');
$tick = 0;
while (true) {
$this->safeBroadcast(fn () => broadcast(new SystemStatsBroadcast($stats->collect())), 'stats');
if ($tick % $uptimeEvery === 0) {
$this->safeBroadcast(fn () => broadcast(new UptimeBroadcast($uptime->collect())), 'uptime');
}
if ($tick % $weatherEvery === 0) {
$this->safeBroadcast(fn () => broadcast(new WeatherBroadcast($weather->fetch())), 'weather');
}
$tick++;
sleep($interval);
}
}
protected function safeBroadcast(\Closure $fn, string $label): void
{
try {
$fn();
} catch (\Throwable $e) {
$this->error("Broadcast {$label} fehlgeschlagen: ".$e->getMessage());
}
}
}

37
app/Events/FoxAction.php Normal file
View File

@ -0,0 +1,37 @@
<?php
namespace App\Events;
use Illuminate\Broadcasting\Channel;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Foundation\Events\Dispatchable;
/**
* Intent-getriebene Aktionen vom Fox-Agent ans HUD.
* Beispiel-Payloads:
* ['action' => 'map', 'city' => 'wien', 'data' => [...city...]]
* ['action' => 'food', 'location' => 'wien']
* ['action' => 'response', 'text' => '...']
*/
class FoxAction implements ShouldBroadcastNow
{
use Dispatchable, InteractsWithSockets;
public function __construct(public array $payload) {}
public function broadcastOn(): array
{
return [new Channel('fox')];
}
public function broadcastAs(): string
{
return 'fox.action';
}
public function broadcastWith(): array
{
return $this->payload;
}
}

View File

@ -0,0 +1,30 @@
<?php
namespace App\Events;
use Illuminate\Broadcasting\Channel;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Foundation\Events\Dispatchable;
class FoxAudioChunk implements ShouldBroadcastNow
{
use Dispatchable, InteractsWithSockets;
public function __construct(public string $audioUrl) {}
public function broadcastOn(): array
{
return [new Channel('fox')];
}
public function broadcastAs(): string
{
return 'audio.chunk';
}
public function broadcastWith(): array
{
return ['audio_url' => $this->audioUrl];
}
}

36
app/Events/FoxChunk.php Normal file
View File

@ -0,0 +1,36 @@
<?php
namespace App\Events;
use Illuminate\Broadcasting\Channel;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Foundation\Events\Dispatchable;
class FoxChunk implements ShouldBroadcastNow
{
use Dispatchable, InteractsWithSockets;
public function __construct(
public string $chunk,
public string $messageId,
) {}
public function broadcastOn(): array
{
return [new Channel('fox')];
}
public function broadcastAs(): string
{
return 'chunk.created';
}
public function broadcastWith(): array
{
return [
'chunk' => $this->chunk,
'message_id' => $this->messageId,
];
}
}

41
app/Events/FoxMessage.php Normal file
View File

@ -0,0 +1,41 @@
<?php
namespace App\Events;
use App\Models\Message;
use Illuminate\Broadcasting\Channel;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class FoxMessage implements ShouldBroadcastNow
{
use Dispatchable;
use InteractsWithSockets;
use SerializesModels;
public function __construct(public Message $message) {}
public function broadcastOn(): array
{
return [new Channel('fox')];
}
public function broadcastAs(): string
{
return 'message.created';
}
public function broadcastWith(): array
{
return [
'id' => $this->message->id,
'role' => $this->message->role,
'content' => $this->message->content,
'type' => $this->message->type,
'metadata' => $this->message->metadata,
'created_at' => $this->message->created_at?->toIso8601String(),
];
}
}

View File

@ -0,0 +1,30 @@
<?php
namespace App\Events;
use Illuminate\Broadcasting\Channel;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Foundation\Events\Dispatchable;
class SystemStatsBroadcast implements ShouldBroadcastNow
{
use Dispatchable, InteractsWithSockets;
public function __construct(public array $stats) {}
public function broadcastOn(): array
{
return [new Channel('fox')];
}
public function broadcastAs(): string
{
return 'stats.system';
}
public function broadcastWith(): array
{
return $this->stats;
}
}

View File

@ -0,0 +1,30 @@
<?php
namespace App\Events;
use Illuminate\Broadcasting\Channel;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Foundation\Events\Dispatchable;
class UptimeBroadcast implements ShouldBroadcastNow
{
use Dispatchable, InteractsWithSockets;
public function __construct(public array $data) {}
public function broadcastOn(): array
{
return [new Channel('fox')];
}
public function broadcastAs(): string
{
return 'stats.uptime';
}
public function broadcastWith(): array
{
return $this->data;
}
}

View File

@ -0,0 +1,30 @@
<?php
namespace App\Events;
use Illuminate\Broadcasting\Channel;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Contracts\Broadcasting\ShouldBroadcastNow;
use Illuminate\Foundation\Events\Dispatchable;
class WeatherBroadcast implements ShouldBroadcastNow
{
use Dispatchable, InteractsWithSockets;
public function __construct(public array $data) {}
public function broadcastOn(): array
{
return [new Channel('fox')];
}
public function broadcastAs(): string
{
return 'stats.weather';
}
public function broadcastWith(): array
{
return $this->data;
}
}

View File

@ -0,0 +1,24 @@
<?php
namespace App\Http\Controllers\Fox;
use App\Services\Fox\FoxMemoryService;
use Illuminate\Http\JsonResponse;
use Illuminate\View\View;
class FoxBrainController
{
public function __construct(
private readonly FoxMemoryService $memory,
) {}
public function index(): View
{
return view('fox.brain.index', $this->memory->stats());
}
public function graph(): JsonResponse
{
return response()->json($this->memory->buildGraphPayload());
}
}

View File

@ -0,0 +1,85 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
/**
* City-Lookup via Google Geocoding. Wenn nur ein Treffer direkt flyTo.
* Wenn mehrere (z.B. "Linz" Österreich vs Pennsylvania) array für User-Auswahl.
*/
class FoxCitiesController
{
public function __invoke(Request $request): JsonResponse
{
$request->validate([
'query' => 'required|string|max:120',
]);
$key = config('services.google.maps_key');
if (empty($key)) {
return response()->json(['error' => 'no_key'], 503);
}
$query = $request->string('query')->toString();
try {
$response = Http::timeout(8)->get('https://maps.googleapis.com/maps/api/geocode/json', [
'address' => $query,
'language' => 'de',
'key' => $key,
]);
if (! $response->successful()) {
return response()->json(['error' => 'api_error', 'status' => $response->status()], 502);
}
$data = $response->json();
if (($data['status'] ?? null) !== 'OK' || empty($data['results'])) {
return response()->json(['results' => []], 200);
}
// Nur Städte/Ortschaften behalten — keine POI/Adressen
$cityTypes = ['locality', 'administrative_area_level_1', 'administrative_area_level_2', 'political'];
$candidates = [];
foreach ($data['results'] as $r) {
$types = $r['types'] ?? [];
if (! array_intersect($cityTypes, $types)) {
continue;
}
$components = $r['address_components'] ?? [];
$country = '';
$region = '';
foreach ($components as $c) {
if (in_array('country', $c['types'] ?? [], true)) {
$country = $c['long_name'] ?? '';
}
if (in_array('administrative_area_level_1', $c['types'] ?? [], true)) {
$region = $c['long_name'] ?? '';
}
}
$candidates[] = [
'name' => $r['address_components'][0]['long_name'] ?? $query,
'formatted' => $r['formatted_address'] ?? '',
'country' => $country,
'region' => $region,
'lat' => $r['geometry']['location']['lat'] ?? null,
'lng' => $r['geometry']['location']['lng'] ?? null,
];
if (count($candidates) >= 5) {
break;
}
}
return response()->json(['results' => $candidates]);
} catch (\Throwable $e) {
Log::warning('Cities lookup exception', ['e' => $e->getMessage()]);
return response()->json(['error' => 'exception'], 500);
}
}
}

View File

@ -0,0 +1,141 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
/**
* Forward-Geocoding via Google: "El Gaucho Wien" lat/lng + sauberer Adresse.
* Wird genutzt wenn der LLM einen Ort nennt der nicht in der aktuellen POI-Liste ist.
*/
class FoxGeocodeController
{
public function __invoke(Request $request): JsonResponse
{
$request->validate([
'query' => 'required|string|max:200',
'bias_lat' => 'nullable|numeric',
'bias_lng' => 'nullable|numeric',
]);
$key = config('services.google.maps_key');
if (empty($key)) {
return response()->json(['error' => 'Geocoding nicht konfiguriert'], 503);
}
$query = $request->string('query')->toString();
$biasLat = $request->filled('bias_lat') ? (float) $request->input('bias_lat') : null;
$biasLng = $request->filled('bias_lng') ? (float) $request->input('bias_lng') : null;
// 1. Versuch: Places Text Search - findet POIs anhand des Namens
$hit = $this->tryPlacesText($query, $biasLat, $biasLng, $key);
if ($hit) {
return response()->json($hit);
}
// 2. Fallback: Forward Geocoding - falls's eine Adresse ist
$hit = $this->tryGeocode($query, $biasLat, $biasLng, $key);
if ($hit) {
return response()->json($hit);
}
return response()->json(['error' => 'not_found'], 404);
}
protected function tryPlacesText(string $query, ?float $lat, ?float $lng, string $key): ?array
{
try {
$body = [
'textQuery' => $query,
'languageCode' => 'de',
'regionCode' => 'AT',
'maxResultCount' => 1,
];
if ($lat !== null && $lng !== null) {
$body['locationBias'] = [
'circle' => [
'center' => ['latitude' => $lat, 'longitude' => $lng],
'radius' => 20_000.0,
],
];
}
$response = Http::timeout(8)
->withHeaders([
'Content-Type' => 'application/json',
'X-Goog-Api-Key' => $key,
'X-Goog-FieldMask' => 'places.id,places.displayName,places.formattedAddress,places.location',
])
->post('https://places.googleapis.com/v1/places:searchText', $body);
if (! $response->successful()) {
Log::warning('Places-Text HTTP-Fehler', ['status' => $response->status(), 'body' => mb_substr($response->body(), 0, 200)]);
return null;
}
$place = $response->json('places.0');
if (! $place) {
return null;
}
return [
'name' => $place['displayName']['text'] ?? null,
'address' => $place['formattedAddress'] ?? '',
'lat' => $place['location']['latitude'] ?? null,
'lng' => $place['location']['longitude'] ?? null,
'place_id' => $place['id'] ?? null,
'source' => 'places_text',
];
} catch (\Throwable $e) {
Log::warning('Places-Text exception', ['e' => $e->getMessage()]);
return null;
}
}
protected function tryGeocode(string $query, ?float $lat, ?float $lng, string $key): ?array
{
$params = [
'address' => $query,
'language' => 'de',
'region' => 'at',
'key' => $key,
];
if ($lat !== null && $lng !== null) {
$params['bounds'] = sprintf('%f,%f|%f,%f', $lat - 0.1, $lng - 0.15, $lat + 0.1, $lng + 0.15);
}
try {
$response = Http::timeout(8)
->get('https://maps.googleapis.com/maps/api/geocode/json', $params);
if (! $response->successful()) {
return null;
}
$data = $response->json();
if (($data['status'] ?? null) !== 'OK' || empty($data['results'])) {
return null;
}
$r = $data['results'][0];
return [
'name' => $r['address_components'][0]['long_name'] ?? null,
'address' => $r['formatted_address'] ?? '',
'lat' => $r['geometry']['location']['lat'] ?? null,
'lng' => $r['geometry']['location']['lng'] ?? null,
'place_id' => $r['place_id'] ?? null,
'source' => 'geocode',
];
} catch (\Throwable $e) {
Log::warning('Geocode exception', ['e' => $e->getMessage()]);
return null;
}
}
}

View File

@ -0,0 +1,109 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
/**
* "Wie schaut der erste Bezirk aus?" / "Erzähl mir was über das Belvedere"
* Google Places (New) Text Search + Photo-Fetch. Liefert dem HUD ein Detail-Panel
* mit Bild + Adresse + Bewertung + Öffnungszeiten + Website.
*/
class FoxPlaceInfoController
{
public function __invoke(Request $request): JsonResponse
{
$request->validate([
'query' => 'required|string|max:200',
'bias_lat' => 'nullable|numeric',
'bias_lng' => 'nullable|numeric',
]);
$key = config('services.google.maps_key');
if (empty($key)) {
return response()->json(['error' => 'no_key'], 503);
}
$query = $request->string('query')->toString();
$lat = $request->filled('bias_lat') ? (float) $request->input('bias_lat') : null;
$lng = $request->filled('bias_lng') ? (float) $request->input('bias_lng') : null;
try {
$body = [
'textQuery' => $query,
'languageCode' => 'de',
'regionCode' => 'AT',
'maxResultCount' => 1,
];
if ($lat !== null && $lng !== null) {
$body['locationBias'] = [
'circle' => [
'center' => ['latitude' => $lat, 'longitude' => $lng],
'radius' => 30_000.0,
],
];
}
$response = Http::timeout(10)
->withHeaders([
'Content-Type' => 'application/json',
'X-Goog-Api-Key' => $key,
'X-Goog-FieldMask' => 'places.id,places.displayName,places.formattedAddress,'
.'places.location,places.rating,places.userRatingCount,places.websiteUri,'
.'places.regularOpeningHours,places.types,places.editorialSummary,'
.'places.photos,places.priceLevel,'
.'places.nationalPhoneNumber,places.internationalPhoneNumber',
])
->post('https://places.googleapis.com/v1/places:searchText', $body);
if (! $response->successful()) {
Log::warning('Place-Info HTTP-Fehler', ['status' => $response->status()]);
return response()->json(['error' => 'api_error'], 502);
}
$place = $response->json('places.0');
if (! $place) {
return response()->json(['error' => 'not_found'], 404);
}
$photoUrl = null;
if (! empty($place['photos'][0]['name'])) {
// Photo URL: /v1/{photo_resource}/media?maxWidthPx=...&key=...
$photoUrl = 'https://places.googleapis.com/v1/'.$place['photos'][0]['name'].'/media?maxWidthPx=800&key='.urlencode($key);
}
$photoUrls = [];
foreach (array_slice($place['photos'] ?? [], 0, 5) as $photo) {
if (!empty($photo['name'])) {
$photoUrls[] = 'https://places.googleapis.com/v1/'.$photo['name'].'/media?maxWidthPx=800&key='.urlencode($key);
}
}
return response()->json([
'name' => $place['displayName']['text'] ?? null,
'address' => $place['formattedAddress'] ?? '',
'lat' => $place['location']['latitude'] ?? null,
'lng' => $place['location']['longitude'] ?? null,
'rating' => $place['rating'] ?? null,
'rating_count' => $place['userRatingCount'] ?? null,
'website' => $place['websiteUri'] ?? null,
'summary' => $place['editorialSummary']['text'] ?? null,
'opening_hours' => $place['regularOpeningHours']['weekdayDescriptions'] ?? null,
'types' => $place['types'] ?? [],
'photo_url' => $photoUrl,
'price_level' => $place['priceLevel'] ?? null,
'place_id' => $place['id'] ?? null,
'phone' => $place['nationalPhoneNumber'] ?? $place['internationalPhoneNumber'] ?? null,
'photo_urls' => $photoUrls,
]);
} catch (\Throwable $e) {
Log::warning('Place-Info exception', ['e' => $e->getMessage()]);
return response()->json(['error' => 'exception'], 500);
}
}
}

View File

@ -0,0 +1,35 @@
<?php
namespace App\Http\Controllers;
use App\Services\PlacesService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
/**
* Fallback-Endpoint wenn Overpass tot ist - Google Places liefert dann
* vergleichbare POIs (mit Bewertung, dafür weniger).
*/
class FoxPlacesController
{
public function __invoke(Request $request, PlacesService $places): JsonResponse
{
$request->validate([
'category' => 'required|string|max:40',
'lat' => 'required|numeric',
'lng' => 'required|numeric',
'radius' => 'sometimes|integer|min:100|max:50000',
'cuisine' => 'nullable|string|max:40',
]);
$pois = $places->nearby(
$request->string('category')->toString(),
(float) $request->input('lat'),
(float) $request->input('lng'),
(int) $request->input('radius', 2000),
$request->input('cuisine'),
);
return response()->json(['pois' => $pois, 'source' => 'google_places']);
}
}

View File

@ -0,0 +1,33 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Cache;
/**
* Frontend POSTet die letzten POI-Treffer hierher. Backend speichert sie in
* Redis-Cache, damit FoxAgent::think() sie als Kontext ans LLM mitgeben kann.
* So kann Fox auf "empfehl mir eins" eine konkrete Stelle aus der Liste nennen.
*/
class FoxPoiContextController
{
public function __invoke(Request $request): JsonResponse
{
$data = $request->validate([
'category' => 'nullable|string|max:60',
'label' => 'nullable|string|max:120',
'city' => 'nullable|string|max:120',
'pois' => 'nullable|array|max:50',
'pois.*.name' => 'string|max:160',
'pois.*.address' => 'nullable|string|max:200',
'pois.*.lat' => 'numeric',
'pois.*.lng' => 'numeric',
]);
Cache::put('fox.last_pois', $data, now()->addHours(2));
return response()->json(['ok' => true, 'count' => count($data['pois'] ?? [])]);
}
}

View File

@ -0,0 +1,35 @@
<?php
namespace App\Http\Controllers;
use App\Services\RouteService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
/**
* Browser POSTet origin + destination + mode, Backend ruft Google Directions
* mit dem API-Key auf und gibt fertige GeoJSON + Distanz/Dauer zurück.
*/
class FoxRouteController
{
public function __invoke(Request $request, RouteService $routes): JsonResponse
{
$request->validate([
'origin' => 'required|string|max:200',
'destination' => 'required|string|max:200',
'mode' => 'sometimes|string|in:driving,walking,bicycling,transit',
]);
$route = $routes->route(
$request->string('origin')->toString(),
$request->string('destination')->toString(),
$request->string('mode', 'driving')->toString(),
);
if (! $route) {
return response()->json(['error' => 'Keine Route gefunden'], 404);
}
return response()->json($route);
}
}

View File

@ -0,0 +1,147 @@
<?php
namespace App\Http\Controllers;
use App\Services\SystemStatsService;
use Illuminate\Http\JsonResponse;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
/**
* Voller System-Statusbericht für die HUD-Diagnose-Animation.
* Erweitert SystemStatsService um Service-Pings (Reverb, Google) + Uptime.
*/
class FoxStatusController
{
public function __invoke(SystemStatsService $stats): JsonResponse
{
$base = $stats->collect();
return response()->json([
'system' => [
'cpu' => $base['cpu'],
'memory' => $base['memory'],
'disk' => $base['disk'],
'load' => $base['load'],
'uptime' => $this->uptime(),
],
'services' => [
'ollama' => $base['ollama'],
'openai' => $base['openai'],
'reverb' => $this->reverbStatus(),
'google_directions' => $this->googleApiStatus('directions'),
'google_places' => $this->googleApiStatus('places'),
'google_geocoding' => $this->googleApiStatus('geocoding'),
'searxng' => $this->searxStatus(),
'queue' => ['len' => $base['queue']],
],
'fox' => [
'version' => '0.9.0',
'mode' => config('services.tts.engine', 'piper'),
'voice' => config('services.openai.tts_voice', 'onyx'),
'last_action' => $this->lastActionMeta(),
],
'timestamp' => now()->toIso8601String(),
]);
}
protected function uptime(): array
{
$raw = (string) @file_get_contents('/proc/uptime');
$secs = (float) explode(' ', trim($raw))[0];
return [
'seconds' => (int) $secs,
'human' => $this->humanDuration((int) $secs),
];
}
protected function humanDuration(int $secs): string
{
$d = intdiv($secs, 86400);
$h = intdiv($secs % 86400, 3600);
$m = intdiv($secs % 3600, 60);
return ($d ? $d.'d ' : '').($h ? $h.'h ' : '').$m.'m';
}
protected function reverbStatus(): array
{
try {
$r = Http::timeout(2)->get('http://reverb:8080/');
return ['online' => $r->status() < 500];
} catch (\Throwable) {
return ['online' => false];
}
}
protected function googleApiStatus(string $api): array
{
$key = config('services.google.maps_key');
if (empty($key)) {
return ['online' => false, 'reason' => 'no_key'];
}
// Cache 60s damit jeder Status-Call nicht alle APIs pingt.
return Cache::remember('fox.google.'.$api, 60, function () use ($api, $key) {
try {
if ($api === 'directions') {
$r = Http::timeout(3)->get('https://maps.googleapis.com/maps/api/directions/json', [
'origin' => '48.2082,16.3738', 'destination' => '48.21,16.37', 'key' => $key,
]);
$status = $r->json('status');
return ['online' => $r->successful() && in_array($status, ['OK', 'ZERO_RESULTS'], true)];
}
if ($api === 'geocoding') {
$r = Http::timeout(3)->get('https://maps.googleapis.com/maps/api/geocode/json', [
'latlng' => '48.2082,16.3738', 'key' => $key,
]);
return ['online' => $r->successful() && $r->json('status') === 'OK'];
}
if ($api === 'places') {
$r = Http::timeout(3)
->withHeaders(['X-Goog-Api-Key' => $key, 'X-Goog-FieldMask' => 'places.id'])
->post('https://places.googleapis.com/v1/places:searchText', [
'textQuery' => 'wien', 'maxResultCount' => 1,
]);
return ['online' => $r->successful()];
}
return ['online' => false];
} catch (\Throwable) {
return ['online' => false];
}
});
}
protected function searxStatus(): array
{
try {
$r = Http::timeout(2)->get(rtrim((string) config('services.searxng.url'), '/').'/search', [
'q' => 'test', 'format' => 'json',
]);
return ['online' => $r->status() < 500];
} catch (\Throwable) {
return ['online' => false];
}
}
protected function lastActionMeta(): ?array
{
$cached = Cache::get('fox.last_pois');
if (! is_array($cached)) {
return null;
}
return [
'type' => 'poi_search',
'label' => $cached['label'] ?? null,
'count' => count($cached['pois'] ?? []),
];
}
}

View File

@ -0,0 +1,28 @@
<?php
namespace App\Http\Controllers;
use App\Services\SpeechService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
/**
* Schneller TTS-Endpoint: Browser POSTet kurzen Text, bekommt audio_url zurück.
* Wird vom HUD für Status-Ankündigungen genutzt ("Ich suche...", "X gefunden")
* damit alle Sprachausgaben über Piper Thorsten gehen, nicht via Browser-API.
*/
class FoxTtsController
{
public function __invoke(Request $request, SpeechService $speech): JsonResponse
{
$text = trim((string) $request->input('text', ''));
if ($text === '' || mb_strlen($text) > 500) {
return response()->json(['audio_url' => null, 'error' => 'invalid'], 422);
}
$url = $speech->speak($text);
return response()->json(['audio_url' => $url]);
}
}

View File

@ -0,0 +1,55 @@
<?php
namespace App\Http\Controllers;
use App\Services\SpeechService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Process;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
/**
* Endpoint NUR fürs Transkribieren. Frontend bekommt den Text zurück und
* routet ihn durch den normalen send-Flow (IntentDetector + Livewire).
* NICHT mehr: handleUserMessage / speak() - das würde 2x getriggert + Doppel-Audio.
*/
class FoxVoiceController
{
public function __invoke(Request $request, SpeechService $speech): JsonResponse
{
$upload = $request->file('audio');
if (! $upload) {
return response()->json(['error' => 'no_file', 'detail' => 'audio field missing'], 422);
}
if (! $upload->isValid()) {
return response()->json([
'error' => 'invalid_upload',
'detail' => $upload->getErrorMessage(),
'size' => $upload->getSize(),
], 422);
}
if ($upload->getSize() > 50_000_000) {
return response()->json(['error' => 'too_large', 'size' => $upload->getSize()], 413);
}
// webm/ogg → wav konvertieren (whisper.cpp will 16kHz mono wav)
$rawPath = $upload->getRealPath();
$wavName = 'in_'.Str::random(10).'.wav';
$wavPath = Storage::disk('audio')->path($wavName);
Process::timeout(30)->run(sprintf(
'ffmpeg -y -i %s -ar 16000 -ac 1 -f wav %s 2>&1',
escapeshellarg($rawPath),
escapeshellarg($wavPath),
));
$text = $speech->transcribe($wavPath);
if (! $text) {
return response()->json(['text' => null, 'error' => 'Transkription fehlgeschlagen'], 422);
}
return response()->json(['text' => $text]);
}
}

View File

@ -0,0 +1,96 @@
<?php
namespace App\Jobs;
use App\Events\FoxAction;
use App\Events\FoxMessage;
use App\Models\Message;
use App\Tools\BuildTemplateTool;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
use Prism\Prism\Enums\Provider;
use Prism\Prism\Facades\Prism;
use Prism\Prism\ValueObjects\Messages\UserMessage;
class CreateTemplateJob implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public int $timeout = 180;
public int $tries = 1;
public function __construct(
public string $description,
public Message $userMessage,
) {}
public function handle(BuildTemplateTool $builder): void
{
$desc = $this->description;
$title = ucwords($desc);
$systemPrompt = <<<PROMPT
Du bist ein Web-Developer. Erstelle eine vollständige, moderne HTML-Seite für: {$desc}
REGELN:
- Nur den <body>-Inhalt ausgeben (kein <html>, kein <head>, kein <script>)
- Tailwind CSS Klassen verwenden (CDN ist bereits eingebunden)
- Vollständig auf Deutsch
- Professionelles, modernes Design
- Alle Sektionen: Hero, Über uns, Leistungen/Produkte, Kontakt
- Keine Platzhalter-Bilder (verwende Farb-Hintergründe oder SVG Icons)
- Maximale Qualität, produktionsreif
Gib NUR den Body-HTML zurück, ohne Erklärungen.
PROMPT;
$providers = [
['provider' => Provider::Ollama, 'model' => config('services.ollama.model')],
];
if (! empty(config('services.openai.key'))) {
$providers[] = ['provider' => Provider::OpenAI, 'model' => 'gpt-4o'];
}
$htmlBody = null;
foreach ($providers as $p) {
try {
$response = Prism::text()
->using($p['provider'], $p['model'])
->withClientOptions(['timeout' => 120])
->withSystemPrompt($systemPrompt)
->withMessages([new UserMessage("Erstelle eine Website für: {$desc}")])
->asText();
$text = $response->text ?? '';
// Strip markdown code fences if LLM wrapped in ```html ... ```
$text = preg_replace('/^```(?:html)?\s*/i', '', trim($text));
$text = preg_replace('/\s*```$/i', '', $text);
$htmlBody = trim($text);
break;
} catch (\Throwable $e) {
Log::warning('[CreateTemplate] Provider failed', ['error' => $e->getMessage()]);
}
}
if (! $htmlBody) {
broadcast(new FoxAction([
'action' => 'announce',
'text' => 'Leider konnte ich das Template nicht erstellen - der KI-Dienst ist gerade nicht erreichbar.',
]));
return;
}
$url = $builder->execute($title, $htmlBody);
// Broadcast action to open the URL in HUD
broadcast(new FoxAction([
'action' => 'open_url',
'url' => $url,
'text' => "Template fertig: {$title}. Ich öffne es für dich.",
]));
}
}

View File

@ -0,0 +1,43 @@
<?php
namespace App\Jobs;
use App\Services\FoxAgent;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Log;
class FoxProactiveJob implements ShouldQueue
{
use Dispatchable;
use InteractsWithQueue;
use Queueable;
use SerializesModels;
public int $timeout = 90;
public function handle(FoxAgent $agent): void
{
try {
$decision = $agent->shouldISpeak();
} catch (\Throwable $e) {
Log::error('FoxProactiveJob: Entscheidung fehlgeschlagen', ['e' => $e->getMessage()]);
return;
}
if (! ($decision['shouldSpeak'] ?? false)) {
return;
}
$message = trim((string) ($decision['message'] ?? ''));
if ($message === '') {
return;
}
$agent->speakProactively($message, (int) ($decision['priority'] ?? 5));
}
}

View File

@ -0,0 +1,29 @@
<?php
namespace App\Jobs;
use App\Models\Message;
use App\Services\FoxAgent;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
class HandleUserMessage implements ShouldQueue
{
use Dispatchable;
use InteractsWithQueue;
use Queueable;
use SerializesModels;
public int $timeout = 300;
public int $tries = 1;
public function __construct(public Message $userMessage) {}
public function handle(FoxAgent $agent): void
{
$agent->processUserMessage($this->userMessage);
}
}

57
app/Livewire/Chat.php Normal file
View File

@ -0,0 +1,57 @@
<?php
namespace App\Livewire;
use App\Jobs\HandleUserMessage;
use App\Models\Message;
use App\Services\FoxAgent;
use Livewire\Attributes\On;
use Livewire\Attributes\Title;
use Livewire\Component;
#[Title('Fox')]
class Chat extends Component
{
public string $draft = '';
public bool $foxIsThinking = false;
public function send(): void
{
$content = trim($this->draft);
if ($content === '') {
return;
}
$this->draft = '';
$this->foxIsThinking = true;
// User-Nachricht synchron persistieren + broadcasten,
// dann Verarbeitung an Queue weiterreichen.
$userMessage = app(FoxAgent::class)->recordUserMessage($content);
HandleUserMessage::dispatch($userMessage);
$this->dispatch('message-sent');
}
#[On('echo:fox,.message.created')]
public function onBroadcastedMessage(): void
{
// Antwort kam via Reverb-WebSocket - "denkt..." ausblenden.
$this->foxIsThinking = false;
}
#[On('voice-transcribed')]
public function onVoiceTranscribed(string $text): void
{
$this->draft = $text;
$this->send();
}
public function render()
{
return view('livewire.chat', [
'messages' => Message::latest()->limit(80)->get()->reverse()->values(),
]);
}
}

View File

@ -0,0 +1,25 @@
<?php
namespace App\Livewire;
use App\Services\WeatherService;
use Livewire\Attributes\Layout;
use Livewire\Attributes\Title;
use Livewire\Component;
#[Title('Fox HUD')]
#[Layout('layouts.dashboard')]
class Dashboard extends Component
{
public function getWeatherProperty(): array
{
return app(WeatherService::class)->fetch();
}
public function render()
{
return view('livewire.dashboard', [
'weather' => $this->weather,
]);
}
}

129
app/Livewire/FoxHud.php Normal file
View File

@ -0,0 +1,129 @@
<?php
namespace App\Livewire;
use App\Events\FoxAction;
use App\Events\FoxMessage;
use App\Jobs\CreateTemplateJob;
use App\Jobs\HandleUserMessage;
use App\Models\Message;
use App\Services\FoxAgent;
use App\Services\IntentDetector;
use Livewire\Attributes\Layout;
use Livewire\Attributes\On;
use Livewire\Attributes\Title;
use Livewire\Component;
#[Title('Fox HUD')]
#[Layout('layouts.hud')]
class FoxHud extends Component
{
public string $input = '';
public string $status = 'ready'; // ready | listening | thinking | speaking
/** Vom HUD-JS bei jeder Aktion gesetzt — Kontext für Fox: was ist gerade offen */
public array $hudState = [];
public function send(IntentDetector $intents): void
{
$text = trim($this->input);
if ($text === '') {
return;
}
$this->input = '';
$this->status = 'thinking';
// 1. Intent prüfen - wenn Map-Action erkennbar, sofort broadcasten
$intent = $intents->detect($text);
if ($intent) {
broadcast(new FoxAction($intent));
}
$userMessage = app(FoxAgent::class)->recordUserMessage($text, [
'hud_state' => $this->hudState,
]);
// 2. Volltreffer (map / poi via IntentDetector) → kein LLM-Roundtrip nötig.
// Spart 5-30s Wartezeit; das HUD hat den Spruch schon gesprochen.
if ($intent && ($intent['action'] ?? '') === 'create_template') {
$stub = Message::create([
'role' => Message::ROLE_ASSISTANT,
'content' => $intent['text'],
'type' => Message::TYPE_TEXT,
'metadata' => ['action' => 'create_template', 'fast_path' => true],
]);
broadcast(new FoxMessage($stub));
CreateTemplateJob::dispatch($intent['description'], $userMessage);
$this->status = 'ready';
return;
}
if ($intent && in_array($intent['action'] ?? '', ['map', 'poi', 'transit', 'route', 'route_info', 'close_map', 'place_info', 'place_more', 'system_status', 'reminder', 'map_dynamic', 'desk_card', 'desk_down', 'brain'], true)) {
$stub = Message::create([
'role' => Message::ROLE_ASSISTANT,
'content' => $intent['text'] ?? ($intent['label'] ?? ''),
'type' => Message::TYPE_TEXT,
'metadata' => [
'action' => $intent['action'],
'city' => $intent['city'] ?? null,
'category' => $intent['category'] ?? null,
'cuisine' => $intent['cuisine'] ?? null,
'audio_url' => null,
'fast_path' => true,
],
]);
broadcast(new FoxMessage($stub));
$this->status = 'ready';
return;
}
// 3. Sonst: kurzer "Einen Moment..."-Spruch + LLM-Job dispatchen.
$lower = mb_strtolower($text);
$isQuestion = str_contains($lower, 'empfehl')
|| str_contains($lower, 'vorschlag')
|| str_contains($lower, 'kannst du')
|| str_contains($lower, 'welche')
|| str_contains($lower, 'was ist')
|| str_contains($lower, 'wie ')
|| str_contains($text, '?');
if ($isQuestion) {
$phrases = [
'Einen Moment, ich schau mir das an.',
'Lass mich kurz nachdenken.',
'Klar, gib mir eine Sekunde.',
'Bin gleich für dich da.',
'Ich kümmer mich drum.',
'Schau ich mir an.',
'Sekunde, melde mich gleich.',
'Geht klar, einen kurzen Moment.',
];
broadcast(new FoxAction([
'action' => 'announce',
'text' => $phrases[array_rand($phrases)],
]));
}
HandleUserMessage::dispatch($userMessage);
}
#[On('echo:fox,.message.created')]
public function onMessage(): void
{
$this->status = 'ready';
}
public function render()
{
$homeKey = mb_strtolower(config('services.weather.city', 'wien'));
$cities = config('cities', []);
return view('livewire.fox-hud', [
'cities' => $cities,
'homeCity' => $cities[$homeKey] ?? ($cities['wien'] ?? null),
]);
}
}

44
app/Livewire/Goals.php Normal file
View File

@ -0,0 +1,44 @@
<?php
namespace App\Livewire;
use App\Models\Goal;
use Livewire\Attributes\On;
use Livewire\Attributes\Title;
use Livewire\Component;
#[Title('Ziele · Fox')]
class Goals extends Component
{
#[On('goal-created')]
public function refresh(): void
{
// No-op: triggert Re-Render.
}
public function updateProgress(int $id, int $progress): void
{
Goal::where('id', $id)->update(['progress' => max(0, min(100, $progress))]);
}
public function setStatus(int $id, string $status): void
{
if (! in_array($status, [Goal::STATUS_ACTIVE, Goal::STATUS_PAUSED, Goal::STATUS_DONE, Goal::STATUS_ABANDONED], true)) {
return;
}
Goal::where('id', $id)->update(['status' => $status]);
}
public function destroy(int $id): void
{
Goal::where('id', $id)->delete();
}
public function render()
{
return view('livewire.goals', [
'goals' => Goal::latest()->get(),
]);
}
}

View File

@ -0,0 +1,57 @@
<?php
namespace App\Livewire\Modals;
use App\Models\Goal;
use Livewire\Attributes\Validate;
use Livewire\Component;
class NewGoal extends Component
{
public bool $open = false;
#[Validate('required|string|min:3|max:160')]
public string $title = '';
#[Validate('nullable|string|max:2000')]
public string $description = '';
public function show(): void
{
$this->resetForm();
$this->open = true;
}
public function close(): void
{
$this->open = false;
}
public function save(): void
{
$this->validate();
Goal::create([
'title' => $this->title,
'description' => $this->description,
'status' => Goal::STATUS_ACTIVE,
'progress' => 0,
]);
$this->dispatch('goal-created');
$this->close();
$this->resetForm();
}
protected function resetForm(): void
{
$this->title = '';
$this->description = '';
$this->resetErrorBag();
}
public function render()
{
return view('livewire.modals.new-goal');
}
}

View File

@ -0,0 +1,42 @@
<?php
namespace App\Livewire\Widgets;
use App\Models\Message;
use Livewire\Attributes\On;
use Livewire\Component;
class FoxAvatar extends Component
{
public string $status = 'online'; // online | thinking | speaking
public function getRecentLabelProperty(): string
{
$latest = Message::latest()->first();
return match (true) {
! $latest => 'bereit',
$latest->role === Message::ROLE_USER => 'denkt nach …',
default => 'online',
};
}
#[On('echo:fox,.message.created')]
public function onBroadcastedMessage(array $data = []): void
{
$this->status = ($data['role'] ?? null) === 'user' ? 'thinking' : 'online';
}
#[On('fox-thinking')]
public function setThinking(): void
{
$this->status = 'thinking';
}
public function render()
{
return view('livewire.widgets.fox-avatar', [
'label' => $this->recentLabel,
]);
}
}

View File

@ -0,0 +1,38 @@
<?php
namespace App\Livewire\Widgets;
use App\Services\FoxAgent;
use Livewire\Attributes\On;
use Livewire\Component;
class ScreenPreview extends Component
{
public bool $sharing = false;
public bool $analyzing = false;
/**
* Wird vom Browser-JS via Livewire-Dispatch aufgerufen, sobald
* ein Screenshot als Base64 vorliegt.
*/
#[On('screen-shot-captured')]
public function analyze(string $base64): void
{
$this->analyzing = true;
// Nur Hinweis-Text in den Chat geben - echte Vision-Analyse
// braucht ein Multimodal-Modell (llava etc.) - hier Placeholder.
app(FoxAgent::class)->recordUserMessage(
'[Screenshot wurde geteilt - '.strlen($base64).' bytes]',
['screenshot' => true],
);
$this->analyzing = false;
}
public function render()
{
return view('livewire.widgets.screen-preview');
}
}

View File

@ -0,0 +1,33 @@
<?php
namespace App\Livewire\Widgets;
use App\Services\SystemStatsService;
use Livewire\Attributes\On;
use Livewire\Component;
class SystemStats extends Component
{
public array $stats = [];
public function mount(SystemStatsService $service): void
{
// Erstwert beim Page-Load - danach kommen Updates per Reverb-Push.
$this->stats = $service->collect();
}
/**
* Wird vom Reverb-Broadcast (fox:broadcast-stats Command) gefeuert.
* payload kommt direkt aus SystemStatsBroadcast::broadcastWith().
*/
#[On('echo:fox,.stats.system')]
public function onStats(array $payload): void
{
$this->stats = $payload;
}
public function render()
{
return view('livewire.widgets.system-stats', ['stats' => $this->stats]);
}
}

View File

@ -0,0 +1,28 @@
<?php
namespace App\Livewire\Widgets;
use App\Services\UptimeService;
use Livewire\Attributes\On;
use Livewire\Component;
class Uptime extends Component
{
public array $u = [];
public function mount(UptimeService $service): void
{
$this->u = $service->collect();
}
#[On('echo:fox,.stats.uptime')]
public function onUptime(array $payload): void
{
$this->u = $payload;
}
public function render()
{
return view('livewire.widgets.uptime', ['u' => $this->u]);
}
}

View File

@ -0,0 +1,28 @@
<?php
namespace App\Livewire\Widgets;
use App\Services\WeatherService;
use Livewire\Attributes\On;
use Livewire\Component;
class Weather extends Component
{
public array $w = [];
public function mount(WeatherService $service): void
{
$this->w = $service->fetch();
}
#[On('echo:fox,.stats.weather')]
public function onWeather(array $payload): void
{
$this->w = $payload;
}
public function render()
{
return view('livewire.widgets.weather', ['w' => $this->w]);
}
}

View File

@ -0,0 +1,41 @@
<?php
namespace App\Models\Fox;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Model;
class FoxLearningEvent extends Model
{
use HasUuids;
protected $table = 'fox_learning_events';
const STATUS_PENDING = 'pending';
const STATUS_APPROVED = 'approved';
const STATUS_REJECTED = 'rejected';
protected $fillable = [
'input',
'extracted_memory',
'status',
'approved_at',
'rejected_at',
];
protected $casts = [
'extracted_memory' => 'array',
'approved_at' => 'datetime',
'rejected_at' => 'datetime',
];
public function scopePending($query)
{
return $query->where('status', self::STATUS_PENDING);
}
public function isPending(): bool
{
return $this->status === self::STATUS_PENDING;
}
}

View File

@ -0,0 +1,37 @@
<?php
namespace App\Models\Fox;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class FoxMemoryEdge extends Model
{
use HasUuids;
protected $table = 'fox_memory_edges';
protected $fillable = [
'source_node_id',
'target_node_id',
'relation',
'strength',
'meta',
];
protected $casts = [
'meta' => 'array',
'strength' => 'float',
];
public function sourceNode(): BelongsTo
{
return $this->belongsTo(FoxMemoryNode::class, 'source_node_id');
}
public function targetNode(): BelongsTo
{
return $this->belongsTo(FoxMemoryNode::class, 'target_node_id');
}
}

View File

@ -0,0 +1,46 @@
<?php
namespace App\Models\Fox;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
class FoxMemoryNode extends Model
{
use HasUuids;
protected $table = 'fox_memory_nodes';
protected $fillable = [
'type',
'title',
'content',
'meta',
'confidence',
'weight',
'last_used_at',
];
protected $casts = [
'meta' => 'array',
'confidence' => 'float',
'weight' => 'integer',
'last_used_at' => 'datetime',
];
public function outgoingEdges(): HasMany
{
return $this->hasMany(FoxMemoryEdge::class, 'source_node_id');
}
public function incomingEdges(): HasMany
{
return $this->hasMany(FoxMemoryEdge::class, 'target_node_id');
}
public function touchUsed(): void
{
$this->update(['last_used_at' => now(), 'weight' => $this->weight + 1]);
}
}

View File

@ -0,0 +1,27 @@
<?php
namespace App\Models\Fox;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Model;
class FoxMemorySnapshot extends Model
{
use HasUuids;
protected $table = 'fox_memory_snapshots';
protected $fillable = [
'title',
'summary',
'nodes_count',
'edges_count',
'meta',
];
protected $casts = [
'meta' => 'array',
'nodes_count' => 'integer',
'edges_count' => 'integer',
];
}

36
app/Models/Goal.php Normal file
View File

@ -0,0 +1,36 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class Goal extends Model
{
use HasFactory;
public const STATUS_ACTIVE = 'active';
public const STATUS_PAUSED = 'paused';
public const STATUS_DONE = 'done';
public const STATUS_ABANDONED = 'abandoned';
protected $fillable = [
'title',
'description',
'status',
'progress',
'metadata',
'last_reviewed_at',
];
protected $casts = [
'metadata' => 'array',
'last_reviewed_at' => 'datetime',
'progress' => 'integer',
];
public function scopeActive($query)
{
return $query->where('status', self::STATUS_ACTIVE);
}
}

25
app/Models/Memory.php Normal file
View File

@ -0,0 +1,25 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Pgvector\Laravel\HasNeighbors;
use Pgvector\Laravel\Vector;
class Memory extends Model
{
use HasFactory;
use HasNeighbors;
protected $fillable = [
'content',
'metadata',
'embedding',
];
protected $casts = [
'metadata' => 'array',
'embedding' => Vector::class,
];
}

45
app/Models/Message.php Normal file
View File

@ -0,0 +1,45 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
class Message extends Model
{
use HasFactory;
use HasUuids;
public const ROLE_USER = 'user';
public const ROLE_ASSISTANT = 'assistant';
public const ROLE_SYSTEM = 'system';
public const ROLE_TOOL = 'tool';
public const TYPE_TEXT = 'text';
public const TYPE_PROACTIVE = 'proactive';
public const TYPE_VOICE = 'voice';
public const TYPE_TOOL_USE = 'tool_use';
protected $fillable = [
'id',
'role',
'content',
'type',
'metadata',
];
protected $casts = [
'metadata' => 'array',
];
public function isProactive(): bool
{
return $this->type === self::TYPE_PROACTIVE;
}
public function isFromFox(): bool
{
return $this->role === self::ROLE_ASSISTANT;
}
}

34
app/Models/ToolCall.php Normal file
View File

@ -0,0 +1,34 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class ToolCall extends Model
{
use HasFactory;
protected $fillable = [
'message_id',
'tool',
'input',
'output',
'duration_ms',
'success',
'error',
];
protected $casts = [
'input' => 'array',
'output' => 'array',
'duration_ms' => 'integer',
'success' => 'boolean',
];
public function message(): BelongsTo
{
return $this->belongsTo(Message::class);
}
}

View File

@ -0,0 +1,23 @@
<?php
namespace App\Providers;
use App\Services\FoxAgent;
use App\Services\MemoryService;
use App\Services\SpeechService;
use Illuminate\Support\ServiceProvider;
class AppServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->singleton(MemoryService::class);
$this->app->singleton(SpeechService::class);
$this->app->singleton(FoxAgent::class);
}
public function boot(): void
{
//
}
}

View File

@ -0,0 +1,199 @@
<?php
namespace App\Services\Fox;
use App\Models\Fox\FoxLearningEvent;
use App\Models\Fox\FoxMemoryEdge;
use App\Models\Fox\FoxMemoryNode;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
class FoxMemoryService
{
public function createNode(array $data): FoxMemoryNode
{
return FoxMemoryNode::create([
'type' => $data['type'] ?? 'fact',
'title' => $data['title'],
'content' => $data['content'] ?? null,
'meta' => $data['meta'] ?? null,
'confidence' => $data['confidence'] ?? 1.0,
'weight' => $data['weight'] ?? 1,
]);
}
public function updateNode(FoxMemoryNode $node, array $data): FoxMemoryNode
{
$node->update(array_filter([
'type' => $data['type'] ?? null,
'title' => $data['title'] ?? null,
'content' => $data['content'] ?? null,
'meta' => $data['meta'] ?? null,
'confidence' => $data['confidence'] ?? null,
'weight' => $data['weight'] ?? null,
], fn ($v) => $v !== null));
return $node->fresh();
}
public function createEdge(
FoxMemoryNode $source,
FoxMemoryNode $target,
string $relation,
float $strength = 1.0,
array $meta = [],
): FoxMemoryEdge {
return FoxMemoryEdge::create([
'source_node_id' => $source->id,
'target_node_id' => $target->id,
'relation' => $relation,
'strength' => $strength,
'meta' => empty($meta) ? null : $meta,
]);
}
public function storeLearningEvent(
string $input,
array $extractedMemory = [],
string $status = FoxLearningEvent::STATUS_PENDING,
): FoxLearningEvent {
return FoxLearningEvent::create([
'input' => $input,
'extracted_memory' => empty($extractedMemory) ? null : $extractedMemory,
'status' => $status,
]);
}
public function approveLearningEvent(FoxLearningEvent $event): void
{
$event->update([
'status' => FoxLearningEvent::STATUS_APPROVED,
'approved_at' => now(),
]);
$memory = $event->extracted_memory ?? [];
if (empty($memory)) {
return;
}
DB::transaction(function () use ($memory) {
foreach ($memory['nodes'] ?? [] as $nodeData) {
$existing = FoxMemoryNode::where('title', $nodeData['title'])
->where('type', $nodeData['type'] ?? 'fact')
->first();
if ($existing) {
$this->updateNode($existing, $nodeData);
} else {
$this->createNode($nodeData);
}
}
foreach ($memory['edges'] ?? [] as $edgeData) {
$source = FoxMemoryNode::where('title', $edgeData['source'])->first();
$target = FoxMemoryNode::where('title', $edgeData['target'])->first();
if ($source && $target) {
$exists = FoxMemoryEdge::where('source_node_id', $source->id)
->where('target_node_id', $target->id)
->where('relation', $edgeData['relation'])
->exists();
if (! $exists) {
$this->createEdge(
$source,
$target,
$edgeData['relation'],
(float) ($edgeData['strength'] ?? 1.0),
);
}
}
}
});
}
public function rejectLearningEvent(FoxLearningEvent $event): void
{
$event->update([
'status' => FoxLearningEvent::STATUS_REJECTED,
'rejected_at' => now(),
]);
}
/**
* Simple relevance search via title/content LIKE match.
* Structure ready for vector embeddings later swap this method body.
*/
public function searchRelevantMemories(string $query, int $limit = 10): Collection
{
if (blank($query)) {
return collect();
}
$terms = collect(explode(' ', $query))
->filter(fn ($t) => mb_strlen($t) > 2)
->take(5);
$q = FoxMemoryNode::query();
foreach ($terms as $term) {
$q->orWhere('title', 'ilike', "%{$term}%")
->orWhere('content', 'ilike', "%{$term}%")
->orWhere('type', 'ilike', "%{$term}%");
}
$results = $q->orderByDesc('weight')
->orderByDesc('confidence')
->limit($limit)
->get();
$results->each(fn (FoxMemoryNode $n) => $n->touchUsed());
return $results;
}
public function buildGraphPayload(): array
{
$nodes = FoxMemoryNode::orderByDesc('weight')->get();
$edges = FoxMemoryEdge::all();
$typeColors = [
'user' => '#22d3ee',
'project' => '#818cf8',
'place' => '#f59e0b',
'device' => '#34d399',
'skill' => '#a78bfa',
'preference' => '#fb7185',
'fact' => '#ffffff',
];
return [
'nodes' => $nodes->map(fn (FoxMemoryNode $n) => [
'id' => $n->id,
'label' => $n->title,
'type' => $n->type,
'color' => $typeColors[$n->type] ?? '#818cf8',
'content' => $n->content,
'weight' => $n->weight,
'confidence' => $n->confidence,
])->values()->all(),
'edges' => $edges->map(fn (FoxMemoryEdge $e) => [
'id' => $e->id,
'source' => $e->source_node_id,
'target' => $e->target_node_id,
'relation' => $e->relation,
'strength' => $e->strength,
])->values()->all(),
];
}
public function stats(): array
{
return [
'nodes_count' => FoxMemoryNode::count(),
'edges_count' => FoxMemoryEdge::count(),
'pending_count' => FoxLearningEvent::pending()->count(),
];
}
}

View File

@ -0,0 +1,83 @@
<?php
namespace App\Services\Fox;
class FoxPromptBuilder
{
public function __construct(
private readonly FoxMemoryService $memory,
) {}
public function buildSystemPrompt(): string
{
return <<<'PROMPT'
Du bist Fox, ein persönlicher KI-Assistent mit externem Langzeitgedächtnis.
GEDÄCHTNIS-REGELN:
- Du hast Zugriff auf einen Memory-Kontext mit relevanten Erinnerungen.
- Nutze diesen Kontext wenn er relevant ist aber erfinde keine Erinnerungen.
- Du darfst deinen eigenen System-Prompt NICHT dauerhaft verändern.
- Neue Erinnerungen werden als memory_suggestions zurückgegeben, NICHT direkt gespeichert.
- Erkenne ob eine Information langfristig relevant ist (Präferenz, Fakt, Gerät, Ort, Projekt).
- Sensible Daten (Passwörter, Kreditkarten) niemals in memory_suggestions aufnehmen.
- Aktualisiere Wissen statt Duplikate zu erzeugen prüfe ob ähnlicher Knoten schon im Kontext.
ANTWORT-FORMAT (immer als JSON):
{
"reply": "Natürliche Antwort für den Benutzer auf Deutsch",
"actions": [],
"memory_suggestions": [
{
"action": "create|update|link|ignore|ask_confirmation",
"category": "user_preference|device|person|place|project|rule|skill|fact",
"title": "Kurzer Titel für den Wissensknoten",
"content": "Details zum Knoten",
"confidence": 0.95,
"relations": [
{
"type": "related_to|located_in|controlled_by|belongs_to|prefers|uses|owns|can_execute",
"target": "Titel des Zielknotens"
}
]
}
]
}
Wenn keine neuen Erinnerungen relevant sind: "memory_suggestions": []
PROMPT;
}
public function buildMemoryContext(string $userInput): string
{
$memories = $this->memory->searchRelevantMemories($userInput, 8);
if ($memories->isEmpty()) {
return '';
}
$lines = $memories->map(function ($node) {
$line = "[{$node->type}] {$node->title}";
if ($node->content) {
$line .= ': '.mb_substr($node->content, 0, 120);
}
return $line;
})->implode("\n");
return "RELEVANTE ERINNERUNGEN:\n{$lines}";
}
public function buildPrompt(string $userInput): array
{
$system = $this->buildSystemPrompt();
$memCtx = $this->buildMemoryContext($userInput);
$fullSystem = $memCtx
? $system."\n\n".$memCtx
: $system;
return [
'system' => $fullSystem,
'user' => $userInput,
];
}
}

732
app/Services/FoxAgent.php Normal file
View File

@ -0,0 +1,732 @@
<?php
namespace App\Services;
use App\Events\FoxAction;
use App\Events\FoxMessage;
use App\Models\Goal;
use App\Models\Message;
use App\Models\ToolCall;
use App\Tools\BuildTemplateTool;
use App\Tools\FileManagerTool;
use App\Tools\WebSearchTool;
use Illuminate\Support\Facades\Log;
use Prism\Prism\Enums\Provider;
use Prism\Prism\Facades\Prism;
use Prism\Prism\Schema\BooleanSchema;
use Prism\Prism\Schema\EnumSchema;
use Prism\Prism\Schema\NumberSchema;
use Prism\Prism\Schema\ObjectSchema;
use Prism\Prism\Schema\StringSchema;
use Prism\Prism\Tool;
use Prism\Prism\ValueObjects\Messages\AssistantMessage;
use Prism\Prism\ValueObjects\Messages\UserMessage;
/**
* Fox Agent Loop:
* think - Kontext sammeln (Verlauf, aktive Ziele, relevante Memories)
* plan - LLM aufrufen
* act - Antwort speichern + broadcasten
* reflect- ggf. ins Langzeitgedächtnis ablegen
*/
class FoxAgent
{
public function __construct(
protected MemoryService $memory,
) {}
/**
* Persistiert die User-Nachricht und broadcastet sie. Verarbeitung erfolgt
* separat via processUserMessage() (i. d. R. async im Queue-Worker).
*/
public function recordUserMessage(string $content, array $metadata = []): Message
{
$message = Message::create([
'role' => Message::ROLE_USER,
'content' => $content,
'type' => ($metadata['voice'] ?? false) ? Message::TYPE_VOICE : Message::TYPE_TEXT,
'metadata' => $metadata,
]);
broadcast(new FoxMessage($message))->toOthers();
return $message;
}
/**
* Verarbeitet eine User-Nachricht: think plan act reflect.
* Gibt die Assistant-Message zurück.
*/
public function processUserMessage(Message $userMessage): Message
{
$context = $this->think($userMessage->content);
$reply = $this->plan($context, $userMessage->content);
$assistantMessage = $this->act($reply, Message::TYPE_TEXT);
$this->reflect($userMessage->content, $reply['text']);
return $assistantMessage;
}
/**
* Convenience: kombiniert record + process. Synchron - nur für Tests/CLI.
*/
public function handleUserMessage(string $content, array $metadata = []): Message
{
$userMessage = $this->recordUserMessage($content, $metadata);
return $this->processUserMessage($userMessage);
}
/**
* Wird vom Proactive-Job aufgerufen. Fragt das LLM ob Fox aktiv etwas sagen soll.
*
* @return array{shouldSpeak: bool, message: string, priority: int}
*/
public function shouldISpeak(): array
{
$now = now();
$hour = (int) $now->format('H');
$timeBucket = match (true) {
$hour < 6 => 'nacht',
$hour < 11 => 'morgen',
$hour < 14 => 'mittag',
$hour < 17 => 'nachmittag',
$hour < 22 => 'abend',
default => 'nacht',
};
$activeGoals = Goal::active()->get();
$lastMessage = Message::latest()->first();
$minutesSinceLast = $lastMessage
? (int) $lastMessage->created_at->diffInMinutes($now)
: 9999;
if ($minutesSinceLast < 240) {
return ['shouldSpeak' => false, 'message' => '', 'priority' => 0];
}
// Was hat der User zuletzt gemacht? Letzte Suche / Empfehlung
$lastPois = \Illuminate\Support\Facades\Cache::get('fox.last_pois');
$lastSearch = is_array($lastPois)
? ($lastPois['label'] ?? null).' in '.($lastPois['city'] ?? '?')
: null;
// Wetter + Zeit für tageszeitabhängige Tipps
$weather = null;
try {
$w = app(\App\Services\WeatherService::class)->current();
if ($w) {
$weather = round($w['temperature'] ?? 0, 1).'°C';
}
} catch (\Throwable) {
// ignore
}
$context = [
'time' => $now->format('Y-m-d H:i'),
'time_bucket' => $timeBucket,
'weekday' => $now->locale('de')->dayName,
'weather' => $weather,
'active_goals' => $activeGoals->map(fn (Goal $g) => [
'title' => $g->title, 'progress' => $g->progress,
])->all(),
'minutes_since_user_was_active' => $minutesSinceLast,
'last_search' => $lastSearch,
'recent_memories' => $this->memory->summarizeRecent(5),
];
$schema = new ObjectSchema(
name: 'ProactiveDecision',
description: 'Soll Fox jetzt unaufgefordert ein Wort einwerfen?',
properties: [
new BooleanSchema('shouldSpeak', 'true wenn Fox jetzt etwas Sinnvolles oder Persönliches einwerfen kann'),
new StringSchema('message', 'Was Fox sagt - 1-2 deutsche Sätze, locker und persönlich. Leer wenn shouldSpeak=false.'),
new NumberSchema('priority', '0-10. 0=Floskel, 5=Tageszeit/Wetter, 7=Goal-Reminder, 10=wichtig'),
],
requiredFields: ['shouldSpeak', 'message', 'priority'],
);
$systemPrompt = <<<'PROMPT'
Du bist Fox. Sprich NUR wenn du etwas KONKRETES zu erledigen hast.
Erlaubte proaktive Nachrichten:
- Kalendertermin in den nächsten 30 Minuten
- Wichtige E-Mail die eine Antwort braucht
- Aufgabe aus den aktiven Zielen die der User vergessen könnte
- Technisches Problem erkannt
ABSOLUT VERBOTEN:
- NIEMALS fragen ob der User essen gehen möchte
- NIEMALS Small Talk starten
- NIEMALS nach dem Befinden fragen
- NIEMALS Wetter-Tipps zu Aktivitäten
- NIEMALS dasselbe wie in recent_memories wiederholen
- Wenn time_bucket=nacht: shouldSpeak=false (User schläft)
Wenn KEIN konkreter Anlass aus der obigen Liste: shouldSpeak=false.
PROMPT;
// Provider-Chain wie bei plan() - Ollama → OpenAI → still
$providers = [['provider' => Provider::Ollama, 'model' => config('services.ollama.model')]];
if (! empty(config('services.openai.key'))) {
$providers[] = ['provider' => Provider::OpenAI, 'model' => 'gpt-4o-mini'];
}
foreach ($providers as $p) {
try {
$response = Prism::structured()
->using($p['provider'], $p['model'])
->withClientOptions(['timeout' => 25])
->withSystemPrompt($systemPrompt)
->withSchema($schema)
->withPrompt('Kontext: '.json_encode($context, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT))
->asStructured();
$decision = $response->structured ?? [];
return [
'shouldSpeak' => (bool) ($decision['shouldSpeak'] ?? false),
'message' => trim((string) ($decision['message'] ?? '')),
'priority' => (int) ($decision['priority'] ?? 0),
];
} catch (\Throwable $e) {
Log::warning('Proactive: Provider fehlgeschlagen', ['error' => $e->getMessage()]);
}
}
return ['shouldSpeak' => false, 'message' => '', 'priority' => 0];
}
public function speakProactively(string $text, int $priority = 5): Message
{
return $this->act(
['text' => $text, 'tool_calls' => []],
Message::TYPE_PROACTIVE,
['priority' => $priority],
);
}
// ---- Agent-Loop Schritte ----
protected function think(string $userInput): array
{
$recentMessages = Message::latest()->limit(10)->get()->reverse()->values();
$relevantMemories = $this->memory->recall($userInput, 3);
$activeGoals = Goal::active()->get();
$lastPois = \Illuminate\Support\Facades\Cache::get('fox.last_pois');
// HUD-Zustand aus letzter User-Nachricht — was ist gerade offen
$lastUserMessage = Message::where('role', Message::ROLE_USER)->latest()->first();
$hudState = $lastUserMessage?->metadata['hud_state'] ?? [];
return [
'recent_messages' => $recentMessages,
'memories' => $relevantMemories,
'goals' => $activeGoals,
'last_pois' => $lastPois,
'hud_state' => $hudState,
];
}
/**
* HUD-State als Text für System-Prompt Fox weiß damit was gerade sichtbar ist.
*/
protected function buildStateContext(array $state): string
{
if (empty($state)) {
return '';
}
$lines = ['Aktueller HUD-Zustand (was gerade auf dem Bildschirm sichtbar ist):'];
if ($state['map_visible'] ?? false) {
$city = $state['current_city'] ?? 'unbekannte Stadt';
$lines[] = '- Karte ist OFFEN und zeigt: '.$city;
} else {
$lines[] = '- Karte ist GESCHLOSSEN';
}
if ($state['poi_panel_open'] ?? false) {
$label = $state['poi_label'] ?? 'Orte';
$count = $state['poi_count'] ?? 0;
$lines[] = '- POI-Panel ist OFFEN mit '.$count.' '.$label;
}
if ($state['transit_on'] ?? false) {
$lines[] = '- Transit/U-Bahn-Layer ist AKTIV';
}
if ($state['route_visible'] ?? false) {
$lines[] = '- Route-Panel ist OFFEN ('.($state['route_target'] ?? 'unbekanntes Ziel').')';
}
if ($state['status_visible'] ?? false) {
$lines[] = '- System-Diagnose-Panel ist OFFEN';
}
if ($state['place_info_visible'] ?? false) {
$lines[] = '- Place-Info-Modal ist OFFEN ('.($state['place_info_name'] ?? '?').')';
}
$lines[] = '';
$lines[] = 'Schließen-Befehle des Users beziehen sich IMMER auf das was gerade offen ist.';
$lines[] = '"schließe das" / "weg damit" / "zu" → action passend zum aktiven Overlay (close_map / close_poi / close_transit / close_status).';
$lines[] = 'Wenn nichts offen ist und User "schließe X" sagt: action=response mit "Da ist nichts offen.".';
return implode("\n", $lines);
}
/**
* @return array{action:string, city:?string, text:string, tool_calls:array}
*/
protected function plan(array $context, string $userInput): array
{
$messages = $context['recent_messages']
->filter(fn (Message $m) => in_array($m->role, [Message::ROLE_USER, Message::ROLE_ASSISTANT], true))
->map(fn (Message $m) => $m->role === Message::ROLE_USER
? new UserMessage($m->content)
: new AssistantMessage($m->content))
->values()
->all();
$messages[] = new UserMessage($userInput);
$memoriesText = collect($context['memories'])
->map(fn ($m) => '- '.$m->content)
->implode("\n");
$goalsText = $context['goals']
->map(fn (Goal $g) => "- {$g->title} ({$g->progress}%)")
->implode("\n");
// Letzte POI-Treffer als Liste für die Empfehlung formatieren
$poisText = '';
$lastPois = $context['last_pois'] ?? null;
if (is_array($lastPois) && ! empty($lastPois['pois'])) {
$items = collect($lastPois['pois'])
->take(15)
->map(fn ($p) => '- '.($p['name'] ?? '?').(! empty($p['address']) ? ' ('.$p['address'].')' : ''))
->implode("\n");
$what = $lastPois['label'] ?? 'Orte';
$where = $lastPois['city'] ?? '';
$poisText = "Diese {$what} hast du dem User gerade auf der Karte gezeigt".($where ? " in {$where}" : '').":\n{$items}";
}
// HUD-State-Kontext für intelligenteres Schließ-Verhalten
$stateText = $this->buildStateContext($context['hud_state'] ?? []);
$systemPrompt = trim(<<<PROMPT
Du bist Fox, ein persönlicher KI-Assistent der HANDELT statt redet. Du sprichst Deutsch.
KERNREGEL: Führe Aufgaben aus. Frage nicht ob du helfen sollst tu es einfach.
WAS DU KANNST:
map: Karte mit Stadt oder Ort zeigen
poi: Orte suchen (Restaurants, Apotheken, Parkplätze, Hotels, Ärzte, Banken, Supermärkte)
weather: Wetter anzeigen
transit: U-Bahn/Transit Layer (nur Liniennetz, KEINE Route von A nach B)
route: Navigation von A nach B
recommend: Konkreten Ort aus der POI-Liste empfehlen
web_search: Im Web suchen
open_url: URL im Browser öffnen (Google, YouTube, Maps, etc.)
code: VOLLSTÄNDIGEN ausführbaren Code schreiben oder erklären
calculate: Rechnen, Umrechnen, Kalkulieren
remind: Erinnerung setzen
brain: Fox Brain / Gedächtnis-Visualisierung öffnen
response: Normale Antwort wenn keine andere Action passt
BEISPIELE:
"google laravel websockets" action=open_url, url="https://google.com/search?q=laravel+websockets"
"wie geht array_map in php" action=code, language="php", code="\$r = array_map(fn(\$x) => \$x*2, [1,2,3]); // [2,4,6]"
"wieviel ist 15% von 340" action=calculate, result="51"
"öffne youtube" action=open_url, url="https://youtube.com"
"wie ist das wetter morgen" action=weather, city=graz
"zeig wien" action=map, city=wien
"parkplatz in der nähe" action=poi, category=parking
"restaurants wien" action=poi, category=restaurant, city=wien
"was ist docker compose" action=response
"route zum stephansdom" action=route, to="Stephansdom"
"empfehl mir eins" action=recommend, place="EXAKTER_NAME_AUS_LISTE"
"zeig mir dein Gehirn" action=brain
"was weißt du" action=brain
"dein Gedächtnis" action=brain
"memory" action=brain
BEI action=code:
- code: VOLLSTÄNDIGER ausführbarer Code, nicht nur Erklärung
- language: php | javascript | python | bash | sql | html | css
- text: Eine Zeile was der Code macht (kein "Ich erstelle...")
- Kommentare im Code erlaubt, aber kein langer Erklärungs-Text
ROUTING: Bei ROUTE/WEGBESCHREIBUNG/NAVIGATION action=route.
to = Zielname oder "__LAST_RECOMMEND__" bei "dahin/dorthin".
NIEMALS transit dafür verwenden.
EMPFEHLUNG: Wähle EXAKTEN Namen aus der POI-Liste unten.
place = Copy-Paste des Namens mit Sonderzeichen.
WAS DU NICHT TUN SOLLST:
- NIEMALS fragen ob der User essen gehen möchte
- NIEMALS "Kann ich dir helfen?" fragen
- NIEMALS Small Talk
- NIEMALS "Ich kann leider..." sagen
- NIEMALS lange Erklärungen wenn kurze Antwort reicht
text: maximal 2 deutsche Sätze, direkt und knapp.
Aktive Ziele:
{$goalsText}
Gedächtnis:
{$memoriesText}
{$poisText}
{$stateText}
PROMPT);
$schema = new ObjectSchema(
name: 'FoxResponse',
description: 'Strukturierte Antwort von Fox',
properties: [
new EnumSchema('action', 'Was Fox tun soll', [
'map', 'poi', 'weather', 'transit', 'route', 'recommend', 'response',
'close_map', 'close_poi', 'close_transit', 'close_status',
'web_search', 'open_url', 'code', 'calculate', 'remind', 'brain',
]),
new StringSchema('city', 'Kleingeschriebener Stadtname (leer wenn nicht relevant)'),
new StringSchema('category', 'POI-Kategorie bei action=poi: restaurant, parking, pharmacy, hotel, doctor, bank, supermarket'),
new StringSchema('place', 'EXAKTER Name aus der Liste falls action=recommend (sonst leer)'),
new StringSchema('to', 'Ziel falls action=route - Ortsname oder "__LAST_RECOMMEND__"'),
new StringSchema('text', 'Deutsche Antwort die vorgelesen wird, max 2 Sätze'),
new StringSchema('url', 'Vollständige URL bei action=open_url oder web_search'),
new StringSchema('query', 'Suchbegriff bei action=web_search'),
new StringSchema('code', 'Code-Snippet bei action=code'),
new StringSchema('language', 'Programmiersprache bei action=code (php, js, python, etc.)'),
new StringSchema('result', 'Berechnetes Ergebnis bei action=calculate'),
new StringSchema('description', 'Beschreibung was der Code macht, bei action=code'),
],
requiredFields: ['action', 'text', 'city', 'category', 'place', 'to', 'url', 'query', 'code', 'language', 'result', 'description'],
);
// Provider-Chain: Ollama (Mac, qwen2.5:32b) → OpenAI (gpt-4o-mini) → Heuristik.
// So bleibt Fox auch funktional wenn der Mac off ist.
$providers = [
['provider' => Provider::Ollama, 'model' => config('services.ollama.model'), 'label' => 'ollama'],
];
if (! empty(config('services.openai.key'))) {
$providers[] = ['provider' => Provider::OpenAI, 'model' => 'gpt-4o-mini', 'label' => 'openai'];
}
$lastError = null;
foreach ($providers as $p) {
try {
$response = Prism::structured()
->using($p['provider'], $p['model'])
->withClientOptions(['timeout' => $p['label'] === 'ollama' ? 30 : 25])
->withSystemPrompt($systemPrompt)
->withMessages($messages)
->withSchema($schema)
->asStructured();
$data = $response->structured ?? [];
Log::info('Plan: '.$p['label'].' OK');
return [
'action' => (string) ($data['action'] ?? 'response'),
'city' => isset($data['city']) ? mb_strtolower(trim((string) $data['city'])) : null,
'category' => isset($data['category']) ? trim((string) $data['category']) : null,
'place' => isset($data['place']) ? trim((string) $data['place']) : null,
'to' => isset($data['to']) && $data['to'] !== '' ? trim((string) $data['to']) : null,
'text' => trim((string) ($data['text'] ?? '')) ?: 'Hm.',
'url' => isset($data['url']) ? trim((string) $data['url']) : null,
'query' => isset($data['query']) ? trim((string) $data['query']) : null,
'code' => isset($data['code']) ? (string) $data['code'] : null,
'language' => isset($data['language']) ? trim((string) $data['language']) : null,
'result' => isset($data['result']) ? trim((string) $data['result']) : null,
'description' => isset($data['description']) ? trim((string) $data['description']) : null,
'tool_calls' => [],
];
} catch (\Throwable $e) {
$lastError = $e->getMessage();
Log::warning('Plan-Provider '.$p['label'].' fehlgeschlagen, versuche Fallback', ['error' => $lastError]);
}
}
// Letzte Stufe: heuristischer Fallback für Empfehlungen + nützliche Diagnose.
Log::error('Plan-Schritt: alle LLM-Provider tot', ['lastError' => $lastError]);
return $this->heuristicFallback($userInput, $context, $lastError);
}
/**
* Wenn alle LLMs tot sind: aus dem Pattern + letzter POI-Liste etwas Sinnvolles
* generieren statt nur "etwas ist schief gelaufen" zu sagen.
*/
/**
* Garantiert dass ein recommend-place tatsächlich in der zuletzt gezeigten
* Liste war - sonst halluziniert das LLM Namen die das Frontend nicht zoomen kann.
*/
protected function validateRecommendation(string $place): string
{
$cached = \Illuminate\Support\Facades\Cache::get('fox.last_pois');
$pois = is_array($cached) ? ($cached['pois'] ?? []) : [];
if (empty($pois)) {
return $place; // keine Liste → wir lassen den Originalnamen, Frontend versucht Geocoding
}
$norm = fn ($s) => mb_strtolower(preg_replace('/[^\p{L}\p{N} ]/u', '', (string) $s));
$needle = $norm($place);
// exact / substring match
foreach ($pois as $p) {
$n = $norm($p['name'] ?? '');
if ($n === $needle || ($n && (str_contains($n, $needle) || str_contains($needle, $n)))) {
return $p['name'];
}
}
// Halluzination → ersten der Liste nehmen, der ist nach Relevanz sortiert
\Illuminate\Support\Facades\Log::info('Recommendation halluziniert, ersetzt', [
'llm_said' => $place,
'replaced_with' => $pois[0]['name'] ?? '?',
]);
return $pois[0]['name'] ?? $place;
}
protected function heuristicFallback(string $userInput, array $context, ?string $lastError): array
{
$lower = mb_strtolower($userInput);
$lastPois = $context['last_pois'] ?? null;
$pois = is_array($lastPois) ? ($lastPois['pois'] ?? []) : [];
$isRecommend = (bool) preg_match('/\b(empfehl|vorschl|welches|welche|kannst du (mir |eins)|gib mir|zeig mir eins)/u', $lower);
if ($isRecommend && ! empty($pois)) {
// Random aus den oberen 5 - die haben den höchsten Relevanz-Score.
$top = array_slice($pois, 0, 5);
$pick = $top[array_rand($top)];
$name = $pick['name'] ?? '?';
$addr = $pick['address'] ?? '';
return [
'action' => 'recommend',
'city' => $lastPois['city'] ?? null,
'place' => $name,
'text' => 'Probier doch '.$name.($addr ? ' in der '.$addr : '').'.',
'tool_calls' => [],
];
}
// Diagnose statt Floskel: User soll wissen WAS down ist
$diag = 'Mein Sprachmodell ist gerade nicht erreichbar';
if ($lastError && str_contains($lastError, '11434')) {
$diag = 'Der Mac mit Ollama antwortet nicht (Port 11434 dicht)';
} elseif ($lastError && str_contains($lastError, '429')) {
$diag = 'Mein Cloud-LLM hat das Quota-Limit erreicht';
} elseif ($lastError && (str_contains($lastError, 'timeout') || str_contains($lastError, 'timed out'))) {
$diag = 'Mein Sprachmodell antwortet zu langsam';
}
return [
'action' => 'response',
'city' => null,
'text' => $diag.'. Versuch es in ein paar Sekunden nochmal, oder formulier kürzer.',
'tool_calls' => [],
];
}
protected function act(array $reply, string $type, array $metadata = []): Message
{
$action = (string) ($reply['action'] ?? 'response');
$city = $reply['city'] ?? null;
// TTS NUR für reine Chat-Antworten - bei map/poi/transit/weather hat
// das HUD lokal schon "Ich suche..." und "X gefunden" gesagt, sonst
// doppelt gesprochen.
$audioUrl = null;
$shouldSpeak = ! empty($reply['text'])
&& ! empty(config('services.openai.key'));
if ($shouldSpeak) {
try {
$audioUrl = app(SpeechService::class)->speak($reply['text']);
} catch (\Throwable $e) {
Log::warning('TTS fehlgeschlagen', ['error' => $e->getMessage()]);
}
}
// Wenn die Antwort eine HUD-Action enthält - extra Broadcast,
// damit das Frontend Map/Food/Weather sofort verarbeiten kann.
if (in_array($action, ['map', 'poi', 'weather', 'transit'], true)) {
$cities = config('cities', []);
$cityKey = $city ? $this->resolveCityKey($city, $cities) : null;
$payload = [
'action' => $action,
'city' => $cityKey,
'text' => $reply['text'] ?? '',
'data' => $cityKey ? ($cities[$cityKey] ?? null) : null,
];
if ($action === 'poi' && ! empty($reply['category'])) {
$payload['category'] = $reply['category'];
}
broadcast(new FoxAction($payload));
}
// Close-Actions vom LLM (state-aware)
if (in_array($action, ['close_map', 'close_poi', 'close_transit', 'close_status'], true)) {
broadcast(new FoxAction([
'action' => $action,
'text' => $reply['text'] ?? '',
]));
}
// Bei Empfehlung: Frontend matcht den place-Namen aus der letzten POI-Liste,
// zoomt zur Stelle hin und zeigt Popup. Wenn LLM einen Namen halluziniert
// der nicht in der echten Liste ist - durch ersten Treffer ersetzen.
if ($action === 'recommend' && ! empty($reply['place'])) {
$place = $this->validateRecommendation($reply['place']);
$text = $reply['text'] ?? '';
// Falls der Place ersetzt wurde, auch im Text korrigieren wenn der Originalname enthalten ist
if ($place !== $reply['place'] && $text && str_contains($text, $reply['place'])) {
$text = str_replace($reply['place'], $place, $text);
}
broadcast(new FoxAction([
'action' => 'recommend',
'place' => $place,
'text' => $text,
]));
}
// Routing: Frontend ruft danach /fox/route → zeichnet Linie auf der Karte.
if ($action === 'route' && ! empty($reply['to'])) {
broadcast(new FoxAction([
'action' => 'route',
'to' => $reply['to'],
'mode' => 'driving',
'text' => $reply['text'] ?? '',
]));
}
// Web-Suche: als open_url an Frontend schicken
if ($action === 'web_search') {
$searchUrl = ! empty($reply['url'])
? $reply['url']
: 'https://google.com/search?q='.urlencode((string) ($reply['query'] ?? ''));
broadcast(new FoxAction([
'action' => 'open_url',
'url' => $searchUrl,
'text' => $reply['text'] ?? '',
]));
}
// URL öffnen
if ($action === 'open_url' && ! empty($reply['url'])) {
broadcast(new FoxAction([
'action' => 'open_url',
'url' => $reply['url'],
'text' => $reply['text'] ?? '',
]));
}
// Code-Antwort
if ($action === 'code') {
broadcast(new FoxAction([
'action' => 'code',
'code' => $reply['code'] ?? '',
'language' => $reply['language'] ?? 'php',
'text' => $reply['text'] ?? '',
'description' => $reply['description'] ?? $reply['text'] ?? '',
'files' => $reply['files'] ?? [],
]));
}
// Rechenergebnis
if ($action === 'calculate') {
broadcast(new FoxAction([
'action' => 'calculate',
'result' => $reply['result'] ?? '',
'text' => $reply['text'] ?? '',
]));
}
// Brain-Visualisierung öffnen
if ($action === 'brain') {
broadcast(new FoxAction([
'action' => 'brain',
'text' => $reply['text'] ?? 'Hier ist mein Gedächtnis.',
]));
}
$message = Message::create([
'role' => Message::ROLE_ASSISTANT,
'content' => $reply['text'] ?: '...',
'type' => $type,
'metadata' => array_merge($metadata, [
'action' => $action,
'city' => $city,
'audio_url' => $audioUrl,
]),
]);
foreach ($reply['tool_calls'] ?? [] as $call) {
ToolCall::create([
'message_id' => $message->id,
'tool' => $call['tool'] ?? 'unknown',
'input' => $call['arguments'] ?? null,
'output' => is_array($call['result']) ? $call['result'] : ['result' => $call['result']],
'success' => true,
]);
}
broadcast(new FoxMessage($message));
return $message;
}
/**
* LLM gibt manchmal "Wien" oder "Vienna" - wir matchen flexibel auf
* unsere City-Keys (kleinschreibung, alias-tolerant).
*/
protected function resolveCityKey(string $name, array $cities): ?string
{
$needle = mb_strtolower(trim($name));
if (isset($cities[$needle])) {
return $needle;
}
// Fuzzy: Stadt-Name in Cities-Display-Names suchen
foreach ($cities as $key => $data) {
if (mb_strtolower($data['name']) === $needle) {
return $key;
}
}
return null;
}
protected function reflect(string $userInput, string $assistantReply): void
{
$combined = "User: {$userInput}\nFox: {$assistantReply}";
if (mb_strlen($combined) < 80) {
return;
}
$this->memory->remember($combined, [
'kind' => 'conversation_snippet',
'at' => now()->toIso8601String(),
]);
}
/**
* @return array<int, Tool>
*/
protected function resolveTools(): array
{
return [
app(WebSearchTool::class)->asPrismTool(),
app(BuildTemplateTool::class)->asPrismTool(),
app(FileManagerTool::class)->asPrismTool(),
];
}
}

View File

@ -0,0 +1,863 @@
<?php
namespace App\Services;
/**
* Intent-Erkennung mit Stadt + Kategorie.
*
* Erkennt z.B.:
* "Wien" action=map, city=wien
* "Restaurants in Wien" action=poi, category=restaurant, city=wien
* "Apotheke in Berlin" action=poi, category=pharmacy, city=berlin
* "Krankenhaus Salzburg" action=poi, category=hospital, city=salzburg
* "Behörden in Wien" action=poi, category=government, city=wien
*/
class IntentDetector
{
protected const FILLER = [
'zeig', 'zeige', 'mir', 'mal', 'bitte', 'die', 'der', 'das', 'den', 'dem',
'karte', 'von', 'nach', 'zu', 'in', 'über', 'auf', 'an', 'bei', 'um', 'für',
'wie', 'was', 'ist', 'sind', 'gibt', 'es', 'ich', 'will', 'möchte', 'würde',
'gerne', 'flieg', 'fliege', 'geh', 'gehe', 'schau', 'show', 'me', 'fly', 'to',
'go', 'navigate', 'open', 'map', 'eine', 'einen', 'ein', 'the', 'a', 'an',
'und', 'oder', 'aber', 'mit', 'ohne', 'auch', 'noch', 'jetzt',
'finde', 'finden', 'such', 'suche', 'wo', 'where',
];
/**
* Cuisine-Mapping für Restaurants: deutsches Wort OSM cuisine-Tag.
*/
protected const CUISINES = [
'italienisch' => 'italian', 'italienisches' => 'italian',
'pizza' => 'pizza', 'pizzeria' => 'pizza',
'pasta' => 'italian',
'sushi' => 'sushi',
'japanisch' => 'japanese', 'japanisches' => 'japanese', 'ramen' => 'ramen',
'chinesisch' => 'chinese', 'chinesisches' => 'chinese',
'asiatisch' => 'asian', 'asiatisches' => 'asian',
'thai' => 'thai', 'thailändisch' => 'thai',
'vietnamesisch' => 'vietnamese',
'koreanisch' => 'korean',
'indisch' => 'indian', 'indisches' => 'indian', 'curry' => 'indian',
'türkisch' => 'turkish', 'türkisches' => 'turkish', 'kebab' => 'kebab', 'döner' => 'kebab',
'griechisch' => 'greek', 'griechisches' => 'greek',
'mexikanisch' => 'mexican', 'mexikanisches' => 'mexican', 'tacos' => 'mexican',
'französisch' => 'french', 'französisches' => 'french',
'spanisch' => 'spanish', 'spanisches' => 'spanish', 'tapas' => 'tapas',
'amerikanisch' => 'american','amerikanisches' => 'american',
'steak' => 'steak_house', 'steakhouse' => 'steak_house','steaks' => 'steak_house',
'burger' => 'burger', 'burgers' => 'burger',
'fisch' => 'seafood', 'seafood' => 'seafood',
'vegan' => 'vegan', 'vegetarisch' => 'vegetarian',
'sushi' => 'sushi',
'bbq' => 'bbq', 'barbecue' => 'bbq',
];
/**
* POI-Kategorien Trigger-Wörter und Overpass-Filter.
*/
protected const CATEGORIES = [
'restaurant' => [
'keywords' => ['restaurant', 'restaurants', 'restorant', 'restorants', 'restorante', 'restorantes',
'lokal', 'lokale', 'gasthaus', 'gasthof', 'wirtshaus', 'beisl', 'essen', 'food', 'speisen'],
'label' => 'Restaurants',
],
'cafe' => [
'keywords' => ['café', 'cafe', 'cafés', 'cafes', 'kaffee', 'kaffeehaus', 'coffee'],
'label' => 'Cafés',
],
'bar' => [
'keywords' => ['bar', 'bars', 'kneipe', 'kneipen', 'pub', 'pubs', 'lounge', 'club', 'clubs'],
'label' => 'Bars',
],
'fast_food' => [
'keywords' => ['fastfood', 'fast-food', 'burger', 'pizza', 'döner', 'kebab', 'imbiss'],
'label' => 'Fast Food',
],
'pharmacy' => [
'keywords' => ['apotheke', 'apotheken', 'pharmacy'],
'label' => 'Apotheken',
],
'hospital' => [
'keywords' => ['krankenhaus', 'krankenhäuser', 'spital', 'spitäler', 'klinik', 'kliniken', 'hospital', 'hospitals', 'notaufnahme'],
'label' => 'Krankenhäuser',
],
'doctor' => [
'keywords' => ['arzt', 'ärzte', 'doktor', 'praxis', 'doctor'],
'label' => 'Ärzte',
],
'bank' => [
'keywords' => ['bank', 'banken', 'atm', 'bankomat', 'geldautomat'],
'label' => 'Banken',
],
'fuel' => [
'keywords' => ['tankstelle', 'tankstellen', 'tanken', 'benzin', 'gas station'],
'label' => 'Tankstellen',
],
'supermarket' => [
'keywords' => ['supermarkt', 'supermärkte', 'einkaufen', 'lebensmittel', 'spar', 'billa', 'hofer', 'rewe'],
'label' => 'Supermärkte',
],
'hotel' => [
'keywords' => ['hotel', 'hotels', 'unterkunft', 'übernachten', 'pension', 'hostel'],
'label' => 'Hotels',
],
'government' => [
'keywords' => ['behörde', 'behörden', 'amt', 'ämter', 'rathaus', 'magistrat', 'bezirksamt'],
'label' => 'Behörden',
],
'police' => [
'keywords' => ['polizei', 'police', 'polizeistation'],
'label' => 'Polizei',
],
'parking' => [
'keywords' => ['parkplatz', 'parking', 'parkhaus', 'garage'],
'label' => 'Parkplätze',
],
];
/**
* @return array{action:string, city?:string, category?:string, cuisine?:string, label?:string, data?:array}|null
*/
public function detect(string $message): ?array
{
$cities = config('cities', []);
if (empty($cities)) {
return null;
}
// Compound: "X und Y" - erste Hälfte für direktes Fast-Path-Intent,
// restlicher Text in `chain` für Frontend-Sequenz-Verarbeitung.
if (preg_match('/^(.+?)\s+und\s+(.+)$/u', trim($message), $cm)) {
$first = $this->detectSingle(trim($cm[1]));
$second = $this->detectSingle(trim($cm[2]));
if ($first && $second) {
$first['chain'] = [$second];
return $first;
}
if ($first) return $first;
if ($second) return $second;
}
return $this->detectSingle($message);
}
public function detectSingle(string $message): ?array
{
$cities = config('cities', []);
if (empty($cities)) {
return null;
}
$lower = mb_strtolower(trim($message));
// VOR clean() - sonst werden "was/ist/das" als Filler weggestrippt.
// Karte / Panel zu - "karte zu", "schließen", "zu", "weg", "abbrechen"
if (preg_match('/\b(karte|map|diagnose|status|systemcheck|panel|info|empfehlung)\s*(zu|schließe?n?|schliesse?n?|weg|aus|ausblenden|verbergen|verstecken)\b/u', $lower)
|| preg_match('/\b(schließe?n?|schliesse?n?|weg\s+mit|schliess?\s+weg)\s+(die\s+|den\s+|das\s+)?(karte|map|panel|fenster|status|diagnose|systemcheck|empfehlung|info)\b/u', $lower)
|| preg_match('/^(schließen|schliessen|schließe|schliesse|zu|weg|aus|abbrechen|fertig|stop|stopp|ende|alles\s+zu)\.?$/u', $lower)) {
return ['action' => 'close_map'];
}
// Status / Systemcheck / Diagnose
if (preg_match('/\b(status|systemcheck|system\s*check|systemstatus|diagnose|gesundheit|health|alles\s+(in\s+)?(ordnung|ok)|wie\s+geht.s\s+(dem|dir|fox|server)|läuft\s+alles|funktioniert\s+alles|allsystem)\b/u', $lower)) {
return ['action' => 'system_status'];
}
// Zahlwörter → Ziffern (ein/zwei/drei → 1/2/3) für Reminder/Timer
$numWords = [
'einer' => 1, 'einen' => 1, 'eine' => 1, 'ein' => 1, 'eins' => 1,
'zwei' => 2, 'drei' => 3, 'vier' => 4, 'fünf' => 5, 'fuenf' => 5,
'sechs' => 6, 'sieben' => 7, 'acht' => 8, 'neun' => 9, 'zehn' => 10,
'elf' => 11, 'zwölf' => 12, 'zwoelf' => 12,
'dreizehn' => 13, 'vierzehn' => 14, 'fünfzehn' => 15, 'fuenfzehn' => 15,
'sechzehn' => 16, 'siebzehn' => 17, 'achtzehn' => 18, 'neunzehn' => 19,
'zwanzig' => 20, 'dreißig' => 30, 'dreissig' => 30, 'vierzig' => 40,
'fünfzig' => 50, 'fuenfzig' => 50, 'sechzig' => 60,
];
$numerized = $lower;
foreach ($numWords as $w => $n) {
$numerized = preg_replace('/\b'.$w.'\b/u', (string) $n, $numerized);
}
// Reminder/Timer: liberal unit match — "minteun"/"min"/"minuten"/"sekunde"/"std" alle ok
// unit-Erkennung über Anfangsbuchstaben + Levenshtein-Toleranz.
// "errinnere" / "erinere" / Whisper-Typos abgedeckt durch e{1,2}r{1,2}inner\w*
if (preg_match('/(?:e{1,2}r{1,2}inner\w*\s+mich|wecker|timer|alarm|erinner\w*)\s+(?:.{0,15}?)\bin\s+(\d+)\s*(\S+?)(?:\s+(?:zu|an|dass)\s+|\s+|$)(.*)$/u', $numerized, $rm)
|| preg_match('/^(?:wecker|timer|alarm)\s+(\d+)\s*(\S+?)(?:\s+(?:zu|an|dass)\s+|\s+|$)(.*)$/u', $numerized, $rm)
|| preg_match('/^in\s+(\d+)\s*(\S+?)(?:\s+(?:zu|an|dass)\s+|\s+|$)(.*)$/u', $numerized, $rm)) {
$n = (int) $rm[1];
$unitRaw = mb_strtolower($rm[2] ?? '');
$what = trim($rm[3] ?? '');
// Unit über Anfangsbuchstaben - reicht. Whisper-Typos meistens am Wortende.
$unit = null;
if (preg_match('/^(h$|std|stu|stund)/u', $unitRaw)) {
$unit = 'h';
} elseif (preg_match('/^m/u', $unitRaw)) {
$unit = 'min';
} elseif (preg_match('/^s/u', $unitRaw)) {
$unit = 's';
}
if ($unit) {
$secs = match ($unit) {
's' => $n,
'min' => $n * 60,
'h' => $n * 3600,
};
$unitLabel = match ($unit) { 's' => 'Sekunden', 'min' => 'Minuten', 'h' => 'Stunden' };
$what = trim(preg_replace('/^(zu|an|dass)\s+/u', '', $what));
return [
'action' => 'reminder',
'delay_seconds' => $secs,
'what' => $what !== '' ? $what : 'Erinnerung',
'text' => 'Okay, in '.$n.' '.$unitLabel.($what !== '' ? ': '.$what : '').'.',
];
}
}
// Referenz-Anfragen auf zuletzt Empfohlenes ("was ist das", "erzähl mehr darüber")
// → query = __LAST_RECOMMEND__, HUD fetcht Info für die letzte Empfehlung.
if (preg_match('/^(was|wie)\s+(ist|sind|schaut|sieht|heisst|heißt)\s+(das|es|der|die|dies|dieses|dieser)/u', $lower)
|| preg_match('/(erzähl|sag|info)\s+(mir\s+)?(mehr\s+)?(über|zu|von)\s+(das|dem|es|dies|diesem|dieser|der)\b/u', $lower)
|| preg_match('/^(mehr\s+)?(infos?|details)\s+(zu|über|von)\s+(dem|das|es|dies|diesem)\b/u', $lower)) {
return ['action' => 'place_info', 'query' => '__LAST_RECOMMEND__'];
}
$cleaned = $this->clean($message);
if ($cleaned === '') {
return null;
}
// "Erzähl mir was über X" / "Was ist X" / "Was gibt es in X" / "Wie schaut X aus" / "Info zu X"
// → Detail-Karte mit Bild + Beschreibung statt Vollbild-Map
$infoPattern = '/^('
.'erzähl|erzaehl|sag|sage|info|infos|mehr\s+infos?|mehr\s+details?'
.'|was ist|was sind|was gibt(?:s|\s+es)?|was kann (?:man|ich)|was findet|was steht'
.'|was kannst du.+sagen|was weisst|was weißt'
.'|wie schaut|wie sieht|wie ist|wie geht'
.'|wer ist|wer war'
.'|wo ist|wo liegt|wo befindet'
.'|zeig\s+mir\s+infos?|zeig\s+infos?'
.')\b\s*/u';
if (preg_match($infoPattern, $lower)) {
$query = preg_replace($infoPattern, '', $lower);
// Pronomen / Füllwörter
$query = preg_replace('/\b(mir|dir|uns|man|ich|über|ueber|wegen|aus|von|denn|noch|mehr|etwas|kurz|bitte|du|so|halt|eigentlich|der|die|das|den|dem|des|ein|eine|einen|einem|einer)\b/u', ' ', (string) $query);
// Präpositionen
$query = preg_replace('/\b(in|im|am|beim|auf|bei|zu)\b/u', ' ', (string) $query);
// Tätigkeitsverben
$query = preg_replace('/\b(machen|tun|anfangen|sehen|schauen|besuchen|gehen|finden|sein|gibt|kann|könnt|koennt|kannst|will|möchte|moechte)\b/u', ' ', (string) $query);
$query = trim((string) preg_replace('/\s+/u', ' ', (string) $query));
$query = trim($query, "?. \t");
// Bezirks-Erweiterung
$homeCity = (string) config('services.weather.city', 'Wien');
$ordToNum = [
'erst' => '1', 'zweit' => '2', 'dritt' => '3', 'viert' => '4',
'fünft' => '5', 'fuenft' => '5', 'sechst' => '6', 'siebt' => '7',
'acht' => '8', 'neunt' => '9', 'zehnt' => '10',
'elft' => '11', 'zwölft' => '12', 'zwoelft' => '12',
'dreizehnt' => '13', 'vierzehnt' => '14',
'fünfzehnt' => '15', 'fuenfzehnt' => '15',
'sechzehnt' => '16', 'siebzehnt' => '17', 'achtzehnt' => '18',
'neunzehnt' => '19', 'zwanzigst' => '20',
'einundzwanzigst' => '21', 'zweiundzwanzigst' => '22', 'dreiundzwanzigst' => '23',
];
// Erkenne Ordnungszahl irgendwo im Query (z.B. "ersten", "erste bezirk")
foreach ($ordToNum as $word => $num) {
if (preg_match('/\b'.$word.'e?n?\b/u', $query)) {
$query = $num.'. Bezirk '.$homeCity;
break;
}
}
// "bezirk" alleine ohne Ordinal → Heimat-Bezirk-Liste (1)
if ($query === 'bezirk' || preg_match('/^bezirk\s/u', $query)) {
$query = '1. '.$query.' '.$homeCity;
}
// Wenn nach Stripping nur noch Gattungsbegriff übrig ist
// ("restaurant", "lokal", "ort", "platz") → letzte Empfehlung gemeint.
if (preg_match('/^(restaurant|lokal|ort|platz|laden|geschäft|bezirk|location|cafe|bar)$/u', $query)) {
return ['action' => 'place_info', 'query' => '__LAST_RECOMMEND__'];
}
if (mb_strlen($query) >= 3) {
return ['action' => 'place_info', 'query' => $query];
}
}
// "Zeig mir mehr" → Detail-Aufklapp zur letzten Empfehlung/Place-Info
if (preg_match('/^(zeig|zeige)\s+(mir\s+)?(mehr|details|alles)\b/u', $lower)
|| preg_match('/^(mehr|details)\s+(infos?|details)?$/u', $lower)) {
return ['action' => 'place_more'];
}
// "Wie lange brauche ich" / "Wie weit ist es" → kein neues Routing,
// nur die letzte Anzeige nochmal verkünden (Frontend kennt _lastRoute).
if (preg_match('/\b(wie\s+(lange|weit)|dauert|wie\s+viele\s+(km|minuten))\b/u', $lower)
&& ! preg_match('/\b(zu[mr]?|nach|bis|route|navigation)\b/u', $lower)) {
return ['action' => 'route_info'];
}
// Mode-Only-Wechsel: "zu fuß", "mit der u-bahn", "und mit dem rad?"
// → Route neu rechnen, gleiches Ziel.
$modeOnly = $this->parseModeOnly($lower);
if ($modeOnly) {
return $modeOnly;
}
// Route-Anfrage auf dem ROHEN Text prüfen - clean() entfernt sonst "wie/nach/zu/mit".
$route = $this->parseRoute($lower);
if ($route) {
return $route;
}
// Dynamische Map-Anfrage VOR POI-Detection: explizites "zeig (map/karte) von X"
// verhindert Fuzzy-Match wo z.B. "banja" → "bank" wird.
if (preg_match('/\b(zeig\w*|öffn\w*|map|karte)\b.*?\b(?:von|der|des|für|mit)\s+(.+)$/u', $lower, $dm)
|| preg_match('/^(?:karte|map)\s+(.+)$/u', $lower, $dm)) {
$q = trim(end($dm));
$q = preg_replace('/\b(mir|bitte|gerne|jetzt|kurz|noch|mal|die|das|den|dem|der)\b/u', ' ', $q);
$q = trim((string) preg_replace('/\s+/u', ' ', $q));
// Bekannte Stadt aus config? Dann normaler map-flow
if (mb_strlen($q) >= 2) {
$cityKeyPre = $this->findCity(' '.$q.' ', $cities);
if ($cityKeyPre) {
$cityName = $cities[$cityKeyPre]['name'] ?? ucfirst($cityKeyPre);
return [
'action' => 'map',
'city' => $cityKeyPre,
'text' => 'Hier ist '.$cityName.'.',
'data' => $cities[$cityKeyPre],
];
}
return ['action' => 'map_dynamic', 'query' => $q];
}
}
$cityKey = $this->findCity($cleaned, $cities);
$category = $this->findCategory($cleaned);
$cuisine = $this->findCuisine($cleaned);
// Cuisine erwähnt aber keine Kategorie → impliziter restaurant.
if ($cuisine && ! $category) {
$category = 'restaurant';
}
// Transit/U-Bahn → eigene Action (zeichnet Liniennetz auf der Map).
if ($this->isTransit($cleaned)) {
$key = $cityKey ?? mb_strtolower(config('services.weather.city', 'wien'));
$cityData = $cities[$key] ?? null;
$cityName = $cityData['name'] ?? ucfirst($key);
return [
'action' => 'transit',
'city' => $key,
'text' => 'Hier ist das öffentliche Verkehrsnetz von '.$cityName.'.',
'data' => $cityData,
];
}
// "empfehl mir ein steakhaus" → suche + automatisch eines auswählen.
$autoRecommend = (bool) preg_match('/\b(empfehl|empfiehl|vorschl|welch|kannst du eins|gib mir ein|gib mir nen|zeig mir ein|hast du ein)/u', mb_strtolower($message));
if ($cityKey && $category) {
return [
'action' => 'poi',
'category' => $category,
'cuisine' => $cuisine,
'auto_recommend' => $autoRecommend,
'label' => $cuisine
? ucfirst(str_replace('_', '', $cuisine)).' '.self::CATEGORIES[$category]['label']
: self::CATEGORIES[$category]['label'],
'city' => $cityKey,
'data' => $cities[$cityKey],
];
}
if ($category) {
return [
'action' => 'poi',
'category' => $category,
'cuisine' => $cuisine,
'auto_recommend' => $autoRecommend,
'label' => $cuisine
? ucfirst(str_replace('_', '', $cuisine)).' '.self::CATEGORIES[$category]['label']
: self::CATEGORIES[$category]['label'],
'city' => null,
'data' => null,
];
}
if ($cityKey) {
$cityName = $cities[$cityKey]['name'] ?? ucfirst($cityKey);
$phrases = [
'Hier ist '.$cityName.'.',
'Schau, '.$cityName.'.',
'Bitte, '.$cityName.'.',
$cityName.' für dich.',
'Da haben wir '.$cityName.'.',
'Da ist '.$cityName.'.',
'Voilà, '.$cityName.'.',
];
return [
'action' => 'map',
'city' => $cityKey,
'text' => $phrases[array_rand($phrases)],
'data' => $cities[$cityKey],
];
}
// Dynamische Map-Anfrage: "zeig (mir die) (karte/map) von X" - X unbekannt → Google Geocoding
if (preg_match('/\b(zeig\w*|öffn\w*|map|karte)\b.*?\b(?:von|der|des|für|mit)\s+(.+)$/u', $lower, $dm)
|| preg_match('/^(?:karte|map)\s+(.+)$/u', $lower, $dm)) {
$q = trim(end($dm));
$q = preg_replace('/\b(mir|bitte|gerne|jetzt|kurz|noch|mal)\b/u', ' ', $q);
$q = trim((string) preg_replace('/\s+/u', ' ', $q));
if (mb_strlen($q) >= 2) {
return ['action' => 'map_dynamic', 'query' => $q];
}
}
// Template / Website erstellen
if (preg_match('/\b(erstell|baue?|mach|generier|erzeug)\w*\s+(?:mir\s+)?(?:ein(?:e|en)?\s+)?(?:website|webseite|html|template|seite|landingpage|landing\s*page)\b/u', $lower)
|| preg_match('/\b(?:website|webseite|template|seite)\s+(?:für|erstell|baue?n?)\b/u', $lower)) {
// Alles nach dem Trigger-Wort als Beschreibung extrahieren
$desc = preg_replace(
'/^.*?(?:website|webseite|html|template|seite|landingpage)[- ]*/u',
'', $lower
);
$desc = trim(preg_replace('/\b(für|ein(?:e|en)?|eine?\s+seite|erstell\w*|bitte|mir)\b/u', ' ', $desc));
$desc = trim((string) preg_replace('/\s+/u', ' ', $desc));
return [
'action' => 'create_template',
'description' => $desc !== '' ? $desc : 'allgemeine Webseite',
'text' => 'Einen Moment - ich erstelle das Template gerade.',
];
}
// Desk-Panel: Karte hochheben / zurücklegen
if (preg_match('/\b(zurücklegen|zurücklleg\w*|leg\s+\w{0,15}\s*zurück)\b/u', $lower)) {
return ['action' => 'desk_down'];
}
$deskCards = [
'photo' => ['foto', 'fotos', 'bild', 'bilder', 'galerie'],
'summary' => ['beschreibung', 'zusammenfassung', 'info', 'infos', 'details', 'erklärung'],
'hours' => ['öffnungszeiten', 'öffnungszeit'],
'phone' => ['telefonnummer', 'telefon'],
'rating' => ['bewertung', 'sterne'],
'address' => ['adresse', 'anschrift'],
'website' => ['website', 'webseite', 'homepage'],
];
foreach ($deskCards as $type => $keywords) {
foreach ($keywords as $kw) {
if (str_contains($lower, $kw)) {
return ['action' => 'desk_card', 'card' => $type];
}
}
}
// Brain / Gedächtnis-Visualisierung
if (preg_match('/\b(gehirn|gedächtnis|gedaechtnis|memory|brain|was weißt|was weisst|was du weißt|was du weisst)\b/u', $lower)) {
return ['action' => 'brain', 'text' => 'Hier ist mein Gedächtnis.'];
}
return null;
}
/**
* Mode-Only: "zu fuß", "mit der u-bahn", "und mit dem rad" - kein neues Ziel,
* Route mit dem alten Ziel und neuem Mode neu rechnen.
*/
protected function parseModeOnly(string $haystack): ?array
{
// Wenn ein Ortsname (zum/zur/nach X / dahin) drinsteckt → das ist eine VOLLE
// Routenanfrage, nicht Mode-Only. Lass parseRoute() ran.
// ABER: "zu fuß" alleine ist Mode-Only, daher "zu" nur ausschließen wenn
// KEIN Mode-Keyword direkt folgt.
if (preg_match('/\b(?:nach|zum|zur|bis|hinkommen|hinkomme|dahin|dorthin)\b/u', $haystack)) {
return null;
}
if (preg_match('/\bzu\s+(?!fuss|fuß|hause)\S/u', $haystack)) {
return null;
}
$cleaned = trim((string) preg_replace(
'/\b(und|aber|wie|lang|lange|wie\s+lang|wie\s+lange|dauert|jetzt|bitte|kannst du|kann ich)\b/u',
' ',
$haystack,
));
$cleaned = trim((string) preg_replace('/\s+/u', ' ', $cleaned));
// Sehr kurze, mode-spezifische Eingabe: "zu fuß", "mit der ubahn", "öffi", "rad"
if (mb_strlen($cleaned) > 50) {
return null;
}
$mode = null;
if (preg_match('/\b(zu fuss|zu fuß|fussweg|fußweg|gehen|gehe|laufen|spaziere?n|fuss|fuß)\b/u', $cleaned)) {
$mode = 'walking';
} elseif (preg_match('/\b(rad|fahrrad|bike|radeln)\b/u', $cleaned)) {
$mode = 'bicycling';
} elseif (preg_match('/\b(öffi|öffis|ubahn|u-bahn|sbahn|s-bahn|öpnv|oeffis|metro|tram|bus|öffentlich)\b/u', $cleaned)) {
$mode = 'transit';
} elseif (preg_match('/\b(auto|fahren|fahrend|wagen)\b/u', $cleaned)) {
$mode = 'driving';
}
if (! $mode) {
return null;
}
return [
'action' => 'route',
'to' => '__LAST_DESTINATION__',
'mode' => $mode,
];
}
/**
* Erkennt Routenanfragen + extrahiert Origin/Destination/Mode.
* "route zum stephansdom" to=stephansdom, from=null (=current)
* "wie komme ich zu fuß zum prater" to=prater, mode=walking
* "von oper nach schönbrunn" from=oper, to=schönbrunn
* "navigation mit dem rad zum karlsplatz" to=karlsplatz, mode=bicycling
*/
protected function parseRoute(string $haystack): ?array
{
$padded = ' '.$haystack.' ';
$hasRouteWord = str_contains($padded, ' route ')
|| str_contains($padded, ' routenplanung ')
|| str_contains($padded, ' navigation ')
|| str_contains($padded, ' navigiere ')
|| str_contains($padded, ' wegbeschreibung ')
|| str_contains($padded, ' weg ')
|| str_contains($padded, ' dahin ')
|| str_contains($padded, ' dorthin ')
|| str_contains($padded, ' hinkommen ')
|| str_contains($padded, ' hinkomme ')
|| (bool) preg_match('/\bwie\s+(komme|komm|kommen|fahre|fahr|fahren|geh|gehe|gehen)\b/u', $haystack)
|| (bool) preg_match('/\bzeig.{1,20}\bweg\b/u', $haystack)
|| (bool) preg_match('/\bbring(e|st)?\s+mich\b/u', $haystack);
// "von X nach Y" oder "von X zu/zum Y" - immer route
$hasFromTo = (bool) preg_match('/\bvon\s+\S+.+?\s+(?:nach|zu[mr]?)\s+/u', $haystack);
// Mode-Keyword + Zielangabe → route (z.B. "zu fuß zum prater", "mit dem rad zum bahnhof")
$hasModeKeyword = (bool) preg_match('/\b(zu fuss|zu fuß|fussweg|fußweg|fahrrad|rad|öffi|öffis|öpnv|ubahn|u-bahn)\b/u', $haystack);
$hasGoal = (bool) preg_match('/\b(?:nach|zu[mr]?|bis)\s+\S+/u', $haystack);
if (! $hasRouteWord && ! $hasFromTo && ! ($hasModeKeyword && $hasGoal)) {
return null;
}
// Mode-Wörter UND Pronomen entfernen, bevor wir from/to extrahieren -
// sonst landet "mich" / "mir" / "uns" / "fuß" mit im Ziel.
$extractText = preg_replace(
'/\b(zu fuss|zu fuß|fussweg|fußweg|mit dem rad|mit dem fahrrad|fahrrad|rad|mit dem auto|mit den öffis?|öffis?|öpnv|ubahn|u-bahn|sbahn|s-bahn|tram|metro|bahn|bus)\b/u',
' ',
$haystack,
);
$extractText = preg_replace('/\b(mich|mir|uns|du|kannst|bitte)\b/u', ' ', (string) $extractText);
$extractText = trim((string) preg_replace('/\s+/u', ' ', (string) $extractText));
// Travel mode
$mode = 'driving';
if (preg_match('/\b(zu fuss|zu fuß|fussweg|fußweg|zu fu[sß]|gehen|gehe|laufen|spaziere?n)\b/u', $haystack)) {
$mode = 'walking';
} elseif (preg_match('/\b(rad|fahrrad|bike|radeln)\b/u', $haystack)) {
$mode = 'bicycling';
} elseif (preg_match('/\b(öffi|öffis|ubahn|u-bahn|sbahn|s-bahn|bus|tram|öpnv|oeffis|metro|bahn)\b/u', $haystack)) {
$mode = 'transit';
}
// From/To extraction
$from = null;
$to = null;
// "dahin/dorthin/hin" zuerst checken - egal wo im Text -> letzte Empfehlung.
// Sonst würde "route mich dahin" → to="mich dahin" extrahieren.
if (preg_match('/\b(dahin|dorthin|hinkommen|hinkomme)\b/u', $extractText)) {
$to = '__LAST_RECOMMEND__';
} elseif (preg_match('/\bvon\s+(?:der|dem|den|des|einer|einem)?\s*(.+?)\s+(?:nach|zur?m?)\s+(.+)$/iu', $extractText, $m)) {
$from = trim($m[1]);
$to = trim($m[2]);
} elseif (preg_match('/\b(?:nach|zur?m?|bis)\s+(?:der|dem|den|des|einer|einem)?\s*(.+)$/iu', $extractText, $m)) {
$to = trim($m[1]);
} elseif (preg_match('/\b(route|routenplanung|navigation|wegbeschreibung)\s+(.+)$/iu', $extractText, $m)) {
$to = trim($m[2]);
}
if (! $to) {
return null;
}
// Falls "dahin" im extrahierten $to landete (z.B. "der dahin"), trotzdem auf Last-Recommend mappen.
if (preg_match('/\b(dahin|dorthin)\b/u', $to)) {
$to = '__LAST_RECOMMEND__';
}
// Stop-Words am Ende abschneiden
$to = preg_replace('/\b(per|mit|zu)\s+(fuss|fuß|rad|fahrrad|öffi.*|ubahn|bahn|auto)\b.*$/iu', '', $to);
$to = trim(preg_replace('/\s+/', ' ', $to));
if ($from !== null) {
$from = trim(preg_replace('/\s+/', ' ', $from));
}
if ($to === '') {
return null;
}
return [
'action' => 'route',
'from' => $from,
'to' => $to,
'mode' => $mode,
];
}
protected function isTransit(string $haystack): bool
{
$padded = ' '.$haystack.' ';
$keywords = [
'ubahn', 'u-bahn', 'ubn', 'metro', 'subway', 'sbahn', 's-bahn', 'sbn',
'tram', 'strassenbahn', 'straßenbahn', 'bim',
'bus', 'busse', 'busnetz',
'verkehrsnetz', 'verkehr', 'oeffis', 'öffis', 'liniennetz', 'haltestelle',
'haltestellen', 'transit',
];
foreach ($keywords as $kw) {
if (str_contains($padded, ' '.$kw.' ')) {
return true;
}
}
return false;
}
protected function findCity(string $haystack, array $cities): ?string
{
$keys = array_keys($cities);
usort($keys, fn ($a, $b) => mb_strlen($b) <=> mb_strlen($a));
$padded = ' '.$haystack.' ';
foreach ($keys as $key) {
if (str_contains($padded, ' '.$key.' ')) {
return $key;
}
}
$words = array_filter(explode(' ', $haystack), fn ($w) => mb_strlen($w) >= 3);
foreach ($words as $word) {
foreach ($keys as $key) {
if ($key === $word) {
return $key;
}
}
}
return null;
}
protected function findCategory(string $haystack): ?string
{
$padded = ' '.$haystack.' ';
// 1. Exact match auf Wortgrenzen
foreach (self::CATEGORIES as $cat => $cfg) {
foreach ($cfg['keywords'] as $kw) {
if (str_contains($padded, ' '.$kw.' ')) {
return $cat;
}
}
}
// 2. Fuzzy: Stadt-Wörter ausschließen, gegen alle Kategorie-Keywords prüfen.
$words = $this->fuzzyWords($haystack);
foreach ($words as $word) {
$wn = $this->normalize($word);
$len = mb_strlen($wn);
$maxDist = $this->fuzzyThreshold($len);
foreach (self::CATEGORIES as $cat => $cfg) {
foreach ($cfg['keywords'] as $kw) {
$kn = $this->normalize($kw);
if (abs(mb_strlen($kn) - $len) > $maxDist) {
continue;
}
if (levenshtein($wn, $kn) <= $maxDist) {
return $cat;
}
}
}
}
// Substring-Suffix: "steaklokal" / "sushibar" / "burgerrestorant" → category aus Endung.
$allWords = explode(' ', $haystack);
foreach ($allWords as $word) {
if (mb_strlen($word) < 6) {
continue;
}
$wn = $this->normalize($word);
foreach (self::CATEGORIES as $cat => $cfg) {
foreach ($cfg['keywords'] as $kw) {
$kn = $this->normalize($kw);
if (mb_strlen($kn) < 4) {
continue;
}
if (str_contains($wn, $kn)) {
return $cat;
}
}
}
}
return null;
}
protected function findCuisine(string $haystack): ?string
{
$padded = ' '.$haystack.' ';
foreach (self::CUISINES as $kw => $tag) {
if (str_contains($padded, ' '.$kw.' ')) {
return $tag;
}
}
$words = $this->fuzzyWords($haystack);
foreach ($words as $word) {
$wn = $this->normalize($word);
$len = mb_strlen($wn);
$maxDist = $this->fuzzyThreshold($len);
foreach (self::CUISINES as $kw => $tag) {
$kn = $this->normalize($kw);
if (abs(mb_strlen($kn) - $len) > $maxDist) {
continue;
}
if (levenshtein($wn, $kn) <= $maxDist) {
return $tag;
}
}
}
// Letzte Stufe: Substring in Kompositum (z.B. "steaklokal", "sushibar", "burgerladen").
// Nur Cuisine-Keywords ≥ 4 Zeichen, sonst matcht "thai" in "thailand" usw.
$allWords = explode(' ', $haystack);
foreach ($allWords as $word) {
if (mb_strlen($word) < 6) {
continue; // Compound-Suche nur bei längeren Wörtern
}
$wn = $this->normalize($word);
foreach (self::CUISINES as $kw => $tag) {
$kn = $this->normalize($kw);
if (mb_strlen($kn) < 4) {
continue;
}
if (str_contains($wn, $kn)) {
return $tag;
}
}
}
return null;
}
/**
* Wörter für Fuzzy-Match: Stadtnamen + zu kurze Wörter raus,
* sonst matchen "berlin""benzin" und ähnliche Kollisionen.
*/
protected function fuzzyWords(string $haystack): array
{
$cities = config('cities', []);
$cityKeys = array_map(fn ($k) => $this->normalize($k), array_keys($cities));
// Multi-Word City-Keys einzeln auch sperren
foreach (array_keys($cities) as $key) {
foreach (explode(' ', $key) as $part) {
if (mb_strlen($part) >= 3) {
$cityKeys[] = $this->normalize($part);
}
}
}
$cityKeys = array_unique($cityKeys);
return array_values(array_filter(
explode(' ', $haystack),
function ($w) use ($cityKeys) {
if (mb_strlen($w) < 5) {
return false;
}
$n = $this->normalize($w);
// Wenn Wort einer Stadt entspricht oder ähnelt → raus
foreach ($cityKeys as $ck) {
if ($n === $ck) {
return false;
}
// Auch nahe Stadtnamen ausschließen damit "berln"→"berlin"
// nicht versehentlich als Kategorie missgedeutet wird
if (mb_strlen($ck) >= 4 && levenshtein($n, $ck) <= 2) {
return false;
}
}
return true;
}
));
}
protected function lev(string $a, string $b): int
{
return levenshtein(mb_strtolower($a), mb_strtolower($b));
}
protected function fuzzyThreshold(int $len): int
{
// Großzügigere Schwelle: erlaubt Transpositionen wie "staek"→"steak" (dist 2).
if ($len <= 4) {
return 1;
}
if ($len <= 7) {
return 2;
}
return 3;
}
/**
* Umlaute + Akzente entfernen für Levenshtein (PHP rechnet byte-weise).
*/
protected function normalize(string $s): string
{
return strtr(mb_strtolower($s), [
'ä' => 'a', 'ö' => 'o', 'ü' => 'u', 'ß' => 'ss',
'á' => 'a', 'à' => 'a', 'â' => 'a',
'é' => 'e', 'è' => 'e', 'ê' => 'e',
'í' => 'i', 'ì' => 'i',
'ó' => 'o', 'ò' => 'o', 'ô' => 'o',
'ú' => 'u', 'ù' => 'u',
'ç' => 'c', 'ñ' => 'n',
]);
}
protected function clean(string $message): string
{
$text = mb_strtolower($message);
$text = preg_replace('/[.,!?;:]/u', ' ', $text);
$tokens = preg_split('/\s+/u', $text) ?: [];
$tokens = array_values(array_filter(
$tokens,
fn ($t) => $t !== '' && ! in_array($t, self::FILLER, true),
));
return implode(' ', $tokens);
}
}

View File

@ -0,0 +1,88 @@
<?php
namespace App\Services;
use App\Models\Memory;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Pgvector\Laravel\Distance;
use Pgvector\Laravel\Vector;
class MemoryService
{
public function __construct(
protected ?string $ollamaUrl = null,
protected ?string $embedModel = null,
) {
$this->ollamaUrl ??= config('services.ollama.url');
$this->embedModel ??= config('services.ollama.embedding_model');
}
/**
* Erstellt ein Embedding via Ollama und persistiert die Memory.
*/
public function remember(string $content, array $metadata = []): Memory
{
$embedding = $this->embed($content);
return Memory::create([
'content' => $content,
'metadata' => $metadata,
'embedding' => $embedding ? new Vector($embedding) : null,
]);
}
/**
* @return array<int, Memory>
*/
public function recall(string $query, int $limit = 5): array
{
$embedding = $this->embed($query);
if ($embedding === null) {
return Memory::latest()->limit($limit)->get()->all();
}
return Memory::query()
->nearestNeighbors('embedding', new Vector($embedding), Distance::Cosine)
->limit($limit)
->get()
->all();
}
/**
* Erzeugt ein Embedding über die Ollama /api/embeddings Schnittstelle.
*
* @return array<int, float>|null
*/
public function embed(string $text): ?array
{
try {
$response = Http::timeout(30)
->post(rtrim($this->ollamaUrl, '/').'/api/embeddings', [
'model' => $this->embedModel,
'prompt' => $text,
]);
if (! $response->successful()) {
Log::warning('Embedding-Request fehlgeschlagen', ['status' => $response->status()]);
return null;
}
return $response->json('embedding');
} catch (\Throwable $e) {
Log::error('Embedding-Fehler', ['exception' => $e->getMessage()]);
return null;
}
}
public function summarizeRecent(int $limit = 10): string
{
$memories = Memory::latest()->limit($limit)->get();
return $memories
->map(fn (Memory $m) => '- '.str($m->content)->limit(160))
->implode("\n");
}
}

View File

@ -0,0 +1,106 @@
<?php
namespace App\Services;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
/**
* Google Places (New) - Nearby Search.
* Wird als Fallback aufgerufen wenn Overpass-Mirrors alle tot sind.
* https://developers.google.com/maps/documentation/places/web-service/nearby-search
*/
class PlacesService
{
/**
* Map unsere internen Kategorien auf Google-Places-Types.
* Multi-Type möglich, Google nimmt OR-Logik.
*/
protected const CATEGORY_TYPES = [
'restaurant' => ['restaurant'],
'cafe' => ['cafe'],
'bar' => ['bar', 'night_club'],
'fast_food' => ['fast_food_restaurant', 'meal_takeaway'],
'pharmacy' => ['pharmacy'],
'hospital' => ['hospital'],
'doctor' => ['doctor'],
'bank' => ['bank', 'atm'],
'fuel' => ['gas_station'],
'supermarket' => ['supermarket'],
'hotel' => ['hotel', 'lodging'],
'government' => ['city_hall', 'government_office'],
'police' => ['police'],
'parking' => ['parking'],
];
public function nearby(string $category, float $lat, float $lng, int $radius = 2000, ?string $cuisine = null): array
{
$key = config('services.google.maps_key');
if (empty($key)) {
Log::warning('Places: kein Google-Key, Fallback nicht möglich');
return [];
}
$types = self::CATEGORY_TYPES[$category] ?? ['restaurant'];
try {
$response = Http::timeout(10)
->withHeaders([
'Content-Type' => 'application/json',
'X-Goog-Api-Key' => $key,
'X-Goog-FieldMask' => 'places.id,places.displayName,places.formattedAddress,places.location,places.types,places.rating',
])
->post('https://places.googleapis.com/v1/places:searchNearby', [
'includedTypes' => $types,
'maxResultCount' => 20,
'languageCode' => 'de',
'regionCode' => 'AT',
'locationRestriction' => [
'circle' => [
'center' => ['latitude' => $lat, 'longitude' => $lng],
'radius' => min((float) $radius, 50_000.0),
],
],
]);
if (! $response->successful()) {
Log::warning('Places API HTTP-Fehler', [
'status' => $response->status(),
'body' => mb_substr($response->body(), 0, 200),
]);
return [];
}
$places = $response->json('places') ?? [];
// Cuisine-Filter clientseitig: Google hat keine cuisine-Tags, aber wir
// können nach displayName + types filtern (z.B. "steak" enthalten).
if ($cuisine !== null) {
$needle = mb_strtolower($cuisine);
$places = array_values(array_filter($places, function ($p) use ($needle) {
$name = mb_strtolower($p['displayName']['text'] ?? '');
return str_contains($name, $needle) || str_contains($name, str_replace('_', ' ', $needle));
}));
}
return array_map(function ($p) use ($category) {
return [
'id' => 'gp-'.($p['id'] ?? uniqid()),
'name' => $p['displayName']['text'] ?? '?',
'address' => $p['formattedAddress'] ?? '',
'lat' => $p['location']['latitude'] ?? null,
'lng' => $p['location']['longitude'] ?? null,
'rating' => $p['rating'] ?? null,
'category' => $category,
];
}, $places);
} catch (\Throwable $e) {
Log::warning('Places exception', ['e' => $e->getMessage()]);
return [];
}
}
}

View File

@ -0,0 +1,193 @@
<?php
namespace App\Services;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
/**
* Google Directions API fertige GeoJSON-Linie + Distanz/Dauer fürs HUD.
* Der API-Key bleibt im Backend, das Frontend bekommt nur das Ergebnis.
*/
class RouteService
{
public function route(string $origin, string $destination, string $mode = 'driving'): ?array
{
$key = config('services.google.maps_key');
if (empty($key)) {
Log::warning('Google Maps Key fehlt - Routing nicht möglich');
return null;
}
// Bis zu 2 Versuche bei transienten Fehlern (Timeout, 5xx, ZERO_RESULTS).
$response = null;
$data = null;
$status = null;
$lastErr = null;
for ($attempt = 1; $attempt <= 2; $attempt++) {
try {
$response = Http::timeout((int) config('services.google.directions_timeout', 8))
->retry(0) // wir handeln retry selbst
->get('https://maps.googleapis.com/maps/api/directions/json', [
'origin' => $origin,
'destination' => $destination,
'mode' => $mode,
'language' => 'de',
'region' => 'at',
'key' => $key,
]);
if (! $response->successful()) {
$lastErr = 'HTTP '.$response->status();
Log::warning('Directions API HTTP-Fehler', ['attempt' => $attempt, 'status' => $response->status()]);
if ($attempt < 2) {
usleep(400_000);
continue;
}
return null;
}
$data = $response->json();
$status = $data['status'] ?? null;
// ZERO_RESULTS oder UNKNOWN_ERROR sind oft transient bei ÖPNV - retry
if (in_array($status, ['ZERO_RESULTS', 'UNKNOWN_ERROR', 'OVER_QUERY_LIMIT'], true) && $attempt < 2) {
Log::info('Directions transient '.$status.', retry');
usleep(500_000);
continue;
}
break;
} catch (\Throwable $e) {
$lastErr = $e->getMessage();
Log::warning('Directions exception', ['attempt' => $attempt, 'e' => $lastErr]);
if ($attempt < 2) {
usleep(400_000);
continue;
}
return null;
}
}
try {
if ($status !== 'OK' || empty($data['routes'])) {
Log::warning('Directions: keine Route nach '.($attempt ?? 1).' Versuchen', [
'status' => $status,
'msg' => $data['error_message'] ?? $lastErr,
'origin' => $origin,
'destination' => $destination,
]);
return null;
}
$route = $data['routes'][0];
$leg = $route['legs'][0];
return [
'distance_text' => $leg['distance']['text'] ?? '',
'distance_m' => $leg['distance']['value'] ?? 0,
'duration_text' => $leg['duration']['text'] ?? '',
'duration_s' => $leg['duration']['value'] ?? 0,
'start_address' => $leg['start_address'] ?? '',
'end_address' => $leg['end_address'] ?? '',
'start_location' => $leg['start_location'] ?? null,
'end_location' => $leg['end_location'] ?? null,
'bounds' => $route['bounds'] ?? null,
'geojson' => $this->decodePolyline($route['overview_polyline']['points'] ?? ''),
'mode' => $mode,
'steps' => $this->normalizeSteps($leg['steps'] ?? []),
];
} catch (\Throwable $e) {
Log::warning('RouteService exception', ['e' => $e->getMessage()]);
return null;
}
}
/**
* Google-Steps schlankeres Format für die Sidebar mit Transit-Details.
*/
protected function normalizeSteps(array $steps): array
{
$out = [];
foreach ($steps as $s) {
$mode = $s['travel_mode'] ?? 'WALKING';
$instruction = strip_tags(str_replace(
['<div', '</div>'], [' <div', '</div> '],
(string) ($s['html_instructions'] ?? ''),
));
$instruction = trim((string) preg_replace('/\s+/u', ' ', $instruction));
$entry = [
'mode' => $mode,
'instruction' => $instruction,
'distance' => $s['distance']['text'] ?? '',
'duration' => $s['duration']['text'] ?? '',
];
if ($mode === 'TRANSIT' && ! empty($s['transit_details'])) {
$td = $s['transit_details'];
$line = $td['line'] ?? [];
$entry['transit'] = [
'line' => $line['short_name'] ?? ($line['name'] ?? ''),
'line_name' => $line['name'] ?? '',
'color' => $line['color'] ?? '#666',
'text_color' => $line['text_color'] ?? '#fff',
'vehicle' => $line['vehicle']['type'] ?? 'BUS',
'departure_stop' => $td['departure_stop']['name'] ?? '',
'arrival_stop' => $td['arrival_stop']['name'] ?? '',
'departure_time' => $td['departure_time']['text'] ?? '',
'arrival_time' => $td['arrival_time']['text'] ?? '',
'num_stops' => (int) ($td['num_stops'] ?? 0),
'headsign' => $td['headsign'] ?? '',
];
}
$out[] = $entry;
}
return $out;
}
/**
* Google Polyline-Format GeoJSON LineString-Feature.
* https://developers.google.com/maps/documentation/utilities/polylinealgorithm
*/
protected function decodePolyline(string $encoded): array
{
$points = [];
$index = $lat = $lng = 0;
$len = strlen($encoded);
while ($index < $len) {
$shift = $result = 0;
do {
$b = ord($encoded[$index++]) - 63;
$result |= ($b & 0x1f) << $shift;
$shift += 5;
} while ($b >= 0x20 && $index < $len);
$lat += ($result & 1) ? ~($result >> 1) : ($result >> 1);
$shift = $result = 0;
do {
$b = ord($encoded[$index++]) - 63;
$result |= ($b & 0x1f) << $shift;
$shift += 5;
} while ($b >= 0x20 && $index < $len);
$lng += ($result & 1) ? ~($result >> 1) : ($result >> 1);
$points[] = [$lng / 1e5, $lat / 1e5];
}
return [
'type' => 'Feature',
'geometry' => ['type' => 'LineString', 'coordinates' => $points],
'properties' => new \stdClass,
];
}
}

View File

@ -0,0 +1,346 @@
<?php
namespace App\Services;
use Illuminate\Http\UploadedFile;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Process;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
/**
* Brücke zu whisper.cpp (STT) und piper (TTS) via Docker exec.
*
* Whisper läuft im Container fox-whisper, piper ist im PHP-Container installiert (siehe Dockerfile).
*/
class SpeechService
{
public function __construct(
protected ?string $whisperContainer = null,
protected ?string $whisperModel = null,
protected ?string $piperVoice = null,
) {
$this->whisperContainer = $whisperContainer ?? config('services.whisper.container');
$this->whisperModel = $whisperModel ?? config('services.whisper.model');
$this->piperVoice = $piperVoice ?? config('services.piper.voice');
}
/**
* Transkribiert eine Audio-Datei via whisper.cpp.
*/
public function transcribe(UploadedFile|string $file, string $language = 'de'): ?string
{
$filename = 'in_'.Str::random(12).'.wav';
$path = Storage::disk('audio')->path($filename);
if ($file instanceof UploadedFile) {
$file->move(dirname($path), basename($path));
} else {
copy($file, $path);
}
// whisper.cpp im Sidecar-Container
$output = Process::timeout(120)
->run(sprintf(
'docker exec %s /app/build/bin/whisper-cli -m %s -f /audio/%s -l %s -nt -otxt',
escapeshellarg($this->whisperContainer),
escapeshellarg($this->whisperModel),
escapeshellarg($filename),
escapeshellarg($language),
));
if (! $output->successful()) {
Log::warning('Whisper-Transkription fehlgeschlagen', [
'stderr' => $output->errorOutput(),
]);
@unlink($path);
return null;
}
$textFile = Storage::disk('audio')->path($filename.'.txt');
$text = is_file($textFile) ? trim(file_get_contents($textFile)) : trim($output->output());
@unlink($path);
@unlink($textFile);
return $text !== '' ? $text : null;
}
/**
* Synthetisiert Text zu Audio. Engine wählbar: elevenlabs | xtts | piper.
* Bei Fehler automatischer Fallback auf nächste verfügbare Engine.
* Gibt eine öffentliche URL (zum WAV/MP3) zurück oder null.
*/
public function speak(string $text): ?string
{
$clean = $this->normalizeForTts($text);
if ($clean === '') {
return null;
}
$engine = (string) config('services.tts.engine', 'openai');
// Fallback-Reihenfolge nach gewählter Engine
$chain = match ($engine) {
'openai' => ['openai', 'elevenlabs', 'xtts', 'piper'],
'elevenlabs' => ['elevenlabs', 'xtts', 'piper'],
'xtts' => ['xtts', 'piper'],
default => ['piper'],
};
foreach ($chain as $try) {
$url = match ($try) {
'openai' => $this->speakOpenAi($clean),
'elevenlabs' => $this->speakElevenLabs($clean),
'xtts' => $this->speakXtts($clean),
'piper' => $this->speakPiper($clean),
default => null,
};
if ($url !== null) {
return $url;
}
Log::info('TTS-Engine '.$try.' fehlgeschlagen, versuche nächste');
}
return null;
}
/**
* OpenAI Audio API: tts-1 (~300ms), multilingual, Stimmen onyx/echo/alloy/fable/nova/shimmer. Gibt MP3 zurück.
*/
protected function speakOpenAi(string $text): ?string
{
$cfg = config('services.openai');
$key = $cfg['key'] ?? null;
if (empty($key)) {
return null;
}
try {
$response = \Illuminate\Support\Facades\Http::timeout((int) ($cfg['timeout'] ?? 15))
->withHeaders([
'Authorization' => 'Bearer '.$key,
'Content-Type' => 'application/json',
])
->post('https://api.openai.com/v1/audio/speech', [
'model' => $cfg['tts_model'] ?? 'tts-1',
'voice' => $cfg['tts_voice'] ?? 'onyx',
'input' => $text,
'response_format' => 'mp3',
'speed' => (float) ($cfg['tts_speed'] ?? 1.0),
]);
if (! $response->successful()) {
Log::warning('OpenAI TTS Fehler', [
'status' => $response->status(),
'body' => mb_substr($response->body(), 0, 200),
]);
return null;
}
$body = $response->body();
if (strlen($body) < 256) {
Log::warning('OpenAI TTS: Response zu klein für Audio', ['len' => strlen($body)]);
return null;
}
$filename = 'fox_'.Str::random(12).'.mp3';
$path = Storage::disk('audio')->path($filename);
file_put_contents($path, $body);
return rtrim(config('app.url'), '/').'/audio/'.$filename;
} catch (\Throwable $e) {
Log::warning('OpenAI TTS exception', ['e' => $e->getMessage()]);
return null;
}
}
/**
* ElevenLabs API: Cloud-TTS, schnell, multilingual. Gibt MP3 zurück.
*/
protected function speakElevenLabs(string $text): ?string
{
$cfg = config('services.elevenlabs');
$key = $cfg['key'] ?? null;
if (empty($key)) {
return null; // ohne Key keinen Versuch
}
$voiceId = $cfg['voice_id'] ?? 'KDqku3FJfbImX6HKQdWA';
try {
$response = \Illuminate\Support\Facades\Http::timeout((int) ($cfg['timeout'] ?? 10))
->withHeaders([
'xi-api-key' => $key,
'Content-Type' => 'application/json',
'Accept' => 'audio/mpeg',
])
->post("https://api.elevenlabs.io/v1/text-to-speech/{$voiceId}", [
'text' => $text,
'model_id' => $cfg['model'] ?? 'eleven_multilingual_v2',
'voice_settings' => [
'stability' => (float) ($cfg['stability'] ?? 0.5),
'similarity_boost' => (float) ($cfg['similarity_boost'] ?? 0.75),
'style' => (float) ($cfg['style'] ?? 0.3),
'use_speaker_boost' => (bool) ($cfg['speaker_boost'] ?? true),
],
]);
if (! $response->successful()) {
Log::warning('ElevenLabs Fehler', [
'status' => $response->status(),
'body' => mb_substr($response->body(), 0, 200),
]);
return null;
}
$body = $response->body();
if (strlen($body) < 256) {
Log::warning('ElevenLabs: Response zu klein für Audio', ['len' => strlen($body)]);
return null;
}
$filename = 'fox_'.Str::random(12).'.mp3';
$path = Storage::disk('audio')->path($filename);
file_put_contents($path, $body);
return rtrim(config('app.url'), '/').'/audio/'.$filename;
} catch (\Throwable $e) {
Log::warning('ElevenLabs exception', ['e' => $e->getMessage()]);
return null;
}
}
/**
* XTTS v2: HTTP-Call an Mac-Service, WAV-Response lokal speichern.
*/
protected function speakXtts(string $text): ?string
{
$cfg = config('services.xtts');
$url = rtrim((string) $cfg['url'], '/').'/speak';
try {
$response = \Illuminate\Support\Facades\Http::timeout((int) ($cfg['timeout'] ?? 30))
->acceptJson()
->withOptions(['stream' => false])
->asJson()
->post($url, [
'text' => $text,
'language' => config('services.tts.language', 'de'),
'speaker' => $cfg['speaker'] ?? 'Ana Florence',
]);
if (! $response->successful()) {
Log::warning('XTTS Fehler', [
'status' => $response->status(),
'body' => mb_substr($response->body(), 0, 200),
]);
return null;
}
$body = $response->body();
if (strlen($body) < 1024 || str_starts_with($body, '{')) {
Log::warning('XTTS Response sieht nicht nach WAV aus', [
'len' => strlen($body),
'head' => mb_substr($body, 0, 80),
]);
return null;
}
$filename = 'fox_'.Str::random(12).'.wav';
$path = Storage::disk('audio')->path($filename);
file_put_contents($path, $body);
return rtrim(config('app.url'), '/').'/audio/'.$filename;
} catch (\Throwable $e) {
Log::warning('XTTS exception', ['e' => $e->getMessage()]);
return null;
}
}
/**
* Piper: lokales binary im Container.
*/
protected function speakPiper(string $text): ?string
{
$filename = 'fox_'.Str::random(12).'.wav';
$path = Storage::disk('audio')->path($filename);
$voice = $this->piperVoice;
$lengthScale = (float) config('services.piper.length_scale', 1.05);
$sentenceSilence = (float) config('services.piper.sentence_silence', 0.3);
$noiseScale = (float) config('services.piper.noise_scale', 0.667);
$noiseW = (float) config('services.piper.noise_w', 0.8);
$cmd = sprintf(
'echo %s | piper --model %s --output_file %s '.
'--length-scale %s --sentence-silence %s '.
'--noise-scale %s --noise-w %s 2>&1',
escapeshellarg($text),
escapeshellarg($this->resolveVoicePath($voice)),
escapeshellarg($path),
escapeshellarg((string) $lengthScale),
escapeshellarg((string) $sentenceSilence),
escapeshellarg((string) $noiseScale),
escapeshellarg((string) $noiseW),
);
$result = Process::timeout(60)->run($cmd);
if (! $result->successful() || ! is_file($path)) {
Log::warning('Piper-TTS fehlgeschlagen', [
'stderr' => $result->errorOutput(),
'output' => $result->output(),
]);
return null;
}
return rtrim(config('app.url'), '/').'/audio/'.$filename;
}
protected function resolveVoicePath(string $voice): string
{
$base = rtrim(config('services.piper.voices_path'), '/');
return "{$base}/{$voice}.onnx";
}
/**
* Kleine Vorbereitung für piper: Markdown raus, Mehrfach-Whitespace
* normalisieren, ein paar Abkürzungen ausschreiben.
*/
protected function normalizeForTts(string $text): string
{
$text = preg_replace('/```.*?```/s', '', $text); // Code-Blöcke
$text = preg_replace('/`([^`]+)`/', '$1', $text); // Inline-Code
$text = preg_replace('/\*\*?(.*?)\*\*?/', '$1', $text); // bold/italic
$text = preg_replace('/\[([^\]]+)\]\([^)]+\)/', '$1', $text); // markdown links
$text = strtr($text, [
'z.B.' => 'zum Beispiel',
'z. B.' => 'zum Beispiel',
'd.h.' => 'das heißt',
'd. h.' => 'das heißt',
'usw.' => 'und so weiter',
'bzw.' => 'beziehungsweise',
'ca.' => 'circa',
'etc.' => 'et cetera',
'evtl.' => 'eventuell',
'ggf.' => 'gegebenenfalls',
]);
$text = preg_replace('/\s+/', ' ', $text);
return trim($text);
}
}

View File

@ -0,0 +1,128 @@
<?php
namespace App\Services;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Redis;
/**
* Sammelt System-Stats aus /proc, Disk, Ollama API, Redis Queue.
* Ein einziger Producer (Artisan-Command) ruft das auf und broadcastet
* per Reverb an alle Browser.
*/
class SystemStatsService
{
/**
* @return array{cpu:int, memory: array, disk: array, load: array, ollama: array, queue: int}
*/
public function collect(): array
{
return [
'cpu' => $this->cpuUsage(),
'memory' => $this->memoryUsage(),
'disk' => $this->diskUsage(),
'load' => $this->loadAverage(),
'ollama' => $this->ollamaStatus(),
'openai' => $this->openaiStatus(),
'queue' => $this->queueLength(),
];
}
protected function cpuUsage(): int
{
$sample = function (): array {
$line = explode("\n", (string) @file_get_contents('/proc/stat'))[0] ?? '';
$parts = preg_split('/\s+/', trim($line));
array_shift($parts);
$parts = array_map('intval', $parts);
return [
'idle' => ($parts[3] ?? 0) + ($parts[4] ?? 0),
'total' => array_sum($parts),
];
};
$a = $sample();
usleep(100_000);
$b = $sample();
$totalDiff = $b['total'] - $a['total'];
$idleDiff = $b['idle'] - $a['idle'];
return $totalDiff > 0 ? (int) round((1 - $idleDiff / $totalDiff) * 100) : 0;
}
protected function memoryUsage(): array
{
$info = (string) @file_get_contents('/proc/meminfo');
preg_match('/MemTotal:\s+(\d+)/', $info, $totalMatch);
preg_match('/MemAvailable:\s+(\d+)/', $info, $availMatch);
$total = (int) ($totalMatch[1] ?? 0);
$available = (int) ($availMatch[1] ?? 0);
$used = max(0, $total - $available);
return [
'percent' => $total > 0 ? (int) round(($used / $total) * 100) : 0,
'used_mb' => (int) round($used / 1024),
'total_mb' => (int) round($total / 1024),
];
}
protected function diskUsage(): array
{
$total = (int) @disk_total_space('/var/www');
$free = (int) @disk_free_space('/var/www');
$used = max(0, $total - $free);
return [
'percent' => $total > 0 ? (int) round(($used / $total) * 100) : 0,
'used_gb' => round($used / 1024 / 1024 / 1024, 1),
'total_gb' => round($total / 1024 / 1024 / 1024, 1),
];
}
protected function loadAverage(): array
{
$line = (string) @file_get_contents('/proc/loadavg');
$parts = preg_split('/\s+/', trim($line));
return [
'1m' => (float) ($parts[0] ?? 0),
'5m' => (float) ($parts[1] ?? 0),
'15m' => (float) ($parts[2] ?? 0),
];
}
protected function ollamaStatus(): array
{
try {
$r = Http::timeout(2)->get(rtrim((string) config('services.ollama.url'), '/').'/api/tags');
if ($r->successful()) {
return ['online' => true, 'models' => count($r->json('models', []))];
}
} catch (\Throwable) {
// ignore
}
return ['online' => false, 'models' => 0];
}
/**
* OpenAI ist "online" wenn ein Key gesetzt ist - kein Live-Ping (kostet Geld).
* Der echte Health-Check passiert beim ersten realen Request, im LLM-Fallback.
*/
protected function openaiStatus(): array
{
return ['online' => ! empty(config('services.openai.key'))];
}
protected function queueLength(): int
{
try {
return (int) Redis::connection()->llen(config('cache.prefix', '').'queues:default');
} catch (\Throwable) {
return 0;
}
}
}

View File

@ -0,0 +1,61 @@
<?php
namespace App\Services;
use App\Models\Message;
class UptimeService
{
/**
* @return array{system: string, fox: string}
*/
public function collect(): array
{
return [
'system' => $this->systemUptime(),
'fox' => $this->foxUptime(),
];
}
protected function systemUptime(): string
{
$data = (string) @file_get_contents('/proc/uptime');
$seconds = (int) round((float) explode(' ', trim($data))[0]);
return $this->humanize($seconds);
}
protected function foxUptime(): string
{
$first = Message::oldest()->first();
if (! $first) {
return 'soeben gestartet';
}
return $first->created_at->diffForHumans(null, true).' aktiv';
}
protected function humanize(int $seconds): string
{
if ($seconds < 60) {
return "{$seconds}s";
}
$d = intdiv($seconds, 86400);
$h = intdiv($seconds % 86400, 3600);
$m = intdiv($seconds % 3600, 60);
$parts = [];
if ($d) {
$parts[] = "{$d}d";
}
if ($h) {
$parts[] = "{$h}h";
}
if ($m && ! $d) {
$parts[] = "{$m}m";
}
return implode(' ', $parts) ?: '0m';
}
}

View File

@ -0,0 +1,95 @@
<?php
namespace App\Services;
use Illuminate\Support\Facades\Cache;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
class WeatherService
{
/**
* Open-Meteo Weather Code Mapping [description, icon-key]
* Icons werden im Blade als inline SVG anhand des Keys gerendert.
*/
protected const CODES = [
0 => ['Klar', 'sun'],
1 => ['Überwiegend klar', 'sun'],
2 => ['Teilweise bewölkt', 'cloud-sun'],
3 => ['Bedeckt', 'cloud'],
45 => ['Nebel', 'fog'],
48 => ['Reifnebel', 'fog'],
51 => ['Leichter Nieselregen', 'drizzle'],
53 => ['Nieselregen', 'drizzle'],
55 => ['Starker Nieselregen', 'drizzle'],
61 => ['Leichter Regen', 'rain'],
63 => ['Regen', 'rain'],
65 => ['Starker Regen', 'rain'],
71 => ['Leichter Schnee', 'snow'],
73 => ['Schnee', 'snow'],
75 => ['Starker Schnee', 'snow'],
77 => ['Schneegriesel', 'snow'],
80 => ['Regenschauer', 'rain'],
81 => ['Regenschauer', 'rain'],
82 => ['Heftige Regenschauer', 'rain'],
85 => ['Schneeschauer', 'snow'],
86 => ['Heftige Schneeschauer', 'snow'],
95 => ['Gewitter', 'storm'],
96 => ['Gewitter mit Hagel', 'storm'],
99 => ['Schweres Gewitter', 'storm'],
];
/**
* @return array{temperature: float|null, city: string, icon: string, description: string, fetched_at: ?string}
*/
public function fetch(): array
{
$lat = (float) config('services.weather.lat', env('FOX_LAT', 48.2082));
$lon = (float) config('services.weather.lon', env('FOX_LON', 16.3738));
$city = (string) config('services.weather.city', env('FOX_CITY', 'Wien'));
$cacheKey = sprintf('fox.weather.%s.%s', $lat, $lon);
return Cache::remember($cacheKey, now()->addMinutes(10), function () use ($lat, $lon, $city) {
try {
$response = Http::timeout(8)->get('https://api.open-meteo.com/v1/forecast', [
'latitude' => $lat,
'longitude' => $lon,
'current' => 'temperature_2m,weathercode',
'timezone' => 'auto',
]);
if (! $response->successful()) {
return $this->fallback($city);
}
$temp = $response->json('current.temperature_2m');
$code = (int) $response->json('current.weathercode', 0);
[$description, $icon] = self::CODES[$code] ?? ['Unbekannt', 'cloud'];
return [
'temperature' => $temp !== null ? round((float) $temp, 1) : null,
'city' => $city,
'icon' => $icon,
'description' => $description,
'fetched_at' => now()->toIso8601String(),
];
} catch (\Throwable $e) {
Log::warning('WeatherService failed', ['error' => $e->getMessage()]);
return $this->fallback($city);
}
});
}
protected function fallback(string $city): array
{
return [
'temperature' => null,
'city' => $city,
'icon' => 'cloud',
'description' => 'Keine Daten',
'fetched_at' => null,
];
}
}

View File

@ -0,0 +1,71 @@
<?php
namespace App\Tools;
use App\Events\FoxMessage;
use App\Models\Message;
use Illuminate\Support\Facades\Storage;
use Illuminate\Support\Str;
use Prism\Prism\Tool;
/**
* Erzeugt eine eigenständige HTML-Seite aus einem Titel + Inhaltsbeschreibung,
* legt sie unter storage/app/public/templates ab und broadcastet die URL,
* damit das Browser-Frontend sie öffnen / einbetten kann.
*/
class BuildTemplateTool
{
public function asPrismTool(): Tool
{
return (new Tool)
->as('build_template')
->for('Erzeugt eine fertige HTML-Seite und gibt die URL zurück. Nutze das, wenn der User eine Seite, ein Dashboard oder Layout sehen will.')
->withStringParameter('title', 'Titel der Seite')
->withStringParameter('html_body', 'Vollständiges <body>-HTML inkl. Tailwind-Klassen')
->using(fn (string $title, string $html_body) => $this->execute($title, $html_body));
}
public function execute(string $title, string $htmlBody): string
{
$slug = Str::slug($title) ?: 'page-'.Str::random(6);
$filename = 'templates/'.$slug.'-'.Str::random(4).'.html';
$page = $this->wrap($title, $htmlBody);
Storage::disk('public')->put($filename, $page);
$url = rtrim(config('app.url'), '/').'/storage/'.$filename;
// Push im Chat-Stream als Tool-Use Notiz
$message = Message::create([
'role' => Message::ROLE_TOOL,
'content' => "Template erstellt: {$title}",
'type' => Message::TYPE_TOOL_USE,
'metadata' => ['url' => $url, 'tool' => 'build_template'],
]);
broadcast(new FoxMessage($message));
return $url;
}
protected function wrap(string $title, string $body): string
{
$title = e($title);
return <<<HTML
<!doctype html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{$title} · Fox</title>
<script src="https://cdn.tailwindcss.com"></script>
</head>
<body class="bg-slate-950 text-slate-100 antialiased">
{$body}
</body>
</html>
HTML;
}
}

View File

@ -0,0 +1,76 @@
<?php
namespace App\Tools;
use Illuminate\Support\Facades\Storage;
use Prism\Prism\Tool;
/**
* Lese- und Schreibzugriff in storage/app/public/files. Sandboxed - keine
* Path-Traversal über '..' möglich.
*/
class FileManagerTool
{
protected string $base = 'files';
public function asPrismTool(): Tool
{
return (new Tool)
->as('file_manager')
->for('Liest oder schreibt Dateien im Fox-Sandbox-Verzeichnis. action ist "read", "write" oder "list".')
->withStringParameter('action', 'Eine der Aktionen: read, write, list')
->withStringParameter('path', 'Relativer Pfad innerhalb der Sandbox (für list leer)')
->withStringParameter('content', 'Inhalt zum Schreiben (nur bei action=write)')
->using(fn (string $action, string $path = '', string $content = '') => $this->execute($action, $path, $content));
}
public function execute(string $action, string $path = '', string $content = ''): string
{
$safe = $this->safePath($path);
return match ($action) {
'read' => $this->read($safe),
'write' => $this->write($safe, $content),
'list' => $this->list($safe),
default => "Unbekannte Aktion: {$action}",
};
}
protected function read(string $path): string
{
$disk = Storage::disk('public');
if (! $disk->exists($path)) {
return "Datei nicht gefunden: {$path}";
}
return (string) $disk->get($path);
}
protected function write(string $path, string $content): string
{
Storage::disk('public')->put($path, $content);
return "Geschrieben: {$path} (".strlen($content).' Bytes)';
}
protected function list(string $path): string
{
$disk = Storage::disk('public');
$files = $disk->files($path ?: $this->base);
$dirs = $disk->directories($path ?: $this->base);
$lines = array_merge(
array_map(fn ($d) => "[dir] {$d}", $dirs),
array_map(fn ($f) => "[file] {$f}", $files),
);
return $lines ? implode("\n", $lines) : '(leer)';
}
protected function safePath(string $path): string
{
$path = ltrim(str_replace(['..', '\\'], '', $path), '/');
return str_starts_with($path, $this->base) ? $path : $this->base.'/'.$path;
}
}

View File

@ -0,0 +1,63 @@
<?php
namespace App\Tools;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Prism\Prism\Tool;
/**
* Websuche via lokaler SearXNG-Instanz - kein externer API-Key nötig.
*/
class WebSearchTool
{
public function asPrismTool(): Tool
{
return (new Tool)
->as('web_search')
->for('Sucht im Web via SearXNG (Meta-Suchmaschine, lokal) und gibt die besten Treffer zurück.')
->withStringParameter('query', 'Die Suchanfrage')
->using(fn (string $query) => $this->execute($query));
}
public function execute(string $query): string
{
try {
$response = Http::timeout(15)
->acceptJson()
->get(rtrim(config('services.searxng.url'), '/').'/search', [
'q' => $query,
'format' => 'json',
'language' => 'de',
'pageno' => 1,
]);
} catch (\Throwable $e) {
Log::warning('SearXNG nicht erreichbar', ['error' => $e->getMessage()]);
return 'Suche fehlgeschlagen: '.$e->getMessage();
}
if (! $response->successful()) {
return 'Suche fehlgeschlagen (HTTP '.$response->status().').';
}
$results = $response->json('results', []);
if (empty($results)) {
return 'Keine Treffer für: '.$query;
}
$lines = [];
foreach (array_slice($results, 0, 5) as $i => $hit) {
$lines[] = sprintf(
"%d. %s\n %s\n %s",
$i + 1,
$hit['title'] ?? '(ohne Titel)',
$hit['url'] ?? '',
trim((string) ($hit['content'] ?? '')),
);
}
return implode("\n\n", $lines);
}
}

14
artisan Normal file
View File

@ -0,0 +1,14 @@
#!/usr/bin/env php
<?php
use Symfony\Component\Console\Input\ArgvInput;
define('LARAVEL_START', microtime(true));
require __DIR__.'/vendor/autoload.php';
$app = require_once __DIR__.'/bootstrap/app.php';
$status = $app->handleCommand(new ArgvInput);
exit($status);

42
bootstrap/app.php Normal file
View File

@ -0,0 +1,42 @@
<?php
use App\Jobs\FoxProactiveJob;
use Illuminate\Console\Scheduling\Schedule;
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
use Illuminate\Foundation\Configuration\Middleware;
return Application::configure(basePath: dirname(__DIR__))
->withRouting(
web: __DIR__.'/../routes/web.php',
commands: __DIR__.'/../routes/console.php',
channels: __DIR__.'/../routes/channels.php',
health: '/up',
)
->withMiddleware(function (Middleware $middleware) {
// Hinter NPM (Reverse Proxy) - X-Forwarded-* Headers vertrauen,
// damit Laravel HTTPS erkennt und korrekte URLs generiert.
$middleware->trustProxies(at: '*');
$middleware->validateCsrfTokens(except: [
'fox/voice',
'fox/tts',
'fox/poi-context',
'fox/route',
'fox/places',
'fox/geocode',
'fox/place-info',
'fox/cities',
]);
})
->withSchedule(function (Schedule $schedule) {
$minutes = (int) env('FOX_PROACTIVE_INTERVAL_MINUTES', 0);
if ($minutes > 0) {
$schedule->job(new FoxProactiveJob)
->cron(sprintf('*/%d * * * *', $minutes))
->withoutOverlapping()
->name('fox-proactive');
}
})
->withExceptions(function (Exceptions $exceptions) {
//
})->create();

0
bootstrap/cache/.gitkeep vendored Executable file
View File

67
bootstrap/cache/packages.php vendored Executable file
View File

@ -0,0 +1,67 @@
<?php return array (
'laravel/reverb' =>
array (
'providers' =>
array (
0 => 'Laravel\\Reverb\\ApplicationManagerServiceProvider',
1 => 'Laravel\\Reverb\\ReverbServiceProvider',
),
),
'laravel/tinker' =>
array (
'providers' =>
array (
0 => 'Laravel\\Tinker\\TinkerServiceProvider',
),
),
'livewire/livewire' =>
array (
'aliases' =>
array (
'Livewire' => 'Livewire\\Livewire',
),
'providers' =>
array (
0 => 'Livewire\\LivewireServiceProvider',
),
),
'nesbot/carbon' =>
array (
'providers' =>
array (
0 => 'Carbon\\Laravel\\ServiceProvider',
),
),
'nunomaduro/collision' =>
array (
'providers' =>
array (
0 => 'NunoMaduro\\Collision\\Adapters\\Laravel\\CollisionServiceProvider',
),
),
'nunomaduro/termwind' =>
array (
'providers' =>
array (
0 => 'Termwind\\Laravel\\TermwindServiceProvider',
),
),
'pgvector/pgvector' =>
array (
'providers' =>
array (
0 => 'Pgvector\\Laravel\\PgvectorServiceProvider',
),
),
'prism-php/prism' =>
array (
'aliases' =>
array (
'PrismServer' => 'Prism\\Prism\\Facades\\PrismServer',
),
'providers' =>
array (
0 => 'Prism\\Prism\\PrismServiceProvider',
),
),
);

267
bootstrap/cache/services.php vendored Executable file
View File

@ -0,0 +1,267 @@
<?php return array (
'providers' =>
array (
0 => 'Illuminate\\Auth\\AuthServiceProvider',
1 => 'Illuminate\\Broadcasting\\BroadcastServiceProvider',
2 => 'Illuminate\\Bus\\BusServiceProvider',
3 => 'Illuminate\\Cache\\CacheServiceProvider',
4 => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
5 => 'Illuminate\\Concurrency\\ConcurrencyServiceProvider',
6 => 'Illuminate\\Cookie\\CookieServiceProvider',
7 => 'Illuminate\\Database\\DatabaseServiceProvider',
8 => 'Illuminate\\Encryption\\EncryptionServiceProvider',
9 => 'Illuminate\\Filesystem\\FilesystemServiceProvider',
10 => 'Illuminate\\Foundation\\Providers\\FoundationServiceProvider',
11 => 'Illuminate\\Hashing\\HashServiceProvider',
12 => 'Illuminate\\Mail\\MailServiceProvider',
13 => 'Illuminate\\Notifications\\NotificationServiceProvider',
14 => 'Illuminate\\Pagination\\PaginationServiceProvider',
15 => 'Illuminate\\Auth\\Passwords\\PasswordResetServiceProvider',
16 => 'Illuminate\\Pipeline\\PipelineServiceProvider',
17 => 'Illuminate\\Queue\\QueueServiceProvider',
18 => 'Illuminate\\Redis\\RedisServiceProvider',
19 => 'Illuminate\\Session\\SessionServiceProvider',
20 => 'Illuminate\\Translation\\TranslationServiceProvider',
21 => 'Illuminate\\Validation\\ValidationServiceProvider',
22 => 'Illuminate\\View\\ViewServiceProvider',
23 => 'Laravel\\Reverb\\ApplicationManagerServiceProvider',
24 => 'Laravel\\Reverb\\ReverbServiceProvider',
25 => 'Laravel\\Tinker\\TinkerServiceProvider',
26 => 'Livewire\\LivewireServiceProvider',
27 => 'Carbon\\Laravel\\ServiceProvider',
28 => 'NunoMaduro\\Collision\\Adapters\\Laravel\\CollisionServiceProvider',
29 => 'Termwind\\Laravel\\TermwindServiceProvider',
30 => 'Pgvector\\Laravel\\PgvectorServiceProvider',
31 => 'Prism\\Prism\\PrismServiceProvider',
32 => 'App\\Providers\\AppServiceProvider',
),
'eager' =>
array (
0 => 'Illuminate\\Auth\\AuthServiceProvider',
1 => 'Illuminate\\Cookie\\CookieServiceProvider',
2 => 'Illuminate\\Database\\DatabaseServiceProvider',
3 => 'Illuminate\\Encryption\\EncryptionServiceProvider',
4 => 'Illuminate\\Filesystem\\FilesystemServiceProvider',
5 => 'Illuminate\\Foundation\\Providers\\FoundationServiceProvider',
6 => 'Illuminate\\Notifications\\NotificationServiceProvider',
7 => 'Illuminate\\Pagination\\PaginationServiceProvider',
8 => 'Illuminate\\Session\\SessionServiceProvider',
9 => 'Illuminate\\View\\ViewServiceProvider',
10 => 'Laravel\\Reverb\\ReverbServiceProvider',
11 => 'Livewire\\LivewireServiceProvider',
12 => 'Carbon\\Laravel\\ServiceProvider',
13 => 'NunoMaduro\\Collision\\Adapters\\Laravel\\CollisionServiceProvider',
14 => 'Termwind\\Laravel\\TermwindServiceProvider',
15 => 'Pgvector\\Laravel\\PgvectorServiceProvider',
16 => 'Prism\\Prism\\PrismServiceProvider',
17 => 'App\\Providers\\AppServiceProvider',
),
'deferred' =>
array (
'Illuminate\\Broadcasting\\BroadcastManager' => 'Illuminate\\Broadcasting\\BroadcastServiceProvider',
'Illuminate\\Contracts\\Broadcasting\\Factory' => 'Illuminate\\Broadcasting\\BroadcastServiceProvider',
'Illuminate\\Contracts\\Broadcasting\\Broadcaster' => 'Illuminate\\Broadcasting\\BroadcastServiceProvider',
'Illuminate\\Bus\\Dispatcher' => 'Illuminate\\Bus\\BusServiceProvider',
'Illuminate\\Contracts\\Bus\\Dispatcher' => 'Illuminate\\Bus\\BusServiceProvider',
'Illuminate\\Contracts\\Bus\\QueueingDispatcher' => 'Illuminate\\Bus\\BusServiceProvider',
'Illuminate\\Bus\\BatchRepository' => 'Illuminate\\Bus\\BusServiceProvider',
'Illuminate\\Bus\\DatabaseBatchRepository' => 'Illuminate\\Bus\\BusServiceProvider',
'cache' => 'Illuminate\\Cache\\CacheServiceProvider',
'cache.store' => 'Illuminate\\Cache\\CacheServiceProvider',
'cache.psr6' => 'Illuminate\\Cache\\CacheServiceProvider',
'memcached.connector' => 'Illuminate\\Cache\\CacheServiceProvider',
'Illuminate\\Cache\\RateLimiter' => 'Illuminate\\Cache\\CacheServiceProvider',
'Illuminate\\Foundation\\Console\\AboutCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Cache\\Console\\ClearCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Cache\\Console\\ForgetCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\ClearCompiledCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Auth\\Console\\ClearResetsCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\ConfigCacheCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\ConfigClearCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\ConfigShowCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Database\\Console\\DbCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Database\\Console\\MonitorCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Database\\Console\\PruneCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Database\\Console\\ShowCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Database\\Console\\TableCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Database\\Console\\WipeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\DownCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\EnvironmentCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\EnvironmentDecryptCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\EnvironmentEncryptCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\EventCacheCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\EventClearCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\EventListCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Concurrency\\Console\\InvokeSerializedClosureCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\KeyGenerateCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\OptimizeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\OptimizeClearCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\PackageDiscoverCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Cache\\Console\\PruneStaleTagsCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Queue\\Console\\ClearCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Queue\\Console\\ListFailedCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Queue\\Console\\FlushFailedCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Queue\\Console\\ForgetFailedCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Queue\\Console\\ListenCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Queue\\Console\\MonitorCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Queue\\Console\\PauseCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Queue\\Console\\PruneBatchesCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Queue\\Console\\PruneFailedJobsCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Queue\\Console\\RestartCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Queue\\Console\\ResumeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Queue\\Console\\RetryCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Queue\\Console\\RetryBatchCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Queue\\Console\\WorkCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\ReloadCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\RouteCacheCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\RouteClearCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\RouteListCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Database\\Console\\DumpCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Database\\Console\\Seeds\\SeedCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Console\\Scheduling\\ScheduleFinishCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Console\\Scheduling\\ScheduleListCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Console\\Scheduling\\ScheduleRunCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Console\\Scheduling\\ScheduleClearCacheCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Console\\Scheduling\\ScheduleTestCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Console\\Scheduling\\ScheduleWorkCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Console\\Scheduling\\ScheduleInterruptCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Database\\Console\\ShowModelCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\StorageLinkCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\StorageUnlinkCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\UpCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\ViewCacheCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\ViewClearCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\ApiInstallCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\BroadcastingInstallCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Cache\\Console\\CacheTableCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\CastMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\ChannelListCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\ChannelMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\ClassMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\ComponentMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\ConfigMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\ConfigPublishCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\ConsoleMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Routing\\Console\\ControllerMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\DocsCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\EnumMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\EventGenerateCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\EventMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\ExceptionMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Database\\Console\\Factories\\FactoryMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\InterfaceMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\JobMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\JobMiddlewareMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\LangPublishCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\ListenerMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\MailMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Routing\\Console\\MiddlewareMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\ModelMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\NotificationMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Notifications\\Console\\NotificationTableCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\ObserverMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\PolicyMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\ProviderMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Queue\\Console\\FailedTableCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Queue\\Console\\TableCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Queue\\Console\\BatchesTableCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\RequestMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\ResourceMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\RuleMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\ScopeMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Database\\Console\\Seeds\\SeederMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Session\\Console\\SessionTableCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\ServeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\StubPublishCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\TestMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\TraitMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\VendorPublishCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Foundation\\Console\\ViewMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'migrator' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'migration.repository' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'migration.creator' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Database\\Migrations\\Migrator' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Database\\Console\\Migrations\\MigrateCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Database\\Console\\Migrations\\FreshCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Database\\Console\\Migrations\\InstallCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Database\\Console\\Migrations\\RefreshCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Database\\Console\\Migrations\\ResetCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Database\\Console\\Migrations\\RollbackCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Database\\Console\\Migrations\\StatusCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Database\\Console\\Migrations\\MigrateMakeCommand' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'composer' => 'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider',
'Illuminate\\Concurrency\\ConcurrencyManager' => 'Illuminate\\Concurrency\\ConcurrencyServiceProvider',
'hash' => 'Illuminate\\Hashing\\HashServiceProvider',
'hash.driver' => 'Illuminate\\Hashing\\HashServiceProvider',
'mail.manager' => 'Illuminate\\Mail\\MailServiceProvider',
'mailer' => 'Illuminate\\Mail\\MailServiceProvider',
'Illuminate\\Mail\\Markdown' => 'Illuminate\\Mail\\MailServiceProvider',
'auth.password' => 'Illuminate\\Auth\\Passwords\\PasswordResetServiceProvider',
'auth.password.broker' => 'Illuminate\\Auth\\Passwords\\PasswordResetServiceProvider',
'Illuminate\\Contracts\\Pipeline\\Hub' => 'Illuminate\\Pipeline\\PipelineServiceProvider',
'pipeline' => 'Illuminate\\Pipeline\\PipelineServiceProvider',
'queue' => 'Illuminate\\Queue\\QueueServiceProvider',
'queue.connection' => 'Illuminate\\Queue\\QueueServiceProvider',
'queue.failer' => 'Illuminate\\Queue\\QueueServiceProvider',
'queue.listener' => 'Illuminate\\Queue\\QueueServiceProvider',
'queue.worker' => 'Illuminate\\Queue\\QueueServiceProvider',
'redis' => 'Illuminate\\Redis\\RedisServiceProvider',
'redis.connection' => 'Illuminate\\Redis\\RedisServiceProvider',
'translator' => 'Illuminate\\Translation\\TranslationServiceProvider',
'translation.loader' => 'Illuminate\\Translation\\TranslationServiceProvider',
'validator' => 'Illuminate\\Validation\\ValidationServiceProvider',
'validation.presence' => 'Illuminate\\Validation\\ValidationServiceProvider',
'Illuminate\\Contracts\\Validation\\UncompromisedVerifier' => 'Illuminate\\Validation\\ValidationServiceProvider',
'Laravel\\Reverb\\ApplicationManager' => 'Laravel\\Reverb\\ApplicationManagerServiceProvider',
'Laravel\\Reverb\\Contracts\\ApplicationProvider' => 'Laravel\\Reverb\\ApplicationManagerServiceProvider',
'command.tinker' => 'Laravel\\Tinker\\TinkerServiceProvider',
),
'when' =>
array (
'Illuminate\\Broadcasting\\BroadcastServiceProvider' =>
array (
),
'Illuminate\\Bus\\BusServiceProvider' =>
array (
),
'Illuminate\\Cache\\CacheServiceProvider' =>
array (
),
'Illuminate\\Foundation\\Providers\\ConsoleSupportServiceProvider' =>
array (
),
'Illuminate\\Concurrency\\ConcurrencyServiceProvider' =>
array (
),
'Illuminate\\Hashing\\HashServiceProvider' =>
array (
),
'Illuminate\\Mail\\MailServiceProvider' =>
array (
),
'Illuminate\\Auth\\Passwords\\PasswordResetServiceProvider' =>
array (
),
'Illuminate\\Pipeline\\PipelineServiceProvider' =>
array (
),
'Illuminate\\Queue\\QueueServiceProvider' =>
array (
),
'Illuminate\\Redis\\RedisServiceProvider' =>
array (
),
'Illuminate\\Translation\\TranslationServiceProvider' =>
array (
),
'Illuminate\\Validation\\ValidationServiceProvider' =>
array (
),
'Laravel\\Reverb\\ApplicationManagerServiceProvider' =>
array (
),
'Laravel\\Tinker\\TinkerServiceProvider' =>
array (
),
),
);

5
bootstrap/providers.php Normal file
View File

@ -0,0 +1,5 @@
<?php
return [
App\Providers\AppServiceProvider::class,
];

68
composer.json Normal file
View File

@ -0,0 +1,68 @@
{
"name": "fox/assistant",
"type": "project",
"description": "Fox - proactive AI assistant.",
"keywords": ["laravel", "livewire", "ai", "fox"],
"license": "MIT",
"require": {
"php": "^8.3",
"guzzlehttp/guzzle": "^7.9",
"laravel/framework": "^12.0",
"laravel/reverb": "^1.4",
"laravel/tinker": "^2.9",
"livewire/livewire": "^3.5",
"pgvector/pgvector": "^0.2",
"predis/predis": "^2.2",
"prism-php/prism": "^0.100.1"
},
"require-dev": {
"fakerphp/faker": "^1.23",
"laravel/pint": "^1.18",
"mockery/mockery": "^1.6",
"nunomaduro/collision": "^8.0",
"phpunit/phpunit": "^11.0"
},
"autoload": {
"psr-4": {
"App\\": "app/",
"Database\\Factories\\": "database/factories/",
"Database\\Seeders\\": "database/seeders/"
}
},
"autoload-dev": {
"psr-4": {
"Tests\\": "tests/"
}
},
"scripts": {
"post-autoload-dump": [
"Illuminate\\Foundation\\ComposerScripts::postAutoloadDump",
"@php artisan package:discover --ansi"
],
"post-update-cmd": [
"@php artisan vendor:publish --tag=laravel-assets --ansi --force"
],
"post-root-package-install": [
"@php -r \"file_exists('.env') || copy('.env.example', '.env');\""
],
"post-create-project-cmd": [
"@php artisan key:generate --ansi"
]
},
"extra": {
"laravel": {
"dont-discover": []
}
},
"config": {
"optimize-autoloader": true,
"preferred-install": "dist",
"sort-packages": true,
"allow-plugins": {
"pestphp/pest-plugin": true,
"php-http/discovery": true
}
},
"minimum-stability": "stable",
"prefer-stable": true
}

9456
composer.lock generated Normal file

File diff suppressed because it is too large Load Diff

21
config/app.php Normal file
View File

@ -0,0 +1,21 @@
<?php
return [
'name' => env('APP_NAME', 'Fox'),
'env' => env('APP_ENV', 'production'),
'debug' => (bool) env('APP_DEBUG', false),
'url' => env('APP_URL', 'http://localhost'),
'timezone' => env('APP_TIMEZONE', 'Europe/Berlin'),
'locale' => env('APP_LOCALE', 'de'),
'fallback_locale' => env('APP_FALLBACK_LOCALE', 'en'),
'faker_locale' => env('APP_FAKER_LOCALE', 'de_DE'),
'cipher' => 'AES-256-CBC',
'key' => env('APP_KEY'),
'previous_keys' => [
...array_filter(explode(',', (string) env('APP_PREVIOUS_KEYS', ''))),
],
'maintenance' => [
'driver' => env('APP_MAINTENANCE_DRIVER', 'file'),
'store' => env('APP_MAINTENANCE_STORE', 'database'),
],
];

13
config/auth.php Normal file
View File

@ -0,0 +1,13 @@
<?php
return [
'defaults' => ['guard' => 'web', 'passwords' => 'users'],
'guards' => [
'web' => ['driver' => 'session', 'provider' => 'users'],
],
'providers' => [
// Fox kennt vorerst keine Auth - leer lassen.
],
'passwords' => [],
'password_timeout' => 10800,
];

23
config/broadcasting.php Normal file
View File

@ -0,0 +1,23 @@
<?php
return [
'default' => env('BROADCAST_CONNECTION', 'reverb'),
'connections' => [
'reverb' => [
'driver' => 'reverb',
'key' => env('REVERB_APP_KEY'),
'secret' => env('REVERB_APP_SECRET'),
'app_id' => env('REVERB_APP_ID'),
'options' => [
'host' => env('REVERB_HOST'),
'port' => env('REVERB_PORT', 8080),
'scheme' => env('REVERB_SCHEME', 'http'),
'useTLS' => env('REVERB_SCHEME', 'http') === 'https',
],
'client_options' => [],
],
'log' => ['driver' => 'log'],
'null' => ['driver' => 'null'],
],
];

26
config/cache.php Normal file
View File

@ -0,0 +1,26 @@
<?php
use Illuminate\Support\Str;
return [
'default' => env('CACHE_STORE', 'redis'),
'stores' => [
'array' => [
'driver' => 'array',
'serialize' => false,
],
'redis' => [
'driver' => 'redis',
'connection' => env('REDIS_CACHE_CONNECTION', 'cache'),
'lock_connection' => env('REDIS_CACHE_LOCK_CONNECTION', 'default'),
],
'file' => [
'driver' => 'file',
'path' => storage_path('framework/cache/data'),
'lock_path' => storage_path('framework/cache/data'),
],
],
'prefix' => env('CACHE_PREFIX', Str::slug(env('APP_NAME', 'fox'), '_').'_cache_'),
];

80
config/cities.php Normal file
View File

@ -0,0 +1,80 @@
<?php
/**
* Städte-Datenbank für das Fox HUD. Wird sowohl Server-seitig (Intent-Detection)
* als auch Browser-seitig (flyTo) verwendet - Frontend bekommt das Array via JSON.
*/
return [
'wien' => ['lng' => 16.3738, 'lat' => 48.2082, 'zoom' => 13, 'name' => 'Wien', 'bearing' => -15],
'graz' => ['lng' => 15.4395, 'lat' => 47.0707, 'zoom' => 13, 'name' => 'Graz', 'bearing' => 0],
'salzburg' => ['lng' => 13.0550, 'lat' => 47.8095, 'zoom' => 14, 'name' => 'Salzburg', 'bearing' => 10],
'innsbruck' => ['lng' => 11.4041, 'lat' => 47.2692, 'zoom' => 14, 'name' => 'Innsbruck', 'bearing' => -10],
'berlin' => ['lng' => 13.405, 'lat' => 52.52, 'zoom' => 12, 'name' => 'Berlin', 'bearing' => 0],
'munich' => ['lng' => 11.5820, 'lat' => 48.1351, 'zoom' => 13, 'name' => 'München', 'bearing' => 5],
'münchen' => ['lng' => 11.5820, 'lat' => 48.1351, 'zoom' => 13, 'name' => 'München', 'bearing' => 5],
'hamburg' => ['lng' => 9.9937, 'lat' => 53.5511, 'zoom' => 12, 'name' => 'Hamburg', 'bearing' => 0],
'frankfurt' => ['lng' => 8.6821, 'lat' => 50.1109, 'zoom' => 13, 'name' => 'Frankfurt', 'bearing' => 0],
'köln' => ['lng' => 6.9603, 'lat' => 50.9375, 'zoom' => 13, 'name' => 'Köln', 'bearing' => 0],
'cologne' => ['lng' => 6.9603, 'lat' => 50.9375, 'zoom' => 13, 'name' => 'Köln', 'bearing' => 0],
'london' => ['lng' => -0.1278, 'lat' => 51.5074, 'zoom' => 12, 'name' => 'London', 'bearing' => -5],
'paris' => ['lng' => 2.3522, 'lat' => 48.8566, 'zoom' => 13, 'name' => 'Paris', 'bearing' => 0],
'amsterdam' => ['lng' => 4.9041, 'lat' => 52.3676, 'zoom' => 13, 'name' => 'Amsterdam', 'bearing' => -5],
'brüssel' => ['lng' => 4.3517, 'lat' => 50.8503, 'zoom' => 13, 'name' => 'Brüssel', 'bearing' => 0],
'brussels' => ['lng' => 4.3517, 'lat' => 50.8503, 'zoom' => 13, 'name' => 'Brüssel', 'bearing' => 0],
'zürich' => ['lng' => 8.5417, 'lat' => 47.3769, 'zoom' => 13, 'name' => 'Zürich', 'bearing' => 0],
'zurich' => ['lng' => 8.5417, 'lat' => 47.3769, 'zoom' => 13, 'name' => 'Zürich', 'bearing' => 0],
'genf' => ['lng' => 6.1432, 'lat' => 46.2044, 'zoom' => 13, 'name' => 'Genf', 'bearing' => 0],
'rom' => ['lng' => 12.4964, 'lat' => 41.9028, 'zoom' => 13, 'name' => 'Rom', 'bearing' => 0],
'rome' => ['lng' => 12.4964, 'lat' => 41.9028, 'zoom' => 13, 'name' => 'Rom', 'bearing' => 0],
'milan' => ['lng' => 9.1900, 'lat' => 45.4642, 'zoom' => 13, 'name' => 'Mailand', 'bearing' => 0],
'mailand' => ['lng' => 9.1900, 'lat' => 45.4642, 'zoom' => 13, 'name' => 'Mailand', 'bearing' => 0],
'barcelona' => ['lng' => 2.1734, 'lat' => 41.3851, 'zoom' => 13, 'name' => 'Barcelona', 'bearing' => -10],
'madrid' => ['lng' => -3.7038, 'lat' => 40.4168, 'zoom' => 13, 'name' => 'Madrid', 'bearing' => 0],
'lisbon' => ['lng' => -9.1393, 'lat' => 38.7223, 'zoom' => 13, 'name' => 'Lissabon', 'bearing' => 0],
'lissabon' => ['lng' => -9.1393, 'lat' => 38.7223, 'zoom' => 13, 'name' => 'Lissabon', 'bearing' => 0],
'prag' => ['lng' => 14.4378, 'lat' => 50.0755, 'zoom' => 13, 'name' => 'Prag', 'bearing' => 0],
'prague' => ['lng' => 14.4378, 'lat' => 50.0755, 'zoom' => 13, 'name' => 'Prag', 'bearing' => 0],
'budapest' => ['lng' => 19.0402, 'lat' => 47.4979, 'zoom' => 13, 'name' => 'Budapest', 'bearing' => 0],
'warschau' => ['lng' => 21.0122, 'lat' => 52.2297, 'zoom' => 13, 'name' => 'Warschau', 'bearing' => 0],
'warsaw' => ['lng' => 21.0122, 'lat' => 52.2297, 'zoom' => 13, 'name' => 'Warschau', 'bearing' => 0],
'stockholm' => ['lng' => 18.0686, 'lat' => 59.3293, 'zoom' => 12, 'name' => 'Stockholm', 'bearing' => 0],
'kopenhagen' => ['lng' => 12.5683, 'lat' => 55.6761, 'zoom' => 13, 'name' => 'Kopenhagen', 'bearing' => 0],
'copenhagen' => ['lng' => 12.5683, 'lat' => 55.6761, 'zoom' => 13, 'name' => 'Kopenhagen', 'bearing' => 0],
'oslo' => ['lng' => 10.7522, 'lat' => 59.9139, 'zoom' => 12, 'name' => 'Oslo', 'bearing' => 0],
'helsinki' => ['lng' => 24.9384, 'lat' => 60.1699, 'zoom' => 12, 'name' => 'Helsinki', 'bearing' => 0],
'athen' => ['lng' => 23.7275, 'lat' => 37.9838, 'zoom' => 13, 'name' => 'Athen', 'bearing' => 0],
'athens' => ['lng' => 23.7275, 'lat' => 37.9838, 'zoom' => 13, 'name' => 'Athen', 'bearing' => 0],
'istanbul' => ['lng' => 28.9784, 'lat' => 41.0082, 'zoom' => 12, 'name' => 'Istanbul', 'bearing' => 0],
'moskau' => ['lng' => 37.6173, 'lat' => 55.7558, 'zoom' => 12, 'name' => 'Moskau', 'bearing' => 0],
'new york' => ['lng' => -74.006, 'lat' => 40.7128, 'zoom' => 12, 'name' => 'New York', 'bearing' => -20],
'newyork' => ['lng' => -74.006, 'lat' => 40.7128, 'zoom' => 12, 'name' => 'New York', 'bearing' => -20],
'los angeles' => ['lng' => -118.2437,'lat' => 34.0522, 'zoom' => 11, 'name' => 'Los Angeles', 'bearing' => 0],
'la' => ['lng' => -118.2437,'lat' => 34.0522, 'zoom' => 11, 'name' => 'Los Angeles', 'bearing' => 0],
'san francisco'=> ['lng' => -122.4194,'lat' => 37.7749, 'zoom' => 12, 'name' => 'San Francisco','bearing' => 0],
'sf' => ['lng' => -122.4194,'lat' => 37.7749, 'zoom' => 12, 'name' => 'San Francisco','bearing' => 0],
'chicago' => ['lng' => -87.6298, 'lat' => 41.8781, 'zoom' => 12, 'name' => 'Chicago', 'bearing' => 0],
'miami' => ['lng' => -80.1918, 'lat' => 25.7617, 'zoom' => 12, 'name' => 'Miami', 'bearing' => 0],
'toronto' => ['lng' => -79.3832, 'lat' => 43.6532, 'zoom' => 12, 'name' => 'Toronto', 'bearing' => 0],
'mexico city' => ['lng' => -99.1332, 'lat' => 19.4326, 'zoom' => 12, 'name' => 'Mexico City', 'bearing' => 0],
'rio' => ['lng' => -43.1729, 'lat' => -22.9068, 'zoom' => 12, 'name' => 'Rio', 'bearing' => 0],
'são paulo' => ['lng' => -46.6333, 'lat' => -23.5505, 'zoom' => 12, 'name' => 'São Paulo', 'bearing' => 0],
'tokyo' => ['lng' => 139.6503, 'lat' => 35.6762, 'zoom' => 12, 'name' => 'Tokio', 'bearing' => 15],
'tokio' => ['lng' => 139.6503, 'lat' => 35.6762, 'zoom' => 12, 'name' => 'Tokio', 'bearing' => 15],
'osaka' => ['lng' => 135.5023, 'lat' => 34.6937, 'zoom' => 12, 'name' => 'Osaka', 'bearing' => 0],
'seoul' => ['lng' => 126.9780, 'lat' => 37.5665, 'zoom' => 12, 'name' => 'Seoul', 'bearing' => 0],
'beijing' => ['lng' => 116.4074, 'lat' => 39.9042, 'zoom' => 11, 'name' => 'Peking', 'bearing' => 0],
'peking' => ['lng' => 116.4074, 'lat' => 39.9042, 'zoom' => 11, 'name' => 'Peking', 'bearing' => 0],
'shanghai' => ['lng' => 121.4737, 'lat' => 31.2304, 'zoom' => 11, 'name' => 'Shanghai', 'bearing' => 0],
'hongkong' => ['lng' => 114.1694, 'lat' => 22.3193, 'zoom' => 12, 'name' => 'Hongkong', 'bearing' => 0],
'hong kong' => ['lng' => 114.1694, 'lat' => 22.3193, 'zoom' => 12, 'name' => 'Hongkong', 'bearing' => 0],
'singapore' => ['lng' => 103.8198, 'lat' => 1.3521, 'zoom' => 12, 'name' => 'Singapur', 'bearing' => 0],
'singapur' => ['lng' => 103.8198, 'lat' => 1.3521, 'zoom' => 12, 'name' => 'Singapur', 'bearing' => 0],
'bangkok' => ['lng' => 100.5018, 'lat' => 13.7563, 'zoom' => 12, 'name' => 'Bangkok', 'bearing' => 0],
'dubai' => ['lng' => 55.2708, 'lat' => 25.2048, 'zoom' => 12, 'name' => 'Dubai', 'bearing' => 20],
'sydney' => ['lng' => 151.2093, 'lat' => -33.8688, 'zoom' => 12, 'name' => 'Sydney', 'bearing' => 0],
'melbourne' => ['lng' => 144.9631, 'lat' => -37.8136, 'zoom' => 12, 'name' => 'Melbourne', 'bearing' => 0],
'kapstadt' => ['lng' => 18.4241, 'lat' => -33.9249, 'zoom' => 12, 'name' => 'Kapstadt', 'bearing' => 0],
'cape town' => ['lng' => 18.4241, 'lat' => -33.9249, 'zoom' => 12, 'name' => 'Kapstadt', 'bearing' => 0],
'kairo' => ['lng' => 31.2357, 'lat' => 30.0444, 'zoom' => 12, 'name' => 'Kairo', 'bearing' => 0],
'cairo' => ['lng' => 31.2357, 'lat' => 30.0444, 'zoom' => 12, 'name' => 'Kairo', 'bearing' => 0],
];

53
config/database.php Normal file
View File

@ -0,0 +1,53 @@
<?php
use Illuminate\Support\Str;
return [
'default' => env('DB_CONNECTION', 'pgsql'),
'connections' => [
'pgsql' => [
'driver' => 'pgsql',
'url' => env('DB_URL'),
'host' => env('DB_HOST', 'postgres'),
'port' => env('DB_PORT', '5432'),
'database' => env('DB_DATABASE', 'fox'),
'username' => env('DB_USERNAME', 'fox'),
'password' => env('DB_PASSWORD', 'fox'),
'charset' => env('DB_CHARSET', 'utf8'),
'prefix' => '',
'prefix_indexes' => true,
'search_path' => 'public',
'sslmode' => 'prefer',
],
],
'migrations' => [
'table' => 'migrations',
'update_date_on_publish' => true,
],
'redis' => [
'client' => env('REDIS_CLIENT', 'phpredis'),
'options' => [
'cluster' => env('REDIS_CLUSTER', 'redis'),
'prefix' => env('REDIS_PREFIX', Str::slug(env('APP_NAME', 'fox'), '_').'_database_'),
],
'default' => [
'url' => env('REDIS_URL'),
'host' => env('REDIS_HOST', 'redis'),
'username' => env('REDIS_USERNAME'),
'password' => env('REDIS_PASSWORD'),
'port' => env('REDIS_PORT', '6379'),
'database' => env('REDIS_DB', '0'),
],
'cache' => [
'url' => env('REDIS_URL'),
'host' => env('REDIS_HOST', 'redis'),
'username' => env('REDIS_USERNAME'),
'password' => env('REDIS_PASSWORD'),
'port' => env('REDIS_PORT', '6379'),
'database' => env('REDIS_CACHE_DB', '1'),
],
],
];

35
config/filesystems.php Normal file
View File

@ -0,0 +1,35 @@
<?php
return [
'default' => env('FILESYSTEM_DISK', 'local'),
'disks' => [
'local' => [
'driver' => 'local',
'root' => storage_path('app/private'),
'serve' => true,
'throw' => false,
'report' => false,
],
'public' => [
'driver' => 'local',
'root' => storage_path('app/public'),
'url' => env('APP_URL').'/storage',
'visibility' => 'public',
'throw' => false,
'report' => false,
],
'audio' => [
'driver' => 'local',
'root' => storage_path('app/audio'),
'url' => env('APP_URL').'/audio',
'visibility' => 'public',
'throw' => false,
'report' => false,
],
],
'links' => [
public_path('storage') => storage_path('app/public'),
],
];

42
config/livewire.php Normal file
View File

@ -0,0 +1,42 @@
<?php
return [
/*
* Class-based components: PHP-Klassen leben in app/Livewire,
* Blade-Views getrennt unter resources/views/livewire.
*/
'class_namespace' => 'App\\Livewire',
'view_path' => resource_path('views/livewire'),
'layout' => 'layouts.app',
'lazy_placeholder' => null,
'temporary_file_upload' => [
'disk' => null,
'rules' => null,
'directory' => null,
'middleware' => null,
'preview_mimes' => [
'png', 'gif', 'bmp', 'svg', 'wav', 'mp4', 'mov', 'avi', 'wmv',
'mp3', 'm4a', 'jpg', 'jpeg', 'mpga', 'webp', 'wma', 'ogg',
],
'max_upload_time' => 5,
'cleanup' => true,
],
'render_on_redirect' => false,
'legacy_model_binding' => false,
'inject_assets' => true,
'navigate' => [
'show_progress_bar' => true,
'progress_bar_color' => '#4F46E5',
],
'inject_morph_markers' => true,
'pagination_theme' => 'tailwind',
];

32
config/logging.php Normal file
View File

@ -0,0 +1,32 @@
<?php
use Monolog\Handler\StreamHandler;
use Monolog\Processor\PsrLogMessageProcessor;
return [
'default' => env('LOG_CHANNEL', 'stack'),
'deprecations' => ['channel' => 'null', 'trace' => false],
'channels' => [
'stack' => [
'driver' => 'stack',
'channels' => explode(',', (string) env('LOG_STACK', 'single')),
'ignore_exceptions' => false,
],
'single' => [
'driver' => 'single',
'path' => storage_path('logs/laravel.log'),
'level' => env('LOG_LEVEL', 'debug'),
'replace_placeholders' => true,
],
'stderr' => [
'driver' => 'monolog',
'level' => env('LOG_LEVEL', 'debug'),
'handler' => StreamHandler::class,
'formatter' => env('LOG_STDERR_FORMATTER'),
'with' => ['stream' => 'php://stderr'],
'processors' => [PsrLogMessageProcessor::class],
],
'null' => ['driver' => 'monolog', 'handler' => Monolog\Handler\NullHandler::class],
],
];

14
config/mail.php Normal file
View File

@ -0,0 +1,14 @@
<?php
return [
'default' => env('MAIL_MAILER', 'log'),
'mailers' => [
'log' => ['transport' => 'log'],
'array' => ['transport' => 'array'],
'failover' => ['transport' => 'failover', 'mailers' => ['log']],
],
'from' => [
'address' => env('MAIL_FROM_ADDRESS', 'fox@localhost'),
'name' => env('MAIL_FROM_NAME', 'Fox'),
],
];

25
config/prism.php Normal file
View File

@ -0,0 +1,25 @@
<?php
return [
'prism_server' => [
'enabled' => env('PRISM_SERVER_ENABLED', false),
],
'providers' => [
'ollama' => [
'url' => env('OLLAMA_URL', 'http://ollama:11434/api'),
'api_key' => env('OLLAMA_API_KEY', ''),
],
'openai' => [
'url' => env('OPENAI_URL', 'https://api.openai.com/v1'),
'api_key' => env('OPENAI_API_KEY', ''),
'organization' => env('OPENAI_ORGANIZATION', ''),
'project' => env('OPENAI_PROJECT', ''),
],
],
'default' => [
'provider' => env('PRISM_PROVIDER', 'ollama'),
'model' => env('OLLAMA_MODEL', 'llama3.1:8b'),
],
];

28
config/queue.php Normal file
View File

@ -0,0 +1,28 @@
<?php
return [
'default' => env('QUEUE_CONNECTION', 'redis'),
'connections' => [
'sync' => ['driver' => 'sync'],
'redis' => [
'driver' => 'redis',
'connection' => env('REDIS_QUEUE_CONNECTION', 'default'),
'queue' => env('REDIS_QUEUE', 'default'),
'retry_after' => 90,
'block_for' => null,
'after_commit' => false,
],
],
'batching' => [
'database' => env('DB_CONNECTION', 'pgsql'),
'table' => 'job_batches',
],
'failed' => [
'driver' => env('QUEUE_FAILED_DRIVER', 'database-uuids'),
'database' => env('DB_CONNECTION', 'pgsql'),
'table' => 'failed_jobs',
],
];

52
config/reverb.php Normal file
View File

@ -0,0 +1,52 @@
<?php
return [
'default' => env('REVERB_SERVER', 'reverb'),
'servers' => [
'reverb' => [
'host' => env('REVERB_SERVER_HOST', '0.0.0.0'),
'port' => (int) env('REVERB_SERVER_PORT', 8080),
'hostname' => env('REVERB_HOST'),
'options' => [
'tls' => [],
],
'max_request_size' => (int) env('REVERB_MAX_REQUEST_SIZE', 10_000),
'scaling' => [
'enabled' => env('REVERB_SCALING_ENABLED', false),
'channel' => env('REVERB_SCALING_CHANNEL', 'reverb'),
'server' => [
'url' => env('REDIS_URL'),
'host' => env('REDIS_HOST', 'redis'),
'port' => env('REDIS_PORT', '6379'),
'username' => env('REDIS_USERNAME'),
'password' => env('REDIS_PASSWORD'),
'database' => env('REDIS_DB', '0'),
],
],
'pulse_ingest_interval' => (int) env('REVERB_PULSE_INGEST_INTERVAL', 15),
'telescope_ingest_interval' => (int) env('REVERB_TELESCOPE_INGEST_INTERVAL', 15),
],
],
'apps' => [
'provider' => 'config',
'apps' => [
[
'key' => env('REVERB_APP_KEY'),
'secret' => env('REVERB_APP_SECRET'),
'app_id' => env('REVERB_APP_ID'),
'options' => [
'host' => env('REVERB_HOST'),
'port' => env('REVERB_PORT', 8080),
'scheme' => env('REVERB_SCHEME', 'http'),
'useTLS' => env('REVERB_SCHEME', 'http') === 'https',
],
'allowed_origins' => ['*'],
'ping_interval' => (int) env('REVERB_APP_PING_INTERVAL', 30),
'activity_timeout' => (int) env('REVERB_APP_ACTIVITY_TIMEOUT', 120),
'max_message_size' => (int) env('REVERB_APP_MAX_MESSAGE_SIZE', 10_000),
],
],
],
];

74
config/services.php Normal file
View File

@ -0,0 +1,74 @@
<?php
return [
'searxng' => [
'url' => env('SEARXNG_URL', 'http://searxng:8080'),
],
'google' => [
'maps_key' => env('GOOGLE_MAPS_API_KEY'),
'directions_timeout' => (int) env('GOOGLE_DIRECTIONS_TIMEOUT', 8),
],
'ollama' => [
'url' => env('OLLAMA_URL', 'http://ollama:11434'),
'model' => env('OLLAMA_MODEL', 'llama3.1:8b'),
'embedding_model' => env('OLLAMA_EMBEDDING_MODEL', 'nomic-embed-text'),
'tools_enabled' => filter_var(env('OLLAMA_TOOLS_ENABLED', false), FILTER_VALIDATE_BOOLEAN),
],
'whisper' => [
'container' => env('WHISPER_CONTAINER', 'fox-whisper'),
'model' => env('WHISPER_MODEL', '/models/ggml-base.bin'),
],
'weather' => [
'lat' => env('FOX_LAT', 48.2082),
'lon' => env('FOX_LON', 16.3738),
'city' => env('FOX_CITY', 'Wien'),
],
/*
* TTS-Engine. Default openai (Cloud, ~300ms, multilingual), dann elevenlabs, dann xtts (Mac), dann piper.
* Fallback-Kette: wenn engine fehlschlägt, wird automatisch der nächste versucht.
*/
'tts' => [
'engine' => env('TTS_ENGINE', 'openai'), // openai | elevenlabs | xtts | piper
'language' => env('TTS_LANGUAGE', 'de'),
],
'openai' => [
'key' => env('OPENAI_API_KEY'),
'tts_model' => env('OPENAI_TTS_MODEL', 'tts-1'),
'tts_voice' => env('OPENAI_TTS_VOICE', 'onyx'),
'tts_speed' => (float) env('OPENAI_TTS_SPEED', 1.0),
'timeout' => (int) env('OPENAI_TIMEOUT', 15),
],
'elevenlabs' => [
'key' => env('ELEVENLABS_API_KEY'),
'voice_id' => env('ELEVENLABS_VOICE_ID', 'KDqku3FJfbImX6HKQdWA'),
'model' => env('ELEVENLABS_MODEL', 'eleven_multilingual_v2'),
'stability' => (float) env('ELEVENLABS_STABILITY', 0.5),
'similarity_boost' => (float) env('ELEVENLABS_SIMILARITY', 0.75),
'style' => (float) env('ELEVENLABS_STYLE', 0.3),
'speaker_boost' => filter_var(env('ELEVENLABS_SPEAKER_BOOST', true), FILTER_VALIDATE_BOOLEAN),
'timeout' => (int) env('ELEVENLABS_TIMEOUT', 10),
],
'xtts' => [
'url' => env('XTTS_URL', env('TTS_URL', 'http://10.10.80.189:8766')),
'speaker' => env('XTTS_SPEAKER', env('TTS_SPEAKER', 'Ana Florence')),
'timeout' => (int) env('XTTS_TIMEOUT', 30),
],
'piper' => [
'enabled' => filter_var(env('PIPER_ENABLED', true), FILTER_VALIDATE_BOOLEAN),
'voice' => env('PIPER_VOICE', 'de_DE-thorsten-high'),
'voices_path' => env('PIPER_VOICES_PATH', '/var/www/storage/app/voices'),
'length_scale' => env('PIPER_LENGTH_SCALE', 1.05),
'sentence_silence' => env('PIPER_SENTENCE_SILENCE', 0.3),
'noise_scale' => env('PIPER_NOISE_SCALE', 0.667),
'noise_w' => env('PIPER_NOISE_W', 0.8),
],
];

22
config/session.php Normal file
View File

@ -0,0 +1,22 @@
<?php
use Illuminate\Support\Str;
return [
'driver' => env('SESSION_DRIVER', 'redis'),
'lifetime' => (int) env('SESSION_LIFETIME', 120),
'expire_on_close' => env('SESSION_EXPIRE_ON_CLOSE', false),
'encrypt' => env('SESSION_ENCRYPT', false),
'files' => storage_path('framework/sessions'),
'connection' => env('SESSION_CONNECTION'),
'table' => env('SESSION_TABLE', 'sessions'),
'store' => env('SESSION_STORE'),
'lottery' => [2, 100],
'cookie' => env('SESSION_COOKIE', Str::slug(env('APP_NAME', 'fox'), '_').'_session'),
'path' => env('SESSION_PATH', '/'),
'domain' => env('SESSION_DOMAIN'),
'secure' => env('SESSION_SECURE_COOKIE'),
'http_only' => env('SESSION_HTTP_ONLY', true),
'same_site' => env('SESSION_SAME_SITE', 'lax'),
'partitioned' => env('SESSION_PARTITIONED_COOKIE', false),
];

6
config/view.php Normal file
View File

@ -0,0 +1,6 @@
<?php
return [
'paths' => [resource_path('views')],
'compiled' => env('VIEW_COMPILED_PATH', realpath(storage_path('framework/views'))),
];

View File

@ -0,0 +1,17 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
public function up(): void
{
DB::statement('CREATE EXTENSION IF NOT EXISTS vector');
}
public function down(): void
{
DB::statement('DROP EXTENSION IF EXISTS vector');
}
};

View File

@ -0,0 +1,29 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('cache', function (Blueprint $table) {
$table->string('key')->primary();
$table->mediumText('value');
$table->integer('expiration');
});
Schema::create('cache_locks', function (Blueprint $table) {
$table->string('key')->primary();
$table->string('owner');
$table->integer('expiration');
});
}
public function down(): void
{
Schema::dropIfExists('cache');
Schema::dropIfExists('cache_locks');
}
};

View File

@ -0,0 +1,51 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('jobs', function (Blueprint $table) {
$table->id();
$table->string('queue')->index();
$table->longText('payload');
$table->unsignedTinyInteger('attempts');
$table->unsignedInteger('reserved_at')->nullable();
$table->unsignedInteger('available_at');
$table->unsignedInteger('created_at');
});
Schema::create('job_batches', function (Blueprint $table) {
$table->string('id')->primary();
$table->string('name');
$table->integer('total_jobs');
$table->integer('pending_jobs');
$table->integer('failed_jobs');
$table->longText('failed_job_ids');
$table->mediumText('options')->nullable();
$table->integer('cancelled_at')->nullable();
$table->integer('created_at');
$table->integer('finished_at')->nullable();
});
Schema::create('failed_jobs', function (Blueprint $table) {
$table->id();
$table->string('uuid')->unique();
$table->text('connection');
$table->text('queue');
$table->longText('payload');
$table->longText('exception');
$table->timestamp('failed_at')->useCurrent();
});
}
public function down(): void
{
Schema::dropIfExists('jobs');
Schema::dropIfExists('job_batches');
Schema::dropIfExists('failed_jobs');
}
};

View File

@ -0,0 +1,29 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('messages', function (Blueprint $table) {
$table->uuid('id')->primary();
$table->string('role'); // user | assistant | system | tool
$table->text('content');
$table->string('type')->default('text'); // text | proactive | voice | tool_use
$table->jsonb('metadata')->nullable();
$table->timestamps();
$table->index('role');
$table->index('type');
$table->index('created_at');
});
}
public function down(): void
{
Schema::dropIfExists('messages');
}
};

View File

@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('memories', function (Blueprint $table) {
$table->id();
$table->text('content');
$table->jsonb('metadata')->nullable();
$table->timestamps();
});
// pgvector embedding column (768 für nomic-embed-text; auf 1536 anpassen falls OpenAI-kompatibel)
DB::statement('ALTER TABLE memories ADD COLUMN embedding vector(768)');
// IVFFlat index for cosine similarity
DB::statement('CREATE INDEX memories_embedding_idx ON memories USING ivfflat (embedding vector_cosine_ops) WITH (lists = 100)');
}
public function down(): void
{
Schema::dropIfExists('memories');
}
};

View File

@ -0,0 +1,29 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('goals', function (Blueprint $table) {
$table->id();
$table->string('title');
$table->text('description')->nullable();
$table->string('status')->default('active'); // active | paused | done | abandoned
$table->unsignedTinyInteger('progress')->default(0); // 0-100
$table->jsonb('metadata')->nullable();
$table->timestamp('last_reviewed_at')->nullable();
$table->timestamps();
$table->index('status');
});
}
public function down(): void
{
Schema::dropIfExists('goals');
}
};

View File

@ -0,0 +1,30 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('tool_calls', function (Blueprint $table) {
$table->id();
$table->foreignUuid('message_id')->nullable()->constrained()->nullOnDelete();
$table->string('tool');
$table->jsonb('input')->nullable();
$table->jsonb('output')->nullable();
$table->unsignedInteger('duration_ms')->default(0);
$table->boolean('success')->default(true);
$table->text('error')->nullable();
$table->timestamps();
$table->index('tool');
});
}
public function down(): void
{
Schema::dropIfExists('tool_calls');
}
};

View File

@ -0,0 +1,31 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('fox_memory_nodes', function (Blueprint $table) {
$table->uuid('id')->primary();
$table->string('type');
$table->string('title');
$table->text('content')->nullable();
$table->json('meta')->nullable();
$table->float('confidence')->default(1.0);
$table->unsignedInteger('weight')->default(1);
$table->timestamp('last_used_at')->nullable();
$table->timestamps();
$table->index('type');
$table->index('title');
});
}
public function down(): void
{
Schema::dropIfExists('fox_memory_nodes');
}
};

View File

@ -0,0 +1,37 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('fox_memory_edges', function (Blueprint $table) {
$table->uuid('id')->primary();
$table->uuid('source_node_id');
$table->uuid('target_node_id');
$table->string('relation');
$table->float('strength')->default(1.0);
$table->json('meta')->nullable();
$table->timestamps();
$table->foreign('source_node_id')
->references('id')->on('fox_memory_nodes')
->cascadeOnDelete();
$table->foreign('target_node_id')
->references('id')->on('fox_memory_nodes')
->cascadeOnDelete();
$table->index(['source_node_id', 'target_node_id']);
$table->index('relation');
});
}
public function down(): void
{
Schema::dropIfExists('fox_memory_edges');
}
};

View File

@ -0,0 +1,28 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('fox_learning_events', function (Blueprint $table) {
$table->uuid('id')->primary();
$table->text('input');
$table->json('extracted_memory')->nullable();
$table->string('status')->default('pending');
$table->timestamp('approved_at')->nullable();
$table->timestamp('rejected_at')->nullable();
$table->timestamps();
$table->index('status');
});
}
public function down(): void
{
Schema::dropIfExists('fox_learning_events');
}
};

View File

@ -0,0 +1,26 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('fox_memory_snapshots', function (Blueprint $table) {
$table->uuid('id')->primary();
$table->string('title');
$table->longText('summary')->nullable();
$table->unsignedInteger('nodes_count')->default(0);
$table->unsignedInteger('edges_count')->default(0);
$table->json('meta')->nullable();
$table->timestamps();
});
}
public function down(): void
{
Schema::dropIfExists('fox_memory_snapshots');
}
};

View File

@ -0,0 +1,227 @@
<?php
namespace Database\Seeders;
use App\Models\Fox\FoxMemoryEdge;
use App\Models\Fox\FoxMemoryNode;
use Illuminate\Database\Seeder;
class FoxBrainSeeder extends Seeder
{
public function run(): void
{
FoxMemoryEdge::query()->delete();
FoxMemoryNode::query()->delete();
// ── Core ─────────────────────────────────────────────────────────────
$fox = FoxMemoryNode::create([
'type' => 'skill',
'title' => 'Fox',
'content' => 'Persönlicher KI-Assistent. Versteht Sprache, steuert HUD, speichert Wissen. Läuft auf Laravel 12 + Livewire + Alpine.js.',
'confidence' => 1.0,
'weight' => 10,
]);
$boban = FoxMemoryNode::create([
'type' => 'user',
'title' => 'Boban',
'content' => 'Eigentümer und Entwickler des Fox-Systems. Email: boban.blaskovic@gmail.com. Wohnt in Wien.',
'confidence' => 1.0,
'weight' => 9,
]);
$project = FoxMemoryNode::create([
'type' => 'project',
'title' => 'Fox Projekt',
'content' => 'Das Fox HUD System — persönlicher KI-Assistent mit HUD, Karte, Brain-Graph, Sprachsteuerung, Code-Panel und Smart-Home-Integration.',
'confidence' => 1.0,
'weight' => 9,
]);
// ── Standort ─────────────────────────────────────────────────────────
$wien = FoxMemoryNode::create([
'type' => 'place',
'title' => 'Wien',
'content' => 'Heimatstadt. Koordinaten: 48.2082° N, 16.3738° E. Fox zeigt die U-Bahn und Wiener Stadtplan.',
'confidence' => 1.0,
'weight' => 7,
]);
// ── Frameworks / Sprachen ─────────────────────────────────────────────
$laravel = FoxMemoryNode::create([
'type' => 'skill',
'title' => 'Laravel 12',
'content' => 'PHP-Framework. Backend für Fox: Routing, Livewire, Events, Broadcasting, Queues.',
'confidence' => 1.0,
'weight' => 8,
]);
$livewire = FoxMemoryNode::create([
'type' => 'skill',
'title' => 'Livewire 3',
'content' => 'Reactive PHP-Komponenten ohne extra JavaScript. Fox HUD ist eine Livewire-Komponente.',
'confidence' => 1.0,
'weight' => 7,
]);
$alpine = FoxMemoryNode::create([
'type' => 'skill',
'title' => 'Alpine.js',
'content' => 'Leichtgewichtiges JS-Framework. Fox HUD nutzt Alpine für alle UI-States (Karte, Brain, Panels).',
'confidence' => 1.0,
'weight' => 7,
]);
$threejs = FoxMemoryNode::create([
'type' => 'skill',
'title' => 'Three.js',
'content' => 'WebGL-3D-Bibliothek. Zeichnet den Fox Brain Graph: schwebende Knoten, Linien, Glow-Texturen.',
'confidence' => 1.0,
'weight' => 6,
]);
$php = FoxMemoryNode::create([
'type' => 'skill',
'title' => 'PHP 8.3',
'content' => 'Primäre Backend-Sprache. Fox Agent, Memory Service, Intent Detector — alles PHP.',
'confidence' => 1.0,
'weight' => 7,
]);
$javascript = FoxMemoryNode::create([
'type' => 'skill',
'title' => 'JavaScript',
'content' => 'Frontend-Sprache. Fox HUD JS-Komponente, Brain Graph, Karten-Integration, Sprachsteuerung.',
'confidence' => 1.0,
'weight' => 7,
]);
// ── Tools / Infrastruktur ─────────────────────────────────────────────
$docker = FoxMemoryNode::create([
'type' => 'skill',
'title' => 'Docker',
'content' => 'Container-Umgebung. Fox läuft in fox-app (PHP), fox-vite (JS-Build), fox-db (PostgreSQL) Containern.',
'confidence' => 1.0,
'weight' => 6,
]);
$vite = FoxMemoryNode::create([
'type' => 'skill',
'title' => 'Vite',
'content' => 'Build-Tool für Assets. Bündelt JS/CSS mit Content-Hashes. Build läuft im fox-vite Container.',
'confidence' => 1.0,
'weight' => 5,
]);
$postgres = FoxMemoryNode::create([
'type' => 'skill',
'title' => 'PostgreSQL',
'content' => 'Datenbank. Speichert Fox Memory Nodes, Edges, Learning Events, Snapshots.',
'confidence' => 1.0,
'weight' => 6,
]);
// ── KI-Dienste ────────────────────────────────────────────────────────
$ollama = FoxMemoryNode::create([
'type' => 'preference',
'title' => 'Ollama',
'content' => 'Lokales LLM. Bevorzugtes Modell: qwen2.5:32b. Läuft lokal — kein Cloud-Datenschutzproblem.',
'confidence' => 1.0,
'weight' => 7,
]);
$openai = FoxMemoryNode::create([
'type' => 'preference',
'title' => 'OpenAI API',
'content' => 'Cloud-LLM-Fallback. GPT-4 via OpenAI API. Wird genutzt wenn Ollama nicht antwortet.',
'confidence' => 0.9,
'weight' => 6,
]);
$reverb = FoxMemoryNode::create([
'type' => 'skill',
'title' => 'Laravel Reverb',
'content' => 'WebSocket-Server. Ermöglicht Real-Time-Events: Fox-Aktionen (Karte, Brain, Code) werden live gepusht.',
'confidence' => 1.0,
'weight' => 6,
]);
// ── HUD-Komponenten ───────────────────────────────────────────────────
$hud = FoxMemoryNode::create([
'type' => 'project',
'title' => 'Fox HUD',
'content' => 'Heads-Up-Display. Vollbild-Overlay mit Zeit, Wetter, Status, Mikrofon, Karte, Brain, Code-Panel.',
'confidence' => 1.0,
'weight' => 8,
]);
$karte = FoxMemoryNode::create([
'type' => 'project',
'title' => 'Karte',
'content' => 'Mapbox GL JS Karte im HUD. Zeigt Wien, U-Bahn-Linien, POIs, Routen. Blendet sich wie der Brain-Graph ein.',
'confidence' => 1.0,
'weight' => 6,
]);
$brain = FoxMemoryNode::create([
'type' => 'project',
'title' => 'Brain Graph',
'content' => 'Three.js Visualisierung des Fox-Gedächtnisses. Fox im Zentrum, Knoten schweben im 3D-Raum.',
'confidence' => 1.0,
'weight' => 7,
]);
$sprache = FoxMemoryNode::create([
'type' => 'skill',
'title' => 'Sprachsteuerung',
'content' => 'Whisper-API transkribiert Mikrofon-Input. IntentDetector erkennt Absichten direkt. LLM als Fallback.',
'confidence' => 1.0,
'weight' => 7,
]);
// ── Edges ─────────────────────────────────────────────────────────────
$edges = [
[$boban, $fox, 'owns', 1.0],
[$boban, $project, 'owns', 1.0],
[$boban, $wien, 'lives_in', 1.0],
[$fox, $project, 'belongs_to', 1.0],
[$fox, $ollama, 'uses', 1.0],
[$fox, $openai, 'uses', 0.8],
[$fox, $reverb, 'uses', 1.0],
[$fox, $sprache, 'has_feature', 1.0],
[$fox, $hud, 'has_feature', 1.0],
[$fox, $brain, 'has_feature', 1.0],
[$fox, $karte, 'has_feature', 1.0],
[$project, $laravel, 'uses', 1.0],
[$project, $livewire, 'uses', 1.0],
[$project, $alpine, 'uses', 1.0],
[$project, $threejs, 'uses', 1.0],
[$project, $docker, 'uses', 1.0],
[$project, $vite, 'uses', 1.0],
[$project, $postgres, 'uses', 1.0],
[$laravel, $php, 'built_on', 1.0],
[$laravel, $livewire, 'includes', 1.0],
[$laravel, $reverb, 'includes', 1.0],
[$hud, $alpine, 'uses', 1.0],
[$brain, $threejs, 'uses', 1.0],
[$karte, $project, 'part_of', 1.0],
[$docker, $vite, 'runs', 0.9],
[$docker, $postgres, 'runs', 1.0],
[$sprache, $ollama, 'calls', 1.0],
[$sprache, $openai, 'calls', 0.8],
];
foreach ($edges as [$src, $tgt, $rel, $str]) {
FoxMemoryEdge::create([
'source_node_id' => $src->id,
'target_node_id' => $tgt->id,
'relation' => $rel,
'strength' => $str,
]);
}
$nodeCount = FoxMemoryNode::count();
$edgeCount = FoxMemoryEdge::count();
$this->command->info("Fox Brain seeded: {$nodeCount} nodes, {$edgeCount} edges.");
}
}

201
docker-compose.yml Normal file
View File

@ -0,0 +1,201 @@
services:
app:
build:
context: .
dockerfile: docker/php/Dockerfile
container_name: fox-app
restart: unless-stopped
working_dir: /var/www
volumes:
- ./:/var/www
- ./storage/app:/var/www/storage/app
- /var/run/docker.sock:/var/run/docker.sock
environment:
PHP_OPCACHE_ENABLE: 1
depends_on:
- postgres
- redis
networks:
- fox
nginx:
image: nginx:1.27-alpine
container_name: fox-nginx
restart: unless-stopped
ports:
- "80:80"
volumes:
- ./:/var/www
- ./docker/nginx/default.conf:/etc/nginx/conf.d/default.conf:ro
depends_on:
- app
networks:
- fox
postgres:
image: pgvector/pgvector:pg16
container_name: fox-postgres
restart: unless-stopped
environment:
POSTGRES_DB: ${DB_DATABASE:-fox}
POSTGRES_USER: ${DB_USERNAME:-fox}
POSTGRES_PASSWORD: ${DB_PASSWORD:-fox}
volumes:
- postgres-data:/var/lib/postgresql/data
- ./docker/postgres/init.sql:/docker-entrypoint-initdb.d/init.sql:ro
ports:
- "5432:5432"
networks:
- fox
redis:
image: redis:7-alpine
container_name: fox-redis
restart: unless-stopped
command: redis-server --appendonly yes
volumes:
- redis-data:/data
networks:
- fox
# Ollama läuft jetzt extern auf dem Mac (10.10.80.189:11434).
# Wenn du wieder einen lokalen Container willst: profile rausnehmen.
ollama:
image: ollama/ollama:latest
container_name: fox-ollama
profiles: ["local-llm"]
restart: unless-stopped
volumes:
- ollama-data:/root/.ollama
ports:
- "11434:11434"
networks:
- fox
queue:
build:
context: .
dockerfile: docker/php/Dockerfile
container_name: fox-queue
restart: unless-stopped
working_dir: /var/www
command: php artisan queue:work --tries=3 --timeout=120 --sleep=1
environment:
WATCHDOG_OWNER: "0"
volumes:
- ./:/var/www
- /var/run/docker.sock:/var/run/docker.sock
depends_on:
- app
- redis
- postgres
networks:
- fox
scheduler:
build:
context: .
dockerfile: docker/php/Dockerfile
container_name: fox-scheduler
restart: unless-stopped
working_dir: /var/www
command: php artisan schedule:work
environment:
WATCHDOG_OWNER: "0"
volumes:
- ./:/var/www
- /var/run/docker.sock:/var/run/docker.sock
depends_on:
- app
networks:
- fox
stats-broadcaster:
build:
context: .
dockerfile: docker/php/Dockerfile
container_name: fox-stats
restart: unless-stopped
working_dir: /var/www
command: php artisan fox:broadcast-stats --interval=5
environment:
WATCHDOG_OWNER: "0"
volumes:
- ./:/var/www
depends_on:
- app
- reverb
networks:
- fox
reverb:
build:
context: .
dockerfile: docker/php/Dockerfile
container_name: fox-reverb
restart: unless-stopped
working_dir: /var/www
command: php artisan reverb:start --host=0.0.0.0 --port=8080 --debug
environment:
WATCHDOG_OWNER: "0"
ports:
- "8080:8080"
volumes:
- ./:/var/www
depends_on:
- app
networks:
- fox
whisper:
image: ghcr.io/ggerganov/whisper.cpp:main
container_name: fox-whisper
restart: unless-stopped
volumes:
- ./storage/app/audio:/audio
- whisper-models:/models
entrypoint: ["sh", "-c", "tail -f /dev/null"]
networks:
- fox
searxng:
image: searxng/searxng:latest
container_name: fox-searxng
restart: unless-stopped
volumes:
- ./searxng:/etc/searxng:rw
networks:
- fox
vite:
build:
context: .
dockerfile: docker/php/Dockerfile
container_name: fox-vite
profiles: ["dev"]
working_dir: /var/www
command: sh -c "npm install --no-audit --no-fund && npm run dev -- --host 0.0.0.0"
environment:
VITE_DEV_HOST: ${VITE_DEV_HOST:-fox.pxo.at}
VITE_DEV_PORT: 5173
VITE_DEV_SCHEME: ${VITE_DEV_SCHEME:-https}
VITE_DEV_PUBLIC_PORT: ${VITE_DEV_PUBLIC_PORT:-443}
CHOKIDAR_USEPOLLING: "true"
ports:
- "5173:5173"
volumes:
- ./:/var/www
- vite-node-modules:/var/www/node_modules
networks:
- fox
volumes:
postgres-data:
redis-data:
ollama-data:
whisper-models:
vite-node-modules:
networks:
fox:
driver: bridge

87
docker/nginx/default.conf Normal file
View File

@ -0,0 +1,87 @@
server {
listen 80 default_server;
server_name _;
root /var/www/public;
index index.php index.html;
client_max_body_size 50M;
# Vite HMR WebSocket — Browser verbindet wss://domain:443/__vite_hmr
location = /__vite_hmr {
proxy_pass http://vite:5173;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
# Vite dev server: JS/CSS assets + HMR client
location ~* ^/(@vite|@id|@fs|__vite|node_modules|resources/(js|css)/) {
proxy_pass http://vite:5173;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Reverb WebSocket: Browser verbindet wss://host/app/<key>
# NPM terminiert TLS, fox-nginx leitet an reverb:8080 weiter (Docker-Network).
location /app/ {
proxy_pass http://reverb:8080;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
# Reverb HTTP-API (serverseitiges Publish ginge auch direkt über reverb:8080,
# aber so geht's einheitlich über die Domain).
location /apps/ {
proxy_pass http://reverb:8080;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location / {
try_files $uri $uri/ /index.php?$query_string;
}
location ~ \.php$ {
fastcgi_pass app:9000;
fastcgi_index index.php;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
fastcgi_param DOCUMENT_ROOT $realpath_root;
fastcgi_read_timeout 300s;
fastcgi_send_timeout 300s;
}
location /storage/ {
alias /var/www/storage/app/public/;
try_files $uri =404;
}
location /audio/ {
alias /var/www/storage/app/audio/;
try_files $uri =404;
types {
audio/wav wav;
audio/mpeg mp3;
}
default_type audio/wav;
add_header Cache-Control "public, max-age=300";
}
location ~ /\.(?!well-known).* {
deny all;
}
gzip on;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript;
}

64
docker/php/Dockerfile Normal file
View File

@ -0,0 +1,64 @@
FROM php:8.3-fpm-bookworm
ENV DEBIAN_FRONTEND=noninteractive
RUN apt-get update && apt-get install -y --no-install-recommends \
git curl unzip ca-certificates gnupg \
libpq-dev libzip-dev libonig-dev libxml2-dev libcurl4-openssl-dev \
libicu-dev libpng-dev libjpeg-dev libfreetype6-dev \
libsodium-dev pkg-config libssl-dev \
ffmpeg \
&& install -m 0755 -d /etc/apt/keyrings \
&& curl -fsSL https://download.docker.com/linux/debian/gpg | gpg --dearmor -o /etc/apt/keyrings/docker.gpg \
&& chmod a+r /etc/apt/keyrings/docker.gpg \
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/debian bookworm stable" > /etc/apt/sources.list.d/docker.list \
&& apt-get update \
&& apt-get install -y --no-install-recommends docker-ce-cli \
&& rm -rf /var/lib/apt/lists/*
# Piper TTS Binary (rhasspy/piper) - kein apt-Paket, GitHub-Release verwenden
RUN curl -L https://github.com/rhasspy/piper/releases/download/v1.2.0/piper_amd64.tar.gz \
| tar -xz -C /usr/local/bin --strip-components=1 \
&& chmod +x /usr/local/bin/piper
RUN docker-php-ext-configure gd --with-freetype --with-jpeg \
&& docker-php-ext-install -j"$(nproc)" \
pdo pdo_pgsql pgsql \
bcmath intl zip gd opcache exif pcntl sodium
RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
&& apt-get install -y nodejs \
&& rm -rf /var/lib/apt/lists/*
RUN pecl install redis && docker-php-ext-enable redis
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
ARG WWWUSER=1000
ARG WWWGROUP=1000
RUN groupadd -g ${WWWGROUP} fox && useradd -u ${WWWUSER} -g fox -m -s /bin/bash fox
WORKDIR /var/www
# Sticky-Bit auf /tmp damit tempnam() / Logging immer funktioniert
RUN mkdir -p /tmp && chmod 1777 /tmp
# storage + bootstrap/cache an www-data übergeben (Build-time; entrypoint.sh
# wiederholt das zur Laufzeit, weil Bind-Mounts den Build-State überschreiben)
RUN mkdir -p /var/www/storage/framework/cache/data \
/var/www/storage/framework/sessions \
/var/www/storage/framework/views \
/var/www/storage/logs \
/var/www/storage/app/audio \
/var/www/storage/app/public \
/var/www/storage/app/voices \
/var/www/bootstrap/cache \
&& chown -R www-data:www-data /var/www/storage /var/www/bootstrap/cache \
&& chmod -R 775 /var/www/storage /var/www/bootstrap/cache
COPY docker/php/php.ini /usr/local/etc/php/conf.d/zz-fox.ini
COPY docker/php/entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/entrypoint.sh
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
CMD ["php-fpm"]

51
docker/php/entrypoint.sh Normal file
View File

@ -0,0 +1,51 @@
#!/bin/sh
set -e
cd /var/www
# Sicherstellen, dass /tmp jederzeit beschreibbar ist (tempnam, Sessions, Logs)
chmod 1777 /tmp 2>/dev/null || true
# Storage + bootstrap/cache anlegen und auf www-data setzen
mkdir -p \
storage/framework/cache/data \
storage/framework/sessions \
storage/framework/views \
storage/logs \
storage/app/audio \
storage/app/public \
storage/app/voices \
bootstrap/cache
# Log-Datei explizit anlegen + auf www-data setzen (Append-Mode-Fehler vermeiden)
touch storage/logs/laravel.log 2>/dev/null || true
chown -R www-data:www-data storage bootstrap/cache 2>/dev/null || true
chmod -R ug+rwX storage bootstrap/cache 2>/dev/null || true
# Docker-Socket Zugriff für www-data: socket-GID vom Host übernehmen
# damit `docker exec fox-whisper ...` aus PHP klappt.
if [ -S /var/run/docker.sock ]; then
SOCK_GID=$(stat -c '%g' /var/run/docker.sock 2>/dev/null || echo "")
if [ -n "$SOCK_GID" ]; then
if ! getent group "$SOCK_GID" >/dev/null 2>&1; then
groupadd -g "$SOCK_GID" dockerhost 2>/dev/null || addgroup -g "$SOCK_GID" dockerhost 2>/dev/null || true
fi
usermod -aG "$SOCK_GID" www-data 2>/dev/null || adduser www-data $(getent group "$SOCK_GID" | cut -d: -f1) 2>/dev/null || true
fi
fi
# Permission-Watchdog: storage drifet manchmal zurück auf User 1000 (z.B. wenn vom
# Host npm install läuft). Alle 10s nachziehen damit /fox/tts nicht 500't.
# Nur im app-Container laufen (sonst chown'en 4 Container parallel).
if [ "${WATCHDOG_OWNER:-1}" = "1" ]; then
(
while true; do
sleep 10
chown -R www-data:www-data /var/www/storage /var/www/bootstrap/cache 2>/dev/null || true
chmod -R ug+rwX /var/www/storage /var/www/bootstrap/cache 2>/dev/null || true
done
) &
fi
exec "$@"

15
docker/php/php.ini Normal file
View File

@ -0,0 +1,15 @@
memory_limit = 512M
upload_max_filesize = 50M
post_max_size = 50M
max_execution_time = 300
date.timezone = Europe/Berlin
opcache.enable = 1
opcache.enable_cli = 0
opcache.memory_consumption = 192
opcache.max_accelerated_files = 20000
opcache.validate_timestamps = 1
opcache.revalidate_freq = 0
; JIT off - kollidiert in PHP 8.3 mit Livewire/Reverb auf einigen Plattformen
opcache.jit = off
opcache.jit_buffer_size = 0

1
docker/postgres/init.sql Normal file
View File

@ -0,0 +1 @@
CREATE EXTENSION IF NOT EXISTS vector;

Some files were not shown because too many files have changed in this diff Show More