diff --git a/app/Filament/Tenant/Pages/AiAssistant.php b/app/Filament/Tenant/Pages/AiAssistant.php new file mode 100644 index 0000000..8c3b5ac --- /dev/null +++ b/app/Filament/Tenant/Pages/AiAssistant.php @@ -0,0 +1,112 @@ +id(); + $companyId = app(TenantManager::class)->currentId(); + if (! $userId || ! $companyId) return; + + $chat = AiChat::where('user_id', $userId)->latest('updated_at')->first(); + if (! $chat) { + $chat = AiChat::create([ + 'company_id' => $companyId, + 'user_id' => $userId, + 'title' => 'Conversație nouă', + 'provider' => $this->defaultProvider(), + ]); + } + $this->chatId = $chat->id; + } + + public function getChat(): ?AiChat + { + return $this->chatId ? AiChat::with('messages')->find($this->chatId) : null; + } + + public function getChats() + { + return AiChat::where('user_id', auth()->id()) + ->latest('updated_at') + ->limit(20) + ->get(); + } + + public function newChat(): void + { + $chat = AiChat::create([ + 'company_id' => app(TenantManager::class)->currentId(), + 'user_id' => auth()->id(), + 'title' => 'Conversație nouă', + 'provider' => $this->defaultProvider(), + ]); + $this->chatId = $chat->id; + $this->newMessage = ''; + } + + public function selectChat(int $id): void + { + $chat = AiChat::where('user_id', auth()->id())->where('id', $id)->first(); + if ($chat) $this->chatId = $chat->id; + } + + public function deleteChat(int $id): void + { + AiChat::where('user_id', auth()->id())->where('id', $id)->delete(); + if ($this->chatId === $id) { + $this->chatId = AiChat::where('user_id', auth()->id())->latest('updated_at')->value('id'); + if (! $this->chatId) $this->newChat(); + } + } + + public function send(): void + { + $msg = trim($this->newMessage); + if ($msg === '' || ! $this->chatId) return; + + $this->loading = true; + $this->newMessage = ''; + $chat = AiChat::find($this->chatId); + if (! $chat) { $this->loading = false; return; } + + try { + app(AiAssistantService::class)->ask($chat, $msg); + } finally { + $this->loading = false; + } + } + + protected function defaultProvider(): string + { + $tenant = app(TenantManager::class)->current(); + return ($tenant?->settings['ai']['default_provider'] ?? 'claude'); + } +} diff --git a/app/Filament/Tenant/Pages/Settings.php b/app/Filament/Tenant/Pages/Settings.php index 77693e1..1baa2ed 100644 --- a/app/Filament/Tenant/Pages/Settings.php +++ b/app/Filament/Tenant/Pages/Settings.php @@ -50,6 +50,10 @@ class Settings extends Page 'notify_payment' => $notify['payment'] ?? true, 'notify_appointment' => $notify['appointment'] ?? true, 'notify_reminder' => $notify['reminder'] ?? true, + 'ai_default_provider' => $settings['ai']['default_provider'] ?? 'claude', + 'ai_claude_key' => $settings['ai']['claude_key'] ?? null, + 'ai_gpt_key' => $settings['ai']['gpt_key'] ?? null, + 'ai_gemini_key' => $settings['ai']['gemini_key'] ?? null, ]); } @@ -120,6 +124,18 @@ class Settings extends Page Forms\Components\Toggle::make('notify_appointment')->label('Programare confirmată')->default(true), Forms\Components\Toggle::make('notify_reminder')->label('Reminder ITP / revizie')->default(true), ]), + Schemas\Components\Section::make('Asistent AI') + ->description('Adaugă chei API ca să activezi asistentul. Cheile rămân la voi — nu sunt partajate.') + ->columns(2) + ->schema([ + Forms\Components\Select::make('ai_default_provider') + ->label('Provider implicit') + ->options(['claude' => 'Claude (Anthropic)', 'gpt' => 'ChatGPT (OpenAI)', 'gemini' => 'Gemini (Google)']) + ->default('claude'), + Forms\Components\TextInput::make('ai_claude_key')->label('Claude API Key')->password()->revealable()->placeholder('sk-ant-...'), + Forms\Components\TextInput::make('ai_gpt_key')->label('OpenAI API Key')->password()->revealable()->placeholder('sk-proj-...'), + Forms\Components\TextInput::make('ai_gemini_key')->label('Gemini API Key')->password()->revealable(), + ]), ]) ->statePath('data'); } @@ -151,6 +167,12 @@ class Settings extends Page 'appointment' => (bool) ($data['notify_appointment'] ?? true), 'reminder' => (bool) ($data['notify_reminder'] ?? true), ], + 'ai' => [ + 'default_provider' => $data['ai_default_provider'] ?? 'claude', + 'claude_key' => $data['ai_claude_key'] ?? null, + 'gpt_key' => $data['ai_gpt_key'] ?? null, + 'gemini_key' => $data['ai_gemini_key'] ?? null, + ], ]), ]); diff --git a/app/Models/Tenant/AiChat.php b/app/Models/Tenant/AiChat.php new file mode 100644 index 0000000..40df561 --- /dev/null +++ b/app/Models/Tenant/AiChat.php @@ -0,0 +1,25 @@ +belongsTo(User::class); + } + + public function messages(): HasMany + { + return $this->hasMany(AiMessage::class)->orderBy('created_at'); + } +} diff --git a/app/Models/Tenant/AiMessage.php b/app/Models/Tenant/AiMessage.php new file mode 100644 index 0000000..e6290e0 --- /dev/null +++ b/app/Models/Tenant/AiMessage.php @@ -0,0 +1,21 @@ + 'array']; + + public function chat(): BelongsTo + { + return $this->belongsTo(AiChat::class, 'ai_chat_id'); + } +} diff --git a/app/Services/Ai/AiAssistantService.php b/app/Services/Ai/AiAssistantService.php new file mode 100644 index 0000000..7795503 --- /dev/null +++ b/app/Services/Ai/AiAssistantService.php @@ -0,0 +1,206 @@ + $chat->company_id, + 'ai_chat_id' => $chat->id, + 'role' => 'user', + 'content' => $userMessage, + ]); + + $company = Company::withoutGlobalScopes()->findOrFail($chat->company_id); + $aiCfg = (array) ($company->settings['ai'] ?? []); + $provider = $chat->provider ?: ($aiCfg['default_provider'] ?? 'claude'); + $apiKey = $aiCfg["{$provider}_key"] ?? null; + + $start = microtime(true); + + if (! $apiKey) { + $reply = '⚠️ API key pentru ' . strtoupper($provider) . ' nu e configurat. Mergi la Setări → AI și adaugă cheia.'; + $meta = ['error' => 'no_api_key']; + } else { + try { + [$reply, $meta] = match ($provider) { + 'claude' => $this->callClaude($apiKey, $chat, $userMessage, $company), + 'gpt' => $this->callOpenAI($apiKey, $chat, $userMessage, $company), + 'gemini' => $this->callGemini($apiKey, $chat, $userMessage, $company), + default => ['Provider necunoscut: ' . $provider, []], + }; + } catch (\Throwable $e) { + $reply = '❌ Eroare API: ' . $e->getMessage(); + $meta = ['error' => $e->getMessage()]; + } + } + + $meta['latency_ms'] = (int) ((microtime(true) - $start) * 1000); + $meta['provider'] = $provider; + + $msg = AiMessage::create([ + 'company_id' => $chat->company_id, + 'ai_chat_id' => $chat->id, + 'role' => 'assistant', + 'content' => $reply, + 'meta' => $meta, + ]); + + // Update chat title from first user message. + if ($chat->messages()->where('role', 'user')->count() === 1) { + $chat->update(['title' => mb_substr($userMessage, 0, 60)]); + } + $chat->touch(); + + return $msg; + } + + protected function buildSystemPrompt(Company $company): string + { + $clientsCount = Client::count(); + $vehiclesCount = Vehicle::count(); + $newLeads = Lead::where('status', 'new')->count(); + $openWO = WorkOrder::whereNotIn('status', ['done', 'cancelled'])->count(); + $debtTotal = (float) WorkOrder::where('pay_status', '!=', 'paid') + ->whereNotIn('status', ['cancelled']) + ->get()->sum(fn ($w) => max(0, (float) $w->total - (float) $w->payments()->sum('amount'))); + + return <<name}" din {$company->city}. +Răspunzi în română (sau în limba folosită de utilizator). Fii concis, profesional, util. + +Statistici curente: +- {$clientsCount} clienți, {$vehiclesCount} mașini +- {$newLeads} cereri noi neprocesate +- {$openWO} fișe de lucru active +- Total datorii clienți: {$debtTotal} MDL + +Poți ajuta cu: sugestii diagnostic auto, calcul costuri, sumar performanță, +recomandări piese, redactare mesaje pentru clienți, planificare. NU inventezi +date care nu apar în context — dacă nu știi, recunoaște. +TXT; + } + + /** Convert chat history to provider-specific message format. */ + protected function historyMessages(AiChat $chat, int $limit = 20): array + { + return $chat->messages() + ->latest('created_at') + ->limit($limit) + ->get() + ->reverse() + ->values() + ->map(fn (AiMessage $m) => ['role' => $m->role, 'content' => $m->content]) + ->all(); + } + + protected function callClaude(string $key, AiChat $chat, string $msg, Company $company): array + { + $messages = $this->historyMessages($chat); + // Anthropic requires alternating user/assistant; system is separate. + $messages = array_values(array_filter($messages, fn ($m) => in_array($m['role'], ['user', 'assistant'], true))); + + $r = Http::withHeaders([ + 'x-api-key' => $key, + 'anthropic-version' => '2023-06-01', + 'content-type' => 'application/json', + ]) + ->timeout(60) + ->post('https://api.anthropic.com/v1/messages', [ + 'model' => 'claude-sonnet-4-5', + 'max_tokens' => 1024, + 'system' => $this->buildSystemPrompt($company), + 'messages' => $messages, + ]); + + if (! $r->successful()) { + return ['❌ ' . ($r->json('error.message') ?? 'Anthropic API error ' . $r->status()), ['status' => $r->status()]]; + } + + $body = $r->json(); + $text = collect($body['content'] ?? []) + ->where('type', 'text') + ->pluck('text') + ->implode("\n"); + + return [$text ?: '(răspuns gol)', [ + 'model' => $body['model'] ?? null, + 'tokens_in' => $body['usage']['input_tokens'] ?? null, + 'tokens_out' => $body['usage']['output_tokens'] ?? null, + ]]; + } + + protected function callOpenAI(string $key, AiChat $chat, string $msg, Company $company): array + { + $messages = array_merge( + [['role' => 'system', 'content' => $this->buildSystemPrompt($company)]], + $this->historyMessages($chat), + ); + + $r = Http::withHeaders(['Authorization' => 'Bearer ' . $key, 'content-type' => 'application/json']) + ->timeout(60) + ->post('https://api.openai.com/v1/chat/completions', [ + 'model' => 'gpt-4o-mini', + 'messages' => $messages, + 'max_tokens' => 1024, + ]); + + if (! $r->successful()) { + return ['❌ ' . ($r->json('error.message') ?? 'OpenAI error ' . $r->status()), ['status' => $r->status()]]; + } + $body = $r->json(); + return [ + $body['choices'][0]['message']['content'] ?? '(răspuns gol)', + [ + 'model' => $body['model'] ?? null, + 'tokens_in' => $body['usage']['prompt_tokens'] ?? null, + 'tokens_out' => $body['usage']['completion_tokens'] ?? null, + ], + ]; + } + + protected function callGemini(string $key, AiChat $chat, string $msg, Company $company): array + { + $contents = []; + foreach ($this->historyMessages($chat) as $m) { + $contents[] = [ + 'role' => $m['role'] === 'assistant' ? 'model' : 'user', + 'parts' => [['text' => $m['content']]], + ]; + } + + $r = Http::withHeaders(['content-type' => 'application/json']) + ->timeout(60) + ->post('https://generativelanguage.googleapis.com/v1beta/models/gemini-1.5-flash:generateContent?key=' . $key, [ + 'systemInstruction' => ['parts' => [['text' => $this->buildSystemPrompt($company)]]], + 'contents' => $contents, + 'generationConfig' => ['maxOutputTokens' => 1024], + ]); + + if (! $r->successful()) { + return ['❌ Gemini error ' . $r->status() . ': ' . $r->body(), ['status' => $r->status()]]; + } + $body = $r->json(); + $text = $body['candidates'][0]['content']['parts'][0]['text'] ?? '(răspuns gol)'; + return [$text, [ + 'model' => 'gemini-1.5-flash', + 'tokens' => $body['usageMetadata'] ?? null, + ]]; + } +} diff --git a/database/migrations/2026_05_07_140001_create_ai_chats.php b/database/migrations/2026_05_07_140001_create_ai_chats.php new file mode 100644 index 0000000..2a6e31d --- /dev/null +++ b/database/migrations/2026_05_07_140001_create_ai_chats.php @@ -0,0 +1,40 @@ +id(); + $t->foreignId('company_id')->constrained()->cascadeOnDelete(); + $t->foreignId('user_id')->constrained()->cascadeOnDelete(); + $t->string('title')->default('Conversație nouă'); + $t->string('provider')->default('claude'); // claude / gpt / gemini + $t->timestamps(); + + $t->index(['company_id', 'user_id', 'updated_at']); + }); + + Schema::create('ai_messages', function (Blueprint $t) { + $t->id(); + $t->foreignId('company_id')->constrained()->cascadeOnDelete(); + $t->foreignId('ai_chat_id')->constrained()->cascadeOnDelete(); + $t->string('role'); // system / user / assistant + $t->longText('content'); + $t->json('meta')->nullable(); // tokens, model, latency_ms + $t->timestamps(); + + $t->index(['company_id', 'ai_chat_id', 'created_at']); + }); + } + + public function down(): void + { + Schema::dropIfExists('ai_messages'); + Schema::dropIfExists('ai_chats'); + } +}; diff --git a/resources/views/filament/tenant/pages/ai-assistant.blade.php b/resources/views/filament/tenant/pages/ai-assistant.blade.php new file mode 100644 index 0000000..960a64c --- /dev/null +++ b/resources/views/filament/tenant/pages/ai-assistant.blade.php @@ -0,0 +1,160 @@ + + + + @php + $chat = $this->getChat(); + $chats = $this->getChats(); + @endphp + +
+ {{-- Sidebar: chat history --}} +
+ + @forelse ($chats as $c) +
+ + {{ $c->title }} + + +
+ @empty +
Nicio conversație
+ @endforelse +
+ + {{-- Main chat area --}} +
+
+ @if (! $chat || $chat->messages->isEmpty()) +
+
+
Asistent AI pentru autoservice
+
Întreabă-mă orice — diagnostic, sumar, sugestii, redactare mesaje către clienți...
+
+ Configurare: mergi la Setări → Asistent AI și adaugă cheia API (Claude / GPT / Gemini). +
+
+ @else + @foreach ($chat->messages as $m) + @if ($m->role === 'user') +
{{ $m->content }}
+ @elseif ($m->role === 'assistant') +
+
{{ $m->content }}
+ @if (! empty($m->meta['provider'])) +
+ {{ strtoupper($m->meta['provider']) }} + @if (! empty($m->meta['latency_ms'])) · {{ $m->meta['latency_ms'] }}ms @endif + @if (! empty($m->meta['tokens_out'])) · {{ $m->meta['tokens_out'] }} tok @endif +
+ @endif +
+ @endif + @endforeach + @endif + @if ($loading) +
+ + asistent gândește... +
+ @endif +
+ +
+ + +
+
+
+