Add Afia AI assistant to the QR Plus header.
Deploy Ladill QR Plus / deploy (push) Successful in 1m33s

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 <cursoragent@cursor.com>
This commit is contained in:
isaacclad
2026-06-07 08:56:29 +00:00
co-authored by Cursor
parent 6a5e26315e
commit 9ddda21ea3
10 changed files with 306 additions and 121 deletions
+6
View File
@@ -30,4 +30,10 @@ BILLING_API_KEY_QR=
IDENTITY_API_URL=https://ladill.com/api IDENTITY_API_URL=https://ladill.com/api
IDENTITY_API_KEY_QR= 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}" VITE_APP_NAME="${APP_NAME}"
@@ -0,0 +1,87 @@
<?php
namespace App\Http\Controllers\Qr;
use App\Http\Controllers\Controller;
use App\Models\QrCode;
use App\Services\Afia\AfiaService;
use App\Services\Billing\BillingClient;
use App\Support\Qr\QrTypeCatalog;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class AfiaController extends Controller
{
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());
} 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(): 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;
}
}
+126
View File
@@ -0,0 +1,126 @@
<?php
namespace App\Services\Afia;
use Illuminate\Support\Facades\Http;
use RuntimeException;
/**
* Afia Ladill in-app AI assistant scoped to a product (QR Plus).
*/
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 match ((string) config('afia.product', 'qr')) {
'qr' => $this->qrSystemPrompt($ctx),
default => $this->qrSystemPrompt($ctx),
};
}
private function qrSystemPrompt(string $ctx): string
{
return <<<PROMPT
You are Afia, the assistant inside Ladill QR Plus (qrplus.ladill.com).
Help users create and manage dynamic QR codes, understand scans and billing, and print codes that work reliably.
Be concise, friendly, and actionable: short numbered steps and name the screen or section to use.
What Ladill QR Plus does and where things live:
- Overview (Dashboard): recent codes, active code count, scans in the last 30 days, wallet balance.
- My Codes: list, create, and edit QR codes. Types: Link (URL), PDF, List of Links, Business profile, WiFi, App download.
- Creating a code: pick a type, set content, customize style (colors, logo, frame), then download PNG/SVG/PDF.
- Dynamic codes: destination can change after printing the printed QR always points to ladill.com/q/{code}.
- Business QR: name, tagline, contact, hours, logo, cover, social links shown as a mobile landing page.
- WiFi QR: guests scan to join; network name and password are encoded in the code.
- PDF QR: hosts a PDF with optional download button on the viewer.
- Billing: each new code debits the Ladill wallet (account portal Wallet). Top up at account.ladill.com/wallet.
- Team: invite teammates to manage codes together (sidebar Team).
- Developers: API tokens for programmatic code creation (sidebar Developers).
Rules:
- Only answer questions about Ladill QR Plus code types, styling, downloads, scans, wallet billing, team, and API.
- If asked about domains, hosting, email, or payments/commerce QR (shop, events, donations), briefly say those live in other Ladill apps and suggest the app launcher.
- Never invent prices, short codes, or wallet balances use the user context below or tell them where to check.
- For print tips: recommend high contrast, adequate quiet zone, test-scan before mass printing.
Current user context:
{$ctx}
PROMPT;
}
}
+2 -2
View File
@@ -1,8 +1,8 @@
<?php <?php
return [ return [
// Afia — in-app AI assistant. Product scope is set per deployed app (hosting | email). // Afia — in-app AI assistant scoped to Ladill QR Plus.
'product' => env('AFIA_PRODUCT', 'hosting'), 'product' => env('AFIA_PRODUCT', 'qr'),
'enabled' => (bool) env('AFIA_ENABLED', true), 'enabled' => (bool) env('AFIA_ENABLED', true),
'provider' => env('AFIA_PROVIDER', 'openai'), // openai | anthropic 'provider' => env('AFIA_PROVIDER', 'openai'), // openai | anthropic
'model' => env('AFIA_MODEL', 'gpt-4o-mini'), 'model' => env('AFIA_MODEL', 'gpt-4o-mini'),
+1
View File
@@ -18,6 +18,7 @@
</include> </include>
</source> </source>
<php> <php>
<env name="APP_KEY" value="base64:2fl+KtvkdphvQyEfm3qXz2Bdm5fH3IOx4EoZ5/R4eJE="/>
<env name="APP_ENV" value="testing"/> <env name="APP_ENV" value="testing"/>
<env name="APP_MAINTENANCE_DRIVER" value="file"/> <env name="APP_MAINTENANCE_DRIVER" value="file"/>
<env name="BCRYPT_ROUNDS" value="4"/> <env name="BCRYPT_ROUNDS" value="4"/>
+1 -106
View File
@@ -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 = {}) => ({ Alpine.data('balanceGate', (config = {}) => ({
balance: Number(config.balance ?? 0), balance: Number(config.balance ?? 0),
price: Number(config.price ?? 0), price: Number(config.price ?? 0),
@@ -424,6 +318,7 @@ document.addEventListener('keydown', (e) => {
} }
}); });
</script> </script>
@include('partials.afia')
@include('partials.sso-keepalive') @include('partials.sso-keepalive')
</body> </body>
</html> </html>
+14 -10
View File
@@ -1,16 +1,15 @@
@php @php
$afiaProduct = $afiaProduct ?? (request()->routeIs('email.*') ? 'email' : 'hosting'); $afiaGreeting = "Hi, I'm Afia 👋 Ask me about QR codes — creating a Business or WiFi code, styling and downloads, scan stats, or wallet billing…";
$afiaGreeting = $afiaProduct === 'email' $afiaSuggestions = [
? "Hi, I'm Afia 👋 Ask me anything about email — setting up a domain, verifying DNS, creating mailboxes, IMAP/SMTP settings or billing…" 'How do I create a Business QR code?',
: "Hi, I'm Afia 👋 Ask me anything about hosting — choosing a plan, linking a domain, using the control panel, SSL, or renewals…"; 'What is the difference between Link and List of Links?',
$afiaSuggestions = $afiaProduct === 'email' 'How do I download a print-ready PNG?',
? ['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?'] 'Why is my wallet balance low?',
: ['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';
@endphp @endphp
{{-- Afia Ladill AI assistant slide-over. Opened via $dispatch('afia-open'). --}} {{-- Afia Ladill AI assistant slide-over. Opened via $dispatch('afia-open'). --}}
<div x-data="afia({ <div x-data="afia({
chatUrl: '{{ $afiaProduct === 'email' ? route('email.afia.chat') : route('hosting.afia.chat') }}', chatUrl: '{{ route('qr.afia.chat') }}',
csrf: '{{ csrf_token() }}', csrf: '{{ csrf_token() }}',
greeting: @js($afiaGreeting), greeting: @js($afiaGreeting),
suggestions: @js($afiaSuggestions), suggestions: @js($afiaSuggestions),
@@ -49,13 +48,18 @@
</span> </span>
<div class="leading-tight"> <div class="leading-tight">
<p class="text-sm font-semibold text-slate-900">Afia</p> <p class="text-sm font-semibold text-slate-900">Afia</p>
<p class="text-[11px] text-slate-400">{{ $afiaSubtitle }}</p> <p class="text-[11px] text-slate-400">QR Plus assistant</p>
</div> </div>
</div> </div>
<div class="flex items-center gap-1">
<a href="{{ ladill_account_url('ai') }}"
class="rounded-lg px-2 py-1 text-[11px] font-medium text-slate-500 hover:bg-slate-100 hover:text-slate-700"
title="AI usage history on your Ladill account">History</a>
<button @click="close()" class="rounded-lg p-1.5 text-slate-400 hover:bg-slate-100 hover:text-slate-600"> <button @click="close()" class="rounded-lg p-1.5 text-slate-400 hover:bg-slate-100 hover:text-slate-600">
<svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12"/></svg> <svg class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" d="M6 18 18 6M6 6l12 12"/></svg>
</button> </button>
</div> </div>
</div>
<div class="flex-1 overflow-y-auto px-4 py-4" x-ref="scroll"> <div class="flex-1 overflow-y-auto px-4 py-4" x-ref="scroll">
<div class="space-y-3"> <div class="space-y-3">
@@ -133,6 +133,16 @@
</div> </div>
</div> </div>
@auth
<button type="button" @click="$dispatch('afia-open')"
class="hidden lg:inline-flex items-center gap-2 rounded-xl bg-gradient-to-r from-indigo-700 via-blue-600 to-emerald-400 px-4 py-2 text-sm font-semibold text-white shadow-sm transition hover:opacity-95">
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2.25c.414 0 .75.336.75.75a5.25 5.25 0 0 0 5.25 5.25.75.75 0 0 1 0 1.5A5.25 5.25 0 0 0 12.75 15a.75.75 0 0 1-1.5 0A5.25 5.25 0 0 0 6 9.75a.75.75 0 0 1 0-1.5A5.25 5.25 0 0 0 11.25 3a.75.75 0 0 1 .75-.75Zm6.75 11.25a.75.75 0 0 1 .75.75A2.25 2.25 0 0 0 21.75 16.5a.75.75 0 0 1 0 1.5A2.25 2.25 0 0 0 19.5 20.25a.75.75 0 0 1-1.5 0A2.25 2.25 0 0 0 15.75 18a.75.75 0 0 1 0-1.5A2.25 2.25 0 0 0 18 14.25a.75.75 0 0 1 .75-.75ZM5.25 14.25a.75.75 0 0 1 .75.75 3 3 0 0 0 3 3 .75.75 0 0 1 0 1.5 3 3 0 0 0-3 3 .75.75 0 0 1-1.5 0 3 3 0 0 0-3-3 .75.75 0 0 1 0-1.5 3 3 0 0 0 3-3 .75.75 0 0 1 .75-.75Z"/>
</svg>
<span>AI</span>
</button>
@endauth
@include('partials.launcher') @include('partials.launcher')
</div> </div>
</header> </header>
+2
View File
@@ -4,6 +4,7 @@ use App\Http\Controllers\Auth\SsoLoginController;
use App\Http\Controllers\NotificationController; use App\Http\Controllers\NotificationController;
use App\Http\Controllers\Public\QrScanController; use App\Http\Controllers\Public\QrScanController;
use App\Http\Controllers\Qr\AccountController; use App\Http\Controllers\Qr\AccountController;
use App\Http\Controllers\Qr\AfiaController;
use App\Http\Controllers\Qr\DeveloperController; use App\Http\Controllers\Qr\DeveloperController;
use App\Http\Controllers\Qr\OverviewController; use App\Http\Controllers\Qr\OverviewController;
use App\Http\Controllers\Qr\TeamController; 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::post('/notifications/mark-all-read', [NotificationController::class, 'markAllAsRead'])->name('notifications.mark-all-read');
Route::get('/dashboard', [OverviewController::class, 'index'])->name('qr.dashboard'); 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', [QrCodeController::class, 'index'])->name('user.qr-codes.index');
Route::get('/qr-codes/create', [QrCodeController::class, 'create'])->name('user.qr-codes.create'); Route::get('/qr-codes/create', [QrCodeController::class, 'create'])->name('user.qr-codes.create');
+54
View File
@@ -0,0 +1,54 @@
<?php
namespace Tests\Feature;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Str;
use Tests\TestCase;
class AfiaTest extends TestCase
{
use RefreshDatabase;
protected function setUp(): void
{
parent::setUp();
Http::preventStrayRequests();
}
private function user(): User
{
return User::create(['public_id' => (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'));
}
}