b8d31e3dc4
- 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.
178 lines
6.2 KiB
PHP
178 lines
6.2 KiB
PHP
<?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,
|
|
);
|
|
}
|
|
}
|