aziros/src/app/Http/Controllers/Api/AgentChatController.php

321 lines
12 KiB
PHP

<?php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Models\AgentLog;
use App\Services\AgentAIService;
use App\Services\AgentActionService;
use App\Services\AgentContextService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class AgentChatController extends Controller
{
public function chat(Request $request): JsonResponse
{
$request->validate([
'message' => 'required|string|max:2000',
'conversation_history' => 'array',
'conversation_history.*.role' => 'required_with:conversation_history|in:user,assistant',
'conversation_history.*.content' => 'required_with:conversation_history|string',
'with_audio' => 'boolean',
]);
$user = $request->user();
$user->load('subscription.plan');
// Credit-Limit prüfen
if ($user->effective_limit > 0 && $user->monthly_usage >= $user->effective_limit * 1.05) {
return response()->json([
'success' => false,
'message' => 'Dein monatliches Credit-Limit ist erreicht.',
'errors' => ['credits' => 'Limit überschritten'],
], 429);
}
$aiConfig = $user->subscription?->plan?->ai_config ?? [
'model' => config('services.openai.model', 'gpt-4o-mini'),
'temperature' => 0.5,
'max_tokens' => 1500,
'input_cost' => 0.00015,
'output_cost' => 0.0006,
];
// Konversation aufbauen
$history = $request->input('conversation_history', []);
$history[] = ['role' => 'user', 'content' => $request->message];
// User-Kontext erstellen (geteilter Service → Web + API identisch)
$userContext = app(AgentContextService::class)->build($user);
$startTime = microtime(true);
// AI aufrufen
\Log::info('AgentChat: Calling AI', [
'user_id' => $user->id,
'message' => mb_substr($request->message, 0, 100),
'history_count' => count($history),
'model' => $aiConfig['model'] ?? 'default',
]);
try {
$aiResult = AgentAIService::chat($history, $aiConfig, $userContext);
\Log::info('AgentChat: AI result', [
'type' => $aiResult['type'] ?? 'unknown',
'has_message' => !empty($aiResult['data']['message'] ?? $aiResult['message'] ?? ''),
]);
} catch (\Throwable $e) {
\Log::error('AgentChat: AI exception', [
'error' => $e->getMessage(),
'class' => get_class($e),
]);
report($e);
return response()->json([
'success' => true,
'data' => [
'message' => 'Entschuldigung, ich konnte gerade nicht antworten. Bitte versuche es nochmal.',
'action' => null,
'type' => 'chat',
'credits_used' => 0,
],
]);
}
// _error-Flag: OpenAI API down oder Timeout — 0 Credits, Fehler loggen
if (!empty($aiResult['_error'])) {
$durationMs = (int) ((microtime(true) - $startTime) * 1000);
AgentLog::create([
'user_id' => $user->id,
'type' => 'chat',
'input' => mb_substr($request->message, 0, 100),
'status' => 'error',
'output' => null,
'credits' => 0,
'ai_response' => null,
'model' => $aiConfig['model'] ?? null,
'duration_ms' => $durationMs,
'prompt_tokens' => 0,
'completion_tokens' => 0,
'total_tokens' => 0,
'cost_usd' => 0,
]);
return response()->json([
'success' => true,
'data' => [
'message' => $aiResult['data']['message'] ?? 'Entschuldigung, bitte versuche es nochmal.',
'action' => null,
'type' => 'chat',
'credits_used' => 0,
'usage' => [
'credits_used' => $user->monthly_usage,
'credits_limit' => $user->effective_limit,
'usage_percent' => $user->usage_percent,
],
],
]);
}
// AgentAIService gibt direkt das parsed-Objekt zurück (kein 'parsed'-Wrapper)
// Keys: type, data, _usage, _multi (bei Multi-Actions)
$parsed = $aiResult;
$usage = $aiResult['_usage'] ?? [];
$durationMs = (int) ((microtime(true) - $startTime) * 1000);
$actionResult = null;
$assistantMessage = $parsed['data']['message']
?? $parsed['message']
?? $parsed['text']
?? '';
// Aktion ausführen falls vorhanden
try {
if (isset($parsed['_multi'])) {
$actionService = new AgentActionService();
$results = [];
foreach ($parsed['_multi'] as $action) {
$results[] = $actionService->handle($user, $action);
}
$actionResult = [
'status' => collect($results)->every(fn ($r) => $r['status'] === 'success') ? 'success' : 'partial',
'results' => $results,
];
} elseif (isset($parsed['type']) && $parsed['type'] !== 'chat') {
$actionService = new AgentActionService();
$actionResult = $actionService->handle($user, $parsed);
}
} catch (\Throwable $e) {
report($e);
$actionResult = ['status' => 'error', 'message' => 'Aktion fehlgeschlagen'];
}
// Wenn Aktion ausgeführt aber keine Message von der AI → Action-Message verwenden
if (empty($assistantMessage) && $actionResult) {
$assistantMessage = $actionResult['message'] ?? 'Erledigt!';
}
// Letzter Schutz: JSON darf NIE vorgelesen werden
if (is_string($assistantMessage) && $assistantMessage !== '') {
$trim = ltrim($assistantMessage);
$isJsonLike = $trim !== '' && ($trim[0] === '{' || $trim[0] === '[')
|| str_contains($assistantMessage, '"type":')
|| str_contains($assistantMessage, '"data":');
if ($isJsonLike) {
\Log::warning('AgentChat: JSON leaked to assistant message — replaced', [
'preview' => mb_substr($assistantMessage, 0, 200),
]);
$assistantMessage = $actionResult['message'] ?? 'Erledigt!';
}
}
// Credits berechnen — Flat-Rate-Logik
// - Aktionen: tokenbasiert (wie bisher)
// - Erster Chat einer Session (history leer): pauschal 5 Credits
// - Folge-Chat-Nachrichten: 0 Credits, kein Log
$type = $parsed['type'] ?? 'chat';
$isAction = $type !== 'chat';
$historyCount = count($request->input('conversation_history', []));
$shouldLog = true;
if ($isAction) {
$credits = (($actionResult['status'] ?? '') === 'error')
? 0
: $this->calculateCredits($usage, $aiConfig, $type);
} elseif ($historyCount === 0) {
$credits = 5;
} else {
$credits = 0;
$shouldLog = false;
}
if ($shouldLog) {
// Input für Log bestimmen (wie im Web)
$logInput = match ($type) {
'chat' => 'Konversation',
'event', 'event_update' => $parsed['data']['title'] ?? $request->message,
'note', 'note_update' => mb_substr($parsed['data']['content'] ?? $request->message, 0, 100),
'task', 'task_update' => $parsed['data']['title'] ?? $request->message,
'contact' => $parsed['data']['name'] ?? $request->message,
'email' => 'E-Mail an ' . ($parsed['data']['contact'] ?? '') . ': ' . mb_substr($parsed['data']['subject'] ?? '', 0, 50),
'multi' => $request->message,
default => mb_substr($request->message, 0, 200),
};
$logStatus = ($actionResult['status'] ?? 'success') === 'error' ? 'failed' : ($actionResult['status'] ?? 'success');
AgentLog::create([
'user_id' => $user->id,
'type' => $type,
'input' => $logInput,
'status' => $logStatus,
'output' => $actionResult['meta'] ?? $actionResult ?? null,
'credits' => $credits,
'ai_response' => $parsed,
'model' => $aiConfig['model'] ?? null,
'duration_ms' => $durationMs,
'prompt_tokens' => $usage['prompt_tokens'] ?? 0,
'completion_tokens' => $usage['completion_tokens'] ?? 0,
'total_tokens' => $usage['total_tokens'] ?? 0,
'cost_usd' => $this->calculateCostUsd($usage, $aiConfig),
]);
}
// TTS falls gewünscht (Fehler darf Chat nicht blockieren)
$audio = null;
if ($request->boolean('with_audio') && $assistantMessage) {
try {
$audio = AgentAIService::textToSpeech($assistantMessage, $aiConfig);
} catch (\Throwable $e) {
report($e);
}
}
// Usage neu berechnen
$user->refresh();
$responseData = [
'message' => $assistantMessage,
'action' => $actionResult,
'type' => $parsed['type'] ?? 'chat',
'credits_used' => $credits,
'usage' => [
'credits_used' => $user->monthly_usage,
'credits_limit' => $user->effective_limit,
'usage_percent' => $user->usage_percent,
],
];
if ($audio) {
$responseData['audio'] = $audio;
}
return response()->json([
'success' => true,
'data' => $responseData,
]);
}
public function synthesize(Request $request): JsonResponse
{
$request->validate([
'text' => 'required|string|max:500',
'locale' => 'nullable|string',
]);
$user = $request->user();
$aiConfig = $user->subscription?->plan?->ai_config ?? [];
if (is_string($aiConfig)) {
$aiConfig = json_decode($aiConfig, true);
}
// Voice je nach Locale wählen
$voice = str_starts_with($request->input('locale', 'de'), 'en')
? 'shimmer'
: 'nova';
$overrideConfig = array_merge($aiConfig, ['tts_voice' => $voice]);
$audio = AgentAIService::textToSpeech($request->text, $overrideConfig);
return response()->json([
'success' => true,
'data' => ['audio' => $audio],
]);
}
private function calculateCredits(array $usage, array $aiConfig, string $type): 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));
}
private function calculateCostUsd(array $usage, array $aiConfig): float
{
$inputCost = $aiConfig['input_cost'] ?? 0.00015;
$outputCost = $aiConfig['output_cost'] ?? 0.0006;
return (($usage['prompt_tokens'] ?? 0) / 1000) * $inputCost
+ (($usage['completion_tokens'] ?? 0) / 1000) * $outputCost;
}
public function logs(Request $request): JsonResponse
{
$logs = $request->user()
->agentLogs()
->latest()
->take(20)
->get(['id', 'type', 'input', 'output', 'status', 'credits', 'duration_ms', 'created_at']);
return response()->json([
'success' => true,
'data' => $logs,
]);
}
}