Fix Afia AI assistant for Ladill Link.
Deploy Ladill Link / deploy (push) Successful in 31s

Wire the chat route and Link-scoped system prompt, include the Afia panel in the main layout, and point the UI at link.afia.chat instead of the copied QR Plus route.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
isaacclad
2026-06-27 14:31:00 +00:00
co-authored by Cursor
parent f1fe749934
commit 5a3631cd2e
8 changed files with 127 additions and 37 deletions
+6
View File
@@ -32,4 +32,10 @@ IDENTITY_API_KEY_LINK=
LINK_PRICE_PER_LINK_GHS=0.05 LINK_PRICE_PER_LINK_GHS=0.05
AFIA_ENABLED=true
AFIA_PRODUCT=link
AFIA_PROVIDER=openai
AFIA_MODEL=gpt-4o-mini
AFIA_API_KEY=
VITE_APP_NAME="${APP_NAME}" VITE_APP_NAME="${APP_NAME}"
@@ -0,0 +1,84 @@
<?php
namespace App\Http\Controllers\Link;
use App\Http\Controllers\Controller;
use App\Models\LinkWallet;
use App\Models\ShortLink;
use App\Services\Afia\AfiaService;
use App\Services\Billing\BillingClient;
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'];
}
$links = $account->shortLinks();
$ctx = [
'signed_in' => 'yes',
'links_total' => (clone $links)->count(),
'links_active' => (clone $links)->where('is_active', true)->count(),
'clicks_total' => (int) (clone $links)->sum('clicks_total'),
'custom_domains' => $account->linkCustomDomains()->count(),
'price_per_link_ghs' => number_format(LinkWallet::pricePerLink(), 2),
'default_public_domain' => (string) config('link.public_domain', 'ladl.link'),
];
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->shortLinks()
->latest('updated_at')
->limit(3)
->get(['slug', 'label', 'clicks_total', 'source_app', 'is_managed_here']);
if ($recent->isNotEmpty()) {
$ctx['recent_links'] = $recent->map(fn (ShortLink $link): string => sprintf(
'%s → %s (%d clicks%s)',
$link->label ?: $link->slug,
$link->publicUrl($account),
$link->clicks_total,
$link->is_managed_here ? '' : ', managed in '.$link->sourceAppLabel(),
))->implode('; ');
}
return $ctx;
}
}
+18 -24
View File
@@ -6,7 +6,7 @@ use Illuminate\Support\Facades\Http;
use RuntimeException; use RuntimeException;
/** /**
* Afia Ladill in-app AI assistant scoped to a product (QR Plus). * Afia Ladill in-app AI assistant scoped to Ladill Link.
*/ */
class AfiaService class AfiaService
{ {
@@ -88,36 +88,30 @@ class AfiaService
{ {
$ctx = collect($context)->map(fn ($v, $k) => "- {$k}: {$v}")->implode("\n"); $ctx = collect($context)->map(fn ($v, $k) => "- {$k}: {$v}")->implode("\n");
return match ((string) config('afia.product', 'qr')) { return $this->linkSystemPrompt($ctx);
'qr' => $this->qrSystemPrompt($ctx),
default => $this->qrSystemPrompt($ctx),
};
} }
private function qrSystemPrompt(string $ctx): string private function linkSystemPrompt(string $ctx): string
{ {
return <<<PROMPT return <<<PROMPT
You are Afia, the assistant inside Ladill QR Plus (qrplus.ladill.com). You are Afia, the assistant inside Ladill Link (link.ladill.com).
Help users create and manage dynamic QR codes, understand scans and billing, and print codes that work reliably. Help users create and manage short links, track clicks, use custom domains, and understand wallet billing. Be concise, friendly, and actionable: short numbered steps and name the screen or section to use.
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: What Ladill Link does and where things live:
- Overview (Dashboard): recent codes, active code count, scans in the last 30 days, wallet balance. - Overview (Dashboard): link count, recent clicks, and wallet balance.
- My Codes: list, create, and edit QR codes. Types: Link (URL), PDF, List of Links, Business profile, WiFi, App download. - My Links: create, edit, enable/disable, and delete short links. Each new link debits a small fee from the Ladill wallet (see Settings or the create screen for the current price).
- Creating a code: pick a type, set content, customize style (colors, logo, frame), then download PNG/SVG/PDF. - Public URLs: default domain is ladl.link/{slug}. Links can also use a verified custom domain from Custom Domains.
- Dynamic codes: destination can change after printing the printed QR always points to ladill.com/q/{code}. - Analytics: click totals and trends across links.
- Business QR: name, tagline, contact, hours, logo, cover, social links shown as a mobile landing page. - Custom Domains: connect a domain you own, verify DNS, and set a default domain for new links.
- WiFi QR: guests scan to join; network name and password are encoded in the code. - Settings: default domain preference and account shortcuts.
- PDF QR: hosts a PDF with optional download button on the viewer. - Unified catalog: links created in other Ladill apps (Merchant, Give, Events, QR Plus, etc.) also appear in My Links some are managed in their home app, others here.
- Billing: each new code debits the Ladill wallet (account portal Wallet). Top up at account.ladill.com/wallet. - Wallet: link creation is paid from the Ladill 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: Rules:
- Only answer questions about Ladill QR Plus code types, styling, downloads, scans, wallet billing, team, and API. - Only answer questions about Ladill Link short links, slugs, destinations, clicks, custom domains, billing, and the unified link list.
- 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. - If asked about QR code design, donations, events, or storefronts, 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. - Never invent slugs, click counts, 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. - For DNS/custom domain help: mention verifying the TXT/CNAME record in Custom Domains before the domain goes live.
Current user context: Current user context:
{$ctx} {$ctx}
+2 -2
View File
@@ -1,8 +1,8 @@
<?php <?php
return [ return [
// Afia — in-app AI assistant scoped to Ladill QR Plus. // Afia — in-app AI assistant scoped to Ladill Link.
'product' => env('AFIA_PRODUCT', 'qr'), 'product' => env('AFIA_PRODUCT', 'link'),
'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'),
+3
View File
@@ -243,5 +243,8 @@ document.addEventListener('alpine:init', () => {
@include('partials.sso-keepalive') @include('partials.sso-keepalive')
@include('partials.confirm-prompt') @include('partials.confirm-prompt')
@auth
@include('partials.afia')
@endauth
</body> </body>
</html> </html>
+7 -7
View File
@@ -1,15 +1,15 @@
@php @php
$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 = "Hi, I'm Afia 👋 Ask me about short links — creating one, custom domains, click analytics, or wallet billing…";
$afiaSuggestions = [ $afiaSuggestions = [
'How do I create a Business QR code?', 'How do I create a short link?',
'What is the difference between Link and List of Links?', 'How do I connect a custom domain?',
'How do I download a print-ready PNG?', 'Where do I see click stats?',
'Why is my wallet balance low?', 'Why was my wallet debited when I created a link?',
]; ];
@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: '{{ route('qr.afia.chat') }}', chatUrl: '{{ route('link.afia.chat') }}',
csrf: '{{ csrf_token() }}', csrf: '{{ csrf_token() }}',
greeting: @js($afiaGreeting), greeting: @js($afiaGreeting),
suggestions: @js($afiaSuggestions), suggestions: @js($afiaSuggestions),
@@ -48,7 +48,7 @@
</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">QR Plus assistant</p> <p class="text-[11px] text-slate-400">Link assistant</p>
</div> </div>
</div> </div>
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
+3
View File
@@ -1,6 +1,7 @@
<?php <?php
use App\Http\Controllers\Auth\SsoLoginController; use App\Http\Controllers\Auth\SsoLoginController;
use App\Http\Controllers\Link\AfiaController;
use App\Http\Controllers\Link\AnalyticsController; use App\Http\Controllers\Link\AnalyticsController;
use App\Http\Controllers\Link\CustomDomainController; use App\Http\Controllers\Link\CustomDomainController;
use App\Http\Controllers\Link\LinkController; use App\Http\Controllers\Link\LinkController;
@@ -50,6 +51,8 @@ Route::middleware(['auth', 'platform.session'])->group(function () {
Route::patch('/links/{link}', [LinkController::class, 'update'])->name('user.links.update'); Route::patch('/links/{link}', [LinkController::class, 'update'])->name('user.links.update');
Route::delete('/links/{link}', [LinkController::class, 'destroy'])->name('user.links.destroy'); Route::delete('/links/{link}', [LinkController::class, 'destroy'])->name('user.links.destroy');
Route::post('/afia/chat', [AfiaController::class, 'chat'])->name('link.afia.chat');
Route::get('/wallet', fn () => redirect()->away(ladill_account_url('/wallet')))->name('account.wallet'); Route::get('/wallet', fn () => redirect()->away(ladill_account_url('/wallet')))->name('account.wallet');
Route::get('/billing', fn () => redirect()->away(ladill_account_url('/billing')))->name('account.billing'); Route::get('/billing', fn () => redirect()->away(ladill_account_url('/billing')))->name('account.billing');
}); });
+4 -4
View File
@@ -39,16 +39,16 @@ class AfiaTest extends TestCase
{ {
config(['afia.api_key' => 'sk-test', 'afia.provider' => 'openai', 'afia.model' => 'gpt-4o-mini']); config(['afia.api_key' => 'sk-test', 'afia.provider' => 'openai', 'afia.model' => 'gpt-4o-mini']);
Http::fake([ Http::fake([
'api.openai.com/*' => Http::response(['choices' => [['message' => ['content' => 'Create a Business QR under My Codes.']]]]), 'api.openai.com/*' => Http::response(['choices' => [['message' => ['content' => 'Create a short link under My Links.']]]]),
]); ]);
$this->actingAs($this->user()) $this->actingAs($this->user())
->postJson('/afia/chat', ['message' => 'How do I create a business QR?']) ->postJson('/afia/chat', ['message' => 'How do I create a short link?'])
->assertOk() ->assertOk()
->assertJson(['reply' => 'Create a Business QR under My Codes.']); ->assertJson(['reply' => 'Create a short link under My Links.']);
Http::assertSent(fn ($r) => str_contains($r->url(), 'openai.com') Http::assertSent(fn ($r) => str_contains($r->url(), 'openai.com')
&& collect($r['messages'])->first()['role'] === 'system' && collect($r['messages'])->first()['role'] === 'system'
&& str_contains(collect($r['messages'])->first()['content'], 'Ladill QR Plus')); && str_contains(collect($r['messages'])->first()['content'], 'Ladill Link'));
} }
} }