mirror of
https://github.com/InvoiceShelf/InvoiceShelf.git
synced 2026-07-16 22:05:20 +00:00
feat(ai): Phase 1 — provider configuration, installer step, admin + company settings
Foundation for the AI chatbot + text generation feature. Phase 1 is infrastructure only: driver plumbing, configuration storage with encrypted API keys, global vs per-company resolution, admin + company UI pages, and an optional installer wizard step. The chat assistant and text-generation WYSIWYG integration come in later phases.
**Driver plumbing (app/Support/Ai/)** — AiDriver abstract, AiDriverFactory, AiException, AiChatResponse DTO, OpenRouterDriver concrete implementation. OpenRouter is the OpenAI-compatible aggregator that unlocks hundreds of models behind one API key and one request shape — ideal as the default v1 driver. Drivers are extensible the same way exchange rate drivers are: the module Registry's generic registerDriver('ai', ...) machinery plus a typed Registry::registerAiDriver() convenience wrapper (shipped in the upstream invoiceshelf/modules package in a paired commit).
**AiConfigurationService** — mirrors MailConfigurationService shape but with one deliberate deviation: API keys are encrypted at the service layer via Crypt::encryptString before persistence. OpenRouter bearer tokens have much bigger blast radius than SMTP passwords. Same settings / company_settings tables, same global-vs-per-company pattern, same use_custom_ai_config override toggle. Resolution order: global ai_enabled must be YES, then the company either overrides via use_custom_ai_config=YES (and can opt out with ai_enabled=NO inside the override) or inherits the global config.
**Controllers** — Admin/Settings/AiConfigurationController (global CRUD + driver list + test connection), Company/Settings/CompanyAiConfigurationController (per-company override + test), Setup/AiConfigurationController (installer wizard step, skippable with explicit ai_enabled=NO). API key is always masked as '********' in GET responses — the frontend submits the placeholder back on save and the backend preserves the stored value.
**Installer wizard** — new optional step 7 'AI' between Mail and Account. Default OFF with a Skip button. MailView.vue now routes to installation.ai instead of installation.account; installation.ai then routes to installation.account. Step order comment updated in routes.ts.
**Admin + Company settings pages** — AdminAiConfigView (no toggle, always global) and AiConfigView (with use_custom_ai_config BaseSwitchSection that auto-saves OFF). Both share AiConfigurationForm which renders the driver selector, API key input with show/hide, driver-specific config_fields (base_url for OpenRouter), and per-role enable toggles with free-text model inputs backed by a datalist of suggested models from driver metadata.
**Bootstrap endpoint** — adds an ai block to the response: { enabled, chat_enabled, text_generation_enabled }. All three are booleans resolved through AiConfigurationService::resolveForCompany(). Never leaks the API key. Frontend feature flags read from bootstrapData.ai to decide whether to show Phase 2/3 UI.
**Bouncer ability** 'manage ai config' added to SettingsPolicy, gated on isSuperAdmin() (same pattern as manage email config, manage pdf config).
**Tests** (22 new) — Unit: AiDriverFactory resolves built-in + Registry-contributed drivers, rejects unknown, merges availableDrivers. AiConfigurationService: encryption round-trip, resolution order (3 cases: global off, inherit global, override with company key, override with opt-out), makeDriver null/instance cases, listDrivers metadata. Feature: admin save + read with api key masking, preserve-on-placeholder behavior, company toggle ON/OFF semantics, bootstrap ai flags reflect resolution, company opt-out path.
372 tests pass (was 350, +22). Pint clean. npm run build clean. Phase 2 (chat assistant + tool calling) and Phase 3 (WYSIWYG text generation popup) are separate follow-up commits — this one is the foundation only.
This commit is contained in:
@@ -0,0 +1,132 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Admin\Settings;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\AiConfigurationService;
|
||||
use App\Support\Ai\AiDriverFactory;
|
||||
use App\Support\Ai\AiException;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class AiConfigurationController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly AiConfigurationService $aiConfigurationService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Get the global AI configuration with decrypted API key masked for response.
|
||||
*
|
||||
* @throws AuthorizationException
|
||||
*/
|
||||
public function getConfig(): JsonResponse
|
||||
{
|
||||
$this->authorize('manage ai config');
|
||||
|
||||
$config = $this->aiConfigurationService->getGlobalConfig();
|
||||
|
||||
return response()->json($this->maskApiKey($config));
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist the global AI configuration.
|
||||
*
|
||||
* If the submitted api_key is the masked placeholder, we retain the stored value —
|
||||
* otherwise the user would have to re-enter the key every time they save the form.
|
||||
*
|
||||
* @throws AuthorizationException
|
||||
* @throws ValidationException
|
||||
*/
|
||||
public function saveConfig(Request $request): JsonResponse
|
||||
{
|
||||
$this->authorize('manage ai config');
|
||||
|
||||
$validated = $this->validate(
|
||||
$request,
|
||||
$this->aiConfigurationService->validationRules(allowDisabledCustomConfig: false),
|
||||
);
|
||||
|
||||
// Preserve existing key when client submits the masked placeholder
|
||||
if (($validated['ai_api_key'] ?? null) === '********' || ($validated['ai_api_key'] ?? null) === '') {
|
||||
$existing = $this->aiConfigurationService->getGlobalConfig();
|
||||
$validated['ai_api_key'] = $existing['ai_api_key'] ?? '';
|
||||
}
|
||||
|
||||
$this->aiConfigurationService->saveGlobalConfig($validated);
|
||||
|
||||
return response()->json(['success' => 'ai_variables_save_successfully']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the AI driver list for the admin UI — same shape as the exchange rate endpoint.
|
||||
*
|
||||
* @throws AuthorizationException
|
||||
*/
|
||||
public function getDrivers(): JsonResponse
|
||||
{
|
||||
$this->authorize('manage ai config');
|
||||
|
||||
return response()->json([
|
||||
'ai_drivers' => $this->aiConfigurationService->listDrivers(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test the currently configured AI provider by instantiating its driver and calling validateConnection().
|
||||
*
|
||||
* @throws AuthorizationException
|
||||
*/
|
||||
public function testConnection(Request $request): JsonResponse
|
||||
{
|
||||
$this->authorize('manage ai config');
|
||||
|
||||
$this->validate($request, [
|
||||
'ai_driver' => 'required|string',
|
||||
'ai_api_key' => 'nullable|string',
|
||||
'ai_base_url' => 'nullable|string|url',
|
||||
]);
|
||||
|
||||
// If the masked placeholder was submitted, fall back to the stored key
|
||||
$apiKey = $request->input('ai_api_key');
|
||||
if ($apiKey === '********' || $apiKey === null || $apiKey === '') {
|
||||
$existing = $this->aiConfigurationService->getGlobalConfig();
|
||||
$apiKey = $existing['ai_api_key'] ?? '';
|
||||
}
|
||||
|
||||
if ($apiKey === '') {
|
||||
return response()->json(['error' => 'missing_api_key'], 422);
|
||||
}
|
||||
|
||||
try {
|
||||
$driver = AiDriverFactory::make(
|
||||
$request->input('ai_driver'),
|
||||
$apiKey,
|
||||
['base_url' => $request->input('ai_base_url')],
|
||||
);
|
||||
|
||||
$result = $driver->validateConnection();
|
||||
} catch (AiException $e) {
|
||||
return response()->json(['error' => $e->errorKey, 'message' => $e->getMessage()], 422);
|
||||
}
|
||||
|
||||
return response()->json(['success' => true, 'details' => $result]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the stored API key with a masked placeholder so it's never returned to the client.
|
||||
*
|
||||
* @param array<string, mixed> $config
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function maskApiKey(array $config): array
|
||||
{
|
||||
if (! empty($config['ai_api_key'])) {
|
||||
$config['ai_api_key'] = '********';
|
||||
}
|
||||
|
||||
return $config;
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ use App\Models\CompanySetting;
|
||||
use App\Models\Currency;
|
||||
use App\Models\Module;
|
||||
use App\Models\Setting;
|
||||
use App\Services\AiConfigurationService;
|
||||
use App\Traits\GeneratesMenuTrait;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
@@ -121,6 +122,8 @@ class BootstrapController extends Controller
|
||||
|
||||
BouncerFacade::refreshFor($current_user);
|
||||
|
||||
$aiResolved = app(AiConfigurationService::class)->resolveForCompany($current_company->id);
|
||||
|
||||
return response()->json([
|
||||
'current_user' => new UserResource($current_user),
|
||||
'current_user_settings' => $current_user_settings,
|
||||
@@ -131,6 +134,11 @@ class BootstrapController extends Controller
|
||||
'current_company_currency' => $current_company_currency,
|
||||
'config' => config('invoiceshelf'),
|
||||
'global_settings' => $global_settings,
|
||||
'ai' => [
|
||||
'enabled' => $aiResolved !== null,
|
||||
'chat_enabled' => (bool) ($aiResolved['chat_enabled'] ?? false),
|
||||
'text_generation_enabled' => (bool) ($aiResolved['text_generation_enabled'] ?? false),
|
||||
],
|
||||
'main_menu' => $main_menu,
|
||||
'setting_menu' => $setting_menu,
|
||||
'modules' => Module::where('enabled', true)->pluck('name'),
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Company\Settings;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Services\AiConfigurationService;
|
||||
use App\Support\Ai\AiDriverFactory;
|
||||
use App\Support\Ai\AiException;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class CompanyAiConfigurationController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly AiConfigurationService $aiConfigurationService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Get the per-company AI config with decrypted API key masked for response.
|
||||
*/
|
||||
public function getConfig(Request $request): JsonResponse
|
||||
{
|
||||
$config = $this->aiConfigurationService->getCompanyConfig($request->header('company'));
|
||||
|
||||
return response()->json($this->maskApiKey($config));
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist the per-company AI config.
|
||||
*
|
||||
* Respects the `use_custom_ai_config` toggle — when OFF, only the toggle is written
|
||||
* and the driver fields are discarded (same pattern as the mail company override).
|
||||
*
|
||||
* @throws ValidationException
|
||||
*/
|
||||
public function saveConfig(Request $request): JsonResponse
|
||||
{
|
||||
$this->authorize('owner only');
|
||||
|
||||
$validated = $this->validate(
|
||||
$request,
|
||||
$this->aiConfigurationService->validationRules(allowDisabledCustomConfig: true),
|
||||
);
|
||||
|
||||
// Preserve existing key when masked placeholder is submitted
|
||||
if (($validated['ai_api_key'] ?? null) === '********' || ($validated['ai_api_key'] ?? null) === '') {
|
||||
$existing = $this->aiConfigurationService->getCompanyConfig($request->header('company'));
|
||||
$validated['ai_api_key'] = $existing['ai_api_key'] ?? '';
|
||||
}
|
||||
|
||||
$this->aiConfigurationService->saveCompanyConfig(
|
||||
$request->header('company'),
|
||||
$validated,
|
||||
);
|
||||
|
||||
return response()->json(['success' => true]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test a company-level AI configuration without persisting it.
|
||||
*
|
||||
* @throws ValidationException
|
||||
*/
|
||||
public function testConnection(Request $request): JsonResponse
|
||||
{
|
||||
$this->authorize('owner only');
|
||||
|
||||
$this->validate($request, [
|
||||
'ai_driver' => 'required|string',
|
||||
'ai_api_key' => 'nullable|string',
|
||||
'ai_base_url' => 'nullable|string|url',
|
||||
]);
|
||||
|
||||
$apiKey = $request->input('ai_api_key');
|
||||
if ($apiKey === '********' || $apiKey === null || $apiKey === '') {
|
||||
$existing = $this->aiConfigurationService->getCompanyConfig($request->header('company'));
|
||||
$apiKey = $existing['ai_api_key'] ?? '';
|
||||
}
|
||||
|
||||
if ($apiKey === '') {
|
||||
return response()->json(['error' => 'missing_api_key'], 422);
|
||||
}
|
||||
|
||||
try {
|
||||
$driver = AiDriverFactory::make(
|
||||
$request->input('ai_driver'),
|
||||
$apiKey,
|
||||
['base_url' => $request->input('ai_base_url')],
|
||||
);
|
||||
|
||||
$result = $driver->validateConnection();
|
||||
} catch (AiException $e) {
|
||||
return response()->json(['error' => $e->errorKey, 'message' => $e->getMessage()], 422);
|
||||
}
|
||||
|
||||
return response()->json(['success' => true, 'details' => $result]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $config
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function maskApiKey(array $config): array
|
||||
{
|
||||
if (! empty($config['ai_api_key'])) {
|
||||
$config['ai_api_key'] = '********';
|
||||
}
|
||||
|
||||
return $config;
|
||||
}
|
||||
}
|
||||
74
app/Http/Controllers/Setup/AiConfigurationController.php
Normal file
74
app/Http/Controllers/Setup/AiConfigurationController.php
Normal file
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Setup;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Setting;
|
||||
use App\Services\AiConfigurationService;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
/**
|
||||
* Installer wizard step: optional "Enable AI" configuration.
|
||||
*
|
||||
* Runs without an authenticated user — the installer middleware allows public
|
||||
* access until `profile_complete` is marked COMPLETED. Persists a minimal global
|
||||
* AI config so the first super-admin doesn't have to revisit the admin panel
|
||||
* just to turn on chat/text-generation after install.
|
||||
*
|
||||
* Skipping the step (posting ai_enabled=NO) is the default path — users can
|
||||
* always configure AI later from Admin → Settings → AI Configuration.
|
||||
*/
|
||||
class AiConfigurationController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly AiConfigurationService $aiConfigurationService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Return the current AI config defaults plus the driver list for the wizard form.
|
||||
*/
|
||||
public function show(): JsonResponse
|
||||
{
|
||||
return response()->json([
|
||||
'config' => $this->aiConfigurationService->getGlobalConfig(),
|
||||
'drivers' => $this->aiConfigurationService->listDrivers(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist the installer's AI config choice and advance the wizard step.
|
||||
*
|
||||
* @throws ValidationException
|
||||
*/
|
||||
public function save(Request $request): JsonResponse
|
||||
{
|
||||
Artisan::call('optimize:clear');
|
||||
|
||||
$validated = $this->validate($request, [
|
||||
'ai_enabled' => 'required|in:YES,NO',
|
||||
'ai_driver' => 'required_if:ai_enabled,YES|nullable|string',
|
||||
'ai_api_key' => 'required_if:ai_enabled,YES|nullable|string',
|
||||
'ai_base_url' => 'nullable|string|url',
|
||||
'ai_chat_enabled' => 'nullable|in:YES,NO',
|
||||
'ai_chat_model' => 'nullable|string|max:200',
|
||||
'ai_text_generation_enabled' => 'nullable|in:YES,NO',
|
||||
'ai_text_generation_model' => 'nullable|string|max:200',
|
||||
]);
|
||||
|
||||
$this->aiConfigurationService->saveGlobalConfig($validated);
|
||||
|
||||
// Advance the installer's profile_complete marker if we're the first to touch it.
|
||||
// Mail uses `4`; we'll use the next sentinel but leave actual completion to the
|
||||
// final Preferences step (which sets 'COMPLETED'). The sentinel value is ignored
|
||||
// once COMPLETED is written — it only matters for step-tracking during install.
|
||||
$profileComplete = Setting::getSetting('profile_complete');
|
||||
if ($profileComplete !== 'COMPLETED' && (int) $profileComplete < 5) {
|
||||
Setting::setSetting('profile_complete', 5);
|
||||
}
|
||||
|
||||
return response()->json(['success' => true]);
|
||||
}
|
||||
}
|
||||
@@ -46,6 +46,15 @@ class SettingsPolicy
|
||||
return false;
|
||||
}
|
||||
|
||||
public function manageAiConfig(User $user)
|
||||
{
|
||||
if ($user->isSuperAdmin()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public function managePDFConfig(User $user)
|
||||
{
|
||||
if ($user->isSuperAdmin()) {
|
||||
|
||||
@@ -143,6 +143,7 @@ class AppServiceProvider extends ServiceProvider
|
||||
Gate::define('manage backups', [SettingsPolicy::class, 'manageBackups']);
|
||||
Gate::define('manage file disk', [SettingsPolicy::class, 'manageFileDisk']);
|
||||
Gate::define('manage email config', [SettingsPolicy::class, 'manageEmailConfig']);
|
||||
Gate::define('manage ai config', [SettingsPolicy::class, 'manageAiConfig']);
|
||||
Gate::define('manage pdf config', [SettingsPolicy::class, 'managePDFConfig']);
|
||||
Gate::define('manage notes', [NotePolicy::class, 'manageNotes']);
|
||||
Gate::define('view notes', [NotePolicy::class, 'viewNotes']);
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use App\Support\Ai\OpenRouterDriver;
|
||||
use App\Support\ExchangeRate\CurrencyConverterDriver;
|
||||
use App\Support\ExchangeRate\CurrencyFreakDriver;
|
||||
use App\Support\ExchangeRate\CurrencyLayerDriver;
|
||||
@@ -14,6 +15,7 @@ class DriverRegistryProvider extends ServiceProvider
|
||||
public function boot(): void
|
||||
{
|
||||
$this->registerExchangeRateDrivers();
|
||||
$this->registerAiDrivers();
|
||||
}
|
||||
|
||||
protected function registerExchangeRateDrivers(): void
|
||||
@@ -62,4 +64,31 @@ class DriverRegistryProvider extends ServiceProvider
|
||||
'website' => 'https://openexchangerates.org',
|
||||
]);
|
||||
}
|
||||
|
||||
protected function registerAiDrivers(): void
|
||||
{
|
||||
Registry::registerAiDriver('openrouter', [
|
||||
'class' => OpenRouterDriver::class,
|
||||
'label' => 'settings.ai.openrouter',
|
||||
'website' => 'https://openrouter.ai',
|
||||
'default_base_url' => 'https://openrouter.ai/api/v1',
|
||||
'supported_roles' => ['chat', 'text_generation'],
|
||||
'suggested_models' => [
|
||||
['value' => 'openai/gpt-4o', 'label' => 'OpenAI GPT-4o'],
|
||||
['value' => 'openai/gpt-4o-mini', 'label' => 'OpenAI GPT-4o mini'],
|
||||
['value' => 'anthropic/claude-3.5-sonnet', 'label' => 'Anthropic Claude 3.5 Sonnet'],
|
||||
['value' => 'anthropic/claude-3.5-haiku', 'label' => 'Anthropic Claude 3.5 Haiku'],
|
||||
['value' => 'google/gemini-pro-1.5', 'label' => 'Google Gemini Pro 1.5'],
|
||||
['value' => 'meta-llama/llama-3.3-70b-instruct', 'label' => 'Meta Llama 3.3 70B'],
|
||||
],
|
||||
'config_fields' => [
|
||||
[
|
||||
'key' => 'base_url',
|
||||
'type' => 'text',
|
||||
'label' => 'settings.ai.base_url',
|
||||
'default' => 'https://openrouter.ai/api/v1',
|
||||
],
|
||||
],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
353
app/Services/AiConfigurationService.php
Normal file
353
app/Services/AiConfigurationService.php
Normal file
@@ -0,0 +1,353 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\CompanySetting;
|
||||
use App\Models\Setting;
|
||||
use App\Support\Ai\AiDriver;
|
||||
use App\Support\Ai\AiDriverFactory;
|
||||
use Illuminate\Support\Facades\Crypt;
|
||||
use InvoiceShelf\Modules\Registry;
|
||||
|
||||
/**
|
||||
* Reads, writes, and resolves AI configuration at global and per-company scopes.
|
||||
*
|
||||
* Mirrors MailConfigurationService in shape: a global config lives in the `settings`
|
||||
* table under bare keys; a per-company override lives in `company_settings` under
|
||||
* `company_`-prefixed keys plus a `use_custom_ai_config` toggle. The resolution
|
||||
* order supports three layered kill-switches:
|
||||
*
|
||||
* 1. Global off → no AI for anyone
|
||||
* 2. Per-company off → no AI for this company (even if global is on)
|
||||
* 3. Role off (chat / text_generation) → role-level disable at either scope
|
||||
*
|
||||
* Deviation from mail: AI API keys are **encrypted** at the service layer via
|
||||
* Crypt::encryptString before persistence. OpenRouter bearer tokens have much
|
||||
* bigger blast radius than SMTP passwords; this is worth the pattern break.
|
||||
*/
|
||||
class AiConfigurationService
|
||||
{
|
||||
public const ROLE_CHAT = 'chat';
|
||||
|
||||
public const ROLE_TEXT_GENERATION = 'text_generation';
|
||||
|
||||
public const ROLES = [self::ROLE_CHAT, self::ROLE_TEXT_GENERATION];
|
||||
|
||||
private const GLOBAL_SCOPE = 'global';
|
||||
|
||||
private const COMPANY_SCOPE = 'company';
|
||||
|
||||
/**
|
||||
* Fields stored in the settings table (global scope, bare keys).
|
||||
*
|
||||
* Company-scope keys are these prefixed with `company_`.
|
||||
*/
|
||||
private const FIELDS = [
|
||||
'ai_enabled',
|
||||
'ai_driver',
|
||||
'ai_api_key',
|
||||
'ai_base_url',
|
||||
'ai_chat_enabled',
|
||||
'ai_chat_model',
|
||||
'ai_text_generation_enabled',
|
||||
'ai_text_generation_model',
|
||||
];
|
||||
|
||||
/**
|
||||
* Fields whose stored values are encrypted at rest.
|
||||
*/
|
||||
private const ENCRYPTED_FIELDS = [
|
||||
'ai_api_key',
|
||||
];
|
||||
|
||||
/**
|
||||
* Read the global AI config with decrypted secrets.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function getGlobalConfig(): array
|
||||
{
|
||||
$raw = Setting::getSettings(self::FIELDS)->all();
|
||||
|
||||
return $this->hydrateDefaults($this->decryptFields($raw));
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the per-company AI config with decrypted secrets.
|
||||
*
|
||||
* The response always includes the `use_custom_ai_config` toggle so the
|
||||
* frontend can render the override switch. Driver fields are present
|
||||
* regardless of the toggle — the client decides whether to show them.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function getCompanyConfig(int|string $companyId): array
|
||||
{
|
||||
$companyKeys = array_merge(
|
||||
['use_custom_ai_config'],
|
||||
$this->getCompanySettingKeys(),
|
||||
);
|
||||
|
||||
$raw = CompanySetting::getSettings($companyKeys, $companyId)->all();
|
||||
|
||||
return array_merge(
|
||||
['use_custom_ai_config' => $raw['use_custom_ai_config'] ?? 'NO'],
|
||||
$this->hydrateDefaults($this->decryptFields($this->stripCompanyPrefix($raw))),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist the global AI config, encrypting sensitive fields.
|
||||
*
|
||||
* @param array<string, mixed> $payload
|
||||
*/
|
||||
public function saveGlobalConfig(array $payload): void
|
||||
{
|
||||
Setting::setSettings($this->prepareSettingsForStorage($payload, self::GLOBAL_SCOPE));
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist the per-company AI config.
|
||||
*
|
||||
* When `use_custom_ai_config` is NOT 'YES', only the toggle is written —
|
||||
* driver fields in the payload are discarded. This mirrors the mail pattern
|
||||
* exactly and prevents stale per-company config from lingering after toggle-off.
|
||||
*
|
||||
* @param array<string, mixed> $payload
|
||||
*/
|
||||
public function saveCompanyConfig(int|string $companyId, array $payload): void
|
||||
{
|
||||
if (($payload['use_custom_ai_config'] ?? 'YES') !== 'YES') {
|
||||
CompanySetting::setSettings([
|
||||
'use_custom_ai_config' => 'NO',
|
||||
], $companyId);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$toStore = $this->prepareSettingsForStorage($payload, self::COMPANY_SCOPE);
|
||||
$toStore['use_custom_ai_config'] = 'YES';
|
||||
|
||||
CompanySetting::setSettings($toStore, $companyId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the effective AI config for a company.
|
||||
*
|
||||
* Returns the decrypted config array, or `null` when AI is unavailable.
|
||||
* Resolution order:
|
||||
*
|
||||
* 1. Global `ai_enabled` must be YES. Otherwise AI is off for everyone.
|
||||
* 2. If the company has `use_custom_ai_config = YES`, return the company
|
||||
* config. The company's own `ai_enabled` inside that override controls
|
||||
* whether AI is on for this company (the company can opt out by
|
||||
* setting `use_custom_ai_config = YES` and `ai_enabled = NO`).
|
||||
* 3. Otherwise, return the global config.
|
||||
*
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
public function resolveForCompany(int|string $companyId): ?array
|
||||
{
|
||||
$global = $this->getGlobalConfig();
|
||||
|
||||
// Global kill-switch — applies to all companies
|
||||
if (($global['ai_enabled'] ?? 'NO') !== 'YES') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$company = $this->getCompanyConfig($companyId);
|
||||
|
||||
if (($company['use_custom_ai_config'] ?? 'NO') === 'YES') {
|
||||
// Company-specific config — can opt out via ai_enabled=NO
|
||||
if (($company['ai_enabled'] ?? 'NO') !== 'YES') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->addBooleanFlags($company);
|
||||
}
|
||||
|
||||
return $this->addBooleanFlags($global);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience: resolve config and instantiate a driver for the company.
|
||||
*
|
||||
* Returns `null` when AI is disabled for the company.
|
||||
*/
|
||||
public function makeDriver(int|string $companyId): ?AiDriver
|
||||
{
|
||||
$config = $this->resolveForCompany($companyId);
|
||||
|
||||
if ($config === null || empty($config['ai_api_key']) || empty($config['ai_driver'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return AiDriverFactory::make(
|
||||
$config['ai_driver'],
|
||||
$config['ai_api_key'],
|
||||
['base_url' => $config['ai_base_url'] ?? null],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build dynamic validation rules for a save request.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function validationRules(bool $allowDisabledCustomConfig = false): array
|
||||
{
|
||||
$availableDrivers = AiDriverFactory::availableDrivers();
|
||||
|
||||
return [
|
||||
'use_custom_ai_config' => $allowDisabledCustomConfig ? ['nullable', 'in:YES,NO'] : ['prohibited'],
|
||||
'ai_enabled' => ['nullable', 'in:YES,NO'],
|
||||
'ai_driver' => ['required_if:ai_enabled,YES', 'nullable', 'string', 'in:'.implode(',', $availableDrivers)],
|
||||
'ai_api_key' => ['required_if:ai_enabled,YES', 'nullable', 'string'],
|
||||
'ai_base_url' => ['nullable', 'string', 'url'],
|
||||
'ai_chat_enabled' => ['nullable', 'in:YES,NO'],
|
||||
'ai_chat_model' => ['nullable', 'string', 'max:200'],
|
||||
'ai_text_generation_enabled' => ['nullable', 'in:YES,NO'],
|
||||
'ai_text_generation_model' => ['nullable', 'string', 'max:200'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get driver metadata for the AI type — what the frontend needs to render forms.
|
||||
*
|
||||
* Shape matches the exchange rate driver list so the UI can reuse the same
|
||||
* data-driven rendering pattern.
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function listDrivers(): array
|
||||
{
|
||||
return collect(Registry::allDrivers('ai'))
|
||||
->map(fn (array $meta, string $name) => [
|
||||
'value' => $name,
|
||||
'label' => $meta['label'] ?? $name,
|
||||
'website' => $meta['website'] ?? '',
|
||||
'default_base_url' => $meta['default_base_url'] ?? '',
|
||||
'supported_roles' => $meta['supported_roles'] ?? [],
|
||||
'suggested_models' => $meta['suggested_models'] ?? [],
|
||||
'config_fields' => $meta['config_fields'] ?? [],
|
||||
])
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* Company-scope keys: the FIELDS list with `company_` prefix.
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
protected function getCompanySettingKeys(): array
|
||||
{
|
||||
return array_map(fn (string $field) => 'company_'.$field, self::FIELDS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip the `company_` prefix from keys when reading company-scoped settings.
|
||||
*
|
||||
* @param array<string, mixed> $raw
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
protected function stripCompanyPrefix(array $raw): array
|
||||
{
|
||||
$normalized = [];
|
||||
foreach ($raw as $key => $value) {
|
||||
if ($key === 'use_custom_ai_config') {
|
||||
continue;
|
||||
}
|
||||
if (str_starts_with($key, 'company_')) {
|
||||
$normalized[substr($key, strlen('company_'))] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
return $normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt sensitive fields on read.
|
||||
*
|
||||
* @param array<string, mixed> $settings
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
protected function decryptFields(array $settings): array
|
||||
{
|
||||
foreach (self::ENCRYPTED_FIELDS as $field) {
|
||||
if (isset($settings[$field]) && $settings[$field] !== '') {
|
||||
try {
|
||||
$settings[$field] = Crypt::decryptString($settings[$field]);
|
||||
} catch (\Throwable) {
|
||||
// Backward compat: if the value was stored before encryption
|
||||
// was introduced, leave it as-is rather than wiping it.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $settings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fill in defaults for fields missing from storage so consumers get a complete array.
|
||||
*
|
||||
* @param array<string, mixed> $settings
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
protected function hydrateDefaults(array $settings): array
|
||||
{
|
||||
return array_merge([
|
||||
'ai_enabled' => 'NO',
|
||||
'ai_driver' => 'openrouter',
|
||||
'ai_api_key' => '',
|
||||
'ai_base_url' => '',
|
||||
'ai_chat_enabled' => 'NO',
|
||||
'ai_chat_model' => 'openai/gpt-4o',
|
||||
'ai_text_generation_enabled' => 'NO',
|
||||
'ai_text_generation_model' => 'openai/gpt-4o-mini',
|
||||
], $settings);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add derived boolean flags to a config array so consumers don't have to
|
||||
* compare against the 'YES'/'NO' strings everywhere.
|
||||
*
|
||||
* @param array<string, mixed> $config
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
protected function addBooleanFlags(array $config): array
|
||||
{
|
||||
$config['chat_enabled'] = ($config['ai_chat_enabled'] ?? 'NO') === 'YES';
|
||||
$config['text_generation_enabled'] = ($config['ai_text_generation_enabled'] ?? 'NO') === 'YES';
|
||||
|
||||
return $config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare a payload for storage: strip fields the caller doesn't own, encrypt
|
||||
* sensitive fields, prefix with `company_` for the company scope.
|
||||
*
|
||||
* @param array<string, mixed> $payload
|
||||
* @return array<string, string|null>
|
||||
*/
|
||||
protected function prepareSettingsForStorage(array $payload, string $scope): array
|
||||
{
|
||||
$prepared = [];
|
||||
foreach (self::FIELDS as $field) {
|
||||
if (! array_key_exists($field, $payload)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$value = $payload[$field];
|
||||
|
||||
if (in_array($field, self::ENCRYPTED_FIELDS, true) && is_string($value) && $value !== '') {
|
||||
$value = Crypt::encryptString($value);
|
||||
}
|
||||
|
||||
$storageKey = $scope === self::COMPANY_SCOPE ? 'company_'.$field : $field;
|
||||
$prepared[$storageKey] = $value;
|
||||
}
|
||||
|
||||
return $prepared;
|
||||
}
|
||||
}
|
||||
34
app/Support/Ai/AiChatResponse.php
Normal file
34
app/Support/Ai/AiChatResponse.php
Normal file
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support\Ai;
|
||||
|
||||
/**
|
||||
* Provider-agnostic chat completion response.
|
||||
*
|
||||
* Shape is deliberately modelled after OpenAI's response so drivers translating
|
||||
* from other shapes only have to do it once, and the AiAssistantService loop
|
||||
* stays vendor-neutral.
|
||||
*/
|
||||
class AiChatResponse
|
||||
{
|
||||
/**
|
||||
* @param string|null $message The assistant's text reply. Null when the response is a tool-call-only turn.
|
||||
* @param array<int, array{id: string, name: string, arguments: array<string, mixed>}> $toolCalls
|
||||
* Tool calls the model wants the host to execute. Empty array if none.
|
||||
* @param string $finishReason Why generation stopped: 'stop', 'tool_calls', 'length', 'error', etc.
|
||||
* @param array{tokens_in?: int, tokens_out?: int} $usage Token usage for cost tracking (optional).
|
||||
* @param string|null $model Echoed model ID that produced this response (optional).
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly ?string $message,
|
||||
public readonly array $toolCalls = [],
|
||||
public readonly string $finishReason = 'stop',
|
||||
public readonly array $usage = [],
|
||||
public readonly ?string $model = null,
|
||||
) {}
|
||||
|
||||
public function hasToolCalls(): bool
|
||||
{
|
||||
return $this->toolCalls !== [];
|
||||
}
|
||||
}
|
||||
85
app/Support/Ai/AiDriver.php
Normal file
85
app/Support/Ai/AiDriver.php
Normal file
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support\Ai;
|
||||
|
||||
/**
|
||||
* Abstract base for AI drivers.
|
||||
*
|
||||
* Concrete drivers adapt a specific AI provider (OpenRouter, Anthropic direct,
|
||||
* local Ollama, etc.) into the two operations the host app needs:
|
||||
*
|
||||
* - `chatCompletion()` — for the chat assistant. OpenAI chat format, optional
|
||||
* tool-calling support. Drivers MUST translate their provider's tool-call
|
||||
* shape into the OpenAI-style `tool_calls` array on AiChatResponse so the
|
||||
* AiAssistantService orchestration loop stays provider-agnostic.
|
||||
*
|
||||
* - `textCompletion()` — for the text generation popup on WYSIWYG editors.
|
||||
* Stateless single-shot prompt → text.
|
||||
*
|
||||
* Drivers are registered with the host via the module Registry (see
|
||||
* App\Providers\DriverRegistryProvider for built-ins and
|
||||
* InvoiceShelf\Modules\Registry::registerAiDriver() for module-contributed ones).
|
||||
*/
|
||||
abstract class AiDriver
|
||||
{
|
||||
public function __construct(
|
||||
protected string $apiKey,
|
||||
protected array $config = [],
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Perform a chat completion.
|
||||
*
|
||||
* @param array<int, array<string, mixed>> $messages OpenAI chat format: [['role' => 'user', 'content' => '...'], ...]
|
||||
* @param string $model Provider-specific model identifier, e.g. 'openai/gpt-4o'
|
||||
* @param array<int, array<string, mixed>> $tools OpenAI tools schema array (empty = no tool calling)
|
||||
* @param array<string, mixed> $options Provider-specific options (temperature, max_tokens, etc.)
|
||||
*
|
||||
* @throws AiException
|
||||
*/
|
||||
abstract public function chatCompletion(
|
||||
array $messages,
|
||||
string $model,
|
||||
array $tools = [],
|
||||
array $options = [],
|
||||
): AiChatResponse;
|
||||
|
||||
/**
|
||||
* Perform a single-shot text completion.
|
||||
*
|
||||
* Implementations may route this through chatCompletion() with a single
|
||||
* user message — it's a convenience for callers that don't need history.
|
||||
*
|
||||
* @throws AiException
|
||||
*/
|
||||
abstract public function textCompletion(
|
||||
string $prompt,
|
||||
string $model,
|
||||
array $options = [],
|
||||
): string;
|
||||
|
||||
/**
|
||||
* Validate that the configured API key and base URL can reach the provider.
|
||||
*
|
||||
* Called from admin "Test connection" buttons. Should make a cheap round-trip
|
||||
* (list models, short completion, etc.) and throw AiException on failure.
|
||||
*
|
||||
* @return array<string, mixed> Provider info the UI can display (e.g. echoed model list)
|
||||
*
|
||||
* @throws AiException
|
||||
*/
|
||||
abstract public function validateConnection(): array;
|
||||
|
||||
/**
|
||||
* Optional: return the list of available model identifiers from the provider.
|
||||
*
|
||||
* Drivers that don't expose a models endpoint can leave this as an empty array.
|
||||
* The UI falls back to the `suggested_models` declared in driver metadata.
|
||||
*
|
||||
* @return array<int, array{value: string, label: string}>
|
||||
*/
|
||||
public function listModels(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
86
app/Support/Ai/AiDriverFactory.php
Normal file
86
app/Support/Ai/AiDriverFactory.php
Normal file
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support\Ai;
|
||||
|
||||
use InvalidArgumentException;
|
||||
use InvoiceShelf\Modules\Registry;
|
||||
|
||||
/**
|
||||
* Instantiates AiDriver implementations by name.
|
||||
*
|
||||
* Mirrors the shape of ExchangeRateDriverFactory: a static $drivers fallback
|
||||
* map for built-ins registered directly against the factory, plus a fallback
|
||||
* to the module Registry so module-contributed drivers (via
|
||||
* Registry::registerAiDriver()) are also resolvable. Canonical registration
|
||||
* path is the Registry — the local fallback map exists so the factory keeps
|
||||
* working even in tests or contexts where the Registry happens to be flushed.
|
||||
*/
|
||||
class AiDriverFactory
|
||||
{
|
||||
/**
|
||||
* @var array<string, class-string<AiDriver>>
|
||||
*/
|
||||
protected static array $drivers = [
|
||||
'openrouter' => OpenRouterDriver::class,
|
||||
];
|
||||
|
||||
/**
|
||||
* Register a custom AI driver directly with the factory.
|
||||
*
|
||||
* Modules should prefer Registry::registerAiDriver() which carries
|
||||
* the metadata (label, website, supported_roles, suggested_models,
|
||||
* config_fields) that the frontend UI needs to render a configuration
|
||||
* form. This method exists for tests and programmatic registration.
|
||||
*
|
||||
* @param class-string<AiDriver> $driverClass
|
||||
*/
|
||||
public static function register(string $name, string $driverClass): void
|
||||
{
|
||||
static::$drivers[$name] = $driverClass;
|
||||
}
|
||||
|
||||
/**
|
||||
* Instantiate a driver by name.
|
||||
*
|
||||
* @param array<string, mixed> $config Driver-specific config (base_url, timeouts, etc.)
|
||||
*
|
||||
* @throws InvalidArgumentException When the driver name isn't known.
|
||||
*/
|
||||
public static function make(string $driver, string $apiKey, array $config = []): AiDriver
|
||||
{
|
||||
$class = static::resolveDriverClass($driver);
|
||||
|
||||
if (! $class) {
|
||||
throw new InvalidArgumentException("Unknown AI driver: {$driver}");
|
||||
}
|
||||
|
||||
return new $class($apiKey, $config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all known driver names — both factory-registered built-ins and Registry-contributed.
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public static function availableDrivers(): array
|
||||
{
|
||||
$local = array_keys(static::$drivers);
|
||||
$registry = array_keys(Registry::allDrivers('ai'));
|
||||
|
||||
return array_values(array_unique(array_merge($local, $registry)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a driver name to its concrete class via the local map then the Registry.
|
||||
*/
|
||||
protected static function resolveDriverClass(string $driver): ?string
|
||||
{
|
||||
if (isset(static::$drivers[$driver])) {
|
||||
return static::$drivers[$driver];
|
||||
}
|
||||
|
||||
$meta = Registry::driverMeta('ai', $driver);
|
||||
|
||||
return $meta['class'] ?? null;
|
||||
}
|
||||
}
|
||||
25
app/Support/Ai/AiException.php
Normal file
25
app/Support/Ai/AiException.php
Normal file
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support\Ai;
|
||||
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Domain exception for AI driver failures.
|
||||
*
|
||||
* Carries a short `errorKey` alongside the human-readable message so the
|
||||
* frontend can look up a localized error string. Matches the shape of
|
||||
* ExchangeRateException for consistency with the existing driver pattern.
|
||||
*/
|
||||
class AiException extends RuntimeException
|
||||
{
|
||||
public function __construct(
|
||||
string $message,
|
||||
public readonly string $errorKey = 'server_error',
|
||||
int $code = 0,
|
||||
?Throwable $previous = null,
|
||||
) {
|
||||
parent::__construct($message, $code, $previous);
|
||||
}
|
||||
}
|
||||
205
app/Support/Ai/OpenRouterDriver.php
Normal file
205
app/Support/Ai/OpenRouterDriver.php
Normal file
@@ -0,0 +1,205 @@
|
||||
<?php
|
||||
|
||||
namespace App\Support\Ai;
|
||||
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* OpenRouter driver.
|
||||
*
|
||||
* OpenRouter is an OpenAI-compatible aggregator that routes requests to
|
||||
* hundreds of underlying LLMs (OpenAI, Anthropic, Google, open-source, etc.)
|
||||
* behind a single API key and a single request shape. That makes it ideal as
|
||||
* the default v1 driver — one integration unlocks the whole ecosystem.
|
||||
*
|
||||
* Endpoint: POST {base_url}/chat/completions (OpenAI format)
|
||||
* Auth: Bearer token in Authorization header
|
||||
* Docs: https://openrouter.ai/docs
|
||||
*/
|
||||
class OpenRouterDriver extends AiDriver
|
||||
{
|
||||
protected const DEFAULT_BASE_URL = 'https://openrouter.ai/api/v1';
|
||||
|
||||
protected const TIMEOUT_SECONDS = 120;
|
||||
|
||||
public function chatCompletion(
|
||||
array $messages,
|
||||
string $model,
|
||||
array $tools = [],
|
||||
array $options = [],
|
||||
): AiChatResponse {
|
||||
$payload = array_filter([
|
||||
'model' => $model,
|
||||
'messages' => $messages,
|
||||
'tools' => $tools !== [] ? $tools : null,
|
||||
'tool_choice' => $tools !== [] ? ($options['tool_choice'] ?? 'auto') : null,
|
||||
'temperature' => $options['temperature'] ?? null,
|
||||
'max_tokens' => $options['max_tokens'] ?? null,
|
||||
], fn ($v) => $v !== null);
|
||||
|
||||
try {
|
||||
$response = Http::withToken($this->apiKey)
|
||||
->timeout(self::TIMEOUT_SECONDS)
|
||||
->acceptJson()
|
||||
->asJson()
|
||||
->post($this->getBaseUrl().'/chat/completions', $payload);
|
||||
} catch (Throwable $e) {
|
||||
throw new AiException(
|
||||
'OpenRouter request failed: '.$e->getMessage(),
|
||||
'server_error',
|
||||
0,
|
||||
$e,
|
||||
);
|
||||
}
|
||||
|
||||
if ($response->status() === 401) {
|
||||
throw new AiException('Invalid OpenRouter API key', 'invalid_key');
|
||||
}
|
||||
|
||||
if ($response->status() === 429) {
|
||||
throw new AiException('OpenRouter rate limit exceeded', 'rate_limited');
|
||||
}
|
||||
|
||||
if (! $response->successful()) {
|
||||
$errorBody = $response->json('error.message') ?? $response->body();
|
||||
throw new AiException(
|
||||
'OpenRouter returned '.$response->status().': '.$errorBody,
|
||||
'server_error',
|
||||
);
|
||||
}
|
||||
|
||||
return $this->parseChatResponse($response->json());
|
||||
}
|
||||
|
||||
public function textCompletion(string $prompt, string $model, array $options = []): string
|
||||
{
|
||||
$response = $this->chatCompletion(
|
||||
[['role' => 'user', 'content' => $prompt]],
|
||||
$model,
|
||||
[],
|
||||
$options,
|
||||
);
|
||||
|
||||
return $response->message ?? '';
|
||||
}
|
||||
|
||||
public function validateConnection(): array
|
||||
{
|
||||
try {
|
||||
$response = Http::withToken($this->apiKey)
|
||||
->timeout(30)
|
||||
->acceptJson()
|
||||
->get($this->getBaseUrl().'/models');
|
||||
} catch (Throwable $e) {
|
||||
throw new AiException(
|
||||
'Unable to reach OpenRouter: '.$e->getMessage(),
|
||||
'server_error',
|
||||
0,
|
||||
$e,
|
||||
);
|
||||
}
|
||||
|
||||
if ($response->status() === 401) {
|
||||
throw new AiException('Invalid OpenRouter API key', 'invalid_key');
|
||||
}
|
||||
|
||||
if (! $response->successful()) {
|
||||
throw new AiException(
|
||||
'OpenRouter validation failed with status '.$response->status(),
|
||||
'server_error',
|
||||
);
|
||||
}
|
||||
|
||||
$data = $response->json('data', []);
|
||||
|
||||
return [
|
||||
'ok' => true,
|
||||
'model_count' => is_array($data) ? count($data) : 0,
|
||||
];
|
||||
}
|
||||
|
||||
public function listModels(): array
|
||||
{
|
||||
try {
|
||||
$response = Http::withToken($this->apiKey)
|
||||
->timeout(30)
|
||||
->acceptJson()
|
||||
->get($this->getBaseUrl().'/models');
|
||||
} catch (Throwable) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (! $response->successful()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$models = $response->json('data', []);
|
||||
|
||||
if (! is_array($models)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_map(
|
||||
fn (array $m): array => [
|
||||
'value' => $m['id'] ?? '',
|
||||
'label' => $m['name'] ?? ($m['id'] ?? ''),
|
||||
],
|
||||
$models,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed>|null $body
|
||||
*/
|
||||
protected function parseChatResponse(?array $body): AiChatResponse
|
||||
{
|
||||
$choice = $body['choices'][0] ?? [];
|
||||
$message = $choice['message'] ?? [];
|
||||
|
||||
$text = $message['content'] ?? null;
|
||||
$finishReason = $choice['finish_reason'] ?? 'stop';
|
||||
|
||||
// Normalize OpenAI's tool_calls shape — each entry has id, type='function',
|
||||
// and function.{name,arguments} where arguments is a JSON string we need to decode.
|
||||
$toolCalls = [];
|
||||
foreach ($message['tool_calls'] ?? [] as $call) {
|
||||
$name = $call['function']['name'] ?? null;
|
||||
$rawArgs = $call['function']['arguments'] ?? '{}';
|
||||
$args = is_string($rawArgs) ? (json_decode($rawArgs, true) ?: []) : (array) $rawArgs;
|
||||
|
||||
if ($name === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$toolCalls[] = [
|
||||
'id' => $call['id'] ?? '',
|
||||
'name' => $name,
|
||||
'arguments' => $args,
|
||||
];
|
||||
}
|
||||
|
||||
$usage = [];
|
||||
if (isset($body['usage'])) {
|
||||
$usage = [
|
||||
'tokens_in' => (int) ($body['usage']['prompt_tokens'] ?? 0),
|
||||
'tokens_out' => (int) ($body['usage']['completion_tokens'] ?? 0),
|
||||
];
|
||||
}
|
||||
|
||||
return new AiChatResponse(
|
||||
message: $text,
|
||||
toolCalls: $toolCalls,
|
||||
finishReason: $finishReason,
|
||||
usage: $usage,
|
||||
model: $body['model'] ?? null,
|
||||
);
|
||||
}
|
||||
|
||||
protected function getBaseUrl(): string
|
||||
{
|
||||
$url = $this->config['base_url'] ?? self::DEFAULT_BASE_URL;
|
||||
|
||||
return rtrim($url, '/');
|
||||
}
|
||||
}
|
||||
@@ -277,6 +277,16 @@ return [
|
||||
'ability' => '',
|
||||
'model' => '',
|
||||
],
|
||||
[
|
||||
'title' => 'settings.menu_title.ai_configuration',
|
||||
'group' => '',
|
||||
'name' => 'AI Configuration',
|
||||
'link' => '/admin/settings/ai-config',
|
||||
'icon' => 'SparklesIcon',
|
||||
'owner_only' => true,
|
||||
'ability' => '',
|
||||
'model' => '',
|
||||
],
|
||||
[
|
||||
'title' => 'settings.menu_title.module_configuration',
|
||||
'group' => '',
|
||||
|
||||
42
lang/en.json
42
lang/en.json
@@ -916,7 +916,8 @@
|
||||
"address_information": "Address Information",
|
||||
"pdf_generation": "PDF Generation",
|
||||
"appearance": "Appearance",
|
||||
"module_configuration": "Module Configuration"
|
||||
"module_configuration": "Module Configuration",
|
||||
"ai_configuration": "AI Configuration"
|
||||
},
|
||||
"appearance": {
|
||||
"title": "Appearance",
|
||||
@@ -924,6 +925,45 @@
|
||||
"sidebar_group_labels": "Show sidebar group labels",
|
||||
"sidebar_group_labels_desc": "Display section headers like Documents, Administration, and Modules in the sidebar navigation."
|
||||
},
|
||||
"ai": {
|
||||
"title": "AI Configuration",
|
||||
"description": "Configure the AI provider used for chat assistance and text generation. AI is opt-in — leave disabled if you don't want these features.",
|
||||
"openrouter": "OpenRouter",
|
||||
"enable": "Enable AI features",
|
||||
"enable_help": "When disabled, the AI chat drawer and WYSIWYG text-generation button are hidden everywhere in the app.",
|
||||
"driver": "AI Provider",
|
||||
"api_key": "API Key",
|
||||
"api_key_help": "Your API key is encrypted before being stored.",
|
||||
"base_url": "Base URL",
|
||||
"base_url_help": "Leave blank to use the provider's default endpoint.",
|
||||
"roles": "AI Roles",
|
||||
"roles_help": "Pick which AI capabilities are available. Each role uses a specific model.",
|
||||
"chat": "Chat Assistant",
|
||||
"chat_help": "Natural-language Q&A over your company's data via tool-calling.",
|
||||
"chat_model": "Chat model",
|
||||
"text_generation": "Text Generation",
|
||||
"text_generation_help": "One-shot text generation for invoice notes and email bodies.",
|
||||
"text_generation_model": "Text generation model",
|
||||
"suggested_models": "Suggested models",
|
||||
"test_connection": "Test Connection",
|
||||
"test_success": "Connection successful.",
|
||||
"test_failed": "Connection test failed: {error}",
|
||||
"saved": "AI configuration saved successfully.",
|
||||
"use_custom_ai_config": "Use custom AI configuration",
|
||||
"use_custom_ai_config_desc": "Enable this to override the global AI configuration for this company.",
|
||||
"using_global_ai_config": "This company is using the global AI configuration. Enable the toggle above to configure a custom provider.",
|
||||
"company_enabled": "AI enabled for this company",
|
||||
"company_enabled_desc": "Turn off to disable all AI features for this company regardless of the global setting.",
|
||||
"installer_title": "AI Assistant",
|
||||
"installer_description": "Optionally enable AI chat and text generation now. You can change this later in Admin → Settings → AI Configuration.",
|
||||
"errors": {
|
||||
"invalid_key": "The API key is invalid.",
|
||||
"rate_limited": "The provider rate limit was hit. Please try again shortly.",
|
||||
"server_error": "The AI provider returned an error. Check your configuration and try again.",
|
||||
"model_not_found": "The requested model is not available on this provider.",
|
||||
"missing_api_key": "An API key is required to test the connection."
|
||||
}
|
||||
},
|
||||
"address_information": {
|
||||
"section_description": " You can update Your Address information using form below."
|
||||
},
|
||||
|
||||
@@ -115,6 +115,18 @@ export const API = {
|
||||
COMPANY_MAIL_CONFIG: '/api/v1/company/mail/company-config',
|
||||
COMPANY_MAIL_TEST: '/api/v1/company/mail/company-test',
|
||||
|
||||
// AI Configuration (global)
|
||||
AI_DRIVERS: '/api/v1/ai/drivers',
|
||||
AI_CONFIG: '/api/v1/ai/config',
|
||||
AI_TEST: '/api/v1/ai/test',
|
||||
|
||||
// Company AI Configuration
|
||||
COMPANY_AI_CONFIG: '/api/v1/company/ai/config',
|
||||
COMPANY_AI_TEST: '/api/v1/company/ai/test',
|
||||
|
||||
// Installer AI Configuration
|
||||
INSTALLATION_AI_CONFIG: '/api/v1/installation/ai/config',
|
||||
|
||||
// PDF Configuration
|
||||
PDF_DRIVERS: '/api/v1/pdf/drivers',
|
||||
PDF_CONFIG: '/api/v1/pdf/config',
|
||||
|
||||
51
resources/scripts/api/services/ai.service.ts
Normal file
51
resources/scripts/api/services/ai.service.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { client } from '../client'
|
||||
import { API } from '../endpoints'
|
||||
import type {
|
||||
AiConfig,
|
||||
AiDriversResponse,
|
||||
AiTestPayload,
|
||||
AiTestResponse,
|
||||
CompanyAiConfig,
|
||||
} from '@/scripts/types/ai-config'
|
||||
|
||||
export const aiService = {
|
||||
// Driver catalog — same shape across admin, company, installer contexts.
|
||||
async getDrivers(): Promise<AiDriversResponse> {
|
||||
const { data } = await client.get(API.AI_DRIVERS)
|
||||
return data
|
||||
},
|
||||
|
||||
// --- Global (admin) ---
|
||||
|
||||
async getGlobalConfig(): Promise<AiConfig> {
|
||||
const { data } = await client.get(API.AI_CONFIG)
|
||||
return data
|
||||
},
|
||||
|
||||
async saveGlobalConfig(payload: AiConfig): Promise<{ success?: string; error?: string }> {
|
||||
const { data } = await client.post(API.AI_CONFIG, payload)
|
||||
return data
|
||||
},
|
||||
|
||||
async testGlobalConnection(payload: AiTestPayload): Promise<AiTestResponse> {
|
||||
const { data } = await client.post(API.AI_TEST, payload)
|
||||
return data
|
||||
},
|
||||
|
||||
// --- Per-company ---
|
||||
|
||||
async getCompanyConfig(): Promise<CompanyAiConfig> {
|
||||
const { data } = await client.get(API.COMPANY_AI_CONFIG)
|
||||
return data
|
||||
},
|
||||
|
||||
async saveCompanyConfig(payload: CompanyAiConfig): Promise<{ success?: boolean; error?: string }> {
|
||||
const { data } = await client.post(API.COMPANY_AI_CONFIG, payload)
|
||||
return data
|
||||
},
|
||||
|
||||
async testCompanyConnection(payload: AiTestPayload): Promise<AiTestResponse> {
|
||||
const { data } = await client.post(API.COMPANY_AI_TEST, payload)
|
||||
return data
|
||||
},
|
||||
}
|
||||
@@ -29,6 +29,11 @@ export interface BootstrapResponse {
|
||||
config: Record<string, unknown>
|
||||
global_settings: Record<string, string>
|
||||
modules: string[]
|
||||
ai?: {
|
||||
enabled: boolean
|
||||
chat_enabled: boolean
|
||||
text_generation_enabled: boolean
|
||||
}
|
||||
user_menu?: Array<{ title: string; link: string; icon: string; priority: number; name: string }>
|
||||
admin_mode?: boolean
|
||||
pending_invitations?: Array<{
|
||||
|
||||
@@ -9,6 +9,7 @@ const AdminUsersView = () => import('./views/AdminUsersView.vue')
|
||||
const AdminUserEditView = () => import('./views/AdminUserEditView.vue')
|
||||
const AdminSettingsView = () => import('./views/AdminSettingsView.vue')
|
||||
const AdminMailConfigView = () => import('./views/settings/AdminMailConfigView.vue')
|
||||
const AdminAiConfigView = () => import('./views/settings/AdminAiConfigView.vue')
|
||||
const AdminPdfGenerationView = () => import('./views/settings/AdminPdfGenerationView.vue')
|
||||
const AdminBackupView = () => import('./views/settings/AdminBackupView.vue')
|
||||
const AdminFileDiskView = () => import('./views/settings/AdminFileDiskView.vue')
|
||||
@@ -87,6 +88,14 @@ export const adminRoutes: RouteRecordRaw[] = [
|
||||
},
|
||||
component: AdminMailConfigView,
|
||||
},
|
||||
{
|
||||
path: 'ai-configuration',
|
||||
name: 'admin.settings.ai',
|
||||
meta: {
|
||||
isSuperAdmin: true,
|
||||
},
|
||||
component: AdminAiConfigView,
|
||||
},
|
||||
{
|
||||
path: 'pdf-generation',
|
||||
name: 'admin.settings.pdf',
|
||||
|
||||
@@ -73,6 +73,11 @@ const menuItems = computed<SettingsMenuItem[]>(() => [
|
||||
link: '/admin/administration/settings/mail-configuration',
|
||||
icon: 'EnvelopeIcon',
|
||||
},
|
||||
{
|
||||
title: t('settings.menu_title.ai_configuration'),
|
||||
link: '/admin/administration/settings/ai-configuration',
|
||||
icon: 'SparklesIcon',
|
||||
},
|
||||
{
|
||||
title: t('settings.menu_title.pdf_generation'),
|
||||
link: '/admin/administration/settings/pdf-generation',
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useNotificationStore } from '@/scripts/stores/notification.store'
|
||||
import { aiService } from '@/scripts/api/services/ai.service'
|
||||
import type { AiConfig, AiDriverOption, AiTestPayload } from '@/scripts/types/ai-config'
|
||||
import { getErrorTranslationKey, handleApiError } from '@/scripts/utils/error-handling'
|
||||
import AiConfigurationForm from '@/scripts/features/company/settings/components/AiConfigurationForm.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const notificationStore = useNotificationStore()
|
||||
|
||||
const isSaving = ref(false)
|
||||
const isTesting = ref(false)
|
||||
const isFetchingInitialData = ref(false)
|
||||
const configData = ref<AiConfig | null>(null)
|
||||
const drivers = ref<AiDriverOption[]>([])
|
||||
|
||||
loadData()
|
||||
|
||||
async function loadData(): Promise<void> {
|
||||
isFetchingInitialData.value = true
|
||||
try {
|
||||
const [driversResponse, configResponse] = await Promise.all([
|
||||
aiService.getDrivers(),
|
||||
aiService.getGlobalConfig(),
|
||||
])
|
||||
drivers.value = driversResponse.ai_drivers
|
||||
configData.value = configResponse
|
||||
} catch (error: unknown) {
|
||||
const normalizedError = handleApiError(error)
|
||||
notificationStore.showNotification({
|
||||
type: 'error',
|
||||
message: getErrorTranslationKey(normalizedError.message) ?? normalizedError.message,
|
||||
})
|
||||
} finally {
|
||||
isFetchingInitialData.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function saveConfig(value: AiConfig): Promise<void> {
|
||||
isSaving.value = true
|
||||
try {
|
||||
const response = await aiService.saveGlobalConfig(value)
|
||||
if (response.success) {
|
||||
notificationStore.showNotification({
|
||||
type: 'success',
|
||||
message: 'settings.ai.saved',
|
||||
})
|
||||
configData.value = { ...value }
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const normalizedError = handleApiError(error)
|
||||
notificationStore.showNotification({
|
||||
type: 'error',
|
||||
message: getErrorTranslationKey(normalizedError.message) ?? normalizedError.message,
|
||||
})
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function testConnection(payload: AiTestPayload): Promise<void> {
|
||||
isTesting.value = true
|
||||
try {
|
||||
const response = await aiService.testGlobalConnection(payload)
|
||||
if (response.success) {
|
||||
notificationStore.showNotification({
|
||||
type: 'success',
|
||||
message: 'settings.ai.test_success',
|
||||
})
|
||||
} else if (response.error) {
|
||||
notificationStore.showNotification({
|
||||
type: 'error',
|
||||
message: t('settings.ai.errors.' + response.error, { error: response.message ?? '' }),
|
||||
})
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const normalizedError = handleApiError(error)
|
||||
notificationStore.showNotification({
|
||||
type: 'error',
|
||||
message: getErrorTranslationKey(normalizedError.message) ?? normalizedError.message,
|
||||
})
|
||||
} finally {
|
||||
isTesting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseSettingCard
|
||||
:title="$t('settings.ai.title')"
|
||||
:description="$t('settings.ai.description')"
|
||||
>
|
||||
<div v-if="configData" class="mt-14">
|
||||
<AiConfigurationForm
|
||||
:config-data="configData"
|
||||
:drivers="drivers"
|
||||
:is-saving="isSaving"
|
||||
:is-testing="isTesting"
|
||||
:is-fetching-initial-data="isFetchingInitialData"
|
||||
@submit-data="saveConfig"
|
||||
@test-connection="testConnection"
|
||||
/>
|
||||
</div>
|
||||
</BaseSettingCard>
|
||||
</template>
|
||||
@@ -0,0 +1,316 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import useVuelidate from '@vuelidate/core'
|
||||
import { helpers, required, requiredIf, url as urlValidator } from '@vuelidate/validators'
|
||||
import type {
|
||||
AiConfig,
|
||||
AiDriverConfigField,
|
||||
AiDriverOption,
|
||||
} from '@/scripts/types/ai-config'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
configData?: Partial<AiConfig>
|
||||
isSaving?: boolean
|
||||
isFetchingInitialData?: boolean
|
||||
drivers?: AiDriverOption[]
|
||||
isTesting?: boolean
|
||||
}>(),
|
||||
{
|
||||
configData: () => ({}),
|
||||
isSaving: false,
|
||||
isFetchingInitialData: false,
|
||||
drivers: () => [],
|
||||
isTesting: false,
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
'submit-data': [config: AiConfig]
|
||||
'test-connection': [config: Pick<AiConfig, 'ai_driver' | 'ai_api_key' | 'ai_base_url'>]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const form = reactive<AiConfig>(createDefaults())
|
||||
const showKey = ref(false)
|
||||
|
||||
const selectedDriver = computed<AiDriverOption | undefined>(() =>
|
||||
props.drivers.find((d) => d.value === form.ai_driver),
|
||||
)
|
||||
|
||||
const suggestedModels = computed(() => selectedDriver.value?.suggested_models ?? [])
|
||||
const configFields = computed<AiDriverConfigField[]>(() => selectedDriver.value?.config_fields ?? [])
|
||||
|
||||
const isAiOn = computed(() => form.ai_enabled === 'YES')
|
||||
const isChatOn = computed(() => form.ai_chat_enabled === 'YES')
|
||||
const isTextGenOn = computed(() => form.ai_text_generation_enabled === 'YES')
|
||||
|
||||
const driversList = computed(() =>
|
||||
props.drivers.map((d) => ({ value: d.value, label: t(d.label) })),
|
||||
)
|
||||
|
||||
const modelDatalistId = 'ai-model-suggestions'
|
||||
|
||||
const rules = computed(() => ({
|
||||
ai_driver: {
|
||||
required: helpers.withMessage(
|
||||
t('validation.required'),
|
||||
requiredIf(() => isAiOn.value),
|
||||
),
|
||||
},
|
||||
ai_api_key: {
|
||||
required: helpers.withMessage(
|
||||
t('validation.required'),
|
||||
requiredIf(() => isAiOn.value),
|
||||
),
|
||||
},
|
||||
ai_base_url: {
|
||||
url: helpers.withMessage(t('validation.invalid_url'), (value: string) => {
|
||||
if (!value) return true
|
||||
return urlValidator.$validator(value, {} as never, {} as never)
|
||||
}),
|
||||
},
|
||||
ai_chat_model: {
|
||||
required: helpers.withMessage(
|
||||
t('validation.required'),
|
||||
requiredIf(() => isAiOn.value && isChatOn.value),
|
||||
),
|
||||
},
|
||||
ai_text_generation_model: {
|
||||
required: helpers.withMessage(
|
||||
t('validation.required'),
|
||||
requiredIf(() => isAiOn.value && isTextGenOn.value),
|
||||
),
|
||||
},
|
||||
}))
|
||||
|
||||
const v$ = useVuelidate(rules, form)
|
||||
|
||||
function createDefaults(): AiConfig {
|
||||
return {
|
||||
ai_enabled: 'NO',
|
||||
ai_driver: 'openrouter',
|
||||
ai_api_key: '',
|
||||
ai_base_url: '',
|
||||
ai_chat_enabled: 'NO',
|
||||
ai_chat_model: 'openai/gpt-4o',
|
||||
ai_text_generation_enabled: 'NO',
|
||||
ai_text_generation_model: 'openai/gpt-4o-mini',
|
||||
}
|
||||
}
|
||||
|
||||
function hydrateFromProps() {
|
||||
if (!props.configData) return
|
||||
for (const key of Object.keys(form) as Array<keyof AiConfig>) {
|
||||
if (props.configData[key] !== undefined && props.configData[key] !== null) {
|
||||
;(form as Record<string, unknown>)[key] = props.configData[key]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => props.configData, hydrateFromProps, { immediate: true, deep: true })
|
||||
|
||||
// When the driver changes, fill in the driver-default base_url if the user hasn't provided one.
|
||||
watch(
|
||||
() => form.ai_driver,
|
||||
(next) => {
|
||||
const driver = props.drivers.find((d) => d.value === next)
|
||||
if (driver?.default_base_url && !form.ai_base_url) {
|
||||
form.ai_base_url = driver.default_base_url
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
async function onSubmit() {
|
||||
const valid = await v$.value.$validate()
|
||||
if (!valid) return
|
||||
emit('submit-data', { ...form })
|
||||
}
|
||||
|
||||
function onTestConnection() {
|
||||
emit('test-connection', {
|
||||
ai_driver: form.ai_driver,
|
||||
ai_api_key: form.ai_api_key,
|
||||
ai_base_url: form.ai_base_url,
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<form @submit.prevent="onSubmit">
|
||||
<!-- Global enable -->
|
||||
<div class="mb-8">
|
||||
<BaseSwitch
|
||||
:model-value="isAiOn"
|
||||
class="flex"
|
||||
:label-right="$t('settings.ai.enable')"
|
||||
@update:model-value="form.ai_enabled = $event ? 'YES' : 'NO'"
|
||||
/>
|
||||
<p class="mt-2 text-xs text-muted">{{ $t('settings.ai.enable_help') }}</p>
|
||||
</div>
|
||||
|
||||
<div v-if="isAiOn" class="space-y-6">
|
||||
<!-- Provider selection -->
|
||||
<BaseInputGroup
|
||||
:label="$t('settings.ai.driver')"
|
||||
:content-loading="isFetchingInitialData"
|
||||
required
|
||||
:error="v$.ai_driver.$error && v$.ai_driver.$errors[0]?.$message"
|
||||
>
|
||||
<BaseMultiselect
|
||||
v-model="form.ai_driver"
|
||||
:options="driversList"
|
||||
:content-loading="isFetchingInitialData"
|
||||
value-prop="value"
|
||||
label="label"
|
||||
track-by="label"
|
||||
:can-deselect="false"
|
||||
:invalid="v$.ai_driver.$error"
|
||||
/>
|
||||
</BaseInputGroup>
|
||||
|
||||
<!-- API key -->
|
||||
<BaseInputGroup
|
||||
:label="$t('settings.ai.api_key')"
|
||||
:content-loading="isFetchingInitialData"
|
||||
:help-text="$t('settings.ai.api_key_help')"
|
||||
required
|
||||
:error="v$.ai_api_key.$error && v$.ai_api_key.$errors[0]?.$message"
|
||||
>
|
||||
<div class="flex gap-2">
|
||||
<BaseInput
|
||||
v-model="form.ai_api_key"
|
||||
:content-loading="isFetchingInitialData"
|
||||
:type="showKey ? 'text' : 'password'"
|
||||
class="flex-1"
|
||||
name="ai_api_key"
|
||||
:invalid="v$.ai_api_key.$error"
|
||||
/>
|
||||
<BaseButton
|
||||
type="button"
|
||||
variant="primary-outline"
|
||||
@click="showKey = !showKey"
|
||||
>
|
||||
{{ showKey ? $t('general.hide') : $t('general.show') }}
|
||||
</BaseButton>
|
||||
</div>
|
||||
</BaseInputGroup>
|
||||
|
||||
<!-- Driver-specific config fields (base_url for OpenRouter, etc.) -->
|
||||
<BaseInputGroup
|
||||
v-for="field in configFields"
|
||||
:key="field.key"
|
||||
:label="$t(field.label)"
|
||||
:content-loading="isFetchingInitialData"
|
||||
>
|
||||
<BaseInput
|
||||
v-if="field.type === 'text'"
|
||||
:model-value="(form as unknown as Record<string, string>)[`ai_${field.key}`] ?? ''"
|
||||
:placeholder="field.default"
|
||||
type="text"
|
||||
:name="`ai_${field.key}`"
|
||||
@update:model-value="(val: string) => ((form as unknown as Record<string, string>)[`ai_${field.key}`] = val)"
|
||||
/>
|
||||
</BaseInputGroup>
|
||||
|
||||
<!-- Role: chat -->
|
||||
<div class="border-t border-line-default pt-6">
|
||||
<h3 class="text-sm font-semibold text-heading mb-3">{{ $t('settings.ai.roles') }}</h3>
|
||||
<p class="text-xs text-muted mb-4">{{ $t('settings.ai.roles_help') }}</p>
|
||||
|
||||
<div class="mb-6">
|
||||
<BaseSwitch
|
||||
:model-value="isChatOn"
|
||||
class="flex"
|
||||
:label-right="$t('settings.ai.chat')"
|
||||
@update:model-value="form.ai_chat_enabled = $event ? 'YES' : 'NO'"
|
||||
/>
|
||||
<p class="mt-2 text-xs text-muted">{{ $t('settings.ai.chat_help') }}</p>
|
||||
|
||||
<BaseInputGroup
|
||||
v-if="isChatOn"
|
||||
class="mt-3"
|
||||
:label="$t('settings.ai.chat_model')"
|
||||
required
|
||||
:error="v$.ai_chat_model.$error && v$.ai_chat_model.$errors[0]?.$message"
|
||||
>
|
||||
<BaseInput
|
||||
v-model="form.ai_chat_model"
|
||||
type="text"
|
||||
:list="modelDatalistId"
|
||||
:invalid="v$.ai_chat_model.$error"
|
||||
/>
|
||||
</BaseInputGroup>
|
||||
</div>
|
||||
|
||||
<!-- Role: text generation -->
|
||||
<div>
|
||||
<BaseSwitch
|
||||
:model-value="isTextGenOn"
|
||||
class="flex"
|
||||
:label-right="$t('settings.ai.text_generation')"
|
||||
@update:model-value="form.ai_text_generation_enabled = $event ? 'YES' : 'NO'"
|
||||
/>
|
||||
<p class="mt-2 text-xs text-muted">{{ $t('settings.ai.text_generation_help') }}</p>
|
||||
|
||||
<BaseInputGroup
|
||||
v-if="isTextGenOn"
|
||||
class="mt-3"
|
||||
:label="$t('settings.ai.text_generation_model')"
|
||||
required
|
||||
:error="
|
||||
v$.ai_text_generation_model.$error &&
|
||||
v$.ai_text_generation_model.$errors[0]?.$message
|
||||
"
|
||||
>
|
||||
<BaseInput
|
||||
v-model="form.ai_text_generation_model"
|
||||
type="text"
|
||||
:list="modelDatalistId"
|
||||
:invalid="v$.ai_text_generation_model.$error"
|
||||
/>
|
||||
</BaseInputGroup>
|
||||
</div>
|
||||
|
||||
<!-- Datalist with suggested models for both inputs -->
|
||||
<datalist :id="modelDatalistId">
|
||||
<option
|
||||
v-for="model in suggestedModels"
|
||||
:key="model.value"
|
||||
:value="model.value"
|
||||
>
|
||||
{{ model.label }}
|
||||
</option>
|
||||
</datalist>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex items-center gap-3 mt-8">
|
||||
<BaseButton
|
||||
:loading="isSaving"
|
||||
:disabled="isSaving"
|
||||
variant="primary"
|
||||
type="submit"
|
||||
>
|
||||
<template #left="slotProps">
|
||||
<BaseIcon v-if="!isSaving" name="ArrowDownOnSquareIcon" :class="slotProps.class" />
|
||||
</template>
|
||||
{{ $t('general.save') }}
|
||||
</BaseButton>
|
||||
|
||||
<BaseButton
|
||||
v-if="isAiOn"
|
||||
:loading="isTesting"
|
||||
:disabled="isTesting || isSaving"
|
||||
variant="primary-outline"
|
||||
type="button"
|
||||
@click="onTestConnection"
|
||||
>
|
||||
{{ $t('settings.ai.test_connection') }}
|
||||
</BaseButton>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
@@ -158,6 +158,15 @@ const settingsRoutes: RouteRecordRaw[] = [
|
||||
},
|
||||
component: () => import('./views/MailConfigView.vue'),
|
||||
},
|
||||
{
|
||||
path: 'ai-config',
|
||||
name: 'settings.ai-config',
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
isOwner: true,
|
||||
},
|
||||
component: () => import('./views/AiConfigView.vue'),
|
||||
},
|
||||
{
|
||||
path: 'roles',
|
||||
name: 'settings.roles',
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useNotificationStore } from '@/scripts/stores/notification.store'
|
||||
import { aiService } from '@/scripts/api/services/ai.service'
|
||||
import type {
|
||||
AiConfig,
|
||||
AiDriverOption,
|
||||
AiTestPayload,
|
||||
CompanyAiConfig,
|
||||
} from '@/scripts/types/ai-config'
|
||||
import { getErrorTranslationKey, handleApiError } from '@/scripts/utils/error-handling'
|
||||
import AiConfigurationForm from '@/scripts/features/company/settings/components/AiConfigurationForm.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const notificationStore = useNotificationStore()
|
||||
|
||||
const isSaving = ref(false)
|
||||
const isTesting = ref(false)
|
||||
const isFetchingInitialData = ref(false)
|
||||
const useCustomAiConfig = ref(false)
|
||||
const configData = ref<CompanyAiConfig | null>(null)
|
||||
const drivers = ref<AiDriverOption[]>([])
|
||||
|
||||
loadData()
|
||||
|
||||
async function loadData(): Promise<void> {
|
||||
isFetchingInitialData.value = true
|
||||
try {
|
||||
const [driversResponse, configResponse] = await Promise.all([
|
||||
aiService.getDrivers(),
|
||||
aiService.getCompanyConfig(),
|
||||
])
|
||||
drivers.value = driversResponse.ai_drivers
|
||||
configData.value = configResponse
|
||||
useCustomAiConfig.value = configResponse.use_custom_ai_config === 'YES'
|
||||
} catch (error: unknown) {
|
||||
const normalizedError = handleApiError(error)
|
||||
notificationStore.showNotification({
|
||||
type: 'error',
|
||||
message: getErrorTranslationKey(normalizedError.message) ?? normalizedError.message,
|
||||
})
|
||||
} finally {
|
||||
isFetchingInitialData.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Mirror the mail pattern: flipping the toggle OFF auto-saves and discards driver fields.
|
||||
watch(useCustomAiConfig, async (next, prev) => {
|
||||
if (prev === undefined) return
|
||||
if (next) return // ON — wait for explicit save
|
||||
|
||||
isSaving.value = true
|
||||
try {
|
||||
await aiService.saveCompanyConfig({
|
||||
use_custom_ai_config: 'NO',
|
||||
} as CompanyAiConfig)
|
||||
|
||||
if (configData.value) {
|
||||
configData.value.use_custom_ai_config = 'NO'
|
||||
}
|
||||
|
||||
notificationStore.showNotification({
|
||||
type: 'success',
|
||||
message: 'settings.ai.saved',
|
||||
})
|
||||
} catch (error: unknown) {
|
||||
const normalizedError = handleApiError(error)
|
||||
notificationStore.showNotification({
|
||||
type: 'error',
|
||||
message: getErrorTranslationKey(normalizedError.message) ?? normalizedError.message,
|
||||
})
|
||||
useCustomAiConfig.value = true // revert the toggle
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
})
|
||||
|
||||
async function saveConfig(value: AiConfig): Promise<void> {
|
||||
isSaving.value = true
|
||||
try {
|
||||
const payload: CompanyAiConfig = {
|
||||
...value,
|
||||
use_custom_ai_config: 'YES',
|
||||
}
|
||||
|
||||
const response = await aiService.saveCompanyConfig(payload)
|
||||
if (response.success) {
|
||||
notificationStore.showNotification({
|
||||
type: 'success',
|
||||
message: 'settings.ai.saved',
|
||||
})
|
||||
configData.value = payload
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const normalizedError = handleApiError(error)
|
||||
notificationStore.showNotification({
|
||||
type: 'error',
|
||||
message: getErrorTranslationKey(normalizedError.message) ?? normalizedError.message,
|
||||
})
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function testConnection(payload: AiTestPayload): Promise<void> {
|
||||
isTesting.value = true
|
||||
try {
|
||||
const response = await aiService.testCompanyConnection(payload)
|
||||
if (response.success) {
|
||||
notificationStore.showNotification({
|
||||
type: 'success',
|
||||
message: 'settings.ai.test_success',
|
||||
})
|
||||
} else if (response.error) {
|
||||
notificationStore.showNotification({
|
||||
type: 'error',
|
||||
message: t('settings.ai.errors.' + response.error, { error: response.message ?? '' }),
|
||||
})
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const normalizedError = handleApiError(error)
|
||||
notificationStore.showNotification({
|
||||
type: 'error',
|
||||
message: getErrorTranslationKey(normalizedError.message) ?? normalizedError.message,
|
||||
})
|
||||
} finally {
|
||||
isTesting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseSettingCard
|
||||
:title="$t('settings.ai.title')"
|
||||
:description="$t('settings.ai.description')"
|
||||
>
|
||||
<div class="mt-8">
|
||||
<BaseSwitchSection
|
||||
v-model="useCustomAiConfig"
|
||||
:title="$t('settings.ai.use_custom_ai_config')"
|
||||
:description="$t('settings.ai.use_custom_ai_config_desc')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="!useCustomAiConfig"
|
||||
class="mt-6 p-4 rounded bg-alert-success-bg text-alert-success-text text-sm"
|
||||
>
|
||||
{{ $t('settings.ai.using_global_ai_config') }}
|
||||
</div>
|
||||
|
||||
<div v-if="useCustomAiConfig && configData" class="mt-8">
|
||||
<AiConfigurationForm
|
||||
:config-data="configData"
|
||||
:drivers="drivers"
|
||||
:is-saving="isSaving"
|
||||
:is-testing="isTesting"
|
||||
:is-fetching-initial-data="isFetchingInitialData"
|
||||
@submit-data="saveConfig"
|
||||
@test-connection="testConnection"
|
||||
/>
|
||||
</div>
|
||||
</BaseSettingCard>
|
||||
</template>
|
||||
@@ -15,9 +15,10 @@ import InstallationLayout from '@/scripts/layouts/InstallationLayout.vue'
|
||||
* 4. DatabaseView (/installation/database)
|
||||
* 5. DomainView (/installation/domain)
|
||||
* 6. MailView (/installation/mail)
|
||||
* 7. AccountView (/installation/account)
|
||||
* 8. CompanyView (/installation/company)
|
||||
* 9. PreferencesView (/installation/preferences)
|
||||
* 7. AiView (/installation/ai) — optional, skippable
|
||||
* 8. AccountView (/installation/account)
|
||||
* 9. CompanyView (/installation/company)
|
||||
* 10. PreferencesView (/installation/preferences)
|
||||
*
|
||||
* Each child view owns its own next() function and calls router.push() to
|
||||
* the next step by route name. There is no event-based step coordination —
|
||||
@@ -90,6 +91,15 @@ export const installationRoutes: RouteRecordRaw[] = [
|
||||
isInstallation: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'ai',
|
||||
name: 'installation.ai',
|
||||
component: () => import('./views/AiView.vue'),
|
||||
meta: {
|
||||
title: 'settings.ai.installer_title',
|
||||
isInstallation: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'account',
|
||||
name: 'installation.account',
|
||||
|
||||
100
resources/scripts/features/installation/views/AiView.vue
Normal file
100
resources/scripts/features/installation/views/AiView.vue
Normal file
@@ -0,0 +1,100 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { installClient } from '@/scripts/api/install-client'
|
||||
import type {
|
||||
AiConfig,
|
||||
AiDriverOption,
|
||||
AiDriversResponse,
|
||||
} from '@/scripts/types/ai-config'
|
||||
import AiConfigurationForm from '@/scripts/features/company/settings/components/AiConfigurationForm.vue'
|
||||
import { useInstallationFeedback } from '../use-installation-feedback'
|
||||
|
||||
const router = useRouter()
|
||||
const { isSuccessfulResponse, showRequestError, showResponseError } = useInstallationFeedback()
|
||||
|
||||
const isSaving = ref(false)
|
||||
const isFetchingInitialData = ref(false)
|
||||
const configData = ref<AiConfig | null>(null)
|
||||
const drivers = ref<AiDriverOption[]>([])
|
||||
|
||||
onMounted(loadData)
|
||||
|
||||
async function loadData(): Promise<void> {
|
||||
isFetchingInitialData.value = true
|
||||
try {
|
||||
const { data } = await installClient.get<{
|
||||
config: AiConfig
|
||||
drivers: AiDriversResponse['ai_drivers']
|
||||
}>('/api/v1/installation/ai/config')
|
||||
configData.value = data.config
|
||||
drivers.value = data.drivers
|
||||
} catch (error: unknown) {
|
||||
showRequestError(error)
|
||||
} finally {
|
||||
isFetchingInitialData.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function saveAi(value: AiConfig): Promise<void> {
|
||||
isSaving.value = true
|
||||
try {
|
||||
const { data } = await installClient.post('/api/v1/installation/ai/config', value)
|
||||
|
||||
if (!isSuccessfulResponse(data)) {
|
||||
showResponseError(data)
|
||||
return
|
||||
}
|
||||
|
||||
await router.push({ name: 'installation.account' })
|
||||
} catch (error: unknown) {
|
||||
showRequestError(error)
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function skipStep(): Promise<void> {
|
||||
// Persist the disabled default so bootstrap sees an explicit ai_enabled=NO
|
||||
// (rather than a missing key that defaults to NO anyway — we want the value
|
||||
// in storage so tests / repeated installer runs behave predictably).
|
||||
await saveAi({
|
||||
ai_enabled: 'NO',
|
||||
ai_driver: 'openrouter',
|
||||
ai_api_key: '',
|
||||
ai_base_url: '',
|
||||
ai_chat_enabled: 'NO',
|
||||
ai_chat_model: '',
|
||||
ai_text_generation_enabled: 'NO',
|
||||
ai_text_generation_model: '',
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseWizardStep
|
||||
:title="$t('settings.ai.installer_title')"
|
||||
:description="$t('settings.ai.installer_description')"
|
||||
>
|
||||
<div v-if="configData">
|
||||
<AiConfigurationForm
|
||||
:config-data="configData"
|
||||
:drivers="drivers"
|
||||
:is-saving="isSaving"
|
||||
:is-fetching-initial-data="isFetchingInitialData"
|
||||
@submit-data="saveAi"
|
||||
/>
|
||||
|
||||
<div class="mt-6">
|
||||
<BaseButton
|
||||
variant="primary-outline"
|
||||
type="button"
|
||||
:disabled="isSaving"
|
||||
@click="skipStep"
|
||||
>
|
||||
{{ $t('general.skip') }}
|
||||
</BaseButton>
|
||||
</div>
|
||||
</div>
|
||||
</BaseWizardStep>
|
||||
</template>
|
||||
@@ -51,7 +51,7 @@ async function saveMailConfig(value: MailConfig): Promise<void> {
|
||||
...value,
|
||||
}
|
||||
|
||||
await router.push({ name: 'installation.account' })
|
||||
await router.push({ name: 'installation.ai' })
|
||||
} catch (error: unknown) {
|
||||
showRequestError(error)
|
||||
} finally {
|
||||
|
||||
55
resources/scripts/types/ai-config.ts
Normal file
55
resources/scripts/types/ai-config.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
export interface AiSuggestedModel {
|
||||
value: string
|
||||
label: string
|
||||
}
|
||||
|
||||
export interface AiDriverConfigField {
|
||||
key: string
|
||||
type: 'text' | 'select'
|
||||
label: string
|
||||
default?: string
|
||||
options?: Array<{ label: string; value: string }>
|
||||
visible_when?: Record<string, string>
|
||||
}
|
||||
|
||||
export interface AiDriverOption {
|
||||
value: string
|
||||
label: string
|
||||
website: string
|
||||
default_base_url: string
|
||||
supported_roles: string[]
|
||||
suggested_models: AiSuggestedModel[]
|
||||
config_fields: AiDriverConfigField[]
|
||||
}
|
||||
|
||||
export interface AiDriversResponse {
|
||||
ai_drivers: AiDriverOption[]
|
||||
}
|
||||
|
||||
export interface AiConfig {
|
||||
ai_enabled: 'YES' | 'NO'
|
||||
ai_driver: string
|
||||
ai_api_key: string
|
||||
ai_base_url: string
|
||||
ai_chat_enabled: 'YES' | 'NO'
|
||||
ai_chat_model: string
|
||||
ai_text_generation_enabled: 'YES' | 'NO'
|
||||
ai_text_generation_model: string
|
||||
}
|
||||
|
||||
export interface CompanyAiConfig extends AiConfig {
|
||||
use_custom_ai_config: 'YES' | 'NO'
|
||||
}
|
||||
|
||||
export interface AiTestPayload {
|
||||
ai_driver: string
|
||||
ai_api_key?: string
|
||||
ai_base_url?: string
|
||||
}
|
||||
|
||||
export interface AiTestResponse {
|
||||
success?: boolean
|
||||
error?: string
|
||||
message?: string
|
||||
details?: Record<string, unknown>
|
||||
}
|
||||
@@ -8,6 +8,7 @@ use App\Http\Controllers\Admin\CurrenciesController;
|
||||
use App\Http\Controllers\Admin\FontController;
|
||||
use App\Http\Controllers\Admin\Modules\ModuleInstallationController;
|
||||
use App\Http\Controllers\Admin\Modules\ModulesController;
|
||||
use App\Http\Controllers\Admin\Settings\AiConfigurationController;
|
||||
use App\Http\Controllers\Admin\Settings\DiskController;
|
||||
use App\Http\Controllers\Admin\Settings\MailConfigurationController;
|
||||
use App\Http\Controllers\Admin\Settings\PDFConfigurationController;
|
||||
@@ -48,6 +49,7 @@ use App\Http\Controllers\Company\RecurringInvoice\RecurringInvoiceController;
|
||||
use App\Http\Controllers\Company\RecurringInvoice\RecurringInvoiceFrequencyController;
|
||||
use App\Http\Controllers\Company\Role\AbilitiesController;
|
||||
use App\Http\Controllers\Company\Role\RolesController;
|
||||
use App\Http\Controllers\Company\Settings\CompanyAiConfigurationController;
|
||||
use App\Http\Controllers\Company\Settings\CompanyController;
|
||||
use App\Http\Controllers\Company\Settings\CompanyMailConfigurationController;
|
||||
use App\Http\Controllers\Company\Settings\CompanySettingsController;
|
||||
@@ -65,6 +67,7 @@ use App\Http\Controllers\CustomerPortal\General\ProfileController as CustomerPro
|
||||
use App\Http\Controllers\CustomerPortal\Invoice\InvoicesController as CustomerInvoicesController;
|
||||
use App\Http\Controllers\CustomerPortal\Payment\PaymentMethodController;
|
||||
use App\Http\Controllers\CustomerPortal\Payment\PaymentsController as CustomerPaymentsController;
|
||||
use App\Http\Controllers\Setup\AiConfigurationController as InstallerAiConfigurationController;
|
||||
use App\Http\Controllers\Setup\AppDomainController;
|
||||
use App\Http\Controllers\Setup\DatabaseConfigurationController;
|
||||
use App\Http\Controllers\Setup\FilePermissionsController;
|
||||
@@ -153,6 +156,9 @@ Route::prefix('/v1')->group(function () {
|
||||
|
||||
Route::put('/set-domain', AppDomainController::class);
|
||||
|
||||
Route::get('/ai/config', [InstallerAiConfigurationController::class, 'show']);
|
||||
Route::post('/ai/config', [InstallerAiConfigurationController::class, 'save']);
|
||||
|
||||
Route::post('/login', LoginController::class);
|
||||
|
||||
Route::post('/finish', FinishController::class);
|
||||
@@ -415,6 +421,18 @@ Route::prefix('/v1')->group(function () {
|
||||
Route::post('/company/mail/company-config', [CompanyMailConfigurationController::class, 'saveMailConfig']);
|
||||
Route::post('/company/mail/company-test', [CompanyMailConfigurationController::class, 'testMailConfig']);
|
||||
|
||||
// AI Configuration
|
||||
// ----------------------------------
|
||||
|
||||
Route::get('/ai/drivers', [AiConfigurationController::class, 'getDrivers']);
|
||||
Route::get('/ai/config', [AiConfigurationController::class, 'getConfig']);
|
||||
Route::post('/ai/config', [AiConfigurationController::class, 'saveConfig']);
|
||||
Route::post('/ai/test', [AiConfigurationController::class, 'testConnection']);
|
||||
|
||||
Route::get('/company/ai/config', [CompanyAiConfigurationController::class, 'getConfig']);
|
||||
Route::post('/company/ai/config', [CompanyAiConfigurationController::class, 'saveConfig']);
|
||||
Route::post('/company/ai/test', [CompanyAiConfigurationController::class, 'testConnection']);
|
||||
|
||||
// PDF Generation
|
||||
// ----------------------------------
|
||||
|
||||
|
||||
146
tests/Feature/Ai/AiConfigurationTest.php
Normal file
146
tests/Feature/Ai/AiConfigurationTest.php
Normal file
@@ -0,0 +1,146 @@
|
||||
<?php
|
||||
|
||||
use App\Models\CompanySetting;
|
||||
use App\Models\User;
|
||||
use App\Services\AiConfigurationService;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Laravel\Sanctum\Sanctum;
|
||||
|
||||
use function Pest\Laravel\getJson;
|
||||
use function Pest\Laravel\postJson;
|
||||
|
||||
beforeEach(function () {
|
||||
Artisan::call('db:seed', ['--class' => 'DatabaseSeeder', '--force' => true]);
|
||||
Artisan::call('db:seed', ['--class' => 'DemoSeeder', '--force' => true]);
|
||||
|
||||
$this->user = User::find(1);
|
||||
$this->companyId = $this->user->companies()->first()->id;
|
||||
|
||||
$this->withHeaders(['company' => $this->companyId]);
|
||||
Sanctum::actingAs($this->user, ['*']);
|
||||
});
|
||||
|
||||
test('admin can fetch AI driver list with metadata', function () {
|
||||
$response = getJson('/api/v1/ai/drivers')->assertOk();
|
||||
|
||||
$drivers = collect($response->json('ai_drivers'));
|
||||
$openrouter = $drivers->firstWhere('value', 'openrouter');
|
||||
|
||||
expect($openrouter)->not->toBeNull()
|
||||
->and($openrouter['label'])->toBe('settings.ai.openrouter')
|
||||
->and($openrouter['supported_roles'])->toContain('chat')
|
||||
->and($openrouter['suggested_models'])->not->toBeEmpty();
|
||||
});
|
||||
|
||||
test('admin can save and retrieve global AI config with api key masked in response', function () {
|
||||
postJson('/api/v1/ai/config', [
|
||||
'ai_enabled' => 'YES',
|
||||
'ai_driver' => 'openrouter',
|
||||
'ai_api_key' => 'sk-or-super-secret',
|
||||
'ai_base_url' => 'https://openrouter.ai/api/v1',
|
||||
'ai_chat_enabled' => 'YES',
|
||||
'ai_chat_model' => 'openai/gpt-4o',
|
||||
'ai_text_generation_enabled' => 'NO',
|
||||
'ai_text_generation_model' => '',
|
||||
])->assertOk();
|
||||
|
||||
$response = getJson('/api/v1/ai/config')->assertOk();
|
||||
|
||||
// API key is masked in responses — never returned in plaintext
|
||||
expect($response->json('ai_api_key'))->toBe('********');
|
||||
expect($response->json('ai_enabled'))->toBe('YES');
|
||||
expect($response->json('ai_chat_model'))->toBe('openai/gpt-4o');
|
||||
});
|
||||
|
||||
test('admin save preserves existing api key when masked placeholder is submitted', function () {
|
||||
// Initial save
|
||||
postJson('/api/v1/ai/config', [
|
||||
'ai_enabled' => 'YES',
|
||||
'ai_driver' => 'openrouter',
|
||||
'ai_api_key' => 'original-key',
|
||||
])->assertOk();
|
||||
|
||||
// Second save submits the masked placeholder — key should be preserved
|
||||
postJson('/api/v1/ai/config', [
|
||||
'ai_enabled' => 'YES',
|
||||
'ai_driver' => 'openrouter',
|
||||
'ai_api_key' => '********',
|
||||
'ai_chat_model' => 'openai/gpt-4o-mini',
|
||||
])->assertOk();
|
||||
|
||||
$service = app(AiConfigurationService::class);
|
||||
$config = $service->getGlobalConfig();
|
||||
|
||||
expect($config['ai_api_key'])->toBe('original-key')
|
||||
->and($config['ai_chat_model'])->toBe('openai/gpt-4o-mini');
|
||||
});
|
||||
|
||||
test('company save respects use_custom_ai_config toggle OFF', function () {
|
||||
postJson('/api/v1/company/ai/config', [
|
||||
'use_custom_ai_config' => 'NO',
|
||||
'ai_api_key' => 'should-not-be-saved',
|
||||
])->assertOk();
|
||||
|
||||
$raw = CompanySetting::getSettings(['use_custom_ai_config', 'company_ai_api_key'], $this->companyId)->all();
|
||||
expect($raw['use_custom_ai_config'] ?? null)->toBe('NO');
|
||||
expect($raw['company_ai_api_key'] ?? null)->toBeNull();
|
||||
});
|
||||
|
||||
test('company save with toggle ON persists company-specific driver fields', function () {
|
||||
postJson('/api/v1/company/ai/config', [
|
||||
'use_custom_ai_config' => 'YES',
|
||||
'ai_enabled' => 'YES',
|
||||
'ai_driver' => 'openrouter',
|
||||
'ai_api_key' => 'company-key',
|
||||
'ai_chat_enabled' => 'YES',
|
||||
'ai_chat_model' => 'anthropic/claude-3.5-sonnet',
|
||||
])->assertOk();
|
||||
|
||||
$response = getJson('/api/v1/company/ai/config')->assertOk();
|
||||
|
||||
expect($response->json('use_custom_ai_config'))->toBe('YES')
|
||||
->and($response->json('ai_chat_model'))->toBe('anthropic/claude-3.5-sonnet')
|
||||
->and($response->json('ai_api_key'))->toBe('********'); // masked
|
||||
});
|
||||
|
||||
test('bootstrap response surfaces ai flags based on resolution', function () {
|
||||
// No config — AI should be disabled
|
||||
$response = getJson('/api/v1/bootstrap')->assertOk();
|
||||
expect($response->json('ai.enabled'))->toBeFalse();
|
||||
|
||||
// Enable globally with chat enabled
|
||||
app(AiConfigurationService::class)->saveGlobalConfig([
|
||||
'ai_enabled' => 'YES',
|
||||
'ai_driver' => 'openrouter',
|
||||
'ai_api_key' => 'key',
|
||||
'ai_chat_enabled' => 'YES',
|
||||
'ai_chat_model' => 'openai/gpt-4o',
|
||||
'ai_text_generation_enabled' => 'NO',
|
||||
]);
|
||||
|
||||
$response = getJson('/api/v1/bootstrap')->assertOk();
|
||||
expect($response->json('ai.enabled'))->toBeTrue()
|
||||
->and($response->json('ai.chat_enabled'))->toBeTrue()
|
||||
->and($response->json('ai.text_generation_enabled'))->toBeFalse();
|
||||
});
|
||||
|
||||
test('bootstrap response hides ai when company opts out via override', function () {
|
||||
app(AiConfigurationService::class)->saveGlobalConfig([
|
||||
'ai_enabled' => 'YES',
|
||||
'ai_driver' => 'openrouter',
|
||||
'ai_api_key' => 'key',
|
||||
'ai_chat_enabled' => 'YES',
|
||||
'ai_chat_model' => 'openai/gpt-4o',
|
||||
]);
|
||||
|
||||
app(AiConfigurationService::class)->saveCompanyConfig($this->companyId, [
|
||||
'use_custom_ai_config' => 'YES',
|
||||
'ai_enabled' => 'NO',
|
||||
'ai_driver' => 'openrouter',
|
||||
'ai_api_key' => 'unused',
|
||||
]);
|
||||
|
||||
$response = getJson('/api/v1/bootstrap')->assertOk();
|
||||
|
||||
expect($response->json('ai.enabled'))->toBeFalse();
|
||||
});
|
||||
183
tests/Unit/AiConfigurationServiceTest.php
Normal file
183
tests/Unit/AiConfigurationServiceTest.php
Normal file
@@ -0,0 +1,183 @@
|
||||
<?php
|
||||
|
||||
use App\Models\CompanySetting;
|
||||
use App\Models\Setting;
|
||||
use App\Models\User;
|
||||
use App\Services\AiConfigurationService;
|
||||
use App\Support\Ai\OpenRouterDriver;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Support\Facades\Crypt;
|
||||
|
||||
beforeEach(function () {
|
||||
Artisan::call('db:seed', ['--class' => 'DatabaseSeeder', '--force' => true]);
|
||||
Artisan::call('db:seed', ['--class' => 'DemoSeeder', '--force' => true]);
|
||||
|
||||
$this->service = new AiConfigurationService;
|
||||
$this->user = User::find(1);
|
||||
$this->companyId = $this->user->companies()->first()->id;
|
||||
});
|
||||
|
||||
test('saving global config encrypts the api key at rest', function () {
|
||||
$this->service->saveGlobalConfig([
|
||||
'ai_enabled' => 'YES',
|
||||
'ai_driver' => 'openrouter',
|
||||
'ai_api_key' => 'sk-or-secret-key',
|
||||
'ai_base_url' => 'https://openrouter.ai/api/v1',
|
||||
'ai_chat_enabled' => 'YES',
|
||||
'ai_chat_model' => 'openai/gpt-4o',
|
||||
'ai_text_generation_enabled' => 'NO',
|
||||
'ai_text_generation_model' => '',
|
||||
]);
|
||||
|
||||
$storedCiphertext = Setting::getSetting('ai_api_key');
|
||||
expect($storedCiphertext)->not->toBe('sk-or-secret-key');
|
||||
expect(Crypt::decryptString($storedCiphertext))->toBe('sk-or-secret-key');
|
||||
});
|
||||
|
||||
test('reading global config decrypts the api key', function () {
|
||||
$this->service->saveGlobalConfig([
|
||||
'ai_enabled' => 'YES',
|
||||
'ai_driver' => 'openrouter',
|
||||
'ai_api_key' => 'sk-or-secret-key',
|
||||
'ai_chat_enabled' => 'YES',
|
||||
'ai_chat_model' => 'openai/gpt-4o',
|
||||
]);
|
||||
|
||||
$config = $this->service->getGlobalConfig();
|
||||
|
||||
expect($config['ai_api_key'])->toBe('sk-or-secret-key');
|
||||
expect($config['ai_enabled'])->toBe('YES');
|
||||
expect($config['ai_chat_model'])->toBe('openai/gpt-4o');
|
||||
});
|
||||
|
||||
test('company save with toggle OFF only persists the toggle and discards driver fields', function () {
|
||||
$this->service->saveCompanyConfig($this->companyId, [
|
||||
'use_custom_ai_config' => 'NO',
|
||||
'ai_api_key' => 'this-should-not-be-stored',
|
||||
'ai_driver' => 'openrouter',
|
||||
]);
|
||||
|
||||
$raw = CompanySetting::getSettings(['use_custom_ai_config', 'company_ai_api_key'], $this->companyId)->all();
|
||||
|
||||
expect($raw['use_custom_ai_config'] ?? null)->toBe('NO');
|
||||
expect($raw['company_ai_api_key'] ?? null)->toBeNull();
|
||||
});
|
||||
|
||||
test('company save with toggle ON encrypts and persists driver fields', function () {
|
||||
$this->service->saveCompanyConfig($this->companyId, [
|
||||
'use_custom_ai_config' => 'YES',
|
||||
'ai_driver' => 'openrouter',
|
||||
'ai_api_key' => 'company-specific-key',
|
||||
'ai_chat_enabled' => 'YES',
|
||||
'ai_chat_model' => 'anthropic/claude-3.5-sonnet',
|
||||
]);
|
||||
|
||||
$config = $this->service->getCompanyConfig($this->companyId);
|
||||
|
||||
expect($config['use_custom_ai_config'])->toBe('YES');
|
||||
expect($config['ai_api_key'])->toBe('company-specific-key');
|
||||
expect($config['ai_chat_model'])->toBe('anthropic/claude-3.5-sonnet');
|
||||
});
|
||||
|
||||
test('resolveForCompany returns null when global ai_enabled is NO', function () {
|
||||
$this->service->saveGlobalConfig([
|
||||
'ai_enabled' => 'NO',
|
||||
'ai_driver' => 'openrouter',
|
||||
'ai_api_key' => 'key',
|
||||
]);
|
||||
|
||||
expect($this->service->resolveForCompany($this->companyId))->toBeNull();
|
||||
});
|
||||
|
||||
test('resolveForCompany returns null when company opts out via override', function () {
|
||||
$this->service->saveGlobalConfig([
|
||||
'ai_enabled' => 'YES',
|
||||
'ai_driver' => 'openrouter',
|
||||
'ai_api_key' => 'global-key',
|
||||
'ai_chat_enabled' => 'YES',
|
||||
'ai_chat_model' => 'openai/gpt-4o',
|
||||
]);
|
||||
|
||||
// Company uses custom config but explicitly disables AI
|
||||
$this->service->saveCompanyConfig($this->companyId, [
|
||||
'use_custom_ai_config' => 'YES',
|
||||
'ai_enabled' => 'NO',
|
||||
'ai_driver' => 'openrouter',
|
||||
'ai_api_key' => 'unused',
|
||||
]);
|
||||
|
||||
expect($this->service->resolveForCompany($this->companyId))->toBeNull();
|
||||
});
|
||||
|
||||
test('resolveForCompany returns global config when company has no custom override', function () {
|
||||
$this->service->saveGlobalConfig([
|
||||
'ai_enabled' => 'YES',
|
||||
'ai_driver' => 'openrouter',
|
||||
'ai_api_key' => 'global-key',
|
||||
'ai_base_url' => 'https://global.example.com/v1',
|
||||
'ai_chat_enabled' => 'YES',
|
||||
'ai_chat_model' => 'openai/gpt-4o',
|
||||
]);
|
||||
|
||||
$resolved = $this->service->resolveForCompany($this->companyId);
|
||||
|
||||
expect($resolved)->not->toBeNull();
|
||||
expect($resolved['ai_api_key'])->toBe('global-key');
|
||||
expect($resolved['ai_base_url'])->toBe('https://global.example.com/v1');
|
||||
expect($resolved['chat_enabled'])->toBeTrue();
|
||||
});
|
||||
|
||||
test('resolveForCompany returns company config when use_custom_ai_config is YES', function () {
|
||||
$this->service->saveGlobalConfig([
|
||||
'ai_enabled' => 'YES',
|
||||
'ai_driver' => 'openrouter',
|
||||
'ai_api_key' => 'global-key',
|
||||
'ai_chat_enabled' => 'YES',
|
||||
'ai_chat_model' => 'openai/gpt-4o',
|
||||
]);
|
||||
|
||||
$this->service->saveCompanyConfig($this->companyId, [
|
||||
'use_custom_ai_config' => 'YES',
|
||||
'ai_enabled' => 'YES',
|
||||
'ai_driver' => 'openrouter',
|
||||
'ai_api_key' => 'company-key',
|
||||
'ai_chat_enabled' => 'YES',
|
||||
'ai_chat_model' => 'anthropic/claude-3.5-sonnet',
|
||||
]);
|
||||
|
||||
$resolved = $this->service->resolveForCompany($this->companyId);
|
||||
|
||||
expect($resolved)->not->toBeNull();
|
||||
expect($resolved['ai_api_key'])->toBe('company-key');
|
||||
expect($resolved['ai_chat_model'])->toBe('anthropic/claude-3.5-sonnet');
|
||||
expect($resolved['chat_enabled'])->toBeTrue();
|
||||
});
|
||||
|
||||
test('makeDriver returns null when ai is disabled', function () {
|
||||
$this->service->saveGlobalConfig(['ai_enabled' => 'NO']);
|
||||
|
||||
expect($this->service->makeDriver($this->companyId))->toBeNull();
|
||||
});
|
||||
|
||||
test('makeDriver instantiates a driver from the resolved config', function () {
|
||||
$this->service->saveGlobalConfig([
|
||||
'ai_enabled' => 'YES',
|
||||
'ai_driver' => 'openrouter',
|
||||
'ai_api_key' => 'test-key',
|
||||
]);
|
||||
|
||||
$driver = $this->service->makeDriver($this->companyId);
|
||||
|
||||
expect($driver)->toBeInstanceOf(OpenRouterDriver::class);
|
||||
});
|
||||
|
||||
test('listDrivers returns driver metadata from the Registry', function () {
|
||||
$drivers = $this->service->listDrivers();
|
||||
|
||||
expect($drivers)->not->toBeEmpty();
|
||||
|
||||
$openrouter = collect($drivers)->firstWhere('value', 'openrouter');
|
||||
expect($openrouter)->not->toBeNull();
|
||||
expect($openrouter['label'])->toBe('settings.ai.openrouter');
|
||||
expect($openrouter['supported_roles'])->toContain('chat')->toContain('text_generation');
|
||||
});
|
||||
67
tests/Unit/AiDriverFactoryTest.php
Normal file
67
tests/Unit/AiDriverFactoryTest.php
Normal file
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
use App\Support\Ai\AiChatResponse;
|
||||
use App\Support\Ai\AiDriver;
|
||||
use App\Support\Ai\AiDriverFactory;
|
||||
use App\Support\Ai\OpenRouterDriver;
|
||||
use InvoiceShelf\Modules\Registry;
|
||||
|
||||
test('make resolves the built-in openrouter driver', function () {
|
||||
$driver = AiDriverFactory::make('openrouter', 'fake-key');
|
||||
|
||||
expect($driver)->toBeInstanceOf(OpenRouterDriver::class);
|
||||
});
|
||||
|
||||
test('make resolves Registry-only drivers via metadata', function () {
|
||||
$fakeClass = new class('', []) extends AiDriver
|
||||
{
|
||||
public function chatCompletion(array $messages, string $model, array $tools = [], array $options = []): AiChatResponse
|
||||
{
|
||||
return new AiChatResponse(message: 'test');
|
||||
}
|
||||
|
||||
public function textCompletion(string $prompt, string $model, array $options = []): string
|
||||
{
|
||||
return 'test';
|
||||
}
|
||||
|
||||
public function validateConnection(): array
|
||||
{
|
||||
return ['ok' => true];
|
||||
}
|
||||
};
|
||||
|
||||
Registry::registerAiDriver('registry_only_ai', [
|
||||
'class' => $fakeClass::class,
|
||||
'label' => 'test.ai.label',
|
||||
]);
|
||||
|
||||
try {
|
||||
$driver = AiDriverFactory::make('registry_only_ai', 'fake-key');
|
||||
expect($driver)->toBeInstanceOf(AiDriver::class);
|
||||
} finally {
|
||||
unset(Registry::$drivers['ai']['registry_only_ai']);
|
||||
}
|
||||
});
|
||||
|
||||
test('make throws for unknown drivers', function () {
|
||||
expect(fn () => AiDriverFactory::make('definitely_not_a_real_ai_driver', 'k'))
|
||||
->toThrow(InvalidArgumentException::class);
|
||||
});
|
||||
|
||||
test('availableDrivers merges built-in and Registry-registered drivers', function () {
|
||||
Registry::registerAiDriver('extra_ai_driver', [
|
||||
'class' => OpenRouterDriver::class,
|
||||
'label' => 'extra.label',
|
||||
]);
|
||||
|
||||
try {
|
||||
$available = AiDriverFactory::availableDrivers();
|
||||
|
||||
expect($available)
|
||||
->toContain('openrouter')
|
||||
->toContain('extra_ai_driver');
|
||||
} finally {
|
||||
unset(Registry::$drivers['ai']['extra_ai_driver']);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user