From e861fc1fc1bd2eea4fcfcda25e1bea7604bd91c6 Mon Sep 17 00:00:00 2001 From: Darko Gjorgjijoski Date: Sun, 12 Apr 2026 08:00:00 +0200 Subject: [PATCH] =?UTF-8?q?feat(ai):=20Phase=202=20=E2=80=94=20chat=20assi?= =?UTF-8?q?stant=20with=20tool-calling?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second phase of the AI feature. Users can now open a slide-in chat drawer from the SiteHeader and ask natural-language questions about their company's invoices, customers, payments, and expenses. The LLM invokes pre-defined read-only tool functions (scoped to the current company at execute time) to fetch data and synthesize answers. **Database** — new ai_conversations and ai_messages tables. Messages are stored in OpenAI's chat format so AiAssistantService serializes a conversation into an API request with zero translation. Columns: role, content, tool_call_id, tool_calls JSON, model, tokens_in, tokens_out. Conversations are scoped (company_id, user_id) — one user's chats are invisible to everyone else, even inside the same company. Foreign-key cascade deletes. **Tool infrastructure** — AiTool abstract base + AiToolRegistry singleton (registered in a new AiServiceProvider). The base class enforces the security rule: every tool's execute() receives companyId and userId as injected parameters; tools' JSON schemas NEVER include a company_id field. An LLM physically cannot pass a company_id and escape tenancy. Modules can register their own tools by resolving the registry from their own ServiceProvider::boot(). **Nine built-in tools**: search_invoices, get_invoice, search_customers, get_customer, list_recent_payments, list_overdue_invoices, get_company_stats (aggregates for named periods), search_items, list_expense_categories. All read-only; no mutations. Each returns JSON-encodable data the LLM can parse. **AiAssistantService orchestration loop** — the heart of Phase 2. Flow: persist user message → build payload from system prompt + recent history (40-message window) + new user message → call driver.chatCompletion with tools → if tool_calls, execute each one via the registry (with injected scope), persist tool result, loop → if plain text, persist and return. Hard cap at 5 iterations to prevent runaway LLMs. System prompt pins the assistant to this company's data and forbids mutation. **Controllers + policy + rate limit** — POST /api/v1/ai/chat runs the orchestration loop. GET/PATCH/DELETE /api/v1/ai/conversations for CRUD. AiConversationPolicy enforces user_id+company_id match on every action. A new 'ai' RateLimiter in RouteServiceProvider throttles to 30 req/min per (user, company). New 'use ai' Gate defined in AppServiceProvider returns true for any authenticated user — the per-company kill-switch still goes through AiConfigurationService::resolveForCompany. **Frontend** — new features/company/ai/ folder with a Pinia store (ai-chat.store.ts) holding drawer state, current conversation, messages, and loading flags. AiChatDrawer.vue is a slide-in panel teleported to , mounted globally in CompanyLayout.vue when bootstrap reports ai.enabled && ai.chat_enabled. Sub-components: AiChatMessage (user bubbles vs assistant bubbles), AiChatMessageInput (Enter submits, Shift+Enter newline), AiChatConversationList (sidebar with 'new chat' button, rename, delete). A SparklesIcon button in SiteHeader toggles the drawer. **Driver test double** — tests use a ScriptedAiDriver registered via AiDriverFactory::register('scripted', ...) that returns pre-queued AiChatResponse objects. Feature tests cover: happy path (new conversation + message persistence), tool-call loop (multi-round-trip with search_invoices), runaway-loop cap, driver-throws path, ai_enabled=NO rejection, chat role disabled rejection, per-user conversation visibility, cross-user policy enforcement, cascade delete. 388 tests pass (was 372, +16 new). Pint clean. npm run build clean. Phase 3 (WYSIWYG text generation popup) is the remaining follow-up. --- .../Controllers/Company/Ai/ChatController.php | 98 ++++++ .../Company/Ai/ConversationController.php | 103 ++++++ app/Models/AiConversation.php | 37 ++ app/Models/AiMessage.php | 51 +++ app/Policies/AiConversationPolicy.php | 38 ++ app/Policies/SettingsPolicy.php | 9 + app/Providers/AiServiceProvider.php | 47 +++ app/Providers/AppServiceProvider.php | 4 + app/Providers/RouteServiceProvider.php | 10 + app/Services/Ai/AiAssistantService.php | 331 ++++++++++++++++++ app/Services/Ai/AiToolRegistry.php | 91 +++++ app/Services/Ai/Tools/AiTool.php | 112 ++++++ app/Services/Ai/Tools/GetCompanyStatsTool.php | 125 +++++++ app/Services/Ai/Tools/GetCustomerTool.php | 78 +++++ app/Services/Ai/Tools/GetInvoiceTool.php | 85 +++++ .../Ai/Tools/ListExpenseCategoriesTool.php | 43 +++ .../Ai/Tools/ListOverdueInvoicesTool.php | 54 +++ .../Ai/Tools/ListRecentPaymentsTool.php | 79 +++++ app/Services/Ai/Tools/SearchCustomersTool.php | 73 ++++ app/Services/Ai/Tools/SearchInvoicesTool.php | 108 ++++++ app/Services/Ai/Tools/SearchItemsTool.php | 68 ++++ bootstrap/providers.php | 2 + ...e_ai_conversations_and_messages_tables.php | 59 ++++ lang/en.json | 18 +- resources/scripts/api/endpoints.ts | 4 + resources/scripts/api/services/ai.service.ts | 36 ++ .../ai/components/AiChatConversationList.vue | 79 +++++ .../company/ai/components/AiChatDrawer.vue | 140 ++++++++ .../company/ai/components/AiChatMessage.vue | 31 ++ .../ai/components/AiChatMessageInput.vue | 62 ++++ .../company/ai/stores/ai-chat.store.ts | 149 ++++++++ resources/scripts/layouts/CompanyLayout.vue | 4 + .../scripts/layouts/partials/SiteHeader.vue | 24 ++ resources/scripts/stores/global.store.ts | 11 + resources/scripts/types/ai-config.ts | 28 ++ routes/api.php | 11 + tests/Feature/Ai/AiChatFlowTest.php | 290 +++++++++++++++ .../Ai/Tools/SearchInvoicesToolTest.php | 93 +++++ tests/Unit/AiToolRegistryTest.php | 92 +++++ 39 files changed, 2776 insertions(+), 1 deletion(-) create mode 100644 app/Http/Controllers/Company/Ai/ChatController.php create mode 100644 app/Http/Controllers/Company/Ai/ConversationController.php create mode 100644 app/Models/AiConversation.php create mode 100644 app/Models/AiMessage.php create mode 100644 app/Policies/AiConversationPolicy.php create mode 100644 app/Providers/AiServiceProvider.php create mode 100644 app/Services/Ai/AiAssistantService.php create mode 100644 app/Services/Ai/AiToolRegistry.php create mode 100644 app/Services/Ai/Tools/AiTool.php create mode 100644 app/Services/Ai/Tools/GetCompanyStatsTool.php create mode 100644 app/Services/Ai/Tools/GetCustomerTool.php create mode 100644 app/Services/Ai/Tools/GetInvoiceTool.php create mode 100644 app/Services/Ai/Tools/ListExpenseCategoriesTool.php create mode 100644 app/Services/Ai/Tools/ListOverdueInvoicesTool.php create mode 100644 app/Services/Ai/Tools/ListRecentPaymentsTool.php create mode 100644 app/Services/Ai/Tools/SearchCustomersTool.php create mode 100644 app/Services/Ai/Tools/SearchInvoicesTool.php create mode 100644 app/Services/Ai/Tools/SearchItemsTool.php create mode 100644 database/migrations/2026_04_11_154445_create_ai_conversations_and_messages_tables.php create mode 100644 resources/scripts/features/company/ai/components/AiChatConversationList.vue create mode 100644 resources/scripts/features/company/ai/components/AiChatDrawer.vue create mode 100644 resources/scripts/features/company/ai/components/AiChatMessage.vue create mode 100644 resources/scripts/features/company/ai/components/AiChatMessageInput.vue create mode 100644 resources/scripts/features/company/ai/stores/ai-chat.store.ts create mode 100644 tests/Feature/Ai/AiChatFlowTest.php create mode 100644 tests/Feature/Ai/Tools/SearchInvoicesToolTest.php create mode 100644 tests/Unit/AiToolRegistryTest.php diff --git a/app/Http/Controllers/Company/Ai/ChatController.php b/app/Http/Controllers/Company/Ai/ChatController.php new file mode 100644 index 00000000..f3d66d0f --- /dev/null +++ b/app/Http/Controllers/Company/Ai/ChatController.php @@ -0,0 +1,98 @@ +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/Http/Controllers/Company/Ai/ConversationController.php b/app/Http/Controllers/Company/Ai/ConversationController.php new file mode 100644 index 00000000..fa7f54a5 --- /dev/null +++ b/app/Http/Controllers/Company/Ai/ConversationController.php @@ -0,0 +1,103 @@ +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/Models/AiConversation.php b/app/Models/AiConversation.php new file mode 100644 index 00000000..bd3acd44 --- /dev/null +++ b/app/Models/AiConversation.php @@ -0,0 +1,37 @@ +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/Models/AiMessage.php b/app/Models/AiMessage.php new file mode 100644 index 00000000..1b5e2f49 --- /dev/null +++ b/app/Models/AiMessage.php @@ -0,0 +1,51 @@ + '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/Policies/AiConversationPolicy.php b/app/Policies/AiConversationPolicy.php new file mode 100644 index 00000000..34e90a1a --- /dev/null +++ b/app/Policies/AiConversationPolicy.php @@ -0,0 +1,38 @@ +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/Policies/SettingsPolicy.php b/app/Policies/SettingsPolicy.php index 069f5132..6dce39e7 100644 --- a/app/Policies/SettingsPolicy.php +++ b/app/Policies/SettingsPolicy.php @@ -55,6 +55,15 @@ class SettingsPolicy return false; } + /** + * Any authenticated user with an active company context can use AI features, + * subject to the per-company kill-switch in AiConfigurationService. + */ + public function useAi(User $user) + { + return $user !== null; + } + public function managePDFConfig(User $user) { if ($user->isSuperAdmin()) { diff --git a/app/Providers/AiServiceProvider.php b/app/Providers/AiServiceProvider.php new file mode 100644 index 00000000..1c26d299 --- /dev/null +++ b/app/Providers/AiServiceProvider.php @@ -0,0 +1,47 @@ +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); + + return $registry; + }); + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 9fce9187..cf87366c 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -2,6 +2,8 @@ namespace App\Providers; +use App\Models\AiConversation; +use App\Policies\AiConversationPolicy; use App\Policies\CompanyPolicy; use App\Policies\CustomerPolicy; use App\Policies\DashboardPolicy; @@ -64,6 +66,7 @@ class AppServiceProvider extends ServiceProvider } Gate::policy(Role::class, RolePolicy::class); + Gate::policy(AiConversation::class, AiConversationPolicy::class); View::addNamespace('pdf_templates', storage_path('app/templates/pdf')); @@ -144,6 +147,7 @@ class AppServiceProvider extends ServiceProvider Gate::define('manage file disk', [SettingsPolicy::class, 'manageFileDisk']); Gate::define('manage email config', [SettingsPolicy::class, 'manageEmailConfig']); Gate::define('manage ai config', [SettingsPolicy::class, 'manageAiConfig']); + Gate::define('use ai', [SettingsPolicy::class, 'useAi']); Gate::define('manage pdf config', [SettingsPolicy::class, 'managePDFConfig']); Gate::define('manage notes', [NotePolicy::class, 'manageNotes']); Gate::define('view notes', [NotePolicy::class, 'viewNotes']); diff --git a/app/Providers/RouteServiceProvider.php b/app/Providers/RouteServiceProvider.php index 9dffbff5..9e3d4a9a 100644 --- a/app/Providers/RouteServiceProvider.php +++ b/app/Providers/RouteServiceProvider.php @@ -53,5 +53,15 @@ class RouteServiceProvider extends ServiceProvider RateLimiter::for('api', function (Request $request) { return Limit::perMinute(60); }); + + // AI endpoints — per user per company, fairly generous for chat use + // but low enough to contain runaway clients / loops. + RateLimiter::for('ai', function (Request $request) { + $user = $request->user(); + $companyId = $request->header('company') ?? 'noop'; + $key = $user ? "{$user->id}:{$companyId}" : $request->ip(); + + return Limit::perMinute(30)->by($key); + }); } } diff --git a/app/Services/Ai/AiAssistantService.php b/app/Services/Ai/AiAssistantService.php new file mode 100644 index 00000000..eb6ad372 --- /dev/null +++ b/app/Services/Ai/AiAssistantService.php @@ -0,0 +1,331 @@ + $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(); + + 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. + */ + protected function buildSystemPrompt(AiConversation $conversation): string + { + $company = Company::find($conversation->company_id); + $user = User::find($conversation->user_id); + + $companyName = $company?->name ?? 'this company'; + $userName = $user?->name ?? 'the user'; + $today = Carbon::now()->toDateString(); + + return << $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/Services/Ai/AiToolRegistry.php b/app/Services/Ai/AiToolRegistry.php new file mode 100644 index 00000000..9116e8ec --- /dev/null +++ b/app/Services/Ai/AiToolRegistry.php @@ -0,0 +1,91 @@ +app->resolving(AiToolRegistry::class, function (AiToolRegistry $registry) { + * $registry->register(new MyCustomTool); + * }); + */ +class AiToolRegistry +{ + /** + * @var array + */ + protected array $tools = []; + + 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 all registered tools as the `tools` array for an OpenAI-style chat request. + * + * @return array> + */ + public function schemas(): array + { + return array_values(array_map( + fn (AiTool $tool): array => $tool->toOpenAiToolSchema(), + $this->tools, + )); + } + + /** + * 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. + * + * @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}"); + } + + return $tool->execute($arguments, $companyId, $userId); + } + + /** + * Test-only: reset the registry between tests that exercise different tool sets. + */ + public function flush(): void + { + $this->tools = []; + } +} diff --git a/app/Services/Ai/Tools/AiTool.php b/app/Services/Ai/Tools/AiTool.php new file mode 100644 index 00000000..cc1c9818 --- /dev/null +++ b/app/Services/Ai/Tools/AiTool.php @@ -0,0 +1,112 @@ + '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; + + /** + * 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/Services/Ai/Tools/GetCompanyStatsTool.php b/app/Services/Ai/Tools/GetCompanyStatsTool.php new file mode 100644 index 00000000..8aaf462d --- /dev/null +++ b/app/Services/Ai/Tools/GetCompanyStatsTool.php @@ -0,0 +1,125 @@ +". + * Much cheaper than fetching full invoice lists and summing client-side. + */ +class GetCompanyStatsTool extends AiTool +{ + 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 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]; + } + + [$start, $end] = $this->rangeFor($period); + + $invoiceCount = Invoice::query() + ->where('company_id', $companyId) + ->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], + ]; + } + + /** + * @return array{0: Carbon, 1: Carbon} + */ + private function rangeFor(string $period): array + { + $now = Carbon::now(); + + return match ($period) { + '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(), + ], + }; + } +} diff --git a/app/Services/Ai/Tools/GetCustomerTool.php b/app/Services/Ai/Tools/GetCustomerTool.php new file mode 100644 index 00000000..0afda3a8 --- /dev/null +++ b/app/Services/Ai/Tools/GetCustomerTool.php @@ -0,0 +1,78 @@ + 'object', + 'properties' => [ + 'customer_id' => [ + 'type' => 'integer', + 'description' => 'The customer ID.', + ], + ], + 'required' => ['customer_id'], + ]; + } + + 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. + $invoiceCount = Invoice::query() + ->where('company_id', $companyId) + ->where('customer_id', $customer->id) + ->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/Services/Ai/Tools/GetInvoiceTool.php b/app/Services/Ai/Tools/GetInvoiceTool.php new file mode 100644 index 00000000..7677dd36 --- /dev/null +++ b/app/Services/Ai/Tools/GetInvoiceTool.php @@ -0,0 +1,85 @@ + 'object', + 'properties' => [ + 'invoice_number' => [ + 'type' => 'string', + 'description' => 'The invoice_number to look up (e.g. "INV-000001").', + ], + ], + 'required' => ['invoice_number'], + ]; + } + + 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/Services/Ai/Tools/ListExpenseCategoriesTool.php b/app/Services/Ai/Tools/ListExpenseCategoriesTool.php new file mode 100644 index 00000000..142fce15 --- /dev/null +++ b/app/Services/Ai/Tools/ListExpenseCategoriesTool.php @@ -0,0 +1,43 @@ + 'object', + 'properties' => (object) [], + 'required' => [], + ]; + } + + 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/Services/Ai/Tools/ListOverdueInvoicesTool.php b/app/Services/Ai/Tools/ListOverdueInvoicesTool.php new file mode 100644 index 00000000..f2f7707d --- /dev/null +++ b/app/Services/Ai/Tools/ListOverdueInvoicesTool.php @@ -0,0 +1,54 @@ + 'object', + 'properties' => (object) [], + 'required' => [], + ]; + } + + 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/Services/Ai/Tools/ListRecentPaymentsTool.php b/app/Services/Ai/Tools/ListRecentPaymentsTool.php new file mode 100644 index 00000000..ddc60688 --- /dev/null +++ b/app/Services/Ai/Tools/ListRecentPaymentsTool.php @@ -0,0 +1,79 @@ + '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 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(['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, + 'invoice_id' => $p->invoice_id, + 'payment_method' => $p->paymentMethod?->name, + ])->all(), + ]; + } +} diff --git a/app/Services/Ai/Tools/SearchCustomersTool.php b/app/Services/Ai/Tools/SearchCustomersTool.php new file mode 100644 index 00000000..11669457 --- /dev/null +++ b/app/Services/Ai/Tools/SearchCustomersTool.php @@ -0,0 +1,73 @@ + '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 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/Services/Ai/Tools/SearchInvoicesTool.php b/app/Services/Ai/Tools/SearchInvoicesTool.php new file mode 100644 index 00000000..d5f908f4 --- /dev/null +++ b/app/Services/Ai/Tools/SearchInvoicesTool.php @@ -0,0 +1,108 @@ + '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 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/Services/Ai/Tools/SearchItemsTool.php b/app/Services/Ai/Tools/SearchItemsTool.php new file mode 100644 index 00000000..75a2f068 --- /dev/null +++ b/app/Services/Ai/Tools/SearchItemsTool.php @@ -0,0 +1,68 @@ + '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 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/bootstrap/providers.php b/bootstrap/providers.php index 93e1ed79..a562b0c4 100644 --- a/bootstrap/providers.php +++ b/bootstrap/providers.php @@ -1,5 +1,6 @@ id(); + $table->foreignId('company_id')->constrained()->cascadeOnDelete(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $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/lang/en.json b/lang/en.json index 9c033452..93285158 100644 --- a/lang/en.json +++ b/lang/en.json @@ -961,7 +961,10 @@ "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." + "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.", + "missing_model": "No chat model is configured." } }, "address_information": { @@ -1880,5 +1883,18 @@ "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…" + } } } diff --git a/resources/scripts/api/endpoints.ts b/resources/scripts/api/endpoints.ts index 59f9af76..b22f0bb3 100644 --- a/resources/scripts/api/endpoints.ts +++ b/resources/scripts/api/endpoints.ts @@ -127,6 +127,10 @@ export const API = { // 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', + // 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 index f2b94527..41029f3d 100644 --- a/resources/scripts/api/services/ai.service.ts +++ b/resources/scripts/api/services/ai.service.ts @@ -1,7 +1,10 @@ import { client } from '../client' import { API } from '../endpoints' import type { + AiChatSendResponse, AiConfig, + AiConversationDetail, + AiConversationSummary, AiDriversResponse, AiTestPayload, AiTestResponse, @@ -48,4 +51,37 @@ export const aiService = { 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 + }, } diff --git a/resources/scripts/features/company/ai/components/AiChatConversationList.vue b/resources/scripts/features/company/ai/components/AiChatConversationList.vue new file mode 100644 index 00000000..dac0b2e1 --- /dev/null +++ b/resources/scripts/features/company/ai/components/AiChatConversationList.vue @@ -0,0 +1,79 @@ + + + diff --git a/resources/scripts/features/company/ai/components/AiChatDrawer.vue b/resources/scripts/features/company/ai/components/AiChatDrawer.vue new file mode 100644 index 00000000..57df3e6c --- /dev/null +++ b/resources/scripts/features/company/ai/components/AiChatDrawer.vue @@ -0,0 +1,140 @@ + + + + + diff --git a/resources/scripts/features/company/ai/components/AiChatMessage.vue b/resources/scripts/features/company/ai/components/AiChatMessage.vue new file mode 100644 index 00000000..7b2ee2ae --- /dev/null +++ b/resources/scripts/features/company/ai/components/AiChatMessage.vue @@ -0,0 +1,31 @@ + + + diff --git a/resources/scripts/features/company/ai/components/AiChatMessageInput.vue b/resources/scripts/features/company/ai/components/AiChatMessageInput.vue new file mode 100644 index 00000000..f453df3f --- /dev/null +++ b/resources/scripts/features/company/ai/components/AiChatMessageInput.vue @@ -0,0 +1,62 @@ + + +