diff --git a/app/Http/Controllers/Frontdesk/AiController.php b/app/Http/Controllers/Frontdesk/AiController.php new file mode 100644 index 0000000..49942ec --- /dev/null +++ b/app/Http/Controllers/Frontdesk/AiController.php @@ -0,0 +1,70 @@ +validate([ + 'message' => ['required', 'string', 'max:2000'], + 'history' => ['nullable', 'array', 'max:20'], + 'history.*.role' => ['nullable', 'string'], + 'history.*.text' => ['nullable', 'string'], + ]); + + if (! $afia->enabled()) { + return response()->json(['message' => 'Afia is not available right now.'], 503); + } + + try { + $reply = $afia->chat(trim($validated['message']), $validated['history'] ?? [], $this->context($request)); + } catch (\Throwable $e) { + report($e); + + return response()->json(['message' => 'Afia could not respond right now. Please try again.'], 502); + } + + return response()->json(['reply' => $reply]); + } + + /** @return array */ + private function context(Request $request): array + { + $owner = $this->ownerRef($request); + $organization = $this->organization($request); + + $visitQuery = Visit::owned($owner)->where('organization_id', $organization->id); + $this->scopeToBranch($request, $visitQuery); + + return [ + 'signed_in' => 'yes', + 'organization' => $organization->name, + 'visitors_today' => (clone $visitQuery)->where(function ($q) { + $q->whereDate('checked_in_at', today()) + ->orWhereDate('scheduled_at', today()); + })->count(), + 'currently_inside' => (clone $visitQuery)->currentlyInside()->count(), + 'expected_arrivals' => (clone $visitQuery)->whereIn('status', [ + Visit::STATUS_EXPECTED, + Visit::STATUS_SCHEDULED, + ])->whereDate('scheduled_at', today())->count(), + 'registered_visitors' => Visitor::owned($owner)->where('organization_id', $organization->id)->count(), + 'active_hosts' => Host::owned($owner)->where('organization_id', $organization->id)->where('is_available', true)->count(), + 'registered_devices' => Device::owned($owner)->where('organization_id', $organization->id)->count(), + 'online_devices' => Device::owned($owner)->where('organization_id', $organization->id)->where('status', 'online')->count(), + ]; + } +} diff --git a/app/Services/Afia/AfiaService.php b/app/Services/Afia/AfiaService.php new file mode 100644 index 0000000..9c5893b --- /dev/null +++ b/app/Services/Afia/AfiaService.php @@ -0,0 +1,116 @@ + $history + * @param array $context + */ + public function chat(string $message, array $history, array $context): string + { + if (! $this->enabled()) { + throw new RuntimeException('Afia is not configured.'); + } + + $provider = (string) config('afia.provider', 'openai'); + $model = (string) config('afia.model', 'gpt-4o-mini'); + $apiKey = (string) config('afia.api_key'); + + $messages = [['role' => 'system', 'content' => $this->systemPrompt($context)]]; + foreach (array_slice($history, -8) as $turn) { + $role = ($turn['role'] ?? 'user') === 'assistant' ? 'assistant' : 'user'; + $text = trim((string) ($turn['text'] ?? '')); + if ($text !== '') { + $messages[] = ['role' => $role, 'content' => $text]; + } + } + $messages[] = ['role' => 'user', 'content' => $message]; + + return $provider === 'anthropic' + ? $this->viaAnthropic($model, $apiKey, $messages) + : $this->viaOpenAi($model, $apiKey, $messages); + } + + private function viaOpenAi(string $model, string $apiKey, array $messages): string + { + $res = Http::withToken($apiKey)->acceptJson()->timeout(45) + ->post('https://api.openai.com/v1/chat/completions', [ + 'model' => $model, + 'temperature' => 0.3, + 'max_tokens' => 600, + 'messages' => $messages, + ]); + + if ($res->failed()) { + throw new RuntimeException('OpenAI request failed: '.$res->status()); + } + + return trim((string) $res->json('choices.0.message.content', '')); + } + + private function viaAnthropic(string $model, string $apiKey, array $messages): string + { + $system = $messages[0]['content']; + $turns = array_values(array_filter($messages, fn ($m) => $m['role'] !== 'system')); + + $res = Http::withHeaders([ + 'x-api-key' => $apiKey, + 'anthropic-version' => '2023-06-01', + ])->acceptJson()->timeout(45)->post('https://api.anthropic.com/v1/messages', [ + 'model' => $model, + 'max_tokens' => 600, + 'system' => $system, + 'messages' => array_map(fn ($m) => ['role' => $m['role'], 'content' => $m['content']], $turns), + ]); + + if ($res->failed()) { + throw new RuntimeException('Anthropic request failed: '.$res->status()); + } + + return trim((string) $res->json('content.0.text', '')); + } + + /** @param array $context */ + private function systemPrompt(array $context): string + { + $ctx = collect($context)->map(fn ($v, $k) => "- {$k}: {$v}")->implode("\n"); + + return << env('AFIA_PRODUCT', 'frontdesk'), + 'enabled' => (bool) env('AFIA_ENABLED', true), + 'provider' => env('AFIA_PROVIDER', 'openai'), // openai | anthropic + 'model' => env('AFIA_MODEL', 'gpt-4o-mini'), + 'api_key' => env('AFIA_API_KEY', env('OPENAI_API_KEY')), +]; diff --git a/config/ladill_launcher.php b/config/ladill_launcher.php index db8daed..bfdd073 100644 --- a/config/ladill_launcher.php +++ b/config/ladill_launcher.php @@ -29,6 +29,7 @@ return [ ['name' => 'Events', 'url' => 'https://events.'.$root.'/sso/connect?redirect='.urlencode('https://events.'.$root.'/dashboard'), 'icon' => 'events.svg'], ['name' => 'QR Plus', 'url' => 'https://qrplus.'.$root.'/sso/connect?redirect='.urlencode('https://qrplus.'.$root.'/dashboard'), 'icon' => 'qrplus.svg'], ['name' => 'Link', 'url' => 'https://link.'.$root.'/sso/connect?redirect='.urlencode('https://link.'.$root.'/dashboard'), 'icon' => 'link.svg'], + ['name' => 'Frontdesk', 'url' => 'https://frontdesk.'.$root.'/sso/connect?redirect='.urlencode('https://frontdesk.'.$root.'/dashboard'), 'icon' => 'frontdesk.svg'], ['name' => 'SMS', 'url' => 'https://sms.'.$root, 'icon' => 'sms.svg'], ['name' => 'Bird', 'url' => 'https://bird.'.$root, 'icon' => 'bird.svg'], ['name' => 'Mail', 'url' => 'https://mail.'.$root, 'icon' => 'mail.svg'], diff --git a/resources/views/components/app-layout.blade.php b/resources/views/components/app-layout.blade.php index 6137f73..07d64d3 100644 --- a/resources/views/components/app-layout.blade.php +++ b/resources/views/components/app-layout.blade.php @@ -29,5 +29,6 @@ @include('partials.wallet-topup-modal', ['openOnLoad' => (bool) session('topup_required')]) + @include('partials.afia') diff --git a/resources/views/partials/afia.blade.php b/resources/views/partials/afia.blade.php index 68f74eb..265fdb5 100644 --- a/resources/views/partials/afia.blade.php +++ b/resources/views/partials/afia.blade.php @@ -1,15 +1,15 @@ @php - $afiaGreeting = "Hi, I'm Afia 👋 Ask me about contacts, leads, your deal pipeline, logging activities, or sending an email or SMS…"; + $afiaGreeting = "Hi, I'm Afia 👋 Ask me about visitor check-in, kiosks, hosts, badges, devices, or setting up your reception desk…"; $afiaSuggestions = [ - 'How do I add a contact?', - 'How does the deal pipeline work?', - 'How do I convert a lead?', - 'How do I email a contact?', + 'How do I set up a visitor kiosk?', + 'How do I check a visitor in?', + 'Where do I add reception desks?', + 'How do hosts approve visits?', ]; @endphp {{-- Afia — Ladill AI assistant slide-over. Opened via $dispatch('afia-open'). --}}

Afia

-

CRM assistant

+

Frontdesk assistant

diff --git a/resources/views/partials/topbar-desktop-widgets.blade.php b/resources/views/partials/topbar-desktop-widgets.blade.php index 8161ddf..e6d69bd 100644 --- a/resources/views/partials/topbar-desktop-widgets.blade.php +++ b/resources/views/partials/topbar-desktop-widgets.blade.php @@ -11,6 +11,8 @@ $showUserHeader = $showUser ?? true; @endphp +@includeIf('partials.afia-button', ['compact' => true]) + @includeIf('partials.notification-dropdown') @include('partials.launcher') diff --git a/routes/web.php b/routes/web.php index 2ae7533..e999d3b 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,6 +1,7 @@ group(function () { Route::middleware(['frontdesk.setup'])->group(function () { Route::get('/dashboard', [DashboardController::class, 'index'])->name('frontdesk.dashboard'); + Route::post('/ai/chat', [AiController::class, 'chat'])->middleware('throttle:30,1')->name('frontdesk.ai.chat'); + Route::get('/notifications', [NotificationController::class, 'index'])->name('notifications.index'); Route::get('/notifications/unread', [NotificationController::class, 'unread'])->name('notifications.unread'); Route::post('/notifications/{id}/read', [NotificationController::class, 'markAsRead'])->name('notifications.mark-read'); diff --git a/tests/Feature/FrontdeskAfiaTest.php b/tests/Feature/FrontdeskAfiaTest.php new file mode 100644 index 0000000..528f6d9 --- /dev/null +++ b/tests/Feature/FrontdeskAfiaTest.php @@ -0,0 +1,86 @@ +withoutMiddleware(EnsurePlatformSession::class); + + $this->user = User::create([ + 'public_id' => 'afia-user-001', + 'name' => 'Afia User', + 'email' => 'afia@example.com', + ]); + + Organization::create([ + 'owner_ref' => $this->user->public_id, + 'name' => 'Afia Org', + 'slug' => 'afia-org', + 'settings' => ['onboarded' => true], + ]); + + Member::create([ + 'owner_ref' => $this->user->public_id, + 'organization_id' => 1, + 'user_ref' => $this->user->public_id, + 'role' => 'org_admin', + ]); + } + + public function test_dashboard_includes_afia_button_and_panel(): void + { + $response = $this->actingAs($this->user)->get(route('frontdesk.dashboard')); + + $response->assertOk(); + $response->assertSee('Open Afia AI assistant', false); + $response->assertSee('Frontdesk assistant', false); + $response->assertSee(route('frontdesk.ai.chat'), false); + $response->assertSee('How do I set up a visitor kiosk?', false); + } + + public function test_afia_chat_returns_reply_when_enabled(): void + { + config([ + 'afia.enabled' => true, + 'afia.api_key' => 'test-key', + ]); + + $this->mock(AfiaService::class, function ($mock) { + $mock->shouldReceive('enabled')->andReturn(true); + $mock->shouldReceive('chat')->once()->andReturn('Register a Visitor Kiosk under Devices.'); + }); + + $this->actingAs($this->user) + ->postJson(route('frontdesk.ai.chat'), [ + 'message' => 'How do I set up a kiosk?', + ]) + ->assertOk() + ->assertJsonPath('reply', 'Register a Visitor Kiosk under Devices.'); + } + + public function test_afia_chat_returns_service_unavailable_when_disabled(): void + { + config(['afia.enabled' => false, 'afia.api_key' => '']); + + $this->actingAs($this->user) + ->postJson(route('frontdesk.ai.chat'), [ + 'message' => 'Hello', + ]) + ->assertStatus(503); + } +}