From c7fab5d52f4433b8a0212c8cfbfa102ff3ae47ea Mon Sep 17 00:00:00 2001 From: Darko Gjorgjijoski Date: Sat, 11 Apr 2026 22:00:00 +0200 Subject: [PATCH] =?UTF-8?q?feat(ai):=20Phase=201=20=E2=80=94=20provider=20?= =?UTF-8?q?configuration,=20installer=20step,=20admin=20+=20company=20sett?= =?UTF-8?q?ings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../Settings/AiConfigurationController.php | 132 +++++++ .../Company/General/BootstrapController.php | 8 + .../CompanyAiConfigurationController.php | 112 ++++++ .../Setup/AiConfigurationController.php | 74 ++++ app/Policies/SettingsPolicy.php | 9 + app/Providers/AppServiceProvider.php | 1 + app/Providers/DriverRegistryProvider.php | 29 ++ app/Services/AiConfigurationService.php | 353 ++++++++++++++++++ app/Support/Ai/AiChatResponse.php | 34 ++ app/Support/Ai/AiDriver.php | 85 +++++ app/Support/Ai/AiDriverFactory.php | 86 +++++ app/Support/Ai/AiException.php | 25 ++ app/Support/Ai/OpenRouterDriver.php | 205 ++++++++++ config/invoiceshelf.php | 10 + lang/en.json | 42 ++- resources/scripts/api/endpoints.ts | 12 + resources/scripts/api/services/ai.service.ts | 51 +++ .../scripts/api/services/bootstrap.service.ts | 5 + resources/scripts/features/admin/routes.ts | 9 + .../admin/views/AdminSettingsView.vue | 5 + .../views/settings/AdminAiConfigView.vue | 107 ++++++ .../components/AiConfigurationForm.vue | 316 ++++++++++++++++ .../features/company/settings/routes.ts | 9 + .../company/settings/views/AiConfigView.vue | 165 ++++++++ .../scripts/features/installation/routes.ts | 16 +- .../features/installation/views/AiView.vue | 100 +++++ .../features/installation/views/MailView.vue | 2 +- resources/scripts/types/ai-config.ts | 55 +++ routes/api.php | 18 + tests/Feature/Ai/AiConfigurationTest.php | 146 ++++++++ tests/Unit/AiConfigurationServiceTest.php | 183 +++++++++ tests/Unit/AiDriverFactoryTest.php | 67 ++++ 32 files changed, 2466 insertions(+), 5 deletions(-) create mode 100644 app/Http/Controllers/Admin/Settings/AiConfigurationController.php create mode 100644 app/Http/Controllers/Company/Settings/CompanyAiConfigurationController.php create mode 100644 app/Http/Controllers/Setup/AiConfigurationController.php create mode 100644 app/Services/AiConfigurationService.php create mode 100644 app/Support/Ai/AiChatResponse.php create mode 100644 app/Support/Ai/AiDriver.php create mode 100644 app/Support/Ai/AiDriverFactory.php create mode 100644 app/Support/Ai/AiException.php create mode 100644 app/Support/Ai/OpenRouterDriver.php create mode 100644 resources/scripts/api/services/ai.service.ts create mode 100644 resources/scripts/features/admin/views/settings/AdminAiConfigView.vue create mode 100644 resources/scripts/features/company/settings/components/AiConfigurationForm.vue create mode 100644 resources/scripts/features/company/settings/views/AiConfigView.vue create mode 100644 resources/scripts/features/installation/views/AiView.vue create mode 100644 resources/scripts/types/ai-config.ts create mode 100644 tests/Feature/Ai/AiConfigurationTest.php create mode 100644 tests/Unit/AiConfigurationServiceTest.php create mode 100644 tests/Unit/AiDriverFactoryTest.php diff --git a/app/Http/Controllers/Admin/Settings/AiConfigurationController.php b/app/Http/Controllers/Admin/Settings/AiConfigurationController.php new file mode 100644 index 00000000..c40ed522 --- /dev/null +++ b/app/Http/Controllers/Admin/Settings/AiConfigurationController.php @@ -0,0 +1,132 @@ +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 $config + * @return array + */ + private function maskApiKey(array $config): array + { + if (! empty($config['ai_api_key'])) { + $config['ai_api_key'] = '********'; + } + + return $config; + } +} diff --git a/app/Http/Controllers/Company/General/BootstrapController.php b/app/Http/Controllers/Company/General/BootstrapController.php index fa5bf6cb..7efb685f 100644 --- a/app/Http/Controllers/Company/General/BootstrapController.php +++ b/app/Http/Controllers/Company/General/BootstrapController.php @@ -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'), diff --git a/app/Http/Controllers/Company/Settings/CompanyAiConfigurationController.php b/app/Http/Controllers/Company/Settings/CompanyAiConfigurationController.php new file mode 100644 index 00000000..3f2eb12a --- /dev/null +++ b/app/Http/Controllers/Company/Settings/CompanyAiConfigurationController.php @@ -0,0 +1,112 @@ +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 $config + * @return array + */ + private function maskApiKey(array $config): array + { + if (! empty($config['ai_api_key'])) { + $config['ai_api_key'] = '********'; + } + + return $config; + } +} diff --git a/app/Http/Controllers/Setup/AiConfigurationController.php b/app/Http/Controllers/Setup/AiConfigurationController.php new file mode 100644 index 00000000..f127d712 --- /dev/null +++ b/app/Http/Controllers/Setup/AiConfigurationController.php @@ -0,0 +1,74 @@ +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]); + } +} diff --git a/app/Policies/SettingsPolicy.php b/app/Policies/SettingsPolicy.php index 4115a8ae..069f5132 100644 --- a/app/Policies/SettingsPolicy.php +++ b/app/Policies/SettingsPolicy.php @@ -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()) { diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 0af526e7..9fce9187 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -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']); diff --git a/app/Providers/DriverRegistryProvider.php b/app/Providers/DriverRegistryProvider.php index 521011f8..d504007b 100644 --- a/app/Providers/DriverRegistryProvider.php +++ b/app/Providers/DriverRegistryProvider.php @@ -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', + ], + ], + ]); + } } diff --git a/app/Services/AiConfigurationService.php b/app/Services/AiConfigurationService.php new file mode 100644 index 00000000..2e172a31 --- /dev/null +++ b/app/Services/AiConfigurationService.php @@ -0,0 +1,353 @@ + + */ + 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 + */ + 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 $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 $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|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 + */ + 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> + */ + 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 + */ + 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 $raw + * @return array + */ + 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 $settings + * @return array + */ + 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 $settings + * @return array + */ + 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 $config + * @return array + */ + 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 $payload + * @return array + */ + 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; + } +} diff --git a/app/Support/Ai/AiChatResponse.php b/app/Support/Ai/AiChatResponse.php new file mode 100644 index 00000000..dc8e2d61 --- /dev/null +++ b/app/Support/Ai/AiChatResponse.php @@ -0,0 +1,34 @@ +}> $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 !== []; + } +} diff --git a/app/Support/Ai/AiDriver.php b/app/Support/Ai/AiDriver.php new file mode 100644 index 00000000..9d123929 --- /dev/null +++ b/app/Support/Ai/AiDriver.php @@ -0,0 +1,85 @@ +> $messages OpenAI chat format: [['role' => 'user', 'content' => '...'], ...] + * @param string $model Provider-specific model identifier, e.g. 'openai/gpt-4o' + * @param array> $tools OpenAI tools schema array (empty = no tool calling) + * @param array $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 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 + */ + public function listModels(): array + { + return []; + } +} diff --git a/app/Support/Ai/AiDriverFactory.php b/app/Support/Ai/AiDriverFactory.php new file mode 100644 index 00000000..3d8d575f --- /dev/null +++ b/app/Support/Ai/AiDriverFactory.php @@ -0,0 +1,86 @@ +> + */ + 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 $driverClass + */ + public static function register(string $name, string $driverClass): void + { + static::$drivers[$name] = $driverClass; + } + + /** + * Instantiate a driver by name. + * + * @param array $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 + */ + 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; + } +} diff --git a/app/Support/Ai/AiException.php b/app/Support/Ai/AiException.php new file mode 100644 index 00000000..1c4dab9c --- /dev/null +++ b/app/Support/Ai/AiException.php @@ -0,0 +1,25 @@ + $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|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, '/'); + } +} diff --git a/config/invoiceshelf.php b/config/invoiceshelf.php index fefb159d..944c06d4 100644 --- a/config/invoiceshelf.php +++ b/config/invoiceshelf.php @@ -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' => '', diff --git a/lang/en.json b/lang/en.json index 30ae3fff..9c033452 100644 --- a/lang/en.json +++ b/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." }, diff --git a/resources/scripts/api/endpoints.ts b/resources/scripts/api/endpoints.ts index e64de8e3..59f9af76 100644 --- a/resources/scripts/api/endpoints.ts +++ b/resources/scripts/api/endpoints.ts @@ -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', diff --git a/resources/scripts/api/services/ai.service.ts b/resources/scripts/api/services/ai.service.ts new file mode 100644 index 00000000..f2b94527 --- /dev/null +++ b/resources/scripts/api/services/ai.service.ts @@ -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 { + const { data } = await client.get(API.AI_DRIVERS) + return data + }, + + // --- Global (admin) --- + + async getGlobalConfig(): Promise { + 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 { + const { data } = await client.post(API.AI_TEST, payload) + return data + }, + + // --- Per-company --- + + async getCompanyConfig(): Promise { + 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 { + const { data } = await client.post(API.COMPANY_AI_TEST, payload) + return data + }, +} diff --git a/resources/scripts/api/services/bootstrap.service.ts b/resources/scripts/api/services/bootstrap.service.ts index e11d9268..c739c501 100644 --- a/resources/scripts/api/services/bootstrap.service.ts +++ b/resources/scripts/api/services/bootstrap.service.ts @@ -29,6 +29,11 @@ export interface BootstrapResponse { config: Record global_settings: Record 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<{ diff --git a/resources/scripts/features/admin/routes.ts b/resources/scripts/features/admin/routes.ts index 916648b6..70d80295 100644 --- a/resources/scripts/features/admin/routes.ts +++ b/resources/scripts/features/admin/routes.ts @@ -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', diff --git a/resources/scripts/features/admin/views/AdminSettingsView.vue b/resources/scripts/features/admin/views/AdminSettingsView.vue index 18dd7153..40b88e36 100644 --- a/resources/scripts/features/admin/views/AdminSettingsView.vue +++ b/resources/scripts/features/admin/views/AdminSettingsView.vue @@ -73,6 +73,11 @@ const menuItems = computed(() => [ 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', diff --git a/resources/scripts/features/admin/views/settings/AdminAiConfigView.vue b/resources/scripts/features/admin/views/settings/AdminAiConfigView.vue new file mode 100644 index 00000000..2085a314 --- /dev/null +++ b/resources/scripts/features/admin/views/settings/AdminAiConfigView.vue @@ -0,0 +1,107 @@ + + + diff --git a/resources/scripts/features/company/settings/components/AiConfigurationForm.vue b/resources/scripts/features/company/settings/components/AiConfigurationForm.vue new file mode 100644 index 00000000..7b6df26b --- /dev/null +++ b/resources/scripts/features/company/settings/components/AiConfigurationForm.vue @@ -0,0 +1,316 @@ + + + diff --git a/resources/scripts/features/company/settings/routes.ts b/resources/scripts/features/company/settings/routes.ts index 2f18303e..454e54b5 100644 --- a/resources/scripts/features/company/settings/routes.ts +++ b/resources/scripts/features/company/settings/routes.ts @@ -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', diff --git a/resources/scripts/features/company/settings/views/AiConfigView.vue b/resources/scripts/features/company/settings/views/AiConfigView.vue new file mode 100644 index 00000000..5759cbe2 --- /dev/null +++ b/resources/scripts/features/company/settings/views/AiConfigView.vue @@ -0,0 +1,165 @@ + + + diff --git a/resources/scripts/features/installation/routes.ts b/resources/scripts/features/installation/routes.ts index b1f73e51..bcf85c02 100644 --- a/resources/scripts/features/installation/routes.ts +++ b/resources/scripts/features/installation/routes.ts @@ -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', diff --git a/resources/scripts/features/installation/views/AiView.vue b/resources/scripts/features/installation/views/AiView.vue new file mode 100644 index 00000000..7ce878f0 --- /dev/null +++ b/resources/scripts/features/installation/views/AiView.vue @@ -0,0 +1,100 @@ + + + diff --git a/resources/scripts/features/installation/views/MailView.vue b/resources/scripts/features/installation/views/MailView.vue index 975eee9c..7699d40b 100644 --- a/resources/scripts/features/installation/views/MailView.vue +++ b/resources/scripts/features/installation/views/MailView.vue @@ -51,7 +51,7 @@ async function saveMailConfig(value: MailConfig): Promise { ...value, } - await router.push({ name: 'installation.account' }) + await router.push({ name: 'installation.ai' }) } catch (error: unknown) { showRequestError(error) } finally { diff --git a/resources/scripts/types/ai-config.ts b/resources/scripts/types/ai-config.ts new file mode 100644 index 00000000..bd0bde87 --- /dev/null +++ b/resources/scripts/types/ai-config.ts @@ -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 +} + +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 +} diff --git a/routes/api.php b/routes/api.php index 3c4eaf9b..75a54d8a 100644 --- a/routes/api.php +++ b/routes/api.php @@ -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 // ---------------------------------- diff --git a/tests/Feature/Ai/AiConfigurationTest.php b/tests/Feature/Ai/AiConfigurationTest.php new file mode 100644 index 00000000..4c7d9cbe --- /dev/null +++ b/tests/Feature/Ai/AiConfigurationTest.php @@ -0,0 +1,146 @@ + '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(); +}); diff --git a/tests/Unit/AiConfigurationServiceTest.php b/tests/Unit/AiConfigurationServiceTest.php new file mode 100644 index 00000000..a748bb74 --- /dev/null +++ b/tests/Unit/AiConfigurationServiceTest.php @@ -0,0 +1,183 @@ + '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'); +}); diff --git a/tests/Unit/AiDriverFactoryTest.php b/tests/Unit/AiDriverFactoryTest.php new file mode 100644 index 00000000..ca1bc886 --- /dev/null +++ b/tests/Unit/AiDriverFactoryTest.php @@ -0,0 +1,67 @@ +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']); + } +});