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,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,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user