diff --git a/app/Http/Controllers/Woo/AiController.php b/app/Http/Controllers/Woo/AiController.php new file mode 100644 index 0000000..404718c --- /dev/null +++ b/app/Http/Controllers/Woo/AiController.php @@ -0,0 +1,63 @@ +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 */ + 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, + ]; + } +} diff --git a/app/Services/Afia/AfiaService.php b/app/Services/Afia/AfiaService.php new file mode 100644 index 0000000..68d706b --- /dev/null +++ b/app/Services/Afia/AfiaService.php @@ -0,0 +1,169 @@ +hasLocalKey() || $this->hasPlatformRelay(); + } + + /** + * @param array $history + * @param array $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 $history + * @param array $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 $history + * @param array $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 $context */ + private function systemPrompt(array $context): string + { + $ctx = collect($context)->map(fn ($v, $k) => "- {$k}: {$v}")->implode("\n"); + + return << 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')), +]; diff --git a/resources/views/components/user-layout.blade.php b/resources/views/components/user-layout.blade.php index 52d0fc5..8d2a348 100644 --- a/resources/views/components/user-layout.blade.php +++ b/resources/views/components/user-layout.blade.php @@ -28,6 +28,7 @@ @auth @include('partials.sso-keepalive') + @include('partials.afia') @endauth diff --git a/resources/views/layouts/user.blade.php b/resources/views/layouts/user.blade.php index 52d0fc5..8d2a348 100644 --- a/resources/views/layouts/user.blade.php +++ b/resources/views/layouts/user.blade.php @@ -28,6 +28,7 @@ @auth @include('partials.sso-keepalive') + @include('partials.afia') @endauth diff --git a/resources/views/partials/afia.blade.php b/resources/views/partials/afia.blade.php index ea42e07..ba1b6ea 100644 --- a/resources/views/partials/afia.blade.php +++ b/resources/views/partials/afia.blade.php @@ -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 @@

Afia

-

Invoice assistant

+

Woo Manager assistant

diff --git a/routes/web.php b/routes/web.php index 317639f..35605a4 100644 --- a/routes/web.php +++ b/routes/web.php @@ -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'); diff --git a/tests/Feature/WooAfiaTest.php b/tests/Feature/WooAfiaTest.php new file mode 100644 index 0000000..02dfad3 --- /dev/null +++ b/tests/Feature/WooAfiaTest.php @@ -0,0 +1,62 @@ +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.'); + } +}