- 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>
96 lines
2.9 KiB
PHP
96 lines
2.9 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Support\Settings;
|
|
use Illuminate\Support\Facades\Http;
|
|
|
|
/**
|
|
* Generic OpenAI-compatible chat-completions client — works against Groq,
|
|
* OpenAI itself, or a self-hosted Ollama instance's OpenAI-compat endpoint,
|
|
* whichever the admin points ai_base_url at. Not BookStack-specific; the
|
|
* BookStack content tagger is just the first consumer.
|
|
*/
|
|
class AiClient
|
|
{
|
|
public function enabled(): bool
|
|
{
|
|
// Deliberately no api-key requirement here — a self-hosted Ollama
|
|
// instance typically has no auth at all.
|
|
return Settings::bool('ai_enabled')
|
|
&& Settings::get('ai_base_url')
|
|
&& Settings::get('ai_model');
|
|
}
|
|
|
|
/**
|
|
* @param array<int, array{role: string, content: string}> $messages
|
|
* @return string|null the assistant message content, or null on any failure
|
|
*/
|
|
public function chat(array $messages, array $options = []): ?string
|
|
{
|
|
if (! $this->enabled()) {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
$response = $this->client()->post('/chat/completions', [
|
|
'model' => Settings::get('ai_model'),
|
|
'messages' => $messages,
|
|
...$options,
|
|
]);
|
|
|
|
if (! $response->successful()) {
|
|
return null;
|
|
}
|
|
|
|
return $response->json('choices.0.message.content');
|
|
} catch (\Throwable) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Tests unsaved admin-form values directly, rather than whatever's
|
|
* currently stored — mirrors BookStackClient::testConnection().
|
|
*
|
|
* @return array{ok: bool, message: ?string}
|
|
*/
|
|
public function testConnection(string $baseUrl, string $apiKey, string $model, bool $verifySsl = true): array
|
|
{
|
|
try {
|
|
$http = Http::withOptions(['verify' => $verifySsl])
|
|
->timeout(10)
|
|
->baseUrl(rtrim($baseUrl, '/'));
|
|
|
|
if ($apiKey !== '') {
|
|
$http = $http->withToken($apiKey);
|
|
}
|
|
|
|
$response = $http->post('/chat/completions', [
|
|
'model' => $model,
|
|
'messages' => [['role' => 'user', 'content' => 'ping']],
|
|
'max_tokens' => 1,
|
|
]);
|
|
|
|
if ($response->successful()) {
|
|
return ['ok' => true, 'message' => null];
|
|
}
|
|
|
|
return ['ok' => false, 'message' => $response->json('error.message') ?? ('HTTP '.$response->status())];
|
|
} catch (\Throwable $e) {
|
|
return ['ok' => false, 'message' => $e->getMessage()];
|
|
}
|
|
}
|
|
|
|
protected function client()
|
|
{
|
|
$http = Http::withOptions(['verify' => Settings::bool('ai_verify_ssl')])
|
|
->timeout(60)
|
|
->baseUrl(rtrim(Settings::get('ai_base_url'), '/'));
|
|
|
|
$apiKey = Settings::get('ai_api_key');
|
|
|
|
return $apiKey ? $http->withToken($apiKey) : $http;
|
|
}
|
|
}
|