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:
Darko Gjorgjijoski
2026-04-11 22:00:00 +02:00
parent 47907f9bf3
commit c7fab5d52f
32 changed files with 2466 additions and 5 deletions

View File

@@ -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;
}
}

View File

@@ -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'),

View File

@@ -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;
}
}

View 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]);
}
}

View File

@@ -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()) {

View File

@@ -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']);

View File

@@ -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',
],
],
]);
}
}

View 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;
}
}

View 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 !== [];
}
}

View 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 [];
}
}

View 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;
}
}

View 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);
}
}

View 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, '/');
}
}

View File

@@ -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' => '',

View File

@@ -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."
},

View File

@@ -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',

View 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
},
}

View File

@@ -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<{

View File

@@ -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',

View File

@@ -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',

View File

@@ -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>

View File

@@ -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>

View File

@@ -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',

View File

@@ -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>

View File

@@ -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',

View 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>

View File

@@ -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 {

View 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>
}

View File

@@ -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
// ----------------------------------

View 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();
});

View 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');
});

View 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']);
}
});