From 0b9ae9ea009ae8fcb99b669f8defb996960639b1 Mon Sep 17 00:00:00 2001 From: Darko Gjorgjijoski <5760249+gdarko@users.noreply.github.com> Date: Wed, 5 Aug 2026 22:10:21 +0200 Subject: [PATCH] refactor(ai): extract assistant into an official module (#749) * feat(modules): expose host data boundaries * feat(modules): add frontend extension surfaces * refactor(ai): remove assistant from core * chore(ai): prepare the module extraction * fix(modules): load extension styles after the host bundle * chore(modules): lock SDK 3.3.0 --- app/Platform/Ai/AiServiceProvider.php | 107 -- .../Ai/Application/AiAssistantService.php | 324 ---- .../Ai/Application/AiConfigurationService.php | 354 ----- .../Application/AiTextGenerationService.php | 89 -- .../Ai/Application/AiToolRegistry.php | 150 -- app/Platform/Ai/Application/Tools/AiTool.php | 129 -- .../Tools/Concerns/ResolvesPeriod.php | 65 - .../Application/Tools/GetCompanyStatsTool.php | 120 -- .../Ai/Application/Tools/GetCustomerTool.php | 85 - .../Ai/Application/Tools/GetInvoiceTool.php | 90 -- .../Tools/ListExpenseCategoriesTool.php | 50 - .../Tools/ListOverdueInvoicesTool.php | 59 - .../Tools/ListRecentPaymentsTool.php | 89 -- .../Tools/RankExpenseCategoriesTool.php | 112 -- .../Tools/RankTopCustomersTool.php | 234 --- .../Ai/Application/Tools/RankTopItemsTool.php | 128 -- .../Application/Tools/SearchCustomersTool.php | 78 - .../Application/Tools/SearchInvoicesTool.php | 113 -- .../Ai/Application/Tools/SearchItemsTool.php | 73 - app/Platform/Ai/Contracts/AiDriver.php | 88 -- app/Platform/Ai/Data/AiChatResponse.php | 34 - app/Platform/Ai/Drivers/AiDriverFactory.php | 87 -- app/Platform/Ai/Drivers/OpenRouterDriver.php | 234 --- app/Platform/Ai/Exceptions/AiException.php | 25 - .../Http/Admin/AiConfigurationController.php | 133 -- .../Ai/Http/Company/ChatController.php | 98 -- .../CompanyAiConfigurationController.php | 113 -- .../Http/Company/ConversationController.php | 103 -- .../Ai/Http/Company/GenerationController.php | 53 - .../Http/Setup/AiConfigurationController.php | 75 - app/Platform/Ai/Models/AiConversation.php | 41 - app/Platform/Ai/Models/AiMessage.php | 53 - app/Platform/Ai/Policies/AiAccessPolicy.php | 21 - .../Ai/Policies/AiConversationPolicy.php | 38 - app/Platform/Ai/Prompting/PromptLoader.php | 55 - app/Platform/Ai/routes/company.php | 26 - app/Platform/Ai/routes/installer.php | 7 - .../BouncerModuleAuthorization.php | 48 + .../EloquentCompanyDataReader.php | 297 ++++ .../EloquentHostSettingsStore.php | 52 + .../Modules/ModuleServiceProvider.php | 9 + .../Http/Company/BootstrapController.php | 8 - app/Platform/Persistence/ModelIdentityMap.php | 4 - bootstrap/providers.php | 2 - composer.json | 2 +- composer.lock | 14 +- config/invoiceshelf.php | 12 +- ...e_ai_conversations_and_messages_tables.php | 59 - ...05_120000_stabilize_model_type_aliases.php | 2 - database/seeders/RealisticDemoSeeder.php | 10 +- lang/en.json | 71 +- package.json | 1 - pnpm-lock.yaml | 10 - public/openapi.json | 1361 ----------------- resources/scripts/InvoiceShelf.ts | 34 +- resources/scripts/api/endpoints.ts | 19 - resources/scripts/api/services/ai.service.ts | 96 -- .../scripts/api/services/bootstrap.service.ts | 5 - .../scripts/components/editor/RichEditor.vue | 49 +- .../scripts/extensions/ExtensionSlot.vue | 35 + resources/scripts/extensions/runtime.ts | 265 ++++ resources/scripts/extensions/types.ts | 12 + resources/scripts/features/admin/routes.ts | 9 - .../admin/views/AdminSettingsView.vue | 11 +- .../views/settings/AdminAiConfigView.vue | 107 -- .../ai/components/AiChatConversationList.vue | 79 - .../company/ai/components/AiChatDrawer.vue | 140 -- .../company/ai/components/AiChatMessage.vue | 42 - .../ai/components/AiChatMessageInput.vue | 62 - .../company/ai/stores/ai-chat.store.ts | 149 -- .../components/AiConfigurationForm.vue | 316 ---- .../features/company/settings/routes.ts | 10 +- .../company/settings/views/AiConfigView.vue | 165 -- .../settings/views/SettingsLayoutView.vue | 19 +- .../scripts/features/installation/routes.ts | 16 +- .../features/installation/views/AiView.vue | 100 -- .../features/installation/views/MailView.vue | 2 +- .../shared/ai/AiTextGenerationModal.vue | 194 --- resources/scripts/layouts/CompanyLayout.vue | 9 +- .../scripts/layouts/partials/SiteHeader.vue | 25 +- resources/scripts/plugins/i18n.ts | 68 +- resources/scripts/stores/company.store.ts | 21 + resources/scripts/stores/global.store.ts | 20 +- resources/scripts/types/ai-config.ts | 96 -- resources/scripts/utils/markdown.ts | 41 - resources/views/app.blade.php | 6 +- routes/api.php | 3 - tests/Feature/Ai/AiBaseUrlSsrfTest.php | 63 - tests/Feature/Ai/AiChatFlowTest.php | 231 --- tests/Feature/Ai/AiConfigurationTest.php | 146 -- tests/Feature/Ai/AiGenerationTest.php | 144 -- tests/Feature/Ai/AiToolAuthorizationTest.php | 174 --- .../Ai/Tools/ListRecentPaymentsToolTest.php | 50 - .../Tools/RankExpenseCategoriesToolTest.php | 111 -- .../Ai/Tools/RankTopCustomersToolTest.php | 183 --- .../Feature/Ai/Tools/RankTopItemsToolTest.php | 193 --- .../Ai/Tools/SearchInvoicesToolTest.php | 93 -- .../Architecture/AiPlatformBoundaryTest.php | 56 - .../OperationsPlatformBoundaryTest.php | 6 +- .../Company/Modules/ModuleMakeStubTest.php | 2 +- .../BouncerModuleAuthorizationTest.php | 54 + .../Modules/EloquentCompanyDataReaderTest.php | 91 ++ .../Modules/EloquentHostSettingsStoreTest.php | 71 + .../Feature/Modules/ModuleAssetCacheTest.php | 7 +- tests/Support/ScriptedAiDriver.php | 74 - tests/Unit/AiConfigurationServiceTest.php | 183 --- tests/Unit/AiDriverFactoryTest.php | 67 - tests/Unit/AiToolRegistryTest.php | 97 -- tests/Unit/MarketplaceConfigurationTest.php | 11 +- tests/Unit/OpenRouterDriverTest.php | 26 - tests/Unit/PromptLoaderTest.php | 36 - 111 files changed, 1137 insertions(+), 8952 deletions(-) delete mode 100644 app/Platform/Ai/AiServiceProvider.php delete mode 100644 app/Platform/Ai/Application/AiAssistantService.php delete mode 100644 app/Platform/Ai/Application/AiConfigurationService.php delete mode 100644 app/Platform/Ai/Application/AiTextGenerationService.php delete mode 100644 app/Platform/Ai/Application/AiToolRegistry.php delete mode 100644 app/Platform/Ai/Application/Tools/AiTool.php delete mode 100644 app/Platform/Ai/Application/Tools/Concerns/ResolvesPeriod.php delete mode 100644 app/Platform/Ai/Application/Tools/GetCompanyStatsTool.php delete mode 100644 app/Platform/Ai/Application/Tools/GetCustomerTool.php delete mode 100644 app/Platform/Ai/Application/Tools/GetInvoiceTool.php delete mode 100644 app/Platform/Ai/Application/Tools/ListExpenseCategoriesTool.php delete mode 100644 app/Platform/Ai/Application/Tools/ListOverdueInvoicesTool.php delete mode 100644 app/Platform/Ai/Application/Tools/ListRecentPaymentsTool.php delete mode 100644 app/Platform/Ai/Application/Tools/RankExpenseCategoriesTool.php delete mode 100644 app/Platform/Ai/Application/Tools/RankTopCustomersTool.php delete mode 100644 app/Platform/Ai/Application/Tools/RankTopItemsTool.php delete mode 100644 app/Platform/Ai/Application/Tools/SearchCustomersTool.php delete mode 100644 app/Platform/Ai/Application/Tools/SearchInvoicesTool.php delete mode 100644 app/Platform/Ai/Application/Tools/SearchItemsTool.php delete mode 100644 app/Platform/Ai/Contracts/AiDriver.php delete mode 100644 app/Platform/Ai/Data/AiChatResponse.php delete mode 100644 app/Platform/Ai/Drivers/AiDriverFactory.php delete mode 100644 app/Platform/Ai/Drivers/OpenRouterDriver.php delete mode 100644 app/Platform/Ai/Exceptions/AiException.php delete mode 100644 app/Platform/Ai/Http/Admin/AiConfigurationController.php delete mode 100644 app/Platform/Ai/Http/Company/ChatController.php delete mode 100644 app/Platform/Ai/Http/Company/CompanyAiConfigurationController.php delete mode 100644 app/Platform/Ai/Http/Company/ConversationController.php delete mode 100644 app/Platform/Ai/Http/Company/GenerationController.php delete mode 100644 app/Platform/Ai/Http/Setup/AiConfigurationController.php delete mode 100644 app/Platform/Ai/Models/AiConversation.php delete mode 100644 app/Platform/Ai/Models/AiMessage.php delete mode 100644 app/Platform/Ai/Policies/AiAccessPolicy.php delete mode 100644 app/Platform/Ai/Policies/AiConversationPolicy.php delete mode 100644 app/Platform/Ai/Prompting/PromptLoader.php delete mode 100644 app/Platform/Ai/routes/company.php delete mode 100644 app/Platform/Ai/routes/installer.php create mode 100644 app/Platform/Modules/Infrastructure/BouncerModuleAuthorization.php create mode 100644 app/Platform/Modules/Infrastructure/EloquentCompanyDataReader.php create mode 100644 app/Platform/Modules/Infrastructure/EloquentHostSettingsStore.php delete mode 100644 database/migrations/2026_04_11_154445_create_ai_conversations_and_messages_tables.php delete mode 100644 resources/scripts/api/services/ai.service.ts create mode 100644 resources/scripts/extensions/ExtensionSlot.vue create mode 100644 resources/scripts/extensions/runtime.ts create mode 100644 resources/scripts/extensions/types.ts delete mode 100644 resources/scripts/features/admin/views/settings/AdminAiConfigView.vue delete mode 100644 resources/scripts/features/company/ai/components/AiChatConversationList.vue delete mode 100644 resources/scripts/features/company/ai/components/AiChatDrawer.vue delete mode 100644 resources/scripts/features/company/ai/components/AiChatMessage.vue delete mode 100644 resources/scripts/features/company/ai/components/AiChatMessageInput.vue delete mode 100644 resources/scripts/features/company/ai/stores/ai-chat.store.ts delete mode 100644 resources/scripts/features/company/settings/components/AiConfigurationForm.vue delete mode 100644 resources/scripts/features/company/settings/views/AiConfigView.vue delete mode 100644 resources/scripts/features/installation/views/AiView.vue delete mode 100644 resources/scripts/features/shared/ai/AiTextGenerationModal.vue delete mode 100644 resources/scripts/types/ai-config.ts delete mode 100644 tests/Feature/Ai/AiBaseUrlSsrfTest.php delete mode 100644 tests/Feature/Ai/AiChatFlowTest.php delete mode 100644 tests/Feature/Ai/AiConfigurationTest.php delete mode 100644 tests/Feature/Ai/AiGenerationTest.php delete mode 100644 tests/Feature/Ai/AiToolAuthorizationTest.php delete mode 100644 tests/Feature/Ai/Tools/ListRecentPaymentsToolTest.php delete mode 100644 tests/Feature/Ai/Tools/RankExpenseCategoriesToolTest.php delete mode 100644 tests/Feature/Ai/Tools/RankTopCustomersToolTest.php delete mode 100644 tests/Feature/Ai/Tools/RankTopItemsToolTest.php delete mode 100644 tests/Feature/Ai/Tools/SearchInvoicesToolTest.php delete mode 100644 tests/Feature/Architecture/AiPlatformBoundaryTest.php create mode 100644 tests/Feature/Modules/BouncerModuleAuthorizationTest.php create mode 100644 tests/Feature/Modules/EloquentCompanyDataReaderTest.php create mode 100644 tests/Feature/Modules/EloquentHostSettingsStoreTest.php delete mode 100644 tests/Support/ScriptedAiDriver.php delete mode 100644 tests/Unit/AiConfigurationServiceTest.php delete mode 100644 tests/Unit/AiDriverFactoryTest.php delete mode 100644 tests/Unit/AiToolRegistryTest.php delete mode 100644 tests/Unit/OpenRouterDriverTest.php delete mode 100644 tests/Unit/PromptLoaderTest.php diff --git a/app/Platform/Ai/AiServiceProvider.php b/app/Platform/Ai/AiServiceProvider.php deleted file mode 100644 index fb46ef5d..00000000 --- a/app/Platform/Ai/AiServiceProvider.php +++ /dev/null @@ -1,107 +0,0 @@ -user(); - $companyId = $request->header('company') ?? 'noop'; - $key = $user ? "{$user->id}:{$companyId}" : $request->ip(); - - return Limit::perMinute(30)->by($key); - }); - - 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' => 'anthropic/claude-sonnet-4.6', 'label' => 'Anthropic Claude Sonnet 4.6'], - ['value' => 'anthropic/claude-haiku-4.5', 'label' => 'Anthropic Claude Haiku 4.5'], - ['value' => 'anthropic/claude-opus-4.6', 'label' => 'Anthropic Claude Opus 4.6'], - ['value' => 'openai/gpt-5.4', 'label' => 'OpenAI GPT-5.4'], - ['value' => 'openai/gpt-5.4-mini', 'label' => 'OpenAI GPT-5.4 mini'], - ['value' => 'google/gemini-3.1-pro-preview', 'label' => 'Google Gemini 3.1 Pro (preview)'], - ['value' => 'google/gemini-3.1-flash-lite-preview', 'label' => 'Google Gemini 3.1 Flash Lite (preview)'], - ['value' => 'z-ai/glm-5.1', 'label' => 'Z.AI GLM 5.1'], - ['value' => 'z-ai/glm-4.7-flash', 'label' => 'Z.AI GLM 4.7 Flash'], - ], - 'config_fields' => [ - [ - 'key' => 'base_url', - 'type' => 'text', - 'label' => 'settings.ai.base_url', - 'default' => 'https://openrouter.ai/api/v1', - ], - ], - ]); - } - - public function register(): void - { - $this->app->singleton(AiToolRegistry::class, function (Application $app): AiToolRegistry { - $registry = new AiToolRegistry; - - // Built-in read-only tools (order is presentation-only; the LLM picks). - $registry->register(new SearchInvoicesTool); - $registry->register(new GetInvoiceTool); - $registry->register(new ListOverdueInvoicesTool); - $registry->register(new SearchCustomersTool); - $registry->register(new GetCustomerTool); - $registry->register(new ListRecentPaymentsTool); - $registry->register(new SearchItemsTool); - $registry->register(new ListExpenseCategoriesTool); - $registry->register(new GetCompanyStatsTool); - - // Ranking tools — group-by aggregates the individual-record - // tools above can't express. - $registry->register(new RankTopCustomersTool); - $registry->register(new RankTopItemsTool); - $registry->register(new RankExpenseCategoriesTool); - - return $registry; - }); - } -} diff --git a/app/Platform/Ai/Application/AiAssistantService.php b/app/Platform/Ai/Application/AiAssistantService.php deleted file mode 100644 index ac4b0e66..00000000 --- a/app/Platform/Ai/Application/AiAssistantService.php +++ /dev/null @@ -1,324 +0,0 @@ - $companyId, - 'user_id' => $userId, - 'title' => $firstMessage !== null ? $this->titleFromMessage($firstMessage) : null, - ]); - } - - /** - * Process one user message within an existing conversation. - * - * Returns the final assistant AiMessage that should be shown to the user. - * - * @throws AiException When AI is disabled or the driver call fails unrecoverably. - */ - public function chat(AiConversation $conversation, string $userMessage): AiMessage - { - $driver = $this->aiConfiguration->makeDriver($conversation->company_id); - - if ($driver === null) { - throw new AiException('AI is not enabled for this company', 'ai_disabled'); - } - - $resolved = $this->aiConfiguration->resolveForCompany($conversation->company_id); - if (empty($resolved['chat_enabled'])) { - throw new AiException('Chat is not enabled for this company', 'chat_disabled'); - } - - $model = (string) ($resolved['ai_chat_model'] ?? ''); - if ($model === '') { - throw new AiException('No chat model configured', 'missing_model'); - } - - // Auto-title on first message. - if ($conversation->title === null) { - $conversation->title = $this->titleFromMessage($userMessage); - $conversation->model = $model; - } - - // Persist the user's message first so it shows even if the LLM call fails. - $userRow = AiMessage::create([ - 'conversation_id' => $conversation->id, - 'role' => AiMessage::ROLE_USER, - 'content' => $userMessage, - ]); - - $conversation->touch(); // bump updated_at for "recent" ordering - - $messages = $this->buildMessagesPayload($conversation); - $tools = $this->toolRegistry->schemas($conversation->user_id); - - for ($iteration = 0; $iteration < self::MAX_TOOL_ITERATIONS; $iteration++) { - try { - $response = $driver->chatCompletion($messages, $model, $tools); - } catch (AiException $e) { - // Persist the error as an assistant message so the UI can render it. - return AiMessage::create([ - 'conversation_id' => $conversation->id, - 'role' => AiMessage::ROLE_ASSISTANT, - 'content' => "Error: {$e->getMessage()}", - 'model' => $model, - ]); - } - - // Tool calls requested → execute each, append their results, loop. - if ($response->hasToolCalls()) { - $this->persistAssistantToolCallTurn($conversation, $response, $model); - $messages[] = $this->assistantToolCallMessage($response); - - foreach ($response->toolCalls as $call) { - $toolResult = $this->safelyExecuteTool( - name: $call['name'], - arguments: $call['arguments'] ?? [], - companyId: $conversation->company_id, - userId: $conversation->user_id, - ); - - $resultJson = json_encode($toolResult, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); - - AiMessage::create([ - 'conversation_id' => $conversation->id, - 'role' => AiMessage::ROLE_TOOL, - 'content' => $resultJson, - 'tool_call_id' => $call['id'] ?? null, - ]); - - $messages[] = [ - 'role' => 'tool', - 'tool_call_id' => $call['id'] ?? '', - 'content' => $resultJson, - ]; - } - - // Loop — the LLM will receive the tool results on the next iteration. - continue; - } - - // Plain text response — persist and return. - return AiMessage::create([ - 'conversation_id' => $conversation->id, - 'role' => AiMessage::ROLE_ASSISTANT, - 'content' => $response->message ?? '', - 'model' => $model, - 'tokens_in' => $response->usage['tokens_in'] ?? null, - 'tokens_out' => $response->usage['tokens_out'] ?? null, - ]); - } - - // Exceeded the loop cap. - return AiMessage::create([ - 'conversation_id' => $conversation->id, - 'role' => AiMessage::ROLE_ASSISTANT, - 'content' => 'The assistant could not complete this request within the tool-call budget. Please try rephrasing.', - 'model' => $model, - ]); - } - - /** - * Build the OpenAI-format messages payload for the next LLM call. - * - * Starts with a fresh system prompt, then the recent message history - * trimmed to HISTORY_WINDOW items, then the message(s) that will drive - * the upcoming round-trip (already persisted by chat()). - * - * @return array> - */ - protected function buildMessagesPayload(AiConversation $conversation): array - { - $payload = [ - [ - 'role' => 'system', - 'content' => $this->buildSystemPrompt($conversation), - ], - ]; - - $history = AiMessage::query() - ->where('conversation_id', $conversation->id) - ->orderByDesc('created_at') - ->limit(self::HISTORY_WINDOW) - ->get() - ->reverse() - ->values(); - - foreach ($history as $msg) { - $payload[] = $this->formatMessageForDriver($msg); - } - - return $payload; - } - - /** - * @return array - */ - protected function formatMessageForDriver(AiMessage $msg): array - { - $base = ['role' => $msg->role]; - - if ($msg->role === AiMessage::ROLE_TOOL) { - $base['tool_call_id'] = $msg->tool_call_id ?? ''; - $base['content'] = $msg->content ?? ''; - - return $base; - } - - if ($msg->role === AiMessage::ROLE_ASSISTANT && $msg->tool_calls) { - $base['content'] = $msg->content; - $base['tool_calls'] = array_map(function (array $call): array { - return [ - 'id' => $call['id'] ?? '', - 'type' => 'function', - 'function' => [ - 'name' => $call['name'] ?? '', - 'arguments' => json_encode($call['arguments'] ?? [], JSON_UNESCAPED_UNICODE), - ], - ]; - }, $msg->tool_calls); - - return $base; - } - - $base['content'] = $msg->content ?? ''; - - return $base; - } - - /** - * Compose the system prompt with company context. - * - * The template itself lives at resources/ai/prompts/chat-system.md so - * it can be edited without touching this class. See PromptLoader for - * the substitution rules. - */ - protected function buildSystemPrompt(AiConversation $conversation): string - { - $company = Company::find($conversation->company_id); - $user = User::find($conversation->user_id); - - return PromptLoader::load('chat-system', [ - 'user_name' => $user?->name ?? 'the user', - 'company_name' => $company?->name ?? 'this company', - 'today' => Carbon::now()->toDateString(), - ]); - } - - /** - * Persist the assistant's tool_calls-turn message (the one that requested tools). - */ - protected function persistAssistantToolCallTurn( - AiConversation $conversation, - AiChatResponse $response, - string $model, - ): void { - AiMessage::create([ - 'conversation_id' => $conversation->id, - 'role' => AiMessage::ROLE_ASSISTANT, - 'content' => $response->message, // usually null on a tool_calls turn - 'tool_calls' => $response->toolCalls, - 'model' => $model, - 'tokens_in' => $response->usage['tokens_in'] ?? null, - 'tokens_out' => $response->usage['tokens_out'] ?? null, - ]); - } - - /** - * Format the assistant tool-call turn for the NEXT driver call, in OpenAI format. - * - * @return array - */ - protected function assistantToolCallMessage(AiChatResponse $response): array - { - return [ - 'role' => 'assistant', - 'content' => $response->message, - 'tool_calls' => array_map(function (array $call): array { - return [ - 'id' => $call['id'] ?? '', - 'type' => 'function', - 'function' => [ - 'name' => $call['name'] ?? '', - 'arguments' => json_encode($call['arguments'] ?? [], JSON_UNESCAPED_UNICODE), - ], - ]; - }, $response->toolCalls), - ]; - } - - /** - * Call a tool and trap any exception — we want tool failures to be visible - * to the LLM as structured errors, not to crash the whole turn. - * - * @param array $arguments - */ - protected function safelyExecuteTool( - string $name, - array $arguments, - int $companyId, - int $userId, - ): mixed { - try { - return $this->toolRegistry->execute($name, $arguments, $companyId, $userId); - } catch (InvalidArgumentException $e) { - return ['error' => 'unknown_tool', 'message' => $e->getMessage()]; - } catch (Throwable $e) { - return ['error' => 'tool_execution_failed', 'message' => $e->getMessage()]; - } - } - - /** - * Derive a short conversation title from the first user message. - */ - protected function titleFromMessage(string $message): string - { - $trimmed = trim(preg_replace('/\s+/', ' ', $message) ?? ''); - if (mb_strlen($trimmed) <= 60) { - return $trimmed; - } - - return rtrim(mb_substr($trimmed, 0, 57)).'...'; - } -} diff --git a/app/Platform/Ai/Application/AiConfigurationService.php b/app/Platform/Ai/Application/AiConfigurationService.php deleted file mode 100644 index b5028943..00000000 --- a/app/Platform/Ai/Application/AiConfigurationService.php +++ /dev/null @@ -1,354 +0,0 @@ - - */ - 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', new PublicHttpUrl], - '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' => 'anthropic/claude-sonnet-4.6', - 'ai_text_generation_enabled' => 'NO', - 'ai_text_generation_model' => 'anthropic/claude-haiku-4.5', - ], $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/Platform/Ai/Application/AiTextGenerationService.php b/app/Platform/Ai/Application/AiTextGenerationService.php deleted file mode 100644 index feb710dc..00000000 --- a/app/Platform/Ai/Application/AiTextGenerationService.php +++ /dev/null @@ -1,89 +0,0 @@ -aiConfiguration->makeDriver($companyId); - - if ($driver === null) { - throw new AiException('AI is not enabled for this company', 'ai_disabled'); - } - - $resolved = $this->aiConfiguration->resolveForCompany($companyId); - if (empty($resolved['text_generation_enabled'])) { - throw new AiException('Text generation is not enabled for this company', 'text_generation_disabled'); - } - - $model = (string) ($resolved['ai_text_generation_model'] ?? ''); - if ($model === '') { - throw new AiException('No text generation model configured', 'missing_model'); - } - - $fullPrompt = $this->buildPrompt($prompt, $context); - - return trim($driver->textCompletion($fullPrompt, $model)); - } - - /** - * Compose the final prompt sent to the model. - * - * Keep the framing terse — text-generation output should not be padded - * with "Here is the text you requested:" preambles. The instruction is - * always placed last so the model gives it the most weight. - * - * The static preamble lives at resources/ai/prompts/text-generation.md. - * The conditional context + instruction appending stays here because it - * has different structure depending on whether `context` is present. - */ - protected function buildPrompt(string $prompt, ?string $context): string - { - $system = PromptLoader::load('text-generation'); - - if ($context !== null && trim($context) !== '') { - return $system."\n\n" - ."Context (current content the user is working with):\n" - .trim($context) - ."\n\n" - ."Instruction: {$prompt}"; - } - - return $system."\n\nInstruction: {$prompt}"; - } -} diff --git a/app/Platform/Ai/Application/AiToolRegistry.php b/app/Platform/Ai/Application/AiToolRegistry.php deleted file mode 100644 index 1b60629b..00000000 --- a/app/Platform/Ai/Application/AiToolRegistry.php +++ /dev/null @@ -1,150 +0,0 @@ -app->resolving(AiToolRegistry::class, function (AiToolRegistry $registry) { - * $registry->register(new MyCustomTool); - * }); - */ -class AiToolRegistry -{ - /** - * @var array - */ - protected array $tools = []; - - /** - * Per-user memo so a single turn doesn't re-query the user for every tool. - * - * @var array - */ - protected array $userCache = []; - - public function register(AiTool $tool): void - { - $this->tools[$tool->name()] = $tool; - } - - /** - * @return array - */ - public function all(): array - { - return $this->tools; - } - - public function get(string $name): ?AiTool - { - return $this->tools[$name] ?? null; - } - - /** - * Export the tools the given user is authorized to use as the `tools` array - * for an OpenAI-style chat request. - * - * Tools the user lacks the required ability for are omitted entirely, so the - * LLM is never even told they exist. `execute()` re-checks as a backstop. - * - * @return array> - */ - public function schemas(int $userId): array - { - $authorized = array_filter( - $this->tools, - fn (AiTool $tool): bool => $this->userCan($tool, $userId), - ); - - return array_values(array_map( - fn (AiTool $tool): array => $tool->toOpenAiToolSchema(), - $authorized, - )); - } - - /** - * Execute a tool by name, injecting company + user scope from the caller's session. - * - * The AiAssistantService is the only place this should be called from — that's - * how we guarantee the `$companyId` and `$userId` arguments are session-authoritative - * and never influenced by LLM output. - * - * Authorization backstop: even though `schemas()` already hides tools the user - * can't use, we re-check the required ability here so a model that hallucinates - * an unauthorized tool name gets a structured error instead of data. - * - * @param array $arguments - * - * @throws InvalidArgumentException When the tool name is not registered. - */ - public function execute(string $name, array $arguments, int $companyId, int $userId): mixed - { - $tool = $this->get($name); - - if ($tool === null) { - throw new InvalidArgumentException("Unknown AI tool: {$name}"); - } - - if (! $this->userCan($tool, $userId)) { - return [ - 'error' => 'unauthorized', - 'message' => 'You do not have permission to access this data.', - ]; - } - - return $tool->execute($arguments, $companyId, $userId); - } - - /** - * Whether the user holds the Bouncer ability a tool requires. - * - * The ability is evaluated under the ambient Bouncer scope, which the - * `company` + `bouncer` (ScopeBouncer) middleware set to the active company - * on every AI request — the same way the app's policies check abilities. The - * resolved user is always the conversation owner (ChatController binds it to - * the request user), so this never trusts an identifier from LLM output. - */ - protected function userCan(AiTool $tool, int $userId): bool - { - $required = $tool->requiredAbility(); - - if ($required === null) { - return true; - } - - [$ability, $model] = $required; - - $user = $this->userCache[$userId] ??= User::find($userId); - - if ($user === null) { - return false; - } - - return $model === null - ? $user->can($ability) - : $user->can($ability, $model); - } - - /** - * Test-only: reset the registry between tests that exercise different tool sets. - */ - public function flush(): void - { - $this->tools = []; - $this->userCache = []; - } -} diff --git a/app/Platform/Ai/Application/Tools/AiTool.php b/app/Platform/Ai/Application/Tools/AiTool.php deleted file mode 100644 index 2ebe2f9f..00000000 --- a/app/Platform/Ai/Application/Tools/AiTool.php +++ /dev/null @@ -1,129 +0,0 @@ - 'object', 'properties' => (object) []]`. - * - * Critical: do NOT include `company_id` or `user_id` in the schema. - * Scoping is the host's responsibility and is injected at execute time. - * - * @return array - */ - abstract public function parameterSchema(): array; - - /** - * Run the tool with the given arguments, scoped to the caller's session. - * - * Implementations MUST query within the given `$companyId` and never trust - * any company/user identifier the LLM tries to sneak into `$arguments`. - * - * @param array $arguments Parsed from the LLM's tool_call JSON - * @param int $companyId Injected from the current session — authoritative - * @param int $userId Injected from the current session - * @return mixed Anything JSON-encodable; will be serialized and sent back to the LLM - */ - abstract public function execute(array $arguments, int $companyId, int $userId): mixed; - - /** - * The Bouncer ability a caller must hold to use this tool, as a - * `[ability, modelClass]` pair — or null if no ability beyond `use ai`. - * - * The registry checks this against the session user before exposing the tool - * to the LLM and again before executing it, so the assistant honours the same - * per-user permissions as the rest of the app. The model element may be null - * for gate-style abilities that take no model (e.g. `dashboard`). - * - * @return array{0: string, 1: class-string|null}|null - */ - abstract public function requiredAbility(): ?array; - - /** - * Convert this tool into an OpenAI-style tools array entry. - * - * @return array - */ - public function toOpenAiToolSchema(): array - { - return [ - 'type' => 'function', - 'function' => [ - 'name' => $this->name(), - 'description' => $this->description(), - 'parameters' => $this->parameterSchema(), - ], - ]; - } - - /** - * Normalize a model date field (which may be a string OR a Carbon instance - * depending on model casts) to a YYYY-MM-DD string for tool output. - * - * Many InvoiceShelf models store dates as raw strings; others cast to Carbon. - * Centralizing this avoids cast-guessing in every tool. - */ - protected function asDate(mixed $value): ?string - { - if ($value === null || $value === '') { - return null; - } - - if (is_object($value) && method_exists($value, 'toDateString')) { - return $value->toDateString(); - } - - // String like '2026-04-11' or '2026-04-11 00:00:00' — keep the date part only. - if (is_string($value)) { - return substr($value, 0, 10); - } - - return null; - } -} diff --git a/app/Platform/Ai/Application/Tools/Concerns/ResolvesPeriod.php b/app/Platform/Ai/Application/Tools/Concerns/ResolvesPeriod.php deleted file mode 100644 index fe5074a5..00000000 --- a/app/Platform/Ai/Application/Tools/Concerns/ResolvesPeriod.php +++ /dev/null @@ -1,65 +0,0 @@ - null, - 'today' => [$now->copy()->startOfDay(), $now->copy()->endOfDay()], - 'this_week' => [$now->copy()->startOfWeek(), $now->copy()->endOfWeek()], - 'this_month' => [$now->copy()->startOfMonth(), $now->copy()->endOfMonth()], - 'last_month' => [ - $now->copy()->subMonthNoOverflow()->startOfMonth(), - $now->copy()->subMonthNoOverflow()->endOfMonth(), - ], - 'this_quarter' => [$now->copy()->startOfQuarter(), $now->copy()->endOfQuarter()], - 'this_year' => [$now->copy()->startOfYear(), $now->copy()->endOfYear()], - 'last_year' => [ - $now->copy()->subYearNoOverflow()->startOfYear(), - $now->copy()->subYearNoOverflow()->endOfYear(), - ], - default => null, - }; - } -} diff --git a/app/Platform/Ai/Application/Tools/GetCompanyStatsTool.php b/app/Platform/Ai/Application/Tools/GetCompanyStatsTool.php deleted file mode 100644 index eafa0a90..00000000 --- a/app/Platform/Ai/Application/Tools/GetCompanyStatsTool.php +++ /dev/null @@ -1,120 +0,0 @@ -". - * Much cheaper than fetching full invoice lists and summing client-side. - */ -class GetCompanyStatsTool extends AiTool -{ - use ResolvesPeriod; - - /** - * Bounded periods only — stats over `all_time` is almost always useless - * (collapses every record into one giant bucket), so we don't offer it - * here. The ranking tools do expose `all_time` because ranking by totals - * across the full history is a meaningful question. - */ - private const PERIODS = [ - 'today', - 'this_week', - 'this_month', - 'last_month', - 'this_quarter', - 'this_year', - 'last_year', - ]; - - public function name(): string - { - return 'get_company_stats'; - } - - public function description(): string - { - return "Aggregate stats for the current company over a named time period: invoice count and total, payment count and total, expense count and total. Use this for 'how much did we earn/spend' questions."; - } - - public function parameterSchema(): array - { - return [ - 'type' => 'object', - 'properties' => [ - 'period' => [ - 'type' => 'string', - 'enum' => self::PERIODS, - 'description' => 'Named time window.', - ], - ], - 'required' => ['period'], - ]; - } - - public function requiredAbility(): ?array - { - // Cross-entity financial snapshot — gated like the company dashboard. - return ['dashboard', null]; - } - - public function execute(array $arguments, int $companyId, int $userId): mixed - { - $period = (string) ($arguments['period'] ?? 'this_month'); - if (! in_array($period, self::PERIODS, true)) { - return ['error' => 'invalid_period', 'valid' => self::PERIODS]; - } - - // Stats are always date-scoped (the enum above excludes `all_time`), - // so rangeFor() is guaranteed to return a non-null pair here. - [$start, $end] = $this->rangeFor($period); - - // Counts issued invoices only; the total below keeps the credit notes, - // whose negated amounts are what net the sales figure back out. - $invoiceCount = Invoice::query() - ->where('company_id', $companyId) - ->where('type', Invoice::TYPE_INVOICE) - ->whereBetween('invoice_date', [$start, $end]) - ->count(); - - $invoiceTotal = (float) Invoice::query() - ->where('company_id', $companyId) - ->whereBetween('invoice_date', [$start, $end]) - ->sum('total'); - - $paymentCount = Payment::query() - ->where('company_id', $companyId) - ->whereBetween('payment_date', [$start, $end]) - ->count(); - - $paymentTotal = (float) Payment::query() - ->where('company_id', $companyId) - ->whereBetween('payment_date', [$start, $end]) - ->sum('amount'); - - $expenseCount = Expense::query() - ->where('company_id', $companyId) - ->whereBetween('expense_date', [$start, $end]) - ->count(); - - $expenseTotal = (float) Expense::query() - ->where('company_id', $companyId) - ->whereBetween('expense_date', [$start, $end]) - ->sum('amount'); - - return [ - 'period' => $period, - 'start' => $start->toDateString(), - 'end' => $end->toDateString(), - 'invoices' => ['count' => $invoiceCount, 'total' => $invoiceTotal], - 'payments' => ['count' => $paymentCount, 'total' => $paymentTotal], - 'expenses' => ['count' => $expenseCount, 'total' => $expenseTotal], - ]; - } -} diff --git a/app/Platform/Ai/Application/Tools/GetCustomerTool.php b/app/Platform/Ai/Application/Tools/GetCustomerTool.php deleted file mode 100644 index f64f22dd..00000000 --- a/app/Platform/Ai/Application/Tools/GetCustomerTool.php +++ /dev/null @@ -1,85 +0,0 @@ - 'object', - 'properties' => [ - 'customer_id' => [ - 'type' => 'integer', - 'description' => 'The customer ID.', - ], - ], - 'required' => ['customer_id'], - ]; - } - - public function requiredAbility(): ?array - { - return ['view-customer', Customer::class]; - } - - public function execute(array $arguments, int $companyId, int $userId): mixed - { - $customer = Customer::query() - ->where('company_id', $companyId) - ->where('id', (int) ($arguments['customer_id'] ?? 0)) - ->with(['billingAddress', 'shippingAddress']) - ->first(); - - if (! $customer) { - return ['error' => 'customer_not_found']; - } - - // Aggregate totals — done with lightweight queries rather than loading every invoice. - // Issued invoices only: a credit note reverses one, it is not another. - $invoiceCount = Invoice::query() - ->where('company_id', $companyId) - ->where('customer_id', $customer->id) - ->where('type', Invoice::TYPE_INVOICE) - ->count(); - - $outstanding = (float) Invoice::query() - ->where('company_id', $companyId) - ->where('customer_id', $customer->id) - ->whereIn('paid_status', ['UNPAID', 'PARTIALLY_PAID']) - ->sum('due_amount'); - - return [ - 'customer' => [ - 'id' => $customer->id, - 'name' => $customer->name, - 'display_name' => $customer->display_name, - 'email' => $customer->email, - 'phone' => $customer->phone, - 'contact_name' => $customer->contact_name, - 'company_name' => $customer->company_name, - 'website' => $customer->website, - 'enable_portal' => (bool) $customer->enable_portal, - 'billing_address' => $customer->billingAddress, - 'shipping_address' => $customer->shippingAddress, - 'totals' => [ - 'invoice_count' => $invoiceCount, - 'outstanding_amount' => $outstanding, - ], - ], - ]; - } -} diff --git a/app/Platform/Ai/Application/Tools/GetInvoiceTool.php b/app/Platform/Ai/Application/Tools/GetInvoiceTool.php deleted file mode 100644 index 3c9601b9..00000000 --- a/app/Platform/Ai/Application/Tools/GetInvoiceTool.php +++ /dev/null @@ -1,90 +0,0 @@ - 'object', - 'properties' => [ - 'invoice_number' => [ - 'type' => 'string', - 'description' => 'The invoice_number to look up (e.g. "INV-000001").', - ], - ], - 'required' => ['invoice_number'], - ]; - } - - public function requiredAbility(): ?array - { - return ['view-invoice', Invoice::class]; - } - - public function execute(array $arguments, int $companyId, int $userId): mixed - { - $invoice = Invoice::query() - ->where('company_id', $companyId) - ->where('invoice_number', (string) ($arguments['invoice_number'] ?? '')) - ->with(['customer:id,name,email,phone', 'items', 'taxes']) - ->first(); - - if (! $invoice) { - return ['error' => 'invoice_not_found']; - } - - return [ - 'invoice' => [ - 'id' => $invoice->id, - 'invoice_number' => $invoice->invoice_number, - 'reference_number' => $invoice->reference_number, - 'status' => $invoice->status, - 'paid_status' => $invoice->paid_status, - 'invoice_date' => $this->asDate($invoice->invoice_date), - 'due_date' => $this->asDate($invoice->due_date), - 'sub_total' => $invoice->sub_total, - 'tax' => $invoice->tax, - 'discount' => $invoice->discount, - 'total' => $invoice->total, - 'due_amount' => $invoice->due_amount, - 'overdue' => (bool) $invoice->overdue, - 'notes' => $invoice->notes, - 'customer' => $invoice->customer ? [ - 'id' => $invoice->customer->id, - 'name' => $invoice->customer->name, - 'email' => $invoice->customer->email, - 'phone' => $invoice->customer->phone, - ] : null, - 'items' => $invoice->items->map(fn ($item): array => [ - 'name' => $item->name, - 'description' => $item->description, - 'quantity' => $item->quantity, - 'price' => $item->price, - 'total' => $item->total, - ])->all(), - 'taxes' => $invoice->taxes->map(fn ($tax): array => [ - 'name' => $tax->name, - 'percent' => $tax->percent, - 'amount' => $tax->amount, - ])->all(), - ], - ]; - } -} diff --git a/app/Platform/Ai/Application/Tools/ListExpenseCategoriesTool.php b/app/Platform/Ai/Application/Tools/ListExpenseCategoriesTool.php deleted file mode 100644 index 7852b284..00000000 --- a/app/Platform/Ai/Application/Tools/ListExpenseCategoriesTool.php +++ /dev/null @@ -1,50 +0,0 @@ - 'object', - 'properties' => (object) [], - 'required' => [], - ]; - } - - public function requiredAbility(): ?array - { - // Expense categories are gated by the expense ability (see ExpenseCategoryPolicy). - return ['view-expense', Expense::class]; - } - - public function execute(array $arguments, int $companyId, int $userId): mixed - { - $categories = ExpenseCategory::query() - ->where('company_id', $companyId) - ->orderBy('name') - ->get(['id', 'name', 'description']); - - return [ - 'categories' => $categories->map(fn ($c): array => [ - 'id' => $c->id, - 'name' => $c->name, - 'description' => $c->description, - ])->all(), - ]; - } -} diff --git a/app/Platform/Ai/Application/Tools/ListOverdueInvoicesTool.php b/app/Platform/Ai/Application/Tools/ListOverdueInvoicesTool.php deleted file mode 100644 index 48f3a8b1..00000000 --- a/app/Platform/Ai/Application/Tools/ListOverdueInvoicesTool.php +++ /dev/null @@ -1,59 +0,0 @@ - 'object', - 'properties' => (object) [], - 'required' => [], - ]; - } - - public function requiredAbility(): ?array - { - return ['view-invoice', Invoice::class]; - } - - public function execute(array $arguments, int $companyId, int $userId): mixed - { - $invoices = Invoice::query() - ->where('company_id', $companyId) - ->where('overdue', true) - ->with('customer:id,name') - ->orderBy('due_date') - ->limit(100) - ->get(); - - $totalOutstanding = (float) $invoices->sum('due_amount'); - - return [ - 'count' => $invoices->count(), - 'total_outstanding' => $totalOutstanding, - 'invoices' => $invoices->map(fn (Invoice $inv): array => [ - 'id' => $inv->id, - 'invoice_number' => $inv->invoice_number, - 'customer_id' => $inv->customer_id, - 'customer_name' => $inv->customer?->name, - 'due_date' => $this->asDate($inv->due_date), - 'due_amount' => $inv->due_amount, - 'total' => $inv->total, - ])->all(), - ]; - } -} diff --git a/app/Platform/Ai/Application/Tools/ListRecentPaymentsTool.php b/app/Platform/Ai/Application/Tools/ListRecentPaymentsTool.php deleted file mode 100644 index 95fb17c2..00000000 --- a/app/Platform/Ai/Application/Tools/ListRecentPaymentsTool.php +++ /dev/null @@ -1,89 +0,0 @@ - 'object', - 'properties' => [ - 'days' => [ - 'type' => 'integer', - 'minimum' => 1, - 'maximum' => self::MAX_DAYS, - 'description' => 'How many days back to look (default 30, max 365).', - ], - 'limit' => [ - 'type' => 'integer', - 'minimum' => 1, - 'maximum' => self::MAX_LIMIT, - 'description' => 'Max rows to return (default 20, max 100).', - ], - ], - 'required' => [], - ]; - } - - public function requiredAbility(): ?array - { - return ['view-payment', Payment::class]; - } - - public function execute(array $arguments, int $companyId, int $userId): mixed - { - $days = min((int) ($arguments['days'] ?? self::DEFAULT_DAYS), self::MAX_DAYS); - $limit = min((int) ($arguments['limit'] ?? self::DEFAULT_LIMIT), self::MAX_LIMIT); - - $since = Carbon::now()->subDays($days)->startOfDay(); - - $payments = Payment::query() - ->where('company_id', $companyId) - ->where('payment_date', '>=', $since) - ->with(['allocations:id,payment_id,invoice_id,amount', 'customer:id,name', 'paymentMethod:id,name']) - ->latest('payment_date') - ->limit($limit) - ->get(); - - return [ - 'since' => $since->toDateString(), - 'payments' => $payments->map(fn (Payment $p): array => [ - 'id' => $p->id, - 'payment_number' => $p->payment_number, - 'payment_date' => $this->asDate($p->payment_date), - 'amount' => $p->amount, - 'customer_id' => $p->customer_id, - 'customer_name' => $p->customer?->name, - 'allocations' => $p->allocations->map(fn ($allocation) => [ - 'invoice_id' => $allocation->invoice_id, - 'amount' => $allocation->amount, - ])->all(), - 'allocated_amount' => (int) $p->allocations->sum('amount'), - 'unallocated_amount' => (int) $p->amount - (int) $p->allocations->sum('amount'), - 'payment_method' => $p->paymentMethod?->name, - ])->all(), - ]; - } -} diff --git a/app/Platform/Ai/Application/Tools/RankExpenseCategoriesTool.php b/app/Platform/Ai/Application/Tools/RankExpenseCategoriesTool.php deleted file mode 100644 index 9dc46ea9..00000000 --- a/app/Platform/Ai/Application/Tools/RankExpenseCategoriesTool.php +++ /dev/null @@ -1,112 +0,0 @@ - 'object', - 'properties' => [ - 'period' => [ - 'type' => 'string', - 'enum' => self::ALL_PERIODS, - 'description' => 'Named time window. Use all_time for lifetime totals.', - ], - 'limit' => [ - 'type' => 'integer', - 'minimum' => 1, - 'maximum' => self::MAX_LIMIT, - 'description' => 'Max number of categories to return. Default 10.', - ], - ], - 'required' => [], - ]; - } - - public function requiredAbility(): ?array - { - return ['view-expense', Expense::class]; - } - - public function execute(array $arguments, int $companyId, int $userId): mixed - { - $period = (string) ($arguments['period'] ?? 'all_time'); - if (! in_array($period, self::ALL_PERIODS, true)) { - return ['error' => 'invalid_period', 'valid' => self::ALL_PERIODS]; - } - - $limit = min(max((int) ($arguments['limit'] ?? self::DEFAULT_LIMIT), 1), self::MAX_LIMIT); - $range = $this->rangeFor($period); - - $query = Expense::query() - ->where('company_id', $companyId) - ->whereNotNull('expense_category_id') - ->select([ - 'expense_category_id', - DB::raw('SUM(amount) as total_amount'), - DB::raw('COUNT(*) as expense_count'), - ]) - ->groupBy('expense_category_id') - ->orderByDesc('total_amount') - ->limit($limit); - - if ($range !== null) { - $query->whereBetween('expense_date', [$range[0], $range[1]]); - } - - $rows = $query->get()->all(); - - // Batch-load category names in one query. - $categoryIds = array_map(static fn ($row) => (int) $row->expense_category_id, $rows); - $categories = ExpenseCategory::query() - ->whereIn('id', $categoryIds) - ->get() - ->keyBy('id'); - - $ranked = array_map(function ($row) use ($categories): array { - $category = $categories->get((int) $row->expense_category_id); - - return [ - 'expense_category_id' => (int) $row->expense_category_id, - 'name' => $category?->name, - 'total_amount' => (float) $row->total_amount, - 'expense_count' => (int) $row->expense_count, - ]; - }, $rows); - - return [ - 'period' => $period, - 'categories' => $ranked, - ]; - } -} diff --git a/app/Platform/Ai/Application/Tools/RankTopCustomersTool.php b/app/Platform/Ai/Application/Tools/RankTopCustomersTool.php deleted file mode 100644 index d521ffa1..00000000 --- a/app/Platform/Ai/Application/Tools/RankTopCustomersTool.php +++ /dev/null @@ -1,234 +0,0 @@ - 'object', - 'properties' => [ - 'metric' => [ - 'type' => 'string', - 'enum' => self::METRICS, - 'description' => 'Which metric to rank by.', - ], - 'period' => [ - 'type' => 'string', - 'enum' => self::ALL_PERIODS, - 'description' => 'Named time window. Use all_time for lifetime rankings. Ignored for outstanding_balance (always current).', - ], - 'limit' => [ - 'type' => 'integer', - 'minimum' => 1, - 'maximum' => self::MAX_LIMIT, - 'description' => 'Max number of customers to return. Default 5.', - ], - ], - 'required' => ['metric'], - ]; - } - - public function requiredAbility(): ?array - { - return ['view-customer', Customer::class]; - } - - public function execute(array $arguments, int $companyId, int $userId): mixed - { - $metric = (string) ($arguments['metric'] ?? 'invoiced_total'); - if (! in_array($metric, self::METRICS, true)) { - return ['error' => 'invalid_metric', 'valid' => self::METRICS]; - } - - $period = (string) ($arguments['period'] ?? 'all_time'); - if (! in_array($period, self::ALL_PERIODS, true)) { - return ['error' => 'invalid_period', 'valid' => self::ALL_PERIODS]; - } - - $limit = min(max((int) ($arguments['limit'] ?? self::DEFAULT_LIMIT), 1), self::MAX_LIMIT); - - // outstanding_balance is a current-state snapshot; period is meaningless. - $range = $metric === 'outstanding_balance' ? null : $this->rangeFor($period); - - $rows = match ($metric) { - 'invoiced_total' => $this->rankByInvoiceSum($companyId, $range, $limit, 'total'), - 'paid_total' => $this->rankByPaymentSum($companyId, $range, $limit), - 'invoice_count' => $this->rankByInvoiceCount($companyId, $range, $limit), - 'outstanding_balance' => $this->rankByOutstandingBalance($companyId, $limit), - }; - - // Batch-load the customers we're about to return so we can decorate - // each ranking row with name fields. One query regardless of $limit. - $customerIds = array_map(static fn ($row) => (int) $row->customer_id, $rows); - $customers = Customer::query() - ->whereIn('id', $customerIds) - ->get() - ->keyBy('id'); - - $ranked = array_map(function ($row) use ($customers, $metric): array { - $customer = $customers->get((int) $row->customer_id); - - return [ - 'customer_id' => (int) $row->customer_id, - 'name' => $customer?->name, - 'display_name' => $customer?->display_name, - 'company_name' => $customer?->company_name, - 'metric_value' => $metric === 'invoice_count' - ? (int) $row->metric_value - : (float) $row->metric_value, - 'invoice_count' => isset($row->invoice_count) ? (int) $row->invoice_count : null, - ]; - }, $rows); - - return [ - 'metric' => $metric, - 'period' => $metric === 'outstanding_balance' ? 'current' : $period, - 'customers' => $ranked, - ]; - } - - /** - * @param array{0: Carbon, 1: Carbon}|null $range - * @return array - */ - private function rankByInvoiceSum(int $companyId, ?array $range, int $limit, string $sumColumn): array - { - $query = Invoice::query() - ->where('company_id', $companyId) - ->whereNotNull('customer_id') - ->select([ - 'customer_id', - DB::raw("SUM({$sumColumn}) as metric_value"), - DB::raw('COUNT(*) as invoice_count'), - ]) - ->groupBy('customer_id') - ->orderByDesc('metric_value') - ->limit($limit); - - if ($range !== null) { - $query->whereBetween('invoice_date', [$range[0], $range[1]]); - } - - return $query->get()->all(); - } - - /** - * @param array{0: Carbon, 1: Carbon}|null $range - * @return array - */ - private function rankByPaymentSum(int $companyId, ?array $range, int $limit): array - { - $query = Payment::query() - ->where('company_id', $companyId) - ->whereNotNull('customer_id') - ->select([ - 'customer_id', - DB::raw('SUM(amount) as metric_value'), - DB::raw('COUNT(*) as invoice_count'), - ]) - ->groupBy('customer_id') - ->orderByDesc('metric_value') - ->limit($limit); - - if ($range !== null) { - $query->whereBetween('payment_date', [$range[0], $range[1]]); - } - - // Note: `invoice_count` here is actually the payment count for this - // customer within the window — semantically confusing, so drop it. - return array_map(function ($row) { - unset($row->invoice_count); - - return $row; - }, $query->get()->all()); - } - - /** - * @param array{0: Carbon, 1: Carbon}|null $range - * @return array - */ - private function rankByInvoiceCount(int $companyId, ?array $range, int $limit): array - { - $query = Invoice::query() - ->where('company_id', $companyId) - ->whereNotNull('customer_id') - ->select([ - 'customer_id', - DB::raw('COUNT(*) as metric_value'), - DB::raw('COUNT(*) as invoice_count'), - ]) - ->groupBy('customer_id') - ->orderByDesc('metric_value') - ->limit($limit); - - if ($range !== null) { - $query->whereBetween('invoice_date', [$range[0], $range[1]]); - } - - return $query->get()->all(); - } - - /** - * @return array - */ - private function rankByOutstandingBalance(int $companyId, int $limit): array - { - return Invoice::query() - ->where('company_id', $companyId) - ->whereNotNull('customer_id') - ->whereIn('paid_status', ['UNPAID', 'PARTIALLY_PAID']) - ->select([ - 'customer_id', - DB::raw('SUM(due_amount) as metric_value'), - DB::raw('COUNT(*) as invoice_count'), - ]) - ->groupBy('customer_id') - ->orderByDesc('metric_value') - ->limit($limit) - ->get() - ->all(); - } -} diff --git a/app/Platform/Ai/Application/Tools/RankTopItemsTool.php b/app/Platform/Ai/Application/Tools/RankTopItemsTool.php deleted file mode 100644 index ea74b1e6..00000000 --- a/app/Platform/Ai/Application/Tools/RankTopItemsTool.php +++ /dev/null @@ -1,128 +0,0 @@ - 'object', - 'properties' => [ - 'metric' => [ - 'type' => 'string', - 'enum' => self::METRICS, - 'description' => 'Which dimension to rank by.', - ], - 'period' => [ - 'type' => 'string', - 'enum' => self::ALL_PERIODS, - 'description' => 'Named time window. Use all_time for lifetime rankings.', - ], - 'limit' => [ - 'type' => 'integer', - 'minimum' => 1, - 'maximum' => self::MAX_LIMIT, - 'description' => 'Max number of items to return. Default 5.', - ], - ], - 'required' => ['metric'], - ]; - } - - public function requiredAbility(): ?array - { - return ['view-item', Item::class]; - } - - public function execute(array $arguments, int $companyId, int $userId): mixed - { - $metric = (string) ($arguments['metric'] ?? 'revenue'); - if (! in_array($metric, self::METRICS, true)) { - return ['error' => 'invalid_metric', 'valid' => self::METRICS]; - } - - $period = (string) ($arguments['period'] ?? 'all_time'); - if (! in_array($period, self::ALL_PERIODS, true)) { - return ['error' => 'invalid_period', 'valid' => self::ALL_PERIODS]; - } - - $limit = min(max((int) ($arguments['limit'] ?? self::DEFAULT_LIMIT), 1), self::MAX_LIMIT); - $range = $this->rangeFor($period); - $orderColumn = $metric === 'revenue' ? 'total_revenue' : 'total_quantity'; - - $query = InvoiceItem::query() - ->join('invoices', 'invoice_items.invoice_id', '=', 'invoices.id') - ->where('invoices.company_id', $companyId) - ->whereNotNull('invoice_items.item_id') - ->select([ - 'invoice_items.item_id', - DB::raw('SUM(invoice_items.quantity) as total_quantity'), - DB::raw('SUM(invoice_items.total) as total_revenue'), - ]) - ->groupBy('invoice_items.item_id') - ->orderByDesc($orderColumn) - ->limit($limit); - - if ($range !== null) { - $query->whereBetween('invoices.invoice_date', [$range[0], $range[1]]); - } - - $rows = $query->get()->all(); - - // Batch-load item names in one query. - $itemIds = array_map(static fn ($row) => (int) $row->item_id, $rows); - $items = Item::query() - ->whereIn('id', $itemIds) - ->get() - ->keyBy('id'); - - $ranked = array_map(function ($row) use ($items): array { - $item = $items->get((int) $row->item_id); - - return [ - 'item_id' => (int) $row->item_id, - 'name' => $item?->name, - 'quantity_sold' => (float) $row->total_quantity, - 'revenue' => (float) $row->total_revenue, - ]; - }, $rows); - - return [ - 'metric' => $metric, - 'period' => $period, - 'items' => $ranked, - ]; - } -} diff --git a/app/Platform/Ai/Application/Tools/SearchCustomersTool.php b/app/Platform/Ai/Application/Tools/SearchCustomersTool.php deleted file mode 100644 index 6ed71911..00000000 --- a/app/Platform/Ai/Application/Tools/SearchCustomersTool.php +++ /dev/null @@ -1,78 +0,0 @@ - 'object', - 'properties' => [ - 'query' => [ - 'type' => 'string', - 'description' => 'Free-text search against name, email, and related fields.', - ], - 'limit' => [ - 'type' => 'integer', - 'minimum' => 1, - 'maximum' => self::MAX_LIMIT, - ], - ], - 'required' => [], - ]; - } - - public function requiredAbility(): ?array - { - return ['view-customer', Customer::class]; - } - - public function execute(array $arguments, int $companyId, int $userId): mixed - { - $limit = min((int) ($arguments['limit'] ?? self::DEFAULT_LIMIT), self::MAX_LIMIT); - - $query = Customer::query() - ->where('company_id', $companyId) - ->orderBy('name') - ->limit($limit); - - if (! empty($arguments['query'])) { - $q = $arguments['query']; - $query->where(function ($qb) use ($q) { - $qb->where('name', 'like', "%{$q}%") - ->orWhere('display_name', 'like', "%{$q}%") - ->orWhere('email', 'like', "%{$q}%") - ->orWhere('company_name', 'like', "%{$q}%") - ->orWhere('contact_name', 'like', "%{$q}%"); - }); - } - - return [ - 'customers' => $query->get()->map(fn (Customer $c): array => [ - 'id' => $c->id, - 'name' => $c->name, - 'display_name' => $c->display_name, - 'email' => $c->email, - 'phone' => $c->phone, - 'company_name' => $c->company_name, - ])->all(), - ]; - } -} diff --git a/app/Platform/Ai/Application/Tools/SearchInvoicesTool.php b/app/Platform/Ai/Application/Tools/SearchInvoicesTool.php deleted file mode 100644 index b1635ab0..00000000 --- a/app/Platform/Ai/Application/Tools/SearchInvoicesTool.php +++ /dev/null @@ -1,113 +0,0 @@ - 'object', - 'properties' => [ - 'query' => [ - 'type' => 'string', - 'description' => 'Optional free-text search against invoice_number and reference_number.', - ], - 'status' => [ - 'type' => 'string', - 'enum' => ['DRAFT', 'SENT', 'VIEWED', 'COMPLETED', 'UNPAID', 'PARTIALLY_PAID', 'PAID', 'OVERDUE'], - 'description' => 'Optional status filter.', - ], - 'customer_id' => [ - 'type' => 'integer', - 'description' => 'Optional customer ID to restrict to a specific customer.', - ], - 'limit' => [ - 'type' => 'integer', - 'minimum' => 1, - 'maximum' => self::MAX_LIMIT, - 'description' => 'Max rows to return (default 10, max 50).', - ], - ], - 'required' => [], - ]; - } - - public function requiredAbility(): ?array - { - return ['view-invoice', Invoice::class]; - } - - public function execute(array $arguments, int $companyId, int $userId): mixed - { - $limit = min((int) ($arguments['limit'] ?? self::DEFAULT_LIMIT), self::MAX_LIMIT); - - $query = Invoice::query() - ->where('company_id', $companyId) - ->with('customer:id,name') - ->latest('invoice_date') - ->limit($limit); - - if (! empty($arguments['query'])) { - $q = $arguments['query']; - $query->where(function ($qb) use ($q) { - $qb->where('invoice_number', 'like', "%{$q}%") - ->orWhere('reference_number', 'like', "%{$q}%"); - }); - } - - if (! empty($arguments['status'])) { - $status = strtoupper((string) $arguments['status']); - // 'PAID' / 'UNPAID' / 'PARTIALLY_PAID' live on paid_status; the rest on status. - if (in_array($status, ['PAID', 'UNPAID', 'PARTIALLY_PAID'], true)) { - $query->where('paid_status', $status); - } elseif ($status === 'OVERDUE') { - $query->where('overdue', true); - } else { - $query->where('status', $status); - } - } - - if (! empty($arguments['customer_id'])) { - $query->where('customer_id', (int) $arguments['customer_id']); - } - - return [ - 'invoices' => $query->get()->map(fn (Invoice $inv): array => [ - 'id' => $inv->id, - 'invoice_number' => $inv->invoice_number, - 'customer_id' => $inv->customer_id, - 'customer_name' => $inv->customer?->name, - 'invoice_date' => $this->asDate($inv->invoice_date), - 'due_date' => $this->asDate($inv->due_date), - 'status' => $inv->status, - 'paid_status' => $inv->paid_status, - 'total' => $inv->total, - 'due_amount' => $inv->due_amount, - 'overdue' => (bool) $inv->overdue, - ])->all(), - ]; - } -} diff --git a/app/Platform/Ai/Application/Tools/SearchItemsTool.php b/app/Platform/Ai/Application/Tools/SearchItemsTool.php deleted file mode 100644 index 7656aa48..00000000 --- a/app/Platform/Ai/Application/Tools/SearchItemsTool.php +++ /dev/null @@ -1,73 +0,0 @@ - 'object', - 'properties' => [ - 'query' => [ - 'type' => 'string', - 'description' => 'Free-text search against name and description.', - ], - 'limit' => [ - 'type' => 'integer', - 'minimum' => 1, - 'maximum' => self::MAX_LIMIT, - ], - ], - 'required' => [], - ]; - } - - public function requiredAbility(): ?array - { - return ['view-item', Item::class]; - } - - public function execute(array $arguments, int $companyId, int $userId): mixed - { - $limit = min((int) ($arguments['limit'] ?? self::DEFAULT_LIMIT), self::MAX_LIMIT); - - $query = Item::query() - ->where('company_id', $companyId) - ->orderBy('name') - ->limit($limit); - - if (! empty($arguments['query'])) { - $q = $arguments['query']; - $query->where(function ($qb) use ($q) { - $qb->where('name', 'like', "%{$q}%") - ->orWhere('description', 'like', "%{$q}%"); - }); - } - - return [ - 'items' => $query->get()->map(fn (Item $item): array => [ - 'id' => $item->id, - 'name' => $item->name, - 'description' => $item->description, - 'price' => $item->price, - ])->all(), - ]; - } -} diff --git a/app/Platform/Ai/Contracts/AiDriver.php b/app/Platform/Ai/Contracts/AiDriver.php deleted file mode 100644 index 017b3bb9..00000000 --- a/app/Platform/Ai/Contracts/AiDriver.php +++ /dev/null @@ -1,88 +0,0 @@ -> $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/Platform/Ai/Data/AiChatResponse.php b/app/Platform/Ai/Data/AiChatResponse.php deleted file mode 100644 index 67bf6c18..00000000 --- a/app/Platform/Ai/Data/AiChatResponse.php +++ /dev/null @@ -1,34 +0,0 @@ -}> $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/Platform/Ai/Drivers/AiDriverFactory.php b/app/Platform/Ai/Drivers/AiDriverFactory.php deleted file mode 100644 index 2bf219b3..00000000 --- a/app/Platform/Ai/Drivers/AiDriverFactory.php +++ /dev/null @@ -1,87 +0,0 @@ -> - */ - 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/Platform/Ai/Drivers/OpenRouterDriver.php b/app/Platform/Ai/Drivers/OpenRouterDriver.php deleted file mode 100644 index 203a1616..00000000 --- a/app/Platform/Ai/Drivers/OpenRouterDriver.php +++ /dev/null @@ -1,234 +0,0 @@ -getBaseUrl().'/chat/completions'; - - $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($endpoint, $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 - { - // Resolve (and SSRF-validate) the URL before the try so a blocked base - // URL surfaces as `invalid_base_url`, not a generic `server_error`. - $endpoint = $this->getBaseUrl().'/models'; - - try { - $response = Http::withToken($this->apiKey) - ->timeout(30) - ->acceptJson() - ->get($endpoint); - } 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 - { - if ($this->validatedBaseUrl !== null) { - return $this->validatedBaseUrl; - } - - $configured = (string) ($this->config['base_url'] ?? ''); - $url = rtrim($configured !== '' ? $configured : self::DEFAULT_BASE_URL, '/'); - - // SSRF guard: never let an admin/owner-supplied base URL point the - // server (with the bearer token attached) at a private/reserved host. - try { - PrivateNetworkGuard::assertAllowed($url); - } catch (BlockedUrlException $e) { - throw new AiException('Invalid AI base URL: '.$e->getMessage(), 'invalid_base_url', 0, $e); - } - - return $this->validatedBaseUrl = $url; - } -} diff --git a/app/Platform/Ai/Exceptions/AiException.php b/app/Platform/Ai/Exceptions/AiException.php deleted file mode 100644 index c2271a11..00000000 --- a/app/Platform/Ai/Exceptions/AiException.php +++ /dev/null @@ -1,25 +0,0 @@ -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', new PublicHttpUrl], - ]); - - // 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/Platform/Ai/Http/Company/ChatController.php b/app/Platform/Ai/Http/Company/ChatController.php deleted file mode 100644 index 8e5b65a8..00000000 --- a/app/Platform/Ai/Http/Company/ChatController.php +++ /dev/null @@ -1,98 +0,0 @@ -authorize('use ai'); - - $validated = $this->validate($request, [ - 'conversation_id' => 'nullable|integer', - 'message' => 'required|string|max:10000', - ]); - - $companyId = (int) $request->header('company'); - $userId = (int) $request->user()->id; - - $conversation = $this->resolveConversation( - $validated['conversation_id'] ?? null, - $companyId, - $userId, - $validated['message'], - ); - - try { - $assistantMessage = $this->assistant->chat($conversation, $validated['message']); - } catch (AiException $e) { - return response()->json([ - 'error' => $e->errorKey, - 'message' => $e->getMessage(), - ], 422); - } - - $conversation->refresh(); - - return response()->json([ - 'conversation' => [ - 'id' => $conversation->id, - 'title' => $conversation->title, - 'model' => $conversation->model, - 'updated_at' => $conversation->updated_at, - ], - 'message' => [ - 'id' => $assistantMessage->id, - 'role' => $assistantMessage->role, - 'content' => $assistantMessage->content, - 'created_at' => $assistantMessage->created_at, - ], - ]); - } - - /** - * Pick an existing conversation the user owns, or create a new one. - */ - protected function resolveConversation( - ?int $conversationId, - int $companyId, - int $userId, - string $firstMessage, - ): AiConversation { - if ($conversationId !== null) { - $existing = AiConversation::query() - ->where('id', $conversationId) - ->where('company_id', $companyId) - ->where('user_id', $userId) - ->first(); - - if ($existing) { - return $existing; - } - } - - return $this->assistant->startConversation($companyId, $userId, $firstMessage); - } -} diff --git a/app/Platform/Ai/Http/Company/CompanyAiConfigurationController.php b/app/Platform/Ai/Http/Company/CompanyAiConfigurationController.php deleted file mode 100644 index b1207d9e..00000000 --- a/app/Platform/Ai/Http/Company/CompanyAiConfigurationController.php +++ /dev/null @@ -1,113 +0,0 @@ -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', new PublicHttpUrl], - ]); - - $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/Platform/Ai/Http/Company/ConversationController.php b/app/Platform/Ai/Http/Company/ConversationController.php deleted file mode 100644 index 505a0d15..00000000 --- a/app/Platform/Ai/Http/Company/ConversationController.php +++ /dev/null @@ -1,103 +0,0 @@ -authorize('use ai'); - - $conversations = AiConversation::query() - ->where('company_id', $request->header('company')) - ->where('user_id', $request->user()->id) - ->latest('updated_at') - ->limit(50) - ->get(['id', 'title', 'model', 'updated_at', 'created_at']); - - return response()->json(['conversations' => $conversations]); - } - - /** - * Show a single conversation with its full message history. - */ - public function show(Request $request, int $id): JsonResponse - { - $this->authorize('use ai'); - - $conversation = AiConversation::query() - ->where('id', $id) - ->where('company_id', $request->header('company')) - ->firstOrFail(); - - $this->authorize('view', $conversation); - - $messages = $conversation->messages() - ->whereIn('role', ['user', 'assistant']) - ->get(['id', 'role', 'content', 'created_at']); - - return response()->json([ - 'conversation' => [ - 'id' => $conversation->id, - 'title' => $conversation->title, - 'model' => $conversation->model, - 'created_at' => $conversation->created_at, - 'updated_at' => $conversation->updated_at, - ], - 'messages' => $messages, - ]); - } - - /** - * Rename a conversation. - * - * @throws ValidationException - */ - public function update(Request $request, int $id): JsonResponse - { - $this->authorize('use ai'); - - $conversation = AiConversation::query() - ->where('id', $id) - ->where('company_id', $request->header('company')) - ->firstOrFail(); - - $this->authorize('update', $conversation); - - $validated = $this->validate($request, [ - 'title' => 'required|string|max:255', - ]); - - $conversation->update(['title' => $validated['title']]); - - return response()->json(['success' => true]); - } - - /** - * Delete a conversation (cascades to messages via DB foreign key). - */ - public function destroy(Request $request, int $id): JsonResponse - { - $this->authorize('use ai'); - - $conversation = AiConversation::query() - ->where('id', $id) - ->where('company_id', $request->header('company')) - ->firstOrFail(); - - $this->authorize('delete', $conversation); - - $conversation->delete(); - - return response()->json(['success' => true]); - } -} diff --git a/app/Platform/Ai/Http/Company/GenerationController.php b/app/Platform/Ai/Http/Company/GenerationController.php deleted file mode 100644 index a7e8eb31..00000000 --- a/app/Platform/Ai/Http/Company/GenerationController.php +++ /dev/null @@ -1,53 +0,0 @@ -authorize('use ai'); - - $validated = $this->validate($request, [ - 'prompt' => 'required|string|max:4000', - 'context' => 'nullable|string|max:20000', - ]); - - try { - $text = $this->generator->generate( - (int) $request->header('company'), - $validated['prompt'], - $validated['context'] ?? null, - ); - } catch (AiException $e) { - return response()->json([ - 'error' => $e->errorKey, - 'message' => $e->getMessage(), - ], 422); - } - - return response()->json([ - 'text' => $text, - ]); - } -} diff --git a/app/Platform/Ai/Http/Setup/AiConfigurationController.php b/app/Platform/Ai/Http/Setup/AiConfigurationController.php deleted file mode 100644 index eab6dec8..00000000 --- a/app/Platform/Ai/Http/Setup/AiConfigurationController.php +++ /dev/null @@ -1,75 +0,0 @@ -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', new PublicHttpUrl], - '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/Platform/Ai/Models/AiConversation.php b/app/Platform/Ai/Models/AiConversation.php deleted file mode 100644 index 802fda93..00000000 --- a/app/Platform/Ai/Models/AiConversation.php +++ /dev/null @@ -1,41 +0,0 @@ -belongsTo(Company::class); - } - - public function user(): BelongsTo - { - return $this->belongsTo(User::class); - } - - public function messages(): HasMany - { - return $this->hasMany(AiMessage::class, 'conversation_id')->orderBy('created_at'); - } -} diff --git a/app/Platform/Ai/Models/AiMessage.php b/app/Platform/Ai/Models/AiMessage.php deleted file mode 100644 index 734f0941..00000000 --- a/app/Platform/Ai/Models/AiMessage.php +++ /dev/null @@ -1,53 +0,0 @@ - 'array', - 'tokens_in' => 'integer', - 'tokens_out' => 'integer', - 'created_at' => 'datetime', - ]; - } - - public function conversation(): BelongsTo - { - return $this->belongsTo(AiConversation::class, 'conversation_id'); - } -} diff --git a/app/Platform/Ai/Policies/AiAccessPolicy.php b/app/Platform/Ai/Policies/AiAccessPolicy.php deleted file mode 100644 index 9bc9cd9e..00000000 --- a/app/Platform/Ai/Policies/AiAccessPolicy.php +++ /dev/null @@ -1,21 +0,0 @@ -isSuperAdmin(); - } - - /** - * Feature configuration applies the instance and company kill switches. - */ - public function use(User $user): bool - { - return true; - } -} diff --git a/app/Platform/Ai/Policies/AiConversationPolicy.php b/app/Platform/Ai/Policies/AiConversationPolicy.php deleted file mode 100644 index 9523e190..00000000 --- a/app/Platform/Ai/Policies/AiConversationPolicy.php +++ /dev/null @@ -1,38 +0,0 @@ -owns($user, $conversation); - } - - public function update(User $user, AiConversation $conversation): bool - { - return $this->owns($user, $conversation); - } - - public function delete(User $user, AiConversation $conversation): bool - { - return $this->owns($user, $conversation); - } - - protected function owns(User $user, AiConversation $conversation): bool - { - return $conversation->user_id === $user->id - && $user->hasCompany($conversation->company_id); - } -} diff --git a/app/Platform/Ai/Prompting/PromptLoader.php b/app/Platform/Ai/Prompting/PromptLoader.php deleted file mode 100644 index 38a47b4c..00000000 --- a/app/Platform/Ai/Prompting/PromptLoader.php +++ /dev/null @@ -1,55 +0,0 @@ - $vars Placeholder => value map. - * - * @throws RuntimeException when the template file does not exist. - */ - public static function load(string $name, array $vars = []): string - { - $path = resource_path("ai/prompts/{$name}.md"); - - if (! is_file($path)) { - throw new RuntimeException("Missing AI prompt template: {$name} (expected at {$path})"); - } - - $template = (string) file_get_contents($path); - - if ($vars === []) { - return trim($template); - } - - $replacements = []; - foreach ($vars as $key => $value) { - $replacements['{{'.$key.'}}'] = (string) $value; - } - - return trim(strtr($template, $replacements)); - } -} diff --git a/app/Platform/Ai/routes/company.php b/app/Platform/Ai/routes/company.php deleted file mode 100644 index 7bc2d25b..00000000 --- a/app/Platform/Ai/routes/company.php +++ /dev/null @@ -1,26 +0,0 @@ -group(function () { - Route::post('/ai/chat', ChatController::class); - Route::get('/ai/conversations', [ConversationController::class, 'index']); - Route::get('/ai/conversations/{id}', [ConversationController::class, 'show']); - Route::patch('/ai/conversations/{id}', [ConversationController::class, 'update']); - Route::delete('/ai/conversations/{id}', [ConversationController::class, 'destroy']); - Route::post('/ai/generate', GenerationController::class); -}); diff --git a/app/Platform/Ai/routes/installer.php b/app/Platform/Ai/routes/installer.php deleted file mode 100644 index 11cd2706..00000000 --- a/app/Platform/Ai/routes/installer.php +++ /dev/null @@ -1,7 +0,0 @@ - */ + private const RESOURCE_MODELS = [ + 'customer' => Customer::class, + 'invoice' => Invoice::class, + 'expense' => Expense::class, + 'payment' => Payment::class, + 'item' => Item::class, + ]; + + public function allows(int $userId, int $companyId, string $ability, ?string $resource = null): bool + { + $user = User::query() + ->whereKey($userId) + ->whereHas('companies', fn ($query) => $query->whereKey($companyId)) + ->first(); + + if ($user === null) { + return false; + } + + if ($resource === null) { + return BouncerFacade::scope()->onceTo($companyId, fn (): bool => $user->can($ability)); + } + + $model = self::RESOURCE_MODELS[$resource] ?? throw new LogicException("Unknown module resource: {$resource}"); + + // Module calls are not necessarily made from an HTTP request, so they + // cannot rely on ScopeBouncer middleware having established this scope. + // Keep the caller's scope intact for long-running workers and tests. + return BouncerFacade::scope()->onceTo($companyId, fn (): bool => $user->can($ability, $model)); + } +} diff --git a/app/Platform/Modules/Infrastructure/EloquentCompanyDataReader.php b/app/Platform/Modules/Infrastructure/EloquentCompanyDataReader.php new file mode 100644 index 00000000..515b8abc --- /dev/null +++ b/app/Platform/Modules/Infrastructure/EloquentCompanyDataReader.php @@ -0,0 +1,297 @@ + [ + 'count' => Invoice::query()->where('company_id', $companyId)->where('type', Invoice::TYPE_INVOICE)->whereBetween('invoice_date', [$startDate, $endDate])->count(), + 'total' => (float) Invoice::query()->where('company_id', $companyId)->whereBetween('invoice_date', [$startDate, $endDate])->sum('total'), + ], + 'payments' => [ + 'count' => Payment::query()->where('company_id', $companyId)->whereBetween('payment_date', [$startDate, $endDate])->count(), + 'total' => (float) Payment::query()->where('company_id', $companyId)->whereBetween('payment_date', [$startDate, $endDate])->sum('amount'), + ], + 'expenses' => [ + 'count' => Expense::query()->where('company_id', $companyId)->whereBetween('expense_date', [$startDate, $endDate])->count(), + 'total' => (float) Expense::query()->where('company_id', $companyId)->whereBetween('expense_date', [$startDate, $endDate])->sum('amount'), + ], + ]; + } + + public function findCustomer(int $companyId, int $customerId): ?array + { + $customer = Customer::query() + ->where('company_id', $companyId) + ->whereKey($customerId) + ->with(['billingAddress', 'shippingAddress']) + ->first(); + + if ($customer === null) { + return null; + } + + return [ + 'id' => $customer->id, + 'name' => $customer->name, + 'display_name' => $customer->display_name, + 'email' => $customer->email, + 'phone' => $customer->phone, + 'contact_name' => $customer->contact_name, + 'company_name' => $customer->company_name, + 'website' => $customer->website, + 'enable_portal' => (bool) $customer->enable_portal, + 'billing_address' => $this->address($customer->billingAddress), + 'shipping_address' => $this->address($customer->shippingAddress), + 'totals' => [ + 'invoice_count' => Invoice::query()->where('company_id', $companyId)->where('customer_id', $customer->id)->where('type', Invoice::TYPE_INVOICE)->count(), + 'outstanding_amount' => (float) Invoice::query()->where('company_id', $companyId)->where('customer_id', $customer->id)->whereIn('paid_status', ['UNPAID', 'PARTIALLY_PAID'])->sum('due_amount'), + ], + ]; + } + + public function searchCustomers(int $companyId, ?string $query, int $limit): array + { + $customers = Customer::query()->where('company_id', $companyId)->orderBy('name')->limit($limit); + + if ($query !== null && $query !== '') { + $customers->where(function ($builder) use ($query) { + $builder->where('name', 'like', "%{$query}%") + ->orWhere('display_name', 'like', "%{$query}%") + ->orWhere('email', 'like', "%{$query}%") + ->orWhere('company_name', 'like', "%{$query}%") + ->orWhere('contact_name', 'like', "%{$query}%"); + }); + } + + return $customers->get()->map(fn (Customer $customer): array => [ + 'id' => $customer->id, + 'name' => $customer->name, + 'display_name' => $customer->display_name, + 'email' => $customer->email, + 'phone' => $customer->phone, + 'company_name' => $customer->company_name, + ])->all(); + } + + public function rankCustomers(int $companyId, string $metric, ?string $startDate, ?string $endDate, int $limit): array + { + $rows = match ($metric) { + 'invoiced_total' => $this->customerInvoiceRanking($companyId, $startDate, $endDate, $limit, 'SUM(total)'), + 'paid_total' => $this->customerPaymentRanking($companyId, $startDate, $endDate, $limit), + 'invoice_count' => $this->customerInvoiceRanking($companyId, $startDate, $endDate, $limit, 'COUNT(*)'), + 'outstanding_balance' => Invoice::query()->where('company_id', $companyId)->whereNotNull('customer_id')->whereIn('paid_status', ['UNPAID', 'PARTIALLY_PAID'])->selectRaw('customer_id, SUM(due_amount) as metric_value, COUNT(*) as invoice_count')->groupBy('customer_id')->orderByDesc('metric_value')->limit($limit)->get(), + }; + + $customers = Customer::query()->where('company_id', $companyId)->whereIn('id', $rows->pluck('customer_id'))->get()->keyBy('id'); + + return $rows->map(function ($row) use ($customers, $metric): array { + $customer = $customers->get($row->customer_id); + + return [ + 'customer_id' => (int) $row->customer_id, + 'name' => $customer?->name, + 'display_name' => $customer?->display_name, + 'company_name' => $customer?->company_name, + 'metric_value' => $metric === 'invoice_count' ? (int) $row->metric_value : (float) $row->metric_value, + 'invoice_count' => isset($row->invoice_count) ? (int) $row->invoice_count : null, + ]; + })->all(); + } + + public function findInvoice(int $companyId, string $invoiceNumber): ?array + { + $invoice = Invoice::query()->where('company_id', $companyId)->where('invoice_number', $invoiceNumber)->with(['customer:id,name,email,phone', 'items', 'taxes'])->first(); + + if ($invoice === null) { + return null; + } + + return [ + 'id' => $invoice->id, + 'invoice_number' => $invoice->invoice_number, + 'reference_number' => $invoice->reference_number, + 'status' => $invoice->status, + 'paid_status' => $invoice->paid_status, + 'invoice_date' => $this->date($invoice->invoice_date), + 'due_date' => $this->date($invoice->due_date), + 'sub_total' => $invoice->sub_total, + 'tax' => $invoice->tax, + 'discount' => $invoice->discount, + 'total' => $invoice->total, + 'due_amount' => $invoice->due_amount, + 'overdue' => (bool) $invoice->overdue, + 'notes' => $invoice->notes, + 'customer' => $invoice->customer ? ['id' => $invoice->customer->id, 'name' => $invoice->customer->name, 'email' => $invoice->customer->email, 'phone' => $invoice->customer->phone] : null, + 'items' => $invoice->items->map(fn ($item): array => ['name' => $item->name, 'description' => $item->description, 'quantity' => $item->quantity, 'price' => $item->price, 'total' => $item->total])->all(), + 'taxes' => $invoice->taxes->map(fn ($tax): array => ['name' => $tax->name, 'percent' => $tax->percent, 'amount' => $tax->amount])->all(), + ]; + } + + public function searchInvoices(int $companyId, ?string $query, ?string $status, ?int $customerId, int $limit): array + { + $invoices = Invoice::query()->where('company_id', $companyId)->with('customer:id,name')->latest('invoice_date')->limit($limit); + + if ($query !== null && $query !== '') { + $invoices->where(fn ($builder) => $builder->where('invoice_number', 'like', "%{$query}%")->orWhere('reference_number', 'like', "%{$query}%")); + } + + if ($status !== null && $status !== '') { + $status = strtoupper($status); + if (in_array($status, ['PAID', 'UNPAID', 'PARTIALLY_PAID'], true)) { + $invoices->where('paid_status', $status); + } elseif ($status === 'OVERDUE') { + $invoices->where('overdue', true); + } else { + $invoices->where('status', $status); + } + } + + if ($customerId !== null) { + $invoices->where('customer_id', $customerId); + } + + return $invoices->get()->map(fn (Invoice $invoice): array => $this->invoiceSummary($invoice))->all(); + } + + public function overdueInvoices(int $companyId, int $limit): array + { + return Invoice::query()->where('company_id', $companyId)->where('overdue', true)->with('customer:id,name')->orderBy('due_date')->limit($limit)->get()->map(fn (Invoice $invoice): array => $this->invoiceSummary($invoice))->all(); + } + + public function recentPayments(int $companyId, string $startDate, int $limit): array + { + return Payment::query()->where('company_id', $companyId)->where('payment_date', '>=', $startDate)->with(['allocations:id,payment_id,invoice_id,amount', 'customer:id,name', 'paymentMethod:id,name'])->latest('payment_date')->limit($limit)->get()->map(function (Payment $payment): array { + $allocated = (int) $payment->allocations->sum('amount'); + + return [ + 'id' => $payment->id, + 'payment_number' => $payment->payment_number, + 'payment_date' => $this->date($payment->payment_date), + 'amount' => $payment->amount, + 'customer_id' => $payment->customer_id, + 'customer_name' => $payment->customer?->name, + 'allocations' => $payment->allocations->map(fn ($allocation): array => ['invoice_id' => $allocation->invoice_id, 'amount' => $allocation->amount])->all(), + 'allocated_amount' => $allocated, + 'unallocated_amount' => (int) $payment->amount - $allocated, + 'payment_method' => $payment->paymentMethod?->name, + ]; + })->all(); + } + + public function expenseCategories(int $companyId): array + { + return ExpenseCategory::query()->where('company_id', $companyId)->orderBy('name')->get(['id', 'name', 'description'])->map(fn (ExpenseCategory $category): array => ['id' => $category->id, 'name' => $category->name, 'description' => $category->description])->all(); + } + + public function rankExpenseCategories(int $companyId, ?string $startDate, ?string $endDate, int $limit): array + { + $expenses = Expense::query()->where('company_id', $companyId)->whereNotNull('expense_category_id')->selectRaw('expense_category_id, SUM(amount) as total_amount, COUNT(*) as expense_count')->groupBy('expense_category_id')->orderByDesc('total_amount')->limit($limit); + + if ($startDate !== null && $endDate !== null) { + $expenses->whereBetween('expense_date', [$startDate, $endDate]); + } + + $rows = $expenses->get(); + $categories = ExpenseCategory::query()->where('company_id', $companyId)->whereIn('id', $rows->pluck('expense_category_id'))->get()->keyBy('id'); + + return $rows->map(fn ($row): array => ['expense_category_id' => (int) $row->expense_category_id, 'name' => $categories->get($row->expense_category_id)?->name, 'total_amount' => (float) $row->total_amount, 'expense_count' => (int) $row->expense_count])->all(); + } + + public function searchItems(int $companyId, ?string $query, int $limit): array + { + $items = Item::query()->where('company_id', $companyId)->orderBy('name')->limit($limit); + + if ($query !== null && $query !== '') { + $items->where(fn ($builder) => $builder->where('name', 'like', "%{$query}%")->orWhere('description', 'like', "%{$query}%")); + } + + return $items->get()->map(fn (Item $item): array => ['id' => $item->id, 'name' => $item->name, 'description' => $item->description, 'price' => $item->price])->all(); + } + + public function rankItems(int $companyId, string $metric, ?string $startDate, ?string $endDate, int $limit): array + { + $items = InvoiceItem::query()->join('invoices', 'invoice_items.invoice_id', '=', 'invoices.id')->where('invoices.company_id', $companyId)->whereNotNull('invoice_items.item_id')->selectRaw('invoice_items.item_id, SUM(invoice_items.quantity) as total_quantity, SUM(invoice_items.total) as total_revenue')->groupBy('invoice_items.item_id')->orderByDesc($metric === 'revenue' ? 'total_revenue' : 'total_quantity')->limit($limit); + + if ($startDate !== null && $endDate !== null) { + $items->whereBetween('invoices.invoice_date', [$startDate, $endDate]); + } + + $rows = $items->get(); + $catalog = Item::query()->where('company_id', $companyId)->whereIn('id', $rows->pluck('item_id'))->get()->keyBy('id'); + + return $rows->map(fn ($row): array => ['item_id' => (int) $row->item_id, 'name' => $catalog->get($row->item_id)?->name, 'quantity_sold' => (float) $row->total_quantity, 'revenue' => (float) $row->total_revenue])->all(); + } + + private function customerInvoiceRanking(int $companyId, ?string $startDate, ?string $endDate, int $limit, string $aggregate): Collection + { + $invoices = Invoice::query()->where('company_id', $companyId)->whereNotNull('customer_id')->selectRaw("customer_id, {$aggregate} as metric_value, COUNT(*) as invoice_count")->groupBy('customer_id')->orderByDesc('metric_value')->limit($limit); + + if ($startDate !== null && $endDate !== null) { + $invoices->whereBetween('invoice_date', [$startDate, $endDate]); + } + + return $invoices->get(); + } + + private function customerPaymentRanking(int $companyId, ?string $startDate, ?string $endDate, int $limit): Collection + { + $payments = Payment::query()->where('company_id', $companyId)->whereNotNull('customer_id')->selectRaw('customer_id, SUM(amount) as metric_value')->groupBy('customer_id')->orderByDesc('metric_value')->limit($limit); + + if ($startDate !== null && $endDate !== null) { + $payments->whereBetween('payment_date', [$startDate, $endDate]); + } + + return $payments->get(); + } + + private function invoiceSummary(Invoice $invoice): array + { + return [ + 'id' => $invoice->id, + 'invoice_number' => $invoice->invoice_number, + 'customer_id' => $invoice->customer_id, + 'customer_name' => $invoice->customer?->name, + 'invoice_date' => $this->date($invoice->invoice_date), + 'due_date' => $this->date($invoice->due_date), + 'status' => $invoice->status, + 'paid_status' => $invoice->paid_status, + 'total' => $invoice->total, + 'due_amount' => $invoice->due_amount, + 'overdue' => (bool) $invoice->overdue, + ]; + } + + private function address(?Address $address): ?array + { + if ($address === null) { + return null; + } + + return $address->only(['id', 'name', 'address_street_1', 'address_street_2', 'city', 'state', 'country_id', 'zip', 'phone', 'fax', 'type']); + } + + private function date(mixed $value): ?string + { + if ($value === null || $value === '') { + return null; + } + + return $value instanceof Carbon ? $value->toDateString() : substr((string) $value, 0, 10); + } +} diff --git a/app/Platform/Modules/Infrastructure/EloquentHostSettingsStore.php b/app/Platform/Modules/Infrastructure/EloquentHostSettingsStore.php new file mode 100644 index 00000000..fdb0c532 --- /dev/null +++ b/app/Platform/Modules/Infrastructure/EloquentHostSettingsStore.php @@ -0,0 +1,52 @@ +where('option', $key)->delete(); + } + + public function getCompany(int $companyId, string $key, mixed $default = null): mixed + { + return CompanySetting::getSetting($key, $companyId) ?? $default; + } + + public function putCompany(int $companyId, string $key, mixed $value): void + { + CompanySetting::setSettings([$key => $value], $companyId); + } + + public function deleteCompany(int $companyId, string $key): void + { + CompanySetting::query() + ->where('company_id', $companyId) + ->where('option', $key) + ->delete(); + } + + public function deleteCompanyForAll(string $key): void + { + CompanySetting::query()->where('option', $key)->delete(); + } +} diff --git a/app/Platform/Modules/ModuleServiceProvider.php b/app/Platform/Modules/ModuleServiceProvider.php index e7f0e64a..217ff1ec 100644 --- a/app/Platform/Modules/ModuleServiceProvider.php +++ b/app/Platform/Modules/ModuleServiceProvider.php @@ -5,16 +5,25 @@ namespace App\Platform\Modules; use App\Platform\Modules\Console\InstallModuleCommand; use App\Platform\Modules\Console\UninstallModuleCommand; use App\Platform\Modules\Contracts\ModuleSettingsStore; +use App\Platform\Modules\Infrastructure\BouncerModuleAuthorization; +use App\Platform\Modules\Infrastructure\EloquentCompanyDataReader; +use App\Platform\Modules\Infrastructure\EloquentHostSettingsStore; use App\Platform\Modules\Infrastructure\EloquentModuleSettingsStore; use App\Platform\Modules\Policies\ModulePolicy; use Illuminate\Support\Facades\Gate; use Illuminate\Support\ServiceProvider; +use InvoiceShelf\Modules\Contracts\Host\CompanyDataReader; +use InvoiceShelf\Modules\Contracts\Host\ModuleAuthorization; +use InvoiceShelf\Modules\Contracts\Host\SettingsStore; class ModuleServiceProvider extends ServiceProvider { public function register(): void { $this->app->bind(ModuleSettingsStore::class, EloquentModuleSettingsStore::class); + $this->app->bind(SettingsStore::class, EloquentHostSettingsStore::class); + $this->app->bind(ModuleAuthorization::class, BouncerModuleAuthorization::class); + $this->app->bind(CompanyDataReader::class, EloquentCompanyDataReader::class); } public function boot(): void diff --git a/app/Platform/Operations/Http/Company/BootstrapController.php b/app/Platform/Operations/Http/Company/BootstrapController.php index 8ea21284..2ca63675 100644 --- a/app/Platform/Operations/Http/Company/BootstrapController.php +++ b/app/Platform/Operations/Http/Company/BootstrapController.php @@ -9,7 +9,6 @@ use App\Domains\Accounts\Models\Company; use App\Domains\Accounts\Models\CompanyInvitation; use App\Domains\Accounts\Models\CompanySetting; use App\Domains\Money\Models\Currency; -use App\Platform\Ai\Application\AiConfigurationService; use App\Platform\Http\Controller; use App\Platform\Modules\Models\Module; use App\Platform\Operations\Http\Concerns\GeneratesMenu; @@ -121,8 +120,6 @@ 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, @@ -133,11 +130,6 @@ 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/Platform/Persistence/ModelIdentityMap.php b/app/Platform/Persistence/ModelIdentityMap.php index fc316e4a..ca65453b 100644 --- a/app/Platform/Persistence/ModelIdentityMap.php +++ b/app/Platform/Persistence/ModelIdentityMap.php @@ -34,8 +34,6 @@ use App\Domains\Sales\Models\InvoiceItem; use App\Domains\Sales\Models\RecurringInvoice; use App\Domains\Taxation\Models\Tax; use App\Domains\Taxation\Models\TaxType; -use App\Platform\Ai\Models\AiConversation; -use App\Platform\Ai\Models\AiMessage; use App\Platform\Mail\Models\EmailLog; use App\Platform\Modules\Models\MarketplaceCredential; use App\Platform\Modules\Models\MarketplaceOperation; @@ -70,8 +68,6 @@ final class ModelIdentityMap { return [ 'address' => Address::class, - 'ai_conversation' => AiConversation::class, - 'ai_message' => AiMessage::class, 'company' => Company::class, 'company_invitation' => CompanyInvitation::class, 'company_setting' => CompanySetting::class, diff --git a/bootstrap/providers.php b/bootstrap/providers.php index 20cd47b1..b6aa5df7 100644 --- a/bootstrap/providers.php +++ b/bootstrap/providers.php @@ -10,7 +10,6 @@ use App\Domains\Receivables\ReceivablesServiceProvider; use App\Domains\Reporting\ReportingServiceProvider; use App\Domains\Sales\SalesServiceProvider; use App\Domains\Taxation\TaxationServiceProvider; -use App\Platform\Ai\AiServiceProvider; use App\Platform\Mail\MailServiceProvider; use App\Platform\Modules\ModuleServiceProvider; use App\Platform\Operations\OperationsServiceProvider; @@ -39,7 +38,6 @@ return [ SalesServiceProvider::class, TaxationServiceProvider::class, ReportingServiceProvider::class, - AiServiceProvider::class, MailServiceProvider::class, OperationsServiceProvider::class, ModuleServiceProvider::class, diff --git a/composer.json b/composer.json index 561f827b..3c702762 100644 --- a/composer.json +++ b/composer.json @@ -14,7 +14,7 @@ "gotenberg/gotenberg-php": "^2.8", "guzzlehttp/guzzle": "^7.9", "hashids/hashids": "^5.0", - "invoiceshelf/modules": "^3.2", + "invoiceshelf/modules": "^3.3", "laravel/framework": "^13.0", "laravel/helpers": "^1.7", "laravel/sanctum": "^4.0", diff --git a/composer.lock b/composer.lock index 58b50675..03df7623 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "39311493e2ff6efae8c6be98b23a5687", + "content-hash": "c72ff845827581c6cc675c52d10509d3", "packages": [ { "name": "aws/aws-crt-php", @@ -1735,16 +1735,16 @@ }, { "name": "invoiceshelf/modules", - "version": "3.2.0", + "version": "3.3.0", "source": { "type": "git", "url": "https://github.com/InvoiceShelf/modules.git", - "reference": "bd6ae29a25bdbe3f395572b95c59843dcc52ac50" + "reference": "87f33519e14df47314dc8bcb7abbd26fd14976eb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/InvoiceShelf/modules/zipball/bd6ae29a25bdbe3f395572b95c59843dcc52ac50", - "reference": "bd6ae29a25bdbe3f395572b95c59843dcc52ac50", + "url": "https://api.github.com/repos/InvoiceShelf/modules/zipball/87f33519e14df47314dc8bcb7abbd26fd14976eb", + "reference": "87f33519e14df47314dc8bcb7abbd26fd14976eb", "shasum": "" }, "require": { @@ -1796,10 +1796,10 @@ "modules" ], "support": { - "source": "https://github.com/InvoiceShelf/modules/tree/3.2.0", + "source": "https://github.com/InvoiceShelf/modules/tree/3.3.0", "issues": "https://github.com/InvoiceShelf/modules/issues" }, - "time": "2026-08-05T09:35:08+00:00" + "time": "2026-08-05T19:47:16+00:00" }, { "name": "laravel/framework", diff --git a/config/invoiceshelf.php b/config/invoiceshelf.php index 449cedc7..ff0f600c 100644 --- a/config/invoiceshelf.php +++ b/config/invoiceshelf.php @@ -67,7 +67,7 @@ return [ */ 'marketplace' => [ 'channel' => env('MARKETPLACE_CHANNEL', 'stable'), - 'module_api_version' => (string) env('MARKETPLACE_MODULE_API_VERSION', '1.1.0'), + 'module_api_version' => (string) env('MARKETPLACE_MODULE_API_VERSION', '1.2.0'), // JSON object: {"key-id":"base64-ed25519-public-key"}. Keys add to // (or replace values in) the built-in pinned map. Key identity is part // of the signed release and must match this trusted map. @@ -317,16 +317,6 @@ 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/database/migrations/2026_04_11_154445_create_ai_conversations_and_messages_tables.php b/database/migrations/2026_04_11_154445_create_ai_conversations_and_messages_tables.php deleted file mode 100644 index 75c9267f..00000000 --- a/database/migrations/2026_04_11_154445_create_ai_conversations_and_messages_tables.php +++ /dev/null @@ -1,59 +0,0 @@ -id(); - $table->unsignedInteger('company_id'); - $table->unsignedInteger('user_id'); - $table->string('title')->nullable(); - $table->string('model', 100)->nullable(); - $table->timestamps(); - - // List my conversations, most recently updated first - $table->index(['company_id', 'user_id', 'updated_at']); - }); - - Schema::create('ai_messages', function (Blueprint $table) { - $table->id(); - $table->foreignId('conversation_id') - ->constrained('ai_conversations') - ->cascadeOnDelete(); - - // OpenAI chat message roles. Persisted as string (not enum) so future - // roles don't require a migration — the application layer validates. - $table->string('role', 20); - - $table->longText('content')->nullable(); - - // For role=tool messages: which tool_call_id from the assistant turn this answers. - $table->string('tool_call_id')->nullable(); - - // For role=assistant messages that requested tool execution: the parsed tool_calls array. - $table->json('tool_calls')->nullable(); - - // Which model produced this turn (nullable for user/tool messages). - $table->string('model', 100)->nullable(); - - // For future cost tracking dashboards. - $table->unsignedInteger('tokens_in')->nullable(); - $table->unsignedInteger('tokens_out')->nullable(); - - $table->timestamp('created_at')->useCurrent(); - - $table->index(['conversation_id', 'created_at']); - }); - } - - public function down(): void - { - Schema::dropIfExists('ai_messages'); - Schema::dropIfExists('ai_conversations'); - } -}; diff --git a/database/migrations/2026_08_05_120000_stabilize_model_type_aliases.php b/database/migrations/2026_08_05_120000_stabilize_model_type_aliases.php index 720301f2..34224aa7 100644 --- a/database/migrations/2026_08_05_120000_stabilize_model_type_aliases.php +++ b/database/migrations/2026_08_05_120000_stabilize_model_type_aliases.php @@ -31,8 +31,6 @@ return new class extends Migration */ public const FIRST_PARTY_ALIASES = [ 'address' => 'Address', - 'ai_conversation' => 'AiConversation', - 'ai_message' => 'AiMessage', 'company' => 'Company', 'company_invitation' => 'CompanyInvitation', 'company_setting' => 'CompanySetting', diff --git a/database/seeders/RealisticDemoSeeder.php b/database/seeders/RealisticDemoSeeder.php index 94ba0401..6b84d048 100644 --- a/database/seeders/RealisticDemoSeeder.php +++ b/database/seeders/RealisticDemoSeeder.php @@ -29,7 +29,6 @@ use App\Domains\Sales\Models\RecurringInvoice; use App\Domains\Taxation\Models\Tax; use App\Domains\Taxation\Models\TaxType; use App\Facades\Hashids; -use App\Platform\Ai\Models\AiConversation; use App\Support\Hashids\HashidConnection; use Carbon\Carbon; use Illuminate\Database\Seeder; @@ -42,8 +41,7 @@ use RuntimeException; * Populates the demo company with ~100 realistic records (8 customers, 12 * catalog items, 6 expense categories, 35 invoices, ~20 payments, 8 estimates, * 15 expenses, 2 tax types, a notes library and a recurring invoice) so the app - * looks like a real install during local development, and so the AI chat - * assistant has meaningful data to query. + * looks like a real install during local development. * * This seeder is intentionally NOT wired into DatabaseSeeder and is NOT used * by the test suite (the minimal DemoSeeder remains in the test path to keep @@ -67,9 +65,8 @@ use RuntimeException; * - Item prices and all monetary columns are stored in **cents**. A $250 * item has `price = 25000`. The frontend divides by 100 for display. * - * - Dates are deliberately distributed over the last 6 months so that - * AI tool queries like `get_company_stats(period=this_month)` vs - * `get_company_stats(period=last_month)` return different numbers. + * - Dates are deliberately distributed over the last 6 months to make the + * reporting views useful during local development. * * - Invoice totals are computed from line items, not random. Tax is applied * at document level (tax_per_item = 'NO') to most but not all documents, @@ -241,7 +238,6 @@ class RealisticDemoSeeder extends Seeder */ private function cleanupExistingDemoData(): void { - AiConversation::where('company_id', $this->companyId)->delete(); // cascades to ai_messages $paymentIds = Payment::where('company_id', $this->companyId)->pluck('id'); PaymentAllocation::whereIn('payment_id', $paymentIds)->delete(); Payment::whereIn('id', $paymentIds)->delete(); diff --git a/lang/en.json b/lang/en.json index 397e3cef..52938781 100644 --- a/lang/en.json +++ b/lang/en.json @@ -1000,8 +1000,7 @@ "address_information": "Address Information", "pdf_generation": "PDF Generation", "appearance": "Appearance", - "module_configuration": "Module Configuration", - "ai_configuration": "AI Configuration" + "module_configuration": "Module Configuration" }, "appearance": { "title": "Appearance", @@ -1009,49 +1008,6 @@ "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.", - "ai_disabled": "AI is not enabled for this company.", - "chat_disabled": "The chat assistant is not enabled for this company.", - "text_generation_disabled": "Text generation is not enabled for this company.", - "missing_model": "No model is configured." - } - }, "address_information": { "section_description": " You can update Your Address information using form below." }, @@ -2039,30 +1995,5 @@ "impersonating_banner": "You are currently impersonating a user. All actions are logged.", "stop_impersonating": "Stop Impersonating" } - }, - "ai": { - "chat": { - "title": "AI Assistant", - "new_conversation": "New conversation", - "no_conversations": "No conversations yet.", - "untitled": "Untitled", - "empty_state": "Ask me anything about your invoices, customers, payments, or expenses.", - "thinking": "Thinking…", - "send": "Send", - "sending": "Sending…", - "input_placeholder": "Ask about your invoices, customers, or payments…" - }, - "generate": { - "title": "AI Text Generation", - "prompt_label": "What should the AI write?", - "prompt_placeholder": "e.g. a polite late-payment reminder for an invoice that's 10 days overdue", - "use_current_as_context": "Use current content as context", - "use_context_help": "When enabled, the AI will see the editor's current text and can rewrite or extend it.", - "preview": "Preview", - "generate": "Generate", - "regenerate": "Regenerate", - "insert": "Insert", - "replace": "Replace" - } } } diff --git a/package.json b/package.json index 6aa474f0..e0ecb310 100644 --- a/package.json +++ b/package.json @@ -51,7 +51,6 @@ "dompurify": "^3.4.9", "laravel-vite-plugin": "^3.0.0", "lodash": "^4.17.21", - "marked": "^18.0.5", "pinia": "^3.0.0", "v-money3": "^3.24.1", "v-tooltip": "^4.0.0-beta.17", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index eb9f8f12..b2bc8405 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -63,9 +63,6 @@ importers: lodash: specifier: ^4.17.21 version: 4.18.1 - marked: - specifier: ^18.0.5 - version: 18.0.5 pinia: specifier: ^3.0.0 version: 3.0.4(typescript@6.0.2)(vue@3.5.31(typescript@6.0.2)) @@ -1313,11 +1310,6 @@ packages: resolution: {integrity: sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==} hasBin: true - marked@18.0.5: - resolution: {integrity: sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w==} - engines: {node: '>= 20'} - hasBin: true - math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} @@ -2929,8 +2921,6 @@ snapshots: punycode.js: 2.3.1 uc.micro: 2.1.0 - marked@18.0.5: {} - math-intrinsics@1.1.0: {} mdurl@2.0.0: {} diff --git a/public/openapi.json b/public/openapi.json index 3ee72bf7..aec1160c 100644 --- a/public/openapi.json +++ b/public/openapi.json @@ -202,490 +202,6 @@ } } }, - "/installation/ai/config": { - "get": { - "operationId": "aiConfiguration.show", - "summary": "Return the current AI config defaults plus the driver list for the wizard form", - "tags": [ - "AiConfiguration" - ], - "responses": { - "200": { - "description": "", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "config": { - "type": "array", - "items": {} - }, - "drivers": { - "type": "array", - "items": { - "type": "object", - "properties": { - "value": { - "type": "string" - }, - "label": {}, - "website": {}, - "default_base_url": {}, - "supported_roles": {}, - "suggested_models": {}, - "config_fields": {} - }, - "required": [ - "value", - "label", - "website", - "default_base_url", - "supported_roles", - "suggested_models", - "config_fields" - ] - } - } - }, - "required": [ - "config", - "drivers" - ] - } - } - } - } - } - }, - "post": { - "operationId": "aiConfiguration.save", - "summary": "Persist the installer's AI config choice and advance the wizard step", - "tags": [ - "AiConfiguration" - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "ai_enabled": { - "type": "string", - "enum": [ - "YES", - "NO" - ] - }, - "ai_driver": { - "type": [ - "string", - "null" - ] - }, - "ai_api_key": { - "type": [ - "string", - "null" - ] - }, - "ai_base_url": { - "type": [ - "string", - "null" - ], - "format": "uri" - }, - "ai_chat_enabled": { - "type": [ - "string", - "null" - ], - "enum": [ - "YES", - "NO" - ] - }, - "ai_chat_model": { - "type": [ - "string", - "null" - ], - "maxLength": 200 - }, - "ai_text_generation_enabled": { - "type": [ - "string", - "null" - ], - "enum": [ - "YES", - "NO" - ] - }, - "ai_text_generation_model": { - "type": [ - "string", - "null" - ], - "maxLength": 200 - } - }, - "required": [ - "ai_enabled" - ] - } - } - } - }, - "responses": { - "200": { - "description": "", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean" - } - }, - "required": [ - "success" - ] - } - } - } - }, - "422": { - "$ref": "#/components/responses/ValidationException" - } - } - } - }, - "/ai/drivers": { - "get": { - "operationId": "aiConfiguration.getDrivers", - "summary": "Return the AI driver list for the admin UI \u2014 same shape as the exchange rate endpoint", - "tags": [ - "AiConfiguration" - ], - "parameters": [ - { - "name": "company", - "in": "header", - "required": true, - "description": "ID of the company the request operates on (multi-tenancy).", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "ai_drivers": { - "type": "array", - "items": { - "type": "object", - "properties": { - "value": { - "type": "string" - }, - "label": {}, - "website": {}, - "default_base_url": {}, - "supported_roles": {}, - "suggested_models": {}, - "config_fields": {} - }, - "required": [ - "value", - "label", - "website", - "default_base_url", - "supported_roles", - "suggested_models", - "config_fields" - ] - } - } - }, - "required": [ - "ai_drivers" - ] - } - } - } - }, - "403": { - "$ref": "#/components/responses/AuthorizationException" - }, - "401": { - "$ref": "#/components/responses/AuthenticationException" - } - } - } - }, - "/ai/config": { - "get": { - "operationId": "aiConfiguration.getConfig", - "summary": "Get the global AI configuration with decrypted API key masked for response", - "tags": [ - "AiConfiguration" - ], - "parameters": [ - { - "name": "company", - "in": "header", - "required": true, - "description": "ID of the company the request operates on (multi-tenancy).", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": {} - } - } - } - }, - "403": { - "$ref": "#/components/responses/AuthorizationException" - }, - "401": { - "$ref": "#/components/responses/AuthenticationException" - } - } - }, - "post": { - "operationId": "aiConfiguration.saveConfig", - "description": "If the submitted api_key is the masked placeholder, we retain the stored value \u2014\notherwise the user would have to re-enter the key every time they save the form.", - "summary": "Persist the global AI configuration", - "tags": [ - "AiConfiguration" - ], - "parameters": [ - { - "name": "company", - "in": "header", - "required": true, - "description": "ID of the company the request operates on (multi-tenancy).", - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "use_custom_ai_config": { - "type": "string" - }, - "ai_enabled": { - "type": [ - "string", - "null" - ], - "enum": [ - "YES", - "NO" - ] - }, - "ai_driver": { - "type": [ - "string", - "null" - ], - "enum": [ - "openrouter" - ] - }, - "ai_api_key": { - "type": [ - "string", - "null" - ] - }, - "ai_base_url": { - "type": [ - "string", - "null" - ], - "format": "uri" - }, - "ai_chat_enabled": { - "type": [ - "string", - "null" - ], - "enum": [ - "YES", - "NO" - ] - }, - "ai_chat_model": { - "type": [ - "string", - "null" - ], - "maxLength": 200 - }, - "ai_text_generation_enabled": { - "type": [ - "string", - "null" - ], - "enum": [ - "YES", - "NO" - ] - }, - "ai_text_generation_model": { - "type": [ - "string", - "null" - ], - "maxLength": 200 - } - } - } - } - } - }, - "responses": { - "200": { - "description": "", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "success": { - "type": "string", - "const": "ai_variables_save_successfully" - } - }, - "required": [ - "success" - ] - } - } - } - }, - "403": { - "$ref": "#/components/responses/AuthorizationException" - }, - "422": { - "$ref": "#/components/responses/ValidationException" - }, - "401": { - "$ref": "#/components/responses/AuthenticationException" - } - } - } - }, - "/ai/test": { - "post": { - "operationId": "aiConfiguration.testConnection", - "summary": "Test the currently configured AI provider by instantiating its driver and calling validateConnection()", - "tags": [ - "AiConfiguration" - ], - "parameters": [ - { - "name": "company", - "in": "header", - "required": true, - "description": "ID of the company the request operates on (multi-tenancy).", - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "ai_driver": { - "type": "string" - }, - "ai_api_key": { - "type": [ - "string", - "null" - ] - }, - "ai_base_url": { - "type": [ - "string", - "null" - ], - "format": "uri" - } - }, - "required": [ - "ai_driver" - ] - } - } - } - }, - "responses": { - "200": { - "description": "", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean" - }, - "details": { - "type": "object", - "additionalProperties": {} - } - }, - "required": [ - "success", - "details" - ] - } - } - } - }, - "422": { - "$ref": "#/components/responses/ValidationException" - }, - "403": { - "$ref": "#/components/responses/AuthorizationException" - }, - "401": { - "$ref": "#/components/responses/AuthenticationException" - } - } - } - }, "/installation/set-domain": { "put": { "operationId": "setup.appDomain", @@ -1241,25 +757,6 @@ "type": "string" } }, - "ai": { - "type": "object", - "properties": { - "enabled": { - "type": "boolean" - }, - "chat_enabled": { - "type": "boolean" - }, - "text_generation_enabled": { - "type": "boolean" - } - }, - "required": [ - "enabled", - "chat_enabled", - "text_generation_enabled" - ] - }, "main_menu": { "type": "array", "items": { @@ -1495,7 +992,6 @@ "current_company_currency", "config", "global_settings", - "ai", "main_menu", "setting_menu", "modules", @@ -1902,139 +1398,6 @@ } } }, - "/ai/chat": { - "post": { - "operationId": "ai.chat", - "description": "If `conversation_id` is omitted (or belongs to a conversation the user\ndoesn't own), we start a fresh conversation scoped to the current\n(company_id, user_id). This matches the \"new chat\" UX where the user\nopens the drawer and starts typing immediately.", - "summary": "Send a message into a conversation and get the assistant's reply", - "tags": [ - "Chat" - ], - "parameters": [ - { - "name": "company", - "in": "header", - "required": true, - "description": "ID of the company the request operates on (multi-tenancy).", - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "conversation_id": { - "type": [ - "integer", - "null" - ] - }, - "message": { - "type": "string", - "maxLength": 10000 - } - }, - "required": [ - "message" - ] - } - } - } - }, - "responses": { - "200": { - "description": "", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "conversation": { - "type": "object", - "properties": { - "id": { - "type": "integer" - }, - "title": { - "type": [ - "string", - "null" - ] - }, - "model": { - "type": [ - "string", - "null" - ] - }, - "updated_at": { - "type": [ - "string", - "null" - ], - "format": "date-time" - } - }, - "required": [ - "id", - "title", - "model", - "updated_at" - ] - }, - "message": { - "type": "object", - "properties": { - "id": { - "type": "integer" - }, - "role": { - "type": "string" - }, - "content": { - "type": [ - "string", - "null" - ] - }, - "created_at": { - "type": "string", - "format": "date-time" - } - }, - "required": [ - "id", - "role", - "content", - "created_at" - ] - } - }, - "required": [ - "conversation", - "message" - ] - } - } - } - }, - "422": { - "$ref": "#/components/responses/ValidationException" - }, - "403": { - "$ref": "#/components/responses/AuthorizationException" - }, - "401": { - "$ref": "#/components/responses/AuthenticationException" - } - } - } - }, "/super-admin/companies": { "get": { "operationId": "companies.index", @@ -2569,262 +1932,6 @@ } } }, - "/company/ai/config": { - "get": { - "operationId": "companyAiConfiguration.getConfig", - "summary": "Get the per-company AI config with decrypted API key masked for response", - "tags": [ - "CompanyAiConfiguration" - ], - "parameters": [ - { - "name": "company", - "in": "header", - "required": true, - "description": "ID of the company the request operates on (multi-tenancy).", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "", - "content": { - "application/json": { - "schema": { - "type": "array", - "items": {} - } - } - } - }, - "401": { - "$ref": "#/components/responses/AuthenticationException" - } - } - }, - "post": { - "operationId": "companyAiConfiguration.saveConfig", - "description": "Respects the `use_custom_ai_config` toggle \u2014 when OFF, only the toggle is written\nand the driver fields are discarded (same pattern as the mail company override).", - "summary": "Persist the per-company AI config", - "tags": [ - "CompanyAiConfiguration" - ], - "parameters": [ - { - "name": "company", - "in": "header", - "required": true, - "description": "ID of the company the request operates on (multi-tenancy).", - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "use_custom_ai_config": { - "type": [ - "string", - "null" - ], - "enum": [ - "YES", - "NO" - ] - }, - "ai_enabled": { - "type": [ - "string", - "null" - ], - "enum": [ - "YES", - "NO" - ] - }, - "ai_driver": { - "type": [ - "string", - "null" - ], - "enum": [ - "openrouter" - ] - }, - "ai_api_key": { - "type": [ - "string", - "null" - ] - }, - "ai_base_url": { - "type": [ - "string", - "null" - ], - "format": "uri" - }, - "ai_chat_enabled": { - "type": [ - "string", - "null" - ], - "enum": [ - "YES", - "NO" - ] - }, - "ai_chat_model": { - "type": [ - "string", - "null" - ], - "maxLength": 200 - }, - "ai_text_generation_enabled": { - "type": [ - "string", - "null" - ], - "enum": [ - "YES", - "NO" - ] - }, - "ai_text_generation_model": { - "type": [ - "string", - "null" - ], - "maxLength": 200 - } - } - } - } - } - }, - "responses": { - "200": { - "description": "", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean" - } - }, - "required": [ - "success" - ] - } - } - } - }, - "403": { - "$ref": "#/components/responses/AuthorizationException" - }, - "422": { - "$ref": "#/components/responses/ValidationException" - }, - "401": { - "$ref": "#/components/responses/AuthenticationException" - } - } - } - }, - "/company/ai/test": { - "post": { - "operationId": "companyAiConfiguration.testConnection", - "summary": "Test a company-level AI configuration without persisting it", - "tags": [ - "CompanyAiConfiguration" - ], - "parameters": [ - { - "name": "company", - "in": "header", - "required": true, - "description": "ID of the company the request operates on (multi-tenancy).", - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "ai_driver": { - "type": "string" - }, - "ai_api_key": { - "type": [ - "string", - "null" - ] - }, - "ai_base_url": { - "type": [ - "string", - "null" - ], - "format": "uri" - } - }, - "required": [ - "ai_driver" - ] - } - } - } - }, - "responses": { - "200": { - "description": "", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean" - }, - "details": { - "type": "object", - "additionalProperties": {} - } - }, - "required": [ - "success", - "details" - ] - } - } - } - }, - "422": { - "$ref": "#/components/responses/ValidationException" - }, - "403": { - "$ref": "#/components/responses/AuthorizationException" - }, - "401": { - "$ref": "#/components/responses/AuthenticationException" - } - } - } - }, "/company/mail/config": { "get": { "operationId": "companyMailConfiguration.getDefaultConfig", @@ -3471,280 +2578,6 @@ } } }, - "/ai/conversations": { - "get": { - "operationId": "conversation.index", - "summary": "List the current user's conversations for the current company", - "tags": [ - "Conversation" - ], - "parameters": [ - { - "name": "company", - "in": "header", - "required": true, - "description": "ID of the company the request operates on (multi-tenancy).", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "conversations": { - "type": "array", - "items": { - "$ref": "#/components/schemas/AiConversation" - } - } - }, - "required": [ - "conversations" - ] - } - } - } - }, - "403": { - "$ref": "#/components/responses/AuthorizationException" - }, - "401": { - "$ref": "#/components/responses/AuthenticationException" - } - } - } - }, - "/ai/conversations/{id}": { - "get": { - "operationId": "conversation.show", - "summary": "Show a single conversation with its full message history", - "tags": [ - "Conversation" - ], - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "integer" - } - }, - { - "name": "company", - "in": "header", - "required": true, - "description": "ID of the company the request operates on (multi-tenancy).", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "conversation": { - "type": "object", - "properties": { - "id": { - "type": "integer" - }, - "title": { - "type": [ - "string", - "null" - ] - }, - "model": { - "type": [ - "string", - "null" - ] - }, - "created_at": { - "type": [ - "string", - "null" - ], - "format": "date-time" - }, - "updated_at": { - "type": [ - "string", - "null" - ], - "format": "date-time" - } - }, - "required": [ - "id", - "title", - "model", - "created_at", - "updated_at" - ] - }, - "messages": { - "type": "array", - "items": { - "$ref": "#/components/schemas/AiMessage" - } - } - }, - "required": [ - "conversation", - "messages" - ] - } - } - } - }, - "403": { - "$ref": "#/components/responses/AuthorizationException" - }, - "401": { - "$ref": "#/components/responses/AuthenticationException" - } - } - }, - "patch": { - "operationId": "conversation.update", - "summary": "Rename a conversation", - "tags": [ - "Conversation" - ], - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "integer" - } - }, - { - "name": "company", - "in": "header", - "required": true, - "description": "ID of the company the request operates on (multi-tenancy).", - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "title": { - "type": "string", - "maxLength": 255 - } - }, - "required": [ - "title" - ] - } - } - } - }, - "responses": { - "200": { - "description": "", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean" - } - }, - "required": [ - "success" - ] - } - } - } - }, - "403": { - "$ref": "#/components/responses/AuthorizationException" - }, - "422": { - "$ref": "#/components/responses/ValidationException" - }, - "401": { - "$ref": "#/components/responses/AuthenticationException" - } - } - }, - "delete": { - "operationId": "conversation.destroy", - "summary": "Delete a conversation (cascades to messages via DB foreign key)", - "tags": [ - "Conversation" - ], - "parameters": [ - { - "name": "id", - "in": "path", - "required": true, - "schema": { - "type": "integer" - } - }, - { - "name": "company", - "in": "header", - "required": true, - "description": "ID of the company the request operates on (multi-tenancy).", - "schema": { - "type": "string" - } - } - ], - "responses": { - "200": { - "description": "", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "success": { - "type": "boolean" - } - }, - "required": [ - "success" - ] - } - } - } - }, - "403": { - "$ref": "#/components/responses/AuthorizationException" - }, - "401": { - "$ref": "#/components/responses/AuthenticationException" - } - } - } - }, "/countries": { "get": { "operationId": "admin.countries_0", @@ -15674,82 +14507,6 @@ } } }, - "/ai/generate": { - "post": { - "operationId": "ai.generation", - "description": "Stateless \u2014 nothing is persisted. Each call is fully self-contained.\nRate-limited via the shared 'ai' limiter so a stuck client can't\nhammer the provider.", - "summary": "One-shot text generation for the WYSIWYG popup", - "tags": [ - "Generation" - ], - "parameters": [ - { - "name": "company", - "in": "header", - "required": true, - "description": "ID of the company the request operates on (multi-tenancy).", - "schema": { - "type": "string" - } - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "prompt": { - "type": "string", - "maxLength": 4000 - }, - "context": { - "type": [ - "string", - "null" - ], - "maxLength": 20000 - } - }, - "required": [ - "prompt" - ] - } - } - } - }, - "responses": { - "200": { - "description": "", - "content": { - "application/json": { - "schema": { - "type": "object", - "properties": { - "text": { - "type": "string" - } - }, - "required": [ - "text" - ] - } - } - } - }, - "422": { - "$ref": "#/components/responses/ValidationException" - }, - "403": { - "$ref": "#/components/responses/AuthorizationException" - }, - "401": { - "$ref": "#/components/responses/AuthenticationException" - } - } - } - }, "/company-invitations": { "get": { "operationId": "company-invitations.index", @@ -24182,124 +22939,6 @@ ], "title": "AdminUserUpdateRequest" }, - "AiConversation": { - "type": "object", - "properties": { - "id": { - "type": "integer" - }, - "company_id": { - "type": "integer" - }, - "user_id": { - "type": "integer" - }, - "title": { - "type": [ - "string", - "null" - ] - }, - "model": { - "type": [ - "string", - "null" - ] - }, - "created_at": { - "type": [ - "string", - "null" - ], - "format": "date-time" - }, - "updated_at": { - "type": [ - "string", - "null" - ], - "format": "date-time" - } - }, - "required": [ - "id", - "company_id", - "user_id", - "title", - "model", - "created_at", - "updated_at" - ], - "title": "AiConversation" - }, - "AiMessage": { - "type": "object", - "properties": { - "id": { - "type": "integer" - }, - "conversation_id": { - "type": "integer" - }, - "role": { - "type": "string" - }, - "content": { - "type": [ - "string", - "null" - ] - }, - "tool_call_id": { - "type": [ - "string", - "null" - ] - }, - "tool_calls": { - "type": [ - "array", - "null" - ], - "items": {} - }, - "model": { - "type": [ - "string", - "null" - ] - }, - "tokens_in": { - "type": [ - "integer", - "null" - ] - }, - "tokens_out": { - "type": [ - "integer", - "null" - ] - }, - "created_at": { - "type": "string", - "format": "date-time" - } - }, - "required": [ - "id", - "conversation_id", - "role", - "content", - "tool_call_id", - "tool_calls", - "model", - "tokens_in", - "tokens_out", - "created_at" - ], - "title": "AiMessage" - }, "App.Http.Resources.Customer.AddressResource": { "type": "object", "properties": { diff --git a/resources/scripts/InvoiceShelf.ts b/resources/scripts/InvoiceShelf.ts index 58877b1c..350ad248 100644 --- a/resources/scripts/InvoiceShelf.ts +++ b/resources/scripts/InvoiceShelf.ts @@ -3,18 +3,37 @@ import type { App } from 'vue' import type { Router } from 'vue-router' import App_ from './App.vue' import router from './router' -import { createAppI18n, setI18nLanguage } from './plugins/i18n' +import { createAppI18n, mergeMessageObjects, setI18nLanguage } from './plugins/i18n' import type { AppI18n } from './plugins/i18n' import { createAppPinia } from './plugins/pinia' import { installTooltipDirective } from './plugins/tooltip' import { defineGlobalComponents } from './global-components' +import { createExtensionApi } from './extensions/runtime' +import type { InvoiceShelfExtensionApi } from './extensions/types' + +export type { + BootstrapCompletedEvent, + CompanyChangeEvent, + ComponentExtensionContribution, + ExtensionContribution, + ExtensionVisibilityPredicate, + InvoiceShelfExtensionApi, + InvoiceShelfExtensionEvents, + RichEditorContext, + SettingsNavigationContribution, + SettingsPageContribution, +} from './extensions/types' /** * Callback signature for the `booting` hook. * Receives the Vue app instance and the router so that modules / * plugins can register additional routes, components, or providers. */ -type BootCallback = (app: App, router: Router) => void +export type BootCallback = ( + app: App, + router: Router, + extensions: InvoiceShelfExtensionApi, +) => void /** * Bootstrap class for InvoiceShelf. @@ -31,9 +50,12 @@ export default class InvoiceShelf { private messages: Record> = {} private i18n: AppI18n | null = null private app: App + private readonly extensions: InvoiceShelfExtensionApi constructor() { this.app = createApp(App_) + this.extensions = createExtensionApi(router) + window.addEventListener('pagehide', () => this.extensions.reset(), { once: true }) } /** @@ -47,11 +69,9 @@ export default class InvoiceShelf { * Merge additional i18n message bundles (typically from modules). */ addMessages(moduleMessages: Record>): void { + this.extensions.addMessages(moduleMessages) for (const [locale, msgs] of Object.entries(moduleMessages)) { - this.messages[locale] = { - ...this.messages[locale], - ...msgs, - } + this.messages[locale] = mergeMessageObjects(this.messages[locale] ?? {}, msgs) } } @@ -106,7 +126,7 @@ export default class InvoiceShelf { private executeCallbacks(): void { for (const callback of this.bootingCallbacks) { - callback(this.app, router) + callback(this.app, router, this.extensions) } } diff --git a/resources/scripts/api/endpoints.ts b/resources/scripts/api/endpoints.ts index b12bf3ab..d380cb18 100644 --- a/resources/scripts/api/endpoints.ts +++ b/resources/scripts/api/endpoints.ts @@ -116,25 +116,6 @@ 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', - - // AI Chat (Phase 2) - AI_CHAT: '/api/v1/ai/chat', - AI_CONVERSATIONS: '/api/v1/ai/conversations', - - // AI Text Generation (Phase 3) - AI_GENERATE: '/api/v1/ai/generate', - // 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 deleted file mode 100644 index 8ba429e8..00000000 --- a/resources/scripts/api/services/ai.service.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { client } from '../client' -import { API } from '../endpoints' -import type { - AiChatSendResponse, - AiConfig, - AiConversationDetail, - AiConversationSummary, - AiDriversResponse, - AiGenerateRequest, - AiGenerateResponse, - 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 - }, - - // --- Phase 2: chat --- - - async sendChatMessage( - conversationId: number | null, - message: string, - ): Promise { - const { data } = await client.post(API.AI_CHAT, { - conversation_id: conversationId, - message, - }) - return data - }, - - async listConversations(): Promise<{ conversations: AiConversationSummary[] }> { - const { data } = await client.get(API.AI_CONVERSATIONS) - return data - }, - - async getConversation(id: number): Promise { - const { data } = await client.get(`${API.AI_CONVERSATIONS}/${id}`) - return data - }, - - async renameConversation(id: number, title: string): Promise<{ success: boolean }> { - const { data } = await client.patch(`${API.AI_CONVERSATIONS}/${id}`, { title }) - return data - }, - - async deleteConversation(id: number): Promise<{ success: boolean }> { - const { data } = await client.delete(`${API.AI_CONVERSATIONS}/${id}`) - return data - }, - - // --- Phase 3: text generation --- - - async generateText(payload: AiGenerateRequest): Promise { - const { data } = await client.post(API.AI_GENERATE, payload) - return data - }, -} diff --git a/resources/scripts/api/services/bootstrap.service.ts b/resources/scripts/api/services/bootstrap.service.ts index c739c501..e11d9268 100644 --- a/resources/scripts/api/services/bootstrap.service.ts +++ b/resources/scripts/api/services/bootstrap.service.ts @@ -29,11 +29,6 @@ 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/components/editor/RichEditor.vue b/resources/scripts/components/editor/RichEditor.vue index 44c2d6a7..5682b1a0 100644 --- a/resources/scripts/components/editor/RichEditor.vue +++ b/resources/scripts/components/editor/RichEditor.vue @@ -37,6 +37,10 @@ {{ button.text }} + @@ -58,6 +62,10 @@ {{ button.text }} + ([ }, ]) -// AI text-generation button — shown only when the feature is enabled -// for the current company. The flag is set once at bootstrap time so a -// one-shot push is fine; no reactivity needed. -const globalStore = useGlobalStore() -const modalStore = useModalStore() -if (globalStore.ai?.enabled && globalStore.ai?.text_generation_enabled) { - editorButtons.value.push({ - name: 'aiGenerate', - icon: markRaw(SparklesIcon) as Component, - action: () => { - modalStore.openModal({ - componentName: 'AiTextGenerationModal', - title: 'AI Text Generation', - size: 'md', - data: { - currentContent: editor.value?.getHTML() ?? '', - onInsert: (text: string) => { - editor.value?.chain().focus().insertContent(text).run() - }, - onReplace: (text: string) => { - editor.value?.chain().focus().selectAll().deleteSelection().insertContent(text).run() - }, - }, - }) - }, - }) +const editorContext: RichEditorContext = { + getHtml: () => editor.value?.getHTML() ?? '', + insertContent: (content: string) => { + editor.value?.chain().focus().insertContent(content).run() + }, + replaceContent: (content: string) => { + editor.value?.chain().focus().selectAll().deleteSelection().insertContent(content).run() + }, } watch( () => props.modelValue, (newValue: string) => { if (editor.value && newValue !== editor.value.getHTML()) { - editor.value.commands.setContent(newValue, false) + editor.value.commands.setContent(newValue, { emitUpdate: false }) } } ) diff --git a/resources/scripts/extensions/ExtensionSlot.vue b/resources/scripts/extensions/ExtensionSlot.vue new file mode 100644 index 00000000..18f73b57 --- /dev/null +++ b/resources/scripts/extensions/ExtensionSlot.vue @@ -0,0 +1,35 @@ + + + diff --git a/resources/scripts/extensions/runtime.ts b/resources/scripts/extensions/runtime.ts new file mode 100644 index 00000000..f6574392 --- /dev/null +++ b/resources/scripts/extensions/runtime.ts @@ -0,0 +1,265 @@ +import { markRaw, shallowRef } from 'vue' +import type { ShallowRef } from 'vue' +import type { Router } from 'vue-router' +import { client } from '@/scripts/api/client' +import { useNotificationStore } from '@/scripts/stores/notification.store' +import { registerAdditionalMessages } from '@/scripts/plugins/i18n' +import type { + BootstrapCompletedEvent, + CompanyChangeEvent, + ComponentExtensionContribution, + InvoiceShelfExtensionApi, + InvoiceShelfExtensionEvents, + SettingsNavigationContribution, + SettingsPageContribution, +} from './types' + +type ComponentSlot = + | 'headerActions' + | 'companyLayoutOverlays' + | 'richEditorToolbarActions' + +interface RegisteredComponentContribution extends ComponentExtensionContribution { + component: ComponentExtensionContribution['component'] +} + +function comparePriority(a: T, b: T): number { + return (a.priority ?? 100) - (b.priority ?? 100) || a.id.localeCompare(b.id) +} + +function assertContributionId(id: string): void { + if (!id.trim()) { + throw new Error('InvoiceShelf extension contributions require a stable id.') + } +} + +/** + * Host-owned reactive registry. Modules only receive the public API below, + * never the host's Pinia stores or layout implementation. + */ +export class ExtensionRegistry { + readonly headerActions = shallowRef([]) + readonly companyLayoutOverlays = shallowRef([]) + readonly richEditorToolbarActions = shallowRef([]) + readonly companySettingsNavigation = shallowRef([]) + readonly adminSettingsNavigation = shallowRef([]) + + private readonly teardowns = new Set<() => void>() + + registerComponent( + slot: ComponentSlot, + contribution: ComponentExtensionContribution, + ): () => void { + assertContributionId(contribution.id) + const target = this[slot] as ShallowRef + const entry: RegisteredComponentContribution = { + ...contribution, + component: markRaw(contribution.component), + } + + return this.track(() => { + target.value = [...target.value.filter((item) => item.id !== entry.id), entry] + .sort(comparePriority) + + return () => { + target.value = target.value.filter((item) => item !== entry) + } + }) + } + + registerNavigation( + slot: 'companySettingsNavigation' | 'adminSettingsNavigation', + contribution: SettingsNavigationContribution, + ): () => void { + assertContributionId(contribution.id) + const target = this[slot] as ShallowRef + const entry = { ...contribution } + + return this.track(() => { + target.value = [...target.value.filter((item) => item.id !== entry.id), entry] + .sort(comparePriority) + + return () => { + target.value = target.value.filter((item) => item !== entry) + } + }) + } + + reset(): void { + for (const teardown of [...this.teardowns]) { + teardown() + } + } + + trackTeardown(unregister: () => void): () => void { + return this.track(() => unregister) + } + + private track(register: () => () => void): () => void { + const unregister = register() + let active = true + const teardown = () => { + if (!active) return + active = false + unregister() + this.teardowns.delete(teardown) + } + + this.teardowns.add(teardown) + return teardown + } +} + +export const extensionRegistry = new ExtensionRegistry() + +class ExtensionApi implements InvoiceShelfExtensionApi { + private readonly listeners = new Map< + keyof InvoiceShelfExtensionEvents, + Set<(payload: unknown) => void> + >() + private readonly settingsPageTeardowns = new Map void>() + + constructor(readonly router: Router) {} + + readonly client = client + + registerHeaderAction(contribution: ComponentExtensionContribution): () => void { + return extensionRegistry.registerComponent('headerActions', contribution) + } + + registerCompanyLayoutOverlay(contribution: ComponentExtensionContribution): () => void { + return extensionRegistry.registerComponent('companyLayoutOverlays', contribution) + } + + registerRichEditorToolbarAction(contribution: ComponentExtensionContribution): () => void { + return extensionRegistry.registerComponent('richEditorToolbarActions', contribution) + } + + registerCompanySettingsNavigation(contribution: SettingsNavigationContribution): () => void { + return extensionRegistry.registerNavigation('companySettingsNavigation', contribution) + } + + registerAdminSettingsNavigation(contribution: SettingsNavigationContribution): () => void { + return extensionRegistry.registerNavigation('adminSettingsNavigation', contribution) + } + + registerCompanySettingsPage(contribution: SettingsPageContribution): () => void { + return this.registerSettingsPage('settings', 'companySettingsNavigation', contribution) + } + + registerAdminSettingsPage(contribution: SettingsPageContribution): () => void { + return this.registerSettingsPage('admin.settings', 'adminSettingsNavigation', contribution) + } + + addMessages(messages: Record>): void { + registerAdditionalMessages(messages) + } + + notify(type: 'success' | 'error' | 'warning' | 'info', message: string): void { + useNotificationStore().showNotification({ type, message }) + } + + on( + event: EventName, + listener: (payload: InvoiceShelfExtensionEvents[EventName]) => void, + ): () => void { + const listeners = this.listeners.get(event) ?? new Set<(payload: unknown) => void>() + this.listeners.set(event, listeners) + listeners.add(listener as (payload: unknown) => void) + return () => listeners.delete(listener as (payload: unknown) => void) + } + + emit( + event: EventName, + payload: InvoiceShelfExtensionEvents[EventName], + ): void { + for (const listener of this.listeners.get(event) ?? []) { + listener(payload) + } + } + + reset(): void { + extensionRegistry.reset() + this.settingsPageTeardowns.clear() + for (const listeners of this.listeners.values()) { + listeners.clear() + } + } + + private registerSettingsPage( + parentName: string, + navigationSlot: 'companySettingsNavigation' | 'adminSettingsNavigation', + contribution: SettingsPageContribution, + ): () => void { + assertContributionId(contribution.id) + if (!contribution.path || contribution.path.startsWith('/')) { + throw new Error('InvoiceShelf extension settings paths must be relative.') + } + + const routeName = `extension.${parentName}.${contribution.id}` + const pageKey = `${parentName}:${contribution.id}` + this.settingsPageTeardowns.get(pageKey)?.() + + const removeRoute = this.router.addRoute(parentName, { + path: contribution.path, + name: routeName, + component: markRaw(contribution.component), + meta: contribution.meta, + }) + const removeNavigation = extensionRegistry.registerNavigation(navigationSlot, { + id: contribution.id, + priority: contribution.priority, + visible: contribution.visible, + title: contribution.title, + icon: contribution.icon, + to: { name: routeName }, + }) + + let active = true + const teardown = () => { + if (!active) return + active = false + removeNavigation() + removeRoute() + this.settingsPageTeardowns.delete(pageKey) + } + + const trackedTeardown = extensionRegistry.trackTeardown(teardown) + this.settingsPageTeardowns.set(pageKey, trackedTeardown) + return trackedTeardown + } +} + +let extensionApi: ExtensionApi | null = null + +export function createExtensionApi(router: Router): InvoiceShelfExtensionApi { + extensionApi ??= new ExtensionApi(router) + return extensionApi +} + +export function emitBootstrapCompleted(payload: BootstrapCompletedEvent): void { + extensionApi?.emit('bootstrap:completed', payload) +} + +export function emitCompanyChanging(payload: CompanyChangeEvent): void { + extensionApi?.emit('company:changing', payload) +} + +export function emitCompanyChanged(payload: CompanyChangeEvent): void { + extensionApi?.emit('company:changed', payload) +} + +export function isContributionVisible(contribution: { visible?: () => boolean }): boolean { + try { + return contribution.visible?.() ?? true + } catch (error) { + console.warn('InvoiceShelf extension visibility predicate failed.', error) + return false + } +} + +export function extensionItems boolean }>( + items: readonly T[], +): T[] { + return items.filter(isContributionVisible) +} diff --git a/resources/scripts/extensions/types.ts b/resources/scripts/extensions/types.ts new file mode 100644 index 00000000..11d5f0d9 --- /dev/null +++ b/resources/scripts/extensions/types.ts @@ -0,0 +1,12 @@ +export type { + BootstrapCompletedEvent, + CompanyChangeEvent, + ComponentExtensionContribution, + ExtensionContribution, + ExtensionVisibilityPredicate, + InvoiceShelfExtensionApi, + InvoiceShelfExtensionEvents, + RichEditorContext, + SettingsNavigationContribution, + SettingsPageContribution, +} from '../../../vendor/invoiceshelf/modules/frontend/index' diff --git a/resources/scripts/features/admin/routes.ts b/resources/scripts/features/admin/routes.ts index 70d80295..916648b6 100644 --- a/resources/scripts/features/admin/routes.ts +++ b/resources/scripts/features/admin/routes.ts @@ -9,7 +9,6 @@ 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') @@ -88,14 +87,6 @@ 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 40b88e36..50aa434c 100644 --- a/resources/scripts/features/admin/views/AdminSettingsView.vue +++ b/resources/scripts/features/admin/views/AdminSettingsView.vue @@ -54,6 +54,7 @@ import { ref, computed, watchEffect } from 'vue' import { useRoute, useRouter, RouterView } from 'vue-router' import { useI18n } from 'vue-i18n' +import { extensionItems, extensionRegistry } from '@/scripts/extensions/runtime' interface SettingsMenuItem { title: string @@ -73,11 +74,6 @@ 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', @@ -108,6 +104,11 @@ const menuItems = computed(() => [ link: '/admin/administration/settings/appearance', icon: 'PaintBrushIcon', }, + ...extensionItems(extensionRegistry.adminSettingsNavigation.value).map((item) => ({ + title: t(item.title), + link: router.resolve(item.to).fullPath, + icon: item.icon, + })), ]) watchEffect(() => { diff --git a/resources/scripts/features/admin/views/settings/AdminAiConfigView.vue b/resources/scripts/features/admin/views/settings/AdminAiConfigView.vue deleted file mode 100644 index 2085a314..00000000 --- a/resources/scripts/features/admin/views/settings/AdminAiConfigView.vue +++ /dev/null @@ -1,107 +0,0 @@ - - - diff --git a/resources/scripts/features/company/ai/components/AiChatConversationList.vue b/resources/scripts/features/company/ai/components/AiChatConversationList.vue deleted file mode 100644 index 07520e5c..00000000 --- a/resources/scripts/features/company/ai/components/AiChatConversationList.vue +++ /dev/null @@ -1,79 +0,0 @@ - - - diff --git a/resources/scripts/features/company/ai/components/AiChatDrawer.vue b/resources/scripts/features/company/ai/components/AiChatDrawer.vue deleted file mode 100644 index 18c748ec..00000000 --- a/resources/scripts/features/company/ai/components/AiChatDrawer.vue +++ /dev/null @@ -1,140 +0,0 @@ - - - - - diff --git a/resources/scripts/features/company/ai/components/AiChatMessage.vue b/resources/scripts/features/company/ai/components/AiChatMessage.vue deleted file mode 100644 index 356639a9..00000000 --- a/resources/scripts/features/company/ai/components/AiChatMessage.vue +++ /dev/null @@ -1,42 +0,0 @@ - - - diff --git a/resources/scripts/features/company/ai/components/AiChatMessageInput.vue b/resources/scripts/features/company/ai/components/AiChatMessageInput.vue deleted file mode 100644 index f453df3f..00000000 --- a/resources/scripts/features/company/ai/components/AiChatMessageInput.vue +++ /dev/null @@ -1,62 +0,0 @@ - - -