Add admin views for quick replies, settings, and ticket details
- Created `quick-replies.blade.php` for managing quick replies. - Added `settings.blade.php` for admin settings management. - Implemented `ticket-show.blade.php` to display ticket details. - Introduced `timeline-card.blade.php` component for displaying timeline information. Enhance quick reply management functionality - Developed `quick-reply-manager.blade.php` for creating and editing quick replies. - Integrated Livewire for dynamic interaction and validation. Implement settings page for AI configuration - Created `settings-page.blade.php` for managing AI settings, including prompts and provider instances. - Added functionality for managing models and embeddings. Add ticket show functionality with real-time updates - Implemented ticket details view with processing status and tool call logs. - Added support for displaying article suggestions and error messages. Create unit tests for AI classifier and domain info tool - Added `AIClassifierServiceTest.php` to validate AI classifier functionality. - Implemented `DomainInfoToolTest.php` for domain parameter validation. - Created `OxxaClientTest.php` to test API interactions and password hashing.
This commit is contained in:
@@ -3,6 +3,7 @@
|
||||
namespace App\Livewire\Admin;
|
||||
|
||||
use App\Services\AdminArticleService;
|
||||
use App\Services\AdminQuickReplyService;
|
||||
use Livewire\Component;
|
||||
use Livewire\WithPagination;
|
||||
|
||||
@@ -11,26 +12,107 @@ class ArticleManager extends Component
|
||||
use WithPagination;
|
||||
|
||||
public string $title = '';
|
||||
|
||||
public string $content = '';
|
||||
|
||||
public string $note = '';
|
||||
|
||||
public array $allowedActions = [];
|
||||
|
||||
public array $articleNotes = [];
|
||||
|
||||
public array $articleAllowedActions = [];
|
||||
|
||||
public array $articleQuickReplies = [];
|
||||
|
||||
public function save(AdminArticleService $service): void
|
||||
{
|
||||
$this->validate([
|
||||
'title' => ['required', 'string', 'max:255'],
|
||||
'content' => ['required', 'string', 'max:12000'],
|
||||
'note' => ['nullable', 'string', 'max:12000'],
|
||||
'allowedActions' => ['array'],
|
||||
'allowedActions.*' => ['string', 'in:domain_inf'],
|
||||
]);
|
||||
|
||||
$service->create($this->title, $this->content);
|
||||
$service->create($this->title, $this->content, $this->note, $this->allowedActions);
|
||||
|
||||
$this->reset(['title', 'content']);
|
||||
$this->reset(['title', 'content', 'note', 'allowedActions']);
|
||||
$this->dispatch('article-saved');
|
||||
session()->flash('success', 'Article opgeslagen en embedding wordt automatisch verwerkt.');
|
||||
}
|
||||
|
||||
public function render(AdminArticleService $service)
|
||||
public function deleteArticle(int $articleId, AdminArticleService $service): void
|
||||
{
|
||||
$deleted = $service->deleteById($articleId);
|
||||
|
||||
if ($deleted) {
|
||||
session()->flash('success', "Artikel #{$articleId} is verwijderd.");
|
||||
} else {
|
||||
session()->flash('success', "Artikel #{$articleId} bestond niet meer.");
|
||||
}
|
||||
|
||||
$this->resetPage();
|
||||
}
|
||||
|
||||
public function saveMetadata(int $articleId, AdminArticleService $service): void
|
||||
{
|
||||
$this->validate([
|
||||
"articleNotes.{$articleId}" => ['nullable', 'string', 'max:12000'],
|
||||
"articleAllowedActions.{$articleId}" => ['array'],
|
||||
"articleAllowedActions.{$articleId}.*" => ['string', 'in:domain_inf'],
|
||||
"articleQuickReplies.{$articleId}" => ['array'],
|
||||
"articleQuickReplies.{$articleId}.*" => ['integer', 'exists:quick_replies,id'],
|
||||
]);
|
||||
|
||||
$updated = $service->updateMetadata(
|
||||
$articleId,
|
||||
$this->articleNotes[$articleId] ?? null,
|
||||
$this->articleAllowedActions[$articleId] ?? [],
|
||||
$this->articleQuickReplies[$articleId] ?? []
|
||||
);
|
||||
|
||||
session()->flash('success', $updated
|
||||
? "Metadata voor artikel #{$articleId} is opgeslagen."
|
||||
: "Artikel #{$articleId} bestaat niet meer.");
|
||||
}
|
||||
|
||||
public function approveDraft(int $articleId, AdminArticleService $service): void
|
||||
{
|
||||
$approved = $service->approveDraft($articleId);
|
||||
|
||||
if ($approved) {
|
||||
session()->flash('success', "Conceptartikel #{$articleId} is gevalideerd en gepubliceerd.");
|
||||
} else {
|
||||
session()->flash('success', "Conceptartikel #{$articleId} bestaat niet meer.");
|
||||
}
|
||||
}
|
||||
|
||||
public function render(AdminArticleService $service, AdminQuickReplyService $quickReplyService)
|
||||
{
|
||||
$articles = $service->paginate(10);
|
||||
$this->hydrateArticleMetadataState($articles->items());
|
||||
|
||||
return view('livewire.admin.article-manager', [
|
||||
'articles' => $service->paginate(10),
|
||||
'articles' => $articles,
|
||||
'quickReplyOptions' => $quickReplyService->activeOptions(),
|
||||
]);
|
||||
}
|
||||
|
||||
private function hydrateArticleMetadataState(array $articles): void
|
||||
{
|
||||
foreach ($articles as $article) {
|
||||
if (! array_key_exists($article->id, $this->articleNotes)) {
|
||||
$this->articleNotes[$article->id] = $article->note ?? '';
|
||||
}
|
||||
|
||||
if (! array_key_exists($article->id, $this->articleAllowedActions)) {
|
||||
$this->articleAllowedActions[$article->id] = $article->allowed_actions ?? [];
|
||||
}
|
||||
|
||||
if (! array_key_exists($article->id, $this->articleQuickReplies)) {
|
||||
$this->articleQuickReplies[$article->id] = $article->quickReplies->pluck('id')->map(fn ($id) => (int) $id)->all();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
94
app/Livewire/Admin/QuickReplyManager.php
Normal file
94
app/Livewire/Admin/QuickReplyManager.php
Normal file
@@ -0,0 +1,94 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Admin;
|
||||
|
||||
use App\Services\AdminQuickReplyService;
|
||||
use Livewire\Component;
|
||||
use Livewire\WithPagination;
|
||||
|
||||
class QuickReplyManager extends Component
|
||||
{
|
||||
use WithPagination;
|
||||
|
||||
public string $title = '';
|
||||
|
||||
public string $content = '';
|
||||
|
||||
public bool $isActive = true;
|
||||
|
||||
public array $editRows = [];
|
||||
|
||||
public function save(AdminQuickReplyService $service): void
|
||||
{
|
||||
$this->validate([
|
||||
'title' => ['required', 'string', 'max:255'],
|
||||
'content' => ['required', 'string', 'max:12000'],
|
||||
'isActive' => ['boolean'],
|
||||
]);
|
||||
|
||||
$service->create($this->title, $this->content, $this->isActive);
|
||||
|
||||
$this->reset(['title', 'content']);
|
||||
$this->isActive = true;
|
||||
session()->flash('success', 'Snelantwoord opgeslagen.');
|
||||
$this->resetPage();
|
||||
}
|
||||
|
||||
public function updateQuickReply(int $id, AdminQuickReplyService $service): void
|
||||
{
|
||||
$this->validate([
|
||||
"editRows.{$id}.title" => ['required', 'string', 'max:255'],
|
||||
"editRows.{$id}.content" => ['required', 'string', 'max:12000'],
|
||||
"editRows.{$id}.is_active" => ['boolean'],
|
||||
]);
|
||||
|
||||
$row = $this->editRows[$id] ?? [];
|
||||
$updated = $service->update(
|
||||
$id,
|
||||
(string) ($row['title'] ?? ''),
|
||||
(string) ($row['content'] ?? ''),
|
||||
(bool) ($row['is_active'] ?? false)
|
||||
);
|
||||
|
||||
session()->flash('success', $updated
|
||||
? "Snelantwoord #{$id} is opgeslagen."
|
||||
: "Snelantwoord #{$id} bestaat niet meer.");
|
||||
}
|
||||
|
||||
public function deleteQuickReply(int $id, AdminQuickReplyService $service): void
|
||||
{
|
||||
$deleted = $service->deleteById($id);
|
||||
|
||||
session()->flash('success', $deleted
|
||||
? "Snelantwoord #{$id} is verwijderd."
|
||||
: "Snelantwoord #{$id} bestond niet meer.");
|
||||
|
||||
unset($this->editRows[$id]);
|
||||
$this->resetPage();
|
||||
}
|
||||
|
||||
public function render(AdminQuickReplyService $service)
|
||||
{
|
||||
$quickReplies = $service->paginate(10);
|
||||
$this->hydrateEditRows($quickReplies->items());
|
||||
|
||||
return view('livewire.admin.quick-reply-manager', [
|
||||
'quickReplies' => $quickReplies,
|
||||
]);
|
||||
}
|
||||
|
||||
private function hydrateEditRows(array $quickReplies): void
|
||||
{
|
||||
foreach ($quickReplies as $quickReply) {
|
||||
if (array_key_exists($quickReply->id, $this->editRows)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->editRows[$quickReply->id] = [
|
||||
'title' => $quickReply->title,
|
||||
'content' => $quickReply->content,
|
||||
'is_active' => $quickReply->is_active,
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
199
app/Livewire/Admin/SettingsPage.php
Normal file
199
app/Livewire/Admin/SettingsPage.php
Normal file
@@ -0,0 +1,199 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Admin;
|
||||
|
||||
use App\Services\AppSettingsService;
|
||||
use App\Services\ArticleEmbeddingMaintenanceService;
|
||||
use App\Services\LlmModelCatalogService;
|
||||
use Illuminate\Support\Str;
|
||||
use Livewire\Component;
|
||||
|
||||
class SettingsPage extends Component
|
||||
{
|
||||
public string $activeTab = 'process';
|
||||
|
||||
public string $tone_addressing = 'je';
|
||||
|
||||
public string $activeProviderInstanceId = 'ollama_default';
|
||||
|
||||
public int $llm_timeout = 300;
|
||||
|
||||
public array $promptValues = [];
|
||||
|
||||
public array $providerInstances = [];
|
||||
|
||||
public array $modelValues = [];
|
||||
|
||||
public array $availableModels = [];
|
||||
|
||||
public array $embeddingStats = [];
|
||||
|
||||
public ?string $modelLoadError = null;
|
||||
|
||||
public array $processSteps = [];
|
||||
|
||||
public array $providerDefinitions = [];
|
||||
|
||||
public array $modelTasks = [];
|
||||
|
||||
public function mount(AppSettingsService $settings): void
|
||||
{
|
||||
$all = $settings->all();
|
||||
$providerSettings = $settings->providerSettings();
|
||||
$this->tone_addressing = (string) ($all['tone_addressing'] ?? 'je');
|
||||
$this->activeProviderInstanceId = (string) ($providerSettings['active_instance_id'] ?? 'ollama_default');
|
||||
$this->llm_timeout = (int) ($all['llm.timeout'] ?? 300);
|
||||
$this->promptValues = $settings->promptValues();
|
||||
$this->providerInstances = $providerSettings['instances'] ?? $settings->defaultProviderInstances();
|
||||
$this->modelValues = $settings->modelSettings();
|
||||
$this->processSteps = $settings->processSteps();
|
||||
$this->providerDefinitions = $settings->providerDefinitions();
|
||||
$this->modelTasks = $settings->modelTasks();
|
||||
$this->refreshEmbeddingStats();
|
||||
$this->loadModels();
|
||||
}
|
||||
|
||||
public function setTab(string $tab): void
|
||||
{
|
||||
if (in_array($tab, ['process', 'providers', 'models', 'embeddings'], true)) {
|
||||
$this->activeTab = $tab;
|
||||
|
||||
if ($tab === 'embeddings') {
|
||||
$this->refreshEmbeddingStats();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public function refreshEmbeddingStats(): void
|
||||
{
|
||||
$this->embeddingStats = app(ArticleEmbeddingMaintenanceService::class)->stats();
|
||||
}
|
||||
|
||||
public function reindexMissingEmbeddings(): void
|
||||
{
|
||||
$count = app(ArticleEmbeddingMaintenanceService::class)->dispatchReindex(false);
|
||||
$this->refreshEmbeddingStats();
|
||||
session()->flash('saved', "{$count} artikelen zonder chunks zijn in de queue geplaatst.");
|
||||
}
|
||||
|
||||
public function reindexAllEmbeddings(): void
|
||||
{
|
||||
$count = app(ArticleEmbeddingMaintenanceService::class)->dispatchReindex(true);
|
||||
$this->refreshEmbeddingStats();
|
||||
session()->flash('saved', "{$count} artikelen zijn in de queue geplaatst voor volledige herindex.");
|
||||
}
|
||||
|
||||
public function addProviderInstance(): void
|
||||
{
|
||||
$id = 'provider_'.Str::uuid()->toString();
|
||||
$this->providerInstances[] = [
|
||||
'id' => $id,
|
||||
'name' => 'Nieuwe provider',
|
||||
'type' => 'lmstudio',
|
||||
'base_url' => 'http://localhost:1234',
|
||||
'chat_model' => '',
|
||||
'embedding_model' => '',
|
||||
];
|
||||
$this->activeProviderInstanceId = $id;
|
||||
$this->availableModels = [];
|
||||
$this->modelLoadError = null;
|
||||
}
|
||||
|
||||
public function removeProviderInstance(string $id): void
|
||||
{
|
||||
if (count($this->providerInstances) <= 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->providerInstances = array_values(array_filter(
|
||||
$this->providerInstances,
|
||||
fn (array $instance) => ($instance['id'] ?? null) !== $id
|
||||
));
|
||||
|
||||
if ($this->activeProviderInstanceId === $id) {
|
||||
$this->activeProviderInstanceId = (string) ($this->providerInstances[0]['id'] ?? '');
|
||||
}
|
||||
|
||||
$this->loadModels();
|
||||
}
|
||||
|
||||
public function setActiveProviderInstance(string $id): void
|
||||
{
|
||||
$ids = array_column($this->providerInstances, 'id');
|
||||
if (in_array($id, $ids, true)) {
|
||||
$this->activeProviderInstanceId = $id;
|
||||
$this->loadModels();
|
||||
}
|
||||
}
|
||||
|
||||
public function loadModels(bool $refresh = false): void
|
||||
{
|
||||
$instance = $this->activeProviderInstance();
|
||||
if ($instance === null) {
|
||||
$this->availableModels = [];
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
$this->availableModels = app(LlmModelCatalogService::class)->modelsFor($instance, $refresh);
|
||||
$this->modelLoadError = null;
|
||||
} catch (\Throwable $e) {
|
||||
$this->availableModels = [];
|
||||
$this->modelLoadError = $e->getMessage();
|
||||
}
|
||||
}
|
||||
|
||||
public function refreshModels(): void
|
||||
{
|
||||
$this->loadModels(true);
|
||||
}
|
||||
|
||||
public function save(AppSettingsService $settings): void
|
||||
{
|
||||
$this->validate([
|
||||
'tone_addressing' => ['required', 'in:je,u'],
|
||||
'activeProviderInstanceId' => ['required', 'string'],
|
||||
'llm_timeout' => ['required', 'integer', 'min:5', 'max:600'],
|
||||
'promptValues' => ['array'],
|
||||
'promptValues.*' => ['required', 'string', 'min:10'],
|
||||
'providerInstances' => ['array', 'min:1'],
|
||||
'providerInstances.*.id' => ['required', 'string'],
|
||||
'providerInstances.*.name' => ['required', 'string', 'min:1'],
|
||||
'providerInstances.*.type' => ['required', 'in:ollama,lmstudio'],
|
||||
'providerInstances.*.base_url' => ['required', 'url'],
|
||||
'providerInstances.*.chat_model' => ['nullable', 'string'],
|
||||
'providerInstances.*.embedding_model' => ['nullable', 'string'],
|
||||
'modelValues' => ['array'],
|
||||
'modelValues.*' => ['required', 'string', 'min:1'],
|
||||
]);
|
||||
|
||||
$settings->saveStructuredSettings(
|
||||
promptValues: $this->promptValues,
|
||||
providerInstances: $this->providerInstances,
|
||||
activeProviderInstanceId: $this->activeProviderInstanceId,
|
||||
modelValues: $this->modelValues,
|
||||
timeout: $this->llm_timeout,
|
||||
tone: $this->tone_addressing,
|
||||
);
|
||||
|
||||
session()->flash('saved', 'Settings opgeslagen.');
|
||||
$this->refreshEmbeddingStats();
|
||||
}
|
||||
|
||||
private function activeProviderInstance(): ?array
|
||||
{
|
||||
foreach ($this->providerInstances as $instance) {
|
||||
if (($instance['id'] ?? null) === $this->activeProviderInstanceId) {
|
||||
return $instance;
|
||||
}
|
||||
}
|
||||
|
||||
return $this->providerInstances[0] ?? null;
|
||||
}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return view('livewire.admin.settings-page');
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
namespace App\Livewire\Admin;
|
||||
|
||||
use App\Exceptions\OllamaUnavailableException;
|
||||
use App\Services\AdminTicketService;
|
||||
use App\Services\TicketIngestionService;
|
||||
use Livewire\Component;
|
||||
use Livewire\WithPagination;
|
||||
|
||||
@@ -12,11 +14,61 @@ class TicketMonitor extends Component
|
||||
|
||||
public int $perPage = 10;
|
||||
|
||||
public string $newTicketMessage = '';
|
||||
|
||||
public string $apiUser = '';
|
||||
|
||||
public string $apiPassword = '';
|
||||
|
||||
public ?array $lastResult = null;
|
||||
|
||||
public ?string $submitError = null;
|
||||
|
||||
public function updatedPerPage(): void
|
||||
{
|
||||
$this->resetPage();
|
||||
}
|
||||
|
||||
public function submitTicket(TicketIngestionService $ticketIngestionService): void
|
||||
{
|
||||
$this->submitError = null;
|
||||
$this->lastResult = null;
|
||||
|
||||
$this->validate([
|
||||
'newTicketMessage' => ['required', 'string', 'min:5', 'max:5000'],
|
||||
'apiUser' => ['nullable', 'string', 'max:255', 'required_with:apiPassword'],
|
||||
'apiPassword' => ['nullable', 'string', 'max:255', 'required_with:apiUser'],
|
||||
]);
|
||||
|
||||
try {
|
||||
$ingested = $ticketIngestionService->ingest(trim($this->newTicketMessage), $this->credentialsPayload());
|
||||
} catch (OllamaUnavailableException $e) {
|
||||
$this->submitError = 'LLM provider niet beschikbaar. Reden: '.$e->getMessage();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->reset(['newTicketMessage', 'apiUser', 'apiPassword']);
|
||||
$this->resetPage();
|
||||
|
||||
$this->lastResult = [
|
||||
'ticket_id' => $ingested['ticket']->id,
|
||||
'status' => $ingested['ticket']->status,
|
||||
];
|
||||
}
|
||||
|
||||
private function credentialsPayload(): ?array
|
||||
{
|
||||
if (trim($this->apiUser) === '' && trim($this->apiPassword) === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'apiuser' => $this->apiUser,
|
||||
'apipassword' => $this->apiPassword,
|
||||
];
|
||||
}
|
||||
|
||||
public function render(AdminTicketService $service)
|
||||
{
|
||||
return view('livewire.admin.ticket-monitor', [
|
||||
|
||||
53
app/Livewire/Admin/TicketShow.php
Normal file
53
app/Livewire/Admin/TicketShow.php
Normal file
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
namespace App\Livewire\Admin;
|
||||
|
||||
use App\Jobs\ProcessTicketJob;
|
||||
use App\Models\Ticket;
|
||||
use App\Services\AdminTicketService;
|
||||
use App\Services\TicketProcessingLoggerService;
|
||||
use Livewire\Component;
|
||||
|
||||
class TicketShow extends Component
|
||||
{
|
||||
public int $ticketId;
|
||||
|
||||
public function mount(int $ticketId): void
|
||||
{
|
||||
$this->ticketId = $ticketId;
|
||||
}
|
||||
|
||||
public function reprocess(TicketProcessingLoggerService $logger): void
|
||||
{
|
||||
$ticket = Ticket::query()->find($this->ticketId);
|
||||
abort_if($ticket === null, 404);
|
||||
|
||||
$ticket->update([
|
||||
'status' => 'queued',
|
||||
'best_article_id' => null,
|
||||
'confidence' => null,
|
||||
'explanation' => null,
|
||||
'support_reply' => null,
|
||||
'needs_article_draft' => false,
|
||||
'draft_article_id' => null,
|
||||
'result_payload' => null,
|
||||
'error_message' => null,
|
||||
'processed_at' => null,
|
||||
]);
|
||||
|
||||
$logger->log($ticket, 'queued', 'info', 'Ticket handmatig opnieuw in queue geplaatst via admin.');
|
||||
ProcessTicketJob::dispatch($ticket->id);
|
||||
|
||||
session()->flash('success', 'Ticket is opnieuw in de queue geplaatst.');
|
||||
}
|
||||
|
||||
public function render(AdminTicketService $service)
|
||||
{
|
||||
$ticket = $service->findWithTimeline($this->ticketId);
|
||||
abort_if($ticket === null, 404);
|
||||
|
||||
return view('livewire.admin.ticket-show', [
|
||||
'ticket' => $ticket,
|
||||
]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user