v1.2.1
- Generic AI integration (Admin > Integracje > "Integracja AI"), optional and off by default: an OpenAI-compatible /chat/completions client (Groq, OpenAI, or a self-hosted Ollama instance) configured by base URL, optional API key, model, and an SSL-verification toggle. Foundation for the two AI features below and anything else that wants an LLM call in the future. - BookStack automatic content tagging (AI): "Otaguj nową treść"/"Otaguj wszystko ponownie" buttons plus `php artisan bookstack:tag-content` (--dry-run/--force/--limit=N) tag every book/chapter/page with matching helpdesk subcategory names, idempotent by default. - BookStack search refinement: "Przeszukuj" is now three independent checkboxes (Książki/Strony/Rozdziały) instead of a single dropdown, plus a new "Szukaj po" setting (nazwa/tagi/oba) — tag matching uses the bare subcategory name, matching what auto-tagging writes. - AI-driven ticket triage + summary (Admin > Integracje > "Automatyzacja AI dla zgłoszeń", via new scheduled ai:run-ticket-automation): five toggles auto-assign/correct category+subcategory, rewrite an unclear subject, and set priority from content, once per ticket in the background; every change is logged in the ticket's history. Separately, an AI summary + suggested action for every ticket, shown to operators only, with an admin-editable prompt. - Operators can now reassign a ticket to any team, not just one they belong to. - The auto-refresh countdown badges (ticket view, operator queue) are now clickable — fetch immediately and reset the countdown. - All 7 "cyclical" intervals (3 browser refresh countdowns, the notification bell poll, and the 4 background scheduled commands) are now configurable from Admin > Konfiguracja instead of fixed in code. - Fixed: an operator viewing a ticket that's deleted or moved outside their team scope mid-session is now redirected to the operator queue instead of hitting an error. - Docs: README/ARCHITECTURE/CLAUDE/install/wiki updated for all of the above. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -17,7 +17,9 @@ use App\Models\Team;
|
||||
use App\Models\Ticket;
|
||||
use App\Models\User;
|
||||
use App\Models\UserField;
|
||||
use App\Services\AiClient;
|
||||
use App\Services\BookStackClient;
|
||||
use App\Services\BookStackContentTagger;
|
||||
use App\Services\LdapUserProvisioner;
|
||||
use App\Support\Settings;
|
||||
use Illuminate\Support\Collection;
|
||||
@@ -152,6 +154,24 @@ class Panel extends Component
|
||||
|
||||
public ?string $bookstackTestMessage = null;
|
||||
|
||||
public ?array $bookstackTagResult = null;
|
||||
|
||||
public ?string $bookstackTagError = null;
|
||||
|
||||
public array $aiConfig = [];
|
||||
|
||||
public ?string $aiTestResult = null;
|
||||
|
||||
public ?string $aiTestMessage = null;
|
||||
|
||||
public array $aiTriageConfig = [];
|
||||
|
||||
public bool $aiSummaryEnabled = false;
|
||||
|
||||
public string $aiSummaryPrompt = '';
|
||||
|
||||
public int $aiSummaryPromptVersion = 0;
|
||||
|
||||
// ---- generic pending-delete confirm ----
|
||||
public ?string $pendingDeleteType = null;
|
||||
|
||||
@@ -179,6 +199,13 @@ class Panel extends Component
|
||||
'ticketNumberPrefix' => Settings::get('ticket_number_prefix'),
|
||||
'ticketNumberObfuscate' => Settings::bool('ticket_number_obfuscate'),
|
||||
'ticketNumberMinLength' => Settings::get('ticket_number_min_length'),
|
||||
'refreshTicketViewSeconds' => Settings::get('refresh_ticket_view_seconds'),
|
||||
'refreshQueueSeconds' => Settings::get('refresh_queue_seconds'),
|
||||
'refreshNotificationsSeconds' => Settings::get('refresh_notifications_seconds'),
|
||||
'scheduleSlaCheckMinutes' => Settings::get('schedule_sla_check_minutes'),
|
||||
'scheduleAutomationRulesMinutes' => Settings::get('schedule_automation_rules_minutes'),
|
||||
'scheduleImapFetchMinutes' => Settings::get('schedule_imap_fetch_minutes'),
|
||||
'scheduleAiAutomationMinutes' => Settings::get('schedule_ai_automation_minutes'),
|
||||
];
|
||||
|
||||
$this->ldapConfig = [
|
||||
@@ -202,10 +229,29 @@ class Panel extends Component
|
||||
'tokenSecret' => Settings::get('bookstack_token_secret'),
|
||||
'verifySsl' => Settings::bool('bookstack_verify_ssl'),
|
||||
'showToGuests' => Settings::bool('bookstack_show_to_guests'),
|
||||
'searchTypes' => Settings::get('bookstack_search_types', 'both'),
|
||||
'searchTypes' => BookStackClient::normalizeSearchTypes(Settings::get('bookstack_search_types', '')),
|
||||
'searchBy' => in_array($searchBy = Settings::get('bookstack_search_by', 'both'), BookStackClient::SEARCH_BY_OPTIONS, true) ? $searchBy : 'both',
|
||||
'allowedShelfIdsCreation' => $this->parseShelfIds(Settings::get('bookstack_allowed_shelf_ids_creation', '')),
|
||||
'allowedShelfIdsTicketView' => $this->parseShelfIds(Settings::get('bookstack_allowed_shelf_ids_ticket_view', '')),
|
||||
];
|
||||
|
||||
$this->aiConfig = [
|
||||
'enabled' => Settings::bool('ai_enabled'),
|
||||
'baseUrl' => Settings::get('ai_base_url'),
|
||||
'apiKey' => Settings::get('ai_api_key'),
|
||||
'model' => Settings::get('ai_model'),
|
||||
'verifySsl' => Settings::bool('ai_verify_ssl'),
|
||||
];
|
||||
|
||||
$this->aiTriageConfig = [
|
||||
'categoryWhenMissing' => Settings::bool('ai_triage_category_when_missing'),
|
||||
'subcategoryWhenCategoryOnly' => Settings::bool('ai_triage_subcategory_when_category_only'),
|
||||
'recheckCategorized' => Settings::bool('ai_triage_recheck_categorized'),
|
||||
'fixSubject' => Settings::bool('ai_triage_fix_subject'),
|
||||
'setPriority' => Settings::bool('ai_triage_set_priority'),
|
||||
];
|
||||
$this->aiSummaryEnabled = Settings::bool('ai_summary_enabled');
|
||||
$this->aiSummaryPrompt = Settings::get('ai_summary_prompt');
|
||||
}
|
||||
|
||||
public function setTab(string $tab): void
|
||||
@@ -1348,6 +1394,14 @@ class Panel extends Component
|
||||
Settings::set('ticket_number_prefix', trim((string) $this->systemConfig['ticketNumberPrefix']));
|
||||
Settings::set('ticket_number_obfuscate', $this->systemConfig['ticketNumberObfuscate'] ? '1' : '0');
|
||||
Settings::set('ticket_number_min_length', (string) max(1, (int) $this->systemConfig['ticketNumberMinLength']));
|
||||
|
||||
Settings::set('refresh_ticket_view_seconds', (string) max(1, (int) $this->systemConfig['refreshTicketViewSeconds']));
|
||||
Settings::set('refresh_queue_seconds', (string) max(1, (int) $this->systemConfig['refreshQueueSeconds']));
|
||||
Settings::set('refresh_notifications_seconds', (string) max(1, (int) $this->systemConfig['refreshNotificationsSeconds']));
|
||||
Settings::set('schedule_sla_check_minutes', (string) max(1, (int) $this->systemConfig['scheduleSlaCheckMinutes']));
|
||||
Settings::set('schedule_automation_rules_minutes', (string) max(1, (int) $this->systemConfig['scheduleAutomationRulesMinutes']));
|
||||
Settings::set('schedule_imap_fetch_minutes', (string) max(1, (int) $this->systemConfig['scheduleImapFetchMinutes']));
|
||||
Settings::set('schedule_ai_automation_minutes', (string) max(1, (int) $this->systemConfig['scheduleAiAutomationMinutes']));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1439,8 +1493,10 @@ class Panel extends Component
|
||||
Settings::set('bookstack_verify_ssl', $this->bookstackConfig['verifySsl'] ? '1' : '0');
|
||||
Settings::set('bookstack_show_to_guests', $this->bookstackConfig['showToGuests'] ? '1' : '0');
|
||||
|
||||
if (in_array($this->bookstackConfig['searchTypes'], ['both', 'page', 'book'], true)) {
|
||||
Settings::set('bookstack_search_types', $this->bookstackConfig['searchTypes']);
|
||||
Settings::set('bookstack_search_types', implode(',', BookStackClient::normalizeSearchTypes($this->bookstackConfig['searchTypes'])));
|
||||
|
||||
if (in_array($this->bookstackConfig['searchBy'], BookStackClient::SEARCH_BY_OPTIONS, true)) {
|
||||
Settings::set('bookstack_search_by', $this->bookstackConfig['searchBy']);
|
||||
}
|
||||
|
||||
Settings::set('bookstack_allowed_shelf_ids_creation', implode(',', $this->bookstackConfig['allowedShelfIdsCreation']));
|
||||
@@ -1490,6 +1546,15 @@ class Panel extends Component
|
||||
: [...$ids, $id];
|
||||
}
|
||||
|
||||
public function toggleBookstackSearchType(string $type): void
|
||||
{
|
||||
$types = $this->bookstackConfig['searchTypes'];
|
||||
|
||||
$this->bookstackConfig['searchTypes'] = in_array($type, $types, true)
|
||||
? array_values(array_diff($types, [$type]))
|
||||
: [...$types, $type];
|
||||
}
|
||||
|
||||
public function testBookstackConnection(): void
|
||||
{
|
||||
$cfg = $this->bookstackConfig;
|
||||
@@ -1508,6 +1573,93 @@ class Panel extends Component
|
||||
$this->bookstackTestMessage = $result['message'];
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs synchronously in the request (no queue worker runs in this
|
||||
* deployment — see CLAUDE.md — so a dispatched job would just sit in the
|
||||
* `jobs` table). Safe to click again if it times out on a large wiki:
|
||||
* every write is idempotent, so a re-run just skips whatever already got
|
||||
* tagged (or, for the --force variant, re-classifies from scratch).
|
||||
*/
|
||||
public function runBookstackTagging(bool $force = false): void
|
||||
{
|
||||
if (! app(BookStackClient::class)->enabled() || ! app(AiClient::class)->enabled()) {
|
||||
$this->bookstackTagResult = null;
|
||||
$this->bookstackTagError = 'Włącz i skonfiguruj obie integracje — BookStack oraz AI — przed uruchomieniem tagowania.';
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$this->bookstackTagError = null;
|
||||
|
||||
set_time_limit(0);
|
||||
$this->bookstackTagResult = app(BookStackContentTagger::class)->run(force: $force);
|
||||
}
|
||||
|
||||
public function runBookstackTaggingForce(): void
|
||||
{
|
||||
$this->runBookstackTagging(force: true);
|
||||
}
|
||||
|
||||
// ===================== AI CONFIG =====================
|
||||
|
||||
public function saveAiConfig(): void
|
||||
{
|
||||
Settings::set('ai_enabled', $this->aiConfig['enabled'] ? '1' : '0');
|
||||
Settings::set('ai_base_url', $this->aiConfig['baseUrl']);
|
||||
|
||||
if ($this->aiConfig['apiKey']) {
|
||||
Settings::set('ai_api_key', $this->aiConfig['apiKey']);
|
||||
}
|
||||
|
||||
Settings::set('ai_model', $this->aiConfig['model']);
|
||||
Settings::set('ai_verify_ssl', $this->aiConfig['verifySsl'] ? '1' : '0');
|
||||
|
||||
$this->aiTestResult = null;
|
||||
$this->aiTestMessage = null;
|
||||
}
|
||||
|
||||
public function testAiConnection(): void
|
||||
{
|
||||
$cfg = $this->aiConfig;
|
||||
|
||||
if (empty($cfg['baseUrl']) || empty($cfg['model'])) {
|
||||
$this->aiTestResult = 'error';
|
||||
$this->aiTestMessage = 'Uzupełnij adres API i nazwę modelu.';
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$apiKey = $cfg['apiKey'] ?: Settings::get('ai_api_key');
|
||||
$result = app(AiClient::class)->testConnection($cfg['baseUrl'], $apiKey ?? '', $cfg['model'], (bool) $cfg['verifySsl']);
|
||||
|
||||
$this->aiTestResult = $result['ok'] ? 'ok' : 'error';
|
||||
$this->aiTestMessage = $result['message'];
|
||||
}
|
||||
|
||||
public function saveAiTriageConfig(): void
|
||||
{
|
||||
Settings::set('ai_triage_category_when_missing', $this->aiTriageConfig['categoryWhenMissing'] ? '1' : '0');
|
||||
Settings::set('ai_triage_subcategory_when_category_only', $this->aiTriageConfig['subcategoryWhenCategoryOnly'] ? '1' : '0');
|
||||
Settings::set('ai_triage_recheck_categorized', $this->aiTriageConfig['recheckCategorized'] ? '1' : '0');
|
||||
Settings::set('ai_triage_fix_subject', $this->aiTriageConfig['fixSubject'] ? '1' : '0');
|
||||
Settings::set('ai_triage_set_priority', $this->aiTriageConfig['setPriority'] ? '1' : '0');
|
||||
Settings::set('ai_summary_enabled', $this->aiSummaryEnabled ? '1' : '0');
|
||||
}
|
||||
|
||||
public function saveAiSummaryPrompt(string $value): void
|
||||
{
|
||||
Settings::set('ai_summary_prompt', $value);
|
||||
$this->aiSummaryPrompt = $value;
|
||||
}
|
||||
|
||||
public function resetAiSummaryPrompt(): void
|
||||
{
|
||||
$default = Settings::default('ai_summary_prompt');
|
||||
Settings::set('ai_summary_prompt', $default);
|
||||
$this->aiSummaryPrompt = $default;
|
||||
$this->aiSummaryPromptVersion++;
|
||||
}
|
||||
|
||||
// ===================== GENERIC DELETE CONFIRM =====================
|
||||
|
||||
public function requestDelete(string $type, mixed $id, string $message): void
|
||||
|
||||
@@ -82,8 +82,9 @@ class NewTicket extends Component
|
||||
}
|
||||
|
||||
$query = trim(($this->selectedCategory?->name ?? '').' '.($this->selectedSubcategory?->name ?? ''));
|
||||
$tagQuery = trim($this->selectedSubcategory?->name ?? '');
|
||||
|
||||
return app(BookStackClient::class)->search($query);
|
||||
return app(BookStackClient::class)->search($query, tagQuery: $tagQuery);
|
||||
}
|
||||
|
||||
public function backToCategory(): void
|
||||
|
||||
@@ -122,8 +122,9 @@ class TicketShow extends Component
|
||||
|
||||
$subcategory = $this->ticket->subcategory;
|
||||
$query = trim(($subcategory?->category?->name ?? '').' '.($subcategory?->name ?? ''));
|
||||
$tagQuery = trim($subcategory?->name ?? '');
|
||||
|
||||
return app(BookStackClient::class)->search($query);
|
||||
return app(BookStackClient::class)->search($query, tagQuery: $tagQuery);
|
||||
}
|
||||
|
||||
public function updatedAttachments(): void
|
||||
|
||||
@@ -100,8 +100,9 @@ class Landing extends Component
|
||||
}
|
||||
|
||||
$query = trim(($this->selectedCategory?->name ?? '').' '.($this->selectedSubcategory?->name ?? ''));
|
||||
$tagQuery = trim($this->selectedSubcategory?->name ?? '');
|
||||
|
||||
return app(BookStackClient::class)->search($query);
|
||||
return app(BookStackClient::class)->search($query, tagQuery: $tagQuery);
|
||||
}
|
||||
|
||||
public function backToCategory(): void
|
||||
|
||||
@@ -91,8 +91,9 @@ class NewTicket extends Component
|
||||
}
|
||||
|
||||
$query = trim(($this->selectedCategory?->name ?? '').' '.($this->selectedSubcategory?->name ?? ''));
|
||||
$tagQuery = trim($this->selectedSubcategory?->name ?? '');
|
||||
|
||||
return app(BookStackClient::class)->search($query);
|
||||
return app(BookStackClient::class)->search($query, tagQuery: $tagQuery);
|
||||
}
|
||||
|
||||
public function backToCategory(): void
|
||||
|
||||
@@ -16,6 +16,7 @@ use App\Models\User;
|
||||
use App\Services\BookStackClient;
|
||||
use App\Services\TicketService;
|
||||
use App\Support\Settings;
|
||||
use Illuminate\Database\Eloquent\ModelNotFoundException;
|
||||
use Illuminate\Support\Facades\Auth;
|
||||
use Livewire\Attributes\Computed;
|
||||
use Livewire\Attributes\On;
|
||||
@@ -78,6 +79,18 @@ class TicketShow extends Component
|
||||
$this->suggestedArticlesLoaded = true;
|
||||
}
|
||||
|
||||
// Same wire:init-deferred pattern — the AI summary card just displays
|
||||
// whatever the scheduled ai:run-ticket-automation command last computed
|
||||
// (no live AI call from the ticket page), but refreshing the ticket here
|
||||
// picks up a summary the command generated after this page's initial load.
|
||||
public bool $aiSummaryLoaded = false;
|
||||
|
||||
public function loadAiSummary(): void
|
||||
{
|
||||
$this->aiSummaryLoaded = true;
|
||||
$this->ticket->refresh();
|
||||
}
|
||||
|
||||
public function mount(Ticket $ticket): void
|
||||
{
|
||||
abort_unless($ticket->isVisibleToOperator(Auth::user()), 403);
|
||||
@@ -91,6 +104,24 @@ class TicketShow extends Component
|
||||
$this->ticket->resumeTimer();
|
||||
}
|
||||
|
||||
/**
|
||||
* Livewire lifecycle hook, called for any exception raised while
|
||||
* handling a request for this component — including one thrown while
|
||||
* re-hydrating the typed $ticket property itself (Livewire re-fetches
|
||||
* it by id on every request), which happens before any of this
|
||||
* component's own methods run and so can't be caught locally the way
|
||||
* refreshOrRedirectAway() catches it during an explicit refresh().
|
||||
* Covers a ticket deleted by someone else while an operator still has
|
||||
* it open — sends them back to their queue instead of a hard error.
|
||||
*/
|
||||
public function exception(\Throwable $e, $stopPropagation): void
|
||||
{
|
||||
if ($e instanceof ModelNotFoundException) {
|
||||
$this->redirect(route('operator.queue'), navigate: true);
|
||||
$stopPropagation();
|
||||
}
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
public function isWatching(): bool
|
||||
{
|
||||
@@ -209,6 +240,36 @@ class TicketShow extends Component
|
||||
unset($this->publicMessages, $this->internalMessages);
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-fetches the ticket and, for a non-admin operator, sends them back
|
||||
* to their queue instead of leaving them stuck on a page that can no
|
||||
* longer legitimately show anything — either because the ticket was
|
||||
* deleted (refresh() throws ModelNotFoundException, same as
|
||||
* Model::findOrFail() internally) or because a team/assignee change
|
||||
* (by this operator or anyone else) moved it out of their visible
|
||||
* scope. Returns false when it redirected, so callers can bail out of
|
||||
* whatever they were doing instead of continuing to operate on a
|
||||
* ticket that's about to disappear from under them.
|
||||
*/
|
||||
protected function refreshOrRedirectAway(): bool
|
||||
{
|
||||
try {
|
||||
$this->ticket->refresh();
|
||||
} catch (ModelNotFoundException) {
|
||||
$this->redirect(route('operator.queue'), navigate: true);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! $this->ticket->isVisibleToOperator(Auth::user())) {
|
||||
$this->redirect(route('operator.queue'), navigate: true);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bridged from a TicketQueueChanged broadcast (see resources/js/echo.js
|
||||
* and Queue::onQueueChanged()) — lets a status/priority/team/assignee
|
||||
@@ -222,7 +283,7 @@ class TicketShow extends Component
|
||||
return;
|
||||
}
|
||||
|
||||
$this->ticket->refresh();
|
||||
$this->refreshOrRedirectAway();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -232,8 +293,11 @@ class TicketShow extends Component
|
||||
*/
|
||||
public function refreshTicketData(): void
|
||||
{
|
||||
if (! $this->refreshOrRedirectAway()) {
|
||||
return;
|
||||
}
|
||||
|
||||
unset($this->publicMessages, $this->internalMessages);
|
||||
$this->ticket->refresh();
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
@@ -266,24 +330,22 @@ class TicketShow extends Component
|
||||
|
||||
$subcategory = $this->ticket->subcategory;
|
||||
$query = trim(($subcategory?->category?->name ?? '').' '.($subcategory?->name ?? ''));
|
||||
$tagQuery = trim($subcategory?->name ?? '');
|
||||
|
||||
return app(BookStackClient::class)->search($query, 5, BookStackClient::CONTEXT_TICKET_VIEW);
|
||||
return app(BookStackClient::class)->search($query, 5, BookStackClient::CONTEXT_TICKET_VIEW, $tagQuery);
|
||||
}
|
||||
|
||||
/**
|
||||
* A non-admin operator can only reassign a ticket to one of their own
|
||||
* teams (mirrors the visibility scoping in Operator\Queue).
|
||||
* Every team, regardless of the viewing operator's own membership —
|
||||
* unlike ticket *visibility* (Operator\Queue, scoped to an operator's
|
||||
* own teams), reassignment isn't restricted: an operator working a
|
||||
* ticket needs to be able to route it to whichever team actually owns
|
||||
* the problem, even one they don't personally belong to.
|
||||
*/
|
||||
#[Computed]
|
||||
public function teams()
|
||||
{
|
||||
$query = Team::query();
|
||||
|
||||
if (! Auth::user()->isAdmin()) {
|
||||
$query->whereHas('members', fn ($q) => $q->where('users.id', Auth::id()));
|
||||
}
|
||||
|
||||
return $query->get();
|
||||
return Team::query()->get();
|
||||
}
|
||||
|
||||
#[Computed]
|
||||
@@ -357,7 +419,12 @@ class TicketShow extends Component
|
||||
public function setTeam(string $id): void
|
||||
{
|
||||
app(TicketService::class)->setTeam($this->ticket, $id ? Team::query()->find($id) : null);
|
||||
$this->ticket->refresh();
|
||||
|
||||
// Reassigning to a team the operator doesn't belong to can move the
|
||||
// ticket out of their own visible scope (see Ticket::isVisibleToOperator())
|
||||
// — send them back to their queue rather than leaving them on a
|
||||
// ticket they can no longer legitimately keep viewing.
|
||||
$this->refreshOrRedirectAway();
|
||||
}
|
||||
|
||||
// -------- reporter --------
|
||||
|
||||
Reference in New Issue
Block a user