'handleAgentResult', ]; public function mount(): void { $this->user = auth()->user(); $this->subscription = $this->user->subscription()->with('plan')->first() ?? $this->user->latestSubscription()->with('plan')->first(); $this->plan = $this->subscription?->plan; $this->limit = $this->user->effective_limit; $this->recalcUsage(); } public function send(string $text = '', bool $withAudio = false): ?array { $userMessage = trim($text ?: $this->message); if (!$userMessage) return null; $this->reset('message'); $hardLimit = (int) ceil($this->limit * 1.05); // Stufe 1: Hart blockieren ab 105% if ($this->limit > 0 && $this->usage >= $hardLimit) { $this->dispatch('notify', [ 'type' => 'error', 'title' => 'Limit erreicht', 'message' => 'Dein monatliches Kontingent ist aufgebraucht. Upgrade auf Pro oder warte bis zum nächsten Monat.', ]); return null; } // Stufe 2: Warnung bei 100%-105% — Anfrage läuft durch if ($this->limit > 0 && $this->usage >= $this->limit) { $this->dispatch('notify', [ 'type' => 'warning', 'title' => 'Fast aufgebraucht', 'message' => 'Du nutzt deine Toleranzreserve. Dein Kontingent ist bald erschöpft.', ]); } $startTime = microtime(true); $aiConfig = $this->plan?->ai_config ?? []; if (is_string($aiConfig)) { $aiConfig = json_decode($aiConfig, true); } $model = $aiConfig['model'] ?? 'gpt-4o-mini'; $historyForAI = array_slice($this->conversation, -8); $messagesForAI = array_merge($historyForAI, [ ['role' => 'user', 'content' => $userMessage], ]); // User-Kontext (geteilter Service → Web + API identisch) $userContext = app(AgentContextService::class)->build($this->user); \Log::info('UserContext Länge: ' . str_word_count($userContext) . ' Wörter, ' . strlen($userContext) . ' Zeichen'); $parsed = AgentAIService::chat($messagesForAI, $aiConfig, $userContext); $usage = $parsed['_usage'] ?? []; $this->conversationPromptTokens += $usage['prompt_tokens'] ?? 0; $this->conversationCompletionTokens += $usage['completion_tokens'] ?? 0; $this->conversationTotalTokens += $usage['total_tokens'] ?? 0; $this->conversationCostUsd += $this->calculateCost($usage, $model); try { // ── Multi-Action: Array von Aktionen ────────────────────────── if (isset($parsed['_multi'])) { $actions = $parsed['_multi']; $results = []; $messages = []; foreach ($actions as $action) { if (!isset($action['type'])) continue; $result = AgentActionService::handle($this->user, $action); $results[] = $result; if ($result['status'] === 'success') { $messages[] = $result['message'] ?? 'Erledigt'; if (in_array($action['type'], ['event', 'event_update'])) { $this->dispatch('eventCreated'); } } } $duration = round((microtime(true) - $startTime) * 1000); $credits = $this->calculateCredits(['type' => 'multi'], $duration, $usage); $combinedResult = [ 'status' => 'success', 'message' => implode(' | ', $messages) ?: 'Erledigt!', 'meta' => ['actions' => count($actions)], ]; $this->logConversationAction($userMessage, ['type' => 'multi'], $combinedResult, $model, $duration, $credits); $assistantMsg = implode('. ', $messages) . '. Kann ich noch etwas für dich tun?'; $this->conversation[] = ['role' => 'user', 'content' => $userMessage]; $this->conversation[] = ['role' => 'assistant', 'content' => $assistantMsg]; if (str_contains($assistantMsg, '[END]')) { $this->dispatch('conversation-ended'); } $this->applyResult($combinedResult); $this->dispatch('agent:sent'); } elseif (($parsed['type'] ?? 'unknown') === 'chat') { // ── Chat-Antwort: Log nur bei [END] ohne vorherige Aktion ── $chatMessage = $parsed['data']['message'] ?? 'Hmm, da bin ich überfragt.'; $this->conversation[] = ['role' => 'user', 'content' => $userMessage]; $this->conversation[] = ['role' => 'assistant', 'content' => $chatMessage]; if (str_contains($chatMessage, '[END]')) { // Gab es in dieser Session bereits eine geloggte Aktion? $hadAction = collect($this->conversation) ->where('role', 'assistant') ->filter(fn($msg) => !str_contains($msg['content'], '[END]')) ->count() > 1; if (!$hadAction) { $duration = round((microtime(true) - $startTime) * 1000); $this->logConversationAction( collect($this->conversation) ->where('role', 'user') ->first()['content'] ?? $userMessage, ['type' => 'chat', 'data' => []], ['status' => 'success', 'message' => 'Chat', 'meta' => []], $model, $duration, 5 ); } $this->dispatch('conversation-ended'); } $this->lastAction = null; } else { // ── Einzelne Aktion (event, note, task) ─────────────────────── $result = AgentActionService::handle($this->user, $parsed); $duration = round((microtime(true) - $startTime) * 1000); // Mehrdeutig → AI soll nachfragen (als Chat-Antwort behandeln) if ($result['status'] === 'ambiguous') { $this->conversation[] = ['role' => 'user', 'content' => $userMessage]; $this->conversation[] = ['role' => 'assistant', 'content' => $result['message']]; $this->lastAction = null; } elseif ($result['status'] === 'conflict') { $this->logConversationAction($userMessage, $parsed, $result, $model, $duration, 0); $this->conversation[] = ['role' => 'user', 'content' => $userMessage]; $this->conversation[] = ['role' => 'assistant', 'content' => 'Es gibt einen Terminkonflikt. Ich zeige dir die Details.']; $this->dispatch('openModal', 'agent.modals.conflict-modal', [ 'data' => $parsed['data'], 'meta' => $result['meta'], 'originalInput' => $userMessage, ]); $this->dispatch('agent:sent'); } else { $credits = $this->calculateCredits($parsed, $duration, $usage); $this->logConversationAction($userMessage, $parsed, $result, $model, $duration, $credits); if ($result['status'] === 'success' && ($parsed['type'] ?? '') === 'event') { $this->dispatch('eventCreated'); Activity::log( $this->user->id, Activity::TYPE_EVENT_CREATED, Activity::localizedTitle('event_created_assistant', $this->user->locale ?? 'de'), $result['meta']['title'] ?? null, meta: [ 'start' => $result['meta']['start'] ?? null, 'credits' => $credits, 'duration' => $result['meta']['duration'] ?? null, ] ); } // Natürliche Bestätigung $confirmMsg = $result['status'] === 'success' ? ($result['message'] ?? 'Erledigt!') . ' Kann ich noch etwas für dich tun?' : ($result['message'] ?? 'Da hat etwas nicht geklappt.'); $this->conversation[] = ['role' => 'user', 'content' => $userMessage]; $this->conversation[] = ['role' => 'assistant', 'content' => $confirmMsg]; if (str_contains($confirmMsg, '[END]')) { $this->dispatch('conversation-ended'); } $this->applyResult($result); } } } catch (\Throwable $e) { $duration = round((microtime(true) - $startTime) * 1000); $this->logConversationAction($userMessage, $parsed, [ 'status' => 'failed', 'meta' => ['error' => $e->getMessage()], ], $model, $duration, 0); $this->conversation[] = ['role' => 'user', 'content' => $userMessage]; $this->conversation[] = ['role' => 'assistant', 'content' => 'Da ist leider etwas schiefgelaufen.']; $this->applyResult([ 'status' => 'failed', 'message' => 'Fehler bei der Verarbeitung', 'meta' => [], ]); } $this->dispatch('agent:sent'); // TTS im selben Request mitsynthetisieren (spart einen Round-Trip) if ($withAudio && !empty($this->conversation)) { $lastMsg = end($this->conversation); if ($lastMsg['role'] === 'assistant') { $spokenText = trim(str_replace('[END]', '', $lastMsg['content'])); if ($spokenText) { $audio = AgentAIService::textToSpeech($spokenText, $aiConfig); return ['audio' => $audio]; } } } return null; } public function synthesize(string $text): array { $aiConfig = $this->plan?->ai_config ?? []; if (is_string($aiConfig)) { $aiConfig = json_decode($aiConfig, true); } $audio = AgentAIService::textToSpeech($text, $aiConfig); return ['audio' => $audio]; } public function clearConversation(): void { $this->conversation = []; $this->lastAction = null; $this->conversationPromptTokens = 0; $this->conversationCompletionTokens = 0; $this->conversationTotalTokens = 0; $this->conversationCostUsd = 0; } public function handleAgentResult(array $result): void { $this->applyResult($result); } public function render() { return view('livewire.agent.index')->layout('layouts.app'); } // ── Private ─────────────────────────────────────────────────────────── private function extractChatTitle(string $userMessage): string { $farewells = [ 'nein danke', 'nein', 'danke', 'passt', 'das wars', 'ok danke', 'okay danke', 'super danke', 'alles klar', 'nichts mehr', 'tschüss', 'bye', 'ciao', ]; $isGoodbye = in_array(strtolower(trim($userMessage)), $farewells); if ($isGoodbye && count($this->conversation) > 0) { foreach ($this->conversation as $msg) { if ($msg['role'] === 'user') { return mb_substr($msg['content'], 0, 80); } } } return mb_substr($userMessage, 0, 80); } private function logConversationAction( string $userMessage, array $parsed, array $result, string $model, int $duration, int $credits, ): void { $input = match ($parsed['type']) { 'event', 'event_update' => $parsed['data']['title'] ?? $userMessage, 'note', 'note_update' => mb_substr($parsed['data']['content'] ?? $userMessage, 0, 100), 'task', 'task_update' => $parsed['data']['title'] ?? $userMessage, 'contact' => $parsed['data']['name'] ?? $userMessage, 'email' => 'E-Mail an ' . ($parsed['data']['contact'] ?? '') . ': ' . mb_substr($parsed['data']['subject'] ?? '', 0, 50), 'multi' => $userMessage, 'chat' => $this->extractChatTitle($userMessage), default => mb_substr($userMessage, 0, 200), }; AgentLog::create([ 'user_id' => $this->user->id, 'type' => $parsed['type'], 'input' => $input, 'status' => $result['status'], 'output' => $result['meta'] ?? null, 'credits' => $credits, 'ai_response' => $parsed, 'model' => $model, 'duration_ms' => $duration, 'prompt_tokens' => $this->conversationPromptTokens, 'completion_tokens' => $this->conversationCompletionTokens, 'total_tokens' => $this->conversationTotalTokens, 'cost_usd' => $this->conversationCostUsd, ]); $this->recalcUsage(); if ($this->limit > 0 && $this->usage >= $this->limit) { $this->dispatch('notify', [ 'type' => 'warning', 'title' => 'Kontingent aufgebraucht', 'message' => 'Dein Kontingent ist erschöpft. Upgrade auf Pro oder warte bis zum nächsten Monat.', ]); } } private function applyResult(array $result): void { $this->lastAction = [ 'status' => $result['status'], 'message' => $result['message'], 'meta' => $result['meta'] ?? [], 'time' => now(), ]; $this->recalcUsage(); } private function recalcUsage(): void { $this->user->refresh(); if ($this->user->effective_limit === 0) { $this->usage = 0; $this->usagePercent = 0; $this->limit = 0; return; } $this->limit = $this->user->effective_limit; $this->usage = $this->user->effective_usage; $this->usagePercent = $this->user->usage_percent; } private function calculateCost(array $usage, string $model): float { $aiConfig = $this->plan?->ai_config ?? []; if (is_string($aiConfig)) { $aiConfig = json_decode($aiConfig, true); } $inputCost = ($usage['prompt_tokens'] ?? 0) / 1000 * ($aiConfig['input_cost'] ?? 0.00015); $outputCost = ($usage['completion_tokens'] ?? 0) / 1000 * ($aiConfig['output_cost'] ?? 0.0006); return round($inputCost + $outputCost, 6); } private function calculateCredits(array $parsed, int $duration, array $usage): int { // Flat-Basis + Output-Tokens. Kontext (prompt_tokens) wird ignoriert, // damit Credits nicht mit Kalendergröße skalieren. Cap bei 100. $completionTokens = (int) ($usage['completion_tokens'] ?? 0); $credits = 20 + (int) ceil($completionTokens * 0.3); return min(100, max(1, $credits)); } }