Wire Afia into Frontdesk and sync the app launcher catalog.
Deploy Ladill Frontdesk / deploy (push) Successful in 34s

Add the AI assistant button, chat endpoint, and Frontdesk-specific prompts, and include Frontdesk in the shared launcher config used by the app switcher.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
isaacclad
2026-06-28 01:40:32 +00:00
co-authored by Cursor
parent 8c79c312cf
commit 5a42759886
9 changed files with 296 additions and 7 deletions
@@ -0,0 +1,70 @@
<?php
namespace App\Http\Controllers\Frontdesk;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Frontdesk\Concerns\ScopesToAccount;
use App\Models\Device;
use App\Models\Host;
use App\Models\Visit;
use App\Models\Visitor;
use App\Services\Afia\AfiaService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class AiController extends Controller
{
use ScopesToAccount;
public function chat(Request $request, AfiaService $afia): JsonResponse
{
$validated = $request->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<string, mixed> */
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(),
];
}
}
+116
View File
@@ -0,0 +1,116 @@
<?php
namespace App\Services\Afia;
use Illuminate\Support\Facades\Http;
use RuntimeException;
/**
* Afia Ladill in-app AI assistant scoped to Frontdesk visitor management.
*/
class AfiaService
{
public function enabled(): bool
{
return (bool) config('afia.enabled', true) && (string) config('afia.api_key', '') !== '';
}
/**
* @param array<int, array{role: string, text: string}> $history
* @param array<string, mixed> $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<string, mixed> $context */
private function systemPrompt(array $context): string
{
$ctx = collect($context)->map(fn ($v, $k) => "- {$k}: {$v}")->implode("\n");
return <<<PROMPT
You are Afia, the assistant inside Ladill Frontdesk (frontdesk.ladill.com).
Help reception teams, security staff, and admins manage visitors, check-in, badges, hosts, and devices. Be concise, friendly, and actionable: short numbered steps and name the screen or section to use.
What Ladill Frontdesk does and where things live:
- Dashboard: today's visitors, people currently inside, expected arrivals, and devices online.
- Visits: check visitors in/out, approve visits, print badges, schedule future visits, and view visit history.
- Visitors: search the visitor directory, view past visits, and manage watchlist flags.
- Hosts: the people visitors come to see set availability and link Ladill users where needed.
- Host portal (/host): hosts approve pending visits and schedule expected guests.
- Kiosk: self-service check-in on a tablet register a Visitor Kiosk device, then open its kiosk URL on the hardware.
- Devices: register kiosks, printers, and scanners; kiosks get a device token for unattended access.
- Branches Buildings Reception desks: set up your site hierarchy before assigning devices.
- Security: verify badges, run evacuation reports, and check visitors out at the gate.
- Reports & integrations: exports, webhooks, and calendar feeds.
Rules:
- Only answer questions about Ladill Frontdesk visitors, visits, hosts, kiosks, badges, devices, security, and reception setup.
- If asked about CRM, payments, events ticketing, or email mailboxes, briefly say those live in other Ladill apps and suggest the app launcher.
- Never invent counts or visitor names use the user context below or tell them where to check.
Current user context:
{$ctx}
PROMPT;
}
}
+10
View File
@@ -0,0 +1,10 @@
<?php
return [
// Afia — Ladill in-app AI assistant, scoped to Frontdesk.
'product' => 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')),
];
+1
View File
@@ -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'],
@@ -29,5 +29,6 @@
</div>
</div>
@include('partials.wallet-topup-modal', ['openOnLoad' => (bool) session('topup_required')])
@include('partials.afia')
</body>
</html>
+7 -7
View File
@@ -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'). --}}
<div x-data="afia({
chatUrl: '{{ route('crm.ai.chat') }}',
chatUrl: '{{ route('frontdesk.ai.chat') }}',
csrf: '{{ csrf_token() }}',
greeting: @js($afiaGreeting),
suggestions: @js($afiaSuggestions),
@@ -48,7 +48,7 @@
</span>
<div class="leading-tight">
<p class="text-sm font-semibold text-slate-900">Afia</p>
<p class="text-[11px] text-slate-400">CRM assistant</p>
<p class="text-[11px] text-slate-400">Frontdesk assistant</p>
</div>
</div>
<div class="flex items-center gap-1">
@@ -11,6 +11,8 @@
$showUserHeader = $showUser ?? true;
@endphp
@includeIf('partials.afia-button', ['compact' => true])
@includeIf('partials.notification-dropdown')
@include('partials.launcher')
+3
View File
@@ -1,6 +1,7 @@
<?php
use App\Http\Controllers\Auth\SsoLoginController;
use App\Http\Controllers\Frontdesk\AiController;
use App\Http\Controllers\Frontdesk\AuditLogController;
use App\Http\Controllers\Frontdesk\BadgeController;
use App\Http\Controllers\Frontdesk\BranchController;
@@ -58,6 +59,8 @@ Route::middleware(['auth', 'platform.session'])->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');
+86
View File
@@ -0,0 +1,86 @@
<?php
namespace Tests\Feature;
use App\Http\Middleware\EnsurePlatformSession;
use App\Models\Member;
use App\Models\Organization;
use App\Models\User;
use App\Services\Afia\AfiaService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Tests\TestCase;
class FrontdeskAfiaTest extends TestCase
{
use RefreshDatabase;
protected User $user;
protected function setUp(): void
{
parent::setUp();
$this->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);
}
}