get('support_chat_enabled'); $enabled = is_null($flag) ? (bool) config('app.support_chat_enabled', env('SUPPORT_CHAT_ENABLED', true)) : (bool) $flag; if (!$enabled) { abort(503, 'Support chat is currently unavailable.'); } } private function isChatEnabled(): bool { $flag = cache()->get('support_chat_enabled'); return is_null($flag) ? (bool) config('app.support_chat_enabled', env('SUPPORT_CHAT_ENABLED', true)) : (bool) $flag; } private function sessionState(Request $request): array { $state = $request->session()->get(self::SESSION_KEY, [ 'thread_id' => null, 'status' => 'new', 'topic' => null, 'messages' => [], 'data_access_granted' => false, ]); if (!empty($state['thread_id'])) { $cached = cache()->get('support_threads:'.$state['thread_id']); if (is_array($cached)) { $state['status'] = $cached['status'] ?? $state['status']; foreach ($cached['messages'] ?? [] as $msg) { if (($msg['sender'] ?? '') === 'agent') { $state['status'] = 'agent'; break; } } if (count($cached['messages'] ?? []) > count($state['messages'])) { $state['messages'] = $cached['messages']; } } } return $state; } private function saveState(Request $request, array $state): void { $state['messages'] = array_slice($state['messages'], -100); $state['updated_at'] = now()->toIso8601String(); $request->session()->put(self::SESSION_KEY, $state); $this->persistThread($request, $state); } private function persistThread(Request $request, array $state): void { if (empty($state['thread_id'])) return; $user = $request->user(); // Construct user data safely without assuming local DB columns exist if Auth is mocked $userData = null; if ($user) { $userData = [ 'id' => $user->id ?? 'guest', 'username' => $user->username ?? $user->name ?? 'Guest', 'email' => $user->email ?? '', 'avatar_url' => $user->avatar_url ?? $user->profile_photo_url ?? null, ]; } $record = [ 'id' => $state['thread_id'], 'status' => $state['status'] ?? 'new', 'topic' => $state['topic'] ?? null, 'user' => $userData, 'messages' => $state['messages'] ?? [], 'updated_at' => now()->toIso8601String(), ]; // Cache is file/redis based, so this is fine (no SQL DB) cache()->put('support_threads:'.$state['thread_id'], $record, now()->addDay()); $index = cache()->get('support_threads_index', []); $found = false; foreach ($index as &$row) { if (($row['id'] ?? null) === $record['id']) { $row['updated_at'] = $record['updated_at']; $row['status'] = $record['status']; $row['topic'] = $record['topic']; $row['user'] = $record['user']; $found = true; break; } } if (!$found) { $index[] = [ 'id' => $record['id'], 'updated_at' => $record['updated_at'], 'status' => $record['status'], 'topic' => $record['topic'], 'user' => $record['user'], ]; } cache()->put('support_threads_index', $index, now()->addDay()); } public function start(Request $request) { // ensureEnabled removed here to allow handoff logic $user = $request->user(); abort_unless($user, 401); $data = $request->validate(['topic' => 'nullable|string|max:60']); $enabled = $this->isChatEnabled(); $state = $this->sessionState($request); if (!$state['thread_id']) { $state['thread_id'] = (string) Str::uuid(); $state['status'] = $enabled ? 'ai' : 'handoff'; $state['topic'] = $data['topic'] ?? null; $state['messages'] = []; $state['data_access_granted'] = false; $state['user'] = [ 'id' => $user->id, 'username' => $user->username ?? $user->name, 'email' => $user->email ]; if (!empty($data['topic'])) { $state['messages'][] = ['id' => Str::uuid()->toString(), 'sender' => 'user', 'body' => 'Thema gewählt: ' . $data['topic'], 'at' => now()->toIso8601String()]; if ($enabled) { $aiReply = $this->askOllama($state); if ($aiReply) { $state['messages'][] = ['id' => Str::uuid()->toString(), 'sender' => 'ai', 'body' => $aiReply, 'at' => now()->toIso8601String()]; } else { // Ollama offline or returned nothing — fall back to human handoff $state['status'] = 'handoff'; $state['messages'][] = ['id' => Str::uuid()->toString(), 'sender' => 'system', 'body' => 'Unser KI-Assistent ist gerade nicht erreichbar. Ein Mitarbeiter wird sich in Kürze bei dir melden.', 'at' => now()->toIso8601String()]; } } else { $state['messages'][] = ['id' => Str::uuid()->toString(), 'sender' => 'system', 'body' => 'Der KI-Assistent ist derzeit inaktiv. Ein Mitarbeiter wird sich in Kürze bei dir melden.', 'at' => now()->toIso8601String()]; } } } $this->saveState($request, $state); return response()->json($state); } public function close(Request $request) { abort_unless(Auth::check(), 401); $state = $this->sessionState($request); if (!empty($state['thread_id'])) { cache()->forget('support_threads:'.$state['thread_id']); $index = cache()->get('support_threads_index', []); $index = array_filter($index, fn($item) => $item['id'] !== $state['thread_id']); cache()->put('support_threads_index', array_values($index), now()->addDay()); } $request->session()->forget(self::SESSION_KEY); return response()->json(['thread_id' => null, 'status' => 'closed', 'topic' => null, 'messages' => []]); } public function message(Request $request) { $user = $request->user(); abort_unless($user, 401); $data = $request->validate(['text' => 'required|string|min:1|max:1000']); $text = trim($data['text']); $state = $this->sessionState($request); $enabled = $this->isChatEnabled(); if (!$state['thread_id'] || ($state['status'] ?? null) === 'closed') { return response()->json(['error' => 'No active chat thread.'], 422); } $state['messages'][] = ['id' => Str::uuid()->toString(), 'sender' => 'user', 'body' => $text, 'at' => now()->toIso8601String()]; if (!$enabled) { $lastMsg = collect($state['messages'])->where('sender', 'system')->last(); if (!$lastMsg || !Str::contains($lastMsg['body'], 'Mitarbeiter')) { $state['status'] = 'handoff'; $state['messages'][] = ['id' => Str::uuid()->toString(), 'sender' => 'system', 'body' => 'Deine Nachricht wurde empfangen. Bitte warte auf einen Mitarbeiter.', 'at' => now()->toIso8601String()]; } $this->saveState($request, $state); return response()->json($state); } if (Str::of(Str::lower($text))->contains(['ja', 'yes', 'erlaubt', 'okay', 'ok', 'zugriff'])) { $lastSystemMsg = collect($state['messages'])->where('sender', 'system')->last(); if ($lastSystemMsg && Str::contains($lastSystemMsg['body'], 'zugreifen')) { $state['data_access_granted'] = true; $state['messages'][] = ['id' => Str::uuid()->toString(), 'sender' => 'system', 'body' => 'Zugriff erlaubt. Ich sehe mir deine Daten an...', 'at' => now()->toIso8601String()]; $aiReply = $this->askOllama($state); if ($aiReply) { $state['messages'][] = ['id' => Str::uuid()->toString(), 'sender' => 'ai', 'body' => $aiReply, 'at' => now()->toIso8601String()]; } $this->saveState($request, $state); return response()->json($state); } } if (Str::contains($text, ['🛑', ':stop:', 'STOP'])) { $state['status'] = 'stopped'; $state['messages'][] = ['id' => Str::uuid()->toString(), 'sender' => 'system', 'body' => 'Verstanden. Soll ein Mitarbeiter übernehmen?', 'at' => now()->toIso8601String()]; $this->saveState($request, $state); return response()->json($state); } if ($state['status'] === 'stopped' && Str::of(Str::lower($text))->contains(['ja', 'yes', 'y'])) { return $this->handoff($request); } if (in_array($state['status'], ['handoff', 'agent'])) { $this->saveState($request, $state); return response()->json($state); } $aiReply = $this->askOllama($state); if ($aiReply && (str_contains($aiReply, '[HANDOFF]') || trim($aiReply) === '[HANDOFF]')) { return $this->handoff($request); } if ($aiReply && (str_contains($aiReply, '[REQUEST_DATA]') || trim($aiReply) === '[REQUEST_DATA]')) { $state['messages'][] = ['id' => Str::uuid()->toString(), 'sender' => 'system', 'body' => 'Um dir besser helfen zu können, müsste ich auf deine Kontodaten (Wallet, Boni, etc.) zugreifen. Ist das in Ordnung? (Antworte mit "Ja")', 'at' => now()->toIso8601String()]; $this->saveState($request, $state); return response()->json($state); } if ($aiReply) { $state['messages'][] = ['id' => Str::uuid()->toString(), 'sender' => 'ai', 'body' => $aiReply, 'at' => now()->toIso8601String()]; } else { $state['messages'][] = ['id' => Str::uuid()->toString(), 'sender' => 'system', 'body' => 'Ich konnte gerade keine Antwort generieren. Bitte versuche es erneut oder stoppe die KI mit 🛑.', 'at' => now()->toIso8601String()]; } $this->saveState($request, $state); return response()->json($state); } private function askOllama(array $state): ?string { $host = rtrim(env('OLLAMA_HOST', 'http://127.0.0.1:11434'), '/'); $model = env('OLLAMA_MODEL', 'llama3'); if (!$host) return null; $topic = $state['topic'] ? (string) $state['topic'] : 'Allgemeiner Support'; $hasAccess = $state['data_access_granted'] ?? false; $system = "Du bist ein hilfsbereiter Casino-Support-Assistent. Antworte knapp, freundlich und in deutscher Sprache. Frage maximal eine Rückfrage gleichzeitig. Wenn du eine Frage nicht beantworten kannst oder der Nutzer frustriert wirkt, antworte NUR mit dem Wort `[HANDOFF]`. Thema: {$topic}."; if (!$hasAccess) { $system .= "\n\nWICHTIG: Du hast AKTUELL KEINEN ZUGRIFF auf Nutzerdaten. Wenn der Nutzer nach persönlichen Informationen fragt (z.B. E-Mail, Guthaben, Boni, Transaktionen), antworte SOFORT und AUSSCHLIESSLICH mit dem Wort `[REQUEST_DATA]`. Erkläre nichts, entschuldige dich nicht. Nur `[REQUEST_DATA]`."; } else { $contextJson = ''; try { // REPLACED DB CALLS WITH API CALLS $u = Auth::user(); if ($u) { $apiBase = config('app.api_url'); // We assume the user is authenticated via a token or session that is passed along. // Since we are in a local environment acting as a client, we might need to forward the token. // For now, we assume the API endpoints are protected and we need a way to call them. // If Auth::user() works, it means we have a session. // Fetch data from external API // Note: In a real microservice setup, we would pass the user's token. // Here we try to fetch from the API using the configured base URL. // Example: Fetch Wallets $walletsResp = Http::acceptJson()->get($apiBase . '/user/wallets'); $wallets = $walletsResp->ok() ? $walletsResp->json() : []; // Example: Fetch Bonuses $bonusesResp = Http::acceptJson()->get($apiBase . '/user/bonuses/active'); $activeBonuses = $bonusesResp->ok() ? $bonusesResp->json() : []; $ctx = [ 'user' => [ 'username' => $u->username ?? $u->name, 'email' => $u->email, 'id' => $u->id ], 'wallets' => $wallets, 'active_bonuses' => $activeBonuses, ]; $contextJson = json_encode($ctx, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT); } } catch (\Throwable $e) { // Fallback if API calls fail \Illuminate\Support\Facades\Log::error('Failed to fetch remote context: ' . $e->getMessage()); } if ($contextJson) { $system .= "\n\nNutzerdaten (ZUGRIFF ERLAUBT):\n" . $contextJson . "\n\nDU DARFST DIESE DATEN JETZT VERWENDEN. Die Daten im JSON beziehen sich AUSSCHLIESSLICH auf den aktuellen Gesprächspartner. Wenn der Nutzer nach seinen EIGENEN Daten fragt (z.B. 'meine E-Mail', 'mein Guthaben'), antworte ihm direkt mit den Werten aus dem JSON. Wenn der Nutzer nach den Daten einer ANDEREN Person fragt (z.B. 'die E-Mail von Bingo'), musst du die Anfrage ablehnen und antworten, dass du nur Auskunft über sein eigenes Konto geben darfst."; } } $recent = array_slice($state['messages'], -10); $chatText = $system."\n\n"; foreach ($recent as $m) { $role = $m['sender'] === 'user' ? 'User' : ucfirst($m['sender']); $chatText .= "{$role}: {$m['body']}\n"; } $chatText .= "Assistant:"; try { $res = Http::timeout(12)->post($host.'/api/generate', [ 'model' => $model, 'prompt' => $chatText, 'stream' => false, 'options' => ['temperature' => 0.3, 'num_predict' => 180], ]); if (!$res->ok()) return null; $json = $res->json(); $out = trim((string)($json['response'] ?? '')); return $out ?: null; } catch (\Throwable $e) { return null; } } public function status(Request $request) { return response()->json($this->sessionState($request)); } public function stop(Request $request) { abort_unless(Auth::check(), 401); $state = $this->sessionState($request); if (!$state['thread_id'] || ($state['status'] ?? null) === 'closed') { return response()->json(['error' => 'No active chat thread.'], 422); } if ($state['status'] === 'ai') { $state['status'] = 'stopped'; $state['messages'][] = [ 'id' => Str::uuid()->toString(), 'sender' => 'system', 'body' => 'KI-Assistent gestoppt. Du kannst einen Mitarbeiter anfordern.', 'at' => now()->toIso8601String(), ]; $this->saveState($request, $state); } return response()->json($state); } public function handoff(Request $request) { $this->ensureEnabled(); abort_unless(Auth::check(), 401); $state = $this->sessionState($request); if (!$state['thread_id']) return response()->json(['message' => 'No active support thread'], 422); $state['status'] = 'handoff'; $state['messages'][] = ['id' => Str::uuid()->toString(), 'sender' => 'system', 'body' => 'Ich leite dich an einen Mitarbeiter weiter. Bitte habe einen Moment Geduld.', 'at' => now()->toIso8601String()]; $this->saveState($request, $state); $webhook = env('SUPPORT_DASHBOARD_WEBHOOK_URL'); if ($webhook) { try { Http::timeout(5)->post($webhook, ['event' => 'support.handoff', 'thread_id' => $state['thread_id']]); } catch (\Throwable $e) {} } return response()->json(['message' => 'Hand-off requested', 'state' => $state]); } public function stream(Request $request) { $state = $this->sessionState($request); if (empty($state['thread_id'])) return response('', 204); $threadId = $state['thread_id']; return new StreamedResponse(function () use ($request, $threadId) { $send = fn($data) => print('data: ' . json_encode($data) . "\n\n"); $start = time(); $lastUpdated = null; $lastCount = -1; while (time() - $start < 60) { usleep(500000); $cached = cache()->get('support_threads:'.$threadId); $nowState = is_array($cached) ? $cached : $this->sessionState($request); $count = count($nowState['messages'] ?? []); $updated = $nowState['updated_at'] ?? null; if ($count !== $lastCount || $updated !== $lastUpdated) { $send($nowState); $lastCount = $count; $lastUpdated = $updated; } if ((time() - $start) % 15 === 0) { print(": ping\n\n"); } @ob_flush(); @flush(); } }, 200, ['Content-Type' => 'text/event-stream', 'Cache-Control' => 'no-cache', 'Connection' => 'keep-alive', 'X-Accel-Buffering' => 'no']); } }