Wire Afia AI assistant for Woo Manager via platform relay.
Deploy Ladill Woo Manager / deploy (push) Successful in 1m19s

The chat panel and POST /ai/chat route were missing after the Invoice scaffold; Afia now relays through the platform when no local OpenAI key is set.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
isaacclad
2026-07-07 01:23:21 +00:00
co-authored by Cursor
parent cebf0d2e61
commit 26b1d5b433
8 changed files with 316 additions and 6 deletions
+63
View File
@@ -0,0 +1,63 @@
<?php
namespace App\Http\Controllers\Woo;
use App\Http\Controllers\Controller;
use App\Http\Controllers\Woo\Concerns\ResolvesWooContext;
use App\Models\WooOrder;
use App\Models\WooProduct;
use App\Models\WooStore;
use App\Services\Afia\AfiaService;
use App\Services\Woo\SubscriptionService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class AiController extends Controller
{
use ResolvesWooContext;
public function __construct(private SubscriptionService $subscriptions) {}
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($request));
} 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(Request $request): array
{
$user = $this->accountUser($request);
$store = $this->currentStore($request);
$storeIds = WooStore::query()->where('user_id', $user->id)->pluck('id');
return [
'signed_in' => 'yes',
'plan' => $this->subscriptions->hasPaidPlan($user) ? 'paid' : 'free',
'connected_stores' => WooStore::query()->where('user_id', $user->id)->where('status', WooStore::STATUS_ACTIVE)->count(),
'active_store' => $store?->site_name ?? $store?->site_url ?? 'none selected',
'products_total' => WooProduct::query()->whereIn('woo_store_id', $storeIds)->count(),
'orders_new' => $store
? WooOrder::query()->where('woo_store_id', $store->id)->where('fulfillment_status', WooOrder::FULFILLMENT_NEW)->count()
: 0,
];
}
}
+169
View File
@@ -0,0 +1,169 @@
<?php
namespace App\Services\Afia;
use Illuminate\Support\Facades\Http;
use RuntimeException;
class AfiaService
{
public function enabled(): bool
{
if (! (bool) config('afia.enabled', true)) {
return false;
}
return $this->hasLocalKey() || $this->hasPlatformRelay();
}
/**
* @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.');
}
if ($this->hasLocalKey()) {
return $this->chatLocally($message, $history, $context);
}
return $this->chatViaPlatform($message, $history, $context);
}
/**
* @param array<int, array{role: string, text: string}> $history
* @param array<string, mixed> $context
*/
private function chatLocally(string $message, array $history, array $context): string
{
$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);
}
/**
* @param array<int, array{role: string, text: string}> $history
* @param array<string, mixed> $context
*/
private function chatViaPlatform(string $message, array $history, array $context): string
{
$base = rtrim((string) config('afia.platform_api_url', ''), '/');
$token = (string) config('afia.platform_api_key', '');
$res = Http::withToken($token)->acceptJson()->timeout(50)->post($base.'/afia/chat', [
'product' => (string) config('afia.product', 'woo'),
'message' => $message,
'history' => $history,
'system_prompt' => $this->systemPrompt($context),
]);
if ($res->status() === 503) {
throw new RuntimeException('Afia is not configured on the platform.');
}
if ($res->failed()) {
throw new RuntimeException('Platform Afia relay failed: '.$res->status());
}
$reply = trim((string) $res->json('reply', ''));
if ($reply === '') {
throw new RuntimeException('Platform Afia relay returned an empty response.');
}
return $reply;
}
private function hasLocalKey(): bool
{
return (string) config('afia.api_key', '') !== '';
}
private function hasPlatformRelay(): bool
{
return rtrim((string) config('afia.platform_api_url', ''), '/') !== ''
&& (string) config('afia.platform_api_key', '') !== '';
}
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 <<<PROMPT
You are Afia, the assistant inside Ladill Woo Manager (woo.ladill.com).
Help merchants connect WooCommerce stores, fulfill orders, sync products and categories, and manage catalog content. Be concise, friendly, and actionable.
What Ladill Woo Manager does:
- Connect a WordPress/WooCommerce store via the Ladill plugin (Connect with Ladill).
- Switch between multiple stores from the top bar (Pro plan).
- Orders: sync and update fulfillment status; payments stay in WooCommerce.
- Products & categories: sync from the store, edit, and push changes back when the plugin is connected.
- Stores page: view connected sites, switch active store, disconnect.
- Free plan: 1 store and 20 products. Pro unlocks multiple stores and unlimited products.
Rules:
- Only answer questions about Ladill Woo Manager and WooCommerce fulfillment via Ladill.
- If asked about invoicing, POS, or email, say those live in other Ladill apps.
- Never invent order numbers or product counts use the user context below or say where to check.
Current user context:
{$ctx}
PROMPT;
}
}
+11
View File
@@ -0,0 +1,11 @@
<?php
return [
'product' => env('AFIA_PRODUCT', 'woo'),
'enabled' => (bool) env('AFIA_ENABLED', true),
'provider' => env('AFIA_PROVIDER', 'openai'),
'model' => env('AFIA_MODEL', 'gpt-4o-mini'),
'api_key' => env('AFIA_API_KEY', env('OPENAI_API_KEY')),
'platform_api_url' => env('AFIA_PLATFORM_API_URL', env('IDENTITY_API_URL', 'https://ladill.com/api')),
'platform_api_key' => env('AFIA_PLATFORM_API_KEY', env('IDENTITY_API_KEY_WOO')),
];
@@ -28,6 +28,7 @@
</div>
@auth
@include('partials.sso-keepalive')
@include('partials.afia')
@endauth
</body>
</html>
+1
View File
@@ -28,6 +28,7 @@
</div>
@auth
@include('partials.sso-keepalive')
@include('partials.afia')
@endauth
</body>
</html>
+6 -6
View File
@@ -1,10 +1,10 @@
@php
$afiaGreeting = "Hi, I'm Afia 👋 Ask me about creating invoices, getting paid online, customers, products, or templates…";
$afiaGreeting = "Hi, I'm Afia 👋 Ask me about connecting your WooCommerce store, syncing orders, or managing products…";
$afiaSuggestions = [
'How do I create an invoice?',
'How do customers pay an invoice?',
'How do I add a product?',
'Where do payments settle?',
'How do I connect my WooCommerce store?',
'How do I sync orders for fulfillment?',
'How do I add or edit a product?',
'What is the difference between Free and Pro?',
];
@endphp
{{-- Afia Ladill AI assistant slide-over. Opened via $dispatch('afia-open'). --}}
@@ -48,7 +48,7 @@
</span>
<div class="leading-tight">
<p class="text-sm font-semibold text-slate-900">Afia</p>
<p class="text-[11px] text-slate-400">Invoice assistant</p>
<p class="text-[11px] text-slate-400">Woo Manager assistant</p>
</div>
</div>
<div class="flex items-center gap-1">
+3
View File
@@ -3,6 +3,7 @@
use App\Http\Controllers\Auth\SsoLoginController;
use App\Http\Controllers\NotificationController;
use App\Http\Controllers\WalletBalanceController;
use App\Http\Controllers\Woo\AiController;
use App\Http\Controllers\Woo\CategoryController;
use App\Http\Controllers\Woo\ConnectWordPressController;
use App\Http\Controllers\Woo\DashboardController;
@@ -62,6 +63,8 @@ Route::middleware(['auth', 'platform.session'])->group(function () {
Route::delete('/stores/{store}', [StoreController::class, 'destroy'])->name('woo.stores.destroy');
Route::get('/settings', [SettingsController::class, 'edit'])->name('woo.settings');
Route::post('/ai/chat', [AiController::class, 'chat'])->middleware('throttle:30,1')->name('woo.ai.chat');
Route::get('/pro', [ProController::class, 'index'])->name('woo.pro.index');
Route::post('/pro/subscribe', [ProController::class, 'subscribe'])->name('woo.pro.subscribe');
Route::post('/pro/subscribe-enterprise', [ProController::class, 'subscribeEnterprise'])->name('woo.pro.subscribe-enterprise');
+62
View File
@@ -0,0 +1,62 @@
<?php
namespace Tests\Feature;
use App\Models\User;
use App\Services\Afia\AfiaService;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Http;
use Tests\TestCase;
class WooAfiaTest extends TestCase
{
use RefreshDatabase;
public function test_dashboard_includes_afia_panel_and_chat_route(): void
{
$user = User::factory()->create();
$this->actingAs($user)
->get(route('woo.dashboard'))
->assertOk()
->assertSee('Open Afia AI assistant', false)
->assertSee('Woo Manager assistant', false)
->assertSee(route('woo.ai.chat'), false);
}
public function test_afia_chat_returns_reply_when_enabled(): void
{
config(['afia.enabled' => true, 'afia.api_key' => 'test-key']);
$this->mock(AfiaService::class, function ($mock) {
$mock->shouldReceive('enabled')->andReturn(true);
$mock->shouldReceive('chat')->once()->andReturn('Install the Ladill plugin and click Connect with Ladill.');
});
$this->actingAs(User::factory()->create())
->postJson(route('woo.ai.chat'), ['message' => 'How do I connect my store?'])
->assertOk()
->assertJsonPath('reply', 'Install the Ladill plugin and click Connect with Ladill.');
}
public function test_afia_chat_uses_platform_relay_when_local_key_missing(): void
{
config([
'afia.enabled' => true,
'afia.api_key' => '',
'afia.platform_api_url' => 'https://platform.test/api',
'afia.platform_api_key' => 'woo-relay-key',
]);
Http::fake([
'platform.test/api/afia/chat' => Http::response([
'reply' => 'Use Sync from store on the Orders page.',
]),
]);
$this->actingAs(User::factory()->create())
->postJson(route('woo.ai.chat'), ['message' => 'How do I sync orders?'])
->assertOk()
->assertJsonPath('reply', 'Use Sync from store on the Orders page.');
}
}