diff --git a/.env.example b/.env.example index 8af40f5..7cc03f9 100644 --- a/.env.example +++ b/.env.example @@ -32,4 +32,10 @@ IDENTITY_API_KEY_LINK= 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}" diff --git a/app/Http/Controllers/Link/AfiaController.php b/app/Http/Controllers/Link/AfiaController.php new file mode 100644 index 0000000..67680b2 --- /dev/null +++ b/app/Http/Controllers/Link/AfiaController.php @@ -0,0 +1,84 @@ +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']; + } + + $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; + } +} diff --git a/app/Services/Afia/AfiaService.php b/app/Services/Afia/AfiaService.php index 377ef7e..62ae01a 100644 --- a/app/Services/Afia/AfiaService.php +++ b/app/Services/Afia/AfiaService.php @@ -6,7 +6,7 @@ use Illuminate\Support\Facades\Http; 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 { @@ -88,36 +88,30 @@ class AfiaService { $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), - }; + return $this->linkSystemPrompt($ctx); } - private function qrSystemPrompt(string $ctx): string + private function linkSystemPrompt(string $ctx): string { return << env('AFIA_PRODUCT', 'qr'), + // Afia — in-app AI assistant scoped to Ladill Link. + 'product' => env('AFIA_PRODUCT', 'link'), 'enabled' => (bool) env('AFIA_ENABLED', true), 'provider' => env('AFIA_PROVIDER', 'openai'), // openai | anthropic 'model' => env('AFIA_MODEL', 'gpt-4o-mini'), diff --git a/resources/views/layouts/user.blade.php b/resources/views/layouts/user.blade.php index 0b72d2c..7b9b114 100644 --- a/resources/views/layouts/user.blade.php +++ b/resources/views/layouts/user.blade.php @@ -243,5 +243,8 @@ document.addEventListener('alpine:init', () => { @include('partials.sso-keepalive') @include('partials.confirm-prompt') +@auth + @include('partials.afia') +@endauth diff --git a/resources/views/partials/afia.blade.php b/resources/views/partials/afia.blade.php index 14700d5..d84ceef 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 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 = [ - '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?', + 'How do I create a short link?', + 'How do I connect a custom domain?', + 'Where do I see click stats?', + 'Why was my wallet debited when I created a link?', ]; @endphp {{-- Afia — Ladill AI assistant slide-over. Opened via $dispatch('afia-open'). --}}

Afia

-

QR Plus assistant

+

Link assistant

diff --git a/routes/web.php b/routes/web.php index 3dd169e..ebada98 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,6 +1,7 @@ group(function () { Route::patch('/links/{link}', [LinkController::class, 'update'])->name('user.links.update'); 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('/billing', fn () => redirect()->away(ladill_account_url('/billing')))->name('account.billing'); }); diff --git a/tests/Feature/AfiaTest.php b/tests/Feature/AfiaTest.php index 7868306..185c17f 100644 --- a/tests/Feature/AfiaTest.php +++ b/tests/Feature/AfiaTest.php @@ -39,16 +39,16 @@ class AfiaTest extends TestCase { 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.']]]]), + 'api.openai.com/*' => Http::response(['choices' => [['message' => ['content' => 'Create a short link under My Links.']]]]), ]); $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() - ->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') && 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')); } }