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:
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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'),
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user