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(<< 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.', ])); } // Desktop-Actions (Spotify, Mail, App, System) — für Electron Mac Agent if (in_array($action, [ 'spotify_play', 'spotify_pause', 'spotify_next', 'spotify_prev', 'mail_open', 'open_app', 'screenshot', 'save_desktop', 'volume_up', 'volume_down', ], true)) { broadcast(new FoxAction([ 'action' => $action, 'text' => $reply['text'] ?? '', 'app' => $reply['app'] ?? null, ])); } $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 */ protected function resolveTools(): array { return [ app(WebSearchTool::class)->asPrismTool(), app(BuildTemplateTool::class)->asPrismTool(), app(FileManagerTool::class)->asPrismTool(), ]; } }