From 9ddda21ea3081fc3404b88b152da4abdd8c4d694 Mon Sep 17 00:00:00 2001 From: isaacclad Date: Sun, 7 Jun 2026 08:56:29 +0000 Subject: [PATCH] Add Afia AI assistant to the QR Plus header. Wire in-app chat scoped to QR codes, billing, and scans so users get product-specific help without leaving qrplus.ladill.com. Co-authored-by: Cursor --- .env.example | 6 + app/Http/Controllers/Qr/AfiaController.php | 87 +++++++++++++ app/Services/Afia/AfiaService.php | 126 +++++++++++++++++++ config/afia.php | 4 +- phpunit.xml | 1 + resources/views/layouts/user.blade.php | 107 +--------------- resources/views/partials/afia.blade.php | 30 +++-- resources/views/partials/topbar-qr.blade.php | 10 ++ routes/web.php | 2 + tests/Feature/AfiaTest.php | 54 ++++++++ 10 files changed, 306 insertions(+), 121 deletions(-) create mode 100644 app/Http/Controllers/Qr/AfiaController.php create mode 100644 app/Services/Afia/AfiaService.php create mode 100644 tests/Feature/AfiaTest.php diff --git a/.env.example b/.env.example index 2788b78..7899371 100644 --- a/.env.example +++ b/.env.example @@ -30,4 +30,10 @@ BILLING_API_KEY_QR= IDENTITY_API_URL=https://ladill.com/api IDENTITY_API_KEY_QR= +AFIA_ENABLED=true +AFIA_PRODUCT=qr +AFIA_PROVIDER=openai +AFIA_MODEL=gpt-4o-mini +AFIA_API_KEY= + VITE_APP_NAME="${APP_NAME}" diff --git a/app/Http/Controllers/Qr/AfiaController.php b/app/Http/Controllers/Qr/AfiaController.php new file mode 100644 index 0000000..fbc0ef3 --- /dev/null +++ b/app/Http/Controllers/Qr/AfiaController.php @@ -0,0 +1,87 @@ +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()); + } 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(): array + { + $account = ladill_account(); + + if (! $account) { + return ['signed_in' => 'no']; + } + + $types = QrTypeCatalog::plusTypes(); + $codes = $account->qrCodes()->whereIn('type', $types); + + $ctx = [ + 'signed_in' => 'yes', + 'qr_codes_total' => (clone $codes)->count(), + 'qr_codes_active' => (clone $codes)->where('is_active', true)->count(), + 'scans_total' => (int) (clone $codes)->sum('scans_total'), + 'top_code_type' => (clone $codes) + ->selectRaw('type, count(*) as total') + ->groupBy('type') + ->orderByDesc('total') + ->value('type') ?: 'none yet', + ]; + + if ($account->public_id) { + try { + $ctx['wallet_balance_ghs'] = number_format(app(BillingClient::class)->balanceMinor((string) $account->public_id) / 100, 2); + } catch (\Throwable) { + } + } + + $recent = $account->qrCodes() + ->whereIn('type', $types) + ->latest('updated_at') + ->limit(3) + ->get(['type', 'label', 'short_code', 'scans_total']); + + if ($recent->isNotEmpty()) { + $ctx['recent_codes'] = $recent->map(fn (QrCode $code): string => sprintf( + '%s (%s, %d scans)', + $code->label ?: $code->short_code, + QrTypeCatalog::label($code->type), + $code->scans_total, + ))->implode('; '); + } + + return $ctx; + } +} diff --git a/app/Services/Afia/AfiaService.php b/app/Services/Afia/AfiaService.php new file mode 100644 index 0000000..377ef7e --- /dev/null +++ b/app/Services/Afia/AfiaService.php @@ -0,0 +1,126 @@ + $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 match ((string) config('afia.product', 'qr')) { + 'qr' => $this->qrSystemPrompt($ctx), + default => $this->qrSystemPrompt($ctx), + }; + } + + private function qrSystemPrompt(string $ctx): string + { + return << env('AFIA_PRODUCT', 'hosting'), + // Afia — in-app AI assistant scoped to Ladill QR Plus. + 'product' => env('AFIA_PRODUCT', 'qr'), 'enabled' => (bool) env('AFIA_ENABLED', true), 'provider' => env('AFIA_PROVIDER', 'openai'), // openai | anthropic 'model' => env('AFIA_MODEL', 'gpt-4o-mini'), diff --git a/phpunit.xml b/phpunit.xml index e7f0a48..5d8de89 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -18,6 +18,7 @@ + diff --git a/resources/views/layouts/user.blade.php b/resources/views/layouts/user.blade.php index 8f0dc46..a9c5ad1 100644 --- a/resources/views/layouts/user.blade.php +++ b/resources/views/layouts/user.blade.php @@ -242,112 +242,6 @@ document.addEventListener('alpine:init', () => { }, })); - Alpine.data('afiaChat', (config = {}) => ({ - profileOpen: false, - afiaOpen: false, - afiaChatUrl: config.chatUrl || '/ai/chat', - afiaCsrfToken: config.csrfToken || document.querySelector('meta[name="csrf-token"]')?.content || '', - afiaCurrentPage: config.currentPage ?? null, - afiaLoading: false, - afiaError: '', - afiaInput: '', - afiaSuggestions: [ - 'How do I register a domain?', - 'Help me set up email', - 'What hosting plan should I choose?', - ], - afiaMessages: [ - { - role: 'assistant', - text: "Hi, I'm Afia! I can help with domains, hosting, servers, business email, SMTP services, and billing.", - }, - ], - - openAfia() { - this.profileOpen = false; - this.afiaOpen = true; - document.body.classList.add('overflow-hidden'); - - this.$nextTick(() => { - this.$refs.afiaInput?.focus(); - }); - }, - - closeAfia() { - this.afiaOpen = false; - document.body.classList.remove('overflow-hidden'); - }, - - handleEscape() { - if (this.profileOpen) { - this.profileOpen = false; - return; - } - if (this.afiaOpen) { - this.closeAfia(); - } - }, - - useSuggestion(suggestion) { - if (this.afiaLoading) return; - this.afiaInput = suggestion; - this.sendMessage(); - }, - - async sendMessage() { - const text = this.afiaInput.trim(); - if (!text || this.afiaLoading) return; - - this.afiaError = ''; - this.afiaMessages.push({ role: 'user', text }); - this.afiaInput = ''; - this.afiaLoading = true; - this.$nextTick(() => { - const el = this.$refs.afiaScroll; - if (el) el.scrollTop = el.scrollHeight; - }); - - try { - const response = await fetch(this.afiaChatUrl, { - method: 'POST', - headers: { - 'Accept': 'application/json', - 'Content-Type': 'application/json', - 'X-CSRF-TOKEN': this.afiaCsrfToken, - 'X-Requested-With': 'XMLHttpRequest', - }, - body: JSON.stringify({ - message: text, - current_page: this.afiaCurrentPage, - }), - }); - - const payload = await response.json().catch(() => ({})); - if (!response.ok) { - throw new Error(payload.message || 'Afia is unavailable right now.'); - } - - const reply = (payload?.data?.reply || '').toString().trim(); - this.afiaMessages.push({ - role: 'assistant', - text: reply !== '' ? reply : 'I could not parse a reply from the AI provider.', - }); - } catch (error) { - this.afiaError = error?.message || 'Afia could not process your request.'; - this.afiaMessages.push({ - role: 'assistant', - text: 'I could not complete that request right now. Please try again.', - }); - } finally { - this.afiaLoading = false; - this.$nextTick(() => { - const el = this.$refs.afiaScroll; - if (el) el.scrollTop = el.scrollHeight; - }); - } - }, - })); - Alpine.data('balanceGate', (config = {}) => ({ balance: Number(config.balance ?? 0), price: Number(config.price ?? 0), @@ -424,6 +318,7 @@ document.addEventListener('keydown', (e) => { } }); +@include('partials.afia') @include('partials.sso-keepalive') diff --git a/resources/views/partials/afia.blade.php b/resources/views/partials/afia.blade.php index adc2c23..401397b 100644 --- a/resources/views/partials/afia.blade.php +++ b/resources/views/partials/afia.blade.php @@ -1,16 +1,15 @@ @php - $afiaProduct = $afiaProduct ?? (request()->routeIs('email.*') ? 'email' : 'hosting'); - $afiaGreeting = $afiaProduct === 'email' - ? "Hi, I'm Afia 👋 Ask me anything about email — setting up a domain, verifying DNS, creating mailboxes, IMAP/SMTP settings or billing…" - : "Hi, I'm Afia 👋 Ask me anything about hosting — choosing a plan, linking a domain, using the control panel, SSL, or renewals…"; - $afiaSuggestions = $afiaProduct === 'email' - ? ['How do I set up email for my domain?', 'What DNS records do I need to verify?', 'How do I create a mailbox?', 'What are my IMAP and SMTP settings?'] - : ['How do I link a domain to my hosting?', 'How do I open the control panel?', 'How do I renew my hosting plan?', 'How do I install WordPress?']; - $afiaSubtitle = $afiaProduct === 'email' ? 'Email assistant' : 'Hosting assistant'; + $afiaGreeting = "Hi, I'm Afia 👋 Ask me about QR codes — creating a Business or WiFi code, styling and downloads, scan stats, or wallet billing…"; + $afiaSuggestions = [ + 'How do I create a Business QR code?', + 'What is the difference between Link and List of Links?', + 'How do I download a print-ready PNG?', + 'Why is my wallet balance low?', + ]; @endphp {{-- Afia — Ladill AI assistant slide-over. Opened via $dispatch('afia-open'). --}}

Afia

-

{{ $afiaSubtitle }}

+

QR Plus assistant

- +
+ History + +
diff --git a/resources/views/partials/topbar-qr.blade.php b/resources/views/partials/topbar-qr.blade.php index 2630f42..9801c27 100644 --- a/resources/views/partials/topbar-qr.blade.php +++ b/resources/views/partials/topbar-qr.blade.php @@ -133,6 +133,16 @@
+ @auth + + @endauth + @include('partials.launcher') diff --git a/routes/web.php b/routes/web.php index 3d2219a..75250c3 100644 --- a/routes/web.php +++ b/routes/web.php @@ -4,6 +4,7 @@ use App\Http\Controllers\Auth\SsoLoginController; use App\Http\Controllers\NotificationController; use App\Http\Controllers\Public\QrScanController; use App\Http\Controllers\Qr\AccountController; +use App\Http\Controllers\Qr\AfiaController; use App\Http\Controllers\Qr\DeveloperController; use App\Http\Controllers\Qr\OverviewController; use App\Http\Controllers\Qr\TeamController; @@ -35,6 +36,7 @@ Route::middleware(['auth'])->group(function () { Route::post('/notifications/mark-all-read', [NotificationController::class, 'markAllAsRead'])->name('notifications.mark-all-read'); Route::get('/dashboard', [OverviewController::class, 'index'])->name('qr.dashboard'); + Route::post('/afia/chat', [AfiaController::class, 'chat'])->name('qr.afia.chat'); Route::get('/qr-codes', [QrCodeController::class, 'index'])->name('user.qr-codes.index'); Route::get('/qr-codes/create', [QrCodeController::class, 'create'])->name('user.qr-codes.create'); diff --git a/tests/Feature/AfiaTest.php b/tests/Feature/AfiaTest.php new file mode 100644 index 0000000..7868306 --- /dev/null +++ b/tests/Feature/AfiaTest.php @@ -0,0 +1,54 @@ + (string) Str::uuid(), 'name' => 'A', 'email' => 'a+'.uniqid().'@x.com']); + } + + public function test_requires_a_message(): void + { + config(['afia.api_key' => 'sk-test']); + $this->actingAs($this->user())->postJson('/afia/chat', [])->assertStatus(422); + } + + public function test_returns_503_when_not_configured(): void + { + config(['afia.api_key' => '']); + $this->actingAs($this->user())->postJson('/afia/chat', ['message' => 'hi'])->assertStatus(503); + } + + public function test_returns_reply_from_llm(): void + { + config(['afia.api_key' => 'sk-test', 'afia.provider' => 'openai', 'afia.model' => 'gpt-4o-mini']); + Http::fake([ + 'api.openai.com/*' => Http::response(['choices' => [['message' => ['content' => 'Create a Business QR under My Codes.']]]]), + ]); + + $this->actingAs($this->user()) + ->postJson('/afia/chat', ['message' => 'How do I create a business QR?']) + ->assertOk() + ->assertJson(['reply' => 'Create a Business QR under My Codes.']); + + Http::assertSent(fn ($r) => str_contains($r->url(), 'openai.com') + && collect($r['messages'])->first()['role'] === 'system' + && str_contains(collect($r['messages'])->first()['content'], 'Ladill QR Plus')); + } +}