Add Afia AI assistant to the QR Plus header.
Deploy Ladill QR Plus / deploy (push) Successful in 1m33s
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:
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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'),
|
||||||
|
|||||||
@@ -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"/>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
@@ -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'));
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user