mirror of
https://github.com/InvoiceShelf/InvoiceShelf.git
synced 2026-07-16 13:55:21 +00:00
feat(ai): Phase 2 — chat assistant with tool-calling
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 <body>, 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.
This commit is contained in:
98
app/Http/Controllers/Company/Ai/ChatController.php
Normal file
98
app/Http/Controllers/Company/Ai/ChatController.php
Normal file
@@ -0,0 +1,98 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Company\Ai;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\AiConversation;
|
||||
use App\Services\Ai\AiAssistantService;
|
||||
use App\Support\Ai\AiException;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class ChatController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly AiAssistantService $assistant,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Send a message into a conversation and get the assistant's reply.
|
||||
*
|
||||
* If `conversation_id` is omitted (or belongs to a conversation the user
|
||||
* doesn't own), we start a fresh conversation scoped to the current
|
||||
* (company_id, user_id). This matches the "new chat" UX where the user
|
||||
* opens the drawer and starts typing immediately.
|
||||
*
|
||||
* @throws ValidationException
|
||||
*/
|
||||
public function __invoke(Request $request): JsonResponse
|
||||
{
|
||||
$this->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);
|
||||
}
|
||||
}
|
||||
103
app/Http/Controllers/Company/Ai/ConversationController.php
Normal file
103
app/Http/Controllers/Company/Ai/ConversationController.php
Normal file
@@ -0,0 +1,103 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Company\Ai;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\AiConversation;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class ConversationController extends Controller
|
||||
{
|
||||
/**
|
||||
* List the current user's conversations for the current company.
|
||||
*/
|
||||
public function index(Request $request): JsonResponse
|
||||
{
|
||||
$this->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]);
|
||||
}
|
||||
}
|
||||
37
app/Models/AiConversation.php
Normal file
37
app/Models/AiConversation.php
Normal file
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
|
||||
/**
|
||||
* A single chat-assistant thread, scoped to (company_id, user_id).
|
||||
*
|
||||
* One user in one company owns many conversations; messages belong to a
|
||||
* conversation. Conversations are deleted when either the company or the
|
||||
* user is deleted (cascade at the DB level).
|
||||
*/
|
||||
class AiConversation extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $guarded = ['id'];
|
||||
|
||||
public function company(): BelongsTo
|
||||
{
|
||||
return $this->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');
|
||||
}
|
||||
}
|
||||
51
app/Models/AiMessage.php
Normal file
51
app/Models/AiMessage.php
Normal file
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
|
||||
/**
|
||||
* One message in an AI conversation.
|
||||
*
|
||||
* Column shapes match OpenAI's chat message format exactly so that
|
||||
* AiAssistantService can serialize a conversation into an API request
|
||||
* payload with no translation layer. Supported roles:
|
||||
*
|
||||
* - user — human-authored prompt
|
||||
* - assistant — model-generated reply (may carry tool_calls instead of content)
|
||||
* - tool — result of a tool invocation, tied to an assistant's tool_call_id
|
||||
* - system — persisted system prompts (not typically stored; reserved for future)
|
||||
*/
|
||||
class AiMessage extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
public const ROLE_USER = 'user';
|
||||
|
||||
public const ROLE_ASSISTANT = 'assistant';
|
||||
|
||||
public const ROLE_TOOL = 'tool';
|
||||
|
||||
public const ROLE_SYSTEM = 'system';
|
||||
|
||||
public const UPDATED_AT = null; // created_at only — messages are immutable once written
|
||||
|
||||
protected $guarded = ['id'];
|
||||
|
||||
protected function casts(): array
|
||||
{
|
||||
return [
|
||||
'tool_calls' => 'array',
|
||||
'tokens_in' => 'integer',
|
||||
'tokens_out' => 'integer',
|
||||
'created_at' => 'datetime',
|
||||
];
|
||||
}
|
||||
|
||||
public function conversation(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(AiConversation::class, 'conversation_id');
|
||||
}
|
||||
}
|
||||
38
app/Policies/AiConversationPolicy.php
Normal file
38
app/Policies/AiConversationPolicy.php
Normal file
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace App\Policies;
|
||||
|
||||
use App\Models\AiConversation;
|
||||
use App\Models\User;
|
||||
|
||||
/**
|
||||
* Conversation visibility is strictly per-user per-company.
|
||||
*
|
||||
* Every action requires (a) the user is a member of the conversation's
|
||||
* company AND (b) the user IS the conversation's owner. We do not leak
|
||||
* conversations between users — the whole point of the per-user scope
|
||||
* is privacy within a shared company workspace.
|
||||
*/
|
||||
class AiConversationPolicy
|
||||
{
|
||||
public function view(User $user, AiConversation $conversation): bool
|
||||
{
|
||||
return $this->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);
|
||||
}
|
||||
}
|
||||
@@ -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()) {
|
||||
|
||||
47
app/Providers/AiServiceProvider.php
Normal file
47
app/Providers/AiServiceProvider.php
Normal file
@@ -0,0 +1,47 @@
|
||||
<?php
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use App\Services\Ai\AiToolRegistry;
|
||||
use App\Services\Ai\Tools\GetCompanyStatsTool;
|
||||
use App\Services\Ai\Tools\GetCustomerTool;
|
||||
use App\Services\Ai\Tools\GetInvoiceTool;
|
||||
use App\Services\Ai\Tools\ListExpenseCategoriesTool;
|
||||
use App\Services\Ai\Tools\ListOverdueInvoicesTool;
|
||||
use App\Services\Ai\Tools\ListRecentPaymentsTool;
|
||||
use App\Services\Ai\Tools\SearchCustomersTool;
|
||||
use App\Services\Ai\Tools\SearchInvoicesTool;
|
||||
use App\Services\Ai\Tools\SearchItemsTool;
|
||||
use Illuminate\Contracts\Foundation\Application;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
/**
|
||||
* Binds the AI tool registry as a singleton and registers every built-in
|
||||
* read-only tool the chat assistant can call.
|
||||
*
|
||||
* Modules that ship additional tools should extend the registry from their
|
||||
* own ServiceProvider::boot() by resolving AiToolRegistry from the container
|
||||
* and calling register() on it.
|
||||
*/
|
||||
class AiServiceProvider extends ServiceProvider
|
||||
{
|
||||
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);
|
||||
|
||||
return $registry;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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']);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
331
app/Services/Ai/AiAssistantService.php
Normal file
331
app/Services/Ai/AiAssistantService.php
Normal file
@@ -0,0 +1,331 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Ai;
|
||||
|
||||
use App\Models\AiConversation;
|
||||
use App\Models\AiMessage;
|
||||
use App\Models\Company;
|
||||
use App\Models\User;
|
||||
use App\Services\AiConfigurationService;
|
||||
use App\Support\Ai\AiChatResponse;
|
||||
use App\Support\Ai\AiException;
|
||||
use Carbon\Carbon;
|
||||
use InvalidArgumentException;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Orchestrates a single turn of the chat assistant.
|
||||
*
|
||||
* The public surface is `chat(conversation, userMessage)` — it persists the
|
||||
* user's message, runs the LLM → tool-calls → LLM loop until the model emits
|
||||
* a plain-text reply (or we hit the hard cap), persists everything along the
|
||||
* way, and returns the final assistant AiMessage.
|
||||
*
|
||||
* Per-phase-2 plan this is NON-STREAMING: the caller waits for the final
|
||||
* response. Streaming is a future polish refactor — the storage shape and
|
||||
* return type will be the same.
|
||||
*/
|
||||
class AiAssistantService
|
||||
{
|
||||
/** Hard cap on tool-call iterations per turn. Prevents runaway loops. */
|
||||
public const MAX_TOOL_ITERATIONS = 5;
|
||||
|
||||
/** Cap on messages loaded from history to fit the model context window. */
|
||||
private const HISTORY_WINDOW = 40;
|
||||
|
||||
public function __construct(
|
||||
private readonly AiConfigurationService $aiConfiguration,
|
||||
private readonly AiToolRegistry $toolRegistry,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Start a new conversation for the given user in the given company.
|
||||
*/
|
||||
public function startConversation(int $companyId, int $userId, ?string $firstMessage = null): AiConversation
|
||||
{
|
||||
return AiConversation::create([
|
||||
'company_id' => $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<int, array<string, mixed>>
|
||||
*/
|
||||
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<string, mixed>
|
||||
*/
|
||||
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 <<<PROMPT
|
||||
You are the InvoiceShelf assistant, embedded in an invoicing application. You help {$userName} answer questions about **{$companyName}**'s data: invoices, estimates, payments, expenses, customers, and items.
|
||||
|
||||
Today is {$today}.
|
||||
|
||||
Rules:
|
||||
- Use the provided tools whenever the user asks about specific records. Do not invent data.
|
||||
- Only answer questions about {$companyName}'s data. If asked about another company or unrelated topics, politely decline.
|
||||
- You cannot modify data — you are read-only. If the user asks you to create/update/delete something, explain that they need to do it in the UI.
|
||||
- Be concise. Format responses in Markdown.
|
||||
- If a tool returns an error or empty result, say so plainly and suggest a next step.
|
||||
PROMPT;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<string, mixed>
|
||||
*/
|
||||
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<string, mixed> $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)).'...';
|
||||
}
|
||||
}
|
||||
91
app/Services/Ai/AiToolRegistry.php
Normal file
91
app/Services/Ai/AiToolRegistry.php
Normal file
@@ -0,0 +1,91 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Ai;
|
||||
|
||||
use App\Services\Ai\Tools\AiTool;
|
||||
use InvalidArgumentException;
|
||||
|
||||
/**
|
||||
* In-memory registry of AiTool instances.
|
||||
*
|
||||
* Register tools from a service provider (see `App\Providers\AiServiceProvider`)
|
||||
* at app boot; the AiAssistantService reads `schemas()` to populate the LLM's
|
||||
* tool-calling payload and calls `execute()` when the model returns a tool_call.
|
||||
*
|
||||
* The registry is intentionally a singleton: tools themselves are stateless
|
||||
* dispatchers, so one instance shared across the request is safe. Modules can
|
||||
* register their own tools by resolving this service and calling `register()`
|
||||
* from their own ServiceProvider::boot().
|
||||
*
|
||||
* $this->app->resolving(AiToolRegistry::class, function (AiToolRegistry $registry) {
|
||||
* $registry->register(new MyCustomTool);
|
||||
* });
|
||||
*/
|
||||
class AiToolRegistry
|
||||
{
|
||||
/**
|
||||
* @var array<string, AiTool>
|
||||
*/
|
||||
protected array $tools = [];
|
||||
|
||||
public function register(AiTool $tool): void
|
||||
{
|
||||
$this->tools[$tool->name()] = $tool;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, AiTool>
|
||||
*/
|
||||
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<int, array<string, mixed>>
|
||||
*/
|
||||
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<string, mixed> $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 = [];
|
||||
}
|
||||
}
|
||||
112
app/Services/Ai/Tools/AiTool.php
Normal file
112
app/Services/Ai/Tools/AiTool.php
Normal file
@@ -0,0 +1,112 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Ai\Tools;
|
||||
|
||||
/**
|
||||
* Abstract base class for read-only AI tools invokable by the chat assistant.
|
||||
*
|
||||
* Tools are what the LLM "calls" to fetch data when answering a user question.
|
||||
* Every tool is a small, well-typed PHP function:
|
||||
*
|
||||
* - `name()` — snake_case identifier passed to the LLM
|
||||
* - `description()` — one-sentence description; this is how the LLM decides
|
||||
* when to invoke the tool. Write it like documentation, not marketing.
|
||||
* - `parameterSchema()` — JSON schema for the parameters the LLM can pass.
|
||||
* Must NEVER include a `company_id` field — scoping is injected at execute
|
||||
* time from the caller's session. This is the v1 prompt-injection defense.
|
||||
* - `execute()` — actually runs the query. Receives the resolved `$companyId`
|
||||
* and `$userId` from the session, plus the arguments the LLM chose.
|
||||
*
|
||||
* Tools are **read-only** by contract. There is intentionally no mutation
|
||||
* surface in v1 — the chat assistant cannot create, update, or delete
|
||||
* anything, no matter what the LLM is told. Any attempt to add a mutation
|
||||
* tool should go through a separate design review.
|
||||
*/
|
||||
abstract class AiTool
|
||||
{
|
||||
/** Stable identifier sent to the LLM. Use snake_case. */
|
||||
abstract public function name(): string;
|
||||
|
||||
/**
|
||||
* Natural-language description the LLM uses to decide when to call this tool.
|
||||
*
|
||||
* Keep it one sentence, written in imperative mood, describing what you get
|
||||
* back. Good examples:
|
||||
*
|
||||
* - "Search invoices by customer, status, or free-text query."
|
||||
* - "Fetch full details for a customer by ID, including their address and totals."
|
||||
*
|
||||
* Avoid marketing language and hedging. The LLM pays close attention to
|
||||
* this string.
|
||||
*/
|
||||
abstract public function description(): string;
|
||||
|
||||
/**
|
||||
* JSON schema for the arguments the LLM may pass.
|
||||
*
|
||||
* Shape matches OpenAI's function-calling parameters schema — an object
|
||||
* with a `properties` map and a `required` array. Tools that take no
|
||||
* arguments should return a bare `['type' => '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<string, mixed>
|
||||
*/
|
||||
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<string, mixed> $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<string, mixed>
|
||||
*/
|
||||
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;
|
||||
}
|
||||
}
|
||||
125
app/Services/Ai/Tools/GetCompanyStatsTool.php
Normal file
125
app/Services/Ai/Tools/GetCompanyStatsTool.php
Normal file
@@ -0,0 +1,125 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Ai\Tools;
|
||||
|
||||
use App\Models\Expense;
|
||||
use App\Models\Invoice;
|
||||
use App\Models\Payment;
|
||||
use Carbon\Carbon;
|
||||
|
||||
/**
|
||||
* Aggregate stats for the current company over a time window.
|
||||
*
|
||||
* Use this when the user asks "how much did we make/spend/invoice in <period>".
|
||||
* 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(),
|
||||
],
|
||||
};
|
||||
}
|
||||
}
|
||||
78
app/Services/Ai/Tools/GetCustomerTool.php
Normal file
78
app/Services/Ai/Tools/GetCustomerTool.php
Normal file
@@ -0,0 +1,78 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Ai\Tools;
|
||||
|
||||
use App\Models\Customer;
|
||||
use App\Models\Invoice;
|
||||
|
||||
class GetCustomerTool extends AiTool
|
||||
{
|
||||
public function name(): string
|
||||
{
|
||||
return 'get_customer';
|
||||
}
|
||||
|
||||
public function description(): string
|
||||
{
|
||||
return 'Fetch full details for a single customer by ID, including contact info, billing/shipping address, and aggregate totals (invoice count, outstanding balance).';
|
||||
}
|
||||
|
||||
public function parameterSchema(): array
|
||||
{
|
||||
return [
|
||||
'type' => '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,
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
85
app/Services/Ai/Tools/GetInvoiceTool.php
Normal file
85
app/Services/Ai/Tools/GetInvoiceTool.php
Normal file
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Ai\Tools;
|
||||
|
||||
use App\Models\Invoice;
|
||||
|
||||
/**
|
||||
* Fetch one invoice's full details by invoice_number, including items and taxes.
|
||||
*/
|
||||
class GetInvoiceTool extends AiTool
|
||||
{
|
||||
public function name(): string
|
||||
{
|
||||
return 'get_invoice';
|
||||
}
|
||||
|
||||
public function description(): string
|
||||
{
|
||||
return 'Fetch full details for a single invoice by its invoice_number, including line items, taxes, totals, customer info, and dates. Use this after search_invoices when the user wants more detail on a specific invoice.';
|
||||
}
|
||||
|
||||
public function parameterSchema(): array
|
||||
{
|
||||
return [
|
||||
'type' => '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(),
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
43
app/Services/Ai/Tools/ListExpenseCategoriesTool.php
Normal file
43
app/Services/Ai/Tools/ListExpenseCategoriesTool.php
Normal file
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Ai\Tools;
|
||||
|
||||
use App\Models\ExpenseCategory;
|
||||
|
||||
class ListExpenseCategoriesTool extends AiTool
|
||||
{
|
||||
public function name(): string
|
||||
{
|
||||
return 'list_expense_categories';
|
||||
}
|
||||
|
||||
public function description(): string
|
||||
{
|
||||
return 'List all expense categories defined for the current company.';
|
||||
}
|
||||
|
||||
public function parameterSchema(): array
|
||||
{
|
||||
return [
|
||||
'type' => '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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
54
app/Services/Ai/Tools/ListOverdueInvoicesTool.php
Normal file
54
app/Services/Ai/Tools/ListOverdueInvoicesTool.php
Normal file
@@ -0,0 +1,54 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Ai\Tools;
|
||||
|
||||
use App\Models\Invoice;
|
||||
|
||||
class ListOverdueInvoicesTool extends AiTool
|
||||
{
|
||||
public function name(): string
|
||||
{
|
||||
return 'list_overdue_invoices';
|
||||
}
|
||||
|
||||
public function description(): string
|
||||
{
|
||||
return 'List all invoices for the current company that are currently overdue (past their due date and unpaid or partially paid). Sorted by oldest-due-first.';
|
||||
}
|
||||
|
||||
public function parameterSchema(): array
|
||||
{
|
||||
return [
|
||||
'type' => '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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
79
app/Services/Ai/Tools/ListRecentPaymentsTool.php
Normal file
79
app/Services/Ai/Tools/ListRecentPaymentsTool.php
Normal file
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Ai\Tools;
|
||||
|
||||
use App\Models\Payment;
|
||||
use Carbon\Carbon;
|
||||
|
||||
class ListRecentPaymentsTool extends AiTool
|
||||
{
|
||||
private const DEFAULT_DAYS = 30;
|
||||
|
||||
private const MAX_DAYS = 365;
|
||||
|
||||
private const DEFAULT_LIMIT = 20;
|
||||
|
||||
private const MAX_LIMIT = 100;
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return 'list_recent_payments';
|
||||
}
|
||||
|
||||
public function description(): string
|
||||
{
|
||||
return 'List payments received in the last N days for the current company, sorted most recent first. Returns payment number, customer, amount, payment date, and payment method.';
|
||||
}
|
||||
|
||||
public function parameterSchema(): array
|
||||
{
|
||||
return [
|
||||
'type' => '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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
73
app/Services/Ai/Tools/SearchCustomersTool.php
Normal file
73
app/Services/Ai/Tools/SearchCustomersTool.php
Normal file
@@ -0,0 +1,73 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Ai\Tools;
|
||||
|
||||
use App\Models\Customer;
|
||||
|
||||
class SearchCustomersTool extends AiTool
|
||||
{
|
||||
private const DEFAULT_LIMIT = 10;
|
||||
|
||||
private const MAX_LIMIT = 50;
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return 'search_customers';
|
||||
}
|
||||
|
||||
public function description(): string
|
||||
{
|
||||
return 'Search customers for the current company by free-text query (matches name, display_name, email, company_name, contact_name). Returns a compact list with ids, names, and contact info.';
|
||||
}
|
||||
|
||||
public function parameterSchema(): array
|
||||
{
|
||||
return [
|
||||
'type' => '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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
108
app/Services/Ai/Tools/SearchInvoicesTool.php
Normal file
108
app/Services/Ai/Tools/SearchInvoicesTool.php
Normal file
@@ -0,0 +1,108 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Ai\Tools;
|
||||
|
||||
use App\Models\Invoice;
|
||||
|
||||
/**
|
||||
* Search invoices by free text, status, and/or customer, scoped to the current company.
|
||||
*
|
||||
* Returns a compact list — no line items, no taxes. For full details the LLM
|
||||
* should follow up with GetInvoiceTool using the returned invoice_number.
|
||||
*/
|
||||
class SearchInvoicesTool extends AiTool
|
||||
{
|
||||
private const DEFAULT_LIMIT = 10;
|
||||
|
||||
private const MAX_LIMIT = 50;
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return 'search_invoices';
|
||||
}
|
||||
|
||||
public function description(): string
|
||||
{
|
||||
return 'Search invoices for the current company. Filter by free-text query (matches invoice number and reference), status, or customer_id. Returns a compact list with invoice numbers, customers, dates, totals, and statuses.';
|
||||
}
|
||||
|
||||
public function parameterSchema(): array
|
||||
{
|
||||
return [
|
||||
'type' => '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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
68
app/Services/Ai/Tools/SearchItemsTool.php
Normal file
68
app/Services/Ai/Tools/SearchItemsTool.php
Normal file
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Ai\Tools;
|
||||
|
||||
use App\Models\Item;
|
||||
|
||||
class SearchItemsTool extends AiTool
|
||||
{
|
||||
private const DEFAULT_LIMIT = 10;
|
||||
|
||||
private const MAX_LIMIT = 50;
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return 'search_items';
|
||||
}
|
||||
|
||||
public function description(): string
|
||||
{
|
||||
return 'Search catalog items (products/services) for the current company by free-text query (matches name and description). Returns id, name, unit price, and description.';
|
||||
}
|
||||
|
||||
public function parameterSchema(): array
|
||||
{
|
||||
return [
|
||||
'type' => '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(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
<?php
|
||||
|
||||
use App\Providers\AiServiceProvider;
|
||||
use App\Providers\AppConfigProvider;
|
||||
use App\Providers\AppServiceProvider;
|
||||
use App\Providers\DriverRegistryProvider;
|
||||
@@ -17,5 +18,6 @@ return [
|
||||
ViewServiceProvider::class,
|
||||
PdfServiceProvider::class,
|
||||
DriverRegistryProvider::class,
|
||||
AiServiceProvider::class,
|
||||
AppConfigProvider::class,
|
||||
];
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('ai_conversations', function (Blueprint $table) {
|
||||
$table->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');
|
||||
}
|
||||
};
|
||||
18
lang/en.json
18
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…"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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<AiChatSendResponse> {
|
||||
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<AiConversationDetail> {
|
||||
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
|
||||
},
|
||||
}
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
<script setup lang="ts">
|
||||
import { useAiChatStore } from '../stores/ai-chat.store'
|
||||
import type { AiConversationSummary } from '@/scripts/types/ai-config'
|
||||
|
||||
const store = useAiChatStore()
|
||||
|
||||
async function select(convo: AiConversationSummary): Promise<void> {
|
||||
await store.loadConversation(convo.id)
|
||||
}
|
||||
|
||||
async function remove(convo: AiConversationSummary, event: MouseEvent): Promise<void> {
|
||||
event.stopPropagation()
|
||||
if (!window.confirm('Delete this conversation?')) return
|
||||
await store.deleteConversation(convo.id)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col h-full">
|
||||
<div class="p-3 border-b border-line-default">
|
||||
<button
|
||||
type="button"
|
||||
class="w-full text-left text-sm font-medium rounded px-3 py-2 bg-btn-primary text-white hover:bg-btn-primary-hover"
|
||||
@click="store.newConversation()"
|
||||
>
|
||||
+ {{ $t('ai.chat.new_conversation') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto">
|
||||
<div
|
||||
v-if="store.isLoadingConversations && store.conversations.length === 0"
|
||||
class="p-3 text-xs text-muted"
|
||||
>
|
||||
{{ $t('general.loading') }}...
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="store.conversations.length === 0"
|
||||
class="p-3 text-xs text-muted"
|
||||
>
|
||||
{{ $t('ai.chat.no_conversations') }}
|
||||
</div>
|
||||
|
||||
<ul v-else class="space-y-1 p-2">
|
||||
<li
|
||||
v-for="convo in store.conversations"
|
||||
:key="convo.id"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="
|
||||
w-full text-left flex items-center justify-between
|
||||
px-3 py-2 rounded text-sm group
|
||||
hover:bg-hover
|
||||
"
|
||||
:class="{
|
||||
'bg-hover-strong font-semibold': store.currentConversationId === convo.id,
|
||||
}"
|
||||
@click="select(convo)"
|
||||
>
|
||||
<span class="truncate text-body">
|
||||
{{ convo.title ?? $t('ai.chat.untitled') }}
|
||||
</span>
|
||||
<span
|
||||
class="
|
||||
ml-2 text-xs text-muted opacity-0 group-hover:opacity-100
|
||||
hover:text-alert-error-text
|
||||
"
|
||||
@click="remove(convo, $event)"
|
||||
>
|
||||
{{ $t('general.delete') }}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,140 @@
|
||||
<script setup lang="ts">
|
||||
import { nextTick, ref, watch } from 'vue'
|
||||
import { useAiChatStore } from '../stores/ai-chat.store'
|
||||
import AiChatMessage from './AiChatMessage.vue'
|
||||
import AiChatMessageInput from './AiChatMessageInput.vue'
|
||||
import AiChatConversationList from './AiChatConversationList.vue'
|
||||
|
||||
const store = useAiChatStore()
|
||||
|
||||
const messagesEl = ref<HTMLDivElement | null>(null)
|
||||
|
||||
// Auto-scroll to the bottom whenever the message list grows.
|
||||
watch(
|
||||
() => store.messages.length,
|
||||
async () => {
|
||||
await nextTick()
|
||||
if (messagesEl.value) {
|
||||
messagesEl.value.scrollTop = messagesEl.value.scrollHeight
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
async function onSend(message: string): Promise<void> {
|
||||
await store.sendMessage(message)
|
||||
await nextTick()
|
||||
if (messagesEl.value) {
|
||||
messagesEl.value.scrollTop = messagesEl.value.scrollHeight
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- Backdrop -->
|
||||
<Teleport to="body">
|
||||
<transition name="ai-drawer-fade">
|
||||
<div
|
||||
v-if="store.isOpen"
|
||||
class="fixed inset-0 bg-black/20 z-40"
|
||||
@click="store.close()"
|
||||
/>
|
||||
</transition>
|
||||
|
||||
<!-- Drawer panel -->
|
||||
<transition name="ai-drawer-slide">
|
||||
<aside
|
||||
v-if="store.isOpen"
|
||||
class="
|
||||
fixed top-0 right-0 bottom-0 z-50
|
||||
w-full sm:w-[480px] lg:w-[640px]
|
||||
bg-surface shadow-2xl
|
||||
flex
|
||||
"
|
||||
>
|
||||
<!-- Conversation list sidebar -->
|
||||
<div class="hidden sm:block w-48 border-r border-line-default bg-surface-secondary">
|
||||
<AiChatConversationList />
|
||||
</div>
|
||||
|
||||
<!-- Messages + input -->
|
||||
<div class="flex-1 flex flex-col">
|
||||
<div class="flex items-center justify-between p-3 border-b border-line-default">
|
||||
<div class="flex items-center gap-2">
|
||||
<BaseIcon name="SparklesIcon" class="w-5 h-5 text-primary-500" />
|
||||
<h2 class="text-sm font-semibold text-heading">
|
||||
{{ $t('ai.chat.title') }}
|
||||
</h2>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="text-muted hover:text-heading"
|
||||
@click="store.close()"
|
||||
>
|
||||
<BaseIcon name="XMarkIcon" class="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref="messagesEl"
|
||||
class="flex-1 overflow-y-auto p-4 space-y-3"
|
||||
>
|
||||
<div
|
||||
v-if="store.messages.length === 0"
|
||||
class="text-center text-sm text-muted mt-12"
|
||||
>
|
||||
<BaseIcon name="SparklesIcon" class="w-10 h-10 mx-auto mb-2 text-subtle" />
|
||||
<p>{{ $t('ai.chat.empty_state') }}</p>
|
||||
</div>
|
||||
|
||||
<AiChatMessage
|
||||
v-for="msg in store.messages"
|
||||
:key="msg.id"
|
||||
:message="msg"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-if="store.isSending"
|
||||
class="flex justify-start"
|
||||
>
|
||||
<div class="bg-surface-tertiary rounded-lg px-4 py-2 text-sm text-muted italic">
|
||||
{{ $t('ai.chat.thinking') }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="store.lastError"
|
||||
class="p-3 text-xs text-alert-error-text bg-alert-error-bg rounded"
|
||||
>
|
||||
{{ store.lastError }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AiChatMessageInput
|
||||
:is-sending="store.isSending"
|
||||
@send="onSend"
|
||||
/>
|
||||
</div>
|
||||
</aside>
|
||||
</transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ai-drawer-fade-enter-active,
|
||||
.ai-drawer-fade-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
.ai-drawer-fade-enter-from,
|
||||
.ai-drawer-fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.ai-drawer-slide-enter-active,
|
||||
.ai-drawer-slide-leave-active {
|
||||
transition: transform 0.25s ease;
|
||||
}
|
||||
.ai-drawer-slide-enter-from,
|
||||
.ai-drawer-slide-leave-to {
|
||||
transform: translateX(100%);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,31 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { AiChatMessage } from '@/scripts/types/ai-config'
|
||||
|
||||
const props = defineProps<{
|
||||
message: AiChatMessage
|
||||
}>()
|
||||
|
||||
const isUser = computed(() => props.message.role === 'user')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="flex"
|
||||
:class="isUser ? 'justify-end' : 'justify-start'"
|
||||
>
|
||||
<div
|
||||
class="max-w-[85%] rounded-lg px-4 py-2 text-sm"
|
||||
:class="
|
||||
isUser
|
||||
? 'bg-primary-500 text-white'
|
||||
: 'bg-surface-tertiary text-body'
|
||||
"
|
||||
>
|
||||
<!-- Plain text rendering for v1 — markdown support can ship later. -->
|
||||
<p class="whitespace-pre-wrap break-words">
|
||||
{{ message.content ?? '' }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,62 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
isSending?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
send: [message: string]
|
||||
}>()
|
||||
|
||||
const text = ref<string>('')
|
||||
|
||||
function submit(): void {
|
||||
const trimmed = text.value.trim()
|
||||
if (!trimmed || props.isSending) return
|
||||
|
||||
emit('send', trimmed)
|
||||
text.value = ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Shift+Enter → newline, Enter alone → submit (standard chat UX).
|
||||
*/
|
||||
function onKeydown(e: KeyboardEvent): void {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
submit()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<form
|
||||
class="border-t border-line-default p-3 flex items-end gap-2"
|
||||
@submit.prevent="submit"
|
||||
>
|
||||
<textarea
|
||||
v-model="text"
|
||||
rows="2"
|
||||
class="
|
||||
flex-1 resize-none rounded-md border border-line-default
|
||||
bg-surface text-body text-sm px-3 py-2
|
||||
focus:outline-none focus:ring-1 focus:ring-primary-500
|
||||
"
|
||||
:placeholder="$t('ai.chat.input_placeholder')"
|
||||
:disabled="isSending"
|
||||
@keydown="onKeydown"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
class="
|
||||
rounded-md px-3 py-2 text-sm font-medium
|
||||
bg-btn-primary text-white hover:bg-btn-primary-hover
|
||||
disabled:opacity-50 disabled:cursor-not-allowed
|
||||
"
|
||||
:disabled="!text.trim() || isSending"
|
||||
>
|
||||
{{ isSending ? $t('ai.chat.sending') : $t('ai.chat.send') }}
|
||||
</button>
|
||||
</form>
|
||||
</template>
|
||||
149
resources/scripts/features/company/ai/stores/ai-chat.store.ts
Normal file
149
resources/scripts/features/company/ai/stores/ai-chat.store.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { computed, ref } from 'vue'
|
||||
import { aiService } from '@/scripts/api/services/ai.service'
|
||||
import type {
|
||||
AiChatMessage,
|
||||
AiConversationSummary,
|
||||
} from '@/scripts/types/ai-config'
|
||||
|
||||
/**
|
||||
* Chat drawer state + conversation history.
|
||||
*
|
||||
* The drawer is a global overlay (not a route), so this store is where its
|
||||
* open/closed state, current conversation, message list, and loading state all
|
||||
* live. Message-sending is persisted server-side — we don't keep optimistic
|
||||
* state across reloads.
|
||||
*/
|
||||
export const useAiChatStore = defineStore('ai-chat', () => {
|
||||
// --- Drawer UI state ---
|
||||
const isOpen = ref<boolean>(false)
|
||||
|
||||
// --- Current conversation ---
|
||||
const currentConversationId = ref<number | null>(null)
|
||||
const messages = ref<AiChatMessage[]>([])
|
||||
const isSending = ref<boolean>(false)
|
||||
const lastError = ref<string | null>(null)
|
||||
|
||||
// --- Conversation list (sidebar inside the drawer) ---
|
||||
const conversations = ref<AiConversationSummary[]>([])
|
||||
const isLoadingConversations = ref<boolean>(false)
|
||||
|
||||
const hasActiveConversation = computed<boolean>(() => currentConversationId.value !== null)
|
||||
|
||||
// --- Actions ---
|
||||
|
||||
function open(): void {
|
||||
isOpen.value = true
|
||||
// Refresh the sidebar list on open so the user sees any new conversations
|
||||
// they started in another tab.
|
||||
void refreshConversations()
|
||||
}
|
||||
|
||||
function close(): void {
|
||||
isOpen.value = false
|
||||
}
|
||||
|
||||
function toggle(): void {
|
||||
isOpen.value ? close() : open()
|
||||
}
|
||||
|
||||
function newConversation(): void {
|
||||
currentConversationId.value = null
|
||||
messages.value = []
|
||||
lastError.value = null
|
||||
}
|
||||
|
||||
async function refreshConversations(): Promise<void> {
|
||||
isLoadingConversations.value = true
|
||||
try {
|
||||
const response = await aiService.listConversations()
|
||||
conversations.value = response.conversations
|
||||
} catch {
|
||||
// silent — the drawer stays functional without the sidebar list
|
||||
} finally {
|
||||
isLoadingConversations.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadConversation(id: number): Promise<void> {
|
||||
lastError.value = null
|
||||
const response = await aiService.getConversation(id)
|
||||
currentConversationId.value = response.conversation.id
|
||||
messages.value = response.messages
|
||||
}
|
||||
|
||||
async function sendMessage(text: string): Promise<void> {
|
||||
if (!text.trim()) return
|
||||
if (isSending.value) return
|
||||
|
||||
lastError.value = null
|
||||
isSending.value = true
|
||||
|
||||
// Optimistic local append so the user sees their message immediately.
|
||||
const optimistic: AiChatMessage = {
|
||||
id: Date.now() * -1,
|
||||
role: 'user',
|
||||
content: text,
|
||||
created_at: new Date().toISOString(),
|
||||
}
|
||||
messages.value.push(optimistic)
|
||||
|
||||
try {
|
||||
const response = await aiService.sendChatMessage(currentConversationId.value, text)
|
||||
|
||||
// The backend may have started a new conversation for us.
|
||||
currentConversationId.value = response.conversation.id
|
||||
messages.value.push(response.message)
|
||||
|
||||
// Refresh the sidebar so the new/updated conversation bubbles to the top.
|
||||
void refreshConversations()
|
||||
} catch (err) {
|
||||
// Roll back the optimistic message so the user can retry.
|
||||
messages.value = messages.value.filter((m) => m.id !== optimistic.id)
|
||||
|
||||
const message = err instanceof Error ? err.message : 'Unknown error'
|
||||
lastError.value = message
|
||||
} finally {
|
||||
isSending.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteConversation(id: number): Promise<void> {
|
||||
await aiService.deleteConversation(id)
|
||||
conversations.value = conversations.value.filter((c) => c.id !== id)
|
||||
|
||||
// If the deleted conversation is the one currently shown, start fresh.
|
||||
if (currentConversationId.value === id) {
|
||||
newConversation()
|
||||
}
|
||||
}
|
||||
|
||||
async function renameConversation(id: number, title: string): Promise<void> {
|
||||
await aiService.renameConversation(id, title)
|
||||
const existing = conversations.value.find((c) => c.id === id)
|
||||
if (existing) existing.title = title
|
||||
}
|
||||
|
||||
return {
|
||||
// state
|
||||
isOpen,
|
||||
currentConversationId,
|
||||
messages,
|
||||
isSending,
|
||||
lastError,
|
||||
conversations,
|
||||
isLoadingConversations,
|
||||
// getters
|
||||
hasActiveConversation,
|
||||
// actions
|
||||
open,
|
||||
close,
|
||||
toggle,
|
||||
newConversation,
|
||||
refreshConversations,
|
||||
loadConversation,
|
||||
sendMessage,
|
||||
deleteConversation,
|
||||
renameConversation,
|
||||
}
|
||||
})
|
||||
@@ -22,6 +22,9 @@
|
||||
<router-view />
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- AI chat drawer — always mounted, visibility driven by the store -->
|
||||
<AiChatDrawer v-if="globalStore.ai?.enabled && globalStore.ai?.chat_enabled" />
|
||||
</div>
|
||||
|
||||
<BaseGlobalLoader v-else />
|
||||
@@ -39,6 +42,7 @@ import SiteHeader from './partials/SiteHeader.vue'
|
||||
import SiteSidebar from './partials/SiteSidebar.vue'
|
||||
import NotificationRoot from '@/scripts/components/notifications/NotificationRoot.vue'
|
||||
import ImpersonationBanner from './partials/ImpersonationBanner.vue'
|
||||
import AiChatDrawer from '@/scripts/features/company/ai/components/AiChatDrawer.vue'
|
||||
|
||||
interface RouteMeta {
|
||||
ability?: string | string[]
|
||||
|
||||
@@ -99,6 +99,28 @@
|
||||
/>
|
||||
</li>
|
||||
|
||||
<!-- AI chat drawer trigger -->
|
||||
<li
|
||||
v-if="
|
||||
!companyStore.isAdminMode &&
|
||||
globalStore.ai?.enabled &&
|
||||
globalStore.ai?.chat_enabled
|
||||
"
|
||||
class="ml-2"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="
|
||||
flex items-center justify-center w-8 h-8 md:w-9 md:h-9 rounded-lg
|
||||
bg-white/20 hover:bg-white/30 text-white
|
||||
"
|
||||
:title="$t('ai.chat.title')"
|
||||
@click="aiChatStore.toggle()"
|
||||
>
|
||||
<BaseIcon name="SparklesIcon" class="w-5 h-5" />
|
||||
</button>
|
||||
</li>
|
||||
|
||||
<!-- Company switcher -->
|
||||
<li>
|
||||
<CompanySwitcher />
|
||||
@@ -182,6 +204,7 @@ import { useAuthStore } from '@/scripts/stores/auth.store'
|
||||
import { useUserStore } from '@/scripts/stores/user.store'
|
||||
import { useGlobalStore } from '@/scripts/stores/global.store'
|
||||
import { useCompanyStore } from '@/scripts/stores/company.store'
|
||||
import { useAiChatStore } from '@/scripts/features/company/ai/stores/ai-chat.store'
|
||||
import { useTheme } from '@/scripts/composables/use-theme'
|
||||
import { ABILITIES } from '@/scripts/config/abilities'
|
||||
import { THEME } from '@/scripts/config/constants'
|
||||
@@ -199,6 +222,7 @@ const authStore = useAuthStore()
|
||||
const userStore = useUserStore()
|
||||
const globalStore = useGlobalStore()
|
||||
const companyStore = useCompanyStore()
|
||||
const aiChatStore = useAiChatStore()
|
||||
const router = useRouter()
|
||||
const { currentTheme, setTheme } = useTheme()
|
||||
|
||||
|
||||
@@ -36,6 +36,11 @@ export const useGlobalStore = defineStore('global', () => {
|
||||
const mainMenu = ref<MenuItem[]>([])
|
||||
const settingMenu = ref<MenuItem[]>([])
|
||||
const userMenu = ref<Array<{ title: string; link: string; icon: string; name: string }>>([])
|
||||
const ai = ref<{ enabled: boolean; chat_enabled: boolean; text_generation_enabled: boolean }>({
|
||||
enabled: false,
|
||||
chat_enabled: false,
|
||||
text_generation_enabled: false,
|
||||
})
|
||||
const isAppLoaded = ref<boolean>(false)
|
||||
const isSidebarOpen = ref<boolean>(false)
|
||||
const isSidebarCollapsed = ref<boolean>(localStore.getBoolean('sidebarCollapsed'))
|
||||
@@ -64,6 +69,11 @@ export const useGlobalStore = defineStore('global', () => {
|
||||
mainMenu.value = response.main_menu
|
||||
settingMenu.value = response.setting_menu
|
||||
userMenu.value = response.user_menu ?? []
|
||||
ai.value = response.ai ?? {
|
||||
enabled: false,
|
||||
chat_enabled: false,
|
||||
text_generation_enabled: false,
|
||||
}
|
||||
|
||||
config.value = response.config
|
||||
globalSettings.value = response.global_settings
|
||||
@@ -292,6 +302,7 @@ export const useGlobalStore = defineStore('global', () => {
|
||||
mainMenu,
|
||||
settingMenu,
|
||||
userMenu,
|
||||
ai,
|
||||
isAppLoaded,
|
||||
isSidebarOpen,
|
||||
isSidebarCollapsed,
|
||||
|
||||
@@ -53,3 +53,31 @@ export interface AiTestResponse {
|
||||
message?: string
|
||||
details?: Record<string, unknown>
|
||||
}
|
||||
|
||||
// --- Phase 2: chat types ---
|
||||
|
||||
export interface AiConversationSummary {
|
||||
id: number
|
||||
title: string | null
|
||||
model: string | null
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface AiChatMessage {
|
||||
id: number
|
||||
role: 'user' | 'assistant'
|
||||
content: string | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export interface AiChatSendResponse {
|
||||
conversation: AiConversationSummary
|
||||
message: AiChatMessage
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface AiConversationDetail {
|
||||
conversation: AiConversationSummary
|
||||
messages: AiChatMessage[]
|
||||
}
|
||||
|
||||
@@ -16,6 +16,8 @@ use App\Http\Controllers\Admin\Settings\SettingsController;
|
||||
use App\Http\Controllers\Admin\UpdateController;
|
||||
use App\Http\Controllers\Admin\UsersController;
|
||||
use App\Http\Controllers\AppVersionController;
|
||||
use App\Http\Controllers\Company\Ai\ChatController as AiChatController;
|
||||
use App\Http\Controllers\Company\Ai\ConversationController as AiConversationController;
|
||||
use App\Http\Controllers\Company\Auth\AuthController;
|
||||
use App\Http\Controllers\Company\Auth\ForgotPasswordController;
|
||||
use App\Http\Controllers\Company\Auth\InvitationRegistrationController;
|
||||
@@ -433,6 +435,15 @@ Route::prefix('/v1')->group(function () {
|
||||
Route::post('/company/ai/config', [CompanyAiConfigurationController::class, 'saveConfig']);
|
||||
Route::post('/company/ai/test', [CompanyAiConfigurationController::class, 'testConnection']);
|
||||
|
||||
// AI Chat — rate-limited via the 'ai' limiter defined in RouteServiceProvider
|
||||
Route::middleware('throttle:ai')->group(function () {
|
||||
Route::post('/ai/chat', AiChatController::class);
|
||||
Route::get('/ai/conversations', [AiConversationController::class, 'index']);
|
||||
Route::get('/ai/conversations/{id}', [AiConversationController::class, 'show']);
|
||||
Route::patch('/ai/conversations/{id}', [AiConversationController::class, 'update']);
|
||||
Route::delete('/ai/conversations/{id}', [AiConversationController::class, 'destroy']);
|
||||
});
|
||||
|
||||
// PDF Generation
|
||||
// ----------------------------------
|
||||
|
||||
|
||||
290
tests/Feature/Ai/AiChatFlowTest.php
Normal file
290
tests/Feature/Ai/AiChatFlowTest.php
Normal file
@@ -0,0 +1,290 @@
|
||||
<?php
|
||||
|
||||
use App\Models\AiConversation;
|
||||
use App\Models\AiMessage;
|
||||
use App\Models\User;
|
||||
use App\Services\AiConfigurationService;
|
||||
use App\Support\Ai\AiChatResponse;
|
||||
use App\Support\Ai\AiDriver;
|
||||
use App\Support\Ai\AiDriverFactory;
|
||||
use App\Support\Ai\AiException;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Laravel\Sanctum\Sanctum;
|
||||
|
||||
use function Pest\Laravel\getJson;
|
||||
use function Pest\Laravel\postJson;
|
||||
|
||||
/**
|
||||
* Test double for AiDriver that returns pre-queued responses from an array,
|
||||
* so tests can script tool-call loops without hitting any real LLM API.
|
||||
*
|
||||
* Usage:
|
||||
* ScriptedAiDriver::setResponses([
|
||||
* new AiChatResponse(message: null, toolCalls: [...]),
|
||||
* new AiChatResponse(message: 'Final answer'),
|
||||
* ]);
|
||||
*/
|
||||
class ScriptedAiDriver extends AiDriver
|
||||
{
|
||||
/** @var array<int, AiChatResponse> */
|
||||
public static array $responses = [];
|
||||
|
||||
public static int $callCount = 0;
|
||||
|
||||
/** @var array<int, array<string, mixed>> */
|
||||
public static array $lastMessages = [];
|
||||
|
||||
public static function reset(): void
|
||||
{
|
||||
self::$responses = [];
|
||||
self::$callCount = 0;
|
||||
self::$lastMessages = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, AiChatResponse> $responses
|
||||
*/
|
||||
public static function setResponses(array $responses): void
|
||||
{
|
||||
self::$responses = $responses;
|
||||
self::$callCount = 0;
|
||||
}
|
||||
|
||||
public function chatCompletion(
|
||||
array $messages,
|
||||
string $model,
|
||||
array $tools = [],
|
||||
array $options = [],
|
||||
): AiChatResponse {
|
||||
self::$lastMessages = $messages;
|
||||
$response = self::$responses[self::$callCount] ?? new AiChatResponse(message: 'Default test reply');
|
||||
self::$callCount++;
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
public function textCompletion(string $prompt, string $model, array $options = []): string
|
||||
{
|
||||
return 'text completion test';
|
||||
}
|
||||
|
||||
public function validateConnection(): array
|
||||
{
|
||||
return ['ok' => true];
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(function () {
|
||||
Artisan::call('db:seed', ['--class' => 'DatabaseSeeder', '--force' => true]);
|
||||
Artisan::call('db:seed', ['--class' => 'DemoSeeder', '--force' => true]);
|
||||
|
||||
$this->user = User::find(1);
|
||||
$this->companyId = $this->user->companies()->first()->id;
|
||||
|
||||
$this->withHeaders(['company' => $this->companyId]);
|
||||
Sanctum::actingAs($this->user, ['*']);
|
||||
|
||||
// Enable AI globally so resolveForCompany returns a config
|
||||
app(AiConfigurationService::class)->saveGlobalConfig([
|
||||
'ai_enabled' => 'YES',
|
||||
'ai_driver' => 'scripted',
|
||||
'ai_api_key' => 'test-key',
|
||||
'ai_chat_enabled' => 'YES',
|
||||
'ai_chat_model' => 'test-model',
|
||||
]);
|
||||
|
||||
// Register the scripted driver so AiDriverFactory returns it for 'scripted'
|
||||
AiDriverFactory::register('scripted', ScriptedAiDriver::class);
|
||||
ScriptedAiDriver::reset();
|
||||
});
|
||||
|
||||
afterEach(function () {
|
||||
ScriptedAiDriver::reset();
|
||||
});
|
||||
|
||||
test('chat endpoint creates a new conversation and persists user + assistant messages', function () {
|
||||
ScriptedAiDriver::setResponses([
|
||||
new AiChatResponse(message: 'Hello back!'),
|
||||
]);
|
||||
|
||||
$response = postJson('/api/v1/ai/chat', [
|
||||
'message' => 'Hi assistant',
|
||||
])->assertOk();
|
||||
|
||||
$conversationId = $response->json('conversation.id');
|
||||
expect($conversationId)->toBeInt();
|
||||
expect($response->json('message.content'))->toBe('Hello back!');
|
||||
expect($response->json('message.role'))->toBe('assistant');
|
||||
|
||||
// Both user and assistant messages should be persisted
|
||||
$conversation = AiConversation::find($conversationId);
|
||||
expect($conversation->user_id)->toBe($this->user->id);
|
||||
expect($conversation->company_id)->toBe($this->companyId);
|
||||
|
||||
$messages = $conversation->messages()->orderBy('created_at')->get();
|
||||
expect($messages->pluck('role')->all())->toBe(['user', 'assistant']);
|
||||
expect($messages[0]->content)->toBe('Hi assistant');
|
||||
expect($messages[1]->content)->toBe('Hello back!');
|
||||
});
|
||||
|
||||
test('chat endpoint runs a tool-call loop and returns the final answer', function () {
|
||||
// Round 1: LLM requests search_invoices
|
||||
// Round 2: LLM produces final text
|
||||
ScriptedAiDriver::setResponses([
|
||||
new AiChatResponse(
|
||||
message: null,
|
||||
toolCalls: [[
|
||||
'id' => 'call_1',
|
||||
'name' => 'search_invoices',
|
||||
'arguments' => ['limit' => 5],
|
||||
]],
|
||||
finishReason: 'tool_calls',
|
||||
),
|
||||
new AiChatResponse(message: 'Here are your invoices.'),
|
||||
]);
|
||||
|
||||
$response = postJson('/api/v1/ai/chat', [
|
||||
'message' => 'Show me recent invoices',
|
||||
])->assertOk();
|
||||
|
||||
expect($response->json('message.content'))->toBe('Here are your invoices.');
|
||||
expect(ScriptedAiDriver::$callCount)->toBe(2);
|
||||
|
||||
// Conversation should now contain: user, assistant(tool_calls), tool(result), assistant(final)
|
||||
$conversation = AiConversation::find($response->json('conversation.id'));
|
||||
$roles = $conversation->messages()->orderBy('id')->pluck('role')->all();
|
||||
expect($roles)->toBe(['user', 'assistant', 'tool', 'assistant']);
|
||||
});
|
||||
|
||||
test('chat endpoint caps runaway tool-call loops at MAX_TOOL_ITERATIONS', function () {
|
||||
// All responses are tool_calls — simulating a model stuck in a loop
|
||||
$loopResponse = new AiChatResponse(
|
||||
message: null,
|
||||
toolCalls: [[
|
||||
'id' => 'call_loop',
|
||||
'name' => 'search_invoices',
|
||||
'arguments' => [],
|
||||
]],
|
||||
finishReason: 'tool_calls',
|
||||
);
|
||||
// Queue 10 of them — way more than the 5-iteration cap
|
||||
ScriptedAiDriver::setResponses(array_fill(0, 10, $loopResponse));
|
||||
|
||||
$response = postJson('/api/v1/ai/chat', [
|
||||
'message' => 'Loop forever',
|
||||
])->assertOk();
|
||||
|
||||
// We should hit the cap and the service should return a graceful error message.
|
||||
expect($response->json('message.content'))->toContain('tool-call budget');
|
||||
// 5 calls + the "I give up" turn shouldn't exceed the hard cap
|
||||
expect(ScriptedAiDriver::$callCount)->toBeLessThanOrEqual(5);
|
||||
});
|
||||
|
||||
test('chat endpoint persists an error message when the driver throws', function () {
|
||||
ScriptedAiDriver::setResponses([]); // no responses queued → driver returns default
|
||||
|
||||
// Inject a failing driver via the factory
|
||||
$failingDriver = new class('fake', []) extends AiDriver
|
||||
{
|
||||
public function chatCompletion(array $messages, string $model, array $tools = [], array $options = []): AiChatResponse
|
||||
{
|
||||
throw new AiException('test failure', 'server_error');
|
||||
}
|
||||
|
||||
public function textCompletion(string $prompt, string $model, array $options = []): string
|
||||
{
|
||||
return '';
|
||||
}
|
||||
|
||||
public function validateConnection(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
};
|
||||
AiDriverFactory::register('failing', $failingDriver::class);
|
||||
|
||||
app(AiConfigurationService::class)->saveGlobalConfig([
|
||||
'ai_enabled' => 'YES',
|
||||
'ai_driver' => 'failing',
|
||||
'ai_api_key' => 'k',
|
||||
'ai_chat_enabled' => 'YES',
|
||||
'ai_chat_model' => 'test',
|
||||
]);
|
||||
|
||||
$response = postJson('/api/v1/ai/chat', [
|
||||
'message' => 'Hello',
|
||||
])->assertOk();
|
||||
|
||||
expect($response->json('message.content'))->toContain('Error:');
|
||||
});
|
||||
|
||||
test('chat endpoint rejects when AI is disabled for the company', function () {
|
||||
app(AiConfigurationService::class)->saveGlobalConfig([
|
||||
'ai_enabled' => 'NO',
|
||||
]);
|
||||
|
||||
postJson('/api/v1/ai/chat', [
|
||||
'message' => 'Hi',
|
||||
])->assertStatus(422);
|
||||
});
|
||||
|
||||
test('chat endpoint rejects when chat role is disabled even if AI is enabled', function () {
|
||||
app(AiConfigurationService::class)->saveGlobalConfig([
|
||||
'ai_enabled' => 'YES',
|
||||
'ai_driver' => 'scripted',
|
||||
'ai_api_key' => 'k',
|
||||
'ai_chat_enabled' => 'NO', // <— off
|
||||
'ai_chat_model' => 'test',
|
||||
]);
|
||||
|
||||
postJson('/api/v1/ai/chat', [
|
||||
'message' => 'Hi',
|
||||
])->assertStatus(422);
|
||||
});
|
||||
|
||||
test('conversation index returns only the current user\'s conversations', function () {
|
||||
// Create a conversation for the authenticated user
|
||||
ScriptedAiDriver::setResponses([new AiChatResponse(message: 'hi')]);
|
||||
postJson('/api/v1/ai/chat', ['message' => 'First message'])->assertOk();
|
||||
|
||||
// Create a conversation for a different user in the same company — should NOT be visible
|
||||
$otherUser = User::factory()->create();
|
||||
$otherUser->companies()->attach($this->companyId);
|
||||
AiConversation::create([
|
||||
'company_id' => $this->companyId,
|
||||
'user_id' => $otherUser->id,
|
||||
'title' => 'Other users secret chat',
|
||||
]);
|
||||
|
||||
$response = getJson('/api/v1/ai/conversations')->assertOk();
|
||||
|
||||
$titles = collect($response->json('conversations'))->pluck('title');
|
||||
expect($titles)->not->toContain('Other users secret chat');
|
||||
});
|
||||
|
||||
test('conversation show enforces ownership via policy', function () {
|
||||
$otherUser = User::factory()->create();
|
||||
$otherUser->companies()->attach($this->companyId);
|
||||
|
||||
$foreignConvo = AiConversation::create([
|
||||
'company_id' => $this->companyId,
|
||||
'user_id' => $otherUser->id,
|
||||
'title' => 'Not yours',
|
||||
]);
|
||||
|
||||
getJson("/api/v1/ai/conversations/{$foreignConvo->id}")->assertForbidden();
|
||||
});
|
||||
|
||||
test('conversation delete cascades messages', function () {
|
||||
ScriptedAiDriver::setResponses([new AiChatResponse(message: 'reply')]);
|
||||
$sendResponse = postJson('/api/v1/ai/chat', ['message' => 'First'])->assertOk();
|
||||
$conversationId = $sendResponse->json('conversation.id');
|
||||
|
||||
// There should now be 2 messages
|
||||
expect(AiMessage::where('conversation_id', $conversationId)->count())->toBe(2);
|
||||
|
||||
$this->deleteJson("/api/v1/ai/conversations/{$conversationId}")->assertOk();
|
||||
|
||||
expect(AiConversation::find($conversationId))->toBeNull();
|
||||
expect(AiMessage::where('conversation_id', $conversationId)->count())->toBe(0);
|
||||
});
|
||||
93
tests/Feature/Ai/Tools/SearchInvoicesToolTest.php
Normal file
93
tests/Feature/Ai/Tools/SearchInvoicesToolTest.php
Normal file
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Company;
|
||||
use App\Models\Customer;
|
||||
use App\Models\Invoice;
|
||||
use App\Services\Ai\Tools\SearchInvoicesTool;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
|
||||
beforeEach(function () {
|
||||
Artisan::call('db:seed', ['--class' => 'DatabaseSeeder', '--force' => true]);
|
||||
Artisan::call('db:seed', ['--class' => 'DemoSeeder', '--force' => true]);
|
||||
});
|
||||
|
||||
test('search_invoices scopes strictly to the passed company id', function () {
|
||||
$companyA = Company::first();
|
||||
$companyB = Company::factory()->create();
|
||||
|
||||
$customerA = Customer::factory()->create(['company_id' => $companyA->id]);
|
||||
$customerB = Customer::factory()->create(['company_id' => $companyB->id]);
|
||||
|
||||
$invoiceA = Invoice::factory()->create([
|
||||
'company_id' => $companyA->id,
|
||||
'customer_id' => $customerA->id,
|
||||
'invoice_number' => 'AAA-001',
|
||||
]);
|
||||
$invoiceB = Invoice::factory()->create([
|
||||
'company_id' => $companyB->id,
|
||||
'customer_id' => $customerB->id,
|
||||
'invoice_number' => 'BBB-001',
|
||||
]);
|
||||
|
||||
$tool = new SearchInvoicesTool;
|
||||
|
||||
// Call with companyA — should ONLY see invoiceA
|
||||
$resultA = $tool->execute([], $companyA->id, 1);
|
||||
$numbersA = collect($resultA['invoices'])->pluck('invoice_number');
|
||||
expect($numbersA)->toContain('AAA-001')->not->toContain('BBB-001');
|
||||
|
||||
// Call with companyB — should ONLY see invoiceB
|
||||
$resultB = $tool->execute([], $companyB->id, 1);
|
||||
$numbersB = collect($resultB['invoices'])->pluck('invoice_number');
|
||||
expect($numbersB)->toContain('BBB-001')->not->toContain('AAA-001');
|
||||
});
|
||||
|
||||
test('search_invoices ignores any company_id the caller tries to pass in arguments', function () {
|
||||
$companyA = Company::first();
|
||||
$companyB = Company::factory()->create();
|
||||
|
||||
$customerB = Customer::factory()->create(['company_id' => $companyB->id]);
|
||||
Invoice::factory()->create([
|
||||
'company_id' => $companyB->id,
|
||||
'customer_id' => $customerB->id,
|
||||
'invoice_number' => 'BBB-LEAK',
|
||||
]);
|
||||
|
||||
$tool = new SearchInvoicesTool;
|
||||
|
||||
// Simulate an LLM trying to pass company_id as an argument — even if present,
|
||||
// the tool must ignore it because the schema doesn't include that field and
|
||||
// the execute() method uses the injected $companyId.
|
||||
$result = $tool->execute(
|
||||
['company_id' => $companyB->id, 'query' => 'BBB'],
|
||||
$companyA->id,
|
||||
1,
|
||||
);
|
||||
|
||||
expect(collect($result['invoices'])->pluck('invoice_number'))
|
||||
->not->toContain('BBB-LEAK');
|
||||
});
|
||||
|
||||
test('search_invoices respects the limit parameter with a hard cap', function () {
|
||||
$company = Company::first();
|
||||
$customer = Customer::factory()->create(['company_id' => $company->id]);
|
||||
|
||||
// Create 60 invoices — more than the max limit of 50
|
||||
for ($i = 0; $i < 60; $i++) {
|
||||
Invoice::factory()->create([
|
||||
'company_id' => $company->id,
|
||||
'customer_id' => $customer->id,
|
||||
'invoice_number' => 'SCAN-'.str_pad((string) $i, 3, '0', STR_PAD_LEFT),
|
||||
]);
|
||||
}
|
||||
|
||||
$tool = new SearchInvoicesTool;
|
||||
|
||||
// Passing limit=99 should be capped to 50
|
||||
$result = $tool->execute(['limit' => 99], $company->id, 1);
|
||||
expect($result['invoices'])->toHaveCount(50);
|
||||
|
||||
// Default limit is 10
|
||||
$result = $tool->execute([], $company->id, 1);
|
||||
expect($result['invoices'])->toHaveCount(10);
|
||||
});
|
||||
92
tests/Unit/AiToolRegistryTest.php
Normal file
92
tests/Unit/AiToolRegistryTest.php
Normal file
@@ -0,0 +1,92 @@
|
||||
<?php
|
||||
|
||||
use App\Services\Ai\AiToolRegistry;
|
||||
use App\Services\Ai\Tools\AiTool;
|
||||
|
||||
/**
|
||||
* A stub tool for exercising the registry without touching any real DB tables.
|
||||
*/
|
||||
class FakeAiTool extends AiTool
|
||||
{
|
||||
public array $lastArgs = [];
|
||||
|
||||
public int $lastCompanyId = 0;
|
||||
|
||||
public int $lastUserId = 0;
|
||||
|
||||
public function __construct(
|
||||
private readonly string $toolName = 'fake_tool',
|
||||
) {}
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return $this->toolName;
|
||||
}
|
||||
|
||||
public function description(): string
|
||||
{
|
||||
return 'A fake tool for tests.';
|
||||
}
|
||||
|
||||
public function parameterSchema(): array
|
||||
{
|
||||
return [
|
||||
'type' => 'object',
|
||||
'properties' => [
|
||||
'echo' => ['type' => 'string'],
|
||||
],
|
||||
'required' => [],
|
||||
];
|
||||
}
|
||||
|
||||
public function execute(array $arguments, int $companyId, int $userId): mixed
|
||||
{
|
||||
$this->lastArgs = $arguments;
|
||||
$this->lastCompanyId = $companyId;
|
||||
$this->lastUserId = $userId;
|
||||
|
||||
return ['echo' => $arguments['echo'] ?? null];
|
||||
}
|
||||
}
|
||||
|
||||
test('register stores a tool by its name', function () {
|
||||
$registry = new AiToolRegistry;
|
||||
$tool = new FakeAiTool;
|
||||
|
||||
$registry->register($tool);
|
||||
|
||||
expect($registry->get('fake_tool'))->toBe($tool);
|
||||
expect($registry->all())->toHaveKey('fake_tool');
|
||||
});
|
||||
|
||||
test('schemas returns OpenAI-format tool entries for every registered tool', function () {
|
||||
$registry = new AiToolRegistry;
|
||||
$registry->register(new FakeAiTool('alpha'));
|
||||
$registry->register(new FakeAiTool('beta'));
|
||||
|
||||
$schemas = $registry->schemas();
|
||||
|
||||
expect($schemas)->toHaveCount(2);
|
||||
expect($schemas[0]['type'])->toBe('function');
|
||||
expect($schemas[0]['function']['name'])->toBe('alpha');
|
||||
expect($schemas[1]['function']['name'])->toBe('beta');
|
||||
});
|
||||
|
||||
test('execute injects companyId and userId into the tool and returns its result', function () {
|
||||
$registry = new AiToolRegistry;
|
||||
$tool = new FakeAiTool;
|
||||
$registry->register($tool);
|
||||
|
||||
$result = $registry->execute('fake_tool', ['echo' => 'hello'], 42, 7);
|
||||
|
||||
expect($result)->toEqual(['echo' => 'hello']);
|
||||
expect($tool->lastCompanyId)->toBe(42);
|
||||
expect($tool->lastUserId)->toBe(7);
|
||||
});
|
||||
|
||||
test('execute throws for unknown tool names', function () {
|
||||
$registry = new AiToolRegistry;
|
||||
|
||||
expect(fn () => $registry->execute('nonexistent', [], 1, 1))
|
||||
->toThrow(InvalidArgumentException::class);
|
||||
});
|
||||
Reference in New Issue
Block a user