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:
Nyan Lin Paing
2026-08-30 23:52:21 +07:00
parent 95b369174d
commit b8d31e3dc4
30 changed files with 1362 additions and 1 deletions
@@ -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,
);
}
}