434 lines
17 KiB
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));
|
|
}
|
|
}
|