aziros/src/app/Livewire/Agent/Index.php

434 lines
17 KiB
PHP

<?php
namespace App\Livewire\Agent;
use App\Models\Activity;
use App\Models\AgentLog;
use App\Models\Contact;
use App\Models\Event;
use App\Models\Note;
use App\Models\Task;
use App\Services\AgentActionService;
use App\Services\AgentAIService;
use App\Services\AgentContextService;
use Carbon\Carbon;
use Livewire\Component;
class Index extends Component
{
public string $message = '';
public $user;
public $subscription;
public $plan;
public int $usage = 0;
public int $limit = 0;
public int $usagePercent = 0;
public ?array $lastAction = null;
public array $conversation = [];
// Token-Tracking über die gesamte Konversation hinweg
public int $conversationPromptTokens = 0;
public int $conversationCompletionTokens = 0;
public int $conversationTotalTokens = 0;
public float $conversationCostUsd = 0;
protected $listeners = [
'agent:result' => '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));
}
}