Add bnfexpress signed admin client and AI Agent Filament UI
- Modules\Shared\Bnfexpress\BnfexpressAdminClient: HMAC-signed HTTP client for bnfexpress's admin API (EV FAQs, agent instructions, chat history), with a bnfexpress:smoke-test command and full unit coverage. - New ai-agent module: Filament pages to manage EV FAQs, publish/roll back agent instruction versions, and browse EV chat history + transcripts. - New manage_ai_agent permission (super_admin/admin). - Recorded .ai/rules for the client's auth scheme and non-Resource Filament page/table testing gotchas.
This commit is contained in:
@@ -0,0 +1,15 @@
|
|||||||
|
---
|
||||||
|
paths:
|
||||||
|
- 'app-modules/shared/src/Bnfexpress/**'
|
||||||
|
---
|
||||||
|
|
||||||
|
# Bnfexpress
|
||||||
|
|
||||||
|
## bnfexpress admin API calls go through BnfexpressAdminClient
|
||||||
|
Signed backend-to-backend calls to bnfexpress's admin API (EV FAQs, agent instructions, chat history) go through `Modules\Shared\Bnfexpress\BnfexpressAdminClient` — do not call `Http::` directly against BNFEXPRESS_AI_API_URL elsewhere.
|
||||||
|
|
||||||
|
Auth is HMAC, not JWT/session: X-Client-Id/X-Timestamp/X-Signature per `BnfexpressSignature::headers()`, signed over `METHOD\nPATH\nTIMESTAMP\nRAW_BODY` (path only, no query string; empty string body for GET/DELETE). Timestamps must be generated fresh per request (server rejects >300s skew) — never cache/reuse a signed header set.
|
||||||
|
|
||||||
|
Config lives in `config('services.bnfexpress')` (BNFEXPRESS_AI_API_URL/CLIENT_ID/CLIENT_SECRET in .env). The client_secret must match bnfexpress's own ADMIN_SERVICE_CLIENTS entry for ev_admin — get it from whoever manages that deploy.
|
||||||
|
|
||||||
|
Non-2xx responses throw `BnfexpressApiException` carrying the gateway's `{"detail": "..."}` message. Verify signing end-to-end with `php artisan bnfexpress:smoke-test` before wiring up any UI.
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
# Project Rules Index
|
||||||
|
|
||||||
|
Before planning or editing, find the row whose globs match the file's path and read that rule file.
|
||||||
|
|
||||||
|
| Applies to | Rule file |
|
||||||
|
| --- | --- |
|
||||||
|
| app-modules/shared/src/Bnfexpress/** | .ai/rules/bnfexpress.md |
|
||||||
|
| app-modules/*/src/Filament/Pages/** | .ai/rules/pages.md |
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
---
|
||||||
|
paths:
|
||||||
|
- 'app-modules/*/src/Filament/Pages/**'
|
||||||
|
---
|
||||||
|
|
||||||
|
# Pages
|
||||||
|
|
||||||
|
## Non-Resource Filament pages need an explicit table-rendering view + deferLoading-aware tests
|
||||||
|
A `Filament\Pages\Page implements HasTable` (not a Resource) does NOT render its table automatically — it must set `protected string $view = '<module>::filament.pages.<slug>';` pointing at a Blade file containing `<x-filament-panels::page>{{ $this->table }}</x-filament-panels::page>` (see `ManageFaqs`/`ViewEvChatHistory`/`BookingsRevenueReport`). Omitting this silently renders an empty page — no error, just a blank `fi-page-content`.
|
||||||
|
|
||||||
|
Filament v4 tables default to deferred loading. In Pest/Livewire tests, call `->loadTable()` before any `assertSee()`/`assertCanSeeTableRecords()` on a freshly-mounted component, or the table body won't be in the rendered HTML yet.
|
||||||
|
|
||||||
|
For custom-data (`->records()`-backed, non-Eloquent) tables: use `->callTableAction($name, $record, data: [...])` / `->mountTableAction(...)` (not the generic `->callAction()`, which targets page-level actions and misses table header/record actions), and use `->assertMountedActionModalSee(...)` to check `->modalContent()` output — modal content is lazily rendered and won't appear in a plain `->html()`/`->assertSee()` snapshot even after mounting the action. See `[[project_internachi_modular]]`-style module layout in `app-modules/ai-agent`.
|
||||||
@@ -99,3 +99,8 @@ VITE_APP_NAME="${APP_NAME}"
|
|||||||
|
|
||||||
FASTAPI_AGENT_JWT_SECRET=
|
FASTAPI_AGENT_JWT_SECRET=
|
||||||
FASTAPI_AGENT_JWT_ALGORITHM=HS256
|
FASTAPI_AGENT_JWT_ALGORITHM=HS256
|
||||||
|
|
||||||
|
# Must match the ev_admin entry in bnfexpress's own ADMIN_SERVICE_CLIENTS.
|
||||||
|
BNFEXPRESS_AI_API_URL=http://bnfexpress-app:8000
|
||||||
|
BNFEXPRESS_AI_CLIENT_ID=ev_admin
|
||||||
|
BNFEXPRESS_AI_CLIENT_SECRET=
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"name": "modules/ai-agent",
|
||||||
|
"description": "",
|
||||||
|
"type": "library",
|
||||||
|
"version": "1.0",
|
||||||
|
"license": "proprietary",
|
||||||
|
"require": {},
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Modules\\AiAgent\\": "src/",
|
||||||
|
"Modules\\AiAgent\\Tests\\": "tests/",
|
||||||
|
"Modules\\AiAgent\\Database\\Factories\\": "database/factories/",
|
||||||
|
"Modules\\AiAgent\\Database\\Seeders\\": "database/seeders/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"minimum-stability": "stable",
|
||||||
|
"extra": {
|
||||||
|
"laravel": {
|
||||||
|
"providers": [
|
||||||
|
"Modules\\AiAgent\\Providers\\AiAgentServiceProvider"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+13
@@ -0,0 +1,13 @@
|
|||||||
|
<x-filament-panels::page>
|
||||||
|
<x-filament::section heading="Active Instruction">
|
||||||
|
@if ($active)
|
||||||
|
<pre class="whitespace-pre-wrap text-sm">{{ $active['content'] ?? '' }}</pre>
|
||||||
|
@elseif ($activeError)
|
||||||
|
<p class="text-sm text-danger-600">Could not load the active instruction: {{ $activeError }}</p>
|
||||||
|
@else
|
||||||
|
<p class="text-sm text-gray-500">No active instruction.</p>
|
||||||
|
@endif
|
||||||
|
</x-filament::section>
|
||||||
|
|
||||||
|
{{ $this->table }}
|
||||||
|
</x-filament-panels::page>
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
<x-filament-panels::page>
|
||||||
|
{{ $this->table }}
|
||||||
|
</x-filament-panels::page>
|
||||||
+1
@@ -0,0 +1 @@
|
|||||||
|
<p class="text-sm text-danger-600">Could not load this transcript: {{ $message }}</p>
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
@php
|
||||||
|
$labels = [
|
||||||
|
'user' => 'User',
|
||||||
|
'bnfexpress_ev_agent' => 'Assistant',
|
||||||
|
];
|
||||||
|
@endphp
|
||||||
|
|
||||||
|
<div class="max-h-[32rem] space-y-3 overflow-y-auto">
|
||||||
|
@forelse (($transcript['messages'] ?? []) as $message)
|
||||||
|
@php
|
||||||
|
$author = $message['author'] ?? 'unknown';
|
||||||
|
$isUser = $author === 'user';
|
||||||
|
@endphp
|
||||||
|
<div @class([
|
||||||
|
'max-w-[85%] rounded-lg border p-3',
|
||||||
|
'ms-auto border-primary-200 bg-primary-50 dark:border-primary-800 dark:bg-primary-950' => $isUser,
|
||||||
|
'border-gray-200 bg-gray-50 dark:border-gray-700 dark:bg-gray-800' => ! $isUser,
|
||||||
|
])>
|
||||||
|
<div class="mb-1 flex items-center justify-between gap-3">
|
||||||
|
<span class="text-xs font-medium uppercase text-gray-500 dark:text-gray-400">
|
||||||
|
{{ $labels[$author] ?? $author }}
|
||||||
|
</span>
|
||||||
|
@if (isset($message['timestamp']))
|
||||||
|
<span class="text-xs text-gray-400 dark:text-gray-500">
|
||||||
|
{{ \Illuminate\Support\Carbon::createFromTimestamp($message['timestamp'])->format('M j, Y g:i A') }}
|
||||||
|
</span>
|
||||||
|
@endif
|
||||||
|
</div>
|
||||||
|
<p class="whitespace-pre-wrap text-sm">{{ $message['text'] ?? '' }}</p>
|
||||||
|
</div>
|
||||||
|
@empty
|
||||||
|
<p class="text-sm text-gray-500">No messages in this session.</p>
|
||||||
|
@endforelse
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
<x-filament-panels::page>
|
||||||
|
{{ $this->table }}
|
||||||
|
</x-filament-panels::page>
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\AiAgent;
|
||||||
|
|
||||||
|
use Filament\Contracts\Plugin;
|
||||||
|
use Filament\Panel;
|
||||||
|
|
||||||
|
class AiAgentPlugin implements Plugin
|
||||||
|
{
|
||||||
|
public function getId(): string
|
||||||
|
{
|
||||||
|
return 'ai-agent';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function register(Panel $panel): void
|
||||||
|
{
|
||||||
|
$panel->discoverPages(
|
||||||
|
in: __DIR__.'/Filament/Pages',
|
||||||
|
for: 'Modules\AiAgent\Filament\Pages',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function boot(Panel $panel): void {}
|
||||||
|
|
||||||
|
public static function make(): static
|
||||||
|
{
|
||||||
|
return app(static::class);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\AiAgent\Filament\Concerns;
|
||||||
|
|
||||||
|
use Filament\Notifications\Notification;
|
||||||
|
use Modules\Shared\Bnfexpress\Exceptions\BnfexpressApiException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Shared try/call/notify wrapper for bnfexpress admin API calls triggered
|
||||||
|
* from a Filament action — every mutating action across the AI Agent pages
|
||||||
|
* (create/update/delete/publish/activate) follows this same shape.
|
||||||
|
*/
|
||||||
|
trait HandlesBnfexpressErrors
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param callable(): void $callback
|
||||||
|
*/
|
||||||
|
protected function callBnfexpress(callable $callback, string $successTitle, string $failureTitle): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$callback();
|
||||||
|
|
||||||
|
Notification::make()->title($successTitle)->success()->send();
|
||||||
|
} catch (BnfexpressApiException $exception) {
|
||||||
|
Notification::make()->title($failureTitle)->body($exception->getMessage())->danger()->send();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\AiAgent\Filament\Pages;
|
||||||
|
|
||||||
|
use BackedEnum;
|
||||||
|
use Filament\Actions\Action;
|
||||||
|
use Filament\Forms\Components\Textarea;
|
||||||
|
use Filament\Forms\Components\Toggle;
|
||||||
|
use Filament\Notifications\Notification;
|
||||||
|
use Filament\Pages\Page;
|
||||||
|
use Filament\Support\Icons\Heroicon;
|
||||||
|
use Filament\Tables\Columns\IconColumn;
|
||||||
|
use Filament\Tables\Columns\TextColumn;
|
||||||
|
use Filament\Tables\Concerns\InteractsWithTable;
|
||||||
|
use Filament\Tables\Contracts\HasTable;
|
||||||
|
use Filament\Tables\Table;
|
||||||
|
use Illuminate\Pagination\LengthAwarePaginator;
|
||||||
|
use Modules\AiAgent\Filament\Concerns\HandlesBnfexpressErrors;
|
||||||
|
use Modules\Shared\Bnfexpress\BnfexpressAdminClient;
|
||||||
|
use Modules\Shared\Bnfexpress\Exceptions\BnfexpressApiException;
|
||||||
|
use UnitEnum;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Version history + publish/rollback for the EV agent's system prompt.
|
||||||
|
* Needs more than a bare table (an "active version" banner above the
|
||||||
|
* history), so — like ManageAppSettings — it renders through a custom view
|
||||||
|
* rather than relying purely on Filament's generated table layout.
|
||||||
|
*/
|
||||||
|
class ManageAgentInstructions extends Page implements HasTable
|
||||||
|
{
|
||||||
|
use HandlesBnfexpressErrors;
|
||||||
|
use InteractsWithTable;
|
||||||
|
|
||||||
|
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedDocumentText;
|
||||||
|
|
||||||
|
protected static string|UnitEnum|null $navigationGroup = 'AI Agent';
|
||||||
|
|
||||||
|
protected static ?string $navigationLabel = 'Agent Instructions';
|
||||||
|
|
||||||
|
protected static ?string $title = 'Agent Instructions';
|
||||||
|
|
||||||
|
protected string $view = 'ai-agent::filament.pages.manage-agent-instructions';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var array<string, mixed>|null
|
||||||
|
*/
|
||||||
|
public ?array $active = null;
|
||||||
|
|
||||||
|
public ?string $activeError = null;
|
||||||
|
|
||||||
|
public static function canAccess(): bool
|
||||||
|
{
|
||||||
|
return auth()->user()?->can('manage_ai_agent') ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function mount(): void
|
||||||
|
{
|
||||||
|
$this->refreshActive();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function table(Table $table): Table
|
||||||
|
{
|
||||||
|
return $table
|
||||||
|
->records(function (int $page, int $recordsPerPage): LengthAwarePaginator {
|
||||||
|
$result = app(BnfexpressAdminClient::class)->listInstructions(
|
||||||
|
limit: $recordsPerPage,
|
||||||
|
offset: ($page - 1) * $recordsPerPage,
|
||||||
|
);
|
||||||
|
|
||||||
|
$items = $result['instructions'] ?? (array_is_list($result) ? $result : []);
|
||||||
|
$total = $result['total'] ?? (($page - 1) * $recordsPerPage) + count($items) + (count($items) === $recordsPerPage ? 1 : 0);
|
||||||
|
|
||||||
|
return new LengthAwarePaginator(
|
||||||
|
items: collect($items)->mapWithKeys(fn (array $item): array => [$item['id'] => $item]),
|
||||||
|
total: $total,
|
||||||
|
perPage: $recordsPerPage,
|
||||||
|
currentPage: $page,
|
||||||
|
);
|
||||||
|
})
|
||||||
|
->columns([
|
||||||
|
TextColumn::make('id'),
|
||||||
|
IconColumn::make('is_active')->boolean(),
|
||||||
|
TextColumn::make('content')->limit(80)->wrap(),
|
||||||
|
TextColumn::make('created_at')->dateTime(),
|
||||||
|
])
|
||||||
|
->recordActions([
|
||||||
|
Action::make('activate')
|
||||||
|
->label('Activate')
|
||||||
|
->icon(Heroicon::OutlinedArrowUturnLeft)
|
||||||
|
->visible(fn (array $record): bool => ! ($record['is_active'] ?? false))
|
||||||
|
->requiresConfirmation()
|
||||||
|
->action(function (array $record): void {
|
||||||
|
$this->callBnfexpress(
|
||||||
|
fn () => app(BnfexpressAdminClient::class)->activateInstruction($record['id']),
|
||||||
|
successTitle: 'Instruction activated',
|
||||||
|
failureTitle: 'Failed to activate instruction',
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->resetTable();
|
||||||
|
$this->refreshActive();
|
||||||
|
}),
|
||||||
|
])
|
||||||
|
->headerActions([
|
||||||
|
Action::make('publish')
|
||||||
|
->label('Publish New Version')
|
||||||
|
->icon(Heroicon::OutlinedPlusCircle)
|
||||||
|
->schema([
|
||||||
|
Textarea::make('content')
|
||||||
|
->required()
|
||||||
|
->rows(10),
|
||||||
|
Toggle::make('activate')
|
||||||
|
->label('Activate immediately')
|
||||||
|
->default(true)
|
||||||
|
->helperText('Deactivates the current active version automatically.'),
|
||||||
|
])
|
||||||
|
->action(function (array $data): void {
|
||||||
|
$this->callBnfexpress(
|
||||||
|
fn () => app(BnfexpressAdminClient::class)->publishInstruction($data['content'], $data['activate']),
|
||||||
|
successTitle: 'New instruction version published',
|
||||||
|
failureTitle: 'Failed to publish instruction',
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->resetTable();
|
||||||
|
$this->refreshActive();
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function refreshActive(): void
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$this->active = app(BnfexpressAdminClient::class)->getActiveInstruction();
|
||||||
|
$this->activeError = null;
|
||||||
|
} catch (BnfexpressApiException $exception) {
|
||||||
|
$this->active = null;
|
||||||
|
$this->activeError = $exception->getMessage();
|
||||||
|
|
||||||
|
Notification::make()
|
||||||
|
->title('Could not load the active instruction')
|
||||||
|
->body($exception->getMessage())
|
||||||
|
->danger()
|
||||||
|
->send();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\AiAgent\Filament\Pages;
|
||||||
|
|
||||||
|
use BackedEnum;
|
||||||
|
use Filament\Actions\Action;
|
||||||
|
use Filament\Forms\Components\KeyValue;
|
||||||
|
use Filament\Forms\Components\Textarea;
|
||||||
|
use Filament\Pages\Page;
|
||||||
|
use Filament\Schemas\Components\Component;
|
||||||
|
use Filament\Support\Icons\Heroicon;
|
||||||
|
use Filament\Tables\Columns\TextColumn;
|
||||||
|
use Filament\Tables\Concerns\InteractsWithTable;
|
||||||
|
use Filament\Tables\Contracts\HasTable;
|
||||||
|
use Filament\Tables\Filters\SelectFilter;
|
||||||
|
use Filament\Tables\Table;
|
||||||
|
use Illuminate\Pagination\LengthAwarePaginator;
|
||||||
|
use Modules\AiAgent\Filament\Concerns\HandlesBnfexpressErrors;
|
||||||
|
use Modules\Shared\Bnfexpress\BnfexpressAdminClient;
|
||||||
|
use UnitEnum;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Manage EV FAQs stored by bnfexpress — data isn't Eloquent-backed, so the
|
||||||
|
* table is fed via Table::records() (Filament's documented "custom data"
|
||||||
|
* mechanism) rather than a query, and row/header actions use plain
|
||||||
|
* Filament\Actions\Action instead of EditAction/DeleteAction (which assume
|
||||||
|
* a Model).
|
||||||
|
*/
|
||||||
|
class ManageFaqs extends Page implements HasTable
|
||||||
|
{
|
||||||
|
use HandlesBnfexpressErrors;
|
||||||
|
use InteractsWithTable;
|
||||||
|
|
||||||
|
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedQuestionMarkCircle;
|
||||||
|
|
||||||
|
protected static string|UnitEnum|null $navigationGroup = 'AI Agent';
|
||||||
|
|
||||||
|
protected static ?string $navigationLabel = 'EV FAQs';
|
||||||
|
|
||||||
|
protected static ?string $title = 'EV FAQs';
|
||||||
|
|
||||||
|
protected string $view = 'ai-agent::filament.pages.manage-faqs';
|
||||||
|
|
||||||
|
public static function canAccess(): bool
|
||||||
|
{
|
||||||
|
return auth()->user()?->can('manage_ai_agent') ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function table(Table $table): Table
|
||||||
|
{
|
||||||
|
return $table
|
||||||
|
->records(function (?string $search, array $filters, int $page, int $recordsPerPage): LengthAwarePaginator {
|
||||||
|
$result = app(BnfexpressAdminClient::class)->listFaqs(
|
||||||
|
q: $search,
|
||||||
|
search: $filters['search_mode']['value'] ?? 'normal',
|
||||||
|
limit: $recordsPerPage,
|
||||||
|
offset: ($page - 1) * $recordsPerPage,
|
||||||
|
);
|
||||||
|
|
||||||
|
return $this->paginate($result, 'faqs', $page, $recordsPerPage);
|
||||||
|
})
|
||||||
|
->columns([
|
||||||
|
TextColumn::make('id'),
|
||||||
|
TextColumn::make('content')
|
||||||
|
->limit(80)
|
||||||
|
->wrap(),
|
||||||
|
TextColumn::make('metadata')
|
||||||
|
->formatStateUsing(fn (mixed $state): string => json_encode($state ?? [], JSON_THROW_ON_ERROR))
|
||||||
|
->toggleable(isToggledHiddenByDefault: true),
|
||||||
|
])
|
||||||
|
->searchable()
|
||||||
|
->filters([
|
||||||
|
SelectFilter::make('search_mode')
|
||||||
|
->label('Search mode')
|
||||||
|
->options([
|
||||||
|
'normal' => 'Normal (substring)',
|
||||||
|
'semantic' => 'Semantic (meaning-based)',
|
||||||
|
])
|
||||||
|
->default('normal'),
|
||||||
|
])
|
||||||
|
->recordActions([
|
||||||
|
$this->editAction(),
|
||||||
|
$this->deleteAction(),
|
||||||
|
])
|
||||||
|
->headerActions([
|
||||||
|
$this->createAction(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function createAction(): Action
|
||||||
|
{
|
||||||
|
return Action::make('create')
|
||||||
|
->label('New FAQ')
|
||||||
|
->icon(Heroicon::OutlinedPlus)
|
||||||
|
->schema($this->formSchema())
|
||||||
|
->action(function (array $data): void {
|
||||||
|
$this->callBnfexpress(
|
||||||
|
fn () => app(BnfexpressAdminClient::class)->createFaq($data['content'], $data['metadata'] ?? []),
|
||||||
|
successTitle: 'FAQ created',
|
||||||
|
failureTitle: 'Failed to create FAQ',
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->resetTable();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function editAction(): Action
|
||||||
|
{
|
||||||
|
return Action::make('edit')
|
||||||
|
->icon(Heroicon::OutlinedPencilSquare)
|
||||||
|
->fillForm(fn (array $record): array => $record)
|
||||||
|
->schema($this->formSchema())
|
||||||
|
->action(function (array $data, array $record): void {
|
||||||
|
$this->callBnfexpress(
|
||||||
|
fn () => app(BnfexpressAdminClient::class)->updateFaq($record['id'], $data['content'], $data['metadata'] ?? []),
|
||||||
|
successTitle: 'FAQ updated',
|
||||||
|
failureTitle: 'Failed to update FAQ',
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->resetTable();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
protected function deleteAction(): Action
|
||||||
|
{
|
||||||
|
return Action::make('delete')
|
||||||
|
->color('danger')
|
||||||
|
->icon(Heroicon::OutlinedTrash)
|
||||||
|
->requiresConfirmation()
|
||||||
|
->action(function (array $record): void {
|
||||||
|
$this->callBnfexpress(
|
||||||
|
fn () => app(BnfexpressAdminClient::class)->deleteFaq($record['id']),
|
||||||
|
successTitle: 'FAQ deleted',
|
||||||
|
failureTitle: 'Failed to delete FAQ',
|
||||||
|
);
|
||||||
|
|
||||||
|
$this->resetTable();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int, Component>
|
||||||
|
*/
|
||||||
|
protected function formSchema(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
Textarea::make('content')
|
||||||
|
->required()
|
||||||
|
->rows(4),
|
||||||
|
KeyValue::make('metadata'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* bnfexpress's list/faqs and list/agent-instructions endpoints return a
|
||||||
|
* bare JSON array (confirmed live via `php artisan bnfexpress:smoke-test`),
|
||||||
|
* with no total/limit/offset envelope — so there's no real total to
|
||||||
|
* report, and this falls back to a "there might be one more page"
|
||||||
|
* heuristic (also tolerates a {total, <$itemsKey>} envelope, in case
|
||||||
|
* that ever changes).
|
||||||
|
*
|
||||||
|
* @param array<string, mixed> $result
|
||||||
|
*/
|
||||||
|
private function paginate(array $result, string $itemsKey, int $page, int $recordsPerPage): LengthAwarePaginator
|
||||||
|
{
|
||||||
|
$items = $result[$itemsKey] ?? (array_is_list($result) ? $result : []);
|
||||||
|
|
||||||
|
$total = $result['total'] ?? (($page - 1) * $recordsPerPage) + count($items) + (count($items) === $recordsPerPage ? 1 : 0);
|
||||||
|
|
||||||
|
return new LengthAwarePaginator(
|
||||||
|
items: collect($items)->mapWithKeys(fn (array $item): array => [$item['id'] => $item]),
|
||||||
|
total: $total,
|
||||||
|
perPage: $recordsPerPage,
|
||||||
|
currentPage: $page,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\AiAgent\Filament\Pages;
|
||||||
|
|
||||||
|
use BackedEnum;
|
||||||
|
use Filament\Actions\Action;
|
||||||
|
use Filament\Notifications\Notification;
|
||||||
|
use Filament\Pages\Page;
|
||||||
|
use Filament\Support\Icons\Heroicon;
|
||||||
|
use Filament\Tables\Columns\TextColumn;
|
||||||
|
use Filament\Tables\Concerns\InteractsWithTable;
|
||||||
|
use Filament\Tables\Contracts\HasTable;
|
||||||
|
use Filament\Tables\Table;
|
||||||
|
use Illuminate\Contracts\View\View;
|
||||||
|
use Illuminate\Pagination\LengthAwarePaginator;
|
||||||
|
use Modules\Shared\Bnfexpress\BnfexpressAdminClient;
|
||||||
|
use Modules\Shared\Bnfexpress\Exceptions\BnfexpressApiException;
|
||||||
|
use UnitEnum;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read-only browse of all users' EV chat sessions. No create/edit/delete —
|
||||||
|
* this is a support/QA tool, not a data-management screen.
|
||||||
|
*/
|
||||||
|
class ViewEvChatHistory extends Page implements HasTable
|
||||||
|
{
|
||||||
|
use InteractsWithTable;
|
||||||
|
|
||||||
|
protected static string|BackedEnum|null $navigationIcon = Heroicon::OutlinedChatBubbleLeftRight;
|
||||||
|
|
||||||
|
protected static string|UnitEnum|null $navigationGroup = 'AI Agent';
|
||||||
|
|
||||||
|
protected static ?string $navigationLabel = 'EV Chat History';
|
||||||
|
|
||||||
|
protected static ?string $title = 'EV Chat History';
|
||||||
|
|
||||||
|
protected string $view = 'ai-agent::filament.pages.view-ev-chat-history';
|
||||||
|
|
||||||
|
public static function canAccess(): bool
|
||||||
|
{
|
||||||
|
return auth()->user()?->can('manage_ai_agent') ?? false;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function table(Table $table): Table
|
||||||
|
{
|
||||||
|
return $table
|
||||||
|
->records(function (int $page, int $recordsPerPage): LengthAwarePaginator {
|
||||||
|
$result = app(BnfexpressAdminClient::class)->listSessions(
|
||||||
|
limit: $recordsPerPage,
|
||||||
|
offset: ($page - 1) * $recordsPerPage,
|
||||||
|
);
|
||||||
|
|
||||||
|
$sessions = $result['sessions'] ?? [];
|
||||||
|
|
||||||
|
return new LengthAwarePaginator(
|
||||||
|
items: collect($sessions)->mapWithKeys(
|
||||||
|
fn (array $session): array => ["{$session['user_id']}:{$session['session_id']}" => $session]
|
||||||
|
),
|
||||||
|
total: $result['total'] ?? count($sessions),
|
||||||
|
perPage: $result['limit'] ?? $recordsPerPage,
|
||||||
|
currentPage: $page,
|
||||||
|
);
|
||||||
|
})
|
||||||
|
->columns([
|
||||||
|
TextColumn::make('session_id')
|
||||||
|
->limit(20)
|
||||||
|
->tooltip(fn (TextColumn $column): ?string => $this->tooltipIfTruncated($column)),
|
||||||
|
TextColumn::make('user_id')
|
||||||
|
->limit(20)
|
||||||
|
->tooltip(fn (TextColumn $column): ?string => $this->tooltipIfTruncated($column)),
|
||||||
|
TextColumn::make('title')->limit(50),
|
||||||
|
TextColumn::make('last_message')->limit(80)->wrap(),
|
||||||
|
TextColumn::make('updated_at')->dateTime(),
|
||||||
|
])
|
||||||
|
->recordActions([
|
||||||
|
Action::make('view')
|
||||||
|
->label('View Transcript')
|
||||||
|
->icon(Heroicon::OutlinedEye)
|
||||||
|
->modalHeading(fn (array $record): string => "Transcript — {$record['session_id']}")
|
||||||
|
->modalContent(fn (array $record): View => $this->transcriptView($record))
|
||||||
|
->modalWidth('2xl')
|
||||||
|
->modalSubmitAction(false)
|
||||||
|
->modalCancelActionLabel('Close'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function tooltipIfTruncated(TextColumn $column): ?string
|
||||||
|
{
|
||||||
|
$state = (string) $column->getState();
|
||||||
|
|
||||||
|
return strlen($state) > $column->getCharacterLimit() ? $state : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function transcriptView(array $record): View
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$transcript = app(BnfexpressAdminClient::class)->getSessionTranscript($record['user_id'], $record['session_id']);
|
||||||
|
|
||||||
|
return view('ai-agent::filament.pages.partials.transcript', ['transcript' => $transcript]);
|
||||||
|
} catch (BnfexpressApiException $exception) {
|
||||||
|
Notification::make()
|
||||||
|
->title('Could not load transcript')
|
||||||
|
->body($exception->getMessage())
|
||||||
|
->danger()
|
||||||
|
->send();
|
||||||
|
|
||||||
|
return view('ai-agent::filament.pages.partials.transcript-error', ['message' => $exception->getMessage()]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\AiAgent\Providers;
|
||||||
|
|
||||||
|
use Illuminate\Support\ServiceProvider;
|
||||||
|
|
||||||
|
class AiAgentServiceProvider extends ServiceProvider
|
||||||
|
{
|
||||||
|
public function register(): void {}
|
||||||
|
|
||||||
|
public function boot(): void {}
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Http\Client\Request;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
use Modules\AiAgent\Filament\Pages\ManageAgentInstructions;
|
||||||
|
use Spatie\Permission\Models\Permission;
|
||||||
|
|
||||||
|
beforeEach(function () {
|
||||||
|
config([
|
||||||
|
'services.bnfexpress.ai_api_url' => 'https://bnfexpress.test',
|
||||||
|
'services.bnfexpress.client_id' => 'ev_admin',
|
||||||
|
'services.bnfexpress.client_secret' => 'test-secret',
|
||||||
|
]);
|
||||||
|
|
||||||
|
Permission::findOrCreate('manage_ai_agent', 'web');
|
||||||
|
|
||||||
|
$this->admin = User::factory()->create()->givePermissionTo(['manage_ai_agent']);
|
||||||
|
$this->actingAs($this->admin);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a user without manage_ai_agent cannot access it', function () {
|
||||||
|
$this->actingAs(User::factory()->create());
|
||||||
|
|
||||||
|
expect(ManageAgentInstructions::canAccess())->toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it renders the active instruction and version history', function () {
|
||||||
|
Http::fake([
|
||||||
|
'bnfexpress.test/admin/agent-instructions/active*' => Http::response(['id' => 2, 'content' => 'You are the EV assistant.']),
|
||||||
|
'bnfexpress.test/admin/agent-instructions*' => Http::response([
|
||||||
|
'total' => 1,
|
||||||
|
'instructions' => [['id' => 2, 'is_active' => true, 'content' => 'You are the EV assistant.', 'created_at' => now()->toIso8601String()]],
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
Livewire::test(ManageAgentInstructions::class)
|
||||||
|
->assertOk()
|
||||||
|
->assertSee('You are the EV assistant.')
|
||||||
|
->loadTable()
|
||||||
|
->assertSee('You are the EV assistant.');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('publishing a new version calls publishInstruction and shows a success notification', function () {
|
||||||
|
Http::fake([
|
||||||
|
'bnfexpress.test/admin/agent-instructions/active*' => Http::response(['id' => 3, 'content' => 'New instructions.']),
|
||||||
|
'bnfexpress.test/admin/agent-instructions*' => fn (Request $request) => match ($request->method()) {
|
||||||
|
'POST' => Http::response(['id' => 3, 'content' => 'New instructions.', 'is_active' => true], 201),
|
||||||
|
default => Http::response(['total' => 0, 'instructions' => []]),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
Livewire::test(ManageAgentInstructions::class)
|
||||||
|
->loadTable()
|
||||||
|
->callTableAction('publish', data: ['content' => 'New instructions.', 'activate' => true])
|
||||||
|
->assertNotified('New instruction version published');
|
||||||
|
|
||||||
|
Http::assertSent(fn (Request $request) => $request->method() === 'POST'
|
||||||
|
&& str_contains($request->url(), '/admin/agent-instructions')
|
||||||
|
&& ! str_contains($request->url(), '/active')
|
||||||
|
&& $request['content'] === 'New instructions.'
|
||||||
|
&& $request['activate'] === true);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a failed publish surfaces the gateway detail message', function () {
|
||||||
|
Http::fake([
|
||||||
|
'bnfexpress.test/admin/agent-instructions/active*' => Http::response(['id' => 1, 'content' => 'Old instructions.']),
|
||||||
|
'bnfexpress.test/admin/agent-instructions*' => fn (Request $request) => match ($request->method()) {
|
||||||
|
'POST' => Http::response(['detail' => 'Content is required.'], 422),
|
||||||
|
default => Http::response(['total' => 0, 'instructions' => []]),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
Livewire::test(ManageAgentInstructions::class)
|
||||||
|
->loadTable()
|
||||||
|
->callTableAction('publish', data: ['content' => 'New instructions.', 'activate' => true])
|
||||||
|
->assertNotified('Failed to publish instruction');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('activate is hidden on the already-active row and visible on others', function () {
|
||||||
|
Http::fake([
|
||||||
|
'bnfexpress.test/admin/agent-instructions/active*' => Http::response(['id' => 2, 'content' => 'Current.']),
|
||||||
|
'bnfexpress.test/admin/agent-instructions*' => Http::response([
|
||||||
|
'total' => 2,
|
||||||
|
'instructions' => [
|
||||||
|
['id' => 2, 'is_active' => true, 'content' => 'Current.', 'created_at' => now()->toIso8601String()],
|
||||||
|
['id' => 1, 'is_active' => false, 'content' => 'Older.', 'created_at' => now()->toIso8601String()],
|
||||||
|
],
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
Livewire::test(ManageAgentInstructions::class)
|
||||||
|
->loadTable()
|
||||||
|
->assertTableActionHidden('activate', 2)
|
||||||
|
->assertTableActionVisible('activate', 1);
|
||||||
|
});
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Http\Client\Request;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
use Modules\AiAgent\Filament\Pages\ManageFaqs;
|
||||||
|
use Spatie\Permission\Models\Permission;
|
||||||
|
|
||||||
|
beforeEach(function () {
|
||||||
|
config([
|
||||||
|
'services.bnfexpress.ai_api_url' => 'https://bnfexpress.test',
|
||||||
|
'services.bnfexpress.client_id' => 'ev_admin',
|
||||||
|
'services.bnfexpress.client_secret' => 'test-secret',
|
||||||
|
]);
|
||||||
|
|
||||||
|
Permission::findOrCreate('manage_ai_agent', 'web');
|
||||||
|
|
||||||
|
$this->admin = User::factory()->create()->givePermissionTo(['manage_ai_agent']);
|
||||||
|
$this->actingAs($this->admin);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a user without manage_ai_agent cannot access it', function () {
|
||||||
|
$this->actingAs(User::factory()->create());
|
||||||
|
|
||||||
|
expect(ManageFaqs::canAccess())->toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it renders and lists faqs from the client', function () {
|
||||||
|
Http::fake(['bnfexpress.test/*' => Http::response([
|
||||||
|
'total' => 1,
|
||||||
|
'faqs' => [
|
||||||
|
['id' => 1, 'content' => 'How do I charge my EV?', 'metadata' => []],
|
||||||
|
],
|
||||||
|
])]);
|
||||||
|
|
||||||
|
Livewire::test(ManageFaqs::class)
|
||||||
|
->assertOk()
|
||||||
|
->loadTable()
|
||||||
|
->assertSee('How do I charge my EV?');
|
||||||
|
|
||||||
|
Http::assertSent(fn (Request $request) => str_contains($request->url(), '/admin/faqs') && $request->method() === 'GET');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('creating a faq calls createFaq and shows a success notification', function () {
|
||||||
|
Http::fake(['bnfexpress.test/*' => fn (Request $request) => match ($request->method()) {
|
||||||
|
'POST' => Http::response(['id' => 1, 'content' => 'New FAQ', 'metadata' => []], 201),
|
||||||
|
default => Http::response(['total' => 0, 'faqs' => []]),
|
||||||
|
}]);
|
||||||
|
|
||||||
|
Livewire::test(ManageFaqs::class)
|
||||||
|
->loadTable()
|
||||||
|
->callTableAction('create', data: ['content' => 'New FAQ', 'metadata' => []])
|
||||||
|
->assertNotified('FAQ created');
|
||||||
|
|
||||||
|
Http::assertSent(fn (Request $request) => $request->method() === 'POST' && $request['content'] === 'New FAQ');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a failed create surfaces the gateway detail message', function () {
|
||||||
|
Http::fake(['bnfexpress.test/*' => fn (Request $request) => match ($request->method()) {
|
||||||
|
'POST' => Http::response(['detail' => 'Content is required.'], 422),
|
||||||
|
default => Http::response(['total' => 0, 'faqs' => []]),
|
||||||
|
}]);
|
||||||
|
|
||||||
|
Livewire::test(ManageFaqs::class)
|
||||||
|
->loadTable()
|
||||||
|
->callTableAction('create', data: ['content' => 'New FAQ', 'metadata' => []])
|
||||||
|
->assertNotified('Failed to create FAQ');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('deleting a faq calls deleteFaq and shows a success notification', function () {
|
||||||
|
Http::fake(['bnfexpress.test/*' => fn (Request $request) => match ($request->method()) {
|
||||||
|
'DELETE' => Http::response(['deleted' => true]),
|
||||||
|
default => Http::response(['total' => 1, 'faqs' => [['id' => 1, 'content' => 'To delete', 'metadata' => []]]]),
|
||||||
|
}]);
|
||||||
|
|
||||||
|
Livewire::test(ManageFaqs::class)
|
||||||
|
->loadTable()
|
||||||
|
->callTableAction('delete', 1)
|
||||||
|
->assertNotified('FAQ deleted');
|
||||||
|
|
||||||
|
Http::assertSent(fn (Request $request) => $request->method() === 'DELETE' && str_contains($request->url(), '/admin/faqs/1'));
|
||||||
|
});
|
||||||
@@ -0,0 +1,88 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use App\Models\User;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Livewire\Livewire;
|
||||||
|
use Modules\AiAgent\Filament\Pages\ViewEvChatHistory;
|
||||||
|
use Spatie\Permission\Models\Permission;
|
||||||
|
|
||||||
|
beforeEach(function () {
|
||||||
|
config([
|
||||||
|
'services.bnfexpress.ai_api_url' => 'https://bnfexpress.test',
|
||||||
|
'services.bnfexpress.client_id' => 'ev_admin',
|
||||||
|
'services.bnfexpress.client_secret' => 'test-secret',
|
||||||
|
]);
|
||||||
|
|
||||||
|
Permission::findOrCreate('manage_ai_agent', 'web');
|
||||||
|
|
||||||
|
$this->admin = User::factory()->create()->givePermissionTo(['manage_ai_agent']);
|
||||||
|
$this->actingAs($this->admin);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a user without manage_ai_agent cannot access it', function () {
|
||||||
|
$this->actingAs(User::factory()->create());
|
||||||
|
|
||||||
|
expect(ViewEvChatHistory::canAccess())->toBeFalse();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('it lists sessions from the client', function () {
|
||||||
|
Http::fake(['bnfexpress.test/admin/ev/history*' => Http::response([
|
||||||
|
'total' => 1,
|
||||||
|
'limit' => 25,
|
||||||
|
'offset' => 0,
|
||||||
|
'sessions' => [
|
||||||
|
['session_id' => 'sess-1', 'user_id' => 'user-1', 'title' => 'Charging question', 'last_message' => 'Thanks!', 'updated_at' => now()->toIso8601String()],
|
||||||
|
],
|
||||||
|
])]);
|
||||||
|
|
||||||
|
Livewire::test(ViewEvChatHistory::class)
|
||||||
|
->assertOk()
|
||||||
|
->loadTable()
|
||||||
|
->assertSee('Charging question');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('viewing a transcript shows its messages', function () {
|
||||||
|
Http::fake([
|
||||||
|
'bnfexpress.test/admin/ev/history/user-1/sess-1' => Http::response([
|
||||||
|
'session_id' => 'sess-1',
|
||||||
|
'user_id' => 'user-1',
|
||||||
|
'messages' => [
|
||||||
|
['author' => 'user', 'text' => 'How do I charge my EV?', 'timestamp' => now()->timestamp],
|
||||||
|
['author' => 'bnfexpress_ev_agent', 'text' => 'Plug it in at a station.', 'timestamp' => now()->timestamp],
|
||||||
|
],
|
||||||
|
]),
|
||||||
|
'bnfexpress.test/admin/ev/history*' => Http::response([
|
||||||
|
'total' => 1,
|
||||||
|
'limit' => 25,
|
||||||
|
'offset' => 0,
|
||||||
|
'sessions' => [
|
||||||
|
['session_id' => 'sess-1', 'user_id' => 'user-1', 'title' => 'Charging question', 'last_message' => 'Thanks!', 'updated_at' => now()->toIso8601String()],
|
||||||
|
],
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
Livewire::test(ViewEvChatHistory::class)
|
||||||
|
->loadTable()
|
||||||
|
->mountTableAction('view', 'user-1:sess-1')
|
||||||
|
->assertMountedActionModalSee('Plug it in at a station.');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a failed transcript fetch surfaces the gateway detail message', function () {
|
||||||
|
Http::fake([
|
||||||
|
'bnfexpress.test/admin/ev/history/user-1/sess-1' => Http::response(['detail' => 'Session not found.'], 404),
|
||||||
|
'bnfexpress.test/admin/ev/history*' => Http::response([
|
||||||
|
'total' => 1,
|
||||||
|
'limit' => 25,
|
||||||
|
'offset' => 0,
|
||||||
|
'sessions' => [
|
||||||
|
['session_id' => 'sess-1', 'user_id' => 'user-1', 'title' => 'Charging question', 'last_message' => 'Thanks!', 'updated_at' => now()->toIso8601String()],
|
||||||
|
],
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
Livewire::test(ViewEvChatHistory::class)
|
||||||
|
->loadTable()
|
||||||
|
->mountTableAction('view', 'user-1:sess-1')
|
||||||
|
->assertMountedActionModalSee('Session not found.')
|
||||||
|
->assertNotified('Could not load transcript');
|
||||||
|
});
|
||||||
@@ -26,6 +26,7 @@ class RolePermissionSeeder extends Seeder
|
|||||||
'view_customers',
|
'view_customers',
|
||||||
'manage_settings',
|
'manage_settings',
|
||||||
'view_reports',
|
'view_reports',
|
||||||
|
'manage_ai_agent',
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -46,6 +47,7 @@ class RolePermissionSeeder extends Seeder
|
|||||||
'view_customers',
|
'view_customers',
|
||||||
'manage_settings',
|
'manage_settings',
|
||||||
'view_reports',
|
'view_reports',
|
||||||
|
'manage_ai_agent',
|
||||||
],
|
],
|
||||||
'admin' => [
|
'admin' => [
|
||||||
'manage_catalog',
|
'manage_catalog',
|
||||||
@@ -59,6 +61,7 @@ class RolePermissionSeeder extends Seeder
|
|||||||
'view_customers',
|
'view_customers',
|
||||||
'manage_settings',
|
'manage_settings',
|
||||||
'view_reports',
|
'view_reports',
|
||||||
|
'manage_ai_agent',
|
||||||
],
|
],
|
||||||
'support' => [
|
'support' => [
|
||||||
'view_bookings',
|
'view_bookings',
|
||||||
|
|||||||
@@ -0,0 +1,204 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\Shared\Bnfexpress;
|
||||||
|
|
||||||
|
use Illuminate\Http\Client\ConnectionException;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Modules\Shared\Bnfexpress\Exceptions\BnfexpressApiException;
|
||||||
|
use Modules\Shared\Bnfexpress\Support\BnfexpressSignature;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Signed HTTP client for bnfexpress's admin APIs — EV FAQs, agent
|
||||||
|
* instruction versions, and read-only EV chat history. Backend-to-backend
|
||||||
|
* auth only (no user session/JWT): every request is signed per
|
||||||
|
* BnfexpressSignature (config('services.bnfexpress')).
|
||||||
|
*/
|
||||||
|
class BnfexpressAdminClient
|
||||||
|
{
|
||||||
|
private const AGENT = 'ev';
|
||||||
|
|
||||||
|
private readonly string $baseUrl;
|
||||||
|
|
||||||
|
private readonly string $clientId;
|
||||||
|
|
||||||
|
private readonly string $secret;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed>|null $config
|
||||||
|
*/
|
||||||
|
public function __construct(?array $config = null)
|
||||||
|
{
|
||||||
|
$config ??= (array) config('services.bnfexpress');
|
||||||
|
|
||||||
|
$this->baseUrl = rtrim((string) ($config['ai_api_url'] ?? ''), '/');
|
||||||
|
$this->clientId = (string) ($config['client_id'] ?? '');
|
||||||
|
$this->secret = (string) ($config['client_secret'] ?? '');
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- FAQs ---------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function listFaqs(?string $q = null, string $search = 'normal', ?int $limit = null, ?int $offset = null): array
|
||||||
|
{
|
||||||
|
return $this->request('GET', '/admin/faqs', query: array_filter([
|
||||||
|
'agent' => self::AGENT,
|
||||||
|
'q' => $q,
|
||||||
|
// Ignored by bnfexpress when q is empty, but only sent when q is set.
|
||||||
|
'search' => ($q !== null && $q !== '') ? $search : null,
|
||||||
|
'limit' => $limit,
|
||||||
|
'offset' => $offset,
|
||||||
|
], fn (mixed $value): bool => $value !== null));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function getFaq(int|string $id): array
|
||||||
|
{
|
||||||
|
return $this->request('GET', "/admin/faqs/{$id}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $metadata
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function createFaq(string $content, array $metadata = []): array
|
||||||
|
{
|
||||||
|
return $this->request('POST', '/admin/faqs', body: [
|
||||||
|
'content' => $content,
|
||||||
|
'agent' => self::AGENT,
|
||||||
|
'metadata' => $metadata,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed>|null $metadata
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function updateFaq(int|string $id, ?string $content = null, ?array $metadata = null): array
|
||||||
|
{
|
||||||
|
return $this->request('PATCH', "/admin/faqs/{$id}", body: array_filter([
|
||||||
|
'content' => $content,
|
||||||
|
'metadata' => $metadata,
|
||||||
|
], fn (mixed $value): bool => $value !== null));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function deleteFaq(int|string $id): array
|
||||||
|
{
|
||||||
|
return $this->request('DELETE', "/admin/faqs/{$id}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Agent instructions --------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function listInstructions(?int $limit = null, ?int $offset = null): array
|
||||||
|
{
|
||||||
|
return $this->request('GET', '/admin/agent-instructions', query: array_filter([
|
||||||
|
'agent' => self::AGENT,
|
||||||
|
'limit' => $limit,
|
||||||
|
'offset' => $offset,
|
||||||
|
], fn (mixed $value): bool => $value !== null));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function getActiveInstruction(): array
|
||||||
|
{
|
||||||
|
return $this->request('GET', '/admin/agent-instructions/active', query: [
|
||||||
|
'agent' => self::AGENT,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Publishes a new instruction version. Setting $activate (default true)
|
||||||
|
* deactivates the previously active version automatically, server-side.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function publishInstruction(string $content, bool $activate = true): array
|
||||||
|
{
|
||||||
|
return $this->request('POST', '/admin/agent-instructions', body: [
|
||||||
|
'agent' => self::AGENT,
|
||||||
|
'content' => $content,
|
||||||
|
'activate' => $activate,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Rolls back to an older instruction version.
|
||||||
|
*
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function activateInstruction(int|string $id): array
|
||||||
|
{
|
||||||
|
return $this->request('POST', "/admin/agent-instructions/{$id}/activate");
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- EV chat history (read-only) ------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{total: int, limit: int, offset: int, sessions: list<array<string, mixed>>}
|
||||||
|
*/
|
||||||
|
public function listSessions(?int $limit = null, ?int $offset = null): array
|
||||||
|
{
|
||||||
|
return $this->request('GET', '/admin/ev/history', query: array_filter([
|
||||||
|
'limit' => $limit,
|
||||||
|
'offset' => $offset,
|
||||||
|
], fn (mixed $value): bool => $value !== null));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
public function getSessionTranscript(string $userId, string $sessionId): array
|
||||||
|
{
|
||||||
|
return $this->request('GET', "/admin/ev/history/{$userId}/{$sessionId}");
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Request plumbing ------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $query
|
||||||
|
* @param array<string, mixed>|null $body
|
||||||
|
* @return array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function request(string $method, string $path, array $query = [], ?array $body = null): array
|
||||||
|
{
|
||||||
|
// Signed over exactly these bytes — must match what's actually sent,
|
||||||
|
// so it's built once and reused for both the signature and the body.
|
||||||
|
$rawBody = $body !== null ? json_encode($body, JSON_THROW_ON_ERROR) : '';
|
||||||
|
|
||||||
|
$headers = BnfexpressSignature::headers($method, $path, $rawBody, $this->clientId, $this->secret);
|
||||||
|
|
||||||
|
$pending = Http::baseUrl($this->baseUrl)->withHeaders($headers);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$response = match ($method) {
|
||||||
|
'GET' => $pending->get($path, $query),
|
||||||
|
'DELETE' => $pending->delete($path, $query),
|
||||||
|
'POST' => $pending->withBody($rawBody, 'application/json')->post($path),
|
||||||
|
'PATCH' => $pending->withBody($rawBody, 'application/json')->patch($path),
|
||||||
|
default => throw new \InvalidArgumentException("Unsupported HTTP method [{$method}]."),
|
||||||
|
};
|
||||||
|
} catch (ConnectionException $exception) {
|
||||||
|
throw new BnfexpressApiException($exception->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $response->successful()) {
|
||||||
|
throw new BnfexpressApiException(
|
||||||
|
(string) ($response->json('detail') ?? "bnfexpress request failed with status {$response->status()}."),
|
||||||
|
$response->status(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (array) $response->json();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\Shared\Bnfexpress\Exceptions;
|
||||||
|
|
||||||
|
use RuntimeException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thrown when bnfexpress's admin API returns a non-2xx response or the
|
||||||
|
* request fails to connect. Carries the gateway's own {"detail": "..."}
|
||||||
|
* message (falling back to a generic one) rather than a bare status code.
|
||||||
|
*/
|
||||||
|
class BnfexpressApiException extends RuntimeException
|
||||||
|
{
|
||||||
|
public function __construct(string $message, public readonly int $status = 0)
|
||||||
|
{
|
||||||
|
parent::__construct($message);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\Shared\Bnfexpress\Support;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* bnfexpress's backend-to-backend admin auth scheme: every request carries
|
||||||
|
* X-Client-Id/X-Timestamp/X-Signature, where the signature is a hex HMAC-SHA256
|
||||||
|
* over "{METHOD}\n{PATH}\n{TIMESTAMP}\n{RAW_BODY}" (uppercase verb, path only —
|
||||||
|
* no scheme/host/query — and the exact raw JSON bytes being sent, or "" for a
|
||||||
|
* bodyless request). Timestamps are generated fresh per call — bnfexpress
|
||||||
|
* rejects anything more than 300s from server time — so headers() must never
|
||||||
|
* be memoized/reused across requests.
|
||||||
|
*/
|
||||||
|
class BnfexpressSignature
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param int|null $timestamp Overrides the current time; only ever passed in tests.
|
||||||
|
* @return array{'X-Client-Id': string, 'X-Timestamp': string, 'X-Signature': string}
|
||||||
|
*/
|
||||||
|
public static function headers(string $method, string $path, string $rawBody, string $clientId, string $secret, ?int $timestamp = null): array
|
||||||
|
{
|
||||||
|
$timestamp = (string) ($timestamp ?? time());
|
||||||
|
|
||||||
|
$payload = strtoupper($method)."\n".$path."\n".$timestamp."\n".$rawBody;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'X-Client-Id' => $clientId,
|
||||||
|
'X-Timestamp' => $timestamp,
|
||||||
|
'X-Signature' => hash_hmac('sha256', $payload, $secret),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Modules\Shared\Console\Commands;
|
||||||
|
|
||||||
|
use Illuminate\Console\Command;
|
||||||
|
use Modules\Shared\Bnfexpress\BnfexpressAdminClient;
|
||||||
|
use Modules\Shared\Bnfexpress\Exceptions\BnfexpressApiException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exercises the signed bnfexpress admin client end to end (list FAQs, get
|
||||||
|
* the active instruction) against the real BNFEXPRESS_AI_API_URL, to confirm
|
||||||
|
* request signing checks out before any UI is wired up to it.
|
||||||
|
*/
|
||||||
|
class BnfexpressSmokeTestCommand extends Command
|
||||||
|
{
|
||||||
|
protected $signature = 'bnfexpress:smoke-test';
|
||||||
|
|
||||||
|
protected $description = 'Call bnfexpress\'s admin API (list EV FAQs, get the active EV instruction) to verify request signing';
|
||||||
|
|
||||||
|
public function handle(BnfexpressAdminClient $client): int
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$this->components->task('GET /admin/faqs?agent=ev', function () use ($client) {
|
||||||
|
$faqs = $client->listFaqs();
|
||||||
|
$this->line(' '.json_encode($faqs));
|
||||||
|
});
|
||||||
|
|
||||||
|
$this->components->task('GET /admin/agent-instructions/active?agent=ev', function () use ($client) {
|
||||||
|
$active = $client->getActiveInstruction();
|
||||||
|
$this->line(' '.json_encode($active));
|
||||||
|
});
|
||||||
|
} catch (BnfexpressApiException $exception) {
|
||||||
|
$this->components->error('bnfexpress request failed: '.$exception->getMessage());
|
||||||
|
|
||||||
|
return self::FAILURE;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->components->info('bnfexpress signing verified.');
|
||||||
|
|
||||||
|
return self::SUCCESS;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Modules\Shared\Bnfexpress\BnfexpressAdminClient;
|
||||||
|
use Modules\Shared\Bnfexpress\Exceptions\BnfexpressApiException;
|
||||||
|
|
||||||
|
$config = [
|
||||||
|
'ai_api_url' => 'https://bnfexpress.test',
|
||||||
|
'client_id' => 'ev_admin',
|
||||||
|
'client_secret' => 'shared-secret',
|
||||||
|
];
|
||||||
|
|
||||||
|
function assertSignedCorrectly($request, string $method, string $path, string $rawBody, string $secret): bool
|
||||||
|
{
|
||||||
|
$timestamp = $request->header('X-Timestamp')[0] ?? null;
|
||||||
|
$expected = hash_hmac('sha256', strtoupper($method)."\n".$path."\n".$timestamp."\n".$rawBody, $secret);
|
||||||
|
|
||||||
|
return $request->hasHeader('X-Client-Id', 'ev_admin')
|
||||||
|
&& $timestamp !== null
|
||||||
|
&& abs(time() - (int) $timestamp) < 5
|
||||||
|
&& $request->header('X-Signature')[0] === $expected;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('listFaqs signs a GET request and excludes the query string from the signed path', function () use ($config) {
|
||||||
|
Http::fake(['bnfexpress.test/*' => Http::response(['faqs' => []])]);
|
||||||
|
|
||||||
|
(new BnfexpressAdminClient($config))->listFaqs(q: 'range', search: 'semantic', limit: 10, offset: 0);
|
||||||
|
|
||||||
|
Http::assertSent(function ($request) use ($config) {
|
||||||
|
return $request->url() === 'https://bnfexpress.test/admin/faqs?agent=ev&q=range&search=semantic&limit=10&offset=0'
|
||||||
|
&& assertSignedCorrectly($request, 'GET', '/admin/faqs', '', $config['client_secret']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('listFaqs omits search when q is empty', function () use ($config) {
|
||||||
|
Http::fake(['bnfexpress.test/*' => Http::response(['faqs' => []])]);
|
||||||
|
|
||||||
|
(new BnfexpressAdminClient($config))->listFaqs();
|
||||||
|
|
||||||
|
Http::assertSent(fn ($request) => $request->url() === 'https://bnfexpress.test/admin/faqs?agent=ev');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('createFaq signs the exact raw JSON body being sent', function () use ($config) {
|
||||||
|
Http::fake(['bnfexpress.test/*' => Http::response(['id' => 1], 201)]);
|
||||||
|
|
||||||
|
(new BnfexpressAdminClient($config))->createFaq('How do I charge?', ['source' => 'admin']);
|
||||||
|
|
||||||
|
$rawBody = json_encode([
|
||||||
|
'content' => 'How do I charge?',
|
||||||
|
'agent' => 'ev',
|
||||||
|
'metadata' => ['source' => 'admin'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
Http::assertSent(function ($request) use ($config, $rawBody) {
|
||||||
|
return $request->url() === 'https://bnfexpress.test/admin/faqs'
|
||||||
|
&& $request->method() === 'POST'
|
||||||
|
&& $request->body() === $rawBody
|
||||||
|
&& assertSignedCorrectly($request, 'POST', '/admin/faqs', $rawBody, $config['client_secret']);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('getActiveInstruction requests the active instruction for the ev agent', function () use ($config) {
|
||||||
|
Http::fake(['bnfexpress.test/*' => Http::response(['content' => 'You are the EV assistant.'])]);
|
||||||
|
|
||||||
|
$result = (new BnfexpressAdminClient($config))->getActiveInstruction();
|
||||||
|
|
||||||
|
expect($result)->toBe(['content' => 'You are the EV assistant.']);
|
||||||
|
Http::assertSent(fn ($request) => $request->url() === 'https://bnfexpress.test/admin/agent-instructions/active?agent=ev');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a non-2xx response surfaces the gateway detail message', function () use ($config) {
|
||||||
|
Http::fake(['bnfexpress.test/*' => Http::response(['detail' => 'FAQ not found.'], 404)]);
|
||||||
|
|
||||||
|
(new BnfexpressAdminClient($config))->getFaq(999);
|
||||||
|
})->throws(BnfexpressApiException::class, 'FAQ not found.');
|
||||||
|
|
||||||
|
test('deleteFaq signs a bodyless DELETE request', function () use ($config) {
|
||||||
|
Http::fake(['bnfexpress.test/*' => Http::response(['deleted' => true])]);
|
||||||
|
|
||||||
|
(new BnfexpressAdminClient($config))->deleteFaq(5);
|
||||||
|
|
||||||
|
Http::assertSent(function ($request) use ($config) {
|
||||||
|
return $request->url() === 'https://bnfexpress.test/admin/faqs/5'
|
||||||
|
&& $request->method() === 'DELETE'
|
||||||
|
&& assertSignedCorrectly($request, 'DELETE', '/admin/faqs/5', '', $config['client_secret']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
use Modules\Shared\Bnfexpress\Support\BnfexpressSignature;
|
||||||
|
|
||||||
|
test('headers computes the hex HMAC-SHA256 over METHOD\PATH\TIMESTAMP\RAW_BODY joined by newlines', function () {
|
||||||
|
$headers = BnfexpressSignature::headers(
|
||||||
|
method: 'post',
|
||||||
|
path: '/admin/faqs',
|
||||||
|
rawBody: '{"content":"hi"}',
|
||||||
|
clientId: 'ev_admin',
|
||||||
|
secret: 'shared-secret',
|
||||||
|
timestamp: 1_700_000_000,
|
||||||
|
);
|
||||||
|
|
||||||
|
$expected = hash_hmac('sha256', "POST\n/admin/faqs\n1700000000\n{\"content\":\"hi\"}", 'shared-secret');
|
||||||
|
|
||||||
|
expect($headers)->toBe([
|
||||||
|
'X-Client-Id' => 'ev_admin',
|
||||||
|
'X-Timestamp' => '1700000000',
|
||||||
|
'X-Signature' => $expected,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('headers signs an empty raw body for a bodyless request', function () {
|
||||||
|
$headers = BnfexpressSignature::headers(
|
||||||
|
method: 'GET',
|
||||||
|
path: '/admin/faqs/1',
|
||||||
|
rawBody: '',
|
||||||
|
clientId: 'ev_admin',
|
||||||
|
secret: 'shared-secret',
|
||||||
|
timestamp: 1_700_000_000,
|
||||||
|
);
|
||||||
|
|
||||||
|
$expected = hash_hmac('sha256', "GET\n/admin/faqs/1\n1700000000\n", 'shared-secret');
|
||||||
|
|
||||||
|
expect($headers['X-Signature'])->toBe($expected);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('headers generates a fresh timestamp per call when none is given', function () {
|
||||||
|
$before = time();
|
||||||
|
|
||||||
|
$headers = BnfexpressSignature::headers('GET', '/admin/faqs', '', 'ev_admin', 'secret');
|
||||||
|
|
||||||
|
expect((int) $headers['X-Timestamp'])->toBeGreaterThanOrEqual($before)
|
||||||
|
->and((int) $headers['X-Timestamp'])->toBeLessThanOrEqual(time());
|
||||||
|
});
|
||||||
@@ -21,6 +21,7 @@ use Illuminate\Foundation\Http\Middleware\PreventRequestForgery;
|
|||||||
use Illuminate\Routing\Middleware\SubstituteBindings;
|
use Illuminate\Routing\Middleware\SubstituteBindings;
|
||||||
use Illuminate\Session\Middleware\StartSession;
|
use Illuminate\Session\Middleware\StartSession;
|
||||||
use Illuminate\View\Middleware\ShareErrorsFromSession;
|
use Illuminate\View\Middleware\ShareErrorsFromSession;
|
||||||
|
use Modules\AiAgent\AiAgentPlugin;
|
||||||
use Modules\Booking\BookingPlugin;
|
use Modules\Booking\BookingPlugin;
|
||||||
use Modules\Catalog\CatalogPlugin;
|
use Modules\Catalog\CatalogPlugin;
|
||||||
use Modules\Cms\CmsPlugin;
|
use Modules\Cms\CmsPlugin;
|
||||||
@@ -51,6 +52,7 @@ class AdminPanelProvider extends PanelProvider
|
|||||||
NavigationGroup::make()->label('Operations'),
|
NavigationGroup::make()->label('Operations'),
|
||||||
NavigationGroup::make()->label('Reports'),
|
NavigationGroup::make()->label('Reports'),
|
||||||
NavigationGroup::make()->label('CMS'),
|
NavigationGroup::make()->label('CMS'),
|
||||||
|
NavigationGroup::make()->label('AI Agent'),
|
||||||
])
|
])
|
||||||
->plugins([
|
->plugins([
|
||||||
CatalogPlugin::make(),
|
CatalogPlugin::make(),
|
||||||
@@ -60,6 +62,7 @@ class AdminPanelProvider extends PanelProvider
|
|||||||
IdentityPlugin::make(),
|
IdentityPlugin::make(),
|
||||||
ReportingPlugin::make(),
|
ReportingPlugin::make(),
|
||||||
CmsPlugin::make(),
|
CmsPlugin::make(),
|
||||||
|
AiAgentPlugin::make(),
|
||||||
// T6.5 — ops convenience for browsing storage/logs/*.log
|
// T6.5 — ops convenience for browsing storage/logs/*.log
|
||||||
// in-browser; distinct from the structured, per-model audit
|
// in-browser; distinct from the structured, per-model audit
|
||||||
// trail (AuditLogResource, T6.2). No extra permission gate:
|
// trail (AuditLogResource, T6.2). No extra permission gate:
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
"laravel/sanctum": "^4.0",
|
"laravel/sanctum": "^4.0",
|
||||||
"laravel/tinker": "^3.0",
|
"laravel/tinker": "^3.0",
|
||||||
"internachi/modular": "^3.0",
|
"internachi/modular": "^3.0",
|
||||||
|
"modules/ai-agent": "*",
|
||||||
"modules/booking": "*",
|
"modules/booking": "*",
|
||||||
"modules/catalog": "*",
|
"modules/catalog": "*",
|
||||||
"modules/cms": "*",
|
"modules/cms": "*",
|
||||||
|
|||||||
Generated
+33
-1
@@ -4,7 +4,7 @@
|
|||||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||||
"This file is @generated automatically"
|
"This file is @generated automatically"
|
||||||
],
|
],
|
||||||
"content-hash": "00ca2f59b7cce7ed3ade4856855a5d51",
|
"content-hash": "947c21682eb62d7b6f63b0f1e11dbe38",
|
||||||
"packages": [
|
"packages": [
|
||||||
{
|
{
|
||||||
"name": "anourvalar/eloquent-serialize",
|
"name": "anourvalar/eloquent-serialize",
|
||||||
@@ -4663,6 +4663,38 @@
|
|||||||
},
|
},
|
||||||
"time": "2022-12-02T22:17:43+00:00"
|
"time": "2022-12-02T22:17:43+00:00"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "modules/ai-agent",
|
||||||
|
"version": "1.0",
|
||||||
|
"dist": {
|
||||||
|
"type": "path",
|
||||||
|
"url": "app-modules/ai-agent",
|
||||||
|
"reference": "3a8b4cb091495eaa57bbb80f6879f85a014318bd"
|
||||||
|
},
|
||||||
|
"type": "library",
|
||||||
|
"extra": {
|
||||||
|
"laravel": {
|
||||||
|
"providers": [
|
||||||
|
"Modules\\AiAgent\\Providers\\AiAgentServiceProvider"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Modules\\AiAgent\\": "src/",
|
||||||
|
"Modules\\AiAgent\\Tests\\": "tests/",
|
||||||
|
"Modules\\AiAgent\\Database\\Factories\\": "database/factories/",
|
||||||
|
"Modules\\AiAgent\\Database\\Seeders\\": "database/seeders/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"license": [
|
||||||
|
"proprietary"
|
||||||
|
],
|
||||||
|
"transport-options": {
|
||||||
|
"symlink": true,
|
||||||
|
"relative": true
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "modules/booking",
|
"name": "modules/booking",
|
||||||
"version": "1.0",
|
"version": "1.0",
|
||||||
|
|||||||
@@ -40,6 +40,12 @@ return [
|
|||||||
'jwt_algorithm' => env('FASTAPI_AGENT_JWT_ALGORITHM', 'HS256'),
|
'jwt_algorithm' => env('FASTAPI_AGENT_JWT_ALGORITHM', 'HS256'),
|
||||||
],
|
],
|
||||||
|
|
||||||
|
'bnfexpress' => [
|
||||||
|
'ai_api_url' => env('BNFEXPRESS_AI_API_URL', 'http://bnfexpress-app:8000'),
|
||||||
|
'client_id' => env('BNFEXPRESS_AI_CLIENT_ID', 'ev_admin'),
|
||||||
|
'client_secret' => env('BNFEXPRESS_AI_CLIENT_SECRET'),
|
||||||
|
],
|
||||||
|
|
||||||
'sms' => [
|
'sms' => [
|
||||||
'enabled' => env('SMS_ENABLED', false),
|
'enabled' => env('SMS_ENABLED', false),
|
||||||
'sms_poh' => [
|
'sms_poh' => [
|
||||||
|
|||||||
Reference in New Issue
Block a user