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