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:
@@ -23,6 +23,31 @@ class BookStackClient
|
||||
self::CONTEXT_TICKET_VIEW => 'bookstack_allowed_shelf_ids_ticket_view',
|
||||
];
|
||||
|
||||
/**
|
||||
* Content types selectable via the admin's "Przeszukuj" checkboxes.
|
||||
* Deliberately excludes 'bookshelf' — shelves are only ever a filter
|
||||
* (dozwolone półki), never a suggestion result in their own right.
|
||||
*/
|
||||
public const SEARCH_TYPES = ['book', 'page', 'chapter'];
|
||||
|
||||
/**
|
||||
* How the query text is matched, via the admin's "Szukaj po" option —
|
||||
* 'name' restricts to the title ({in_name:...}), 'tags' matches a tag
|
||||
* whose name equals the query (expected to hold the helpdesk
|
||||
* category/subcategory name, e.g. a "Drukarki" tag on the relevant
|
||||
* BookStack pages), 'both' runs both and merges the results (BookStack's
|
||||
* query syntax ANDs filters together, so there's no single-query way to
|
||||
* express "name OR tag").
|
||||
*/
|
||||
public const SEARCH_BY_OPTIONS = ['name', 'tags', 'both'];
|
||||
|
||||
/**
|
||||
* Content types the bulk-tagging command operates over — same set as
|
||||
* SEARCH_TYPES (book/page/chapter, no bookshelf), named separately since
|
||||
* the two consts serve different features that happen to share a domain.
|
||||
*/
|
||||
public const CONTENT_TYPES = self::SEARCH_TYPES;
|
||||
|
||||
public function enabled(): bool
|
||||
{
|
||||
return Settings::bool('bookstack_enabled')
|
||||
@@ -39,44 +64,56 @@ class BookStackClient
|
||||
* before any content is ever suggested, independently per context).
|
||||
* Cached briefly since the same category/subcategory query repeats
|
||||
* across every ticket created/viewed with that combination. Respects the
|
||||
* admin-configured bookstack_search_types setting ('both'|'page'|'book')
|
||||
* via BookStack's own `{type:x}` query syntax. The cache key folds in the
|
||||
* allowed-shelf list so changing it in Admin > Konfiguracja is reflected
|
||||
* immediately, instead of possibly serving a pre-change result for up to
|
||||
* 10 minutes.
|
||||
* admin-configured bookstack_search_types (subset of SEARCH_TYPES, via
|
||||
* BookStack's `{type:a|b}` syntax) and bookstack_search_by ('name'|
|
||||
* 'tags'|'both', via `{in_name:...}`/`[...]`) settings. The cache key
|
||||
* folds in the allowed-shelf list so changing it in Admin > Konfiguracja
|
||||
* is reflected immediately, instead of possibly serving a pre-change
|
||||
* result for up to 10 minutes.
|
||||
*
|
||||
* $tagQuery is the text matched by the 'tags' variant, separate from
|
||||
* $query (matched by the 'name' variant) — callers pass the bare
|
||||
* subcategory name here (what bookstack:tag-content actually writes as
|
||||
* a tag), while $query stays the fuller "Category Subcategory" text
|
||||
* that's more useful for a plain title/body search. Defaults to $query
|
||||
* so existing call sites that don't pass it keep working.
|
||||
*
|
||||
* @return array<int, array{name: string, url: ?string, type: string, book: ?string, shelf: ?string}>
|
||||
*/
|
||||
public function search(string $query, int $limit = 5, string $context = self::CONTEXT_CREATION): array
|
||||
public function search(string $query, int $limit = 5, string $context = self::CONTEXT_CREATION, ?string $tagQuery = null): array
|
||||
{
|
||||
$query = trim($query);
|
||||
$tagQuery = trim($tagQuery ?? $query);
|
||||
$allowedShelfIds = $this->allowedShelfIds($context);
|
||||
|
||||
if (! $this->enabled() || $query === '' || ! $allowedShelfIds) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$typeFilter = Settings::get('bookstack_search_types', 'both');
|
||||
$searchBy = $this->searchBy();
|
||||
$bookstackQueries = $searchBy === 'both'
|
||||
? [$this->buildQuery($query, 'name'), $this->buildQuery($tagQuery, 'tags')]
|
||||
: [$this->buildQuery($searchBy === 'tags' ? $tagQuery : $query, $searchBy)];
|
||||
|
||||
if (in_array($typeFilter, ['page', 'book'], true)) {
|
||||
$query .= " {type:{$typeFilter}}";
|
||||
}
|
||||
$cacheKey = 'bookstack:search:'.md5(implode('||', $bookstackQueries).'|'.$limit.'|'.implode(',', $allowedShelfIds));
|
||||
|
||||
$cacheKey = 'bookstack:search:'.md5($query.'|'.$limit.'|'.implode(',', $allowedShelfIds));
|
||||
|
||||
return Cache::remember($cacheKey, now()->addMinutes(10), function () use ($query, $limit, $allowedShelfIds) {
|
||||
return Cache::remember($cacheKey, now()->addMinutes(10), function () use ($bookstackQueries, $limit, $allowedShelfIds) {
|
||||
try {
|
||||
$response = $this->client()->get('/api/search', ['query' => $query, 'count' => $limit]);
|
||||
|
||||
if (! $response->successful()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$shelfMap = $this->shelfBookMap();
|
||||
$allowedBookIds = $this->bookIdsForShelves($shelfMap, $allowedShelfIds);
|
||||
$bookShelfNames = $this->bookShelfNames($shelfMap);
|
||||
|
||||
return collect($response->json('data', []))
|
||||
$items = collect();
|
||||
|
||||
foreach ($bookstackQueries as $bookstackQuery) {
|
||||
$response = $this->client()->get('/api/search', ['query' => $bookstackQuery, 'count' => $limit]);
|
||||
|
||||
if ($response->successful()) {
|
||||
$items = $items->concat($response->json('data', []));
|
||||
}
|
||||
}
|
||||
|
||||
return $items
|
||||
->filter(function (array $item) use ($allowedShelfIds, $allowedBookIds) {
|
||||
$type = $item['type'] ?? null;
|
||||
|
||||
@@ -103,6 +140,8 @@ class BookStackClient
|
||||
];
|
||||
})
|
||||
->filter(fn (array $item) => $item['name'] !== '')
|
||||
->unique(fn (array $item) => $item['url'] ?? $item['name'])
|
||||
->take($limit)
|
||||
->values()
|
||||
->all();
|
||||
} catch (\Throwable) {
|
||||
@@ -111,6 +150,68 @@ class BookStackClient
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds one BookStack search-syntax query string for $query, restricted
|
||||
* to $by ('name' -> `{in_name:...}`, 'tags' -> `[...]`) and to the
|
||||
* configured content types (`{type:a|b}`, omitted if all types are
|
||||
* allowed since that's equivalent to no filter).
|
||||
*/
|
||||
protected function buildQuery(string $query, string $by): string
|
||||
{
|
||||
$parts = [$by === 'tags' ? "[{$query}]" : "{in_name:{$query}}"];
|
||||
|
||||
$types = $this->searchTypes();
|
||||
|
||||
if (array_diff(self::SEARCH_TYPES, $types)) {
|
||||
$parts[] = '{type:'.implode('|', $types).'}';
|
||||
}
|
||||
|
||||
return implode(' ', $parts);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string[] non-empty subset of SEARCH_TYPES
|
||||
*/
|
||||
protected function searchTypes(): array
|
||||
{
|
||||
return self::normalizeSearchTypes(Settings::get('bookstack_search_types', ''));
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes bookstack_search_types storage into a non-empty subset of
|
||||
* SEARCH_TYPES — shared with Admin\Panel so the checkbox UI and the
|
||||
* actual search agree on the same format. Also understands the legacy
|
||||
* single-value 'both'/'page'/'book' storage from before the setting
|
||||
* became a checkbox list, so existing configuration keeps working.
|
||||
*
|
||||
* @param string[]|string $raw
|
||||
* @return string[]
|
||||
*/
|
||||
public static function normalizeSearchTypes(array|string $raw): array
|
||||
{
|
||||
$legacy = ['both' => self::SEARCH_TYPES, 'page' => ['page'], 'book' => ['book']];
|
||||
|
||||
if (is_string($raw) && isset($legacy[$raw])) {
|
||||
return $legacy[$raw];
|
||||
}
|
||||
|
||||
$types = collect(is_array($raw) ? $raw : explode(',', $raw))
|
||||
->map(fn ($v) => trim((string) $v))
|
||||
->filter(fn ($v) => in_array($v, self::SEARCH_TYPES, true))
|
||||
->unique()
|
||||
->values()
|
||||
->all();
|
||||
|
||||
return $types ?: self::SEARCH_TYPES;
|
||||
}
|
||||
|
||||
protected function searchBy(): string
|
||||
{
|
||||
$raw = Settings::get('bookstack_search_by', 'both');
|
||||
|
||||
return in_array($raw, self::SEARCH_BY_OPTIONS, true) ? $raw : 'both';
|
||||
}
|
||||
|
||||
/**
|
||||
* Drops the cached shelf list and shelf>book membership map — used by
|
||||
* the admin's "Odśwież listę półek" button so a shelf renamed/added/
|
||||
@@ -155,6 +256,103 @@ class BookStackClient
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Every book/chapter/page of $type across the whole BookStack instance —
|
||||
* NOT filtered by the allowed-shelf settings, unlike search(). Those only
|
||||
* gate which suggestions are ever shown to a client/operator; the bulk
|
||||
* tagger is meant to cover every piece of content regardless. Paginates
|
||||
* through BookStack's count/offset list endpoints (count capped at 500,
|
||||
* the API's own per-page maximum). Uncached — this is a one-shot batch
|
||||
* read, not a repeated request-path lookup.
|
||||
*
|
||||
* @return array<int, array{id: int, name: string}>
|
||||
*/
|
||||
public function listAll(string $type): array
|
||||
{
|
||||
if (! $this->enabled() || ! in_array($type, self::CONTENT_TYPES, true)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$items = [];
|
||||
$offset = 0;
|
||||
|
||||
try {
|
||||
do {
|
||||
$response = $this->client()->get("/api/{$type}s", ['count' => 500, 'offset' => $offset]);
|
||||
|
||||
if (! $response->successful()) {
|
||||
break;
|
||||
}
|
||||
|
||||
$page = $response->json('data', []);
|
||||
$items = [...$items, ...$page];
|
||||
$offset += 500;
|
||||
$total = $response->json('total', 0);
|
||||
} while (count($page) > 0 && count($items) < $total);
|
||||
} catch (\Throwable) {
|
||||
return $items;
|
||||
}
|
||||
|
||||
return $items;
|
||||
}
|
||||
|
||||
/**
|
||||
* Full detail for a single book/chapter/page — its current tags (needed
|
||||
* to merge rather than clobber when the tagger writes new ones) and the
|
||||
* text used to classify it (a page's markdown source, or a book/
|
||||
* chapter's description). Null if $type is invalid or the item can't be
|
||||
* fetched.
|
||||
*
|
||||
* @return array{id: int, name: string, tags: array<int, array{name: string, value: string}>, content: string}|null
|
||||
*/
|
||||
public function detail(string $type, int $id): ?array
|
||||
{
|
||||
if (! $this->enabled() || ! in_array($type, self::CONTENT_TYPES, true)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
$response = $this->client()->get("/api/{$type}s/{$id}");
|
||||
|
||||
if (! $response->successful()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$data = $response->json();
|
||||
|
||||
return [
|
||||
'id' => $data['id'],
|
||||
'name' => $data['name'] ?? '',
|
||||
'tags' => $data['tags'] ?? [],
|
||||
'content' => $data['markdown'] ?? $data['description'] ?? '',
|
||||
];
|
||||
} catch (\Throwable) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Overwrites just the tags field on a book/chapter/page — BookStack
|
||||
* treats every field on its update endpoints as optional, so this never
|
||||
* touches the item's name/content/other attributes. Callers are
|
||||
* responsible for merging in any tags they want to keep (this replaces
|
||||
* the whole array, it doesn't append).
|
||||
*
|
||||
* @param array<int, array{name: string, value: string}> $tags
|
||||
*/
|
||||
public function updateTags(string $type, int $id, array $tags): bool
|
||||
{
|
||||
if (! $this->enabled() || ! in_array($type, self::CONTENT_TYPES, true)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
return $this->client()->put("/api/{$type}s/{$id}", ['tags' => $tags])->successful();
|
||||
} catch (\Throwable) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return int[]
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user