321 lines
12 KiB
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,
|
|
]);
|
|
}
|
|
}
|