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:
SitiWeb
2026-04-30 01:50:21 +02:00
parent 01aa115a49
commit f939133fe0
103 changed files with 4721 additions and 245 deletions

View File

@@ -0,0 +1,196 @@
<x-layouts.admin title="Proces">
<div class="space-y-6">
<section class="bg-white rounded-xl p-5 shadow">
<div class="flex items-start justify-between gap-6">
<div>
<h2 class="text-xl font-semibold">Hoe werkt de Ticket Assistant?</h2>
<p class="mt-2 max-w-3xl text-sm text-slate-600">
Zie het systeem als een supportmedewerker met een hele grote map handleidingen. Eerst maakt hij
de vraag netjes leesbaar, daarna zoekt hij de beste stukjes uitleg, en daarna schrijft hij een
kort advies dat een supportmedewerker kan controleren.
</p>
</div>
<div class="rounded bg-slate-100 px-3 py-2 text-xs text-slate-600">
Draait lokaal met je eigen kennisbank en je eigen AI-model
</div>
</div>
</section>
<section class="grid gap-4 lg:grid-cols-2">
<div class="bg-white rounded-xl p-5 shadow">
<h3 class="font-semibold">Wat gebeurt er met een ticket?</h3>
<div class="mt-4 space-y-4">
<div class="border-l-4 border-slate-900 pl-4">
<div class="font-medium">1. De vraag komt binnen</div>
<p class="text-sm text-slate-600">
Een klantvraag komt binnen via de API of via het handmatige testveld in het admin scherm.
Het ticket komt eerst in de wachtrij, zodat de zware stappen op de achtergrond kunnen lopen.
</p>
</div>
<div class="border-l-4 border-slate-900 pl-4">
<div class="font-medium">2. De vraag wordt netjes gemaakt</div>
<p class="text-sm text-slate-600">
De AI haalt ruis, typefouten en privegegevens weg. Denk aan: naam, adres, IBAN of andere
gegevens die niet nodig zijn om het probleem op te lossen.
</p>
</div>
<div class="border-l-4 border-slate-900 pl-4">
<div class="font-medium">3. De betekenis wordt omgezet naar een zoekcode</div>
<p class="text-sm text-slate-600">
De app maakt van de nette vraag een soort getallen-code. Die code beschrijft niet de exacte
woorden, maar waar de vraag over gaat. Daardoor kan "mail doet het niet" ook artikelen vinden
waarin "e-mail storing" staat.
</p>
</div>
<div class="border-l-4 border-slate-900 pl-4">
<div class="font-medium">4. De kennisbank wordt doorzocht</div>
<p class="text-sm text-slate-600">
Artikelen zijn opgeknipt in kleine tekstkaartjes. De app zoekt de kaartjes die het meest
lijken op de klantvraag en groepeert die daarna terug naar artikelen.
</p>
</div>
<div class="border-l-4 border-slate-900 pl-4">
<div class="font-medium">5. De AI kiest het beste artikel</div>
<p class="text-sm text-slate-600">
De AI krijgt de beste kandidaten te zien en kiest welk artikel het beste past. Daarbij geeft
hij ook aan hoe zeker hij is en waarom hij dat artikel kiest.
</p>
</div>
<div class="border-l-4 border-slate-900 pl-4">
<div class="font-medium">6. Eventueel wordt extra informatie opgehaald</div>
<p class="text-sm text-slate-600">
Sommige artikelen mogen extra hulpmiddelen gebruiken. Voor nu is dat alleen domeininformatie
ophalen. Dat gebeurt alleen als het artikel dit toestaat en de vraag genoeg gegevens bevat.
</p>
</div>
<div class="border-l-4 border-slate-900 pl-4">
<div class="font-medium">7. Er komt een advies of een melding</div>
<p class="text-sm text-slate-600">
Als het artikel een snelantwoord heeft, gebruikt de app dat direct. Anders maakt de AI een
korte conceptreactie. Als er geen passend artikel is, schrijft de app geen klantantwoord maar
meldt hij dat de kennisbank iets mist.
</p>
</div>
</div>
</div>
<div class="bg-white rounded-xl p-5 shadow">
<h3 class="font-semibold">Hoe werkt de kennisbank?</h3>
<div class="mt-4 space-y-4">
<div>
<div class="font-medium">Artikelen zijn de handleidingen</div>
<p class="text-sm text-slate-600">
Elk artikel is een uitleg die support kan gebruiken. Artikelen kunnen uit de externe
helpdesk komen of handmatig worden aangemaakt.
</p>
</div>
<div>
<div class="font-medium">Artikelen worden in kleine kaartjes geknipt</div>
<p class="text-sm text-slate-600">
Een lang artikel kan over meerdere dingen gaan. Daarom knippen we het in kleine stukken.
Zo kan de app precies het stukje vinden dat bij de vraag hoort.
</p>
</div>
<div>
<div class="font-medium">Elk kaartje krijgt een betekenis-code</div>
<p class="text-sm text-slate-600">
Die code helpt zoeken op betekenis. Het gaat dus niet alleen om dezelfde woorden, maar om
dezelfde bedoeling.
</p>
</div>
<div>
<div class="font-medium">Bij een nieuw AI-model opnieuw indexeren</div>
<p class="text-sm text-slate-600">
Als je het model wijzigt dat deze betekenis-codes maakt, moeten de kaartjes opnieuw worden
berekend. Dat kan via Settings bij Embeddings.
</p>
</div>
</div>
</div>
</section>
<section class="grid gap-4 lg:grid-cols-3">
<div class="bg-white rounded-xl p-5 shadow">
<h3 class="font-semibold">Wat zie je op een ticket?</h3>
<p class="mt-2 text-sm text-slate-600">
Je ziet de originele vraag, de opgeschoonde vraag, de gekozen artikelen, eventuele hulpmiddelen die
gebruikt zijn, fouten, en alle stappen in volgorde. De nieuwste stap staat bovenaan.
</p>
</div>
<div class="bg-white rounded-xl p-5 shadow">
<h3 class="font-semibold">Wanneer komt er geen antwoord?</h3>
<p class="mt-2 text-sm text-slate-600">
Als de app geen passend artikel vindt, maakt hij bewust geen reactie naar de klant. Dan zie je een
melding dat de kennisbank tekortschiet, plus een suggestie voor wat er mist.
</p>
</div>
<div class="bg-white rounded-xl p-5 shadow">
<h3 class="font-semibold">Waarvoor zijn artikelnotities?</h3>
<p class="mt-2 text-sm text-slate-600">
Een artikelnotitie is een interne tip voor de AI. Bijvoorbeeld: "Gebruik dit artikel alleen voor
domeinen" of "Vraag eerst om klantnummer als dit ontbreekt". De notitie is geen klanttekst.
</p>
</div>
</section>
<section class="grid gap-4 lg:grid-cols-2">
<div class="bg-white rounded-xl p-5 shadow">
<h3 class="font-semibold">Snelantwoorden</h3>
<p class="mt-2 text-sm text-slate-600">
Sommige artikelen hebben uiteindelijk hetzelfde antwoord nodig. Dan kun je een snelantwoord maken en
aan meerdere artikelen koppelen. Als zo'n artikel wordt gekozen, gebruikt de app het snelantwoord en
hoeft de AI geen nieuwe klantreactie te schrijven.
</p>
</div>
<div class="bg-white rounded-xl p-5 shadow">
<h3 class="font-semibold">Hulpmiddelen en allowed actions</h3>
<p class="mt-2 text-sm text-slate-600">
Sommige antwoorden worden beter als de app iets kan nakijken. Daarom kan een artikel aangeven welke
hulpmiddelen toegestaan zijn. De AI mag zo'n hulpmiddel alleen voorstellen; de applicatie controleert
daarna zelf of het echt mag.
</p>
<div class="mt-4 rounded border p-3">
<div class="font-medium">Nu beschikbaar: domain_inf</div>
<p class="mt-1 text-sm text-slate-600">
Hiermee kan de app domeininformatie ophalen. Dat kan alleen als het artikel `domain_inf` toestaat,
als de vraag een domeinnaam bevat, en als er API-gegevens op het ticket zijn opgeslagen.
</p>
</div>
</div>
<div class="bg-white rounded-xl p-5 shadow">
<h3 class="font-semibold">Woordenlijst</h3>
<div class="mt-4 space-y-3 text-sm text-slate-600">
<div><span class="font-medium text-slate-900">Embedding:</span> een betekenis-code van tekst.</div>
<div><span class="font-medium text-slate-900">Chunk:</span> een klein stukje van een artikel.</div>
<div><span class="font-medium text-slate-900">Confidence:</span> hoe zeker de AI is van zijn keuze.</div>
<div><span class="font-medium text-slate-900">Knowledge gap:</span> de kennisbank heeft waarschijnlijk nog geen goed artikel voor deze vraag.</div>
<div><span class="font-medium text-slate-900">Toolcall:</span> een gecontroleerde actie waarmee de app extra informatie kan ophalen.</div>
</div>
</div>
</section>
<section class="bg-white rounded-xl p-5 shadow">
<h3 class="font-semibold">Voorbeeld in gewone taal</h3>
<div class="mt-4 grid gap-3 lg:grid-cols-4">
<div class="rounded border p-3">
<div class="font-medium">Klant vraagt</div>
<p class="mt-1 text-sm text-slate-600">"Mijn mail doet het niet op mijn domein."</p>
</div>
<div class="rounded border p-3">
<div class="font-medium">App zoekt</div>
<p class="mt-1 text-sm text-slate-600">De app zoekt kaartjes over mail, domeinen en instellingen.</p>
</div>
<div class="rounded border p-3">
<div class="font-medium">AI kiest</div>
<p class="mt-1 text-sm text-slate-600">De AI kiest het artikel dat het meest bruikbaar lijkt.</p>
</div>
<div class="rounded border p-3">
<div class="font-medium">Support controleert</div>
<p class="mt-1 text-sm text-slate-600">Support ziet het advies, de reden en de gebruikte stappen.</p>
</div>
</div>
</section>
</div>
</x-layouts.admin>

View File

@@ -0,0 +1,3 @@
<x-layouts.admin title="Snelantwoorden">
<livewire:admin.quick-reply-manager />
</x-layouts.admin>

View File

@@ -0,0 +1,3 @@
<x-layouts.admin title="Settings">
<livewire:admin.settings-page />
</x-layouts.admin>

View File

@@ -0,0 +1,3 @@
<x-layouts.admin title="Ticket detail">
<livewire:admin.ticket-show :ticket-id="$ticket->id" />
</x-layouts.admin>

View File

@@ -0,0 +1,27 @@
@props([
'number' => null,
'title',
'description' => null,
'badge' => null,
])
<div class="grid gap-3 border rounded p-3 md:grid-cols-[2.5rem_1fr]">
<div class="h-8 w-8 rounded-full bg-slate-900 text-white text-sm font-semibold flex items-center justify-center">
{{ $number }}
</div>
<div class="space-y-3">
<div class="flex items-start justify-between gap-3">
<div>
<div class="font-medium">{{ $title }}</div>
@if($description)
<p class="mt-1 text-sm text-slate-600">{{ $description }}</p>
@endif
</div>
@if($badge)
<span class="shrink-0 rounded bg-slate-100 px-2 py-1 text-xs text-slate-600">{{ $badge }}</span>
@endif
</div>
{{ $slot }}
</div>
</div>

View File

@@ -15,7 +15,10 @@
<nav class="flex gap-3 text-sm">
<a href="{{ route('admin.dashboard') }}" class="hover:underline">Dashboard</a>
<a href="{{ route('admin.articles') }}" class="hover:underline">Articles</a>
<a href="{{ route('admin.quick-replies') }}" class="hover:underline">Snelantwoorden</a>
<a href="{{ route('admin.tickets') }}" class="hover:underline">Tickets</a>
<a href="{{ route('admin.process') }}" class="hover:underline">Proces</a>
<a href="{{ route('admin.settings') }}" class="hover:underline">Settings</a>
</nav>
</div>
</header>

View File

@@ -9,6 +9,16 @@
@error('title') <p class="text-red-600 text-sm">{{ $message }}</p> @enderror
<textarea wire:model="content" class="w-full border rounded p-2 min-h-40" placeholder="Content"></textarea>
@error('content') <p class="text-red-600 text-sm">{{ $message }}</p> @enderror
<textarea wire:model="note" class="w-full border rounded p-2 min-h-24" placeholder="Interne notitie voor de LLM (niet gebruikt voor embeddings)"></textarea>
@error('note') <p class="text-red-600 text-sm">{{ $message }}</p> @enderror
<label class="flex items-start gap-2 text-sm">
<input wire:model="allowedActions" type="checkbox" value="domain_inf" class="mt-1 rounded border-slate-300">
<span>
<span class="font-medium">domain_inf toestaan</span>
<span class="block text-slate-500">Alleen uitvoeren wanneer de LLM deze action aanvraagt en sld/tld aanwezig zijn.</span>
</span>
</label>
@error('allowedActions.*') <p class="text-red-600 text-sm">{{ $message }}</p> @enderror
<button class="bg-slate-900 text-white px-4 py-2 rounded" type="submit">Opslaan</button>
</form>
</div>
@@ -18,8 +28,84 @@
<div class="space-y-3">
@foreach($articles as $article)
<div class="border rounded p-3">
<div class="font-medium">#{{ $article->id }} {{ $article->title }}</div>
<div class="flex items-start justify-between gap-3">
<div>
<div class="font-medium">#{{ $article->id }} {{ $article->title }}</div>
@if(($article->status ?? 'published') === 'draft')
<span class="inline-block mt-1 text-xs px-2 py-0.5 rounded bg-amber-100 text-amber-800">Concept (AI)</span>
@endif
</div>
<div class="flex items-center gap-3">
@if(($article->status ?? 'published') === 'draft')
<button
type="button"
wire:click="approveDraft({{ $article->id }})"
class="text-sm text-green-700 hover:underline"
>
Valideren & publiceren
</button>
@endif
<button
type="button"
wire:click="deleteArticle({{ $article->id }})"
wire:confirm="Weet je zeker dat je dit artikel wilt verwijderen?"
class="text-sm text-red-600 hover:underline"
>
Verwijderen
</button>
</div>
</div>
@if($article->note)
<div class="mt-2 text-xs rounded bg-slate-50 p-2 text-slate-600">
<span class="font-semibold">LLM note:</span> {{ \Illuminate\Support\Str::limit($article->note, 180) }}
</div>
@endif
@if(($article->allowed_actions ?? []) !== [])
<div class="mt-2 flex flex-wrap gap-1">
@foreach($article->allowed_actions as $action)
<span class="text-xs px-2 py-0.5 rounded bg-blue-100 text-blue-800">{{ $action }}</span>
@endforeach
</div>
@endif
<div class="text-sm text-slate-600">{{ \Illuminate\Support\Str::limit($article->content, 140) }}</div>
<div class="mt-3 rounded bg-slate-50 p-3 space-y-2">
<label class="block text-xs font-semibold text-slate-500">LLM note</label>
<textarea wire:model="articleNotes.{{ $article->id }}" class="w-full border rounded p-2 min-h-20 text-sm" placeholder="Interne aanwijzingen voor dit artikel"></textarea>
@error("articleNotes.{$article->id}") <p class="text-red-600 text-sm">{{ $message }}</p> @enderror
<label class="flex items-start gap-2 text-sm">
<input wire:model="articleAllowedActions.{{ $article->id }}" type="checkbox" value="domain_inf" class="mt-1 rounded border-slate-300">
<span>domain_inf toestaan</span>
</label>
@error("articleAllowedActions.{$article->id}.*") <p class="text-red-600 text-sm">{{ $message }}</p> @enderror
<div>
<label class="block text-xs font-semibold text-slate-500 mb-1">Gekoppelde snelantwoorden</label>
@if($quickReplyOptions->isEmpty())
<p class="text-xs text-slate-500">Nog geen actieve snelantwoorden beschikbaar.</p>
@else
<div class="grid gap-1 sm:grid-cols-2">
@foreach($quickReplyOptions as $quickReply)
<label class="flex items-center gap-2 text-sm">
<input
wire:model="articleQuickReplies.{{ $article->id }}"
type="checkbox"
value="{{ $quickReply->id }}"
class="rounded border-slate-300"
>
<span>{{ $quickReply->title }}</span>
</label>
@endforeach
</div>
@endif
@error("articleQuickReplies.{$article->id}.*") <p class="text-red-600 text-sm">{{ $message }}</p> @enderror
</div>
<button
type="button"
wire:click="saveMetadata({{ $article->id }})"
class="text-sm px-3 py-1 rounded bg-slate-900 text-white"
>
Metadata opslaan
</button>
</div>
</div>
@endforeach
</div>

View File

@@ -0,0 +1,77 @@
<div class="space-y-6">
<div class="bg-white rounded-xl p-4 shadow">
<h2 class="font-semibold mb-1">Nieuw snelantwoord</h2>
<p class="text-sm text-slate-600 mb-3">
Gebruik dit voor vaste antwoorden die bij meerdere kennisbankartikelen passen. Als een gekozen artikel een
actief snelantwoord heeft, wordt er geen AI-antwoord meer gegenereerd.
</p>
@if (session('success'))
<div class="mb-3 text-green-700 bg-green-100 p-2 rounded">{{ session('success') }}</div>
@endif
<form wire:submit="save" class="space-y-3">
<input wire:model="title" type="text" class="w-full border rounded p-2" placeholder="Titel">
@error('title') <p class="text-red-600 text-sm">{{ $message }}</p> @enderror
<textarea wire:model="content" class="w-full border rounded p-2 min-h-36" placeholder="Snelantwoord tekst"></textarea>
@error('content') <p class="text-red-600 text-sm">{{ $message }}</p> @enderror
<label class="flex items-center gap-2 text-sm">
<input wire:model="isActive" type="checkbox" class="rounded border-slate-300">
<span>Actief</span>
</label>
<button class="bg-slate-900 text-white px-4 py-2 rounded" type="submit">Opslaan</button>
</form>
</div>
<div class="bg-white rounded-xl p-4 shadow">
<h2 class="font-semibold mb-3">Snelantwoorden</h2>
<div class="space-y-3">
@foreach($quickReplies as $quickReply)
<div class="border rounded p-3">
<div class="flex items-start justify-between gap-3">
<div>
<div class="font-medium">#{{ $quickReply->id }} {{ $quickReply->title }}</div>
<div class="text-xs text-slate-500">
Gekoppeld aan {{ $quickReply->articles_count }} artikel(en)
</div>
</div>
<button
type="button"
wire:click="deleteQuickReply({{ $quickReply->id }})"
wire:confirm="Weet je zeker dat je dit snelantwoord wilt verwijderen?"
class="text-sm text-red-600 hover:underline"
>
Verwijderen
</button>
</div>
<div class="mt-3 rounded bg-slate-50 p-3 space-y-2">
<input wire:model="editRows.{{ $quickReply->id }}.title" type="text" class="w-full border rounded p-2 text-sm">
@error("editRows.{$quickReply->id}.title") <p class="text-red-600 text-sm">{{ $message }}</p> @enderror
<textarea wire:model="editRows.{{ $quickReply->id }}.content" class="w-full border rounded p-2 min-h-28 text-sm"></textarea>
@error("editRows.{$quickReply->id}.content") <p class="text-red-600 text-sm">{{ $message }}</p> @enderror
<label class="flex items-center gap-2 text-sm">
<input wire:model="editRows.{{ $quickReply->id }}.is_active" type="checkbox" class="rounded border-slate-300">
<span>Actief</span>
</label>
<button
type="button"
wire:click="updateQuickReply({{ $quickReply->id }})"
class="text-sm px-3 py-1 rounded bg-slate-900 text-white"
>
Wijzigingen opslaan
</button>
</div>
</div>
@endforeach
</div>
<div class="mt-4">{{ $quickReplies->links() }}</div>
</div>
</div>

View File

@@ -0,0 +1,300 @@
<div class="space-y-4">
<div class="bg-white rounded-xl p-4 shadow">
<div class="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div>
<h2 class="text-lg font-semibold">AI Settings</h2>
<p class="text-sm text-slate-600">Beheer prompts, providers en modellen per stap in de pipeline.</p>
</div>
<button form="ai-settings-form" class="bg-slate-900 text-white px-4 py-2 rounded text-sm" type="submit">
Opslaan
</button>
</div>
@if (session('saved'))
<div class="mt-3 rounded bg-green-100 text-green-700 p-2 text-sm">{{ session('saved') }}</div>
@endif
<div class="mt-4 flex flex-wrap gap-2 text-sm">
@foreach(['process' => 'Proces & prompts', 'providers' => 'Providers', 'models' => 'Modellen', 'embeddings' => 'Embeddings'] as $tab => $label)
<button
type="button"
wire:click="setTab('{{ $tab }}')"
class="rounded px-3 py-2 {{ $activeTab === $tab ? 'bg-slate-900 text-white' : 'bg-slate-100 text-slate-700' }}"
>
{{ $label }}
</button>
@endforeach
</div>
</div>
<form id="ai-settings-form" wire:submit="save" class="space-y-4">
@if($activeTab === 'process')
<section class="bg-white rounded-xl p-4 shadow space-y-4">
<div>
<h3 class="font-semibold">Proces & prompts</h3>
<p class="text-sm text-slate-600">Elke stap toont wat er gebeurt. Waar een prompt gebruikt wordt, kun je die hier aanpassen.</p>
</div>
<div>
<label class="block text-sm mb-1">Aanspreekvorm fallback</label>
<select wire:model="tone_addressing" class="border rounded px-2 py-1">
<option value="je">je/jij</option>
<option value="u">u/uw</option>
</select>
</div>
<div class="space-y-3">
@foreach($processSteps as $step)
<x-admin.timeline-card
:number="$step['number']"
:title="$step['title']"
:description="$step['description']"
:badge="isset($step['prompt_key']) ? 'Prompt' : 'Geen prompt'"
>
@if(isset($step['prompt_key']))
<label class="block text-xs font-medium text-slate-500">{{ $step['prompt_key'] }}</label>
<textarea
wire:model="promptValues.{{ $step['id'] }}"
class="w-full border rounded p-2 min-h-28 text-sm"
></textarea>
@error('promptValues.'.$step['id']) <p class="text-sm text-red-600">{{ $message }}</p> @enderror
@endif
</x-admin.timeline-card>
@endforeach
</div>
</section>
@endif
@if($activeTab === 'providers')
<section class="bg-white rounded-xl p-4 shadow space-y-4">
<div class="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div>
<h3 class="font-semibold">LLM provider instances</h3>
<p class="text-sm text-slate-600">Voeg meerdere Ollama- of LM Studio-instances toe en kies welke instance actief is.</p>
</div>
<button type="button" wire:click="addProviderInstance" class="rounded bg-slate-900 px-3 py-2 text-sm text-white">
Instance toevoegen
</button>
</div>
<div class="grid gap-3 md:grid-cols-2">
<div>
<label class="block text-sm mb-1">Actieve instance</label>
<select wire:model.live="activeProviderInstanceId" wire:change="loadModels" class="w-full border rounded p-2">
@foreach($providerInstances as $instance)
<option value="{{ $instance['id'] }}">{{ $instance['name'] }} ({{ $providerDefinitions[$instance['type']]['label'] ?? $instance['type'] }})</option>
@endforeach
</select>
</div>
<div>
<label class="block text-sm mb-1">Timeout seconden</label>
<input wire:model="llm_timeout" type="number" min="5" max="600" class="w-full border rounded p-2">
</div>
</div>
<div class="space-y-4">
@foreach($providerInstances as $index => $instance)
@php($type = $instance['type'] ?? 'lmstudio')
@php($definition = $providerDefinitions[$type] ?? ['label' => $type, 'description' => ''])
<div class="border rounded p-4 space-y-3">
<div class="flex items-center justify-between gap-3">
<div>
<div class="font-medium">{{ $instance['name'] ?? 'Provider' }}</div>
<p class="text-sm text-slate-600">{{ $definition['description'] }}</p>
</div>
<div class="flex items-center gap-2">
@if($activeProviderInstanceId === ($instance['id'] ?? null))
<span class="rounded bg-green-100 px-2 py-1 text-xs text-green-700">Actief</span>
@else
<button type="button" wire:click="setActiveProviderInstance('{{ $instance['id'] }}')" class="rounded bg-slate-100 px-2 py-1 text-xs text-slate-700">
Actief maken
</button>
@endif
<button
type="button"
wire:click="removeProviderInstance('{{ $instance['id'] }}')"
class="text-xs text-red-600 hover:underline"
>
Verwijderen
</button>
</div>
</div>
<div class="grid gap-3 md:grid-cols-2">
<div>
<label class="block text-sm mb-1">Naam</label>
<input wire:model="providerInstances.{{ $index }}.name" type="text" class="w-full border rounded p-2">
</div>
<div>
<label class="block text-sm mb-1">Type</label>
<select wire:model="providerInstances.{{ $index }}.type" class="w-full border rounded p-2">
@foreach($providerDefinitions as $provider => $providerDefinition)
<option value="{{ $provider }}">{{ $providerDefinition['label'] }}</option>
@endforeach
</select>
</div>
</div>
<div>
<label class="block text-sm mb-1">Base URL</label>
<input wire:model="providerInstances.{{ $index }}.base_url" type="url" class="w-full border rounded p-2">
</div>
<div class="grid gap-3 md:grid-cols-2">
<div>
<label class="block text-sm mb-1">Standaard chat model</label>
<input wire:model="providerInstances.{{ $index }}.chat_model" type="text" class="w-full border rounded p-2">
</div>
<div>
<label class="block text-sm mb-1">Standaard embedding model</label>
<input wire:model="providerInstances.{{ $index }}.embedding_model" type="text" class="w-full border rounded p-2">
</div>
</div>
</div>
@endforeach
</div>
</section>
@endif
@if($activeTab === 'models')
<section class="bg-white rounded-xl p-4 shadow space-y-4">
<div class="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div>
<h3 class="font-semibold">Modellen per stap</h3>
<p class="text-sm text-slate-600">Kies per stap een model van de actieve instance. De lijst wordt 5 minuten gecachet.</p>
</div>
<button type="button" wire:click="refreshModels" class="rounded bg-slate-900 px-3 py-2 text-sm text-white">
Modellen verversen
</button>
</div>
@if($modelLoadError)
<div class="rounded bg-amber-100 p-2 text-sm text-amber-800">
Modellen konden niet live worden opgehaald: {{ $modelLoadError }}
</div>
@elseif($availableModels === [])
<div class="rounded bg-slate-100 p-2 text-sm text-slate-700">
Geen modellen gevonden voor de actieve instance. Je kunt nog steeds handmatig een modelnaam invullen.
</div>
@else
<div class="rounded bg-green-100 p-2 text-sm text-green-800">
{{ count($availableModels) }} modellen gevonden op de actieve instance.
</div>
@endif
<div class="rounded border p-3 text-sm text-slate-600">
Actieve instance:
@foreach($providerInstances as $instance)
@if(($instance['id'] ?? null) === $activeProviderInstanceId)
<strong>{{ $instance['name'] }}</strong> ({{ $providerDefinitions[$instance['type']]['label'] ?? $instance['type'] }})
@endif
@endforeach
</div>
<div class="space-y-3">
@foreach($modelTasks as $task)
<x-admin.timeline-card
:number="$task['number']"
:title="$task['title']"
:description="$task['description']"
badge="Model"
>
@if($availableModels !== [])
<select wire:model="modelValues.{{ $task['id'] }}" class="w-full border rounded p-2 text-sm">
<option value="">Kies model</option>
@foreach($availableModels as $model)
<option value="{{ $model }}">{{ $model }}</option>
@endforeach
</select>
@else
<input
wire:model="modelValues.{{ $task['id'] }}"
type="text"
class="w-full border rounded p-2 text-sm"
placeholder="Modelnaam"
>
@endif
@error('modelValues.'.$task['id']) <p class="text-sm text-red-600">{{ $message }}</p> @enderror
</x-admin.timeline-card>
@endforeach
</div>
</section>
@endif
@if($activeTab === 'embeddings')
<section class="bg-white rounded-xl p-4 shadow space-y-4">
<div class="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div>
<h3 class="font-semibold">Chunk embeddings</h3>
<p class="text-sm text-slate-600">Hergenereer embeddings voor kennisbankchunks. Dit draait via de queue en gebruikt de actieve embedding-provider en het embeddingmodel.</p>
</div>
<button type="button" wire:click="refreshEmbeddingStats" class="rounded bg-slate-100 px-3 py-2 text-sm text-slate-700">
Status verversen
</button>
</div>
<div class="grid gap-3 md:grid-cols-3">
<div class="rounded border p-3">
<div class="text-xs text-slate-500">Artikelen</div>
<div class="mt-1 text-2xl font-semibold">{{ $embeddingStats['articles'] ?? 0 }}</div>
<p class="mt-1 text-xs text-slate-600">{{ $embeddingStats['articles_without_chunks'] ?? 0 }} zonder chunks</p>
</div>
<div class="rounded border p-3">
<div class="text-xs text-slate-500">Chunks</div>
<div class="mt-1 text-2xl font-semibold">{{ $embeddingStats['chunks'] ?? 0 }}</div>
<p class="mt-1 text-xs text-slate-600">{{ $embeddingStats['chunks_without_embedding'] ?? 0 }} zonder embedding</p>
</div>
<div class="rounded border p-3">
<div class="text-xs text-slate-500">Chunks met embedding</div>
<div class="mt-1 text-2xl font-semibold">{{ $embeddingStats['chunks_with_embedding'] ?? 0 }}</div>
<p class="mt-1 text-xs text-slate-600">{{ $embeddingStats['articles_with_chunks'] ?? 0 }} artikelen geindexeerd</p>
</div>
</div>
<div class="rounded border p-3 text-sm">
<div class="font-medium">Actieve embedding context</div>
<div class="mt-2 grid gap-2 md:grid-cols-2">
<div>
<span class="text-slate-500">Provider instance:</span>
<span class="font-mono">{{ $embeddingStats['active_provider_instance_id'] ?? '-' }}</span>
</div>
<div>
<span class="text-slate-500">Embedding model:</span>
<span class="font-mono">{{ $embeddingStats['active_embedding_model'] ?? '-' }}</span>
</div>
</div>
<p class="mt-2 text-slate-600">
{{ $embeddingStats['current_embedding_chunks'] ?? 0 }} chunks passen bij het actieve embeddingmodel.
{{ $embeddingStats['stale_or_other_model_chunks'] ?? 0 }} chunks zijn leeg, oud of voor een ander model.
</p>
</div>
<div class="grid gap-4 lg:grid-cols-2">
<div class="border rounded p-4 space-y-3">
<div>
<h4 class="font-medium">Alleen ontbrekende chunks genereren</h4>
<p class="mt-1 text-sm text-slate-600">Plaats alleen artikelen zonder chunks opnieuw in de queue.</p>
</div>
<button type="button" wire:click="reindexMissingEmbeddings" class="rounded bg-slate-900 px-3 py-2 text-sm text-white">
Ontbrekende chunks genereren
</button>
</div>
<div class="border rounded p-4 space-y-3">
<div>
<h4 class="font-medium">Alles opnieuw genereren</h4>
<p class="mt-1 text-sm text-slate-600">Plaats alle artikelen opnieuw in de queue. Bestaande chunks worden per artikel vervangen tijdens verwerking.</p>
</div>
<button
type="button"
wire:click="reindexAllEmbeddings"
wire:confirm="Weet je zeker dat je alle artikelchunks opnieuw wilt genereren?"
class="rounded bg-red-700 px-3 py-2 text-sm text-white"
>
Alle chunks opnieuw genereren
</button>
</div>
</div>
</section>
@endif
</form>
</div>

View File

@@ -1,30 +1,65 @@
<div class="bg-white rounded-xl p-4 shadow">
<div class="flex items-center justify-between mb-3">
<h2 class="font-semibold">Tickets + AI Decisions</h2>
<div class="flex items-center gap-2 text-sm">
<label for="perPage">Per pagina</label>
<select id="perPage" wire:model.live="perPage" class="border rounded px-2 py-1">
<option value="10">10</option>
<option value="25">25</option>
<option value="50">50</option>
</select>
</div>
<div class="space-y-6">
<div class="bg-white rounded-xl p-4 shadow">
<h2 class="font-semibold mb-3">Ticket simulatie (handmatig inschieten)</h2>
@if($submitError)
<div class="mb-3 rounded bg-red-100 text-red-700 px-3 py-2 text-sm">{{ $submitError }}</div>
@endif
@if($lastResult)
<div class="mb-3 rounded bg-green-100 text-green-800 px-3 py-2 text-sm">
Ticket #{{ $lastResult['ticket_id'] }} aangemaakt met status '{{ $lastResult['status'] }}'.
<a class="underline" href="{{ route('admin.tickets.show', ['ticket' => $lastResult['ticket_id']]) }}">Bekijk voortgang</a>
</div>
@endif
<form wire:submit="submitTicket" class="space-y-3">
<textarea wire:model="newTicketMessage" class="w-full border rounded p-2 min-h-28" placeholder="Bijv: Mijn website geeft 500 fout na plugin update"></textarea>
@error('newTicketMessage') <p class="text-red-600 text-sm">{{ $message }}</p> @enderror
<div class="grid md:grid-cols-2 gap-3">
<div>
<input wire:model="apiUser" type="text" class="w-full border rounded p-2" placeholder="API user (optioneel)">
@error('apiUser') <p class="text-red-600 text-sm">{{ $message }}</p> @enderror
</div>
<div>
<input wire:model="apiPassword" type="password" class="w-full border rounded p-2" placeholder="API password/key (optioneel)">
@error('apiPassword') <p class="text-red-600 text-sm">{{ $message }}</p> @enderror
</div>
</div>
<p class="text-xs text-slate-500">Credentials worden encrypted op het ticket opgeslagen en alleen gebruikt voor toegestane toolcalls.</p>
<button class="bg-slate-900 text-white px-4 py-2 rounded" type="submit">Ticket inschieten</button>
</form>
</div>
<div class="space-y-3">
@foreach($tickets as $ticket)
<div class="border rounded p-3">
<div class="font-medium">Ticket #{{ $ticket->id }}</div>
<div class="text-sm text-slate-700 mb-2">{{ $ticket->message }}</div>
@php($decision = $ticket->decisions->first())
@if($decision)
<div class="text-sm">Article: #{{ $decision->article_id ?? 'N/A' }} | Confidence: {{ number_format($decision->confidence, 2) }}</div>
<div class="text-xs text-slate-500">{{ $decision->explanation }}</div>
@else
<div class="text-sm text-slate-500">Nog geen AI beslissing.</div>
@endif
<div class="bg-white rounded-xl p-4 shadow">
<div class="flex items-center justify-between mb-3">
<h2 class="font-semibold">Tickets + status</h2>
<div class="flex items-center gap-2 text-sm">
<label for="perPage">Per pagina</label>
<select id="perPage" wire:model.live="perPage" class="border rounded px-2 py-1">
<option value="10">10</option>
<option value="25">25</option>
<option value="50">50</option>
</select>
</div>
@endforeach
</div>
<div class="space-y-3">
@foreach($tickets as $ticket)
<div class="border rounded p-3">
<div class="flex items-center justify-between">
<div class="font-medium">Ticket #{{ $ticket->id }}</div>
<span class="text-xs px-2 py-1 rounded bg-slate-100">{{ $ticket->status }}</span>
</div>
<div class="text-sm text-slate-700 mb-2">{{ $ticket->message }}</div>
@if($ticket->bestArticle)
<div class="text-sm">Article: #{{ $ticket->bestArticle->id }} | Confidence: {{ number_format((float) $ticket->confidence, 2) }}</div>
<div class="text-xs text-slate-500">{{ $ticket->explanation }}</div>
@endif
<a class="text-sm underline" href="{{ route('admin.tickets.show', ['ticket' => $ticket->id]) }}">Bekijk detail/progress</a>
</div>
@endforeach
</div>
<div class="mt-4">{{ $tickets->links() }}</div>
</div>
<div class="mt-4">{{ $tickets->links() }}</div>
</div>

View File

@@ -0,0 +1,147 @@
<div wire:poll.5s class="space-y-6">
<div class="bg-white rounded-xl p-4 shadow">
<div class="flex items-center justify-between">
<h2 class="font-semibold">Ticket #{{ $ticket->id }}</h2>
<div class="flex items-center gap-3">
<button
type="button"
wire:click="reprocess"
wire:confirm="Weet je zeker dat je ticket #{{ $ticket->id }} opnieuw wilt verwerken?"
class="text-sm px-3 py-1 rounded bg-slate-900 text-white"
>
Herverwerk ticket
</button>
<span class="text-sm px-2 py-1 rounded bg-slate-100">Status: {{ $ticket->status }}</span>
</div>
</div>
@if (session('success'))
<div class="mt-3 rounded bg-green-100 text-green-700 p-2 text-sm">{{ session('success') }}</div>
@endif
@if($ticket->needs_article_draft)
<div class="mt-3 rounded bg-amber-50 border border-amber-300 p-3">
<div class="text-sm font-semibold text-amber-900 mb-1">Kennisbank-gat gedetecteerd</div>
<p class="text-sm text-amber-900">
Er is geen geschikt artikel in de kennisbank gevonden voor deze vraag.
</p>
@php($suggestion = $ticket->result_payload['draft_article_suggestion'] ?? null)
@if(is_array($suggestion))
<div class="mt-3">
<p class="text-sm font-semibold text-amber-900">Voorgestelde titel</p>
<p class="text-sm text-amber-900">{{ $suggestion['title'] ?? '-' }}</p>
</div>
<div class="mt-2">
<p class="text-sm font-semibold text-amber-900">Voorgestelde inhoud</p>
<pre class="text-sm whitespace-pre-wrap text-amber-900">{{ $suggestion['content'] ?? '-' }}</pre>
</div>
@endif
</div>
@endif
@if($ticket->support_reply && !$ticket->needs_article_draft)
<div class="mt-3 rounded bg-blue-50 border border-blue-200 p-3">
<div class="text-sm font-semibold text-blue-900 mb-1">Concept reactie voor klant</div>
@if(is_array($ticket->result_payload['quick_reply'] ?? null))
<div class="mb-2 inline-flex rounded bg-blue-100 px-2 py-1 text-xs text-blue-900">
Snelantwoord gebruikt: #{{ $ticket->result_payload['quick_reply']['id'] ?? '-' }} {{ $ticket->result_payload['quick_reply']['title'] ?? '' }}
</div>
@endif
<pre class="text-sm whitespace-pre-wrap text-blue-900">{{ $ticket->support_reply }}</pre>
</div>
@endif
<p class="mt-3 text-sm text-slate-800"><strong>Origineel:</strong> {{ $ticket->message }}</p>
@if($ticket->normalized_message)
<p class="mt-2 text-sm text-slate-800"><strong>Genormaliseerd:</strong> {{ $ticket->normalized_message }}</p>
@endif
@if($ticket->redaction_report)
<pre class="text-xs mt-2 bg-slate-50 p-2 rounded overflow-x-auto">{{ json_encode($ticket->redaction_report, JSON_PRETTY_PRINT|JSON_UNESCAPED_UNICODE) }}</pre>
@endif
@if(is_array($ticket->api_credentials) && !empty($ticket->api_credentials['apiuser']))
<div class="mt-3 text-sm rounded bg-slate-100 text-slate-700 p-2">
API credentials aanwezig voor deze ticket-run. Waarden worden niet getoond.
</div>
@endif
@if($ticket->error_message)
<div class="mt-3 text-sm rounded bg-red-100 text-red-700 p-2">{{ $ticket->error_message }}</div>
@endif
@if($ticket->bestArticle)
<div class="mt-3 text-sm rounded bg-green-100 text-green-800 p-2">
Beste artikel: #{{ $ticket->bestArticle->id }} - {{ $ticket->bestArticle->title }}
@if($ticket->confidence !== null)
(confidence {{ number_format($ticket->confidence, 2) }})
@endif
</div>
@endif
</div>
<div class="bg-white rounded-xl p-4 shadow">
<h3 class="font-semibold mb-3">Toolcalls</h3>
@if($ticket->toolCalls->isEmpty())
<p class="text-sm text-slate-600">Geen toolcalls uitgevoerd of voorgesteld.</p>
@else
<div class="space-y-2">
@foreach($ticket->toolCalls as $toolCall)
<div class="border rounded p-3 text-sm">
<div class="flex items-center justify-between">
<div class="font-medium">
{{ $toolCall->action }} ({{ $toolCall->status }})
@if($toolCall->article)
<span class="text-slate-500">via artikel #{{ $toolCall->article->id }}</span>
@endif
</div>
<span class="text-xs text-slate-500">{{ $toolCall->executed_at ?? $toolCall->created_at }}</span>
</div>
@if($toolCall->parameters)
<div class="mt-2">
<div class="text-xs font-semibold text-slate-500">Parameters</div>
<pre class="text-xs bg-slate-50 p-2 rounded overflow-x-auto">{{ json_encode($toolCall->parameters, JSON_PRETTY_PRINT|JSON_UNESCAPED_UNICODE) }}</pre>
</div>
@endif
@if($toolCall->response)
<div class="mt-2">
<div class="text-xs font-semibold text-slate-500">Response</div>
<pre class="text-xs bg-slate-50 p-2 rounded overflow-x-auto">{{ json_encode($toolCall->response, JSON_PRETTY_PRINT|JSON_UNESCAPED_UNICODE|JSON_UNESCAPED_SLASHES) }}</pre>
</div>
@endif
@if($toolCall->error)
<div class="mt-2 rounded bg-amber-50 text-amber-900 p-2">{{ $toolCall->error }}</div>
@endif
</div>
@endforeach
</div>
@endif
</div>
<div class="bg-white rounded-xl p-4 shadow">
<h3 class="font-semibold mb-3">Verwerkingsstappen</h3>
@php($orderedLogs = $ticket->logs->sortBy([['created_at', 'desc'], ['id', 'desc']]))
@php($latestLog = $orderedLogs->first())
@if($ticket->status === 'processing' && $latestLog)
<div class="mb-3 rounded border border-blue-200 bg-blue-50 p-2 text-sm text-blue-900 flex items-center justify-between">
<span>Huidige stap: <strong>{{ $latestLog->step }}</strong></span>
<span class="inline-flex items-center gap-1">
<span class="inline-block h-2 w-2 rounded-full bg-blue-600 animate-pulse"></span>
Bezig
</span>
</div>
@endif
<div class="space-y-2">
@foreach($orderedLogs as $log)
<div class="border rounded p-3 text-sm">
<div class="flex items-center justify-between">
<span class="font-medium">{{ $log->step }} ({{ $log->status }})</span>
<span class="text-xs text-slate-500">{{ $log->created_at }}</span>
</div>
@if($log->message)
<div class="text-slate-700 mt-1">{{ $log->message }}</div>
@endif
@if($log->context)
<pre class="text-xs mt-2 bg-slate-50 p-2 rounded overflow-x-auto">{{ json_encode($log->context, JSON_PRETTY_PRINT|JSON_UNESCAPED_UNICODE) }}</pre>
@endif
</div>
@endforeach
</div>
</div>
</div>