Wire Afia AI assistant for Woo Manager via platform relay.
Deploy Ladill Woo Manager / deploy (push) Successful in 1m19s
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:
@@ -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,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
</div>
|
||||
@auth
|
||||
@include('partials.sso-keepalive')
|
||||
@include('partials.afia')
|
||||
@endauth
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -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,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');
|
||||
|
||||
@@ -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.');
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user