mirror of
https://github.com/InvoiceShelf/InvoiceShelf.git
synced 2026-09-07 15:44:10 +00:00
refactor(ai): extract assistant into an official module (#749)
* feat(modules): expose host data boundaries * feat(modules): add frontend extension surfaces * refactor(ai): remove assistant from core * chore(ai): prepare the module extraction * fix(modules): load extension styles after the host bundle * chore(modules): lock SDK 3.3.0
This commit is contained in:
@@ -1,107 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Platform\Ai;
|
||||
|
||||
use App\Platform\Ai\Application\AiToolRegistry;
|
||||
use App\Platform\Ai\Application\Tools\GetCompanyStatsTool;
|
||||
use App\Platform\Ai\Application\Tools\GetCustomerTool;
|
||||
use App\Platform\Ai\Application\Tools\GetInvoiceTool;
|
||||
use App\Platform\Ai\Application\Tools\ListExpenseCategoriesTool;
|
||||
use App\Platform\Ai\Application\Tools\ListOverdueInvoicesTool;
|
||||
use App\Platform\Ai\Application\Tools\ListRecentPaymentsTool;
|
||||
use App\Platform\Ai\Application\Tools\RankExpenseCategoriesTool;
|
||||
use App\Platform\Ai\Application\Tools\RankTopCustomersTool;
|
||||
use App\Platform\Ai\Application\Tools\RankTopItemsTool;
|
||||
use App\Platform\Ai\Application\Tools\SearchCustomersTool;
|
||||
use App\Platform\Ai\Application\Tools\SearchInvoicesTool;
|
||||
use App\Platform\Ai\Application\Tools\SearchItemsTool;
|
||||
use App\Platform\Ai\Drivers\OpenRouterDriver;
|
||||
use App\Platform\Ai\Models\AiConversation;
|
||||
use App\Platform\Ai\Policies\AiAccessPolicy;
|
||||
use App\Platform\Ai\Policies\AiConversationPolicy;
|
||||
use Illuminate\Cache\RateLimiting\Limit;
|
||||
use Illuminate\Contracts\Foundation\Application;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use InvoiceShelf\Modules\Registry;
|
||||
|
||||
/**
|
||||
* 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 boot(): void
|
||||
{
|
||||
Gate::policy(AiConversation::class, AiConversationPolicy::class);
|
||||
Gate::define('manage ai config', [AiAccessPolicy::class, 'manageConfiguration']);
|
||||
Gate::define('use ai', [AiAccessPolicy::class, 'use']);
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
Registry::registerAiDriver('openrouter', [
|
||||
'class' => OpenRouterDriver::class,
|
||||
'label' => 'settings.ai.openrouter',
|
||||
'website' => 'https://openrouter.ai',
|
||||
'default_base_url' => 'https://openrouter.ai/api/v1',
|
||||
'supported_roles' => ['chat', 'text_generation'],
|
||||
'suggested_models' => [
|
||||
['value' => 'anthropic/claude-sonnet-4.6', 'label' => 'Anthropic Claude Sonnet 4.6'],
|
||||
['value' => 'anthropic/claude-haiku-4.5', 'label' => 'Anthropic Claude Haiku 4.5'],
|
||||
['value' => 'anthropic/claude-opus-4.6', 'label' => 'Anthropic Claude Opus 4.6'],
|
||||
['value' => 'openai/gpt-5.4', 'label' => 'OpenAI GPT-5.4'],
|
||||
['value' => 'openai/gpt-5.4-mini', 'label' => 'OpenAI GPT-5.4 mini'],
|
||||
['value' => 'google/gemini-3.1-pro-preview', 'label' => 'Google Gemini 3.1 Pro (preview)'],
|
||||
['value' => 'google/gemini-3.1-flash-lite-preview', 'label' => 'Google Gemini 3.1 Flash Lite (preview)'],
|
||||
['value' => 'z-ai/glm-5.1', 'label' => 'Z.AI GLM 5.1'],
|
||||
['value' => 'z-ai/glm-4.7-flash', 'label' => 'Z.AI GLM 4.7 Flash'],
|
||||
],
|
||||
'config_fields' => [
|
||||
[
|
||||
'key' => 'base_url',
|
||||
'type' => 'text',
|
||||
'label' => 'settings.ai.base_url',
|
||||
'default' => 'https://openrouter.ai/api/v1',
|
||||
],
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function register(): void
|
||||
{
|
||||
$this->app->singleton(AiToolRegistry::class, function (Application $app): AiToolRegistry {
|
||||
$registry = new AiToolRegistry;
|
||||
|
||||
// Built-in read-only tools (order is presentation-only; the LLM picks).
|
||||
$registry->register(new SearchInvoicesTool);
|
||||
$registry->register(new GetInvoiceTool);
|
||||
$registry->register(new ListOverdueInvoicesTool);
|
||||
$registry->register(new SearchCustomersTool);
|
||||
$registry->register(new GetCustomerTool);
|
||||
$registry->register(new ListRecentPaymentsTool);
|
||||
$registry->register(new SearchItemsTool);
|
||||
$registry->register(new ListExpenseCategoriesTool);
|
||||
$registry->register(new GetCompanyStatsTool);
|
||||
|
||||
// Ranking tools — group-by aggregates the individual-record
|
||||
// tools above can't express.
|
||||
$registry->register(new RankTopCustomersTool);
|
||||
$registry->register(new RankTopItemsTool);
|
||||
$registry->register(new RankExpenseCategoriesTool);
|
||||
|
||||
return $registry;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,324 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Platform\Ai\Application;
|
||||
|
||||
use App\Domains\Accounts\Models\Company;
|
||||
use App\Domains\Accounts\Models\User;
|
||||
use App\Platform\Ai\Data\AiChatResponse;
|
||||
use App\Platform\Ai\Exceptions\AiException;
|
||||
use App\Platform\Ai\Models\AiConversation;
|
||||
use App\Platform\Ai\Models\AiMessage;
|
||||
use App\Platform\Ai\Prompting\PromptLoader;
|
||||
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($conversation->user_id);
|
||||
|
||||
for ($iteration = 0; $iteration < self::MAX_TOOL_ITERATIONS; $iteration++) {
|
||||
try {
|
||||
$response = $driver->chatCompletion($messages, $model, $tools);
|
||||
} catch (AiException $e) {
|
||||
// Persist the error as an assistant message so the UI can render it.
|
||||
return AiMessage::create([
|
||||
'conversation_id' => $conversation->id,
|
||||
'role' => AiMessage::ROLE_ASSISTANT,
|
||||
'content' => "Error: {$e->getMessage()}",
|
||||
'model' => $model,
|
||||
]);
|
||||
}
|
||||
|
||||
// Tool calls requested → execute each, append their results, loop.
|
||||
if ($response->hasToolCalls()) {
|
||||
$this->persistAssistantToolCallTurn($conversation, $response, $model);
|
||||
$messages[] = $this->assistantToolCallMessage($response);
|
||||
|
||||
foreach ($response->toolCalls as $call) {
|
||||
$toolResult = $this->safelyExecuteTool(
|
||||
name: $call['name'],
|
||||
arguments: $call['arguments'] ?? [],
|
||||
companyId: $conversation->company_id,
|
||||
userId: $conversation->user_id,
|
||||
);
|
||||
|
||||
$resultJson = json_encode($toolResult, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
|
||||
AiMessage::create([
|
||||
'conversation_id' => $conversation->id,
|
||||
'role' => AiMessage::ROLE_TOOL,
|
||||
'content' => $resultJson,
|
||||
'tool_call_id' => $call['id'] ?? null,
|
||||
]);
|
||||
|
||||
$messages[] = [
|
||||
'role' => 'tool',
|
||||
'tool_call_id' => $call['id'] ?? '',
|
||||
'content' => $resultJson,
|
||||
];
|
||||
}
|
||||
|
||||
// Loop — the LLM will receive the tool results on the next iteration.
|
||||
continue;
|
||||
}
|
||||
|
||||
// Plain text response — persist and return.
|
||||
return AiMessage::create([
|
||||
'conversation_id' => $conversation->id,
|
||||
'role' => AiMessage::ROLE_ASSISTANT,
|
||||
'content' => $response->message ?? '',
|
||||
'model' => $model,
|
||||
'tokens_in' => $response->usage['tokens_in'] ?? null,
|
||||
'tokens_out' => $response->usage['tokens_out'] ?? null,
|
||||
]);
|
||||
}
|
||||
|
||||
// Exceeded the loop cap.
|
||||
return AiMessage::create([
|
||||
'conversation_id' => $conversation->id,
|
||||
'role' => AiMessage::ROLE_ASSISTANT,
|
||||
'content' => 'The assistant could not complete this request within the tool-call budget. Please try rephrasing.',
|
||||
'model' => $model,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the OpenAI-format messages payload for the next LLM call.
|
||||
*
|
||||
* Starts with a fresh system prompt, then the recent message history
|
||||
* trimmed to HISTORY_WINDOW items, then the message(s) that will drive
|
||||
* the upcoming round-trip (already persisted by chat()).
|
||||
*
|
||||
* @return array<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.
|
||||
*
|
||||
* The template itself lives at resources/ai/prompts/chat-system.md so
|
||||
* it can be edited without touching this class. See PromptLoader for
|
||||
* the substitution rules.
|
||||
*/
|
||||
protected function buildSystemPrompt(AiConversation $conversation): string
|
||||
{
|
||||
$company = Company::find($conversation->company_id);
|
||||
$user = User::find($conversation->user_id);
|
||||
|
||||
return PromptLoader::load('chat-system', [
|
||||
'user_name' => $user?->name ?? 'the user',
|
||||
'company_name' => $company?->name ?? 'this company',
|
||||
'today' => Carbon::now()->toDateString(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist the assistant's tool_calls-turn message (the one that requested tools).
|
||||
*/
|
||||
protected function persistAssistantToolCallTurn(
|
||||
AiConversation $conversation,
|
||||
AiChatResponse $response,
|
||||
string $model,
|
||||
): void {
|
||||
AiMessage::create([
|
||||
'conversation_id' => $conversation->id,
|
||||
'role' => AiMessage::ROLE_ASSISTANT,
|
||||
'content' => $response->message, // usually null on a tool_calls turn
|
||||
'tool_calls' => $response->toolCalls,
|
||||
'model' => $model,
|
||||
'tokens_in' => $response->usage['tokens_in'] ?? null,
|
||||
'tokens_out' => $response->usage['tokens_out'] ?? null,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format the assistant tool-call turn for the NEXT driver call, in OpenAI format.
|
||||
*
|
||||
* @return array<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)).'...';
|
||||
}
|
||||
}
|
||||
@@ -1,354 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Platform\Ai\Application;
|
||||
|
||||
use App\Domains\Accounts\Models\CompanySetting;
|
||||
use App\Platform\Ai\Contracts\AiDriver;
|
||||
use App\Platform\Ai\Drivers\AiDriverFactory;
|
||||
use App\Platform\Operations\Models\Setting;
|
||||
use App\Rules\PublicHttpUrl;
|
||||
use Illuminate\Support\Facades\Crypt;
|
||||
use InvoiceShelf\Modules\Registry;
|
||||
|
||||
/**
|
||||
* Reads, writes, and resolves AI configuration at global and per-company scopes.
|
||||
*
|
||||
* Mirrors MailConfigurationService in shape: a global config lives in the `settings`
|
||||
* table under bare keys; a per-company override lives in `company_settings` under
|
||||
* `company_`-prefixed keys plus a `use_custom_ai_config` toggle. The resolution
|
||||
* order supports three layered kill-switches:
|
||||
*
|
||||
* 1. Global off → no AI for anyone
|
||||
* 2. Per-company off → no AI for this company (even if global is on)
|
||||
* 3. Role off (chat / text_generation) → role-level disable at either scope
|
||||
*
|
||||
* Deviation from mail: AI API keys are **encrypted** at the service layer via
|
||||
* Crypt::encryptString before persistence. OpenRouter bearer tokens have much
|
||||
* bigger blast radius than SMTP passwords; this is worth the pattern break.
|
||||
*/
|
||||
class AiConfigurationService
|
||||
{
|
||||
public const ROLE_CHAT = 'chat';
|
||||
|
||||
public const ROLE_TEXT_GENERATION = 'text_generation';
|
||||
|
||||
public const ROLES = [self::ROLE_CHAT, self::ROLE_TEXT_GENERATION];
|
||||
|
||||
private const GLOBAL_SCOPE = 'global';
|
||||
|
||||
private const COMPANY_SCOPE = 'company';
|
||||
|
||||
/**
|
||||
* Fields stored in the settings table (global scope, bare keys).
|
||||
*
|
||||
* Company-scope keys are these prefixed with `company_`.
|
||||
*/
|
||||
private const FIELDS = [
|
||||
'ai_enabled',
|
||||
'ai_driver',
|
||||
'ai_api_key',
|
||||
'ai_base_url',
|
||||
'ai_chat_enabled',
|
||||
'ai_chat_model',
|
||||
'ai_text_generation_enabled',
|
||||
'ai_text_generation_model',
|
||||
];
|
||||
|
||||
/**
|
||||
* Fields whose stored values are encrypted at rest.
|
||||
*/
|
||||
private const ENCRYPTED_FIELDS = [
|
||||
'ai_api_key',
|
||||
];
|
||||
|
||||
/**
|
||||
* Read the global AI config with decrypted secrets.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function getGlobalConfig(): array
|
||||
{
|
||||
$raw = Setting::getSettings(self::FIELDS)->all();
|
||||
|
||||
return $this->hydrateDefaults($this->decryptFields($raw));
|
||||
}
|
||||
|
||||
/**
|
||||
* Read the per-company AI config with decrypted secrets.
|
||||
*
|
||||
* The response always includes the `use_custom_ai_config` toggle so the
|
||||
* frontend can render the override switch. Driver fields are present
|
||||
* regardless of the toggle — the client decides whether to show them.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function getCompanyConfig(int|string $companyId): array
|
||||
{
|
||||
$companyKeys = array_merge(
|
||||
['use_custom_ai_config'],
|
||||
$this->getCompanySettingKeys(),
|
||||
);
|
||||
|
||||
$raw = CompanySetting::getSettings($companyKeys, $companyId)->all();
|
||||
|
||||
return array_merge(
|
||||
['use_custom_ai_config' => $raw['use_custom_ai_config'] ?? 'NO'],
|
||||
$this->hydrateDefaults($this->decryptFields($this->stripCompanyPrefix($raw))),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist the global AI config, encrypting sensitive fields.
|
||||
*
|
||||
* @param array<string, mixed> $payload
|
||||
*/
|
||||
public function saveGlobalConfig(array $payload): void
|
||||
{
|
||||
Setting::setSettings($this->prepareSettingsForStorage($payload, self::GLOBAL_SCOPE));
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist the per-company AI config.
|
||||
*
|
||||
* When `use_custom_ai_config` is NOT 'YES', only the toggle is written —
|
||||
* driver fields in the payload are discarded. This mirrors the mail pattern
|
||||
* exactly and prevents stale per-company config from lingering after toggle-off.
|
||||
*
|
||||
* @param array<string, mixed> $payload
|
||||
*/
|
||||
public function saveCompanyConfig(int|string $companyId, array $payload): void
|
||||
{
|
||||
if (($payload['use_custom_ai_config'] ?? 'YES') !== 'YES') {
|
||||
CompanySetting::setSettings([
|
||||
'use_custom_ai_config' => 'NO',
|
||||
], $companyId);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$toStore = $this->prepareSettingsForStorage($payload, self::COMPANY_SCOPE);
|
||||
$toStore['use_custom_ai_config'] = 'YES';
|
||||
|
||||
CompanySetting::setSettings($toStore, $companyId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the effective AI config for a company.
|
||||
*
|
||||
* Returns the decrypted config array, or `null` when AI is unavailable.
|
||||
* Resolution order:
|
||||
*
|
||||
* 1. Global `ai_enabled` must be YES. Otherwise AI is off for everyone.
|
||||
* 2. If the company has `use_custom_ai_config = YES`, return the company
|
||||
* config. The company's own `ai_enabled` inside that override controls
|
||||
* whether AI is on for this company (the company can opt out by
|
||||
* setting `use_custom_ai_config = YES` and `ai_enabled = NO`).
|
||||
* 3. Otherwise, return the global config.
|
||||
*
|
||||
* @return array<string, mixed>|null
|
||||
*/
|
||||
public function resolveForCompany(int|string $companyId): ?array
|
||||
{
|
||||
$global = $this->getGlobalConfig();
|
||||
|
||||
// Global kill-switch — applies to all companies
|
||||
if (($global['ai_enabled'] ?? 'NO') !== 'YES') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$company = $this->getCompanyConfig($companyId);
|
||||
|
||||
if (($company['use_custom_ai_config'] ?? 'NO') === 'YES') {
|
||||
// Company-specific config — can opt out via ai_enabled=NO
|
||||
if (($company['ai_enabled'] ?? 'NO') !== 'YES') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->addBooleanFlags($company);
|
||||
}
|
||||
|
||||
return $this->addBooleanFlags($global);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience: resolve config and instantiate a driver for the company.
|
||||
*
|
||||
* Returns `null` when AI is disabled for the company.
|
||||
*/
|
||||
public function makeDriver(int|string $companyId): ?AiDriver
|
||||
{
|
||||
$config = $this->resolveForCompany($companyId);
|
||||
|
||||
if ($config === null || empty($config['ai_api_key']) || empty($config['ai_driver'])) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return AiDriverFactory::make(
|
||||
$config['ai_driver'],
|
||||
$config['ai_api_key'],
|
||||
['base_url' => $config['ai_base_url'] ?? null],
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Build dynamic validation rules for a save request.
|
||||
*
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
public function validationRules(bool $allowDisabledCustomConfig = false): array
|
||||
{
|
||||
$availableDrivers = AiDriverFactory::availableDrivers();
|
||||
|
||||
return [
|
||||
'use_custom_ai_config' => $allowDisabledCustomConfig ? ['nullable', 'in:YES,NO'] : ['prohibited'],
|
||||
'ai_enabled' => ['nullable', 'in:YES,NO'],
|
||||
'ai_driver' => ['required_if:ai_enabled,YES', 'nullable', 'string', 'in:'.implode(',', $availableDrivers)],
|
||||
'ai_api_key' => ['required_if:ai_enabled,YES', 'nullable', 'string'],
|
||||
'ai_base_url' => ['nullable', 'string', 'url', new PublicHttpUrl],
|
||||
'ai_chat_enabled' => ['nullable', 'in:YES,NO'],
|
||||
'ai_chat_model' => ['nullable', 'string', 'max:200'],
|
||||
'ai_text_generation_enabled' => ['nullable', 'in:YES,NO'],
|
||||
'ai_text_generation_model' => ['nullable', 'string', 'max:200'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get driver metadata for the AI type — what the frontend needs to render forms.
|
||||
*
|
||||
* Shape matches the exchange rate driver list so the UI can reuse the same
|
||||
* data-driven rendering pattern.
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function listDrivers(): array
|
||||
{
|
||||
return collect(Registry::allDrivers('ai'))
|
||||
->map(fn (array $meta, string $name) => [
|
||||
'value' => $name,
|
||||
'label' => $meta['label'] ?? $name,
|
||||
'website' => $meta['website'] ?? '',
|
||||
'default_base_url' => $meta['default_base_url'] ?? '',
|
||||
'supported_roles' => $meta['supported_roles'] ?? [],
|
||||
'suggested_models' => $meta['suggested_models'] ?? [],
|
||||
'config_fields' => $meta['config_fields'] ?? [],
|
||||
])
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* Company-scope keys: the FIELDS list with `company_` prefix.
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
protected function getCompanySettingKeys(): array
|
||||
{
|
||||
return array_map(fn (string $field) => 'company_'.$field, self::FIELDS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip the `company_` prefix from keys when reading company-scoped settings.
|
||||
*
|
||||
* @param array<string, mixed> $raw
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
protected function stripCompanyPrefix(array $raw): array
|
||||
{
|
||||
$normalized = [];
|
||||
foreach ($raw as $key => $value) {
|
||||
if ($key === 'use_custom_ai_config') {
|
||||
continue;
|
||||
}
|
||||
if (str_starts_with($key, 'company_')) {
|
||||
$normalized[substr($key, strlen('company_'))] = $value;
|
||||
}
|
||||
}
|
||||
|
||||
return $normalized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt sensitive fields on read.
|
||||
*
|
||||
* @param array<string, mixed> $settings
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
protected function decryptFields(array $settings): array
|
||||
{
|
||||
foreach (self::ENCRYPTED_FIELDS as $field) {
|
||||
if (isset($settings[$field]) && $settings[$field] !== '') {
|
||||
try {
|
||||
$settings[$field] = Crypt::decryptString($settings[$field]);
|
||||
} catch (\Throwable) {
|
||||
// Backward compat: if the value was stored before encryption
|
||||
// was introduced, leave it as-is rather than wiping it.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $settings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fill in defaults for fields missing from storage so consumers get a complete array.
|
||||
*
|
||||
* @param array<string, mixed> $settings
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
protected function hydrateDefaults(array $settings): array
|
||||
{
|
||||
return array_merge([
|
||||
'ai_enabled' => 'NO',
|
||||
'ai_driver' => 'openrouter',
|
||||
'ai_api_key' => '',
|
||||
'ai_base_url' => '',
|
||||
'ai_chat_enabled' => 'NO',
|
||||
'ai_chat_model' => 'anthropic/claude-sonnet-4.6',
|
||||
'ai_text_generation_enabled' => 'NO',
|
||||
'ai_text_generation_model' => 'anthropic/claude-haiku-4.5',
|
||||
], $settings);
|
||||
}
|
||||
|
||||
/**
|
||||
* Add derived boolean flags to a config array so consumers don't have to
|
||||
* compare against the 'YES'/'NO' strings everywhere.
|
||||
*
|
||||
* @param array<string, mixed> $config
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
protected function addBooleanFlags(array $config): array
|
||||
{
|
||||
$config['chat_enabled'] = ($config['ai_chat_enabled'] ?? 'NO') === 'YES';
|
||||
$config['text_generation_enabled'] = ($config['ai_text_generation_enabled'] ?? 'NO') === 'YES';
|
||||
|
||||
return $config;
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepare a payload for storage: strip fields the caller doesn't own, encrypt
|
||||
* sensitive fields, prefix with `company_` for the company scope.
|
||||
*
|
||||
* @param array<string, mixed> $payload
|
||||
* @return array<string, string|null>
|
||||
*/
|
||||
protected function prepareSettingsForStorage(array $payload, string $scope): array
|
||||
{
|
||||
$prepared = [];
|
||||
foreach (self::FIELDS as $field) {
|
||||
if (! array_key_exists($field, $payload)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$value = $payload[$field];
|
||||
|
||||
if (in_array($field, self::ENCRYPTED_FIELDS, true) && is_string($value) && $value !== '') {
|
||||
$value = Crypt::encryptString($value);
|
||||
}
|
||||
|
||||
$storageKey = $scope === self::COMPANY_SCOPE ? 'company_'.$field : $field;
|
||||
$prepared[$storageKey] = $value;
|
||||
}
|
||||
|
||||
return $prepared;
|
||||
}
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Platform\Ai\Application;
|
||||
|
||||
use App\Platform\Ai\Exceptions\AiException;
|
||||
use App\Platform\Ai\Prompting\PromptLoader;
|
||||
|
||||
/**
|
||||
* Stateless one-shot text generation for the WYSIWYG popup.
|
||||
*
|
||||
* Much simpler than the chat assistant — no conversation state, no tool calls,
|
||||
* no history. Takes a user-authored instruction plus optional surrounding
|
||||
* context (e.g. "here's the current editor content") and returns a single
|
||||
* generated text blob the frontend can insert into the editor.
|
||||
*
|
||||
* The text-generation role is distinct from chat in two places:
|
||||
* - AiConfigurationService.text_generation_enabled gates availability
|
||||
* - ai_text_generation_model picks which model to use
|
||||
*
|
||||
* That means an instance can use a cheap fast model for one-shot writing
|
||||
* (anthropic/claude-haiku-4.5) while pointing chat at a smarter model
|
||||
* (anthropic/claude-sonnet-4.6) without config gymnastics.
|
||||
*/
|
||||
class AiTextGenerationService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly AiConfigurationService $aiConfiguration,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Generate text from a user instruction, optionally grounded in a context blob.
|
||||
*
|
||||
* @param int $companyId Current company — resolves config and model selection
|
||||
* @param string $prompt User's instruction (e.g. "write a polite late-payment reminder")
|
||||
* @param string|null $context Optional surrounding content — usually the editor's
|
||||
* current HTML/text, passed when the user wants the AI
|
||||
* to work from existing copy
|
||||
*
|
||||
* @throws AiException When AI is disabled, text generation is off, or the driver call fails
|
||||
*/
|
||||
public function generate(int $companyId, string $prompt, ?string $context = null): string
|
||||
{
|
||||
$driver = $this->aiConfiguration->makeDriver($companyId);
|
||||
|
||||
if ($driver === null) {
|
||||
throw new AiException('AI is not enabled for this company', 'ai_disabled');
|
||||
}
|
||||
|
||||
$resolved = $this->aiConfiguration->resolveForCompany($companyId);
|
||||
if (empty($resolved['text_generation_enabled'])) {
|
||||
throw new AiException('Text generation is not enabled for this company', 'text_generation_disabled');
|
||||
}
|
||||
|
||||
$model = (string) ($resolved['ai_text_generation_model'] ?? '');
|
||||
if ($model === '') {
|
||||
throw new AiException('No text generation model configured', 'missing_model');
|
||||
}
|
||||
|
||||
$fullPrompt = $this->buildPrompt($prompt, $context);
|
||||
|
||||
return trim($driver->textCompletion($fullPrompt, $model));
|
||||
}
|
||||
|
||||
/**
|
||||
* Compose the final prompt sent to the model.
|
||||
*
|
||||
* Keep the framing terse — text-generation output should not be padded
|
||||
* with "Here is the text you requested:" preambles. The instruction is
|
||||
* always placed last so the model gives it the most weight.
|
||||
*
|
||||
* The static preamble lives at resources/ai/prompts/text-generation.md.
|
||||
* The conditional context + instruction appending stays here because it
|
||||
* has different structure depending on whether `context` is present.
|
||||
*/
|
||||
protected function buildPrompt(string $prompt, ?string $context): string
|
||||
{
|
||||
$system = PromptLoader::load('text-generation');
|
||||
|
||||
if ($context !== null && trim($context) !== '') {
|
||||
return $system."\n\n"
|
||||
."Context (current content the user is working with):\n"
|
||||
.trim($context)
|
||||
."\n\n"
|
||||
."Instruction: {$prompt}";
|
||||
}
|
||||
|
||||
return $system."\n\nInstruction: {$prompt}";
|
||||
}
|
||||
}
|
||||
@@ -1,150 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Platform\Ai\Application;
|
||||
|
||||
use App\Domains\Accounts\Models\User;
|
||||
use App\Platform\Ai\Application\Tools\AiTool;
|
||||
use InvalidArgumentException;
|
||||
|
||||
/**
|
||||
* In-memory registry of AiTool instances.
|
||||
*
|
||||
* Register tools from a service provider (see `App\Platform\Ai\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 = [];
|
||||
|
||||
/**
|
||||
* Per-user memo so a single turn doesn't re-query the user for every tool.
|
||||
*
|
||||
* @var array<int, User|null>
|
||||
*/
|
||||
protected array $userCache = [];
|
||||
|
||||
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 the tools the given user is authorized to use as the `tools` array
|
||||
* for an OpenAI-style chat request.
|
||||
*
|
||||
* Tools the user lacks the required ability for are omitted entirely, so the
|
||||
* LLM is never even told they exist. `execute()` re-checks as a backstop.
|
||||
*
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public function schemas(int $userId): array
|
||||
{
|
||||
$authorized = array_filter(
|
||||
$this->tools,
|
||||
fn (AiTool $tool): bool => $this->userCan($tool, $userId),
|
||||
);
|
||||
|
||||
return array_values(array_map(
|
||||
fn (AiTool $tool): array => $tool->toOpenAiToolSchema(),
|
||||
$authorized,
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a tool by name, injecting company + user scope from the caller's session.
|
||||
*
|
||||
* The AiAssistantService is the only place this should be called from — that's
|
||||
* how we guarantee the `$companyId` and `$userId` arguments are session-authoritative
|
||||
* and never influenced by LLM output.
|
||||
*
|
||||
* Authorization backstop: even though `schemas()` already hides tools the user
|
||||
* can't use, we re-check the required ability here so a model that hallucinates
|
||||
* an unauthorized tool name gets a structured error instead of data.
|
||||
*
|
||||
* @param array<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}");
|
||||
}
|
||||
|
||||
if (! $this->userCan($tool, $userId)) {
|
||||
return [
|
||||
'error' => 'unauthorized',
|
||||
'message' => 'You do not have permission to access this data.',
|
||||
];
|
||||
}
|
||||
|
||||
return $tool->execute($arguments, $companyId, $userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the user holds the Bouncer ability a tool requires.
|
||||
*
|
||||
* The ability is evaluated under the ambient Bouncer scope, which the
|
||||
* `company` + `bouncer` (ScopeBouncer) middleware set to the active company
|
||||
* on every AI request — the same way the app's policies check abilities. The
|
||||
* resolved user is always the conversation owner (ChatController binds it to
|
||||
* the request user), so this never trusts an identifier from LLM output.
|
||||
*/
|
||||
protected function userCan(AiTool $tool, int $userId): bool
|
||||
{
|
||||
$required = $tool->requiredAbility();
|
||||
|
||||
if ($required === null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
[$ability, $model] = $required;
|
||||
|
||||
$user = $this->userCache[$userId] ??= User::find($userId);
|
||||
|
||||
if ($user === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return $model === null
|
||||
? $user->can($ability)
|
||||
: $user->can($ability, $model);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test-only: reset the registry between tests that exercise different tool sets.
|
||||
*/
|
||||
public function flush(): void
|
||||
{
|
||||
$this->tools = [];
|
||||
$this->userCache = [];
|
||||
}
|
||||
}
|
||||
@@ -1,129 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Platform\Ai\Application\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.
|
||||
* - `requiredAbility()` — the Bouncer ability the caller must hold for this
|
||||
* tool to be offered to the LLM and executed. Enforces per-user permissions
|
||||
* on top of company scoping, so a restricted role can't read data via the
|
||||
* assistant that it couldn't read through the normal API.
|
||||
*
|
||||
* 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;
|
||||
|
||||
/**
|
||||
* The Bouncer ability a caller must hold to use this tool, as a
|
||||
* `[ability, modelClass]` pair — or null if no ability beyond `use ai`.
|
||||
*
|
||||
* The registry checks this against the session user before exposing the tool
|
||||
* to the LLM and again before executing it, so the assistant honours the same
|
||||
* per-user permissions as the rest of the app. The model element may be null
|
||||
* for gate-style abilities that take no model (e.g. `dashboard`).
|
||||
*
|
||||
* @return array{0: string, 1: class-string|null}|null
|
||||
*/
|
||||
abstract public function requiredAbility(): ?array;
|
||||
|
||||
/**
|
||||
* Convert this tool into an OpenAI-style tools array entry.
|
||||
*
|
||||
* @return array<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;
|
||||
}
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Platform\Ai\Application\Tools\Concerns;
|
||||
|
||||
use Carbon\Carbon;
|
||||
|
||||
/**
|
||||
* Shared period-name → [start, end] resolution for AI tools.
|
||||
*
|
||||
* Several tools (stats, rankings) accept a named time window and need the
|
||||
* same match logic. Rather than duplicate the Carbon juggling in every
|
||||
* tool, they `use ResolvesPeriod` and call `rangeFor()` consistently.
|
||||
*
|
||||
* `all_time` is included in the superset of period names so ranking tools
|
||||
* can offer an unbounded window — in that case `rangeFor()` returns null
|
||||
* and the caller skips any `whereBetween` filter. `GetCompanyStatsTool`
|
||||
* deliberately does NOT expose `all_time` in its own enum (stats over all
|
||||
* time drops every record into one giant bucket and is rarely useful).
|
||||
*/
|
||||
trait ResolvesPeriod
|
||||
{
|
||||
/** Superset of period names supported by the trait. */
|
||||
protected const ALL_PERIODS = [
|
||||
'all_time',
|
||||
'today',
|
||||
'this_week',
|
||||
'this_month',
|
||||
'last_month',
|
||||
'this_quarter',
|
||||
'this_year',
|
||||
'last_year',
|
||||
];
|
||||
|
||||
/**
|
||||
* Resolve a named period to a start/end Carbon pair.
|
||||
*
|
||||
* Returns null for `all_time` (meaning: "no date filter — use every
|
||||
* record regardless of date"). Callers are expected to branch on the
|
||||
* null and skip any `whereBetween` clause.
|
||||
*
|
||||
* @return array{0: Carbon, 1: Carbon}|null
|
||||
*/
|
||||
protected function rangeFor(string $period): ?array
|
||||
{
|
||||
$now = Carbon::now();
|
||||
|
||||
return match ($period) {
|
||||
'all_time' => null,
|
||||
'today' => [$now->copy()->startOfDay(), $now->copy()->endOfDay()],
|
||||
'this_week' => [$now->copy()->startOfWeek(), $now->copy()->endOfWeek()],
|
||||
'this_month' => [$now->copy()->startOfMonth(), $now->copy()->endOfMonth()],
|
||||
'last_month' => [
|
||||
$now->copy()->subMonthNoOverflow()->startOfMonth(),
|
||||
$now->copy()->subMonthNoOverflow()->endOfMonth(),
|
||||
],
|
||||
'this_quarter' => [$now->copy()->startOfQuarter(), $now->copy()->endOfQuarter()],
|
||||
'this_year' => [$now->copy()->startOfYear(), $now->copy()->endOfYear()],
|
||||
'last_year' => [
|
||||
$now->copy()->subYearNoOverflow()->startOfYear(),
|
||||
$now->copy()->subYearNoOverflow()->endOfYear(),
|
||||
],
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,120 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Platform\Ai\Application\Tools;
|
||||
|
||||
use App\Domains\Purchases\Models\Expense;
|
||||
use App\Domains\Receivables\Models\Payment;
|
||||
use App\Domains\Sales\Models\Invoice;
|
||||
use App\Platform\Ai\Application\Tools\Concerns\ResolvesPeriod;
|
||||
|
||||
/**
|
||||
* 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
|
||||
{
|
||||
use ResolvesPeriod;
|
||||
|
||||
/**
|
||||
* Bounded periods only — stats over `all_time` is almost always useless
|
||||
* (collapses every record into one giant bucket), so we don't offer it
|
||||
* here. The ranking tools do expose `all_time` because ranking by totals
|
||||
* across the full history is a meaningful question.
|
||||
*/
|
||||
private const PERIODS = [
|
||||
'today',
|
||||
'this_week',
|
||||
'this_month',
|
||||
'last_month',
|
||||
'this_quarter',
|
||||
'this_year',
|
||||
'last_year',
|
||||
];
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return 'get_company_stats';
|
||||
}
|
||||
|
||||
public function description(): string
|
||||
{
|
||||
return "Aggregate stats for the current company over a named time period: invoice count and total, payment count and total, expense count and total. Use this for 'how much did we earn/spend' questions.";
|
||||
}
|
||||
|
||||
public function parameterSchema(): array
|
||||
{
|
||||
return [
|
||||
'type' => 'object',
|
||||
'properties' => [
|
||||
'period' => [
|
||||
'type' => 'string',
|
||||
'enum' => self::PERIODS,
|
||||
'description' => 'Named time window.',
|
||||
],
|
||||
],
|
||||
'required' => ['period'],
|
||||
];
|
||||
}
|
||||
|
||||
public function requiredAbility(): ?array
|
||||
{
|
||||
// Cross-entity financial snapshot — gated like the company dashboard.
|
||||
return ['dashboard', null];
|
||||
}
|
||||
|
||||
public function execute(array $arguments, int $companyId, int $userId): mixed
|
||||
{
|
||||
$period = (string) ($arguments['period'] ?? 'this_month');
|
||||
if (! in_array($period, self::PERIODS, true)) {
|
||||
return ['error' => 'invalid_period', 'valid' => self::PERIODS];
|
||||
}
|
||||
|
||||
// Stats are always date-scoped (the enum above excludes `all_time`),
|
||||
// so rangeFor() is guaranteed to return a non-null pair here.
|
||||
[$start, $end] = $this->rangeFor($period);
|
||||
|
||||
// Counts issued invoices only; the total below keeps the credit notes,
|
||||
// whose negated amounts are what net the sales figure back out.
|
||||
$invoiceCount = Invoice::query()
|
||||
->where('company_id', $companyId)
|
||||
->where('type', Invoice::TYPE_INVOICE)
|
||||
->whereBetween('invoice_date', [$start, $end])
|
||||
->count();
|
||||
|
||||
$invoiceTotal = (float) Invoice::query()
|
||||
->where('company_id', $companyId)
|
||||
->whereBetween('invoice_date', [$start, $end])
|
||||
->sum('total');
|
||||
|
||||
$paymentCount = Payment::query()
|
||||
->where('company_id', $companyId)
|
||||
->whereBetween('payment_date', [$start, $end])
|
||||
->count();
|
||||
|
||||
$paymentTotal = (float) Payment::query()
|
||||
->where('company_id', $companyId)
|
||||
->whereBetween('payment_date', [$start, $end])
|
||||
->sum('amount');
|
||||
|
||||
$expenseCount = Expense::query()
|
||||
->where('company_id', $companyId)
|
||||
->whereBetween('expense_date', [$start, $end])
|
||||
->count();
|
||||
|
||||
$expenseTotal = (float) Expense::query()
|
||||
->where('company_id', $companyId)
|
||||
->whereBetween('expense_date', [$start, $end])
|
||||
->sum('amount');
|
||||
|
||||
return [
|
||||
'period' => $period,
|
||||
'start' => $start->toDateString(),
|
||||
'end' => $end->toDateString(),
|
||||
'invoices' => ['count' => $invoiceCount, 'total' => $invoiceTotal],
|
||||
'payments' => ['count' => $paymentCount, 'total' => $paymentTotal],
|
||||
'expenses' => ['count' => $expenseCount, 'total' => $expenseTotal],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Platform\Ai\Application\Tools;
|
||||
|
||||
use App\Domains\Contacts\Models\Customer;
|
||||
use App\Domains\Sales\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 requiredAbility(): ?array
|
||||
{
|
||||
return ['view-customer', Customer::class];
|
||||
}
|
||||
|
||||
public function execute(array $arguments, int $companyId, int $userId): mixed
|
||||
{
|
||||
$customer = Customer::query()
|
||||
->where('company_id', $companyId)
|
||||
->where('id', (int) ($arguments['customer_id'] ?? 0))
|
||||
->with(['billingAddress', 'shippingAddress'])
|
||||
->first();
|
||||
|
||||
if (! $customer) {
|
||||
return ['error' => 'customer_not_found'];
|
||||
}
|
||||
|
||||
// Aggregate totals — done with lightweight queries rather than loading every invoice.
|
||||
// Issued invoices only: a credit note reverses one, it is not another.
|
||||
$invoiceCount = Invoice::query()
|
||||
->where('company_id', $companyId)
|
||||
->where('customer_id', $customer->id)
|
||||
->where('type', Invoice::TYPE_INVOICE)
|
||||
->count();
|
||||
|
||||
$outstanding = (float) Invoice::query()
|
||||
->where('company_id', $companyId)
|
||||
->where('customer_id', $customer->id)
|
||||
->whereIn('paid_status', ['UNPAID', 'PARTIALLY_PAID'])
|
||||
->sum('due_amount');
|
||||
|
||||
return [
|
||||
'customer' => [
|
||||
'id' => $customer->id,
|
||||
'name' => $customer->name,
|
||||
'display_name' => $customer->display_name,
|
||||
'email' => $customer->email,
|
||||
'phone' => $customer->phone,
|
||||
'contact_name' => $customer->contact_name,
|
||||
'company_name' => $customer->company_name,
|
||||
'website' => $customer->website,
|
||||
'enable_portal' => (bool) $customer->enable_portal,
|
||||
'billing_address' => $customer->billingAddress,
|
||||
'shipping_address' => $customer->shippingAddress,
|
||||
'totals' => [
|
||||
'invoice_count' => $invoiceCount,
|
||||
'outstanding_amount' => $outstanding,
|
||||
],
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Platform\Ai\Application\Tools;
|
||||
|
||||
use App\Domains\Sales\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 requiredAbility(): ?array
|
||||
{
|
||||
return ['view-invoice', Invoice::class];
|
||||
}
|
||||
|
||||
public function execute(array $arguments, int $companyId, int $userId): mixed
|
||||
{
|
||||
$invoice = Invoice::query()
|
||||
->where('company_id', $companyId)
|
||||
->where('invoice_number', (string) ($arguments['invoice_number'] ?? ''))
|
||||
->with(['customer:id,name,email,phone', 'items', 'taxes'])
|
||||
->first();
|
||||
|
||||
if (! $invoice) {
|
||||
return ['error' => 'invoice_not_found'];
|
||||
}
|
||||
|
||||
return [
|
||||
'invoice' => [
|
||||
'id' => $invoice->id,
|
||||
'invoice_number' => $invoice->invoice_number,
|
||||
'reference_number' => $invoice->reference_number,
|
||||
'status' => $invoice->status,
|
||||
'paid_status' => $invoice->paid_status,
|
||||
'invoice_date' => $this->asDate($invoice->invoice_date),
|
||||
'due_date' => $this->asDate($invoice->due_date),
|
||||
'sub_total' => $invoice->sub_total,
|
||||
'tax' => $invoice->tax,
|
||||
'discount' => $invoice->discount,
|
||||
'total' => $invoice->total,
|
||||
'due_amount' => $invoice->due_amount,
|
||||
'overdue' => (bool) $invoice->overdue,
|
||||
'notes' => $invoice->notes,
|
||||
'customer' => $invoice->customer ? [
|
||||
'id' => $invoice->customer->id,
|
||||
'name' => $invoice->customer->name,
|
||||
'email' => $invoice->customer->email,
|
||||
'phone' => $invoice->customer->phone,
|
||||
] : null,
|
||||
'items' => $invoice->items->map(fn ($item): array => [
|
||||
'name' => $item->name,
|
||||
'description' => $item->description,
|
||||
'quantity' => $item->quantity,
|
||||
'price' => $item->price,
|
||||
'total' => $item->total,
|
||||
])->all(),
|
||||
'taxes' => $invoice->taxes->map(fn ($tax): array => [
|
||||
'name' => $tax->name,
|
||||
'percent' => $tax->percent,
|
||||
'amount' => $tax->amount,
|
||||
])->all(),
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Platform\Ai\Application\Tools;
|
||||
|
||||
use App\Domains\Purchases\Models\Expense;
|
||||
use App\Domains\Purchases\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 requiredAbility(): ?array
|
||||
{
|
||||
// Expense categories are gated by the expense ability (see ExpenseCategoryPolicy).
|
||||
return ['view-expense', Expense::class];
|
||||
}
|
||||
|
||||
public function execute(array $arguments, int $companyId, int $userId): mixed
|
||||
{
|
||||
$categories = ExpenseCategory::query()
|
||||
->where('company_id', $companyId)
|
||||
->orderBy('name')
|
||||
->get(['id', 'name', 'description']);
|
||||
|
||||
return [
|
||||
'categories' => $categories->map(fn ($c): array => [
|
||||
'id' => $c->id,
|
||||
'name' => $c->name,
|
||||
'description' => $c->description,
|
||||
])->all(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Platform\Ai\Application\Tools;
|
||||
|
||||
use App\Domains\Sales\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 requiredAbility(): ?array
|
||||
{
|
||||
return ['view-invoice', Invoice::class];
|
||||
}
|
||||
|
||||
public function execute(array $arguments, int $companyId, int $userId): mixed
|
||||
{
|
||||
$invoices = Invoice::query()
|
||||
->where('company_id', $companyId)
|
||||
->where('overdue', true)
|
||||
->with('customer:id,name')
|
||||
->orderBy('due_date')
|
||||
->limit(100)
|
||||
->get();
|
||||
|
||||
$totalOutstanding = (float) $invoices->sum('due_amount');
|
||||
|
||||
return [
|
||||
'count' => $invoices->count(),
|
||||
'total_outstanding' => $totalOutstanding,
|
||||
'invoices' => $invoices->map(fn (Invoice $inv): array => [
|
||||
'id' => $inv->id,
|
||||
'invoice_number' => $inv->invoice_number,
|
||||
'customer_id' => $inv->customer_id,
|
||||
'customer_name' => $inv->customer?->name,
|
||||
'due_date' => $this->asDate($inv->due_date),
|
||||
'due_amount' => $inv->due_amount,
|
||||
'total' => $inv->total,
|
||||
])->all(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Platform\Ai\Application\Tools;
|
||||
|
||||
use App\Domains\Receivables\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, allocation breakdown, unapplied credit, 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 requiredAbility(): ?array
|
||||
{
|
||||
return ['view-payment', Payment::class];
|
||||
}
|
||||
|
||||
public function execute(array $arguments, int $companyId, int $userId): mixed
|
||||
{
|
||||
$days = min((int) ($arguments['days'] ?? self::DEFAULT_DAYS), self::MAX_DAYS);
|
||||
$limit = min((int) ($arguments['limit'] ?? self::DEFAULT_LIMIT), self::MAX_LIMIT);
|
||||
|
||||
$since = Carbon::now()->subDays($days)->startOfDay();
|
||||
|
||||
$payments = Payment::query()
|
||||
->where('company_id', $companyId)
|
||||
->where('payment_date', '>=', $since)
|
||||
->with(['allocations:id,payment_id,invoice_id,amount', 'customer:id,name', 'paymentMethod:id,name'])
|
||||
->latest('payment_date')
|
||||
->limit($limit)
|
||||
->get();
|
||||
|
||||
return [
|
||||
'since' => $since->toDateString(),
|
||||
'payments' => $payments->map(fn (Payment $p): array => [
|
||||
'id' => $p->id,
|
||||
'payment_number' => $p->payment_number,
|
||||
'payment_date' => $this->asDate($p->payment_date),
|
||||
'amount' => $p->amount,
|
||||
'customer_id' => $p->customer_id,
|
||||
'customer_name' => $p->customer?->name,
|
||||
'allocations' => $p->allocations->map(fn ($allocation) => [
|
||||
'invoice_id' => $allocation->invoice_id,
|
||||
'amount' => $allocation->amount,
|
||||
])->all(),
|
||||
'allocated_amount' => (int) $p->allocations->sum('amount'),
|
||||
'unallocated_amount' => (int) $p->amount - (int) $p->allocations->sum('amount'),
|
||||
'payment_method' => $p->paymentMethod?->name,
|
||||
])->all(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,112 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Platform\Ai\Application\Tools;
|
||||
|
||||
use App\Domains\Purchases\Models\Expense;
|
||||
use App\Domains\Purchases\Models\ExpenseCategory;
|
||||
use App\Platform\Ai\Application\Tools\Concerns\ResolvesPeriod;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Rank expense categories by total spend over a named time period.
|
||||
*
|
||||
* Answers "what are we spending the most on" / "top expense categories"
|
||||
* questions. There's only one sensible aggregation (sum of amount per
|
||||
* category), so no `metric` parameter — just period + limit.
|
||||
*/
|
||||
class RankExpenseCategoriesTool extends AiTool
|
||||
{
|
||||
use ResolvesPeriod;
|
||||
|
||||
private const DEFAULT_LIMIT = 10;
|
||||
|
||||
private const MAX_LIMIT = 20;
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return 'rank_expense_categories';
|
||||
}
|
||||
|
||||
public function description(): string
|
||||
{
|
||||
return "Rank expense categories by total spend over a named time period. Use this when the user asks 'what are we spending the most on', 'top expense categories', or 'where is the money going'.";
|
||||
}
|
||||
|
||||
public function parameterSchema(): array
|
||||
{
|
||||
return [
|
||||
'type' => 'object',
|
||||
'properties' => [
|
||||
'period' => [
|
||||
'type' => 'string',
|
||||
'enum' => self::ALL_PERIODS,
|
||||
'description' => 'Named time window. Use all_time for lifetime totals.',
|
||||
],
|
||||
'limit' => [
|
||||
'type' => 'integer',
|
||||
'minimum' => 1,
|
||||
'maximum' => self::MAX_LIMIT,
|
||||
'description' => 'Max number of categories to return. Default 10.',
|
||||
],
|
||||
],
|
||||
'required' => [],
|
||||
];
|
||||
}
|
||||
|
||||
public function requiredAbility(): ?array
|
||||
{
|
||||
return ['view-expense', Expense::class];
|
||||
}
|
||||
|
||||
public function execute(array $arguments, int $companyId, int $userId): mixed
|
||||
{
|
||||
$period = (string) ($arguments['period'] ?? 'all_time');
|
||||
if (! in_array($period, self::ALL_PERIODS, true)) {
|
||||
return ['error' => 'invalid_period', 'valid' => self::ALL_PERIODS];
|
||||
}
|
||||
|
||||
$limit = min(max((int) ($arguments['limit'] ?? self::DEFAULT_LIMIT), 1), self::MAX_LIMIT);
|
||||
$range = $this->rangeFor($period);
|
||||
|
||||
$query = Expense::query()
|
||||
->where('company_id', $companyId)
|
||||
->whereNotNull('expense_category_id')
|
||||
->select([
|
||||
'expense_category_id',
|
||||
DB::raw('SUM(amount) as total_amount'),
|
||||
DB::raw('COUNT(*) as expense_count'),
|
||||
])
|
||||
->groupBy('expense_category_id')
|
||||
->orderByDesc('total_amount')
|
||||
->limit($limit);
|
||||
|
||||
if ($range !== null) {
|
||||
$query->whereBetween('expense_date', [$range[0], $range[1]]);
|
||||
}
|
||||
|
||||
$rows = $query->get()->all();
|
||||
|
||||
// Batch-load category names in one query.
|
||||
$categoryIds = array_map(static fn ($row) => (int) $row->expense_category_id, $rows);
|
||||
$categories = ExpenseCategory::query()
|
||||
->whereIn('id', $categoryIds)
|
||||
->get()
|
||||
->keyBy('id');
|
||||
|
||||
$ranked = array_map(function ($row) use ($categories): array {
|
||||
$category = $categories->get((int) $row->expense_category_id);
|
||||
|
||||
return [
|
||||
'expense_category_id' => (int) $row->expense_category_id,
|
||||
'name' => $category?->name,
|
||||
'total_amount' => (float) $row->total_amount,
|
||||
'expense_count' => (int) $row->expense_count,
|
||||
];
|
||||
}, $rows);
|
||||
|
||||
return [
|
||||
'period' => $period,
|
||||
'categories' => $ranked,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,234 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Platform\Ai\Application\Tools;
|
||||
|
||||
use App\Domains\Contacts\Models\Customer;
|
||||
use App\Domains\Receivables\Models\Payment;
|
||||
use App\Domains\Sales\Models\Invoice;
|
||||
use App\Platform\Ai\Application\Tools\Concerns\ResolvesPeriod;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Rank customers by a business metric (invoiced total, paid total,
|
||||
* invoice count, or outstanding balance) over a named time period.
|
||||
*
|
||||
* Answers questions the rest of the tool set can't touch: "who did the
|
||||
* most business with us", "top 5 customers by revenue this year", "who
|
||||
* owes us the most right now", "which customers sent us the most
|
||||
* invoices last quarter". All four metrics are groupBy aggregates on
|
||||
* either the invoices or payments table, scoped to the session's
|
||||
* company.
|
||||
*/
|
||||
class RankTopCustomersTool extends AiTool
|
||||
{
|
||||
use ResolvesPeriod;
|
||||
|
||||
private const METRICS = [
|
||||
'invoiced_total',
|
||||
'paid_total',
|
||||
'invoice_count',
|
||||
'outstanding_balance',
|
||||
];
|
||||
|
||||
private const DEFAULT_LIMIT = 5;
|
||||
|
||||
private const MAX_LIMIT = 20;
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return 'rank_top_customers';
|
||||
}
|
||||
|
||||
public function description(): string
|
||||
{
|
||||
return "Rank customers by a business metric (invoiced_total, paid_total, invoice_count, or outstanding_balance). Use this when the user asks 'who are our top customers', 'who did the most business with us', 'who owes us the most', or similar ranking questions. outstanding_balance ignores the period — it's always the current snapshot.";
|
||||
}
|
||||
|
||||
public function parameterSchema(): array
|
||||
{
|
||||
return [
|
||||
'type' => 'object',
|
||||
'properties' => [
|
||||
'metric' => [
|
||||
'type' => 'string',
|
||||
'enum' => self::METRICS,
|
||||
'description' => 'Which metric to rank by.',
|
||||
],
|
||||
'period' => [
|
||||
'type' => 'string',
|
||||
'enum' => self::ALL_PERIODS,
|
||||
'description' => 'Named time window. Use all_time for lifetime rankings. Ignored for outstanding_balance (always current).',
|
||||
],
|
||||
'limit' => [
|
||||
'type' => 'integer',
|
||||
'minimum' => 1,
|
||||
'maximum' => self::MAX_LIMIT,
|
||||
'description' => 'Max number of customers to return. Default 5.',
|
||||
],
|
||||
],
|
||||
'required' => ['metric'],
|
||||
];
|
||||
}
|
||||
|
||||
public function requiredAbility(): ?array
|
||||
{
|
||||
return ['view-customer', Customer::class];
|
||||
}
|
||||
|
||||
public function execute(array $arguments, int $companyId, int $userId): mixed
|
||||
{
|
||||
$metric = (string) ($arguments['metric'] ?? 'invoiced_total');
|
||||
if (! in_array($metric, self::METRICS, true)) {
|
||||
return ['error' => 'invalid_metric', 'valid' => self::METRICS];
|
||||
}
|
||||
|
||||
$period = (string) ($arguments['period'] ?? 'all_time');
|
||||
if (! in_array($period, self::ALL_PERIODS, true)) {
|
||||
return ['error' => 'invalid_period', 'valid' => self::ALL_PERIODS];
|
||||
}
|
||||
|
||||
$limit = min(max((int) ($arguments['limit'] ?? self::DEFAULT_LIMIT), 1), self::MAX_LIMIT);
|
||||
|
||||
// outstanding_balance is a current-state snapshot; period is meaningless.
|
||||
$range = $metric === 'outstanding_balance' ? null : $this->rangeFor($period);
|
||||
|
||||
$rows = match ($metric) {
|
||||
'invoiced_total' => $this->rankByInvoiceSum($companyId, $range, $limit, 'total'),
|
||||
'paid_total' => $this->rankByPaymentSum($companyId, $range, $limit),
|
||||
'invoice_count' => $this->rankByInvoiceCount($companyId, $range, $limit),
|
||||
'outstanding_balance' => $this->rankByOutstandingBalance($companyId, $limit),
|
||||
};
|
||||
|
||||
// Batch-load the customers we're about to return so we can decorate
|
||||
// each ranking row with name fields. One query regardless of $limit.
|
||||
$customerIds = array_map(static fn ($row) => (int) $row->customer_id, $rows);
|
||||
$customers = Customer::query()
|
||||
->whereIn('id', $customerIds)
|
||||
->get()
|
||||
->keyBy('id');
|
||||
|
||||
$ranked = array_map(function ($row) use ($customers, $metric): array {
|
||||
$customer = $customers->get((int) $row->customer_id);
|
||||
|
||||
return [
|
||||
'customer_id' => (int) $row->customer_id,
|
||||
'name' => $customer?->name,
|
||||
'display_name' => $customer?->display_name,
|
||||
'company_name' => $customer?->company_name,
|
||||
'metric_value' => $metric === 'invoice_count'
|
||||
? (int) $row->metric_value
|
||||
: (float) $row->metric_value,
|
||||
'invoice_count' => isset($row->invoice_count) ? (int) $row->invoice_count : null,
|
||||
];
|
||||
}, $rows);
|
||||
|
||||
return [
|
||||
'metric' => $metric,
|
||||
'period' => $metric === 'outstanding_balance' ? 'current' : $period,
|
||||
'customers' => $ranked,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{0: Carbon, 1: Carbon}|null $range
|
||||
* @return array<int, object>
|
||||
*/
|
||||
private function rankByInvoiceSum(int $companyId, ?array $range, int $limit, string $sumColumn): array
|
||||
{
|
||||
$query = Invoice::query()
|
||||
->where('company_id', $companyId)
|
||||
->whereNotNull('customer_id')
|
||||
->select([
|
||||
'customer_id',
|
||||
DB::raw("SUM({$sumColumn}) as metric_value"),
|
||||
DB::raw('COUNT(*) as invoice_count'),
|
||||
])
|
||||
->groupBy('customer_id')
|
||||
->orderByDesc('metric_value')
|
||||
->limit($limit);
|
||||
|
||||
if ($range !== null) {
|
||||
$query->whereBetween('invoice_date', [$range[0], $range[1]]);
|
||||
}
|
||||
|
||||
return $query->get()->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{0: Carbon, 1: Carbon}|null $range
|
||||
* @return array<int, object>
|
||||
*/
|
||||
private function rankByPaymentSum(int $companyId, ?array $range, int $limit): array
|
||||
{
|
||||
$query = Payment::query()
|
||||
->where('company_id', $companyId)
|
||||
->whereNotNull('customer_id')
|
||||
->select([
|
||||
'customer_id',
|
||||
DB::raw('SUM(amount) as metric_value'),
|
||||
DB::raw('COUNT(*) as invoice_count'),
|
||||
])
|
||||
->groupBy('customer_id')
|
||||
->orderByDesc('metric_value')
|
||||
->limit($limit);
|
||||
|
||||
if ($range !== null) {
|
||||
$query->whereBetween('payment_date', [$range[0], $range[1]]);
|
||||
}
|
||||
|
||||
// Note: `invoice_count` here is actually the payment count for this
|
||||
// customer within the window — semantically confusing, so drop it.
|
||||
return array_map(function ($row) {
|
||||
unset($row->invoice_count);
|
||||
|
||||
return $row;
|
||||
}, $query->get()->all());
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{0: Carbon, 1: Carbon}|null $range
|
||||
* @return array<int, object>
|
||||
*/
|
||||
private function rankByInvoiceCount(int $companyId, ?array $range, int $limit): array
|
||||
{
|
||||
$query = Invoice::query()
|
||||
->where('company_id', $companyId)
|
||||
->whereNotNull('customer_id')
|
||||
->select([
|
||||
'customer_id',
|
||||
DB::raw('COUNT(*) as metric_value'),
|
||||
DB::raw('COUNT(*) as invoice_count'),
|
||||
])
|
||||
->groupBy('customer_id')
|
||||
->orderByDesc('metric_value')
|
||||
->limit($limit);
|
||||
|
||||
if ($range !== null) {
|
||||
$query->whereBetween('invoice_date', [$range[0], $range[1]]);
|
||||
}
|
||||
|
||||
return $query->get()->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, object>
|
||||
*/
|
||||
private function rankByOutstandingBalance(int $companyId, int $limit): array
|
||||
{
|
||||
return Invoice::query()
|
||||
->where('company_id', $companyId)
|
||||
->whereNotNull('customer_id')
|
||||
->whereIn('paid_status', ['UNPAID', 'PARTIALLY_PAID'])
|
||||
->select([
|
||||
'customer_id',
|
||||
DB::raw('SUM(due_amount) as metric_value'),
|
||||
DB::raw('COUNT(*) as invoice_count'),
|
||||
])
|
||||
->groupBy('customer_id')
|
||||
->orderByDesc('metric_value')
|
||||
->limit($limit)
|
||||
->get()
|
||||
->all();
|
||||
}
|
||||
}
|
||||
@@ -1,128 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Platform\Ai\Application\Tools;
|
||||
|
||||
use App\Domains\Catalog\Models\Item;
|
||||
use App\Domains\Sales\Models\InvoiceItem;
|
||||
use App\Platform\Ai\Application\Tools\Concerns\ResolvesPeriod;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Rank catalog items by quantity sold or revenue over a named time period.
|
||||
*
|
||||
* Joins invoice_items → invoices for company scoping + date filtering.
|
||||
* Ad-hoc line items (where `item_id` is null — the user typed a name
|
||||
* directly without picking from the catalog) are excluded, because
|
||||
* ranking them by id isn't meaningful.
|
||||
*/
|
||||
class RankTopItemsTool extends AiTool
|
||||
{
|
||||
use ResolvesPeriod;
|
||||
|
||||
private const METRICS = ['quantity_sold', 'revenue'];
|
||||
|
||||
private const DEFAULT_LIMIT = 5;
|
||||
|
||||
private const MAX_LIMIT = 20;
|
||||
|
||||
public function name(): string
|
||||
{
|
||||
return 'rank_top_items';
|
||||
}
|
||||
|
||||
public function description(): string
|
||||
{
|
||||
return "Rank catalog items by quantity_sold or revenue over a named time period. Use this when the user asks 'what's our best-selling item', 'most popular products', 'which items brought in the most money', or similar.";
|
||||
}
|
||||
|
||||
public function parameterSchema(): array
|
||||
{
|
||||
return [
|
||||
'type' => 'object',
|
||||
'properties' => [
|
||||
'metric' => [
|
||||
'type' => 'string',
|
||||
'enum' => self::METRICS,
|
||||
'description' => 'Which dimension to rank by.',
|
||||
],
|
||||
'period' => [
|
||||
'type' => 'string',
|
||||
'enum' => self::ALL_PERIODS,
|
||||
'description' => 'Named time window. Use all_time for lifetime rankings.',
|
||||
],
|
||||
'limit' => [
|
||||
'type' => 'integer',
|
||||
'minimum' => 1,
|
||||
'maximum' => self::MAX_LIMIT,
|
||||
'description' => 'Max number of items to return. Default 5.',
|
||||
],
|
||||
],
|
||||
'required' => ['metric'],
|
||||
];
|
||||
}
|
||||
|
||||
public function requiredAbility(): ?array
|
||||
{
|
||||
return ['view-item', Item::class];
|
||||
}
|
||||
|
||||
public function execute(array $arguments, int $companyId, int $userId): mixed
|
||||
{
|
||||
$metric = (string) ($arguments['metric'] ?? 'revenue');
|
||||
if (! in_array($metric, self::METRICS, true)) {
|
||||
return ['error' => 'invalid_metric', 'valid' => self::METRICS];
|
||||
}
|
||||
|
||||
$period = (string) ($arguments['period'] ?? 'all_time');
|
||||
if (! in_array($period, self::ALL_PERIODS, true)) {
|
||||
return ['error' => 'invalid_period', 'valid' => self::ALL_PERIODS];
|
||||
}
|
||||
|
||||
$limit = min(max((int) ($arguments['limit'] ?? self::DEFAULT_LIMIT), 1), self::MAX_LIMIT);
|
||||
$range = $this->rangeFor($period);
|
||||
$orderColumn = $metric === 'revenue' ? 'total_revenue' : 'total_quantity';
|
||||
|
||||
$query = InvoiceItem::query()
|
||||
->join('invoices', 'invoice_items.invoice_id', '=', 'invoices.id')
|
||||
->where('invoices.company_id', $companyId)
|
||||
->whereNotNull('invoice_items.item_id')
|
||||
->select([
|
||||
'invoice_items.item_id',
|
||||
DB::raw('SUM(invoice_items.quantity) as total_quantity'),
|
||||
DB::raw('SUM(invoice_items.total) as total_revenue'),
|
||||
])
|
||||
->groupBy('invoice_items.item_id')
|
||||
->orderByDesc($orderColumn)
|
||||
->limit($limit);
|
||||
|
||||
if ($range !== null) {
|
||||
$query->whereBetween('invoices.invoice_date', [$range[0], $range[1]]);
|
||||
}
|
||||
|
||||
$rows = $query->get()->all();
|
||||
|
||||
// Batch-load item names in one query.
|
||||
$itemIds = array_map(static fn ($row) => (int) $row->item_id, $rows);
|
||||
$items = Item::query()
|
||||
->whereIn('id', $itemIds)
|
||||
->get()
|
||||
->keyBy('id');
|
||||
|
||||
$ranked = array_map(function ($row) use ($items): array {
|
||||
$item = $items->get((int) $row->item_id);
|
||||
|
||||
return [
|
||||
'item_id' => (int) $row->item_id,
|
||||
'name' => $item?->name,
|
||||
'quantity_sold' => (float) $row->total_quantity,
|
||||
'revenue' => (float) $row->total_revenue,
|
||||
];
|
||||
}, $rows);
|
||||
|
||||
return [
|
||||
'metric' => $metric,
|
||||
'period' => $period,
|
||||
'items' => $ranked,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Platform\Ai\Application\Tools;
|
||||
|
||||
use App\Domains\Contacts\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 requiredAbility(): ?array
|
||||
{
|
||||
return ['view-customer', Customer::class];
|
||||
}
|
||||
|
||||
public function execute(array $arguments, int $companyId, int $userId): mixed
|
||||
{
|
||||
$limit = min((int) ($arguments['limit'] ?? self::DEFAULT_LIMIT), self::MAX_LIMIT);
|
||||
|
||||
$query = Customer::query()
|
||||
->where('company_id', $companyId)
|
||||
->orderBy('name')
|
||||
->limit($limit);
|
||||
|
||||
if (! empty($arguments['query'])) {
|
||||
$q = $arguments['query'];
|
||||
$query->where(function ($qb) use ($q) {
|
||||
$qb->where('name', 'like', "%{$q}%")
|
||||
->orWhere('display_name', 'like', "%{$q}%")
|
||||
->orWhere('email', 'like', "%{$q}%")
|
||||
->orWhere('company_name', 'like', "%{$q}%")
|
||||
->orWhere('contact_name', 'like', "%{$q}%");
|
||||
});
|
||||
}
|
||||
|
||||
return [
|
||||
'customers' => $query->get()->map(fn (Customer $c): array => [
|
||||
'id' => $c->id,
|
||||
'name' => $c->name,
|
||||
'display_name' => $c->display_name,
|
||||
'email' => $c->email,
|
||||
'phone' => $c->phone,
|
||||
'company_name' => $c->company_name,
|
||||
])->all(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,113 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Platform\Ai\Application\Tools;
|
||||
|
||||
use App\Domains\Sales\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 requiredAbility(): ?array
|
||||
{
|
||||
return ['view-invoice', Invoice::class];
|
||||
}
|
||||
|
||||
public function execute(array $arguments, int $companyId, int $userId): mixed
|
||||
{
|
||||
$limit = min((int) ($arguments['limit'] ?? self::DEFAULT_LIMIT), self::MAX_LIMIT);
|
||||
|
||||
$query = Invoice::query()
|
||||
->where('company_id', $companyId)
|
||||
->with('customer:id,name')
|
||||
->latest('invoice_date')
|
||||
->limit($limit);
|
||||
|
||||
if (! empty($arguments['query'])) {
|
||||
$q = $arguments['query'];
|
||||
$query->where(function ($qb) use ($q) {
|
||||
$qb->where('invoice_number', 'like', "%{$q}%")
|
||||
->orWhere('reference_number', 'like', "%{$q}%");
|
||||
});
|
||||
}
|
||||
|
||||
if (! empty($arguments['status'])) {
|
||||
$status = strtoupper((string) $arguments['status']);
|
||||
// 'PAID' / 'UNPAID' / 'PARTIALLY_PAID' live on paid_status; the rest on status.
|
||||
if (in_array($status, ['PAID', 'UNPAID', 'PARTIALLY_PAID'], true)) {
|
||||
$query->where('paid_status', $status);
|
||||
} elseif ($status === 'OVERDUE') {
|
||||
$query->where('overdue', true);
|
||||
} else {
|
||||
$query->where('status', $status);
|
||||
}
|
||||
}
|
||||
|
||||
if (! empty($arguments['customer_id'])) {
|
||||
$query->where('customer_id', (int) $arguments['customer_id']);
|
||||
}
|
||||
|
||||
return [
|
||||
'invoices' => $query->get()->map(fn (Invoice $inv): array => [
|
||||
'id' => $inv->id,
|
||||
'invoice_number' => $inv->invoice_number,
|
||||
'customer_id' => $inv->customer_id,
|
||||
'customer_name' => $inv->customer?->name,
|
||||
'invoice_date' => $this->asDate($inv->invoice_date),
|
||||
'due_date' => $this->asDate($inv->due_date),
|
||||
'status' => $inv->status,
|
||||
'paid_status' => $inv->paid_status,
|
||||
'total' => $inv->total,
|
||||
'due_amount' => $inv->due_amount,
|
||||
'overdue' => (bool) $inv->overdue,
|
||||
])->all(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Platform\Ai\Application\Tools;
|
||||
|
||||
use App\Domains\Catalog\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 requiredAbility(): ?array
|
||||
{
|
||||
return ['view-item', Item::class];
|
||||
}
|
||||
|
||||
public function execute(array $arguments, int $companyId, int $userId): mixed
|
||||
{
|
||||
$limit = min((int) ($arguments['limit'] ?? self::DEFAULT_LIMIT), self::MAX_LIMIT);
|
||||
|
||||
$query = Item::query()
|
||||
->where('company_id', $companyId)
|
||||
->orderBy('name')
|
||||
->limit($limit);
|
||||
|
||||
if (! empty($arguments['query'])) {
|
||||
$q = $arguments['query'];
|
||||
$query->where(function ($qb) use ($q) {
|
||||
$qb->where('name', 'like', "%{$q}%")
|
||||
->orWhere('description', 'like', "%{$q}%");
|
||||
});
|
||||
}
|
||||
|
||||
return [
|
||||
'items' => $query->get()->map(fn (Item $item): array => [
|
||||
'id' => $item->id,
|
||||
'name' => $item->name,
|
||||
'description' => $item->description,
|
||||
'price' => $item->price,
|
||||
])->all(),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Platform\Ai\Contracts;
|
||||
|
||||
use App\Platform\Ai\Data\AiChatResponse;
|
||||
use App\Platform\Ai\Exceptions\AiException;
|
||||
|
||||
/**
|
||||
* Abstract base for AI drivers.
|
||||
*
|
||||
* Concrete drivers adapt a specific AI provider (OpenRouter, Anthropic direct,
|
||||
* local Ollama, etc.) into the two operations the host app needs:
|
||||
*
|
||||
* - `chatCompletion()` — for the chat assistant. OpenAI chat format, optional
|
||||
* tool-calling support. Drivers MUST translate their provider's tool-call
|
||||
* shape into the OpenAI-style `tool_calls` array on AiChatResponse so the
|
||||
* AiAssistantService orchestration loop stays provider-agnostic.
|
||||
*
|
||||
* - `textCompletion()` — for the text generation popup on WYSIWYG editors.
|
||||
* Stateless single-shot prompt → text.
|
||||
*
|
||||
* Drivers are registered with the host via the module Registry (see
|
||||
* App\Platform\Ai\AiServiceProvider for built-ins and
|
||||
* InvoiceShelf\Modules\Registry::registerAiDriver() for module-contributed ones).
|
||||
*/
|
||||
abstract class AiDriver
|
||||
{
|
||||
public function __construct(
|
||||
protected string $apiKey,
|
||||
protected array $config = [],
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Perform a chat completion.
|
||||
*
|
||||
* @param array<int, array<string, mixed>> $messages OpenAI chat format: [['role' => 'user', 'content' => '...'], ...]
|
||||
* @param string $model Provider-specific model identifier, e.g. 'openai/gpt-4o'
|
||||
* @param array<int, array<string, mixed>> $tools OpenAI tools schema array (empty = no tool calling)
|
||||
* @param array<string, mixed> $options Provider-specific options (temperature, max_tokens, etc.)
|
||||
*
|
||||
* @throws AiException
|
||||
*/
|
||||
abstract public function chatCompletion(
|
||||
array $messages,
|
||||
string $model,
|
||||
array $tools = [],
|
||||
array $options = [],
|
||||
): AiChatResponse;
|
||||
|
||||
/**
|
||||
* Perform a single-shot text completion.
|
||||
*
|
||||
* Implementations may route this through chatCompletion() with a single
|
||||
* user message — it's a convenience for callers that don't need history.
|
||||
*
|
||||
* @throws AiException
|
||||
*/
|
||||
abstract public function textCompletion(
|
||||
string $prompt,
|
||||
string $model,
|
||||
array $options = [],
|
||||
): string;
|
||||
|
||||
/**
|
||||
* Validate that the configured API key and base URL can reach the provider.
|
||||
*
|
||||
* Called from admin "Test connection" buttons. Should make a cheap round-trip
|
||||
* (list models, short completion, etc.) and throw AiException on failure.
|
||||
*
|
||||
* @return array<string, mixed> Provider info the UI can display (e.g. echoed model list)
|
||||
*
|
||||
* @throws AiException
|
||||
*/
|
||||
abstract public function validateConnection(): array;
|
||||
|
||||
/**
|
||||
* Optional: return the list of available model identifiers from the provider.
|
||||
*
|
||||
* Drivers that don't expose a models endpoint can leave this as an empty array.
|
||||
* The UI falls back to the `suggested_models` declared in driver metadata.
|
||||
*
|
||||
* @return array<int, array{value: string, label: string}>
|
||||
*/
|
||||
public function listModels(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Platform\Ai\Data;
|
||||
|
||||
/**
|
||||
* Provider-agnostic chat completion response.
|
||||
*
|
||||
* Shape is deliberately modelled after OpenAI's response so drivers translating
|
||||
* from other shapes only have to do it once, and the AiAssistantService loop
|
||||
* stays vendor-neutral.
|
||||
*/
|
||||
class AiChatResponse
|
||||
{
|
||||
/**
|
||||
* @param string|null $message The assistant's text reply. Null when the response is a tool-call-only turn.
|
||||
* @param array<int, array{id: string, name: string, arguments: array<string, mixed>}> $toolCalls
|
||||
* Tool calls the model wants the host to execute. Empty array if none.
|
||||
* @param string $finishReason Why generation stopped: 'stop', 'tool_calls', 'length', 'error', etc.
|
||||
* @param array{tokens_in?: int, tokens_out?: int} $usage Token usage for cost tracking (optional).
|
||||
* @param string|null $model Echoed model ID that produced this response (optional).
|
||||
*/
|
||||
public function __construct(
|
||||
public readonly ?string $message,
|
||||
public readonly array $toolCalls = [],
|
||||
public readonly string $finishReason = 'stop',
|
||||
public readonly array $usage = [],
|
||||
public readonly ?string $model = null,
|
||||
) {}
|
||||
|
||||
public function hasToolCalls(): bool
|
||||
{
|
||||
return $this->toolCalls !== [];
|
||||
}
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Platform\Ai\Drivers;
|
||||
|
||||
use App\Platform\Ai\Contracts\AiDriver;
|
||||
use InvalidArgumentException;
|
||||
use InvoiceShelf\Modules\Registry;
|
||||
|
||||
/**
|
||||
* Instantiates AiDriver implementations by name.
|
||||
*
|
||||
* Mirrors the shape of ExchangeRateDriverFactory: a static $drivers fallback
|
||||
* map for built-ins registered directly against the factory, plus a fallback
|
||||
* to the module Registry so module-contributed drivers (via
|
||||
* Registry::registerAiDriver()) are also resolvable. Canonical registration
|
||||
* path is the Registry — the local fallback map exists so the factory keeps
|
||||
* working even in tests or contexts where the Registry happens to be flushed.
|
||||
*/
|
||||
class AiDriverFactory
|
||||
{
|
||||
/**
|
||||
* @var array<string, class-string<AiDriver>>
|
||||
*/
|
||||
protected static array $drivers = [
|
||||
'openrouter' => OpenRouterDriver::class,
|
||||
];
|
||||
|
||||
/**
|
||||
* Register a custom AI driver directly with the factory.
|
||||
*
|
||||
* Modules should prefer Registry::registerAiDriver() which carries
|
||||
* the metadata (label, website, supported_roles, suggested_models,
|
||||
* config_fields) that the frontend UI needs to render a configuration
|
||||
* form. This method exists for tests and programmatic registration.
|
||||
*
|
||||
* @param class-string<AiDriver> $driverClass
|
||||
*/
|
||||
public static function register(string $name, string $driverClass): void
|
||||
{
|
||||
static::$drivers[$name] = $driverClass;
|
||||
}
|
||||
|
||||
/**
|
||||
* Instantiate a driver by name.
|
||||
*
|
||||
* @param array<string, mixed> $config Driver-specific config (base_url, timeouts, etc.)
|
||||
*
|
||||
* @throws InvalidArgumentException When the driver name isn't known.
|
||||
*/
|
||||
public static function make(string $driver, string $apiKey, array $config = []): AiDriver
|
||||
{
|
||||
$class = static::resolveDriverClass($driver);
|
||||
|
||||
if (! $class) {
|
||||
throw new InvalidArgumentException("Unknown AI driver: {$driver}");
|
||||
}
|
||||
|
||||
return new $class($apiKey, $config);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all known driver names — both factory-registered built-ins and Registry-contributed.
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public static function availableDrivers(): array
|
||||
{
|
||||
$local = array_keys(static::$drivers);
|
||||
$registry = array_keys(Registry::allDrivers('ai'));
|
||||
|
||||
return array_values(array_unique(array_merge($local, $registry)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a driver name to its concrete class via the local map then the Registry.
|
||||
*/
|
||||
protected static function resolveDriverClass(string $driver): ?string
|
||||
{
|
||||
if (isset(static::$drivers[$driver])) {
|
||||
return static::$drivers[$driver];
|
||||
}
|
||||
|
||||
$meta = Registry::driverMeta('ai', $driver);
|
||||
|
||||
return $meta['class'] ?? null;
|
||||
}
|
||||
}
|
||||
@@ -1,234 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Platform\Ai\Drivers;
|
||||
|
||||
use App\Platform\Ai\Contracts\AiDriver;
|
||||
use App\Platform\Ai\Data\AiChatResponse;
|
||||
use App\Platform\Ai\Exceptions\AiException;
|
||||
use App\Support\Net\BlockedUrlException;
|
||||
use App\Support\Net\PrivateNetworkGuard;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* OpenRouter driver.
|
||||
*
|
||||
* OpenRouter is an OpenAI-compatible aggregator that routes requests to
|
||||
* hundreds of underlying LLMs (OpenAI, Anthropic, Google, open-source, etc.)
|
||||
* behind a single API key and a single request shape. That makes it ideal as
|
||||
* the default v1 driver — one integration unlocks the whole ecosystem.
|
||||
*
|
||||
* Endpoint: POST {base_url}/chat/completions (OpenAI format)
|
||||
* Auth: Bearer token in Authorization header
|
||||
* Docs: https://openrouter.ai/docs
|
||||
*/
|
||||
class OpenRouterDriver extends AiDriver
|
||||
{
|
||||
protected const DEFAULT_BASE_URL = 'https://openrouter.ai/api/v1';
|
||||
|
||||
protected const TIMEOUT_SECONDS = 120;
|
||||
|
||||
/** Memoised, SSRF-validated base URL so we don't re-resolve DNS per request. */
|
||||
private ?string $validatedBaseUrl = null;
|
||||
|
||||
public function chatCompletion(
|
||||
array $messages,
|
||||
string $model,
|
||||
array $tools = [],
|
||||
array $options = [],
|
||||
): AiChatResponse {
|
||||
// Resolve (and SSRF-validate) the URL before the try so a blocked base
|
||||
// URL surfaces as `invalid_base_url`, not a generic `server_error`.
|
||||
$endpoint = $this->getBaseUrl().'/chat/completions';
|
||||
|
||||
$payload = array_filter([
|
||||
'model' => $model,
|
||||
'messages' => $messages,
|
||||
'tools' => $tools !== [] ? $tools : null,
|
||||
'tool_choice' => $tools !== [] ? ($options['tool_choice'] ?? 'auto') : null,
|
||||
'temperature' => $options['temperature'] ?? null,
|
||||
'max_tokens' => $options['max_tokens'] ?? null,
|
||||
], fn ($v) => $v !== null);
|
||||
|
||||
try {
|
||||
$response = Http::withToken($this->apiKey)
|
||||
->timeout(self::TIMEOUT_SECONDS)
|
||||
->acceptJson()
|
||||
->asJson()
|
||||
->post($endpoint, $payload);
|
||||
} catch (Throwable $e) {
|
||||
throw new AiException(
|
||||
'OpenRouter request failed: '.$e->getMessage(),
|
||||
'server_error',
|
||||
0,
|
||||
$e,
|
||||
);
|
||||
}
|
||||
|
||||
if ($response->status() === 401) {
|
||||
throw new AiException('Invalid OpenRouter API key', 'invalid_key');
|
||||
}
|
||||
|
||||
if ($response->status() === 429) {
|
||||
throw new AiException('OpenRouter rate limit exceeded', 'rate_limited');
|
||||
}
|
||||
|
||||
if (! $response->successful()) {
|
||||
$errorBody = $response->json('error.message') ?? $response->body();
|
||||
throw new AiException(
|
||||
'OpenRouter returned '.$response->status().': '.$errorBody,
|
||||
'server_error',
|
||||
);
|
||||
}
|
||||
|
||||
return $this->parseChatResponse($response->json());
|
||||
}
|
||||
|
||||
public function textCompletion(string $prompt, string $model, array $options = []): string
|
||||
{
|
||||
$response = $this->chatCompletion(
|
||||
[['role' => 'user', 'content' => $prompt]],
|
||||
$model,
|
||||
[],
|
||||
$options,
|
||||
);
|
||||
|
||||
return $response->message ?? '';
|
||||
}
|
||||
|
||||
public function validateConnection(): array
|
||||
{
|
||||
// Resolve (and SSRF-validate) the URL before the try so a blocked base
|
||||
// URL surfaces as `invalid_base_url`, not a generic `server_error`.
|
||||
$endpoint = $this->getBaseUrl().'/models';
|
||||
|
||||
try {
|
||||
$response = Http::withToken($this->apiKey)
|
||||
->timeout(30)
|
||||
->acceptJson()
|
||||
->get($endpoint);
|
||||
} catch (Throwable $e) {
|
||||
throw new AiException(
|
||||
'Unable to reach OpenRouter: '.$e->getMessage(),
|
||||
'server_error',
|
||||
0,
|
||||
$e,
|
||||
);
|
||||
}
|
||||
|
||||
if ($response->status() === 401) {
|
||||
throw new AiException('Invalid OpenRouter API key', 'invalid_key');
|
||||
}
|
||||
|
||||
if (! $response->successful()) {
|
||||
throw new AiException(
|
||||
'OpenRouter validation failed with status '.$response->status(),
|
||||
'server_error',
|
||||
);
|
||||
}
|
||||
|
||||
$data = $response->json('data', []);
|
||||
|
||||
return [
|
||||
'ok' => true,
|
||||
'model_count' => is_array($data) ? count($data) : 0,
|
||||
];
|
||||
}
|
||||
|
||||
public function listModels(): array
|
||||
{
|
||||
try {
|
||||
$response = Http::withToken($this->apiKey)
|
||||
->timeout(30)
|
||||
->acceptJson()
|
||||
->get($this->getBaseUrl().'/models');
|
||||
} catch (Throwable) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (! $response->successful()) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$models = $response->json('data', []);
|
||||
|
||||
if (! is_array($models)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_map(
|
||||
fn (array $m): array => [
|
||||
'value' => $m['id'] ?? '',
|
||||
'label' => $m['name'] ?? ($m['id'] ?? ''),
|
||||
],
|
||||
$models,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed>|null $body
|
||||
*/
|
||||
protected function parseChatResponse(?array $body): AiChatResponse
|
||||
{
|
||||
$choice = $body['choices'][0] ?? [];
|
||||
$message = $choice['message'] ?? [];
|
||||
|
||||
$text = $message['content'] ?? null;
|
||||
$finishReason = $choice['finish_reason'] ?? 'stop';
|
||||
|
||||
// Normalize OpenAI's tool_calls shape — each entry has id, type='function',
|
||||
// and function.{name,arguments} where arguments is a JSON string we need to decode.
|
||||
$toolCalls = [];
|
||||
foreach ($message['tool_calls'] ?? [] as $call) {
|
||||
$name = $call['function']['name'] ?? null;
|
||||
$rawArgs = $call['function']['arguments'] ?? '{}';
|
||||
$args = is_string($rawArgs) ? (json_decode($rawArgs, true) ?: []) : (array) $rawArgs;
|
||||
|
||||
if ($name === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$toolCalls[] = [
|
||||
'id' => $call['id'] ?? '',
|
||||
'name' => $name,
|
||||
'arguments' => $args,
|
||||
];
|
||||
}
|
||||
|
||||
$usage = [];
|
||||
if (isset($body['usage'])) {
|
||||
$usage = [
|
||||
'tokens_in' => (int) ($body['usage']['prompt_tokens'] ?? 0),
|
||||
'tokens_out' => (int) ($body['usage']['completion_tokens'] ?? 0),
|
||||
];
|
||||
}
|
||||
|
||||
return new AiChatResponse(
|
||||
message: $text,
|
||||
toolCalls: $toolCalls,
|
||||
finishReason: $finishReason,
|
||||
usage: $usage,
|
||||
model: $body['model'] ?? null,
|
||||
);
|
||||
}
|
||||
|
||||
protected function getBaseUrl(): string
|
||||
{
|
||||
if ($this->validatedBaseUrl !== null) {
|
||||
return $this->validatedBaseUrl;
|
||||
}
|
||||
|
||||
$configured = (string) ($this->config['base_url'] ?? '');
|
||||
$url = rtrim($configured !== '' ? $configured : self::DEFAULT_BASE_URL, '/');
|
||||
|
||||
// SSRF guard: never let an admin/owner-supplied base URL point the
|
||||
// server (with the bearer token attached) at a private/reserved host.
|
||||
try {
|
||||
PrivateNetworkGuard::assertAllowed($url);
|
||||
} catch (BlockedUrlException $e) {
|
||||
throw new AiException('Invalid AI base URL: '.$e->getMessage(), 'invalid_base_url', 0, $e);
|
||||
}
|
||||
|
||||
return $this->validatedBaseUrl = $url;
|
||||
}
|
||||
}
|
||||
@@ -1,25 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Platform\Ai\Exceptions;
|
||||
|
||||
use RuntimeException;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
* Domain exception for AI driver failures.
|
||||
*
|
||||
* Carries a short `errorKey` alongside the human-readable message so the
|
||||
* frontend can look up a localized error string. Matches the shape of
|
||||
* ExchangeRateException for consistency with the existing driver pattern.
|
||||
*/
|
||||
class AiException extends RuntimeException
|
||||
{
|
||||
public function __construct(
|
||||
string $message,
|
||||
public readonly string $errorKey = 'server_error',
|
||||
int $code = 0,
|
||||
?Throwable $previous = null,
|
||||
) {
|
||||
parent::__construct($message, $code, $previous);
|
||||
}
|
||||
}
|
||||
@@ -1,133 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Platform\Ai\Http\Admin;
|
||||
|
||||
use App\Platform\Ai\Application\AiConfigurationService;
|
||||
use App\Platform\Ai\Drivers\AiDriverFactory;
|
||||
use App\Platform\Ai\Exceptions\AiException;
|
||||
use App\Platform\Http\Controller;
|
||||
use App\Rules\PublicHttpUrl;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class AiConfigurationController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly AiConfigurationService $aiConfigurationService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Get the global AI configuration with decrypted API key masked for response.
|
||||
*
|
||||
* @throws AuthorizationException
|
||||
*/
|
||||
public function getConfig(): JsonResponse
|
||||
{
|
||||
$this->authorize('manage ai config');
|
||||
|
||||
$config = $this->aiConfigurationService->getGlobalConfig();
|
||||
|
||||
return response()->json($this->maskApiKey($config));
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist the global AI configuration.
|
||||
*
|
||||
* If the submitted api_key is the masked placeholder, we retain the stored value —
|
||||
* otherwise the user would have to re-enter the key every time they save the form.
|
||||
*
|
||||
* @throws AuthorizationException
|
||||
* @throws ValidationException
|
||||
*/
|
||||
public function saveConfig(Request $request): JsonResponse
|
||||
{
|
||||
$this->authorize('manage ai config');
|
||||
|
||||
$validated = $this->validate(
|
||||
$request,
|
||||
$this->aiConfigurationService->validationRules(allowDisabledCustomConfig: false),
|
||||
);
|
||||
|
||||
// Preserve existing key when client submits the masked placeholder
|
||||
if (($validated['ai_api_key'] ?? null) === '********' || ($validated['ai_api_key'] ?? null) === '') {
|
||||
$existing = $this->aiConfigurationService->getGlobalConfig();
|
||||
$validated['ai_api_key'] = $existing['ai_api_key'] ?? '';
|
||||
}
|
||||
|
||||
$this->aiConfigurationService->saveGlobalConfig($validated);
|
||||
|
||||
return response()->json(['success' => 'ai_variables_save_successfully']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the AI driver list for the admin UI — same shape as the exchange rate endpoint.
|
||||
*
|
||||
* @throws AuthorizationException
|
||||
*/
|
||||
public function getDrivers(): JsonResponse
|
||||
{
|
||||
$this->authorize('manage ai config');
|
||||
|
||||
return response()->json([
|
||||
'ai_drivers' => $this->aiConfigurationService->listDrivers(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test the currently configured AI provider by instantiating its driver and calling validateConnection().
|
||||
*
|
||||
* @throws AuthorizationException
|
||||
*/
|
||||
public function testConnection(Request $request): JsonResponse
|
||||
{
|
||||
$this->authorize('manage ai config');
|
||||
|
||||
$this->validate($request, [
|
||||
'ai_driver' => 'required|string',
|
||||
'ai_api_key' => 'nullable|string',
|
||||
'ai_base_url' => ['nullable', 'string', 'url', new PublicHttpUrl],
|
||||
]);
|
||||
|
||||
// If the masked placeholder was submitted, fall back to the stored key
|
||||
$apiKey = $request->input('ai_api_key');
|
||||
if ($apiKey === '********' || $apiKey === null || $apiKey === '') {
|
||||
$existing = $this->aiConfigurationService->getGlobalConfig();
|
||||
$apiKey = $existing['ai_api_key'] ?? '';
|
||||
}
|
||||
|
||||
if ($apiKey === '') {
|
||||
return response()->json(['error' => 'missing_api_key'], 422);
|
||||
}
|
||||
|
||||
try {
|
||||
$driver = AiDriverFactory::make(
|
||||
$request->input('ai_driver'),
|
||||
$apiKey,
|
||||
['base_url' => $request->input('ai_base_url')],
|
||||
);
|
||||
|
||||
$result = $driver->validateConnection();
|
||||
} catch (AiException $e) {
|
||||
return response()->json(['error' => $e->errorKey, 'message' => $e->getMessage()], 422);
|
||||
}
|
||||
|
||||
return response()->json(['success' => true, 'details' => $result]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the stored API key with a masked placeholder so it's never returned to the client.
|
||||
*
|
||||
* @param array<string, mixed> $config
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function maskApiKey(array $config): array
|
||||
{
|
||||
if (! empty($config['ai_api_key'])) {
|
||||
$config['ai_api_key'] = '********';
|
||||
}
|
||||
|
||||
return $config;
|
||||
}
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Platform\Ai\Http\Company;
|
||||
|
||||
use App\Platform\Ai\Application\AiAssistantService;
|
||||
use App\Platform\Ai\Exceptions\AiException;
|
||||
use App\Platform\Ai\Models\AiConversation;
|
||||
use App\Platform\Http\Controller;
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,113 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Platform\Ai\Http\Company;
|
||||
|
||||
use App\Platform\Ai\Application\AiConfigurationService;
|
||||
use App\Platform\Ai\Drivers\AiDriverFactory;
|
||||
use App\Platform\Ai\Exceptions\AiException;
|
||||
use App\Platform\Http\Controller;
|
||||
use App\Rules\PublicHttpUrl;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class CompanyAiConfigurationController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly AiConfigurationService $aiConfigurationService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Get the per-company AI config with decrypted API key masked for response.
|
||||
*/
|
||||
public function getConfig(Request $request): JsonResponse
|
||||
{
|
||||
$config = $this->aiConfigurationService->getCompanyConfig($request->header('company'));
|
||||
|
||||
return response()->json($this->maskApiKey($config));
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist the per-company AI config.
|
||||
*
|
||||
* Respects the `use_custom_ai_config` toggle — when OFF, only the toggle is written
|
||||
* and the driver fields are discarded (same pattern as the mail company override).
|
||||
*
|
||||
* @throws ValidationException
|
||||
*/
|
||||
public function saveConfig(Request $request): JsonResponse
|
||||
{
|
||||
$this->authorize('owner only');
|
||||
|
||||
$validated = $this->validate(
|
||||
$request,
|
||||
$this->aiConfigurationService->validationRules(allowDisabledCustomConfig: true),
|
||||
);
|
||||
|
||||
// Preserve existing key when masked placeholder is submitted
|
||||
if (($validated['ai_api_key'] ?? null) === '********' || ($validated['ai_api_key'] ?? null) === '') {
|
||||
$existing = $this->aiConfigurationService->getCompanyConfig($request->header('company'));
|
||||
$validated['ai_api_key'] = $existing['ai_api_key'] ?? '';
|
||||
}
|
||||
|
||||
$this->aiConfigurationService->saveCompanyConfig(
|
||||
$request->header('company'),
|
||||
$validated,
|
||||
);
|
||||
|
||||
return response()->json(['success' => true]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test a company-level AI configuration without persisting it.
|
||||
*
|
||||
* @throws ValidationException
|
||||
*/
|
||||
public function testConnection(Request $request): JsonResponse
|
||||
{
|
||||
$this->authorize('owner only');
|
||||
|
||||
$this->validate($request, [
|
||||
'ai_driver' => 'required|string',
|
||||
'ai_api_key' => 'nullable|string',
|
||||
'ai_base_url' => ['nullable', 'string', 'url', new PublicHttpUrl],
|
||||
]);
|
||||
|
||||
$apiKey = $request->input('ai_api_key');
|
||||
if ($apiKey === '********' || $apiKey === null || $apiKey === '') {
|
||||
$existing = $this->aiConfigurationService->getCompanyConfig($request->header('company'));
|
||||
$apiKey = $existing['ai_api_key'] ?? '';
|
||||
}
|
||||
|
||||
if ($apiKey === '') {
|
||||
return response()->json(['error' => 'missing_api_key'], 422);
|
||||
}
|
||||
|
||||
try {
|
||||
$driver = AiDriverFactory::make(
|
||||
$request->input('ai_driver'),
|
||||
$apiKey,
|
||||
['base_url' => $request->input('ai_base_url')],
|
||||
);
|
||||
|
||||
$result = $driver->validateConnection();
|
||||
} catch (AiException $e) {
|
||||
return response()->json(['error' => $e->errorKey, 'message' => $e->getMessage()], 422);
|
||||
}
|
||||
|
||||
return response()->json(['success' => true, 'details' => $result]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $config
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function maskApiKey(array $config): array
|
||||
{
|
||||
if (! empty($config['ai_api_key'])) {
|
||||
$config['ai_api_key'] = '********';
|
||||
}
|
||||
|
||||
return $config;
|
||||
}
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Platform\Ai\Http\Company;
|
||||
|
||||
use App\Platform\Ai\Models\AiConversation;
|
||||
use App\Platform\Http\Controller;
|
||||
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]);
|
||||
}
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Platform\Ai\Http\Company;
|
||||
|
||||
use App\Platform\Ai\Application\AiTextGenerationService;
|
||||
use App\Platform\Ai\Exceptions\AiException;
|
||||
use App\Platform\Http\Controller;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
class GenerationController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly AiTextGenerationService $generator,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* One-shot text generation for the WYSIWYG popup.
|
||||
*
|
||||
* Stateless — nothing is persisted. Each call is fully self-contained.
|
||||
* Rate-limited via the shared 'ai' limiter so a stuck client can't
|
||||
* hammer the provider.
|
||||
*
|
||||
* @throws ValidationException
|
||||
*/
|
||||
public function __invoke(Request $request): JsonResponse
|
||||
{
|
||||
$this->authorize('use ai');
|
||||
|
||||
$validated = $this->validate($request, [
|
||||
'prompt' => 'required|string|max:4000',
|
||||
'context' => 'nullable|string|max:20000',
|
||||
]);
|
||||
|
||||
try {
|
||||
$text = $this->generator->generate(
|
||||
(int) $request->header('company'),
|
||||
$validated['prompt'],
|
||||
$validated['context'] ?? null,
|
||||
);
|
||||
} catch (AiException $e) {
|
||||
return response()->json([
|
||||
'error' => $e->errorKey,
|
||||
'message' => $e->getMessage(),
|
||||
], 422);
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'text' => $text,
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Platform\Ai\Http\Setup;
|
||||
|
||||
use App\Platform\Ai\Application\AiConfigurationService;
|
||||
use App\Platform\Http\Controller;
|
||||
use App\Platform\Operations\Models\Setting;
|
||||
use App\Rules\PublicHttpUrl;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
|
||||
/**
|
||||
* Installer wizard step: optional "Enable AI" configuration.
|
||||
*
|
||||
* Runs without an authenticated user — the installer middleware allows public
|
||||
* access until `profile_complete` is marked COMPLETED. Persists a minimal global
|
||||
* AI config so the first super-admin doesn't have to revisit the admin panel
|
||||
* just to turn on chat/text-generation after install.
|
||||
*
|
||||
* Skipping the step (posting ai_enabled=NO) is the default path — users can
|
||||
* always configure AI later from Admin → Settings → AI Configuration.
|
||||
*/
|
||||
class AiConfigurationController extends Controller
|
||||
{
|
||||
public function __construct(
|
||||
private readonly AiConfigurationService $aiConfigurationService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Return the current AI config defaults plus the driver list for the wizard form.
|
||||
*/
|
||||
public function show(): JsonResponse
|
||||
{
|
||||
return response()->json([
|
||||
'config' => $this->aiConfigurationService->getGlobalConfig(),
|
||||
'drivers' => $this->aiConfigurationService->listDrivers(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist the installer's AI config choice and advance the wizard step.
|
||||
*
|
||||
* @throws ValidationException
|
||||
*/
|
||||
public function save(Request $request): JsonResponse
|
||||
{
|
||||
Artisan::call('optimize:clear');
|
||||
|
||||
$validated = $this->validate($request, [
|
||||
'ai_enabled' => 'required|in:YES,NO',
|
||||
'ai_driver' => 'required_if:ai_enabled,YES|nullable|string',
|
||||
'ai_api_key' => 'required_if:ai_enabled,YES|nullable|string',
|
||||
'ai_base_url' => ['nullable', 'string', 'url', new PublicHttpUrl],
|
||||
'ai_chat_enabled' => 'nullable|in:YES,NO',
|
||||
'ai_chat_model' => 'nullable|string|max:200',
|
||||
'ai_text_generation_enabled' => 'nullable|in:YES,NO',
|
||||
'ai_text_generation_model' => 'nullable|string|max:200',
|
||||
]);
|
||||
|
||||
$this->aiConfigurationService->saveGlobalConfig($validated);
|
||||
|
||||
// Advance the installer's profile_complete marker if we're the first to touch it.
|
||||
// Mail uses `4`; we'll use the next sentinel but leave actual completion to the
|
||||
// final Preferences step (which sets 'COMPLETED'). The sentinel value is ignored
|
||||
// once COMPLETED is written — it only matters for step-tracking during install.
|
||||
$profileComplete = Setting::getSetting('profile_complete');
|
||||
if ($profileComplete !== 'COMPLETED' && (int) $profileComplete < 5) {
|
||||
Setting::setSetting('profile_complete', 5);
|
||||
}
|
||||
|
||||
return response()->json(['success' => true]);
|
||||
}
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Platform\Ai\Models;
|
||||
|
||||
use App\Domains\Accounts\Models\Company;
|
||||
use App\Domains\Accounts\Models\User;
|
||||
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
|
||||
{
|
||||
protected $table = 'ai_conversations';
|
||||
|
||||
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');
|
||||
}
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Platform\Ai\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
|
||||
{
|
||||
protected $table = 'ai_messages';
|
||||
|
||||
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');
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Platform\Ai\Policies;
|
||||
|
||||
use App\Domains\Accounts\Models\User;
|
||||
|
||||
class AiAccessPolicy
|
||||
{
|
||||
public function manageConfiguration(User $user): bool
|
||||
{
|
||||
return $user->isSuperAdmin();
|
||||
}
|
||||
|
||||
/**
|
||||
* Feature configuration applies the instance and company kill switches.
|
||||
*/
|
||||
public function use(User $user): bool
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Platform\Ai\Policies;
|
||||
|
||||
use App\Domains\Accounts\Models\User;
|
||||
use App\Platform\Ai\Models\AiConversation;
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace App\Platform\Ai\Prompting;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
/**
|
||||
* Loads LLM prompt templates from disk and performs lightweight
|
||||
* placeholder substitution.
|
||||
*
|
||||
* Prompts live as plain markdown files under `resources/ai/prompts/`
|
||||
* so they can be edited without touching PHP service classes, get
|
||||
* syntax-highlighted in editors, and produce clean PR diffs. Keeping
|
||||
* the templates outside PHP also sidesteps heredoc indentation
|
||||
* rules and interpolation quirks (dollar signs, curly braces).
|
||||
*
|
||||
* Placeholders use the `{{name}}` double-brace form — a common
|
||||
* template convention that doesn't collide with markdown syntax and
|
||||
* is trivial to substitute via strtr(). We deliberately do NOT use
|
||||
* Blade for this: Blade's `{{ $var }}` HTML-escapes ampersands and
|
||||
* quotes, which would corrupt prompts that reference names like
|
||||
* "Smith & Co" (the model would see "Smith & Co").
|
||||
*/
|
||||
class PromptLoader
|
||||
{
|
||||
/**
|
||||
* Load a prompt template and substitute any placeholders.
|
||||
*
|
||||
* @param string $name Template filename without extension (e.g. 'chat-system').
|
||||
* @param array<string, scalar|\Stringable> $vars Placeholder => value map.
|
||||
*
|
||||
* @throws RuntimeException when the template file does not exist.
|
||||
*/
|
||||
public static function load(string $name, array $vars = []): string
|
||||
{
|
||||
$path = resource_path("ai/prompts/{$name}.md");
|
||||
|
||||
if (! is_file($path)) {
|
||||
throw new RuntimeException("Missing AI prompt template: {$name} (expected at {$path})");
|
||||
}
|
||||
|
||||
$template = (string) file_get_contents($path);
|
||||
|
||||
if ($vars === []) {
|
||||
return trim($template);
|
||||
}
|
||||
|
||||
$replacements = [];
|
||||
foreach ($vars as $key => $value) {
|
||||
$replacements['{{'.$key.'}}'] = (string) $value;
|
||||
}
|
||||
|
||||
return trim(strtr($template, $replacements));
|
||||
}
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
<?php
|
||||
|
||||
use App\Platform\Ai\Http\Admin\AiConfigurationController;
|
||||
use App\Platform\Ai\Http\Company\ChatController;
|
||||
use App\Platform\Ai\Http\Company\CompanyAiConfigurationController;
|
||||
use App\Platform\Ai\Http\Company\ConversationController;
|
||||
use App\Platform\Ai\Http\Company\GenerationController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
Route::get('/ai/drivers', [AiConfigurationController::class, 'getDrivers']);
|
||||
Route::get('/ai/config', [AiConfigurationController::class, 'getConfig']);
|
||||
Route::post('/ai/config', [AiConfigurationController::class, 'saveConfig']);
|
||||
Route::post('/ai/test', [AiConfigurationController::class, 'testConnection']);
|
||||
|
||||
Route::get('/company/ai/config', [CompanyAiConfigurationController::class, 'getConfig']);
|
||||
Route::post('/company/ai/config', [CompanyAiConfigurationController::class, 'saveConfig']);
|
||||
Route::post('/company/ai/test', [CompanyAiConfigurationController::class, 'testConnection']);
|
||||
|
||||
Route::middleware('throttle:ai')->group(function () {
|
||||
Route::post('/ai/chat', ChatController::class);
|
||||
Route::get('/ai/conversations', [ConversationController::class, 'index']);
|
||||
Route::get('/ai/conversations/{id}', [ConversationController::class, 'show']);
|
||||
Route::patch('/ai/conversations/{id}', [ConversationController::class, 'update']);
|
||||
Route::delete('/ai/conversations/{id}', [ConversationController::class, 'destroy']);
|
||||
Route::post('/ai/generate', GenerationController::class);
|
||||
});
|
||||
@@ -1,7 +0,0 @@
|
||||
<?php
|
||||
|
||||
use App\Platform\Ai\Http\Setup\AiConfigurationController;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
|
||||
Route::get('/ai/config', [AiConfigurationController::class, 'show']);
|
||||
Route::post('/ai/config', [AiConfigurationController::class, 'save']);
|
||||
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
namespace App\Platform\Modules\Infrastructure;
|
||||
|
||||
use App\Domains\Accounts\Models\User;
|
||||
use App\Domains\Catalog\Models\Item;
|
||||
use App\Domains\Contacts\Models\Customer;
|
||||
use App\Domains\Purchases\Models\Expense;
|
||||
use App\Domains\Receivables\Models\Payment;
|
||||
use App\Domains\Sales\Models\Invoice;
|
||||
use InvoiceShelf\Modules\Contracts\Host\ModuleAuthorization;
|
||||
use LogicException;
|
||||
use Silber\Bouncer\BouncerFacade;
|
||||
|
||||
class BouncerModuleAuthorization implements ModuleAuthorization
|
||||
{
|
||||
/** @var array<string, class-string> */
|
||||
private const RESOURCE_MODELS = [
|
||||
'customer' => Customer::class,
|
||||
'invoice' => Invoice::class,
|
||||
'expense' => Expense::class,
|
||||
'payment' => Payment::class,
|
||||
'item' => Item::class,
|
||||
];
|
||||
|
||||
public function allows(int $userId, int $companyId, string $ability, ?string $resource = null): bool
|
||||
{
|
||||
$user = User::query()
|
||||
->whereKey($userId)
|
||||
->whereHas('companies', fn ($query) => $query->whereKey($companyId))
|
||||
->first();
|
||||
|
||||
if ($user === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($resource === null) {
|
||||
return BouncerFacade::scope()->onceTo($companyId, fn (): bool => $user->can($ability));
|
||||
}
|
||||
|
||||
$model = self::RESOURCE_MODELS[$resource] ?? throw new LogicException("Unknown module resource: {$resource}");
|
||||
|
||||
// Module calls are not necessarily made from an HTTP request, so they
|
||||
// cannot rely on ScopeBouncer middleware having established this scope.
|
||||
// Keep the caller's scope intact for long-running workers and tests.
|
||||
return BouncerFacade::scope()->onceTo($companyId, fn (): bool => $user->can($ability, $model));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,297 @@
|
||||
<?php
|
||||
|
||||
namespace App\Platform\Modules\Infrastructure;
|
||||
|
||||
use App\Domains\Catalog\Models\Item;
|
||||
use App\Domains\Contacts\Models\Address;
|
||||
use App\Domains\Contacts\Models\Customer;
|
||||
use App\Domains\Purchases\Models\Expense;
|
||||
use App\Domains\Purchases\Models\ExpenseCategory;
|
||||
use App\Domains\Receivables\Models\Payment;
|
||||
use App\Domains\Sales\Models\Invoice;
|
||||
use App\Domains\Sales\Models\InvoiceItem;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Collection;
|
||||
use InvoiceShelf\Modules\Contracts\Host\CompanyDataReader;
|
||||
|
||||
class EloquentCompanyDataReader implements CompanyDataReader
|
||||
{
|
||||
public function companyStats(int $companyId, string $startDate, string $endDate): array
|
||||
{
|
||||
return [
|
||||
'invoices' => [
|
||||
'count' => Invoice::query()->where('company_id', $companyId)->where('type', Invoice::TYPE_INVOICE)->whereBetween('invoice_date', [$startDate, $endDate])->count(),
|
||||
'total' => (float) Invoice::query()->where('company_id', $companyId)->whereBetween('invoice_date', [$startDate, $endDate])->sum('total'),
|
||||
],
|
||||
'payments' => [
|
||||
'count' => Payment::query()->where('company_id', $companyId)->whereBetween('payment_date', [$startDate, $endDate])->count(),
|
||||
'total' => (float) Payment::query()->where('company_id', $companyId)->whereBetween('payment_date', [$startDate, $endDate])->sum('amount'),
|
||||
],
|
||||
'expenses' => [
|
||||
'count' => Expense::query()->where('company_id', $companyId)->whereBetween('expense_date', [$startDate, $endDate])->count(),
|
||||
'total' => (float) Expense::query()->where('company_id', $companyId)->whereBetween('expense_date', [$startDate, $endDate])->sum('amount'),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public function findCustomer(int $companyId, int $customerId): ?array
|
||||
{
|
||||
$customer = Customer::query()
|
||||
->where('company_id', $companyId)
|
||||
->whereKey($customerId)
|
||||
->with(['billingAddress', 'shippingAddress'])
|
||||
->first();
|
||||
|
||||
if ($customer === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => $customer->id,
|
||||
'name' => $customer->name,
|
||||
'display_name' => $customer->display_name,
|
||||
'email' => $customer->email,
|
||||
'phone' => $customer->phone,
|
||||
'contact_name' => $customer->contact_name,
|
||||
'company_name' => $customer->company_name,
|
||||
'website' => $customer->website,
|
||||
'enable_portal' => (bool) $customer->enable_portal,
|
||||
'billing_address' => $this->address($customer->billingAddress),
|
||||
'shipping_address' => $this->address($customer->shippingAddress),
|
||||
'totals' => [
|
||||
'invoice_count' => Invoice::query()->where('company_id', $companyId)->where('customer_id', $customer->id)->where('type', Invoice::TYPE_INVOICE)->count(),
|
||||
'outstanding_amount' => (float) Invoice::query()->where('company_id', $companyId)->where('customer_id', $customer->id)->whereIn('paid_status', ['UNPAID', 'PARTIALLY_PAID'])->sum('due_amount'),
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
public function searchCustomers(int $companyId, ?string $query, int $limit): array
|
||||
{
|
||||
$customers = Customer::query()->where('company_id', $companyId)->orderBy('name')->limit($limit);
|
||||
|
||||
if ($query !== null && $query !== '') {
|
||||
$customers->where(function ($builder) use ($query) {
|
||||
$builder->where('name', 'like', "%{$query}%")
|
||||
->orWhere('display_name', 'like', "%{$query}%")
|
||||
->orWhere('email', 'like', "%{$query}%")
|
||||
->orWhere('company_name', 'like', "%{$query}%")
|
||||
->orWhere('contact_name', 'like', "%{$query}%");
|
||||
});
|
||||
}
|
||||
|
||||
return $customers->get()->map(fn (Customer $customer): array => [
|
||||
'id' => $customer->id,
|
||||
'name' => $customer->name,
|
||||
'display_name' => $customer->display_name,
|
||||
'email' => $customer->email,
|
||||
'phone' => $customer->phone,
|
||||
'company_name' => $customer->company_name,
|
||||
])->all();
|
||||
}
|
||||
|
||||
public function rankCustomers(int $companyId, string $metric, ?string $startDate, ?string $endDate, int $limit): array
|
||||
{
|
||||
$rows = match ($metric) {
|
||||
'invoiced_total' => $this->customerInvoiceRanking($companyId, $startDate, $endDate, $limit, 'SUM(total)'),
|
||||
'paid_total' => $this->customerPaymentRanking($companyId, $startDate, $endDate, $limit),
|
||||
'invoice_count' => $this->customerInvoiceRanking($companyId, $startDate, $endDate, $limit, 'COUNT(*)'),
|
||||
'outstanding_balance' => Invoice::query()->where('company_id', $companyId)->whereNotNull('customer_id')->whereIn('paid_status', ['UNPAID', 'PARTIALLY_PAID'])->selectRaw('customer_id, SUM(due_amount) as metric_value, COUNT(*) as invoice_count')->groupBy('customer_id')->orderByDesc('metric_value')->limit($limit)->get(),
|
||||
};
|
||||
|
||||
$customers = Customer::query()->where('company_id', $companyId)->whereIn('id', $rows->pluck('customer_id'))->get()->keyBy('id');
|
||||
|
||||
return $rows->map(function ($row) use ($customers, $metric): array {
|
||||
$customer = $customers->get($row->customer_id);
|
||||
|
||||
return [
|
||||
'customer_id' => (int) $row->customer_id,
|
||||
'name' => $customer?->name,
|
||||
'display_name' => $customer?->display_name,
|
||||
'company_name' => $customer?->company_name,
|
||||
'metric_value' => $metric === 'invoice_count' ? (int) $row->metric_value : (float) $row->metric_value,
|
||||
'invoice_count' => isset($row->invoice_count) ? (int) $row->invoice_count : null,
|
||||
];
|
||||
})->all();
|
||||
}
|
||||
|
||||
public function findInvoice(int $companyId, string $invoiceNumber): ?array
|
||||
{
|
||||
$invoice = Invoice::query()->where('company_id', $companyId)->where('invoice_number', $invoiceNumber)->with(['customer:id,name,email,phone', 'items', 'taxes'])->first();
|
||||
|
||||
if ($invoice === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => $invoice->id,
|
||||
'invoice_number' => $invoice->invoice_number,
|
||||
'reference_number' => $invoice->reference_number,
|
||||
'status' => $invoice->status,
|
||||
'paid_status' => $invoice->paid_status,
|
||||
'invoice_date' => $this->date($invoice->invoice_date),
|
||||
'due_date' => $this->date($invoice->due_date),
|
||||
'sub_total' => $invoice->sub_total,
|
||||
'tax' => $invoice->tax,
|
||||
'discount' => $invoice->discount,
|
||||
'total' => $invoice->total,
|
||||
'due_amount' => $invoice->due_amount,
|
||||
'overdue' => (bool) $invoice->overdue,
|
||||
'notes' => $invoice->notes,
|
||||
'customer' => $invoice->customer ? ['id' => $invoice->customer->id, 'name' => $invoice->customer->name, 'email' => $invoice->customer->email, 'phone' => $invoice->customer->phone] : null,
|
||||
'items' => $invoice->items->map(fn ($item): array => ['name' => $item->name, 'description' => $item->description, 'quantity' => $item->quantity, 'price' => $item->price, 'total' => $item->total])->all(),
|
||||
'taxes' => $invoice->taxes->map(fn ($tax): array => ['name' => $tax->name, 'percent' => $tax->percent, 'amount' => $tax->amount])->all(),
|
||||
];
|
||||
}
|
||||
|
||||
public function searchInvoices(int $companyId, ?string $query, ?string $status, ?int $customerId, int $limit): array
|
||||
{
|
||||
$invoices = Invoice::query()->where('company_id', $companyId)->with('customer:id,name')->latest('invoice_date')->limit($limit);
|
||||
|
||||
if ($query !== null && $query !== '') {
|
||||
$invoices->where(fn ($builder) => $builder->where('invoice_number', 'like', "%{$query}%")->orWhere('reference_number', 'like', "%{$query}%"));
|
||||
}
|
||||
|
||||
if ($status !== null && $status !== '') {
|
||||
$status = strtoupper($status);
|
||||
if (in_array($status, ['PAID', 'UNPAID', 'PARTIALLY_PAID'], true)) {
|
||||
$invoices->where('paid_status', $status);
|
||||
} elseif ($status === 'OVERDUE') {
|
||||
$invoices->where('overdue', true);
|
||||
} else {
|
||||
$invoices->where('status', $status);
|
||||
}
|
||||
}
|
||||
|
||||
if ($customerId !== null) {
|
||||
$invoices->where('customer_id', $customerId);
|
||||
}
|
||||
|
||||
return $invoices->get()->map(fn (Invoice $invoice): array => $this->invoiceSummary($invoice))->all();
|
||||
}
|
||||
|
||||
public function overdueInvoices(int $companyId, int $limit): array
|
||||
{
|
||||
return Invoice::query()->where('company_id', $companyId)->where('overdue', true)->with('customer:id,name')->orderBy('due_date')->limit($limit)->get()->map(fn (Invoice $invoice): array => $this->invoiceSummary($invoice))->all();
|
||||
}
|
||||
|
||||
public function recentPayments(int $companyId, string $startDate, int $limit): array
|
||||
{
|
||||
return Payment::query()->where('company_id', $companyId)->where('payment_date', '>=', $startDate)->with(['allocations:id,payment_id,invoice_id,amount', 'customer:id,name', 'paymentMethod:id,name'])->latest('payment_date')->limit($limit)->get()->map(function (Payment $payment): array {
|
||||
$allocated = (int) $payment->allocations->sum('amount');
|
||||
|
||||
return [
|
||||
'id' => $payment->id,
|
||||
'payment_number' => $payment->payment_number,
|
||||
'payment_date' => $this->date($payment->payment_date),
|
||||
'amount' => $payment->amount,
|
||||
'customer_id' => $payment->customer_id,
|
||||
'customer_name' => $payment->customer?->name,
|
||||
'allocations' => $payment->allocations->map(fn ($allocation): array => ['invoice_id' => $allocation->invoice_id, 'amount' => $allocation->amount])->all(),
|
||||
'allocated_amount' => $allocated,
|
||||
'unallocated_amount' => (int) $payment->amount - $allocated,
|
||||
'payment_method' => $payment->paymentMethod?->name,
|
||||
];
|
||||
})->all();
|
||||
}
|
||||
|
||||
public function expenseCategories(int $companyId): array
|
||||
{
|
||||
return ExpenseCategory::query()->where('company_id', $companyId)->orderBy('name')->get(['id', 'name', 'description'])->map(fn (ExpenseCategory $category): array => ['id' => $category->id, 'name' => $category->name, 'description' => $category->description])->all();
|
||||
}
|
||||
|
||||
public function rankExpenseCategories(int $companyId, ?string $startDate, ?string $endDate, int $limit): array
|
||||
{
|
||||
$expenses = Expense::query()->where('company_id', $companyId)->whereNotNull('expense_category_id')->selectRaw('expense_category_id, SUM(amount) as total_amount, COUNT(*) as expense_count')->groupBy('expense_category_id')->orderByDesc('total_amount')->limit($limit);
|
||||
|
||||
if ($startDate !== null && $endDate !== null) {
|
||||
$expenses->whereBetween('expense_date', [$startDate, $endDate]);
|
||||
}
|
||||
|
||||
$rows = $expenses->get();
|
||||
$categories = ExpenseCategory::query()->where('company_id', $companyId)->whereIn('id', $rows->pluck('expense_category_id'))->get()->keyBy('id');
|
||||
|
||||
return $rows->map(fn ($row): array => ['expense_category_id' => (int) $row->expense_category_id, 'name' => $categories->get($row->expense_category_id)?->name, 'total_amount' => (float) $row->total_amount, 'expense_count' => (int) $row->expense_count])->all();
|
||||
}
|
||||
|
||||
public function searchItems(int $companyId, ?string $query, int $limit): array
|
||||
{
|
||||
$items = Item::query()->where('company_id', $companyId)->orderBy('name')->limit($limit);
|
||||
|
||||
if ($query !== null && $query !== '') {
|
||||
$items->where(fn ($builder) => $builder->where('name', 'like', "%{$query}%")->orWhere('description', 'like', "%{$query}%"));
|
||||
}
|
||||
|
||||
return $items->get()->map(fn (Item $item): array => ['id' => $item->id, 'name' => $item->name, 'description' => $item->description, 'price' => $item->price])->all();
|
||||
}
|
||||
|
||||
public function rankItems(int $companyId, string $metric, ?string $startDate, ?string $endDate, int $limit): array
|
||||
{
|
||||
$items = InvoiceItem::query()->join('invoices', 'invoice_items.invoice_id', '=', 'invoices.id')->where('invoices.company_id', $companyId)->whereNotNull('invoice_items.item_id')->selectRaw('invoice_items.item_id, SUM(invoice_items.quantity) as total_quantity, SUM(invoice_items.total) as total_revenue')->groupBy('invoice_items.item_id')->orderByDesc($metric === 'revenue' ? 'total_revenue' : 'total_quantity')->limit($limit);
|
||||
|
||||
if ($startDate !== null && $endDate !== null) {
|
||||
$items->whereBetween('invoices.invoice_date', [$startDate, $endDate]);
|
||||
}
|
||||
|
||||
$rows = $items->get();
|
||||
$catalog = Item::query()->where('company_id', $companyId)->whereIn('id', $rows->pluck('item_id'))->get()->keyBy('id');
|
||||
|
||||
return $rows->map(fn ($row): array => ['item_id' => (int) $row->item_id, 'name' => $catalog->get($row->item_id)?->name, 'quantity_sold' => (float) $row->total_quantity, 'revenue' => (float) $row->total_revenue])->all();
|
||||
}
|
||||
|
||||
private function customerInvoiceRanking(int $companyId, ?string $startDate, ?string $endDate, int $limit, string $aggregate): Collection
|
||||
{
|
||||
$invoices = Invoice::query()->where('company_id', $companyId)->whereNotNull('customer_id')->selectRaw("customer_id, {$aggregate} as metric_value, COUNT(*) as invoice_count")->groupBy('customer_id')->orderByDesc('metric_value')->limit($limit);
|
||||
|
||||
if ($startDate !== null && $endDate !== null) {
|
||||
$invoices->whereBetween('invoice_date', [$startDate, $endDate]);
|
||||
}
|
||||
|
||||
return $invoices->get();
|
||||
}
|
||||
|
||||
private function customerPaymentRanking(int $companyId, ?string $startDate, ?string $endDate, int $limit): Collection
|
||||
{
|
||||
$payments = Payment::query()->where('company_id', $companyId)->whereNotNull('customer_id')->selectRaw('customer_id, SUM(amount) as metric_value')->groupBy('customer_id')->orderByDesc('metric_value')->limit($limit);
|
||||
|
||||
if ($startDate !== null && $endDate !== null) {
|
||||
$payments->whereBetween('payment_date', [$startDate, $endDate]);
|
||||
}
|
||||
|
||||
return $payments->get();
|
||||
}
|
||||
|
||||
private function invoiceSummary(Invoice $invoice): array
|
||||
{
|
||||
return [
|
||||
'id' => $invoice->id,
|
||||
'invoice_number' => $invoice->invoice_number,
|
||||
'customer_id' => $invoice->customer_id,
|
||||
'customer_name' => $invoice->customer?->name,
|
||||
'invoice_date' => $this->date($invoice->invoice_date),
|
||||
'due_date' => $this->date($invoice->due_date),
|
||||
'status' => $invoice->status,
|
||||
'paid_status' => $invoice->paid_status,
|
||||
'total' => $invoice->total,
|
||||
'due_amount' => $invoice->due_amount,
|
||||
'overdue' => (bool) $invoice->overdue,
|
||||
];
|
||||
}
|
||||
|
||||
private function address(?Address $address): ?array
|
||||
{
|
||||
if ($address === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $address->only(['id', 'name', 'address_street_1', 'address_street_2', 'city', 'state', 'country_id', 'zip', 'phone', 'fax', 'type']);
|
||||
}
|
||||
|
||||
private function date(mixed $value): ?string
|
||||
{
|
||||
if ($value === null || $value === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $value instanceof Carbon ? $value->toDateString() : substr((string) $value, 0, 10);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
namespace App\Platform\Modules\Infrastructure;
|
||||
|
||||
use App\Domains\Accounts\Models\CompanySetting;
|
||||
use App\Platform\Operations\Models\Setting;
|
||||
use InvoiceShelf\Modules\Contracts\Host\SettingsStore;
|
||||
|
||||
/**
|
||||
* The module SDK intentionally deals in opaque values. Encryption and other
|
||||
* value transformations remain the responsibility of the calling module.
|
||||
*/
|
||||
class EloquentHostSettingsStore implements SettingsStore
|
||||
{
|
||||
public function getGlobal(string $key, mixed $default = null): mixed
|
||||
{
|
||||
return Setting::getSetting($key) ?? $default;
|
||||
}
|
||||
|
||||
public function putGlobal(string $key, mixed $value): void
|
||||
{
|
||||
Setting::setSetting($key, $value);
|
||||
}
|
||||
|
||||
public function deleteGlobal(string $key): void
|
||||
{
|
||||
Setting::query()->where('option', $key)->delete();
|
||||
}
|
||||
|
||||
public function getCompany(int $companyId, string $key, mixed $default = null): mixed
|
||||
{
|
||||
return CompanySetting::getSetting($key, $companyId) ?? $default;
|
||||
}
|
||||
|
||||
public function putCompany(int $companyId, string $key, mixed $value): void
|
||||
{
|
||||
CompanySetting::setSettings([$key => $value], $companyId);
|
||||
}
|
||||
|
||||
public function deleteCompany(int $companyId, string $key): void
|
||||
{
|
||||
CompanySetting::query()
|
||||
->where('company_id', $companyId)
|
||||
->where('option', $key)
|
||||
->delete();
|
||||
}
|
||||
|
||||
public function deleteCompanyForAll(string $key): void
|
||||
{
|
||||
CompanySetting::query()->where('option', $key)->delete();
|
||||
}
|
||||
}
|
||||
@@ -5,16 +5,25 @@ namespace App\Platform\Modules;
|
||||
use App\Platform\Modules\Console\InstallModuleCommand;
|
||||
use App\Platform\Modules\Console\UninstallModuleCommand;
|
||||
use App\Platform\Modules\Contracts\ModuleSettingsStore;
|
||||
use App\Platform\Modules\Infrastructure\BouncerModuleAuthorization;
|
||||
use App\Platform\Modules\Infrastructure\EloquentCompanyDataReader;
|
||||
use App\Platform\Modules\Infrastructure\EloquentHostSettingsStore;
|
||||
use App\Platform\Modules\Infrastructure\EloquentModuleSettingsStore;
|
||||
use App\Platform\Modules\Policies\ModulePolicy;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
use InvoiceShelf\Modules\Contracts\Host\CompanyDataReader;
|
||||
use InvoiceShelf\Modules\Contracts\Host\ModuleAuthorization;
|
||||
use InvoiceShelf\Modules\Contracts\Host\SettingsStore;
|
||||
|
||||
class ModuleServiceProvider extends ServiceProvider
|
||||
{
|
||||
public function register(): void
|
||||
{
|
||||
$this->app->bind(ModuleSettingsStore::class, EloquentModuleSettingsStore::class);
|
||||
$this->app->bind(SettingsStore::class, EloquentHostSettingsStore::class);
|
||||
$this->app->bind(ModuleAuthorization::class, BouncerModuleAuthorization::class);
|
||||
$this->app->bind(CompanyDataReader::class, EloquentCompanyDataReader::class);
|
||||
}
|
||||
|
||||
public function boot(): void
|
||||
|
||||
@@ -9,7 +9,6 @@ use App\Domains\Accounts\Models\Company;
|
||||
use App\Domains\Accounts\Models\CompanyInvitation;
|
||||
use App\Domains\Accounts\Models\CompanySetting;
|
||||
use App\Domains\Money\Models\Currency;
|
||||
use App\Platform\Ai\Application\AiConfigurationService;
|
||||
use App\Platform\Http\Controller;
|
||||
use App\Platform\Modules\Models\Module;
|
||||
use App\Platform\Operations\Http\Concerns\GeneratesMenu;
|
||||
@@ -121,8 +120,6 @@ class BootstrapController extends Controller
|
||||
|
||||
BouncerFacade::refreshFor($current_user);
|
||||
|
||||
$aiResolved = app(AiConfigurationService::class)->resolveForCompany($current_company->id);
|
||||
|
||||
return response()->json([
|
||||
'current_user' => new UserResource($current_user),
|
||||
'current_user_settings' => $current_user_settings,
|
||||
@@ -133,11 +130,6 @@ class BootstrapController extends Controller
|
||||
'current_company_currency' => $current_company_currency,
|
||||
'config' => config('invoiceshelf'),
|
||||
'global_settings' => $global_settings,
|
||||
'ai' => [
|
||||
'enabled' => $aiResolved !== null,
|
||||
'chat_enabled' => (bool) ($aiResolved['chat_enabled'] ?? false),
|
||||
'text_generation_enabled' => (bool) ($aiResolved['text_generation_enabled'] ?? false),
|
||||
],
|
||||
'main_menu' => $main_menu,
|
||||
'setting_menu' => $setting_menu,
|
||||
'modules' => Module::where('enabled', true)->pluck('name'),
|
||||
|
||||
@@ -34,8 +34,6 @@ use App\Domains\Sales\Models\InvoiceItem;
|
||||
use App\Domains\Sales\Models\RecurringInvoice;
|
||||
use App\Domains\Taxation\Models\Tax;
|
||||
use App\Domains\Taxation\Models\TaxType;
|
||||
use App\Platform\Ai\Models\AiConversation;
|
||||
use App\Platform\Ai\Models\AiMessage;
|
||||
use App\Platform\Mail\Models\EmailLog;
|
||||
use App\Platform\Modules\Models\MarketplaceCredential;
|
||||
use App\Platform\Modules\Models\MarketplaceOperation;
|
||||
@@ -70,8 +68,6 @@ final class ModelIdentityMap
|
||||
{
|
||||
return [
|
||||
'address' => Address::class,
|
||||
'ai_conversation' => AiConversation::class,
|
||||
'ai_message' => AiMessage::class,
|
||||
'company' => Company::class,
|
||||
'company_invitation' => CompanyInvitation::class,
|
||||
'company_setting' => CompanySetting::class,
|
||||
|
||||
@@ -10,7 +10,6 @@ use App\Domains\Receivables\ReceivablesServiceProvider;
|
||||
use App\Domains\Reporting\ReportingServiceProvider;
|
||||
use App\Domains\Sales\SalesServiceProvider;
|
||||
use App\Domains\Taxation\TaxationServiceProvider;
|
||||
use App\Platform\Ai\AiServiceProvider;
|
||||
use App\Platform\Mail\MailServiceProvider;
|
||||
use App\Platform\Modules\ModuleServiceProvider;
|
||||
use App\Platform\Operations\OperationsServiceProvider;
|
||||
@@ -39,7 +38,6 @@ return [
|
||||
SalesServiceProvider::class,
|
||||
TaxationServiceProvider::class,
|
||||
ReportingServiceProvider::class,
|
||||
AiServiceProvider::class,
|
||||
MailServiceProvider::class,
|
||||
OperationsServiceProvider::class,
|
||||
ModuleServiceProvider::class,
|
||||
|
||||
+1
-1
@@ -14,7 +14,7 @@
|
||||
"gotenberg/gotenberg-php": "^2.8",
|
||||
"guzzlehttp/guzzle": "^7.9",
|
||||
"hashids/hashids": "^5.0",
|
||||
"invoiceshelf/modules": "^3.2",
|
||||
"invoiceshelf/modules": "^3.3",
|
||||
"laravel/framework": "^13.0",
|
||||
"laravel/helpers": "^1.7",
|
||||
"laravel/sanctum": "^4.0",
|
||||
|
||||
Generated
+7
-7
@@ -4,7 +4,7 @@
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "39311493e2ff6efae8c6be98b23a5687",
|
||||
"content-hash": "c72ff845827581c6cc675c52d10509d3",
|
||||
"packages": [
|
||||
{
|
||||
"name": "aws/aws-crt-php",
|
||||
@@ -1735,16 +1735,16 @@
|
||||
},
|
||||
{
|
||||
"name": "invoiceshelf/modules",
|
||||
"version": "3.2.0",
|
||||
"version": "3.3.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/InvoiceShelf/modules.git",
|
||||
"reference": "bd6ae29a25bdbe3f395572b95c59843dcc52ac50"
|
||||
"reference": "87f33519e14df47314dc8bcb7abbd26fd14976eb"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/InvoiceShelf/modules/zipball/bd6ae29a25bdbe3f395572b95c59843dcc52ac50",
|
||||
"reference": "bd6ae29a25bdbe3f395572b95c59843dcc52ac50",
|
||||
"url": "https://api.github.com/repos/InvoiceShelf/modules/zipball/87f33519e14df47314dc8bcb7abbd26fd14976eb",
|
||||
"reference": "87f33519e14df47314dc8bcb7abbd26fd14976eb",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -1796,10 +1796,10 @@
|
||||
"modules"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/InvoiceShelf/modules/tree/3.2.0",
|
||||
"source": "https://github.com/InvoiceShelf/modules/tree/3.3.0",
|
||||
"issues": "https://github.com/InvoiceShelf/modules/issues"
|
||||
},
|
||||
"time": "2026-08-05T09:35:08+00:00"
|
||||
"time": "2026-08-05T19:47:16+00:00"
|
||||
},
|
||||
{
|
||||
"name": "laravel/framework",
|
||||
|
||||
+1
-11
@@ -67,7 +67,7 @@ return [
|
||||
*/
|
||||
'marketplace' => [
|
||||
'channel' => env('MARKETPLACE_CHANNEL', 'stable'),
|
||||
'module_api_version' => (string) env('MARKETPLACE_MODULE_API_VERSION', '1.1.0'),
|
||||
'module_api_version' => (string) env('MARKETPLACE_MODULE_API_VERSION', '1.2.0'),
|
||||
// JSON object: {"key-id":"base64-ed25519-public-key"}. Keys add to
|
||||
// (or replace values in) the built-in pinned map. Key identity is part
|
||||
// of the signed release and must match this trusted map.
|
||||
@@ -317,16 +317,6 @@ return [
|
||||
'ability' => '',
|
||||
'model' => '',
|
||||
],
|
||||
[
|
||||
'title' => 'settings.menu_title.ai_configuration',
|
||||
'group' => '',
|
||||
'name' => 'AI Configuration',
|
||||
'link' => '/admin/settings/ai-config',
|
||||
'icon' => 'SparklesIcon',
|
||||
'owner_only' => true,
|
||||
'ability' => '',
|
||||
'model' => '',
|
||||
],
|
||||
[
|
||||
'title' => 'settings.menu_title.module_configuration',
|
||||
'group' => '',
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
<?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->unsignedInteger('company_id');
|
||||
$table->unsignedInteger('user_id');
|
||||
$table->string('title')->nullable();
|
||||
$table->string('model', 100)->nullable();
|
||||
$table->timestamps();
|
||||
|
||||
// List my conversations, most recently updated first
|
||||
$table->index(['company_id', 'user_id', 'updated_at']);
|
||||
});
|
||||
|
||||
Schema::create('ai_messages', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->foreignId('conversation_id')
|
||||
->constrained('ai_conversations')
|
||||
->cascadeOnDelete();
|
||||
|
||||
// OpenAI chat message roles. Persisted as string (not enum) so future
|
||||
// roles don't require a migration — the application layer validates.
|
||||
$table->string('role', 20);
|
||||
|
||||
$table->longText('content')->nullable();
|
||||
|
||||
// For role=tool messages: which tool_call_id from the assistant turn this answers.
|
||||
$table->string('tool_call_id')->nullable();
|
||||
|
||||
// For role=assistant messages that requested tool execution: the parsed tool_calls array.
|
||||
$table->json('tool_calls')->nullable();
|
||||
|
||||
// Which model produced this turn (nullable for user/tool messages).
|
||||
$table->string('model', 100)->nullable();
|
||||
|
||||
// For future cost tracking dashboards.
|
||||
$table->unsignedInteger('tokens_in')->nullable();
|
||||
$table->unsignedInteger('tokens_out')->nullable();
|
||||
|
||||
$table->timestamp('created_at')->useCurrent();
|
||||
|
||||
$table->index(['conversation_id', 'created_at']);
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('ai_messages');
|
||||
Schema::dropIfExists('ai_conversations');
|
||||
}
|
||||
};
|
||||
@@ -31,8 +31,6 @@ return new class extends Migration
|
||||
*/
|
||||
public const FIRST_PARTY_ALIASES = [
|
||||
'address' => 'Address',
|
||||
'ai_conversation' => 'AiConversation',
|
||||
'ai_message' => 'AiMessage',
|
||||
'company' => 'Company',
|
||||
'company_invitation' => 'CompanyInvitation',
|
||||
'company_setting' => 'CompanySetting',
|
||||
|
||||
@@ -29,7 +29,6 @@ use App\Domains\Sales\Models\RecurringInvoice;
|
||||
use App\Domains\Taxation\Models\Tax;
|
||||
use App\Domains\Taxation\Models\TaxType;
|
||||
use App\Facades\Hashids;
|
||||
use App\Platform\Ai\Models\AiConversation;
|
||||
use App\Support\Hashids\HashidConnection;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Database\Seeder;
|
||||
@@ -42,8 +41,7 @@ use RuntimeException;
|
||||
* Populates the demo company with ~100 realistic records (8 customers, 12
|
||||
* catalog items, 6 expense categories, 35 invoices, ~20 payments, 8 estimates,
|
||||
* 15 expenses, 2 tax types, a notes library and a recurring invoice) so the app
|
||||
* looks like a real install during local development, and so the AI chat
|
||||
* assistant has meaningful data to query.
|
||||
* looks like a real install during local development.
|
||||
*
|
||||
* This seeder is intentionally NOT wired into DatabaseSeeder and is NOT used
|
||||
* by the test suite (the minimal DemoSeeder remains in the test path to keep
|
||||
@@ -67,9 +65,8 @@ use RuntimeException;
|
||||
* - Item prices and all monetary columns are stored in **cents**. A $250
|
||||
* item has `price = 25000`. The frontend divides by 100 for display.
|
||||
*
|
||||
* - Dates are deliberately distributed over the last 6 months so that
|
||||
* AI tool queries like `get_company_stats(period=this_month)` vs
|
||||
* `get_company_stats(period=last_month)` return different numbers.
|
||||
* - Dates are deliberately distributed over the last 6 months to make the
|
||||
* reporting views useful during local development.
|
||||
*
|
||||
* - Invoice totals are computed from line items, not random. Tax is applied
|
||||
* at document level (tax_per_item = 'NO') to most but not all documents,
|
||||
@@ -241,7 +238,6 @@ class RealisticDemoSeeder extends Seeder
|
||||
*/
|
||||
private function cleanupExistingDemoData(): void
|
||||
{
|
||||
AiConversation::where('company_id', $this->companyId)->delete(); // cascades to ai_messages
|
||||
$paymentIds = Payment::where('company_id', $this->companyId)->pluck('id');
|
||||
PaymentAllocation::whereIn('payment_id', $paymentIds)->delete();
|
||||
Payment::whereIn('id', $paymentIds)->delete();
|
||||
|
||||
+1
-70
@@ -1000,8 +1000,7 @@
|
||||
"address_information": "Address Information",
|
||||
"pdf_generation": "PDF Generation",
|
||||
"appearance": "Appearance",
|
||||
"module_configuration": "Module Configuration",
|
||||
"ai_configuration": "AI Configuration"
|
||||
"module_configuration": "Module Configuration"
|
||||
},
|
||||
"appearance": {
|
||||
"title": "Appearance",
|
||||
@@ -1009,49 +1008,6 @@
|
||||
"sidebar_group_labels": "Show sidebar group labels",
|
||||
"sidebar_group_labels_desc": "Display section headers like Documents, Administration, and Modules in the sidebar navigation."
|
||||
},
|
||||
"ai": {
|
||||
"title": "AI Configuration",
|
||||
"description": "Configure the AI provider used for chat assistance and text generation. AI is opt-in — leave disabled if you don't want these features.",
|
||||
"openrouter": "OpenRouter",
|
||||
"enable": "Enable AI features",
|
||||
"enable_help": "When disabled, the AI chat drawer and WYSIWYG text-generation button are hidden everywhere in the app.",
|
||||
"driver": "AI Provider",
|
||||
"api_key": "API Key",
|
||||
"api_key_help": "Your API key is encrypted before being stored.",
|
||||
"base_url": "Base URL",
|
||||
"base_url_help": "Leave blank to use the provider's default endpoint.",
|
||||
"roles": "AI Roles",
|
||||
"roles_help": "Pick which AI capabilities are available. Each role uses a specific model.",
|
||||
"chat": "Chat Assistant",
|
||||
"chat_help": "Natural-language Q&A over your company's data via tool-calling.",
|
||||
"chat_model": "Chat model",
|
||||
"text_generation": "Text Generation",
|
||||
"text_generation_help": "One-shot text generation for invoice notes and email bodies.",
|
||||
"text_generation_model": "Text generation model",
|
||||
"suggested_models": "Suggested models",
|
||||
"test_connection": "Test Connection",
|
||||
"test_success": "Connection successful.",
|
||||
"test_failed": "Connection test failed: {error}",
|
||||
"saved": "AI configuration saved successfully.",
|
||||
"use_custom_ai_config": "Use custom AI configuration",
|
||||
"use_custom_ai_config_desc": "Enable this to override the global AI configuration for this company.",
|
||||
"using_global_ai_config": "This company is using the global AI configuration. Enable the toggle above to configure a custom provider.",
|
||||
"company_enabled": "AI enabled for this company",
|
||||
"company_enabled_desc": "Turn off to disable all AI features for this company regardless of the global setting.",
|
||||
"installer_title": "AI Assistant",
|
||||
"installer_description": "Optionally enable AI chat and text generation now. You can change this later in Admin → Settings → AI Configuration.",
|
||||
"errors": {
|
||||
"invalid_key": "The API key is invalid.",
|
||||
"rate_limited": "The provider rate limit was hit. Please try again shortly.",
|
||||
"server_error": "The AI provider returned an error. Check your configuration and try again.",
|
||||
"model_not_found": "The requested model is not available on this provider.",
|
||||
"missing_api_key": "An API key is required to test the connection.",
|
||||
"ai_disabled": "AI is not enabled for this company.",
|
||||
"chat_disabled": "The chat assistant is not enabled for this company.",
|
||||
"text_generation_disabled": "Text generation is not enabled for this company.",
|
||||
"missing_model": "No model is configured."
|
||||
}
|
||||
},
|
||||
"address_information": {
|
||||
"section_description": " You can update Your Address information using form below."
|
||||
},
|
||||
@@ -2039,30 +1995,5 @@
|
||||
"impersonating_banner": "You are currently impersonating a user. All actions are logged.",
|
||||
"stop_impersonating": "Stop Impersonating"
|
||||
}
|
||||
},
|
||||
"ai": {
|
||||
"chat": {
|
||||
"title": "AI Assistant",
|
||||
"new_conversation": "New conversation",
|
||||
"no_conversations": "No conversations yet.",
|
||||
"untitled": "Untitled",
|
||||
"empty_state": "Ask me anything about your invoices, customers, payments, or expenses.",
|
||||
"thinking": "Thinking…",
|
||||
"send": "Send",
|
||||
"sending": "Sending…",
|
||||
"input_placeholder": "Ask about your invoices, customers, or payments…"
|
||||
},
|
||||
"generate": {
|
||||
"title": "AI Text Generation",
|
||||
"prompt_label": "What should the AI write?",
|
||||
"prompt_placeholder": "e.g. a polite late-payment reminder for an invoice that's 10 days overdue",
|
||||
"use_current_as_context": "Use current content as context",
|
||||
"use_context_help": "When enabled, the AI will see the editor's current text and can rewrite or extend it.",
|
||||
"preview": "Preview",
|
||||
"generate": "Generate",
|
||||
"regenerate": "Regenerate",
|
||||
"insert": "Insert",
|
||||
"replace": "Replace"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,7 +51,6 @@
|
||||
"dompurify": "^3.4.9",
|
||||
"laravel-vite-plugin": "^3.0.0",
|
||||
"lodash": "^4.17.21",
|
||||
"marked": "^18.0.5",
|
||||
"pinia": "^3.0.0",
|
||||
"v-money3": "^3.24.1",
|
||||
"v-tooltip": "^4.0.0-beta.17",
|
||||
|
||||
Generated
-10
@@ -63,9 +63,6 @@ importers:
|
||||
lodash:
|
||||
specifier: ^4.17.21
|
||||
version: 4.18.1
|
||||
marked:
|
||||
specifier: ^18.0.5
|
||||
version: 18.0.5
|
||||
pinia:
|
||||
specifier: ^3.0.0
|
||||
version: 3.0.4(typescript@6.0.2)(vue@3.5.31(typescript@6.0.2))
|
||||
@@ -1313,11 +1310,6 @@ packages:
|
||||
resolution: {integrity: sha512-BuU2qnTti9YKgK5N+IeMubp14ZUKUUw7yeJbkjtosvHiP0AZ5c8IAgEMk79D0eC8F23r4Ac/q8cAIFdm2FtyoA==}
|
||||
hasBin: true
|
||||
|
||||
marked@18.0.5:
|
||||
resolution: {integrity: sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w==}
|
||||
engines: {node: '>= 20'}
|
||||
hasBin: true
|
||||
|
||||
math-intrinsics@1.1.0:
|
||||
resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==}
|
||||
engines: {node: '>= 0.4'}
|
||||
@@ -2929,8 +2921,6 @@ snapshots:
|
||||
punycode.js: 2.3.1
|
||||
uc.micro: 2.1.0
|
||||
|
||||
marked@18.0.5: {}
|
||||
|
||||
math-intrinsics@1.1.0: {}
|
||||
|
||||
mdurl@2.0.0: {}
|
||||
|
||||
-1361
File diff suppressed because it is too large
Load Diff
@@ -3,18 +3,37 @@ import type { App } from 'vue'
|
||||
import type { Router } from 'vue-router'
|
||||
import App_ from './App.vue'
|
||||
import router from './router'
|
||||
import { createAppI18n, setI18nLanguage } from './plugins/i18n'
|
||||
import { createAppI18n, mergeMessageObjects, setI18nLanguage } from './plugins/i18n'
|
||||
import type { AppI18n } from './plugins/i18n'
|
||||
import { createAppPinia } from './plugins/pinia'
|
||||
import { installTooltipDirective } from './plugins/tooltip'
|
||||
import { defineGlobalComponents } from './global-components'
|
||||
import { createExtensionApi } from './extensions/runtime'
|
||||
import type { InvoiceShelfExtensionApi } from './extensions/types'
|
||||
|
||||
export type {
|
||||
BootstrapCompletedEvent,
|
||||
CompanyChangeEvent,
|
||||
ComponentExtensionContribution,
|
||||
ExtensionContribution,
|
||||
ExtensionVisibilityPredicate,
|
||||
InvoiceShelfExtensionApi,
|
||||
InvoiceShelfExtensionEvents,
|
||||
RichEditorContext,
|
||||
SettingsNavigationContribution,
|
||||
SettingsPageContribution,
|
||||
} from './extensions/types'
|
||||
|
||||
/**
|
||||
* Callback signature for the `booting` hook.
|
||||
* Receives the Vue app instance and the router so that modules /
|
||||
* plugins can register additional routes, components, or providers.
|
||||
*/
|
||||
type BootCallback = (app: App, router: Router) => void
|
||||
export type BootCallback = (
|
||||
app: App,
|
||||
router: Router,
|
||||
extensions: InvoiceShelfExtensionApi,
|
||||
) => void
|
||||
|
||||
/**
|
||||
* Bootstrap class for InvoiceShelf.
|
||||
@@ -31,9 +50,12 @@ export default class InvoiceShelf {
|
||||
private messages: Record<string, Record<string, unknown>> = {}
|
||||
private i18n: AppI18n | null = null
|
||||
private app: App
|
||||
private readonly extensions: InvoiceShelfExtensionApi
|
||||
|
||||
constructor() {
|
||||
this.app = createApp(App_)
|
||||
this.extensions = createExtensionApi(router)
|
||||
window.addEventListener('pagehide', () => this.extensions.reset(), { once: true })
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -47,11 +69,9 @@ export default class InvoiceShelf {
|
||||
* Merge additional i18n message bundles (typically from modules).
|
||||
*/
|
||||
addMessages(moduleMessages: Record<string, Record<string, unknown>>): void {
|
||||
this.extensions.addMessages(moduleMessages)
|
||||
for (const [locale, msgs] of Object.entries(moduleMessages)) {
|
||||
this.messages[locale] = {
|
||||
...this.messages[locale],
|
||||
...msgs,
|
||||
}
|
||||
this.messages[locale] = mergeMessageObjects(this.messages[locale] ?? {}, msgs)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -106,7 +126,7 @@ export default class InvoiceShelf {
|
||||
|
||||
private executeCallbacks(): void {
|
||||
for (const callback of this.bootingCallbacks) {
|
||||
callback(this.app, router)
|
||||
callback(this.app, router, this.extensions)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -116,25 +116,6 @@ export const API = {
|
||||
COMPANY_MAIL_CONFIG: '/api/v1/company/mail/company-config',
|
||||
COMPANY_MAIL_TEST: '/api/v1/company/mail/company-test',
|
||||
|
||||
// AI Configuration (global)
|
||||
AI_DRIVERS: '/api/v1/ai/drivers',
|
||||
AI_CONFIG: '/api/v1/ai/config',
|
||||
AI_TEST: '/api/v1/ai/test',
|
||||
|
||||
// Company AI Configuration
|
||||
COMPANY_AI_CONFIG: '/api/v1/company/ai/config',
|
||||
COMPANY_AI_TEST: '/api/v1/company/ai/test',
|
||||
|
||||
// Installer AI Configuration
|
||||
INSTALLATION_AI_CONFIG: '/api/v1/installation/ai/config',
|
||||
|
||||
// AI Chat (Phase 2)
|
||||
AI_CHAT: '/api/v1/ai/chat',
|
||||
AI_CONVERSATIONS: '/api/v1/ai/conversations',
|
||||
|
||||
// AI Text Generation (Phase 3)
|
||||
AI_GENERATE: '/api/v1/ai/generate',
|
||||
|
||||
// PDF Configuration
|
||||
PDF_DRIVERS: '/api/v1/pdf/drivers',
|
||||
PDF_CONFIG: '/api/v1/pdf/config',
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
import { client } from '../client'
|
||||
import { API } from '../endpoints'
|
||||
import type {
|
||||
AiChatSendResponse,
|
||||
AiConfig,
|
||||
AiConversationDetail,
|
||||
AiConversationSummary,
|
||||
AiDriversResponse,
|
||||
AiGenerateRequest,
|
||||
AiGenerateResponse,
|
||||
AiTestPayload,
|
||||
AiTestResponse,
|
||||
CompanyAiConfig,
|
||||
} from '@/scripts/types/ai-config'
|
||||
|
||||
export const aiService = {
|
||||
// Driver catalog — same shape across admin, company, installer contexts.
|
||||
async getDrivers(): Promise<AiDriversResponse> {
|
||||
const { data } = await client.get(API.AI_DRIVERS)
|
||||
return data
|
||||
},
|
||||
|
||||
// --- Global (admin) ---
|
||||
|
||||
async getGlobalConfig(): Promise<AiConfig> {
|
||||
const { data } = await client.get(API.AI_CONFIG)
|
||||
return data
|
||||
},
|
||||
|
||||
async saveGlobalConfig(payload: AiConfig): Promise<{ success?: string; error?: string }> {
|
||||
const { data } = await client.post(API.AI_CONFIG, payload)
|
||||
return data
|
||||
},
|
||||
|
||||
async testGlobalConnection(payload: AiTestPayload): Promise<AiTestResponse> {
|
||||
const { data } = await client.post(API.AI_TEST, payload)
|
||||
return data
|
||||
},
|
||||
|
||||
// --- Per-company ---
|
||||
|
||||
async getCompanyConfig(): Promise<CompanyAiConfig> {
|
||||
const { data } = await client.get(API.COMPANY_AI_CONFIG)
|
||||
return data
|
||||
},
|
||||
|
||||
async saveCompanyConfig(payload: CompanyAiConfig): Promise<{ success?: boolean; error?: string }> {
|
||||
const { data } = await client.post(API.COMPANY_AI_CONFIG, payload)
|
||||
return data
|
||||
},
|
||||
|
||||
async testCompanyConnection(payload: AiTestPayload): Promise<AiTestResponse> {
|
||||
const { data } = await client.post(API.COMPANY_AI_TEST, payload)
|
||||
return data
|
||||
},
|
||||
|
||||
// --- 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
|
||||
},
|
||||
|
||||
// --- Phase 3: text generation ---
|
||||
|
||||
async generateText(payload: AiGenerateRequest): Promise<AiGenerateResponse> {
|
||||
const { data } = await client.post(API.AI_GENERATE, payload)
|
||||
return data
|
||||
},
|
||||
}
|
||||
@@ -29,11 +29,6 @@ export interface BootstrapResponse {
|
||||
config: Record<string, unknown>
|
||||
global_settings: Record<string, string>
|
||||
modules: string[]
|
||||
ai?: {
|
||||
enabled: boolean
|
||||
chat_enabled: boolean
|
||||
text_generation_enabled: boolean
|
||||
}
|
||||
user_menu?: Array<{ title: string; link: string; icon: string; priority: number; name: string }>
|
||||
admin_mode?: boolean
|
||||
pending_invitations?: Array<{
|
||||
|
||||
@@ -37,6 +37,10 @@
|
||||
{{ button.text }}
|
||||
</span>
|
||||
</button>
|
||||
<ExtensionSlot
|
||||
name="rich-editor-toolbar-actions"
|
||||
:context="editorContext"
|
||||
/>
|
||||
</div>
|
||||
</BaseDropdown>
|
||||
</div>
|
||||
@@ -58,6 +62,10 @@
|
||||
{{ button.text }}
|
||||
</span>
|
||||
</button>
|
||||
<ExtensionSlot
|
||||
name="rich-editor-toolbar-actions"
|
||||
:context="editorContext"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<editor-content
|
||||
@@ -94,11 +102,10 @@ import {
|
||||
Bars3BottomRightIcon,
|
||||
Bars3Icon,
|
||||
LinkIcon,
|
||||
SparklesIcon,
|
||||
} from '@heroicons/vue/24/solid'
|
||||
import { ContentPlaceholder, ContentPlaceholderBox } from '../layout'
|
||||
import { useGlobalStore } from '@/scripts/stores/global.store'
|
||||
import { useModalStore } from '@/scripts/stores/modal.store'
|
||||
import ExtensionSlot from '@/scripts/extensions/ExtensionSlot.vue'
|
||||
import type { RichEditorContext } from '@/scripts/extensions/types'
|
||||
|
||||
interface EditorButton {
|
||||
name: string
|
||||
@@ -170,39 +177,21 @@ const editorButtons = ref<EditorButton[]>([
|
||||
},
|
||||
])
|
||||
|
||||
// AI text-generation button — shown only when the feature is enabled
|
||||
// for the current company. The flag is set once at bootstrap time so a
|
||||
// one-shot push is fine; no reactivity needed.
|
||||
const globalStore = useGlobalStore()
|
||||
const modalStore = useModalStore()
|
||||
if (globalStore.ai?.enabled && globalStore.ai?.text_generation_enabled) {
|
||||
editorButtons.value.push({
|
||||
name: 'aiGenerate',
|
||||
icon: markRaw(SparklesIcon) as Component,
|
||||
action: () => {
|
||||
modalStore.openModal({
|
||||
componentName: 'AiTextGenerationModal',
|
||||
title: 'AI Text Generation',
|
||||
size: 'md',
|
||||
data: {
|
||||
currentContent: editor.value?.getHTML() ?? '',
|
||||
onInsert: (text: string) => {
|
||||
editor.value?.chain().focus().insertContent(text).run()
|
||||
},
|
||||
onReplace: (text: string) => {
|
||||
editor.value?.chain().focus().selectAll().deleteSelection().insertContent(text).run()
|
||||
},
|
||||
},
|
||||
})
|
||||
},
|
||||
})
|
||||
const editorContext: RichEditorContext = {
|
||||
getHtml: () => editor.value?.getHTML() ?? '',
|
||||
insertContent: (content: string) => {
|
||||
editor.value?.chain().focus().insertContent(content).run()
|
||||
},
|
||||
replaceContent: (content: string) => {
|
||||
editor.value?.chain().focus().selectAll().deleteSelection().insertContent(content).run()
|
||||
},
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
(newValue: string) => {
|
||||
if (editor.value && newValue !== editor.value.getHTML()) {
|
||||
editor.value.commands.setContent(newValue, false)
|
||||
editor.value.commands.setContent(newValue, { emitUpdate: false })
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { extensionRegistry, extensionItems } from './runtime'
|
||||
import type { RichEditorContext } from './types'
|
||||
|
||||
const props = defineProps<{
|
||||
name: 'header-actions' | 'company-layout-overlays' | 'rich-editor-toolbar-actions'
|
||||
context?: RichEditorContext
|
||||
}>()
|
||||
|
||||
const contributions = computed(() => {
|
||||
const items = {
|
||||
'header-actions': extensionRegistry.headerActions.value,
|
||||
'company-layout-overlays': extensionRegistry.companyLayoutOverlays.value,
|
||||
'rich-editor-toolbar-actions': extensionRegistry.richEditorToolbarActions.value,
|
||||
}[props.name]
|
||||
|
||||
return extensionItems(items)
|
||||
})
|
||||
|
||||
function componentProps(props_: Record<string, unknown> | undefined): Record<string, unknown> {
|
||||
return props.context === undefined
|
||||
? (props_ ?? {})
|
||||
: { ...props_, context: props.context }
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<component
|
||||
:is="contribution.component"
|
||||
v-for="contribution in contributions"
|
||||
:key="contribution.id"
|
||||
v-bind="componentProps(contribution.props)"
|
||||
/>
|
||||
</template>
|
||||
@@ -0,0 +1,265 @@
|
||||
import { markRaw, shallowRef } from 'vue'
|
||||
import type { ShallowRef } from 'vue'
|
||||
import type { Router } from 'vue-router'
|
||||
import { client } from '@/scripts/api/client'
|
||||
import { useNotificationStore } from '@/scripts/stores/notification.store'
|
||||
import { registerAdditionalMessages } from '@/scripts/plugins/i18n'
|
||||
import type {
|
||||
BootstrapCompletedEvent,
|
||||
CompanyChangeEvent,
|
||||
ComponentExtensionContribution,
|
||||
InvoiceShelfExtensionApi,
|
||||
InvoiceShelfExtensionEvents,
|
||||
SettingsNavigationContribution,
|
||||
SettingsPageContribution,
|
||||
} from './types'
|
||||
|
||||
type ComponentSlot =
|
||||
| 'headerActions'
|
||||
| 'companyLayoutOverlays'
|
||||
| 'richEditorToolbarActions'
|
||||
|
||||
interface RegisteredComponentContribution extends ComponentExtensionContribution {
|
||||
component: ComponentExtensionContribution['component']
|
||||
}
|
||||
|
||||
function comparePriority<T extends { priority?: number; id: string }>(a: T, b: T): number {
|
||||
return (a.priority ?? 100) - (b.priority ?? 100) || a.id.localeCompare(b.id)
|
||||
}
|
||||
|
||||
function assertContributionId(id: string): void {
|
||||
if (!id.trim()) {
|
||||
throw new Error('InvoiceShelf extension contributions require a stable id.')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Host-owned reactive registry. Modules only receive the public API below,
|
||||
* never the host's Pinia stores or layout implementation.
|
||||
*/
|
||||
export class ExtensionRegistry {
|
||||
readonly headerActions = shallowRef<RegisteredComponentContribution[]>([])
|
||||
readonly companyLayoutOverlays = shallowRef<RegisteredComponentContribution[]>([])
|
||||
readonly richEditorToolbarActions = shallowRef<RegisteredComponentContribution[]>([])
|
||||
readonly companySettingsNavigation = shallowRef<SettingsNavigationContribution[]>([])
|
||||
readonly adminSettingsNavigation = shallowRef<SettingsNavigationContribution[]>([])
|
||||
|
||||
private readonly teardowns = new Set<() => void>()
|
||||
|
||||
registerComponent(
|
||||
slot: ComponentSlot,
|
||||
contribution: ComponentExtensionContribution,
|
||||
): () => void {
|
||||
assertContributionId(contribution.id)
|
||||
const target = this[slot] as ShallowRef<RegisteredComponentContribution[]>
|
||||
const entry: RegisteredComponentContribution = {
|
||||
...contribution,
|
||||
component: markRaw(contribution.component),
|
||||
}
|
||||
|
||||
return this.track(() => {
|
||||
target.value = [...target.value.filter((item) => item.id !== entry.id), entry]
|
||||
.sort(comparePriority)
|
||||
|
||||
return () => {
|
||||
target.value = target.value.filter((item) => item !== entry)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
registerNavigation(
|
||||
slot: 'companySettingsNavigation' | 'adminSettingsNavigation',
|
||||
contribution: SettingsNavigationContribution,
|
||||
): () => void {
|
||||
assertContributionId(contribution.id)
|
||||
const target = this[slot] as ShallowRef<SettingsNavigationContribution[]>
|
||||
const entry = { ...contribution }
|
||||
|
||||
return this.track(() => {
|
||||
target.value = [...target.value.filter((item) => item.id !== entry.id), entry]
|
||||
.sort(comparePriority)
|
||||
|
||||
return () => {
|
||||
target.value = target.value.filter((item) => item !== entry)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
for (const teardown of [...this.teardowns]) {
|
||||
teardown()
|
||||
}
|
||||
}
|
||||
|
||||
trackTeardown(unregister: () => void): () => void {
|
||||
return this.track(() => unregister)
|
||||
}
|
||||
|
||||
private track(register: () => () => void): () => void {
|
||||
const unregister = register()
|
||||
let active = true
|
||||
const teardown = () => {
|
||||
if (!active) return
|
||||
active = false
|
||||
unregister()
|
||||
this.teardowns.delete(teardown)
|
||||
}
|
||||
|
||||
this.teardowns.add(teardown)
|
||||
return teardown
|
||||
}
|
||||
}
|
||||
|
||||
export const extensionRegistry = new ExtensionRegistry()
|
||||
|
||||
class ExtensionApi implements InvoiceShelfExtensionApi {
|
||||
private readonly listeners = new Map<
|
||||
keyof InvoiceShelfExtensionEvents,
|
||||
Set<(payload: unknown) => void>
|
||||
>()
|
||||
private readonly settingsPageTeardowns = new Map<string, () => void>()
|
||||
|
||||
constructor(readonly router: Router) {}
|
||||
|
||||
readonly client = client
|
||||
|
||||
registerHeaderAction(contribution: ComponentExtensionContribution): () => void {
|
||||
return extensionRegistry.registerComponent('headerActions', contribution)
|
||||
}
|
||||
|
||||
registerCompanyLayoutOverlay(contribution: ComponentExtensionContribution): () => void {
|
||||
return extensionRegistry.registerComponent('companyLayoutOverlays', contribution)
|
||||
}
|
||||
|
||||
registerRichEditorToolbarAction(contribution: ComponentExtensionContribution): () => void {
|
||||
return extensionRegistry.registerComponent('richEditorToolbarActions', contribution)
|
||||
}
|
||||
|
||||
registerCompanySettingsNavigation(contribution: SettingsNavigationContribution): () => void {
|
||||
return extensionRegistry.registerNavigation('companySettingsNavigation', contribution)
|
||||
}
|
||||
|
||||
registerAdminSettingsNavigation(contribution: SettingsNavigationContribution): () => void {
|
||||
return extensionRegistry.registerNavigation('adminSettingsNavigation', contribution)
|
||||
}
|
||||
|
||||
registerCompanySettingsPage(contribution: SettingsPageContribution): () => void {
|
||||
return this.registerSettingsPage('settings', 'companySettingsNavigation', contribution)
|
||||
}
|
||||
|
||||
registerAdminSettingsPage(contribution: SettingsPageContribution): () => void {
|
||||
return this.registerSettingsPage('admin.settings', 'adminSettingsNavigation', contribution)
|
||||
}
|
||||
|
||||
addMessages(messages: Record<string, Record<string, unknown>>): void {
|
||||
registerAdditionalMessages(messages)
|
||||
}
|
||||
|
||||
notify(type: 'success' | 'error' | 'warning' | 'info', message: string): void {
|
||||
useNotificationStore().showNotification({ type, message })
|
||||
}
|
||||
|
||||
on<EventName extends keyof InvoiceShelfExtensionEvents>(
|
||||
event: EventName,
|
||||
listener: (payload: InvoiceShelfExtensionEvents[EventName]) => void,
|
||||
): () => void {
|
||||
const listeners = this.listeners.get(event) ?? new Set<(payload: unknown) => void>()
|
||||
this.listeners.set(event, listeners)
|
||||
listeners.add(listener as (payload: unknown) => void)
|
||||
return () => listeners.delete(listener as (payload: unknown) => void)
|
||||
}
|
||||
|
||||
emit<EventName extends keyof InvoiceShelfExtensionEvents>(
|
||||
event: EventName,
|
||||
payload: InvoiceShelfExtensionEvents[EventName],
|
||||
): void {
|
||||
for (const listener of this.listeners.get(event) ?? []) {
|
||||
listener(payload)
|
||||
}
|
||||
}
|
||||
|
||||
reset(): void {
|
||||
extensionRegistry.reset()
|
||||
this.settingsPageTeardowns.clear()
|
||||
for (const listeners of this.listeners.values()) {
|
||||
listeners.clear()
|
||||
}
|
||||
}
|
||||
|
||||
private registerSettingsPage(
|
||||
parentName: string,
|
||||
navigationSlot: 'companySettingsNavigation' | 'adminSettingsNavigation',
|
||||
contribution: SettingsPageContribution,
|
||||
): () => void {
|
||||
assertContributionId(contribution.id)
|
||||
if (!contribution.path || contribution.path.startsWith('/')) {
|
||||
throw new Error('InvoiceShelf extension settings paths must be relative.')
|
||||
}
|
||||
|
||||
const routeName = `extension.${parentName}.${contribution.id}`
|
||||
const pageKey = `${parentName}:${contribution.id}`
|
||||
this.settingsPageTeardowns.get(pageKey)?.()
|
||||
|
||||
const removeRoute = this.router.addRoute(parentName, {
|
||||
path: contribution.path,
|
||||
name: routeName,
|
||||
component: markRaw(contribution.component),
|
||||
meta: contribution.meta,
|
||||
})
|
||||
const removeNavigation = extensionRegistry.registerNavigation(navigationSlot, {
|
||||
id: contribution.id,
|
||||
priority: contribution.priority,
|
||||
visible: contribution.visible,
|
||||
title: contribution.title,
|
||||
icon: contribution.icon,
|
||||
to: { name: routeName },
|
||||
})
|
||||
|
||||
let active = true
|
||||
const teardown = () => {
|
||||
if (!active) return
|
||||
active = false
|
||||
removeNavigation()
|
||||
removeRoute()
|
||||
this.settingsPageTeardowns.delete(pageKey)
|
||||
}
|
||||
|
||||
const trackedTeardown = extensionRegistry.trackTeardown(teardown)
|
||||
this.settingsPageTeardowns.set(pageKey, trackedTeardown)
|
||||
return trackedTeardown
|
||||
}
|
||||
}
|
||||
|
||||
let extensionApi: ExtensionApi | null = null
|
||||
|
||||
export function createExtensionApi(router: Router): InvoiceShelfExtensionApi {
|
||||
extensionApi ??= new ExtensionApi(router)
|
||||
return extensionApi
|
||||
}
|
||||
|
||||
export function emitBootstrapCompleted(payload: BootstrapCompletedEvent): void {
|
||||
extensionApi?.emit('bootstrap:completed', payload)
|
||||
}
|
||||
|
||||
export function emitCompanyChanging(payload: CompanyChangeEvent): void {
|
||||
extensionApi?.emit('company:changing', payload)
|
||||
}
|
||||
|
||||
export function emitCompanyChanged(payload: CompanyChangeEvent): void {
|
||||
extensionApi?.emit('company:changed', payload)
|
||||
}
|
||||
|
||||
export function isContributionVisible(contribution: { visible?: () => boolean }): boolean {
|
||||
try {
|
||||
return contribution.visible?.() ?? true
|
||||
} catch (error) {
|
||||
console.warn('InvoiceShelf extension visibility predicate failed.', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function extensionItems<T extends { visible?: () => boolean }>(
|
||||
items: readonly T[],
|
||||
): T[] {
|
||||
return items.filter(isContributionVisible)
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
export type {
|
||||
BootstrapCompletedEvent,
|
||||
CompanyChangeEvent,
|
||||
ComponentExtensionContribution,
|
||||
ExtensionContribution,
|
||||
ExtensionVisibilityPredicate,
|
||||
InvoiceShelfExtensionApi,
|
||||
InvoiceShelfExtensionEvents,
|
||||
RichEditorContext,
|
||||
SettingsNavigationContribution,
|
||||
SettingsPageContribution,
|
||||
} from '../../../vendor/invoiceshelf/modules/frontend/index'
|
||||
@@ -9,7 +9,6 @@ const AdminUsersView = () => import('./views/AdminUsersView.vue')
|
||||
const AdminUserEditView = () => import('./views/AdminUserEditView.vue')
|
||||
const AdminSettingsView = () => import('./views/AdminSettingsView.vue')
|
||||
const AdminMailConfigView = () => import('./views/settings/AdminMailConfigView.vue')
|
||||
const AdminAiConfigView = () => import('./views/settings/AdminAiConfigView.vue')
|
||||
const AdminPdfGenerationView = () => import('./views/settings/AdminPdfGenerationView.vue')
|
||||
const AdminBackupView = () => import('./views/settings/AdminBackupView.vue')
|
||||
const AdminFileDiskView = () => import('./views/settings/AdminFileDiskView.vue')
|
||||
@@ -88,14 +87,6 @@ export const adminRoutes: RouteRecordRaw[] = [
|
||||
},
|
||||
component: AdminMailConfigView,
|
||||
},
|
||||
{
|
||||
path: 'ai-configuration',
|
||||
name: 'admin.settings.ai',
|
||||
meta: {
|
||||
isSuperAdmin: true,
|
||||
},
|
||||
component: AdminAiConfigView,
|
||||
},
|
||||
{
|
||||
path: 'pdf-generation',
|
||||
name: 'admin.settings.pdf',
|
||||
|
||||
@@ -54,6 +54,7 @@
|
||||
import { ref, computed, watchEffect } from 'vue'
|
||||
import { useRoute, useRouter, RouterView } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { extensionItems, extensionRegistry } from '@/scripts/extensions/runtime'
|
||||
|
||||
interface SettingsMenuItem {
|
||||
title: string
|
||||
@@ -73,11 +74,6 @@ const menuItems = computed<SettingsMenuItem[]>(() => [
|
||||
link: '/admin/administration/settings/mail-configuration',
|
||||
icon: 'EnvelopeIcon',
|
||||
},
|
||||
{
|
||||
title: t('settings.menu_title.ai_configuration'),
|
||||
link: '/admin/administration/settings/ai-configuration',
|
||||
icon: 'SparklesIcon',
|
||||
},
|
||||
{
|
||||
title: t('settings.menu_title.pdf_generation'),
|
||||
link: '/admin/administration/settings/pdf-generation',
|
||||
@@ -108,6 +104,11 @@ const menuItems = computed<SettingsMenuItem[]>(() => [
|
||||
link: '/admin/administration/settings/appearance',
|
||||
icon: 'PaintBrushIcon',
|
||||
},
|
||||
...extensionItems(extensionRegistry.adminSettingsNavigation.value).map((item) => ({
|
||||
title: t(item.title),
|
||||
link: router.resolve(item.to).fullPath,
|
||||
icon: item.icon,
|
||||
})),
|
||||
])
|
||||
|
||||
watchEffect(() => {
|
||||
|
||||
@@ -1,107 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useNotificationStore } from '@/scripts/stores/notification.store'
|
||||
import { aiService } from '@/scripts/api/services/ai.service'
|
||||
import type { AiConfig, AiDriverOption, AiTestPayload } from '@/scripts/types/ai-config'
|
||||
import { getErrorTranslationKey, handleApiError } from '@/scripts/utils/error-handling'
|
||||
import AiConfigurationForm from '@/scripts/features/company/settings/components/AiConfigurationForm.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const notificationStore = useNotificationStore()
|
||||
|
||||
const isSaving = ref(false)
|
||||
const isTesting = ref(false)
|
||||
const isFetchingInitialData = ref(false)
|
||||
const configData = ref<AiConfig | null>(null)
|
||||
const drivers = ref<AiDriverOption[]>([])
|
||||
|
||||
loadData()
|
||||
|
||||
async function loadData(): Promise<void> {
|
||||
isFetchingInitialData.value = true
|
||||
try {
|
||||
const [driversResponse, configResponse] = await Promise.all([
|
||||
aiService.getDrivers(),
|
||||
aiService.getGlobalConfig(),
|
||||
])
|
||||
drivers.value = driversResponse.ai_drivers
|
||||
configData.value = configResponse
|
||||
} catch (error: unknown) {
|
||||
const normalizedError = handleApiError(error)
|
||||
notificationStore.showNotification({
|
||||
type: 'error',
|
||||
message: getErrorTranslationKey(normalizedError.message) ?? normalizedError.message,
|
||||
})
|
||||
} finally {
|
||||
isFetchingInitialData.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function saveConfig(value: AiConfig): Promise<void> {
|
||||
isSaving.value = true
|
||||
try {
|
||||
const response = await aiService.saveGlobalConfig(value)
|
||||
if (response.success) {
|
||||
notificationStore.showNotification({
|
||||
type: 'success',
|
||||
message: 'settings.ai.saved',
|
||||
})
|
||||
configData.value = { ...value }
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const normalizedError = handleApiError(error)
|
||||
notificationStore.showNotification({
|
||||
type: 'error',
|
||||
message: getErrorTranslationKey(normalizedError.message) ?? normalizedError.message,
|
||||
})
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function testConnection(payload: AiTestPayload): Promise<void> {
|
||||
isTesting.value = true
|
||||
try {
|
||||
const response = await aiService.testGlobalConnection(payload)
|
||||
if (response.success) {
|
||||
notificationStore.showNotification({
|
||||
type: 'success',
|
||||
message: 'settings.ai.test_success',
|
||||
})
|
||||
} else if (response.error) {
|
||||
notificationStore.showNotification({
|
||||
type: 'error',
|
||||
message: t('settings.ai.errors.' + response.error, { error: response.message ?? '' }),
|
||||
})
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const normalizedError = handleApiError(error)
|
||||
notificationStore.showNotification({
|
||||
type: 'error',
|
||||
message: getErrorTranslationKey(normalizedError.message) ?? normalizedError.message,
|
||||
})
|
||||
} finally {
|
||||
isTesting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseSettingCard
|
||||
:title="$t('settings.ai.title')"
|
||||
:description="$t('settings.ai.description')"
|
||||
>
|
||||
<div v-if="configData" class="mt-14">
|
||||
<AiConfigurationForm
|
||||
:config-data="configData"
|
||||
:drivers="drivers"
|
||||
:is-saving="isSaving"
|
||||
:is-testing="isTesting"
|
||||
:is-fetching-initial-data="isFetchingInitialData"
|
||||
@submit-data="saveConfig"
|
||||
@test-connection="testConnection"
|
||||
/>
|
||||
</div>
|
||||
</BaseSettingCard>
|
||||
</template>
|
||||
@@ -1,79 +0,0 @@
|
||||
<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="h-12 px-3 border-b border-line-default flex items-center">
|
||||
<button
|
||||
type="button"
|
||||
class="w-full text-center text-xs font-medium rounded px-2 py-1 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>
|
||||
@@ -1,140 +0,0 @@
|
||||
<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="h-12 flex items-center justify-between px-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>
|
||||
@@ -1,42 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import type { AiChatMessage } from '@/scripts/types/ai-config'
|
||||
import { renderMarkdown } from '@/scripts/utils/markdown'
|
||||
|
||||
const props = defineProps<{
|
||||
message: AiChatMessage
|
||||
}>()
|
||||
|
||||
const isUser = computed(() => props.message.role === 'user')
|
||||
|
||||
// Assistant messages get rendered as markdown → sanitized HTML so GFM
|
||||
// features (code blocks, lists, tables, inline formatting) display as
|
||||
// the model intended. User messages stay as plain text because the
|
||||
// user typed them verbatim and markdown syntax would be surprising.
|
||||
const renderedHtml = computed(() =>
|
||||
isUser.value ? '' : renderMarkdown(props.message.content ?? ''),
|
||||
)
|
||||
</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'
|
||||
"
|
||||
>
|
||||
<p v-if="isUser" class="whitespace-pre-wrap break-words">
|
||||
{{ message.content ?? '' }}
|
||||
</p>
|
||||
<!-- Assistant output is sanitized via DOMPurify in renderMarkdown
|
||||
before it reaches v-html — see resources/scripts/utils/markdown.ts. -->
|
||||
<BaseSanitizedHtml v-else class="prose prose-sm max-w-none break-words" :html="renderedHtml" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,62 +0,0 @@
|
||||
<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>
|
||||
@@ -1,149 +0,0 @@
|
||||
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,
|
||||
}
|
||||
})
|
||||
@@ -1,316 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import useVuelidate from '@vuelidate/core'
|
||||
import { helpers, required, requiredIf, url as urlValidator } from '@vuelidate/validators'
|
||||
import type {
|
||||
AiConfig,
|
||||
AiDriverConfigField,
|
||||
AiDriverOption,
|
||||
} from '@/scripts/types/ai-config'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
configData?: Partial<AiConfig>
|
||||
isSaving?: boolean
|
||||
isFetchingInitialData?: boolean
|
||||
drivers?: AiDriverOption[]
|
||||
isTesting?: boolean
|
||||
}>(),
|
||||
{
|
||||
configData: () => ({}),
|
||||
isSaving: false,
|
||||
isFetchingInitialData: false,
|
||||
drivers: () => [],
|
||||
isTesting: false,
|
||||
},
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
'submit-data': [config: AiConfig]
|
||||
'test-connection': [config: Pick<AiConfig, 'ai_driver' | 'ai_api_key' | 'ai_base_url'>]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const form = reactive<AiConfig>(createDefaults())
|
||||
const showKey = ref(false)
|
||||
|
||||
const selectedDriver = computed<AiDriverOption | undefined>(() =>
|
||||
props.drivers.find((d) => d.value === form.ai_driver),
|
||||
)
|
||||
|
||||
const suggestedModels = computed(() => selectedDriver.value?.suggested_models ?? [])
|
||||
const configFields = computed<AiDriverConfigField[]>(() => selectedDriver.value?.config_fields ?? [])
|
||||
|
||||
const isAiOn = computed(() => form.ai_enabled === 'YES')
|
||||
const isChatOn = computed(() => form.ai_chat_enabled === 'YES')
|
||||
const isTextGenOn = computed(() => form.ai_text_generation_enabled === 'YES')
|
||||
|
||||
const driversList = computed(() =>
|
||||
props.drivers.map((d) => ({ value: d.value, label: t(d.label) })),
|
||||
)
|
||||
|
||||
const modelDatalistId = 'ai-model-suggestions'
|
||||
|
||||
const rules = computed(() => ({
|
||||
ai_driver: {
|
||||
required: helpers.withMessage(
|
||||
t('validation.required'),
|
||||
requiredIf(() => isAiOn.value),
|
||||
),
|
||||
},
|
||||
ai_api_key: {
|
||||
required: helpers.withMessage(
|
||||
t('validation.required'),
|
||||
requiredIf(() => isAiOn.value),
|
||||
),
|
||||
},
|
||||
ai_base_url: {
|
||||
url: helpers.withMessage(t('validation.invalid_url'), (value: string) => {
|
||||
if (!value) return true
|
||||
return urlValidator.$validator(value, {} as never, {} as never)
|
||||
}),
|
||||
},
|
||||
ai_chat_model: {
|
||||
required: helpers.withMessage(
|
||||
t('validation.required'),
|
||||
requiredIf(() => isAiOn.value && isChatOn.value),
|
||||
),
|
||||
},
|
||||
ai_text_generation_model: {
|
||||
required: helpers.withMessage(
|
||||
t('validation.required'),
|
||||
requiredIf(() => isAiOn.value && isTextGenOn.value),
|
||||
),
|
||||
},
|
||||
}))
|
||||
|
||||
const v$ = useVuelidate(rules, form)
|
||||
|
||||
function createDefaults(): AiConfig {
|
||||
return {
|
||||
ai_enabled: 'NO',
|
||||
ai_driver: 'openrouter',
|
||||
ai_api_key: '',
|
||||
ai_base_url: '',
|
||||
ai_chat_enabled: 'NO',
|
||||
ai_chat_model: 'anthropic/claude-sonnet-4.6',
|
||||
ai_text_generation_enabled: 'NO',
|
||||
ai_text_generation_model: 'anthropic/claude-haiku-4.5',
|
||||
}
|
||||
}
|
||||
|
||||
function hydrateFromProps() {
|
||||
if (!props.configData) return
|
||||
for (const key of Object.keys(form) as Array<keyof AiConfig>) {
|
||||
if (props.configData[key] !== undefined && props.configData[key] !== null) {
|
||||
;(form as Record<string, unknown>)[key] = props.configData[key]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => props.configData, hydrateFromProps, { immediate: true, deep: true })
|
||||
|
||||
// When the driver changes, fill in the driver-default base_url if the user hasn't provided one.
|
||||
watch(
|
||||
() => form.ai_driver,
|
||||
(next) => {
|
||||
const driver = props.drivers.find((d) => d.value === next)
|
||||
if (driver?.default_base_url && !form.ai_base_url) {
|
||||
form.ai_base_url = driver.default_base_url
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
async function onSubmit() {
|
||||
const valid = await v$.value.$validate()
|
||||
if (!valid) return
|
||||
emit('submit-data', { ...form })
|
||||
}
|
||||
|
||||
function onTestConnection() {
|
||||
emit('test-connection', {
|
||||
ai_driver: form.ai_driver,
|
||||
ai_api_key: form.ai_api_key,
|
||||
ai_base_url: form.ai_base_url,
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<form @submit.prevent="onSubmit">
|
||||
<!-- Global enable -->
|
||||
<div class="mb-8">
|
||||
<BaseSwitch
|
||||
:model-value="isAiOn"
|
||||
class="flex"
|
||||
:label-right="$t('settings.ai.enable')"
|
||||
@update:model-value="form.ai_enabled = $event ? 'YES' : 'NO'"
|
||||
/>
|
||||
<p class="mt-2 text-xs text-muted">{{ $t('settings.ai.enable_help') }}</p>
|
||||
</div>
|
||||
|
||||
<div v-if="isAiOn" class="space-y-6">
|
||||
<!-- Provider selection -->
|
||||
<BaseInputGroup
|
||||
:label="$t('settings.ai.driver')"
|
||||
:content-loading="isFetchingInitialData"
|
||||
required
|
||||
:error="v$.ai_driver.$error && v$.ai_driver.$errors[0]?.$message"
|
||||
>
|
||||
<BaseMultiselect
|
||||
v-model="form.ai_driver"
|
||||
:options="driversList"
|
||||
:content-loading="isFetchingInitialData"
|
||||
value-prop="value"
|
||||
label="label"
|
||||
track-by="label"
|
||||
:can-deselect="false"
|
||||
:invalid="v$.ai_driver.$error"
|
||||
/>
|
||||
</BaseInputGroup>
|
||||
|
||||
<!-- API key -->
|
||||
<BaseInputGroup
|
||||
:label="$t('settings.ai.api_key')"
|
||||
:content-loading="isFetchingInitialData"
|
||||
:help-text="$t('settings.ai.api_key_help')"
|
||||
required
|
||||
:error="v$.ai_api_key.$error && v$.ai_api_key.$errors[0]?.$message"
|
||||
>
|
||||
<div class="flex gap-2">
|
||||
<BaseInput
|
||||
v-model="form.ai_api_key"
|
||||
:content-loading="isFetchingInitialData"
|
||||
:type="showKey ? 'text' : 'password'"
|
||||
class="flex-1"
|
||||
name="ai_api_key"
|
||||
:invalid="v$.ai_api_key.$error"
|
||||
/>
|
||||
<BaseButton
|
||||
type="button"
|
||||
variant="primary-outline"
|
||||
@click="showKey = !showKey"
|
||||
>
|
||||
{{ showKey ? $t('general.hide') : $t('general.show') }}
|
||||
</BaseButton>
|
||||
</div>
|
||||
</BaseInputGroup>
|
||||
|
||||
<!-- Driver-specific config fields (base_url for OpenRouter, etc.) -->
|
||||
<BaseInputGroup
|
||||
v-for="field in configFields"
|
||||
:key="field.key"
|
||||
:label="$t(field.label)"
|
||||
:content-loading="isFetchingInitialData"
|
||||
>
|
||||
<BaseInput
|
||||
v-if="field.type === 'text'"
|
||||
:model-value="(form as unknown as Record<string, string>)[`ai_${field.key}`] ?? ''"
|
||||
:placeholder="field.default"
|
||||
type="text"
|
||||
:name="`ai_${field.key}`"
|
||||
@update:model-value="(val: string) => ((form as unknown as Record<string, string>)[`ai_${field.key}`] = val)"
|
||||
/>
|
||||
</BaseInputGroup>
|
||||
|
||||
<!-- Role: chat -->
|
||||
<div class="border-t border-line-default pt-6">
|
||||
<h3 class="text-sm font-semibold text-heading mb-3">{{ $t('settings.ai.roles') }}</h3>
|
||||
<p class="text-xs text-muted mb-4">{{ $t('settings.ai.roles_help') }}</p>
|
||||
|
||||
<div class="mb-6">
|
||||
<BaseSwitch
|
||||
:model-value="isChatOn"
|
||||
class="flex"
|
||||
:label-right="$t('settings.ai.chat')"
|
||||
@update:model-value="form.ai_chat_enabled = $event ? 'YES' : 'NO'"
|
||||
/>
|
||||
<p class="mt-2 text-xs text-muted">{{ $t('settings.ai.chat_help') }}</p>
|
||||
|
||||
<BaseInputGroup
|
||||
v-if="isChatOn"
|
||||
class="mt-3"
|
||||
:label="$t('settings.ai.chat_model')"
|
||||
required
|
||||
:error="v$.ai_chat_model.$error && v$.ai_chat_model.$errors[0]?.$message"
|
||||
>
|
||||
<BaseInput
|
||||
v-model="form.ai_chat_model"
|
||||
type="text"
|
||||
:list="modelDatalistId"
|
||||
:invalid="v$.ai_chat_model.$error"
|
||||
/>
|
||||
</BaseInputGroup>
|
||||
</div>
|
||||
|
||||
<!-- Role: text generation -->
|
||||
<div>
|
||||
<BaseSwitch
|
||||
:model-value="isTextGenOn"
|
||||
class="flex"
|
||||
:label-right="$t('settings.ai.text_generation')"
|
||||
@update:model-value="form.ai_text_generation_enabled = $event ? 'YES' : 'NO'"
|
||||
/>
|
||||
<p class="mt-2 text-xs text-muted">{{ $t('settings.ai.text_generation_help') }}</p>
|
||||
|
||||
<BaseInputGroup
|
||||
v-if="isTextGenOn"
|
||||
class="mt-3"
|
||||
:label="$t('settings.ai.text_generation_model')"
|
||||
required
|
||||
:error="
|
||||
v$.ai_text_generation_model.$error &&
|
||||
v$.ai_text_generation_model.$errors[0]?.$message
|
||||
"
|
||||
>
|
||||
<BaseInput
|
||||
v-model="form.ai_text_generation_model"
|
||||
type="text"
|
||||
:list="modelDatalistId"
|
||||
:invalid="v$.ai_text_generation_model.$error"
|
||||
/>
|
||||
</BaseInputGroup>
|
||||
</div>
|
||||
|
||||
<!-- Datalist with suggested models for both inputs -->
|
||||
<datalist :id="modelDatalistId">
|
||||
<option
|
||||
v-for="model in suggestedModels"
|
||||
:key="model.value"
|
||||
:value="model.value"
|
||||
>
|
||||
{{ model.label }}
|
||||
</option>
|
||||
</datalist>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="flex items-center gap-3 mt-8">
|
||||
<BaseButton
|
||||
:loading="isSaving"
|
||||
:disabled="isSaving"
|
||||
variant="primary"
|
||||
type="submit"
|
||||
>
|
||||
<template #left="slotProps">
|
||||
<BaseIcon v-if="!isSaving" name="ArrowDownOnSquareIcon" :class="slotProps.class" />
|
||||
</template>
|
||||
{{ $t('general.save') }}
|
||||
</BaseButton>
|
||||
|
||||
<BaseButton
|
||||
v-if="isAiOn"
|
||||
:loading="isTesting"
|
||||
:disabled="isTesting || isSaving"
|
||||
variant="primary-outline"
|
||||
type="button"
|
||||
@click="onTestConnection"
|
||||
>
|
||||
{{ $t('settings.ai.test_connection') }}
|
||||
</BaseButton>
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
@@ -34,6 +34,7 @@ const settingsRoutes: RouteRecordRaw[] = [
|
||||
},
|
||||
{
|
||||
path: 'settings',
|
||||
name: 'settings',
|
||||
component: () => import('./views/SettingsLayoutView.vue'),
|
||||
children: [
|
||||
{
|
||||
@@ -158,15 +159,6 @@ const settingsRoutes: RouteRecordRaw[] = [
|
||||
},
|
||||
component: () => import('./views/MailConfigView.vue'),
|
||||
},
|
||||
{
|
||||
path: 'ai-config',
|
||||
name: 'settings.ai-config',
|
||||
meta: {
|
||||
requiresAuth: true,
|
||||
isOwner: true,
|
||||
},
|
||||
component: () => import('./views/AiConfigView.vue'),
|
||||
},
|
||||
{
|
||||
path: 'roles',
|
||||
name: 'settings.roles',
|
||||
|
||||
@@ -1,165 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useNotificationStore } from '@/scripts/stores/notification.store'
|
||||
import { aiService } from '@/scripts/api/services/ai.service'
|
||||
import type {
|
||||
AiConfig,
|
||||
AiDriverOption,
|
||||
AiTestPayload,
|
||||
CompanyAiConfig,
|
||||
} from '@/scripts/types/ai-config'
|
||||
import { getErrorTranslationKey, handleApiError } from '@/scripts/utils/error-handling'
|
||||
import AiConfigurationForm from '@/scripts/features/company/settings/components/AiConfigurationForm.vue'
|
||||
|
||||
const { t } = useI18n()
|
||||
const notificationStore = useNotificationStore()
|
||||
|
||||
const isSaving = ref(false)
|
||||
const isTesting = ref(false)
|
||||
const isFetchingInitialData = ref(false)
|
||||
const useCustomAiConfig = ref(false)
|
||||
const configData = ref<CompanyAiConfig | null>(null)
|
||||
const drivers = ref<AiDriverOption[]>([])
|
||||
|
||||
loadData()
|
||||
|
||||
async function loadData(): Promise<void> {
|
||||
isFetchingInitialData.value = true
|
||||
try {
|
||||
const [driversResponse, configResponse] = await Promise.all([
|
||||
aiService.getDrivers(),
|
||||
aiService.getCompanyConfig(),
|
||||
])
|
||||
drivers.value = driversResponse.ai_drivers
|
||||
configData.value = configResponse
|
||||
useCustomAiConfig.value = configResponse.use_custom_ai_config === 'YES'
|
||||
} catch (error: unknown) {
|
||||
const normalizedError = handleApiError(error)
|
||||
notificationStore.showNotification({
|
||||
type: 'error',
|
||||
message: getErrorTranslationKey(normalizedError.message) ?? normalizedError.message,
|
||||
})
|
||||
} finally {
|
||||
isFetchingInitialData.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Mirror the mail pattern: flipping the toggle OFF auto-saves and discards driver fields.
|
||||
watch(useCustomAiConfig, async (next, prev) => {
|
||||
if (prev === undefined) return
|
||||
if (next) return // ON — wait for explicit save
|
||||
|
||||
isSaving.value = true
|
||||
try {
|
||||
await aiService.saveCompanyConfig({
|
||||
use_custom_ai_config: 'NO',
|
||||
} as CompanyAiConfig)
|
||||
|
||||
if (configData.value) {
|
||||
configData.value.use_custom_ai_config = 'NO'
|
||||
}
|
||||
|
||||
notificationStore.showNotification({
|
||||
type: 'success',
|
||||
message: 'settings.ai.saved',
|
||||
})
|
||||
} catch (error: unknown) {
|
||||
const normalizedError = handleApiError(error)
|
||||
notificationStore.showNotification({
|
||||
type: 'error',
|
||||
message: getErrorTranslationKey(normalizedError.message) ?? normalizedError.message,
|
||||
})
|
||||
useCustomAiConfig.value = true // revert the toggle
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
})
|
||||
|
||||
async function saveConfig(value: AiConfig): Promise<void> {
|
||||
isSaving.value = true
|
||||
try {
|
||||
const payload: CompanyAiConfig = {
|
||||
...value,
|
||||
use_custom_ai_config: 'YES',
|
||||
}
|
||||
|
||||
const response = await aiService.saveCompanyConfig(payload)
|
||||
if (response.success) {
|
||||
notificationStore.showNotification({
|
||||
type: 'success',
|
||||
message: 'settings.ai.saved',
|
||||
})
|
||||
configData.value = payload
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const normalizedError = handleApiError(error)
|
||||
notificationStore.showNotification({
|
||||
type: 'error',
|
||||
message: getErrorTranslationKey(normalizedError.message) ?? normalizedError.message,
|
||||
})
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function testConnection(payload: AiTestPayload): Promise<void> {
|
||||
isTesting.value = true
|
||||
try {
|
||||
const response = await aiService.testCompanyConnection(payload)
|
||||
if (response.success) {
|
||||
notificationStore.showNotification({
|
||||
type: 'success',
|
||||
message: 'settings.ai.test_success',
|
||||
})
|
||||
} else if (response.error) {
|
||||
notificationStore.showNotification({
|
||||
type: 'error',
|
||||
message: t('settings.ai.errors.' + response.error, { error: response.message ?? '' }),
|
||||
})
|
||||
}
|
||||
} catch (error: unknown) {
|
||||
const normalizedError = handleApiError(error)
|
||||
notificationStore.showNotification({
|
||||
type: 'error',
|
||||
message: getErrorTranslationKey(normalizedError.message) ?? normalizedError.message,
|
||||
})
|
||||
} finally {
|
||||
isTesting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseSettingCard
|
||||
:title="$t('settings.ai.title')"
|
||||
:description="$t('settings.ai.description')"
|
||||
>
|
||||
<div class="mt-8">
|
||||
<BaseSwitchSection
|
||||
v-model="useCustomAiConfig"
|
||||
:title="$t('settings.ai.use_custom_ai_config')"
|
||||
:description="$t('settings.ai.use_custom_ai_config_desc')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="!useCustomAiConfig"
|
||||
class="mt-6 p-4 rounded bg-alert-success-bg text-alert-success-text text-sm"
|
||||
>
|
||||
{{ $t('settings.ai.using_global_ai_config') }}
|
||||
</div>
|
||||
|
||||
<div v-if="useCustomAiConfig && configData" class="mt-8">
|
||||
<AiConfigurationForm
|
||||
:config-data="configData"
|
||||
:drivers="drivers"
|
||||
:is-saving="isSaving"
|
||||
:is-testing="isTesting"
|
||||
:is-fetching-initial-data="isFetchingInitialData"
|
||||
@submit-data="saveConfig"
|
||||
@test-connection="testConnection"
|
||||
/>
|
||||
</div>
|
||||
</BaseSettingCard>
|
||||
</template>
|
||||
@@ -4,6 +4,7 @@ import { useRoute, useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useGlobalStore } from '../../../../stores/global.store'
|
||||
import { useUserStore } from '../../../../stores/user.store'
|
||||
import { extensionItems, extensionRegistry } from '@/scripts/extensions/runtime'
|
||||
|
||||
interface SettingMenuItem {
|
||||
title: string
|
||||
@@ -33,6 +34,14 @@ const dropdownMenuItems = computed<DropdownMenuItem[]>(() => {
|
||||
title: t(item.title),
|
||||
}))
|
||||
|
||||
items.push(
|
||||
...extensionItems(extensionRegistry.companySettingsNavigation.value).map((item) => ({
|
||||
title: t(item.title),
|
||||
link: router.resolve(item.to).fullPath,
|
||||
icon: item.icon,
|
||||
})),
|
||||
)
|
||||
|
||||
if (showDangerZone.value) {
|
||||
items.push({
|
||||
title: t('settings.company_info.danger_zone'),
|
||||
@@ -44,6 +53,12 @@ const dropdownMenuItems = computed<DropdownMenuItem[]>(() => {
|
||||
return items
|
||||
})
|
||||
|
||||
const sidebarMenuItems = computed<DropdownMenuItem[]>(() =>
|
||||
dropdownMenuItems.value.filter(
|
||||
(item) => item.link !== '/admin/settings/danger-zone',
|
||||
),
|
||||
)
|
||||
|
||||
watchEffect(() => {
|
||||
if (route.path === '/admin/settings') {
|
||||
// Redirect to first available setting menu item, or account settings as fallback
|
||||
@@ -95,9 +110,9 @@ function navigateToSetting(setting: DropdownMenuItem): void {
|
||||
<div class="hidden mt-1 xl:block min-w-[240px] sticky top-20 self-start">
|
||||
<BaseList>
|
||||
<BaseListItem
|
||||
v-for="(menuItem, index) in globalStore.settingMenu"
|
||||
v-for="(menuItem, index) in sidebarMenuItems"
|
||||
:key="index"
|
||||
:title="$t(menuItem.title)"
|
||||
:title="menuItem.title"
|
||||
:to="menuItem.link"
|
||||
:active="hasActiveUrl(menuItem.link)"
|
||||
:index="index"
|
||||
|
||||
@@ -15,10 +15,9 @@ import InstallationLayout from '@/scripts/layouts/InstallationLayout.vue'
|
||||
* 4. DatabaseView (/installation/database)
|
||||
* 5. DomainView (/installation/domain)
|
||||
* 6. MailView (/installation/mail)
|
||||
* 7. AiView (/installation/ai) — optional, skippable
|
||||
* 8. AccountView (/installation/account)
|
||||
* 9. CompanyView (/installation/company)
|
||||
* 10. PreferencesView (/installation/preferences)
|
||||
* 7. AccountView (/installation/account)
|
||||
* 8. CompanyView (/installation/company)
|
||||
* 9. PreferencesView (/installation/preferences)
|
||||
*
|
||||
* Each child view owns its own next() function and calls router.push() to
|
||||
* the next step by route name. There is no event-based step coordination —
|
||||
@@ -91,15 +90,6 @@ export const installationRoutes: RouteRecordRaw[] = [
|
||||
isInstallation: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'ai',
|
||||
name: 'installation.ai',
|
||||
component: () => import('./views/AiView.vue'),
|
||||
meta: {
|
||||
title: 'settings.ai.installer_title',
|
||||
isInstallation: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'account',
|
||||
name: 'installation.account',
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { installClient } from '@/scripts/api/install-client'
|
||||
import type {
|
||||
AiConfig,
|
||||
AiDriverOption,
|
||||
AiDriversResponse,
|
||||
} from '@/scripts/types/ai-config'
|
||||
import AiConfigurationForm from '@/scripts/features/company/settings/components/AiConfigurationForm.vue'
|
||||
import { useInstallationFeedback } from '../use-installation-feedback'
|
||||
|
||||
const router = useRouter()
|
||||
const { isSuccessfulResponse, showRequestError, showResponseError } = useInstallationFeedback()
|
||||
|
||||
const isSaving = ref(false)
|
||||
const isFetchingInitialData = ref(false)
|
||||
const configData = ref<AiConfig | null>(null)
|
||||
const drivers = ref<AiDriverOption[]>([])
|
||||
|
||||
onMounted(loadData)
|
||||
|
||||
async function loadData(): Promise<void> {
|
||||
isFetchingInitialData.value = true
|
||||
try {
|
||||
const { data } = await installClient.get<{
|
||||
config: AiConfig
|
||||
drivers: AiDriversResponse['ai_drivers']
|
||||
}>('/api/v1/installation/ai/config')
|
||||
configData.value = data.config
|
||||
drivers.value = data.drivers
|
||||
} catch (error: unknown) {
|
||||
showRequestError(error)
|
||||
} finally {
|
||||
isFetchingInitialData.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function saveAi(value: AiConfig): Promise<void> {
|
||||
isSaving.value = true
|
||||
try {
|
||||
const { data } = await installClient.post('/api/v1/installation/ai/config', value)
|
||||
|
||||
if (!isSuccessfulResponse(data)) {
|
||||
showResponseError(data)
|
||||
return
|
||||
}
|
||||
|
||||
await router.push({ name: 'installation.account' })
|
||||
} catch (error: unknown) {
|
||||
showRequestError(error)
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function skipStep(): Promise<void> {
|
||||
// Persist the disabled default so bootstrap sees an explicit ai_enabled=NO
|
||||
// (rather than a missing key that defaults to NO anyway — we want the value
|
||||
// in storage so tests / repeated installer runs behave predictably).
|
||||
await saveAi({
|
||||
ai_enabled: 'NO',
|
||||
ai_driver: 'openrouter',
|
||||
ai_api_key: '',
|
||||
ai_base_url: '',
|
||||
ai_chat_enabled: 'NO',
|
||||
ai_chat_model: '',
|
||||
ai_text_generation_enabled: 'NO',
|
||||
ai_text_generation_model: '',
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseWizardStep
|
||||
:title="$t('settings.ai.installer_title')"
|
||||
:description="$t('settings.ai.installer_description')"
|
||||
>
|
||||
<div v-if="configData">
|
||||
<AiConfigurationForm
|
||||
:config-data="configData"
|
||||
:drivers="drivers"
|
||||
:is-saving="isSaving"
|
||||
:is-fetching-initial-data="isFetchingInitialData"
|
||||
@submit-data="saveAi"
|
||||
/>
|
||||
|
||||
<div class="mt-6">
|
||||
<BaseButton
|
||||
variant="primary-outline"
|
||||
type="button"
|
||||
:disabled="isSaving"
|
||||
@click="skipStep"
|
||||
>
|
||||
{{ $t('general.skip') }}
|
||||
</BaseButton>
|
||||
</div>
|
||||
</div>
|
||||
</BaseWizardStep>
|
||||
</template>
|
||||
@@ -51,7 +51,7 @@ async function saveMailConfig(value: MailConfig): Promise<void> {
|
||||
...value,
|
||||
}
|
||||
|
||||
await router.push({ name: 'installation.ai' })
|
||||
await router.push({ name: 'installation.account' })
|
||||
} catch (error: unknown) {
|
||||
showRequestError(error)
|
||||
} finally {
|
||||
|
||||
@@ -1,194 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useModalStore } from '@/scripts/stores/modal.store'
|
||||
import { useNotificationStore } from '@/scripts/stores/notification.store'
|
||||
import { aiService } from '@/scripts/api/services/ai.service'
|
||||
|
||||
/**
|
||||
* One-shot text generation popup for WYSIWYG editors.
|
||||
*
|
||||
* Usage pattern — a caller opens this modal via modalStore and passes data with:
|
||||
* - currentContent: string // the editor's current HTML (used as optional context)
|
||||
* - onInsert: (text: string) => void // invoked when the user accepts "Insert"
|
||||
* - onReplace: (text: string) => void // invoked when the user accepts "Replace"
|
||||
*
|
||||
* See RichEditor.vue for the canonical caller. The modal doesn't know
|
||||
* anything about tiptap or ProseMirror — it just hands back the text it got
|
||||
* from the backend and lets the caller decide how to splice it into the editor.
|
||||
*/
|
||||
|
||||
interface ModalData {
|
||||
currentContent?: string
|
||||
onInsert?: (text: string) => void
|
||||
onReplace?: (text: string) => void
|
||||
}
|
||||
|
||||
const { t } = useI18n()
|
||||
const modalStore = useModalStore()
|
||||
const notificationStore = useNotificationStore()
|
||||
|
||||
const modalActive = computed<boolean>(
|
||||
() => modalStore.active && modalStore.componentName === 'AiTextGenerationModal',
|
||||
)
|
||||
|
||||
const data = computed<ModalData>(() => (modalStore.data as ModalData) ?? {})
|
||||
|
||||
const prompt = ref<string>('')
|
||||
const useContext = ref<boolean>(false)
|
||||
const generatedText = ref<string>('')
|
||||
const isGenerating = ref<boolean>(false)
|
||||
|
||||
const canInsert = computed<boolean>(() => generatedText.value.trim() !== '')
|
||||
|
||||
async function generate(): Promise<void> {
|
||||
if (!prompt.value.trim() || isGenerating.value) return
|
||||
|
||||
isGenerating.value = true
|
||||
generatedText.value = ''
|
||||
|
||||
try {
|
||||
const response = await aiService.generateText({
|
||||
prompt: prompt.value,
|
||||
context: useContext.value ? data.value.currentContent : undefined,
|
||||
})
|
||||
|
||||
if (response.text !== undefined) {
|
||||
generatedText.value = response.text
|
||||
} else if (response.error) {
|
||||
notificationStore.showNotification({
|
||||
type: 'error',
|
||||
message: t('settings.ai.errors.' + response.error, { error: response.message ?? '' }),
|
||||
})
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : 'Unknown error'
|
||||
notificationStore.showNotification({ type: 'error', message })
|
||||
} finally {
|
||||
isGenerating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function insert(): void {
|
||||
data.value.onInsert?.(generatedText.value)
|
||||
close()
|
||||
}
|
||||
|
||||
function replace(): void {
|
||||
data.value.onReplace?.(generatedText.value)
|
||||
close()
|
||||
}
|
||||
|
||||
function close(): void {
|
||||
modalStore.closeModal()
|
||||
setTimeout(() => {
|
||||
prompt.value = ''
|
||||
generatedText.value = ''
|
||||
useContext.value = false
|
||||
}, 200)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseModal :show="modalActive" @close="close">
|
||||
<template #header>
|
||||
<div class="flex items-center justify-between w-full">
|
||||
<div class="flex items-center gap-2">
|
||||
<BaseIcon name="SparklesIcon" class="w-5 h-5 text-primary-500" />
|
||||
<span>{{ $t('ai.generate.title') }}</span>
|
||||
</div>
|
||||
<BaseIcon
|
||||
name="XMarkIcon"
|
||||
class="w-6 h-6 text-muted cursor-pointer"
|
||||
@click="close"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="p-6 space-y-4">
|
||||
<BaseInputGroup
|
||||
:label="$t('ai.generate.prompt_label')"
|
||||
:content-loading="false"
|
||||
required
|
||||
>
|
||||
<BaseTextarea
|
||||
v-model="prompt"
|
||||
rows="3"
|
||||
:placeholder="$t('ai.generate.prompt_placeholder')"
|
||||
:disabled="isGenerating"
|
||||
/>
|
||||
</BaseInputGroup>
|
||||
|
||||
<div v-if="data.currentContent">
|
||||
<BaseSwitch
|
||||
v-model="useContext"
|
||||
class="flex"
|
||||
:label-right="$t('ai.generate.use_current_as_context')"
|
||||
/>
|
||||
<p class="mt-1 text-xs text-muted">
|
||||
{{ $t('ai.generate.use_context_help') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-if="generatedText" class="border border-line-default rounded-md p-3 bg-surface-secondary">
|
||||
<p class="text-xs text-muted mb-2">{{ $t('ai.generate.preview') }}</p>
|
||||
<p class="text-sm text-body whitespace-pre-wrap">{{ generatedText }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-2 p-4 border-t border-line-default">
|
||||
<BaseButton
|
||||
variant="primary-outline"
|
||||
type="button"
|
||||
:disabled="isGenerating"
|
||||
@click="close"
|
||||
>
|
||||
{{ $t('general.cancel') }}
|
||||
</BaseButton>
|
||||
|
||||
<BaseButton
|
||||
v-if="canInsert"
|
||||
variant="primary-outline"
|
||||
type="button"
|
||||
:disabled="isGenerating"
|
||||
@click="replace"
|
||||
>
|
||||
{{ $t('ai.generate.replace') }}
|
||||
</BaseButton>
|
||||
|
||||
<BaseButton
|
||||
v-if="canInsert"
|
||||
variant="primary-outline"
|
||||
type="button"
|
||||
:disabled="isGenerating"
|
||||
@click="generate"
|
||||
>
|
||||
{{ $t('ai.generate.regenerate') }}
|
||||
</BaseButton>
|
||||
|
||||
<BaseButton
|
||||
v-if="canInsert"
|
||||
variant="primary"
|
||||
type="button"
|
||||
:disabled="isGenerating"
|
||||
@click="insert"
|
||||
>
|
||||
{{ $t('ai.generate.insert') }}
|
||||
</BaseButton>
|
||||
|
||||
<BaseButton
|
||||
v-else
|
||||
variant="primary"
|
||||
type="button"
|
||||
:loading="isGenerating"
|
||||
:disabled="!prompt.trim() || isGenerating"
|
||||
@click="generate"
|
||||
>
|
||||
<template #left="slotProps">
|
||||
<BaseIcon v-if="!isGenerating" name="SparklesIcon" :class="slotProps.class" />
|
||||
</template>
|
||||
{{ $t('ai.generate.generate') }}
|
||||
</BaseButton>
|
||||
</div>
|
||||
</BaseModal>
|
||||
</template>
|
||||
@@ -23,11 +23,7 @@
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<!-- AI chat drawer — always mounted, visibility driven by the store -->
|
||||
<AiChatDrawer v-if="globalStore.ai?.enabled && globalStore.ai?.chat_enabled" />
|
||||
|
||||
<!-- AI text generation modal — triggered from any RichEditor's Sparkles button -->
|
||||
<AiTextGenerationModal v-if="globalStore.ai?.enabled && globalStore.ai?.text_generation_enabled" />
|
||||
<ExtensionSlot name="company-layout-overlays" />
|
||||
</div>
|
||||
|
||||
<BaseGlobalLoader v-else />
|
||||
@@ -45,8 +41,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'
|
||||
import AiTextGenerationModal from '@/scripts/features/shared/ai/AiTextGenerationModal.vue'
|
||||
import ExtensionSlot from '@/scripts/extensions/ExtensionSlot.vue'
|
||||
|
||||
interface RouteMeta {
|
||||
ability?: string | string[]
|
||||
|
||||
@@ -99,27 +99,7 @@
|
||||
/>
|
||||
</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>
|
||||
<ExtensionSlot name="header-actions" />
|
||||
|
||||
<!-- Company switcher -->
|
||||
<li>
|
||||
@@ -204,7 +184,6 @@ 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'
|
||||
@@ -212,6 +191,7 @@ import type { Theme } from '@/scripts/config/constants'
|
||||
import CompanySwitcher from './CompanySwitcher.vue'
|
||||
import GlobalSearchBar from './GlobalSearchBar.vue'
|
||||
import MainLogo from '@/scripts/components/icons/MainLogo.vue'
|
||||
import ExtensionSlot from '@/scripts/extensions/ExtensionSlot.vue'
|
||||
|
||||
interface ThemeOption {
|
||||
value: Theme
|
||||
@@ -222,7 +202,6 @@ const authStore = useAuthStore()
|
||||
const userStore = useUserStore()
|
||||
const globalStore = useGlobalStore()
|
||||
const companyStore = useCompanyStore()
|
||||
const aiChatStore = useAiChatStore()
|
||||
const router = useRouter()
|
||||
const { currentTheme, setTheme } = useTheme()
|
||||
|
||||
|
||||
@@ -17,6 +17,48 @@ const loadedLanguages = new Set<string>(['en'])
|
||||
/** In-memory cache of loaded message objects keyed by locale. */
|
||||
const languageCache = new Map<string, Record<string, unknown>>()
|
||||
|
||||
/** Messages registered by compiled modules. Kept separate from the lazy locale
|
||||
* cache so a later locale import cannot overwrite module translations. */
|
||||
const additionalMessages = new Map<string, Record<string, unknown>>()
|
||||
|
||||
let activeI18n: AppI18n | null = null
|
||||
|
||||
function isMessageObject(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
}
|
||||
|
||||
/** Recursively merge locale trees so one module's `settings.*` keys never
|
||||
* replace the host's complete `settings` namespace. */
|
||||
export function mergeMessageObjects(
|
||||
base: Record<string, unknown>,
|
||||
incoming: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
const merged = { ...base }
|
||||
|
||||
for (const [key, value] of Object.entries(incoming)) {
|
||||
merged[key] = isMessageObject(value) && isMessageObject(merged[key])
|
||||
? mergeMessageObjects(merged[key], value)
|
||||
: value
|
||||
}
|
||||
|
||||
return merged
|
||||
}
|
||||
|
||||
export function registerAdditionalMessages(
|
||||
messages: Record<string, Record<string, unknown>>,
|
||||
): void {
|
||||
for (const [locale, bundle] of Object.entries(messages)) {
|
||||
additionalMessages.set(
|
||||
locale,
|
||||
mergeMessageObjects(additionalMessages.get(locale) ?? {}, bundle),
|
||||
)
|
||||
|
||||
if (activeI18n) {
|
||||
activeI18n.global.mergeLocaleMessage(locale, bundle)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dynamically import a language JSON file for a given locale.
|
||||
*/
|
||||
@@ -40,7 +82,10 @@ async function loadLanguageMessages(
|
||||
const mod: { default: Record<string, unknown> } = await import(
|
||||
`../../../lang/${fileName}.json`
|
||||
)
|
||||
const messages = mod.default ?? mod
|
||||
const messages = mergeMessageObjects(
|
||||
mod.default ?? mod,
|
||||
additionalMessages.get(locale) ?? {},
|
||||
)
|
||||
languageCache.set(locale, messages)
|
||||
loadedLanguages.add(locale)
|
||||
return messages
|
||||
@@ -106,18 +151,29 @@ export type AppI18n = I18n<
|
||||
export function createAppI18n(
|
||||
extraMessages?: Record<string, Record<string, unknown>>
|
||||
): AppI18n {
|
||||
const messages: Record<string, Record<string, unknown>> = {
|
||||
en: en as unknown as Record<string, unknown>,
|
||||
...extraMessages,
|
||||
const messages: Record<string, Record<string, unknown>> = {}
|
||||
|
||||
for (const [locale, bundle] of additionalMessages) {
|
||||
messages[locale] = mergeMessageObjects(messages[locale] ?? {}, bundle)
|
||||
}
|
||||
|
||||
for (const [locale, bundle] of Object.entries(extraMessages ?? {})) {
|
||||
messages[locale] = mergeMessageObjects(messages[locale] ?? {}, bundle)
|
||||
}
|
||||
|
||||
messages.en = mergeMessageObjects(
|
||||
en as unknown as Record<string, unknown>,
|
||||
messages.en ?? {},
|
||||
)
|
||||
|
||||
const options: I18nOptions = {
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
fallbackLocale: 'en',
|
||||
globalInjection: true,
|
||||
messages,
|
||||
messages: messages as I18nOptions['messages'],
|
||||
}
|
||||
|
||||
return createI18n(options) as AppI18n
|
||||
activeI18n = createI18n(options) as AppI18n
|
||||
return activeI18n
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import * as localStore from '../utils/local-storage'
|
||||
import type { Company } from '@/scripts/types/domain/company'
|
||||
import type { Currency } from '@/scripts/types/domain/currency'
|
||||
import type { ApiResponse } from '@/scripts/types/api'
|
||||
import { emitCompanyChanged, emitCompanyChanging } from '@/scripts/extensions/runtime'
|
||||
|
||||
export const useCompanyStore = defineStore('company', () => {
|
||||
// State
|
||||
@@ -24,6 +25,14 @@ export const useCompanyStore = defineStore('company', () => {
|
||||
|
||||
// Actions
|
||||
function setSelectedCompany(data: Company | null): void {
|
||||
const previousCompanyId = selectedCompany.value?.id ?? null
|
||||
const companyId = data?.id ?? null
|
||||
const hasChanged = previousCompanyId !== companyId
|
||||
|
||||
if (hasChanged) {
|
||||
emitCompanyChanging({ previousCompanyId, companyId })
|
||||
}
|
||||
|
||||
if (data) {
|
||||
localStore.set('selectedCompany', data.id)
|
||||
localStore.remove('isAdminMode')
|
||||
@@ -32,14 +41,26 @@ export const useCompanyStore = defineStore('company', () => {
|
||||
localStore.remove('selectedCompany')
|
||||
}
|
||||
selectedCompany.value = data
|
||||
|
||||
if (hasChanged) {
|
||||
emitCompanyChanged({ previousCompanyId, companyId })
|
||||
}
|
||||
}
|
||||
|
||||
function setAdminMode(enabled: boolean): void {
|
||||
const previousCompanyId = selectedCompany.value?.id ?? null
|
||||
if (enabled && previousCompanyId !== null) {
|
||||
emitCompanyChanging({ previousCompanyId, companyId: null })
|
||||
}
|
||||
|
||||
isAdminMode.value = enabled
|
||||
if (enabled) {
|
||||
localStore.set('isAdminMode', true)
|
||||
localStore.remove('selectedCompany')
|
||||
selectedCompany.value = null
|
||||
if (previousCompanyId !== null) {
|
||||
emitCompanyChanged({ previousCompanyId, companyId: null })
|
||||
}
|
||||
} else {
|
||||
localStore.remove('isAdminMode')
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import { handleApiError } from '../utils/error-handling'
|
||||
import * as localStore from '../utils/local-storage'
|
||||
import type { Currency } from '@/scripts/types/domain/currency'
|
||||
import type { Country } from '@/scripts/types/domain/customer'
|
||||
import { emitBootstrapCompleted } from '@/scripts/extensions/runtime'
|
||||
|
||||
export const useGlobalStore = defineStore('global', () => {
|
||||
// State
|
||||
@@ -36,11 +37,6 @@ 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'))
|
||||
@@ -69,12 +65,6 @@ 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
|
||||
|
||||
@@ -123,7 +113,12 @@ export const useGlobalStore = defineStore('global', () => {
|
||||
(userLang && userLang !== 'default' ? userLang : '') ||
|
||||
(response.current_company_settings as Record<string, string>)?.language ||
|
||||
'en'
|
||||
await (window as Record<string, unknown>).loadLanguage?.(uiLanguage)
|
||||
await window.loadLanguage?.(uiLanguage)
|
||||
|
||||
emitBootstrapCompleted({
|
||||
adminMode: response.admin_mode === true,
|
||||
companyId: response.current_company?.id ?? null,
|
||||
})
|
||||
|
||||
return response
|
||||
} catch (err: unknown) {
|
||||
@@ -302,7 +297,6 @@ export const useGlobalStore = defineStore('global', () => {
|
||||
mainMenu,
|
||||
settingMenu,
|
||||
userMenu,
|
||||
ai,
|
||||
isAppLoaded,
|
||||
isSidebarOpen,
|
||||
isSidebarCollapsed,
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
export interface AiSuggestedModel {
|
||||
value: string
|
||||
label: string
|
||||
}
|
||||
|
||||
export interface AiDriverConfigField {
|
||||
key: string
|
||||
type: 'text' | 'select'
|
||||
label: string
|
||||
default?: string
|
||||
options?: Array<{ label: string; value: string }>
|
||||
visible_when?: Record<string, string>
|
||||
}
|
||||
|
||||
export interface AiDriverOption {
|
||||
value: string
|
||||
label: string
|
||||
website: string
|
||||
default_base_url: string
|
||||
supported_roles: string[]
|
||||
suggested_models: AiSuggestedModel[]
|
||||
config_fields: AiDriverConfigField[]
|
||||
}
|
||||
|
||||
export interface AiDriversResponse {
|
||||
ai_drivers: AiDriverOption[]
|
||||
}
|
||||
|
||||
export interface AiConfig {
|
||||
ai_enabled: 'YES' | 'NO'
|
||||
ai_driver: string
|
||||
ai_api_key: string
|
||||
ai_base_url: string
|
||||
ai_chat_enabled: 'YES' | 'NO'
|
||||
ai_chat_model: string
|
||||
ai_text_generation_enabled: 'YES' | 'NO'
|
||||
ai_text_generation_model: string
|
||||
}
|
||||
|
||||
export interface CompanyAiConfig extends AiConfig {
|
||||
use_custom_ai_config: 'YES' | 'NO'
|
||||
}
|
||||
|
||||
export interface AiTestPayload {
|
||||
ai_driver: string
|
||||
ai_api_key?: string
|
||||
ai_base_url?: string
|
||||
}
|
||||
|
||||
export interface AiTestResponse {
|
||||
success?: boolean
|
||||
error?: string
|
||||
message?: string
|
||||
details?: Record<string, unknown>
|
||||
}
|
||||
|
||||
// --- 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[]
|
||||
}
|
||||
|
||||
// --- Phase 3: text generation types ---
|
||||
|
||||
export interface AiGenerateRequest {
|
||||
prompt: string
|
||||
context?: string
|
||||
}
|
||||
|
||||
export interface AiGenerateResponse {
|
||||
text?: string
|
||||
error?: string
|
||||
message?: string
|
||||
}
|
||||
@@ -1,46 +1,5 @@
|
||||
import { marked } from 'marked'
|
||||
import DOMPurify from 'dompurify'
|
||||
|
||||
/**
|
||||
* Render a markdown string to safe, sanitized HTML.
|
||||
*
|
||||
* Used by the AI chat drawer to render assistant responses. Even though
|
||||
* the AI provider controls the immediate source of the content, the model
|
||||
* can echo anything it's fed — including user input from earlier in the
|
||||
* conversation or tool results from the database. We therefore parse
|
||||
* markdown → HTML via marked and then sanitize the result with DOMPurify
|
||||
* before handing it to Vue's v-html.
|
||||
*
|
||||
* Marked is configured with:
|
||||
* - gfm: true — GitHub-flavored markdown (tables, fenced code,
|
||||
* strikethrough, task lists). Matches what users
|
||||
* already expect from any modern chat UI.
|
||||
* - breaks: true — newlines become <br> so a single user-typed line
|
||||
* break renders as a visual break without needing
|
||||
* two trailing spaces.
|
||||
* - async: false — force synchronous parsing so the caller doesn't
|
||||
* have to await; marked defaults to returning a
|
||||
* Promise when extensions are registered.
|
||||
*
|
||||
* DOMPurify is run in its default browser profile which strips <script>,
|
||||
* event handlers, javascript: URLs, and every other HTML vector. We do
|
||||
* NOT customize ALLOWED_TAGS because marked's output is already a
|
||||
* conservative subset of HTML.
|
||||
*/
|
||||
export function renderMarkdown(source: string): string {
|
||||
if (!source) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const rawHtml = marked.parse(source, {
|
||||
gfm: true,
|
||||
breaks: true,
|
||||
async: false,
|
||||
}) as string
|
||||
|
||||
return DOMPurify.sanitize(rawHtml)
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize a raw HTML string with DOMPurify's default browser profile
|
||||
* (strips <script>, event handlers, javascript: URLs, and every other HTML
|
||||
|
||||
@@ -16,14 +16,14 @@
|
||||
<meta name="theme-color" content="#ffffff">
|
||||
<meta name="csrf-token" content="{{ csrf_token() }}">
|
||||
|
||||
<!-- Module Styles -->
|
||||
@vite('resources/scripts/main.ts')
|
||||
|
||||
<!-- Module Styles (after the host bundle so Tailwind's layer order is established first) -->
|
||||
@foreach(\InvoiceShelf\Modules\Registry::allStyles() as $name => $path)
|
||||
@php($version = \App\Platform\Modules\Runtime\ModuleAssetVersion::forPath($path))
|
||||
<link rel="stylesheet" href="/modules/styles/{{ $name }}@if($version)?v={{ $version }}@endif">
|
||||
@endforeach
|
||||
|
||||
@vite('resources/scripts/main.ts')
|
||||
|
||||
<script>
|
||||
(function() {
|
||||
var theme = localStorage.getItem('theme') || 'system';
|
||||
|
||||
@@ -46,7 +46,6 @@ Route::prefix('/v1')->group(function () {
|
||||
|
||||
Route::middleware(['redirect-if-installed'])->prefix('installation')->group(function () {
|
||||
require app_path('Platform/Operations/Installation/routes/api.php');
|
||||
require app_path('Platform/Ai/routes/installer.php');
|
||||
});
|
||||
|
||||
// Super Admin
|
||||
@@ -120,8 +119,6 @@ Route::prefix('/v1')->group(function () {
|
||||
|
||||
require app_path('Platform/Mail/routes/company.php');
|
||||
|
||||
require app_path('Platform/Ai/routes/company.php');
|
||||
|
||||
// Tax Types
|
||||
// ----------------------------------
|
||||
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
<?php
|
||||
|
||||
use App\Domains\Accounts\Models\User;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Laravel\Sanctum\Sanctum;
|
||||
|
||||
use function Pest\Laravel\postJson;
|
||||
|
||||
/**
|
||||
* The admin/owner-supplied AI base URL must not be allowed to point the server
|
||||
* (with the bearer token attached) at a private or reserved address (SSRF).
|
||||
*/
|
||||
beforeEach(function () {
|
||||
Artisan::call('db:seed', ['--class' => 'DatabaseSeeder', '--force' => true]);
|
||||
Artisan::call('db:seed', ['--class' => 'DemoSeeder', '--force' => true]);
|
||||
|
||||
$this->user = User::find(1);
|
||||
$this->companyId = $this->user->companies()->first()->id;
|
||||
$this->withHeaders(['company' => $this->companyId]);
|
||||
Sanctum::actingAs($this->user, ['*']);
|
||||
});
|
||||
|
||||
test('admin AI config rejects a private base URL', function () {
|
||||
postJson('/api/v1/ai/config', [
|
||||
'ai_enabled' => 'YES',
|
||||
'ai_driver' => 'openrouter',
|
||||
'ai_api_key' => 'sk-test',
|
||||
'ai_base_url' => 'http://169.254.169.254/v1',
|
||||
'ai_chat_enabled' => 'YES',
|
||||
'ai_chat_model' => 'openai/gpt-4o',
|
||||
])->assertStatus(422)->assertJsonValidationErrors('ai_base_url');
|
||||
});
|
||||
|
||||
test('admin AI config still accepts a public base URL', function () {
|
||||
postJson('/api/v1/ai/config', [
|
||||
'ai_enabled' => 'YES',
|
||||
'ai_driver' => 'openrouter',
|
||||
'ai_api_key' => 'sk-test',
|
||||
'ai_base_url' => 'https://openrouter.ai/api/v1',
|
||||
'ai_chat_enabled' => 'YES',
|
||||
'ai_chat_model' => 'openai/gpt-4o',
|
||||
])->assertOk();
|
||||
});
|
||||
|
||||
test('admin AI test-connection rejects a private base URL', function () {
|
||||
postJson('/api/v1/ai/test', [
|
||||
'ai_driver' => 'openrouter',
|
||||
'ai_api_key' => 'sk-test',
|
||||
'ai_base_url' => 'http://127.0.0.1:11434',
|
||||
])->assertStatus(422)->assertJsonValidationErrors('ai_base_url');
|
||||
});
|
||||
|
||||
test('company AI config rejects a private base URL', function () {
|
||||
postJson('/api/v1/company/ai/config', [
|
||||
'use_custom_ai_config' => 'YES',
|
||||
'ai_enabled' => 'YES',
|
||||
'ai_driver' => 'openrouter',
|
||||
'ai_api_key' => 'company-key',
|
||||
'ai_base_url' => 'http://10.0.0.5',
|
||||
'ai_chat_enabled' => 'YES',
|
||||
'ai_chat_model' => 'openai/gpt-4o',
|
||||
])->assertStatus(422)->assertJsonValidationErrors('ai_base_url');
|
||||
});
|
||||
@@ -1,231 +0,0 @@
|
||||
<?php
|
||||
|
||||
use App\Domains\Accounts\Models\User;
|
||||
use App\Platform\Ai\Application\AiConfigurationService;
|
||||
use App\Platform\Ai\Contracts\AiDriver;
|
||||
use App\Platform\Ai\Data\AiChatResponse;
|
||||
use App\Platform\Ai\Drivers\AiDriverFactory;
|
||||
use App\Platform\Ai\Exceptions\AiException;
|
||||
use App\Platform\Ai\Models\AiConversation;
|
||||
use App\Platform\Ai\Models\AiMessage;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Laravel\Sanctum\Sanctum;
|
||||
use Tests\Support\ScriptedAiDriver;
|
||||
|
||||
use function Pest\Laravel\getJson;
|
||||
use function Pest\Laravel\postJson;
|
||||
|
||||
beforeEach(function () {
|
||||
Artisan::call('db:seed', ['--class' => 'DatabaseSeeder', '--force' => true]);
|
||||
Artisan::call('db:seed', ['--class' => 'DemoSeeder', '--force' => true]);
|
||||
|
||||
$this->user = User::find(1);
|
||||
$this->companyId = $this->user->companies()->first()->id;
|
||||
|
||||
$this->withHeaders(['company' => $this->companyId]);
|
||||
Sanctum::actingAs($this->user, ['*']);
|
||||
|
||||
// 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);
|
||||
});
|
||||
@@ -1,146 +0,0 @@
|
||||
<?php
|
||||
|
||||
use App\Domains\Accounts\Models\CompanySetting;
|
||||
use App\Domains\Accounts\Models\User;
|
||||
use App\Platform\Ai\Application\AiConfigurationService;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Laravel\Sanctum\Sanctum;
|
||||
|
||||
use function Pest\Laravel\getJson;
|
||||
use function Pest\Laravel\postJson;
|
||||
|
||||
beforeEach(function () {
|
||||
Artisan::call('db:seed', ['--class' => 'DatabaseSeeder', '--force' => true]);
|
||||
Artisan::call('db:seed', ['--class' => 'DemoSeeder', '--force' => true]);
|
||||
|
||||
$this->user = User::find(1);
|
||||
$this->companyId = $this->user->companies()->first()->id;
|
||||
|
||||
$this->withHeaders(['company' => $this->companyId]);
|
||||
Sanctum::actingAs($this->user, ['*']);
|
||||
});
|
||||
|
||||
test('admin can fetch AI driver list with metadata', function () {
|
||||
$response = getJson('/api/v1/ai/drivers')->assertOk();
|
||||
|
||||
$drivers = collect($response->json('ai_drivers'));
|
||||
$openrouter = $drivers->firstWhere('value', 'openrouter');
|
||||
|
||||
expect($openrouter)->not->toBeNull()
|
||||
->and($openrouter['label'])->toBe('settings.ai.openrouter')
|
||||
->and($openrouter['supported_roles'])->toContain('chat')
|
||||
->and($openrouter['suggested_models'])->not->toBeEmpty();
|
||||
});
|
||||
|
||||
test('admin can save and retrieve global AI config with api key masked in response', function () {
|
||||
postJson('/api/v1/ai/config', [
|
||||
'ai_enabled' => 'YES',
|
||||
'ai_driver' => 'openrouter',
|
||||
'ai_api_key' => 'sk-or-super-secret',
|
||||
'ai_base_url' => 'https://openrouter.ai/api/v1',
|
||||
'ai_chat_enabled' => 'YES',
|
||||
'ai_chat_model' => 'openai/gpt-4o',
|
||||
'ai_text_generation_enabled' => 'NO',
|
||||
'ai_text_generation_model' => '',
|
||||
])->assertOk();
|
||||
|
||||
$response = getJson('/api/v1/ai/config')->assertOk();
|
||||
|
||||
// API key is masked in responses — never returned in plaintext
|
||||
expect($response->json('ai_api_key'))->toBe('********');
|
||||
expect($response->json('ai_enabled'))->toBe('YES');
|
||||
expect($response->json('ai_chat_model'))->toBe('openai/gpt-4o');
|
||||
});
|
||||
|
||||
test('admin save preserves existing api key when masked placeholder is submitted', function () {
|
||||
// Initial save
|
||||
postJson('/api/v1/ai/config', [
|
||||
'ai_enabled' => 'YES',
|
||||
'ai_driver' => 'openrouter',
|
||||
'ai_api_key' => 'original-key',
|
||||
])->assertOk();
|
||||
|
||||
// Second save submits the masked placeholder — key should be preserved
|
||||
postJson('/api/v1/ai/config', [
|
||||
'ai_enabled' => 'YES',
|
||||
'ai_driver' => 'openrouter',
|
||||
'ai_api_key' => '********',
|
||||
'ai_chat_model' => 'openai/gpt-4o-mini',
|
||||
])->assertOk();
|
||||
|
||||
$service = app(AiConfigurationService::class);
|
||||
$config = $service->getGlobalConfig();
|
||||
|
||||
expect($config['ai_api_key'])->toBe('original-key')
|
||||
->and($config['ai_chat_model'])->toBe('openai/gpt-4o-mini');
|
||||
});
|
||||
|
||||
test('company save respects use_custom_ai_config toggle OFF', function () {
|
||||
postJson('/api/v1/company/ai/config', [
|
||||
'use_custom_ai_config' => 'NO',
|
||||
'ai_api_key' => 'should-not-be-saved',
|
||||
])->assertOk();
|
||||
|
||||
$raw = CompanySetting::getSettings(['use_custom_ai_config', 'company_ai_api_key'], $this->companyId)->all();
|
||||
expect($raw['use_custom_ai_config'] ?? null)->toBe('NO');
|
||||
expect($raw['company_ai_api_key'] ?? null)->toBeNull();
|
||||
});
|
||||
|
||||
test('company save with toggle ON persists company-specific driver fields', function () {
|
||||
postJson('/api/v1/company/ai/config', [
|
||||
'use_custom_ai_config' => 'YES',
|
||||
'ai_enabled' => 'YES',
|
||||
'ai_driver' => 'openrouter',
|
||||
'ai_api_key' => 'company-key',
|
||||
'ai_chat_enabled' => 'YES',
|
||||
'ai_chat_model' => 'anthropic/claude-3.5-sonnet',
|
||||
])->assertOk();
|
||||
|
||||
$response = getJson('/api/v1/company/ai/config')->assertOk();
|
||||
|
||||
expect($response->json('use_custom_ai_config'))->toBe('YES')
|
||||
->and($response->json('ai_chat_model'))->toBe('anthropic/claude-3.5-sonnet')
|
||||
->and($response->json('ai_api_key'))->toBe('********'); // masked
|
||||
});
|
||||
|
||||
test('bootstrap response surfaces ai flags based on resolution', function () {
|
||||
// No config — AI should be disabled
|
||||
$response = getJson('/api/v1/bootstrap')->assertOk();
|
||||
expect($response->json('ai.enabled'))->toBeFalse();
|
||||
|
||||
// Enable globally with chat enabled
|
||||
app(AiConfigurationService::class)->saveGlobalConfig([
|
||||
'ai_enabled' => 'YES',
|
||||
'ai_driver' => 'openrouter',
|
||||
'ai_api_key' => 'key',
|
||||
'ai_chat_enabled' => 'YES',
|
||||
'ai_chat_model' => 'openai/gpt-4o',
|
||||
'ai_text_generation_enabled' => 'NO',
|
||||
]);
|
||||
|
||||
$response = getJson('/api/v1/bootstrap')->assertOk();
|
||||
expect($response->json('ai.enabled'))->toBeTrue()
|
||||
->and($response->json('ai.chat_enabled'))->toBeTrue()
|
||||
->and($response->json('ai.text_generation_enabled'))->toBeFalse();
|
||||
});
|
||||
|
||||
test('bootstrap response hides ai when company opts out via override', function () {
|
||||
app(AiConfigurationService::class)->saveGlobalConfig([
|
||||
'ai_enabled' => 'YES',
|
||||
'ai_driver' => 'openrouter',
|
||||
'ai_api_key' => 'key',
|
||||
'ai_chat_enabled' => 'YES',
|
||||
'ai_chat_model' => 'openai/gpt-4o',
|
||||
]);
|
||||
|
||||
app(AiConfigurationService::class)->saveCompanyConfig($this->companyId, [
|
||||
'use_custom_ai_config' => 'YES',
|
||||
'ai_enabled' => 'NO',
|
||||
'ai_driver' => 'openrouter',
|
||||
'ai_api_key' => 'unused',
|
||||
]);
|
||||
|
||||
$response = getJson('/api/v1/bootstrap')->assertOk();
|
||||
|
||||
expect($response->json('ai.enabled'))->toBeFalse();
|
||||
});
|
||||
@@ -1,144 +0,0 @@
|
||||
<?php
|
||||
|
||||
use App\Domains\Accounts\Models\User;
|
||||
use App\Platform\Ai\Application\AiConfigurationService;
|
||||
use App\Platform\Ai\Contracts\AiDriver;
|
||||
use App\Platform\Ai\Data\AiChatResponse;
|
||||
use App\Platform\Ai\Drivers\AiDriverFactory;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Laravel\Sanctum\Sanctum;
|
||||
|
||||
use function Pest\Laravel\postJson;
|
||||
|
||||
/**
|
||||
* Scripted driver reused from AiChatFlowTest's strategy — tracks which prompt
|
||||
* went into textCompletion() and echoes a canned reply.
|
||||
*/
|
||||
class TextGenDriver extends AiDriver
|
||||
{
|
||||
public static ?string $lastPrompt = null;
|
||||
|
||||
public static ?string $lastModel = null;
|
||||
|
||||
public static string $reply = 'generated text';
|
||||
|
||||
public static int $callCount = 0;
|
||||
|
||||
public function chatCompletion(array $messages, string $model, array $tools = [], array $options = []): AiChatResponse
|
||||
{
|
||||
return new AiChatResponse(message: self::$reply);
|
||||
}
|
||||
|
||||
public function textCompletion(string $prompt, string $model, array $options = []): string
|
||||
{
|
||||
self::$lastPrompt = $prompt;
|
||||
self::$lastModel = $model;
|
||||
self::$callCount++;
|
||||
|
||||
return self::$reply;
|
||||
}
|
||||
|
||||
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, ['*']);
|
||||
|
||||
AiDriverFactory::register('textgen', TextGenDriver::class);
|
||||
TextGenDriver::$lastPrompt = null;
|
||||
TextGenDriver::$lastModel = null;
|
||||
TextGenDriver::$callCount = 0;
|
||||
TextGenDriver::$reply = 'generated text';
|
||||
|
||||
app(AiConfigurationService::class)->saveGlobalConfig([
|
||||
'ai_enabled' => 'YES',
|
||||
'ai_driver' => 'textgen',
|
||||
'ai_api_key' => 'test-key',
|
||||
'ai_text_generation_enabled' => 'YES',
|
||||
'ai_text_generation_model' => 'test-textgen-model',
|
||||
]);
|
||||
});
|
||||
|
||||
test('generate endpoint returns generated text for a valid prompt', function () {
|
||||
TextGenDriver::$reply = 'Dear customer, your invoice is overdue.';
|
||||
|
||||
$response = postJson('/api/v1/ai/generate', [
|
||||
'prompt' => 'Write a late payment reminder',
|
||||
])->assertOk();
|
||||
|
||||
expect($response->json('text'))->toBe('Dear customer, your invoice is overdue.');
|
||||
expect(TextGenDriver::$lastModel)->toBe('test-textgen-model');
|
||||
expect(TextGenDriver::$lastPrompt)->toContain('Write a late payment reminder');
|
||||
});
|
||||
|
||||
test('generate endpoint includes context in the prompt when provided', function () {
|
||||
postJson('/api/v1/ai/generate', [
|
||||
'prompt' => 'Rewrite this in a friendlier tone',
|
||||
'context' => 'PAY US NOW OR ELSE',
|
||||
])->assertOk();
|
||||
|
||||
expect(TextGenDriver::$lastPrompt)
|
||||
->toContain('Context (current content the user is working with):')
|
||||
->toContain('PAY US NOW OR ELSE')
|
||||
->toContain('Rewrite this in a friendlier tone');
|
||||
});
|
||||
|
||||
test('generate endpoint omits context section when no context is supplied', function () {
|
||||
postJson('/api/v1/ai/generate', [
|
||||
'prompt' => 'Hello world',
|
||||
])->assertOk();
|
||||
|
||||
expect(TextGenDriver::$lastPrompt)->not->toContain('Context (current content');
|
||||
});
|
||||
|
||||
test('generate endpoint rejects when AI is globally disabled', function () {
|
||||
app(AiConfigurationService::class)->saveGlobalConfig(['ai_enabled' => 'NO']);
|
||||
|
||||
postJson('/api/v1/ai/generate', ['prompt' => 'Hi'])->assertStatus(422);
|
||||
});
|
||||
|
||||
test('generate endpoint rejects when text_generation role is disabled', function () {
|
||||
app(AiConfigurationService::class)->saveGlobalConfig([
|
||||
'ai_enabled' => 'YES',
|
||||
'ai_driver' => 'textgen',
|
||||
'ai_api_key' => 'k',
|
||||
'ai_text_generation_enabled' => 'NO', // <— off
|
||||
'ai_text_generation_model' => 'test',
|
||||
]);
|
||||
|
||||
postJson('/api/v1/ai/generate', ['prompt' => 'Hi'])->assertStatus(422);
|
||||
});
|
||||
|
||||
test('generate endpoint validates prompt length', function () {
|
||||
// Empty prompt → validation error
|
||||
postJson('/api/v1/ai/generate', ['prompt' => ''])->assertStatus(422);
|
||||
|
||||
// Prompt over 4000 chars → validation error
|
||||
postJson('/api/v1/ai/generate', ['prompt' => str_repeat('a', 4001)])->assertStatus(422);
|
||||
|
||||
// Context over 20k chars → validation error
|
||||
postJson('/api/v1/ai/generate', [
|
||||
'prompt' => 'Hi',
|
||||
'context' => str_repeat('x', 20001),
|
||||
])->assertStatus(422);
|
||||
});
|
||||
|
||||
test('generate endpoint trims whitespace from the driver response', function () {
|
||||
TextGenDriver::$reply = " \n\n spaced out reply \n\n ";
|
||||
|
||||
$response = postJson('/api/v1/ai/generate', [
|
||||
'prompt' => 'anything',
|
||||
])->assertOk();
|
||||
|
||||
expect($response->json('text'))->toBe('spaced out reply');
|
||||
});
|
||||
@@ -1,174 +0,0 @@
|
||||
<?php
|
||||
|
||||
use App\Domains\Accounts\Models\User;
|
||||
use App\Domains\Contacts\Models\Customer;
|
||||
use App\Domains\Sales\Models\Invoice;
|
||||
use App\Platform\Ai\Application\AiConfigurationService;
|
||||
use App\Platform\Ai\Data\AiChatResponse;
|
||||
use App\Platform\Ai\Drivers\AiDriverFactory;
|
||||
use App\Platform\Ai\Models\AiMessage;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
use Laravel\Sanctum\Sanctum;
|
||||
use Silber\Bouncer\BouncerFacade;
|
||||
use Tests\Support\ScriptedAiDriver;
|
||||
|
||||
use function Pest\Laravel\postJson;
|
||||
|
||||
/**
|
||||
* The AI assistant must honour the SAME per-user Bouncer abilities as the rest
|
||||
* of the app: tools are hidden from the model when the user lacks the ability,
|
||||
* and executing one anyway returns a structured `unauthorized` error. Company
|
||||
* scoping is covered elsewhere; this file is specifically about per-user gating.
|
||||
*/
|
||||
beforeEach(function () {
|
||||
Artisan::call('db:seed', ['--class' => 'DatabaseSeeder', '--force' => true]);
|
||||
Artisan::call('db:seed', ['--class' => 'DemoSeeder', '--force' => true]);
|
||||
|
||||
$this->owner = User::find(1);
|
||||
$this->companyId = $this->owner->companies()->first()->id;
|
||||
|
||||
// A second user in the same company with NO abilities granted yet.
|
||||
$this->restricted = User::factory()->create();
|
||||
$this->restricted->companies()->attach($this->companyId);
|
||||
|
||||
app(AiConfigurationService::class)->saveGlobalConfig([
|
||||
'ai_enabled' => 'YES',
|
||||
'ai_driver' => 'scripted',
|
||||
'ai_api_key' => 'test-key',
|
||||
'ai_chat_enabled' => 'YES',
|
||||
'ai_chat_model' => 'test-model',
|
||||
]);
|
||||
|
||||
AiDriverFactory::register('scripted', ScriptedAiDriver::class);
|
||||
ScriptedAiDriver::reset();
|
||||
});
|
||||
|
||||
afterEach(fn () => ScriptedAiDriver::reset());
|
||||
|
||||
/**
|
||||
* Grant a Bouncer ability to a user within the company scope, mirroring how
|
||||
* CompanyService seeds the owner's abilities.
|
||||
*/
|
||||
function grantAbility(User $user, int $companyId, string $ability, ?string $model = null): void
|
||||
{
|
||||
BouncerFacade::scope()->to($companyId);
|
||||
|
||||
$model === null
|
||||
? BouncerFacade::allow($user)->to($ability)
|
||||
: BouncerFacade::allow($user)->to($ability, $model);
|
||||
}
|
||||
|
||||
test('tools the user lacks the ability for are hidden from the LLM', function () {
|
||||
grantAbility($this->restricted, $this->companyId, 'view-invoice', Invoice::class);
|
||||
|
||||
$this->withHeaders(['company' => $this->companyId]);
|
||||
Sanctum::actingAs($this->restricted, ['*']);
|
||||
|
||||
ScriptedAiDriver::setResponses([new AiChatResponse(message: 'ok')]);
|
||||
|
||||
postJson('/api/v1/ai/chat', ['message' => 'hi'])->assertOk();
|
||||
|
||||
$toolNames = collect(ScriptedAiDriver::$lastTools)->pluck('function.name');
|
||||
|
||||
// Invoice tools visible (has view-invoice)...
|
||||
expect($toolNames)->toContain('search_invoices')
|
||||
->and($toolNames)->toContain('get_invoice')
|
||||
->and($toolNames)->toContain('list_overdue_invoices');
|
||||
|
||||
// ...everything the user can't view is hidden.
|
||||
expect($toolNames)->not->toContain('search_customers')
|
||||
->and($toolNames)->not->toContain('get_customer')
|
||||
->and($toolNames)->not->toContain('rank_top_customers')
|
||||
->and($toolNames)->not->toContain('list_recent_payments')
|
||||
->and($toolNames)->not->toContain('search_items')
|
||||
->and($toolNames)->not->toContain('list_expense_categories')
|
||||
->and($toolNames)->not->toContain('get_company_stats');
|
||||
});
|
||||
|
||||
test('a user with no abilities is offered no tools at all', function () {
|
||||
$this->withHeaders(['company' => $this->companyId]);
|
||||
Sanctum::actingAs($this->restricted, ['*']);
|
||||
|
||||
ScriptedAiDriver::setResponses([new AiChatResponse(message: 'ok')]);
|
||||
|
||||
postJson('/api/v1/ai/chat', ['message' => 'hi'])->assertOk();
|
||||
|
||||
expect(ScriptedAiDriver::$lastTools)->toBe([]);
|
||||
});
|
||||
|
||||
test('executing an unauthorized tool returns an unauthorized error to the model', function () {
|
||||
grantAbility($this->restricted, $this->companyId, 'view-invoice', Invoice::class);
|
||||
|
||||
$this->withHeaders(['company' => $this->companyId]);
|
||||
Sanctum::actingAs($this->restricted, ['*']);
|
||||
|
||||
// The model is scripted to call a tool it was never offered (search_customers).
|
||||
ScriptedAiDriver::setResponses([
|
||||
new AiChatResponse(
|
||||
message: null,
|
||||
toolCalls: [[
|
||||
'id' => 'call_1',
|
||||
'name' => 'search_customers',
|
||||
'arguments' => ['query' => 'acme'],
|
||||
]],
|
||||
finishReason: 'tool_calls',
|
||||
),
|
||||
new AiChatResponse(message: 'done'),
|
||||
]);
|
||||
|
||||
postJson('/api/v1/ai/chat', ['message' => 'list every customer'])->assertOk();
|
||||
|
||||
$toolMessage = AiMessage::where('role', 'tool')->latest('id')->first();
|
||||
|
||||
expect($toolMessage)->not->toBeNull()
|
||||
->and($toolMessage->content)->toContain('unauthorized');
|
||||
});
|
||||
|
||||
test('a user with view-customer can use the customer tools', function () {
|
||||
grantAbility($this->restricted, $this->companyId, 'view-customer', Customer::class);
|
||||
|
||||
$this->withHeaders(['company' => $this->companyId]);
|
||||
Sanctum::actingAs($this->restricted, ['*']);
|
||||
|
||||
ScriptedAiDriver::setResponses([
|
||||
new AiChatResponse(
|
||||
message: null,
|
||||
toolCalls: [[
|
||||
'id' => 'call_1',
|
||||
'name' => 'search_customers',
|
||||
'arguments' => ['query' => ''],
|
||||
]],
|
||||
finishReason: 'tool_calls',
|
||||
),
|
||||
new AiChatResponse(message: 'Here are your customers.'),
|
||||
]);
|
||||
|
||||
postJson('/api/v1/ai/chat', ['message' => 'show customers'])->assertOk();
|
||||
|
||||
$toolMessage = AiMessage::where('role', 'tool')->latest('id')->first();
|
||||
|
||||
expect($toolMessage->content)->not->toContain('unauthorized')
|
||||
->and($toolMessage->content)->toContain('customers');
|
||||
|
||||
$toolNames = collect(ScriptedAiDriver::$lastTools)->pluck('function.name');
|
||||
expect($toolNames)->toContain('search_customers')
|
||||
->and($toolNames)->not->toContain('search_invoices');
|
||||
});
|
||||
|
||||
test('a fully-privileged owner is offered every tool, including stats', function () {
|
||||
$this->withHeaders(['company' => $this->companyId]);
|
||||
Sanctum::actingAs($this->owner, ['*']);
|
||||
|
||||
ScriptedAiDriver::setResponses([new AiChatResponse(message: 'ok')]);
|
||||
|
||||
postJson('/api/v1/ai/chat', ['message' => 'hi'])->assertOk();
|
||||
|
||||
$toolNames = collect(ScriptedAiDriver::$lastTools)->pluck('function.name');
|
||||
|
||||
expect($toolNames)->toContain('search_invoices')
|
||||
->and($toolNames)->toContain('search_customers')
|
||||
->and($toolNames)->toContain('list_recent_payments')
|
||||
->and($toolNames)->toContain('search_items')
|
||||
->and($toolNames)->toContain('list_expense_categories')
|
||||
->and($toolNames)->toContain('get_company_stats');
|
||||
});
|
||||
@@ -1,50 +0,0 @@
|
||||
<?php
|
||||
|
||||
use App\Domains\Accounts\Models\Company;
|
||||
use App\Domains\Contacts\Models\Customer;
|
||||
use App\Domains\Receivables\Models\Payment;
|
||||
use App\Domains\Receivables\Models\PaymentAllocation;
|
||||
use App\Domains\Sales\Models\Invoice;
|
||||
use App\Platform\Ai\Application\Tools\ListRecentPaymentsTool;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
|
||||
beforeEach(function () {
|
||||
Artisan::call('db:seed', ['--class' => 'DatabaseSeeder', '--force' => true]);
|
||||
Artisan::call('db:seed', ['--class' => 'DemoSeeder', '--force' => true]);
|
||||
});
|
||||
|
||||
test('list recent payments returns all allocations and unapplied credit', function () {
|
||||
$company = Company::first();
|
||||
$customer = Customer::factory()->create(['company_id' => $company->id]);
|
||||
$invoice = Invoice::factory()->create([
|
||||
'company_id' => $company->id,
|
||||
'customer_id' => $customer->id,
|
||||
]);
|
||||
$payment = Payment::factory()->create([
|
||||
'company_id' => $company->id,
|
||||
'customer_id' => $customer->id,
|
||||
'amount' => 1000,
|
||||
'base_amount' => 1000,
|
||||
'payment_date' => now()->toDateString(),
|
||||
]);
|
||||
PaymentAllocation::factory()->create([
|
||||
'payment_id' => $payment->id,
|
||||
'invoice_id' => $invoice->id,
|
||||
'amount' => 600,
|
||||
'base_amount' => 600,
|
||||
]);
|
||||
|
||||
$result = (new ListRecentPaymentsTool)->execute(['days' => 1], $company->id, 1);
|
||||
$row = collect($result['payments'])->firstWhere('id', $payment->id);
|
||||
|
||||
expect($row)
|
||||
->toMatchArray([
|
||||
'allocated_amount' => 600,
|
||||
'unallocated_amount' => 400,
|
||||
])
|
||||
->not->toHaveKey('invoice_id')
|
||||
->and($row['allocations'])->toContain([
|
||||
'invoice_id' => $invoice->id,
|
||||
'amount' => 600,
|
||||
]);
|
||||
});
|
||||
@@ -1,111 +0,0 @@
|
||||
<?php
|
||||
|
||||
use App\Domains\Accounts\Models\Company;
|
||||
use App\Domains\Purchases\Models\Expense;
|
||||
use App\Domains\Purchases\Models\ExpenseCategory;
|
||||
use App\Platform\Ai\Application\Tools\RankExpenseCategoriesTool;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
|
||||
beforeEach(function () {
|
||||
Artisan::call('db:seed', ['--class' => 'DatabaseSeeder', '--force' => true]);
|
||||
Artisan::call('db:seed', ['--class' => 'DemoSeeder', '--force' => true]);
|
||||
});
|
||||
|
||||
test('rank_expense_categories orders categories by total spend', function () {
|
||||
$company = Company::first();
|
||||
|
||||
$software = ExpenseCategory::factory()->create(['company_id' => $company->id, 'name' => 'Software']);
|
||||
$travel = ExpenseCategory::factory()->create(['company_id' => $company->id, 'name' => 'Travel']);
|
||||
$office = ExpenseCategory::factory()->create(['company_id' => $company->id, 'name' => 'Office Supplies']);
|
||||
|
||||
// Software: 2 expenses totalling 30000
|
||||
Expense::factory()->create([
|
||||
'company_id' => $company->id, 'expense_category_id' => $software->id, 'amount' => 20000,
|
||||
]);
|
||||
Expense::factory()->create([
|
||||
'company_id' => $company->id, 'expense_category_id' => $software->id, 'amount' => 10000,
|
||||
]);
|
||||
|
||||
// Travel: 1 expense, 15000
|
||||
Expense::factory()->create([
|
||||
'company_id' => $company->id, 'expense_category_id' => $travel->id, 'amount' => 15000,
|
||||
]);
|
||||
|
||||
// Office: 1 expense, 2000
|
||||
Expense::factory()->create([
|
||||
'company_id' => $company->id, 'expense_category_id' => $office->id, 'amount' => 2000,
|
||||
]);
|
||||
|
||||
$result = (new RankExpenseCategoriesTool)->execute([], $company->id, 1);
|
||||
|
||||
// Grab only the three we created
|
||||
$byName = collect($result['categories'])->keyBy('name');
|
||||
|
||||
expect($byName['Software']['total_amount'])->toBe(30000.0);
|
||||
expect($byName['Software']['expense_count'])->toBe(2);
|
||||
expect($byName['Travel']['total_amount'])->toBe(15000.0);
|
||||
expect($byName['Office Supplies']['total_amount'])->toBe(2000.0);
|
||||
|
||||
// Ordering: Software (30k) > Travel (15k) > Office (2k). First three positions should be ours.
|
||||
$names = collect($result['categories'])->pluck('name')->values()->all();
|
||||
$idxSoftware = array_search('Software', $names, true);
|
||||
$idxTravel = array_search('Travel', $names, true);
|
||||
$idxOffice = array_search('Office Supplies', $names, true);
|
||||
expect($idxSoftware)->toBeLessThan($idxTravel);
|
||||
expect($idxTravel)->toBeLessThan($idxOffice);
|
||||
});
|
||||
|
||||
test('rank_expense_categories does not leak across companies', function () {
|
||||
$companyA = Company::first();
|
||||
$companyB = Company::factory()->create();
|
||||
|
||||
$catA = ExpenseCategory::factory()->create(['company_id' => $companyA->id, 'name' => 'Company A Cat']);
|
||||
$catB = ExpenseCategory::factory()->create(['company_id' => $companyB->id, 'name' => 'Company B Cat']);
|
||||
|
||||
Expense::factory()->create([
|
||||
'company_id' => $companyA->id, 'expense_category_id' => $catA->id, 'amount' => 1000,
|
||||
]);
|
||||
Expense::factory()->create([
|
||||
'company_id' => $companyB->id, 'expense_category_id' => $catB->id, 'amount' => 999999,
|
||||
]);
|
||||
|
||||
// Call with companyA — should only see A's category
|
||||
$result = (new RankExpenseCategoriesTool)->execute([], $companyA->id, 1);
|
||||
|
||||
expect(collect($result['categories'])->pluck('name'))
|
||||
->toContain('Company A Cat')
|
||||
->not->toContain('Company B Cat');
|
||||
});
|
||||
|
||||
test('rank_expense_categories rejects an invalid period', function () {
|
||||
$company = Company::first();
|
||||
|
||||
$result = (new RankExpenseCategoriesTool)->execute(
|
||||
['period' => 'next_century'],
|
||||
$company->id,
|
||||
1,
|
||||
);
|
||||
|
||||
expect($result)->toHaveKey('error', 'invalid_period');
|
||||
});
|
||||
|
||||
test('rank_expense_categories respects the limit parameter', function () {
|
||||
$company = Company::first();
|
||||
|
||||
// Create 6 categories each with one expense
|
||||
for ($i = 0; $i < 6; $i++) {
|
||||
$cat = ExpenseCategory::factory()->create([
|
||||
'company_id' => $company->id,
|
||||
'name' => "TestCat {$i}",
|
||||
]);
|
||||
Expense::factory()->create([
|
||||
'company_id' => $company->id,
|
||||
'expense_category_id' => $cat->id,
|
||||
'amount' => 1000 * ($i + 1),
|
||||
]);
|
||||
}
|
||||
|
||||
$result = (new RankExpenseCategoriesTool)->execute(['limit' => 3], $company->id, 1);
|
||||
|
||||
expect($result['categories'])->toHaveCount(3);
|
||||
});
|
||||
@@ -1,183 +0,0 @@
|
||||
<?php
|
||||
|
||||
use App\Domains\Accounts\Models\Company;
|
||||
use App\Domains\Contacts\Models\Customer;
|
||||
use App\Domains\Receivables\Models\Payment;
|
||||
use App\Domains\Sales\Models\Invoice;
|
||||
use App\Platform\Ai\Application\Tools\RankTopCustomersTool;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
|
||||
beforeEach(function () {
|
||||
Artisan::call('db:seed', ['--class' => 'DatabaseSeeder', '--force' => true]);
|
||||
Artisan::call('db:seed', ['--class' => 'DemoSeeder', '--force' => true]);
|
||||
});
|
||||
|
||||
test('rank_top_customers ranks by invoiced_total correctly', function () {
|
||||
$company = Company::first();
|
||||
|
||||
$bigSpender = Customer::factory()->create(['company_id' => $company->id, 'name' => 'Big Spender']);
|
||||
$midSpender = Customer::factory()->create(['company_id' => $company->id, 'name' => 'Mid Spender']);
|
||||
$smallSpender = Customer::factory()->create(['company_id' => $company->id, 'name' => 'Small Spender']);
|
||||
|
||||
// Big: 2 invoices totalling 100000. Mid: 1 invoice totalling 50000. Small: 1 invoice totalling 10000.
|
||||
Invoice::factory()->create(['company_id' => $company->id, 'customer_id' => $bigSpender->id, 'total' => 60000]);
|
||||
Invoice::factory()->create(['company_id' => $company->id, 'customer_id' => $bigSpender->id, 'total' => 40000]);
|
||||
Invoice::factory()->create(['company_id' => $company->id, 'customer_id' => $midSpender->id, 'total' => 50000]);
|
||||
Invoice::factory()->create(['company_id' => $company->id, 'customer_id' => $smallSpender->id, 'total' => 10000]);
|
||||
|
||||
$result = (new RankTopCustomersTool)->execute(
|
||||
['metric' => 'invoiced_total', 'limit' => 3],
|
||||
$company->id,
|
||||
1,
|
||||
);
|
||||
|
||||
expect($result['metric'])->toBe('invoiced_total');
|
||||
expect($result['customers'])->toHaveCount(3);
|
||||
expect($result['customers'][0]['name'])->toBe('Big Spender');
|
||||
expect($result['customers'][0]['metric_value'])->toBe(100000.0);
|
||||
expect($result['customers'][0]['invoice_count'])->toBe(2);
|
||||
expect($result['customers'][1]['name'])->toBe('Mid Spender');
|
||||
expect($result['customers'][2]['name'])->toBe('Small Spender');
|
||||
});
|
||||
|
||||
test('rank_top_customers ranks by invoice_count correctly', function () {
|
||||
$company = Company::first();
|
||||
|
||||
$busy = Customer::factory()->create(['company_id' => $company->id, 'name' => 'Busy Bee']);
|
||||
$quiet = Customer::factory()->create(['company_id' => $company->id, 'name' => 'Quiet One']);
|
||||
|
||||
// Busy has 5 tiny invoices, Quiet has 1 big one.
|
||||
for ($i = 0; $i < 5; $i++) {
|
||||
Invoice::factory()->create(['company_id' => $company->id, 'customer_id' => $busy->id, 'total' => 100]);
|
||||
}
|
||||
Invoice::factory()->create(['company_id' => $company->id, 'customer_id' => $quiet->id, 'total' => 99999]);
|
||||
|
||||
$result = (new RankTopCustomersTool)->execute(
|
||||
['metric' => 'invoice_count'],
|
||||
$company->id,
|
||||
1,
|
||||
);
|
||||
|
||||
expect($result['customers'][0]['name'])->toBe('Busy Bee');
|
||||
expect($result['customers'][0]['metric_value'])->toBe(5);
|
||||
expect($result['customers'][1]['name'])->toBe('Quiet One');
|
||||
expect($result['customers'][1]['metric_value'])->toBe(1);
|
||||
});
|
||||
|
||||
test('rank_top_customers ranks by outstanding_balance and ignores period', function () {
|
||||
$company = Company::first();
|
||||
|
||||
$debtor = Customer::factory()->create(['company_id' => $company->id, 'name' => 'Owes A Lot']);
|
||||
$upToDate = Customer::factory()->create(['company_id' => $company->id, 'name' => 'Up To Date']);
|
||||
|
||||
// Debtor has an unpaid invoice with 75000 due_amount.
|
||||
Invoice::factory()->create([
|
||||
'company_id' => $company->id,
|
||||
'customer_id' => $debtor->id,
|
||||
'total' => 100000,
|
||||
'due_amount' => 75000,
|
||||
'paid_status' => 'PARTIALLY_PAID',
|
||||
]);
|
||||
|
||||
// Up To Date has only a fully-paid invoice — should NOT appear.
|
||||
Invoice::factory()->create([
|
||||
'company_id' => $company->id,
|
||||
'customer_id' => $upToDate->id,
|
||||
'total' => 50000,
|
||||
'due_amount' => 0,
|
||||
'paid_status' => 'PAID',
|
||||
]);
|
||||
|
||||
// Pass an obviously wrong period — outstanding_balance should ignore it.
|
||||
$result = (new RankTopCustomersTool)->execute(
|
||||
['metric' => 'outstanding_balance', 'period' => 'today'],
|
||||
$company->id,
|
||||
1,
|
||||
);
|
||||
|
||||
expect($result['period'])->toBe('current');
|
||||
expect(collect($result['customers'])->pluck('name'))
|
||||
->toContain('Owes A Lot')
|
||||
->not->toContain('Up To Date');
|
||||
});
|
||||
|
||||
test('rank_top_customers respects the limit parameter and default', function () {
|
||||
$company = Company::first();
|
||||
|
||||
// 7 customers, each with one invoice
|
||||
for ($i = 0; $i < 7; $i++) {
|
||||
$c = Customer::factory()->create(['company_id' => $company->id]);
|
||||
Invoice::factory()->create(['company_id' => $company->id, 'customer_id' => $c->id, 'total' => 1000]);
|
||||
}
|
||||
|
||||
$tool = new RankTopCustomersTool;
|
||||
|
||||
// Default limit is 5
|
||||
$result = $tool->execute(['metric' => 'invoiced_total'], $company->id, 1);
|
||||
expect($result['customers'])->toHaveCount(5);
|
||||
|
||||
// Explicit limit 2
|
||||
$result = $tool->execute(['metric' => 'invoiced_total', 'limit' => 2], $company->id, 1);
|
||||
expect($result['customers'])->toHaveCount(2);
|
||||
|
||||
// Over-the-cap limit is clamped to 20
|
||||
$result = $tool->execute(['metric' => 'invoiced_total', 'limit' => 999], $company->id, 1);
|
||||
expect(count($result['customers']))->toBeLessThanOrEqual(20);
|
||||
});
|
||||
|
||||
test('rank_top_customers does not leak across companies', function () {
|
||||
$companyA = Company::first();
|
||||
$companyB = Company::factory()->create();
|
||||
|
||||
$customerA = Customer::factory()->create(['company_id' => $companyA->id, 'name' => 'Company A Cust']);
|
||||
$customerB = Customer::factory()->create(['company_id' => $companyB->id, 'name' => 'Company B Cust']);
|
||||
|
||||
Invoice::factory()->create(['company_id' => $companyA->id, 'customer_id' => $customerA->id, 'total' => 5000]);
|
||||
Invoice::factory()->create(['company_id' => $companyB->id, 'customer_id' => $customerB->id, 'total' => 999999]);
|
||||
|
||||
// Call with companyA — should only see companyA's customer
|
||||
$result = (new RankTopCustomersTool)->execute(
|
||||
['metric' => 'invoiced_total'],
|
||||
$companyA->id,
|
||||
1,
|
||||
);
|
||||
|
||||
expect(collect($result['customers'])->pluck('name'))
|
||||
->toContain('Company A Cust')
|
||||
->not->toContain('Company B Cust');
|
||||
});
|
||||
|
||||
test('rank_top_customers rejects an invalid metric', function () {
|
||||
$company = Company::first();
|
||||
|
||||
$result = (new RankTopCustomersTool)->execute(
|
||||
['metric' => 'not_a_metric'],
|
||||
$company->id,
|
||||
1,
|
||||
);
|
||||
|
||||
expect($result)->toHaveKey('error', 'invalid_metric');
|
||||
});
|
||||
|
||||
test('rank_top_customers ranks by paid_total using payment records', function () {
|
||||
$company = Company::first();
|
||||
|
||||
$topPayer = Customer::factory()->create(['company_id' => $company->id, 'name' => 'Top Payer']);
|
||||
$lowPayer = Customer::factory()->create(['company_id' => $company->id, 'name' => 'Low Payer']);
|
||||
|
||||
// Top Payer has 2 payments totalling 80000. Low Payer has 1 payment for 20000.
|
||||
Payment::factory()->create(['company_id' => $company->id, 'customer_id' => $topPayer->id, 'amount' => 50000]);
|
||||
Payment::factory()->create(['company_id' => $company->id, 'customer_id' => $topPayer->id, 'amount' => 30000]);
|
||||
Payment::factory()->create(['company_id' => $company->id, 'customer_id' => $lowPayer->id, 'amount' => 20000]);
|
||||
|
||||
$result = (new RankTopCustomersTool)->execute(
|
||||
['metric' => 'paid_total'],
|
||||
$company->id,
|
||||
1,
|
||||
);
|
||||
|
||||
expect($result['customers'][0]['name'])->toBe('Top Payer');
|
||||
expect($result['customers'][0]['metric_value'])->toBe(80000.0);
|
||||
expect($result['customers'][1]['name'])->toBe('Low Payer');
|
||||
expect($result['customers'][1]['metric_value'])->toBe(20000.0);
|
||||
});
|
||||
@@ -1,193 +0,0 @@
|
||||
<?php
|
||||
|
||||
use App\Domains\Accounts\Models\Company;
|
||||
use App\Domains\Catalog\Models\Item;
|
||||
use App\Domains\Catalog\Models\Unit;
|
||||
use App\Domains\Contacts\Models\Customer;
|
||||
use App\Domains\Sales\Models\Invoice;
|
||||
use App\Domains\Sales\Models\InvoiceItem;
|
||||
use App\Platform\Ai\Application\Tools\RankTopItemsTool;
|
||||
use Illuminate\Support\Facades\Artisan;
|
||||
|
||||
beforeEach(function () {
|
||||
Artisan::call('db:seed', ['--class' => 'DatabaseSeeder', '--force' => true]);
|
||||
Artisan::call('db:seed', ['--class' => 'DemoSeeder', '--force' => true]);
|
||||
});
|
||||
|
||||
/**
|
||||
* Helper: create an Item with a known name/price for this company. The Item
|
||||
* factory has known bugs (cascades RecurringInvoice, hardcoded creator_id),
|
||||
* so we use direct ::create() for determinism.
|
||||
*/
|
||||
function makeItem(int $companyId, string $name, int $price): Item
|
||||
{
|
||||
$unit = Unit::where('company_id', $companyId)->firstOrFail();
|
||||
|
||||
return Item::create([
|
||||
'name' => $name,
|
||||
'description' => $name.' description',
|
||||
'price' => $price,
|
||||
'company_id' => $companyId,
|
||||
'unit_id' => $unit->id,
|
||||
'currency_id' => 1,
|
||||
]);
|
||||
}
|
||||
|
||||
test('rank_top_items ranks by revenue correctly', function () {
|
||||
$company = Company::first();
|
||||
$customer = Customer::factory()->create(['company_id' => $company->id]);
|
||||
|
||||
$premium = makeItem($company->id, 'Premium Widget', 10000);
|
||||
$standard = makeItem($company->id, 'Standard Widget', 5000);
|
||||
$budget = makeItem($company->id, 'Budget Widget', 1000);
|
||||
|
||||
$invoice = Invoice::factory()->create([
|
||||
'company_id' => $company->id,
|
||||
'customer_id' => $customer->id,
|
||||
]);
|
||||
|
||||
// Premium: 2 × 10000 = 20000
|
||||
// Standard: 3 × 5000 = 15000
|
||||
// Budget: 5 × 1000 = 5000
|
||||
InvoiceItem::factory()->create([
|
||||
'invoice_id' => $invoice->id, 'company_id' => $company->id,
|
||||
'item_id' => $premium->id, 'quantity' => 2, 'price' => 10000, 'total' => 20000,
|
||||
]);
|
||||
InvoiceItem::factory()->create([
|
||||
'invoice_id' => $invoice->id, 'company_id' => $company->id,
|
||||
'item_id' => $standard->id, 'quantity' => 3, 'price' => 5000, 'total' => 15000,
|
||||
]);
|
||||
InvoiceItem::factory()->create([
|
||||
'invoice_id' => $invoice->id, 'company_id' => $company->id,
|
||||
'item_id' => $budget->id, 'quantity' => 5, 'price' => 1000, 'total' => 5000,
|
||||
]);
|
||||
|
||||
$result = (new RankTopItemsTool)->execute(
|
||||
['metric' => 'revenue'],
|
||||
$company->id,
|
||||
1,
|
||||
);
|
||||
|
||||
expect($result['metric'])->toBe('revenue');
|
||||
expect($result['items'][0]['name'])->toBe('Premium Widget');
|
||||
expect($result['items'][0]['revenue'])->toBe(20000.0);
|
||||
expect($result['items'][1]['name'])->toBe('Standard Widget');
|
||||
expect($result['items'][2]['name'])->toBe('Budget Widget');
|
||||
});
|
||||
|
||||
test('rank_top_items ranks by quantity_sold correctly', function () {
|
||||
$company = Company::first();
|
||||
$customer = Customer::factory()->create(['company_id' => $company->id]);
|
||||
|
||||
$bulky = makeItem($company->id, 'Bulk Good', 100);
|
||||
$pricey = makeItem($company->id, 'Pricey Good', 50000);
|
||||
|
||||
$invoice = Invoice::factory()->create([
|
||||
'company_id' => $company->id,
|
||||
'customer_id' => $customer->id,
|
||||
]);
|
||||
|
||||
// Bulky: sold 50 units at 100 each = 5000 revenue
|
||||
// Pricey: sold 1 unit at 50000 = 50000 revenue
|
||||
InvoiceItem::factory()->create([
|
||||
'invoice_id' => $invoice->id, 'company_id' => $company->id,
|
||||
'item_id' => $bulky->id, 'quantity' => 50, 'price' => 100, 'total' => 5000,
|
||||
]);
|
||||
InvoiceItem::factory()->create([
|
||||
'invoice_id' => $invoice->id, 'company_id' => $company->id,
|
||||
'item_id' => $pricey->id, 'quantity' => 1, 'price' => 50000, 'total' => 50000,
|
||||
]);
|
||||
|
||||
// By quantity: Bulky wins (50 > 1)
|
||||
$result = (new RankTopItemsTool)->execute(
|
||||
['metric' => 'quantity_sold'],
|
||||
$company->id,
|
||||
1,
|
||||
);
|
||||
|
||||
expect($result['items'][0]['name'])->toBe('Bulk Good');
|
||||
expect($result['items'][0]['quantity_sold'])->toBe(50.0);
|
||||
|
||||
// By revenue: Pricey wins (50000 > 5000)
|
||||
$result = (new RankTopItemsTool)->execute(
|
||||
['metric' => 'revenue'],
|
||||
$company->id,
|
||||
1,
|
||||
);
|
||||
|
||||
expect($result['items'][0]['name'])->toBe('Pricey Good');
|
||||
});
|
||||
|
||||
test('rank_top_items excludes ad-hoc line items with null item_id', function () {
|
||||
$company = Company::first();
|
||||
$customer = Customer::factory()->create(['company_id' => $company->id]);
|
||||
|
||||
$cataloged = makeItem($company->id, 'Real Item', 1000);
|
||||
|
||||
$invoice = Invoice::factory()->create([
|
||||
'company_id' => $company->id,
|
||||
'customer_id' => $customer->id,
|
||||
]);
|
||||
|
||||
// Cataloged item on the invoice
|
||||
InvoiceItem::factory()->create([
|
||||
'invoice_id' => $invoice->id, 'company_id' => $company->id,
|
||||
'item_id' => $cataloged->id, 'quantity' => 1, 'price' => 1000, 'total' => 1000,
|
||||
]);
|
||||
|
||||
// Ad-hoc line without a catalog item — simulate via direct ::create
|
||||
InvoiceItem::create([
|
||||
'invoice_id' => $invoice->id,
|
||||
'company_id' => $company->id,
|
||||
'item_id' => null,
|
||||
'name' => 'Ad hoc typed line',
|
||||
'price' => 9999,
|
||||
'quantity' => 1,
|
||||
'total' => 9999,
|
||||
'discount' => 0,
|
||||
'discount_val' => 0,
|
||||
'discount_type' => 'fixed',
|
||||
'tax' => 0,
|
||||
'base_price' => 9999,
|
||||
'base_total' => 9999,
|
||||
'base_discount_val' => 0,
|
||||
'base_tax' => 0,
|
||||
'exchange_rate' => 1,
|
||||
]);
|
||||
|
||||
$result = (new RankTopItemsTool)->execute(
|
||||
['metric' => 'revenue'],
|
||||
$company->id,
|
||||
1,
|
||||
);
|
||||
|
||||
// Only the cataloged item should appear — ad-hoc line is excluded by whereNotNull.
|
||||
expect($result['items'])->toHaveCount(1);
|
||||
expect($result['items'][0]['name'])->toBe('Real Item');
|
||||
});
|
||||
|
||||
test('rank_top_items does not leak across companies', function () {
|
||||
$companyA = Company::first();
|
||||
$companyB = Company::factory()->create();
|
||||
|
||||
$itemA = makeItem($companyA->id, 'A Only Item', 1000);
|
||||
|
||||
$customerA = Customer::factory()->create(['company_id' => $companyA->id]);
|
||||
$invoiceA = Invoice::factory()->create([
|
||||
'company_id' => $companyA->id,
|
||||
'customer_id' => $customerA->id,
|
||||
]);
|
||||
InvoiceItem::factory()->create([
|
||||
'invoice_id' => $invoiceA->id, 'company_id' => $companyA->id,
|
||||
'item_id' => $itemA->id, 'quantity' => 1, 'price' => 1000, 'total' => 1000,
|
||||
]);
|
||||
|
||||
// Call with companyB — should see no items at all
|
||||
$result = (new RankTopItemsTool)->execute(
|
||||
['metric' => 'revenue'],
|
||||
$companyB->id,
|
||||
1,
|
||||
);
|
||||
|
||||
expect($result['items'])->toBeEmpty();
|
||||
});
|
||||
@@ -1,93 +0,0 @@
|
||||
<?php
|
||||
|
||||
use App\Domains\Accounts\Models\Company;
|
||||
use App\Domains\Contacts\Models\Customer;
|
||||
use App\Domains\Sales\Models\Invoice;
|
||||
use App\Platform\Ai\Application\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);
|
||||
});
|
||||
@@ -1,56 +0,0 @@
|
||||
<?php
|
||||
|
||||
use App\Platform\Ai\AiServiceProvider;
|
||||
use App\Platform\Ai\Application\AiToolRegistry;
|
||||
use App\Platform\Ai\Drivers\OpenRouterDriver;
|
||||
use App\Platform\Ai\Http\Company\ChatController;
|
||||
use App\Platform\Ai\Models\AiConversation;
|
||||
use App\Platform\Ai\Policies\AiConversationPolicy;
|
||||
use Illuminate\Support\Facades\Gate;
|
||||
use Illuminate\Support\Facades\RateLimiter;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
use InvoiceShelf\Modules\Registry;
|
||||
|
||||
test('the ai platform owns its provider extensions and authorization', function () {
|
||||
expect(app()->getProviders(AiServiceProvider::class))->toHaveCount(1)
|
||||
->and(app(AiToolRegistry::class))->toBe(app(AiToolRegistry::class))
|
||||
->and(Gate::getPolicyFor(AiConversation::class))->toBeInstanceOf(AiConversationPolicy::class)
|
||||
->and(Gate::has('manage ai config'))->toBeTrue()
|
||||
->and(Gate::has('use ai'))->toBeTrue()
|
||||
->and(RateLimiter::limiter('ai'))->not->toBeNull()
|
||||
->and(Registry::driverMeta('ai', 'openrouter')['class'])->toBe(OpenRouterDriver::class);
|
||||
|
||||
expect(class_exists('App\\Providers\\AiServiceProvider'))->toBeFalse()
|
||||
->and(class_exists('App\\Services\\Ai\\AiAssistantService'))->toBeFalse()
|
||||
->and(class_exists('App\\Support\\Ai\\AiDriver'))->toBeFalse()
|
||||
->and(class_exists('App\\Policies\\AiConversationPolicy'))->toBeFalse();
|
||||
});
|
||||
|
||||
test('the ai platform preserves its public routes and middleware', function () {
|
||||
$routes = collect(Route::getRoutes()->getRoutes())
|
||||
->filter(fn ($route): bool => str_contains($route->uri(), '/ai/'))
|
||||
->keyBy(fn ($route): string => implode('|', $route->methods()).' '.$route->uri());
|
||||
|
||||
expect($routes->keys()->sort()->values()->all())->toBe(collect([
|
||||
'DELETE api/v1/ai/conversations/{id}',
|
||||
'GET|HEAD api/v1/ai/config',
|
||||
'GET|HEAD api/v1/ai/conversations',
|
||||
'GET|HEAD api/v1/ai/conversations/{id}',
|
||||
'GET|HEAD api/v1/ai/drivers',
|
||||
'GET|HEAD api/v1/company/ai/config',
|
||||
'GET|HEAD api/v1/installation/ai/config',
|
||||
'PATCH api/v1/ai/conversations/{id}',
|
||||
'POST api/v1/ai/chat',
|
||||
'POST api/v1/ai/config',
|
||||
'POST api/v1/ai/generate',
|
||||
'POST api/v1/ai/test',
|
||||
'POST api/v1/company/ai/config',
|
||||
'POST api/v1/company/ai/test',
|
||||
'POST api/v1/installation/ai/config',
|
||||
])->sort()->values()->all());
|
||||
|
||||
$chat = $routes->get('POST api/v1/ai/chat');
|
||||
|
||||
expect($chat->getActionName())->toBe(ChatController::class)
|
||||
->and($chat->gatherMiddleware())->toContain('auth:sanctum', 'company', 'bouncer', 'throttle:ai');
|
||||
});
|
||||
@@ -115,10 +115,8 @@ test('the operations platform preserves its public routes and middleware', funct
|
||||
|
||||
test('the operations platform owns installation routes and middleware', function () {
|
||||
$routes = collect(Route::getRoutes()->getRoutes())
|
||||
->filter(fn ($route): bool => (
|
||||
str_starts_with($route->uri(), 'api/v1/installation/')
|
||||
&& ! str_starts_with($route->uri(), 'api/v1/installation/ai/')
|
||||
) || str_starts_with($route->uri(), 'installation'))
|
||||
->filter(fn ($route): bool => str_starts_with($route->uri(), 'api/v1/installation/')
|
||||
|| str_starts_with($route->uri(), 'installation'))
|
||||
->keyBy(fn ($route): string => implode('|', $route->methods()).' '.$route->uri());
|
||||
|
||||
expect($routes->keys()->sort()->values()->all())->toBe(collect([
|
||||
|
||||
@@ -54,7 +54,7 @@ test('module:make generates a composer.json that requires invoiceshelf/modules',
|
||||
$manifest = json_decode(File::get($composerPath), true);
|
||||
|
||||
expect($manifest['require'] ?? [])->toHaveKey('invoiceshelf/modules');
|
||||
expect($manifest['require']['invoiceshelf/modules'])->toBe('^3.2');
|
||||
expect($manifest['require']['invoiceshelf/modules'])->toBe('^3.3');
|
||||
});
|
||||
|
||||
test('module:make generates starter lang files for menu and settings', function () {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user