Fox initial commit
commit
e3ba405548
|
|
@ -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
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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."
|
||||||
|
|
||||||
|
%:
|
||||||
|
@:
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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'] ?? [])]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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'] ?? []),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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.",
|
||||||
|
]));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
{
|
||||||
|
//
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
|
@ -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,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',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
@ -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 (
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
App\Providers\AppServiceProvider::class,
|
||||||
|
];
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -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'),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
@ -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,
|
||||||
|
];
|
||||||
|
|
@ -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'],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
@ -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_'),
|
||||||
|
];
|
||||||
|
|
@ -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],
|
||||||
|
];
|
||||||
|
|
@ -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'),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
@ -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'),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
@ -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',
|
||||||
|
];
|
||||||
|
|
@ -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],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
@ -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'),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
@ -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'),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
@ -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',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
@ -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),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
@ -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),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
@ -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),
|
||||||
|
];
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
'paths' => [resource_path('views')],
|
||||||
|
'compiled' => env('VIEW_COMPILED_PATH', realpath(storage_path('framework/views'))),
|
||||||
|
];
|
||||||
|
|
@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -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');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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"]
|
||||||
|
|
@ -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 "$@"
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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
Loading…
Reference in New Issue