- Snipe-IT asset inventory integration (Admin > Integracje), optional and off
  by default: connect by API address + personal token (+ SSL-verification
  bypass). Three independent toggles: client can pick which of their own
  Snipe-IT assets a ticket concerns (scoped to admin-selected subcategories,
  empty = never shows), operator sees the requester's assets in a ticket-view
  sidebar, operator can search the whole inventory from that same sidebar (not
  a separate page) for shared equipment. Assets shown as "numer środka - numer
  seryjny - producent model" + category; a linked asset's live status is
  fetched fresh on the ticket page, and unlinking stays available to an
  operator even with both view/search toggles off.
- AI summary: a "Wygeneruj teraz" button for an immediate on-demand refresh,
  plus a new admin toggle to regenerate right after every new reply/note
  instead of only on the next scheduled sweep. The transcript sent to the
  model now also includes the ticket's own opening body, fixing summaries
  missing the original request on long threads.
- Fixed: the status dropdown in the operator ticket view could keep showing
  the pre-change status after a status-changing quick action until the next
  page load (Livewire/Alpine-morph quirk for wire:change-bound selects).
- Docs: README/ARCHITECTURE/CHANGELOG/install/wiki updated for all of the
  above, including correcting the AI-summary refresh description left over
  from the 1.2.1 release notes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-27 21:11:21 +02:00
parent 1df697afce
commit 7a8cf2037c
32 changed files with 1735 additions and 31 deletions

View File

@@ -470,9 +470,10 @@ layered on top rather than baked into the client itself.
## BookStack integration
`App\Services\BookStackClient` is one of two outbound HTTP clients in the
codebase (Laravel's `Http` facade), alongside `AiClient` above — everything
else here only ever receives requests. It's entirely `Settings`-driven, no
`App\Services\BookStackClient` is one of three outbound HTTP clients in the
codebase (Laravel's `Http` facade), alongside `AiClient` above and
`SnipeItClient` below — everything else here only ever receives requests.
It's entirely `Settings`-driven, no
`.env`/`config()` involved: `bookstack_enabled`, `bookstack_base_url`,
`bookstack_token_id`/`bookstack_token_secret` (encrypted, same as the
LDAP/SMTP passwords), `bookstack_verify_ssl`, and **two independent**
@@ -527,6 +528,70 @@ unless `--force`/the "wszystko ponownie" button is used — and new tags are
merged into an item's existing tags (`updateTags()` PUTs the whole array;
BookStack has no "append a tag" endpoint), never overwriting unrelated ones.
## Snipe-IT asset inventory integration
`App\Services\SnipeItClient` talks to a Snipe-IT instance's REST API
(`/api/v1/...`, bearer token auth), entirely `Settings`-driven like
`BookStackClient`: `snipeit_enabled`, `snipeit_base_url`,
`snipeit_api_token` (encrypted), `snipeit_verify_ssl`. Every call is wrapped
in `try/catch(\Throwable)` returning `[]`/`null` on failure, same
safe-default convention as `AiClient`/`BookStackClient`. Three independently
toggleable settings gate what a client/operator can actually do with it —
none of them affect `SnipeItClient` itself, only which Livewire methods are
willing to call it:
- `snipeit_client_can_select_asset` (+ `snipeit_client_asset_subcategory_ids`,
a comma-separated allow-list) — gates `Client\NewTicket`'s asset picker.
Mirrors BookStack's shelf allow-lists: an **empty** subcategory list means
the picker never shows for any subcategory, not "every subcategory" —
`NewTicket::snipeitAssets()` checks both the toggle and that the currently
selected `subcategoryId` is in the list before calling
`assetsForEmail()`. `selectCategory()`/`selectSubcategory()` reset any
already-picked asset, so switching to an out-of-scope subcategory can't
silently carry a stale selection through to `submit()`.
- `snipeit_operator_view_requester_assets` — gates the same
`assetsForEmail()` lookup (by the ticket's own `email`, not the viewing
operator's) in `Operator\TicketShow`'s sidebar.
- `snipeit_operator_search_inventory` — gates `searchAssets()`, a free-text
`/hardware?search=` lookup across the *whole* inventory, for linking
equipment the requester doesn't personally own (e.g. a shared printer).
Rendered inline in the same sidebar card as the requester-assets list, not
a separate route/page.
`Operator\TicketShow::linkSnipeitAsset(int $id)` deliberately does **not**
fall back to a direct `SnipeItClient::asset($id)` lookup by id — it only
accepts an id present in `snipeitRequesterAssets`/`snipeitSearchResults`,
and each of those is itself empty unless its own setting above is on. This
means an operator can't link an arbitrary asset through a source the admin
has switched off for them, even by tampering with the Livewire request
payload. `unlinkSnipeitAsset()` has no such gate — clearing an existing link
is a correction, not a new way to browse Snipe-IT, so it stays available
even with both toggles off.
`SnipeItClient::assetsForEmail()` has to resolve an e-mail to a Snipe-IT user
first (`GET /users?search=`, no "assets by e-mail" endpoint exists), then
lists what's checked out to them (`GET /users/{id}/assets`) — cached 5
minutes per e-mail. `normalizeAsset()` is the single place that turns a raw
Snipe-IT hardware row into the shape every caller/view uses (`id`, `label`,
`serial`, `manufacturer`, `model`, `category`, `status`, `url`); `label`
joins whichever of asset tag / serial / "manufacturer model" are actually
present with `" - "`, falling back to `Zasób #{id}` if all three are blank —
Snipe-IT doesn't guarantee any of them are filled in. The `x-snipeit-assets`
Blade component renders that shape everywhere an asset list shows up
(client picker, requester sidebar, search results), with a `card` prop that
skips its own wrapping `<div class="card">` when embedded inside a
caller-provided one (the inventory-search box + its results share one card).
A linked ticket only stores `tickets.snipeit_asset_id` + a cached
`snipeit_asset_name` label (`TicketService::setSnipeitAsset()`, which also
writes a ticket-history line) — no other Snipe-IT fields are persisted.
Anywhere a linked asset's live detail is shown (the "Powiązany sprzęt" card),
it's re-fetched fresh via `SnipeItClient::asset($id)` rather than trusted
from the cache, so a status/reassignment change made directly in Snipe-IT is
reflected immediately; the cached label is only ever the fallback shown when
that live fetch fails (instance unreachable, or the asset was deleted
there).
## AI ticket triage & summary
Two independent services, both consuming `AiClient` above, both run from a
@@ -565,17 +630,20 @@ live customer submitting a ticket:
toggle), cached on `tickets.ai_summary`/`ai_suggested_action`/
`ai_summary_generated_at` and shown only in the operator ticket view (a
"Podsumowanie AI" sidebar card, lazy-loaded via `wire:init` like the
BookStack suggestions card next to it — never live-called from the ticket
page itself, only ever displaying whatever the scheduled command last
computed). Regenerates whenever a ticket's latest message postdates its
last summary — deliberately compared against `ticket_messages.created_at`,
not `tickets.updated_at` (which also changes on unrelated actions like a
BookStack suggestions card next to it). `run()` (the scheduled sweep)
regenerates whenever a ticket's latest message postdates its last summary
— deliberately compared against `ticket_messages.created_at`, not
`tickets.updated_at` (which also changes on unrelated actions like a
status/priority edit, which would otherwise trigger spurious
re-summarization on every tick for an active ticket). Unlike the triage
service, a malformed AI response here leaves the previous summary
untouched rather than stamping "done" — the ticket stays in the "stale"
set and gets retried next run, since this feature is meant to keep
refreshing indefinitely, not run once. The system prompt is
re-summarization on every tick for an active ticket). `buildTranscript()`
includes the ticket's own `body` (the opening description, outside
`ticket_messages`) ahead of the message transcript — needed because that
row would otherwise fall outside `TRANSCRIPT_MESSAGE_LIMIT` (30) on any
thread longer than that, silently dropping the original request from the
prompt. Unlike the triage service, a malformed AI response here leaves the
previous summary untouched rather than stamping "done" — the ticket stays
in the "stale" set and gets retried next run, since this feature is meant
to keep refreshing indefinitely, not run once. The system prompt is
admin-editable (`ai_summary_prompt` setting, plain textarea with a
"Resetuj" button restoring `Settings::default('ai_summary_prompt')`
same pattern as the e-mail footer editor) and asks the model for a small
@@ -583,6 +651,22 @@ live customer submitting a ticket:
the same defensive regex-extract-then-decode approach used throughout
these AI services.
Besides `run()`'s scheduled sweep, two paths call `generateFor(Ticket
$ticket): bool` directly, bypassing the staleness check entirely:
`Operator\TicketShow::regenerateAiSummary()` (the sidebar's "Wygeneruj
teraz" button, a synchronous Livewire call — its `wire:loading` state covers
the wait, no need to dispatch anything in the background) and a
`TicketMessagePosted` listener registered in
`AppServiceProvider::regenerateAiSummaryOnNewMessage()`, active only when
both `ai_summary_enabled` and `ai_summary_regenerate_on_message` (off by
default) are on. That listener dispatches `App\Jobs\GenerateTicketAiSummaryJob`
via `::dispatchAfterResponse()` rather than the normal queue — deliberately
**not** `ShouldQueue`, since this deployment's queue worker is optional
infrastructure (see install.md) and anything pushed onto the `jobs` table
has no guarantee of ever being picked up; `dispatchAfterResponse()` instead
runs the job in-process right after the triggering HTTP/console response is
sent, needing no worker at all.
Its own interval (`ai:run-ticket-automation`) is admin-configurable the same
way the other 3 scheduled commands are — see "Configurable scheduled-command
intervals" above for the mechanism and a boot-time trap worth knowing about