diff --git a/app/Filament/Tenant/Pages/Settings.php b/app/Filament/Tenant/Pages/Settings.php index c8f69f6..2128f1b 100644 --- a/app/Filament/Tenant/Pages/Settings.php +++ b/app/Filament/Tenant/Pages/Settings.php @@ -63,6 +63,9 @@ class Settings extends Page 'ai_claude_key' => $settings['ai']['claude_key'] ?? null, 'ai_gpt_key' => $settings['ai']['gpt_key'] ?? null, 'ai_gemini_key' => $settings['ai']['gemini_key'] ?? null, + 'ai_model_claude' => data_get($settings, 'ai.models.claude', \App\Services\Ai\AiAssistantService::MODEL_DEFAULTS['claude']), + 'ai_model_gpt' => data_get($settings, 'ai.models.gpt', \App\Services\Ai\AiAssistantService::MODEL_DEFAULTS['gpt']), + 'ai_model_gemini' => data_get($settings, 'ai.models.gemini', \App\Services\Ai\AiAssistantService::MODEL_DEFAULTS['gemini']), ]); } @@ -193,8 +196,20 @@ class Settings extends Page ->options(['claude' => 'Claude (Anthropic)', 'gpt' => 'ChatGPT (OpenAI)', 'gemini' => 'Gemini (Google)']) ->default('claude'), Forms\Components\TextInput::make('ai_claude_key')->label('Claude API Key')->password()->revealable()->placeholder('sk-ant-...'), + Forms\Components\Select::make('ai_model_claude') + ->label('Model Claude') + ->options(\App\Services\Ai\AiAssistantService::MODEL_OPTIONS['claude']) + ->default(\App\Services\Ai\AiAssistantService::MODEL_DEFAULTS['claude']), Forms\Components\TextInput::make('ai_gpt_key')->label('OpenAI API Key')->password()->revealable()->placeholder('sk-proj-...'), + Forms\Components\Select::make('ai_model_gpt') + ->label('Model OpenAI') + ->options(\App\Services\Ai\AiAssistantService::MODEL_OPTIONS['gpt']) + ->default(\App\Services\Ai\AiAssistantService::MODEL_DEFAULTS['gpt']), Forms\Components\TextInput::make('ai_gemini_key')->label('Gemini API Key')->password()->revealable(), + Forms\Components\Select::make('ai_model_gemini') + ->label('Model Gemini') + ->options(\App\Services\Ai\AiAssistantService::MODEL_OPTIONS['gemini']) + ->default(\App\Services\Ai\AiAssistantService::MODEL_DEFAULTS['gemini']), ]), ]) ->statePath('data'); @@ -246,6 +261,11 @@ class Settings extends Page 'claude_key' => $data['ai_claude_key'] ?? null, 'gpt_key' => $data['ai_gpt_key'] ?? null, 'gemini_key' => $data['ai_gemini_key'] ?? null, + 'models' => [ + 'claude' => $data['ai_model_claude'] ?? \App\Services\Ai\AiAssistantService::MODEL_DEFAULTS['claude'], + 'gpt' => $data['ai_model_gpt'] ?? \App\Services\Ai\AiAssistantService::MODEL_DEFAULTS['gpt'], + 'gemini' => $data['ai_model_gemini'] ?? \App\Services\Ai\AiAssistantService::MODEL_DEFAULTS['gemini'], + ], ], ]), ]); diff --git a/app/Filament/Tenant/Resources/BodyshopJobResource.php b/app/Filament/Tenant/Resources/BodyshopJobResource.php index ec26e8f..f03ac25 100644 --- a/app/Filament/Tenant/Resources/BodyshopJobResource.php +++ b/app/Filament/Tenant/Resources/BodyshopJobResource.php @@ -134,6 +134,27 @@ class BodyshopJobResource extends Resource ]; } + + public static function getNavigationLabel(): string + { + return __('Tinichigerie / Detailing'); + } + + public static function getNavigationGroup(): ?string + { + return __('Tinichigerie'); + } + + public static function getModelLabel(): string + { + return __('lucrare caroserie'); + } + + public static function getPluralModelLabel(): string + { + return __('lucrări caroserie'); + } + public static function getPages(): array { return [ diff --git a/app/Filament/Tenant/Resources/PricingCoefficientResource.php b/app/Filament/Tenant/Resources/PricingCoefficientResource.php index ddbf8f4..839b772 100644 --- a/app/Filament/Tenant/Resources/PricingCoefficientResource.php +++ b/app/Filament/Tenant/Resources/PricingCoefficientResource.php @@ -97,6 +97,27 @@ class PricingCoefficientResource extends Resource ->defaultSort('priority'); } + + public static function getNavigationLabel(): string + { + return __('Coeficienți preț'); + } + + public static function getNavigationGroup(): ?string + { + return __('Depozit'); + } + + public static function getModelLabel(): string + { + return __('coeficient'); + } + + public static function getPluralModelLabel(): string + { + return __('coeficienți preț'); + } + public static function getPages(): array { return [ diff --git a/app/Filament/Tenant/Resources/ShopCustomerResource.php b/app/Filament/Tenant/Resources/ShopCustomerResource.php index a2c364e..1a04ed2 100644 --- a/app/Filament/Tenant/Resources/ShopCustomerResource.php +++ b/app/Filament/Tenant/Resources/ShopCustomerResource.php @@ -93,6 +93,27 @@ class ShopCustomerResource extends Resource ]; } + + public static function getNavigationLabel(): string + { + return __('Clienți magazin'); + } + + public static function getNavigationGroup(): ?string + { + return __('Magazin'); + } + + public static function getModelLabel(): string + { + return __('client magazin'); + } + + public static function getPluralModelLabel(): string + { + return __('clienți magazin'); + } + public static function getPages(): array { return [ diff --git a/app/Filament/Tenant/Resources/SubcontractJobResource.php b/app/Filament/Tenant/Resources/SubcontractJobResource.php index eb9f253..f5fafb4 100644 --- a/app/Filament/Tenant/Resources/SubcontractJobResource.php +++ b/app/Filament/Tenant/Resources/SubcontractJobResource.php @@ -122,6 +122,27 @@ class SubcontractJobResource extends Resource ->defaultSort('created_at', 'desc'); } + + public static function getNavigationLabel(): string + { + return __('Lucrări terți'); + } + + public static function getNavigationGroup(): ?string + { + return __('Subcontractare'); + } + + public static function getModelLabel(): string + { + return __('lucrare terți'); + } + + public static function getPluralModelLabel(): string + { + return __('lucrări terți'); + } + public static function getPages(): array { return [ diff --git a/app/Filament/Tenant/Resources/SubcontractorResource.php b/app/Filament/Tenant/Resources/SubcontractorResource.php index b1d48fc..a2d783a 100644 --- a/app/Filament/Tenant/Resources/SubcontractorResource.php +++ b/app/Filament/Tenant/Resources/SubcontractorResource.php @@ -73,6 +73,27 @@ class SubcontractorResource extends Resource ->defaultSort('name'); } + + public static function getNavigationLabel(): string + { + return __('Subcontractori'); + } + + public static function getNavigationGroup(): ?string + { + return __('Subcontractare'); + } + + public static function getModelLabel(): string + { + return __('subcontractor'); + } + + public static function getPluralModelLabel(): string + { + return __('subcontractori'); + } + public static function getPages(): array { return [ diff --git a/app/Filament/Tenant/Resources/TireSetResource.php b/app/Filament/Tenant/Resources/TireSetResource.php index 39169f9..c6eaa17 100644 --- a/app/Filament/Tenant/Resources/TireSetResource.php +++ b/app/Filament/Tenant/Resources/TireSetResource.php @@ -185,6 +185,27 @@ class TireSetResource extends Resource ]; } + + public static function getNavigationLabel(): string + { + return __('Seturi anvelope'); + } + + public static function getNavigationGroup(): ?string + { + return __('Anvelope'); + } + + public static function getModelLabel(): string + { + return __('set anvelope'); + } + + public static function getPluralModelLabel(): string + { + return __('seturi anvelope'); + } + public static function getPages(): array { return [ diff --git a/app/Services/Ai/AiAssistantService.php b/app/Services/Ai/AiAssistantService.php index de896c3..23be8c2 100644 --- a/app/Services/Ai/AiAssistantService.php +++ b/app/Services/Ai/AiAssistantService.php @@ -21,6 +21,37 @@ use Illuminate\Support\Facades\Http; */ class AiAssistantService { + /** Per-provider default model + the dropdown options exposed in Settings. */ + public const MODEL_DEFAULTS = [ + 'claude' => 'claude-sonnet-4-6', + 'gpt' => 'gpt-4o-mini', + 'gemini' => 'gemini-1.5-flash', + ]; + + public const MODEL_OPTIONS = [ + 'claude' => [ + 'claude-opus-4-7' => 'Opus 4.7 — cel mai capabil', + 'claude-sonnet-4-6' => 'Sonnet 4.6 — echilibrat (recomandat)', + 'claude-haiku-4-5-20251001' => 'Haiku 4.5 — rapid și ieftin', + ], + 'gpt' => [ + 'gpt-4o' => 'GPT-4o', + 'gpt-4o-mini' => 'GPT-4o mini (recomandat)', + ], + 'gemini' => [ + 'gemini-1.5-pro' => 'Gemini 1.5 Pro', + 'gemini-1.5-flash' => 'Gemini 1.5 Flash (recomandat)', + ], + ]; + + /** Resolve the model id for a provider — tenant override > global default. */ + public function modelFor(string $provider, ?Company $company = null): string + { + $company ??= $this->currentCompany(); + $override = $company ? data_get($company->settings, "ai.models.{$provider}") : null; + return $override ?: (self::MODEL_DEFAULTS[$provider] ?? 'claude-sonnet-4-6'); + } + public function ask(AiChat $chat, string $userMessage): AiMessage { // Persist user message. @@ -148,7 +179,7 @@ TXT; $r = Http::withHeaders($headers)->timeout(60)->post( 'https://api.anthropic.com/v1/messages', [ - 'model' => 'claude-sonnet-4-5', + 'model' => $this->modelFor('claude', $company), 'max_tokens' => 1024, 'system' => $system, 'tools' => $tools, @@ -214,7 +245,7 @@ TXT; $r = Http::withHeaders(['Authorization' => 'Bearer ' . $key, 'content-type' => 'application/json']) ->timeout(60) ->post('https://api.openai.com/v1/chat/completions', [ - 'model' => 'gpt-4o-mini', + 'model' => $this->modelFor('gpt', $company), 'messages' => $messages, 'max_tokens' => 1024, ]); @@ -390,7 +421,7 @@ TXT; ]) ->timeout(60) ->post('https://api.anthropic.com/v1/messages', [ - 'model' => 'claude-sonnet-4-5', + 'model' => $this->modelFor('claude'), 'max_tokens' => 1024, 'system' => $system, 'messages' => $messages, @@ -413,7 +444,7 @@ TXT; $r = Http::withHeaders(['Authorization' => 'Bearer ' . $key, 'content-type' => 'application/json']) ->timeout(60) ->post('https://api.openai.com/v1/chat/completions', [ - 'model' => 'gpt-4o-mini', + 'model' => $this->modelFor('gpt'), 'messages' => array_merge([['role' => 'system', 'content' => $system]], $messages), 'max_tokens' => 1024, ]); @@ -442,7 +473,7 @@ TXT; } $r = Http::withHeaders(['content-type' => 'application/json']) ->timeout(60) - ->post('https://generativelanguage.googleapis.com/v1beta/models/gemini-1.5-flash:generateContent?key=' . $key, [ + ->post('https://generativelanguage.googleapis.com/v1beta/models/' . $this->modelFor('gemini') . ':generateContent?key=' . $key, [ 'systemInstruction' => ['parts' => [['text' => $system]]], 'contents' => $contents, 'generationConfig' => ['maxOutputTokens' => 1024], @@ -452,7 +483,7 @@ TXT; } $body = $r->json(); $text = $body['candidates'][0]['content']['parts'][0]['text'] ?? '(răspuns gol)'; - return [$text, ['model' => 'gemini-1.5-flash', 'tokens' => $body['usageMetadata'] ?? null]]; + return [$text, ['model' => $this->modelFor('gemini'), 'tokens' => $body['usageMetadata'] ?? null]]; } protected function currentCompany(): ?Company @@ -474,7 +505,7 @@ TXT; $r = Http::withHeaders(['content-type' => 'application/json']) ->timeout(60) - ->post('https://generativelanguage.googleapis.com/v1beta/models/gemini-1.5-flash:generateContent?key=' . $key, [ + ->post('https://generativelanguage.googleapis.com/v1beta/models/' . $this->modelFor('gemini', $company) . ':generateContent?key=' . $key, [ 'systemInstruction' => ['parts' => [['text' => $this->buildSystemPrompt($company)]]], 'contents' => $contents, 'generationConfig' => ['maxOutputTokens' => 1024], @@ -486,7 +517,7 @@ TXT; $body = $r->json(); $text = $body['candidates'][0]['content']['parts'][0]['text'] ?? '(răspuns gol)'; return [$text, [ - 'model' => 'gemini-1.5-flash', + 'model' => $this->modelFor('gemini', $company), 'tokens' => $body['usageMetadata'] ?? null, ]]; } diff --git a/app/Services/Ai/OcrInvoiceService.php b/app/Services/Ai/OcrInvoiceService.php index eda25dc..e99dfc5 100644 --- a/app/Services/Ai/OcrInvoiceService.php +++ b/app/Services/Ai/OcrInvoiceService.php @@ -52,7 +52,7 @@ class OcrInvoiceService ]) ->timeout(60) ->post('https://api.anthropic.com/v1/messages', [ - 'model' => 'claude-sonnet-4-5', + 'model' => app(AiAssistantService::class)->modelFor('claude', $company), 'max_tokens' => 2048, 'system' => $this->systemPrompt(), 'messages' => [[ diff --git a/lang/en.json b/lang/en.json index ffaae03..76fc6fe 100644 --- a/lang/en.json +++ b/lang/en.json @@ -21,7 +21,6 @@ "Status": "Status", "Actions": "Actions", "Notifications": "Notifications", - "Clienți": "Clients", "Mașini": "Vehicles", "Cereri": "Leads", @@ -53,5 +52,25 @@ "Jurnal": "Audit log", "Telefonie": "Calls", "Finanțe": "Finance", - "Site PSauto": "Public site" + "Site PSauto": "Public site", + "Seturi anvelope": "Tire sets", + "Anvelope": "Tires", + "set anvelope": "tire set", + "seturi anvelope": "tire sets", + "Tinichigerie / Detailing": "Body / Detailing", + "Tinichigerie": "Body shop", + "lucrare caroserie": "body job", + "lucrări caroserie": "body jobs", + "Subcontractori": "Subcontractors", + "Subcontractare": "Subcontracting", + "subcontractor": "subcontractor", + "Lucrări terți": "Outsourced jobs", + "lucrare terți": "outsourced job", + "lucrări terți": "outsourced jobs", + "Coeficienți preț": "Pricing coefficients", + "coeficient": "coefficient", + "Magazin": "Shop", + "Clienți magazin": "Shop customers", + "client magazin": "shop customer", + "clienți magazin": "shop customers" } diff --git a/lang/ru.json b/lang/ru.json index 1444456..df684a4 100644 --- a/lang/ru.json +++ b/lang/ru.json @@ -21,7 +21,6 @@ "Status": "Статус", "Actions": "Действия", "Notifications": "Уведомления", - "Clienți": "Клиенты", "Mașini": "Машины", "Cereri": "Заявки", @@ -53,5 +52,25 @@ "Jurnal": "Журнал", "Telefonie": "Телефония", "Finanțe": "Финансы", - "Site PSauto": "Сайт" + "Site PSauto": "Сайт", + "Seturi anvelope": "Шины (комплекты)", + "Anvelope": "Шины", + "set anvelope": "комплект шин", + "seturi anvelope": "комплекты шин", + "Tinichigerie / Detailing": "Кузов / Детейлинг", + "Tinichigerie": "Кузовной цех", + "lucrare caroserie": "кузовная работа", + "lucrări caroserie": "кузовные работы", + "Subcontractori": "Субподрядчики", + "Subcontractare": "Субподряд", + "subcontractor": "субподрядчик", + "Lucrări terți": "Работы у третьих лиц", + "lucrare terți": "работа у третьих лиц", + "lucrări terți": "работы у третьих лиц", + "Coeficienți preț": "Ценовые коэффициенты", + "coeficient": "коэффициент", + "Magazin": "Магазин", + "Clienți magazin": "Клиенты магазина", + "client magazin": "клиент магазина", + "clienți magazin": "клиенты магазина" } diff --git a/tests/Feature/AiModelSelectorTest.php b/tests/Feature/AiModelSelectorTest.php new file mode 100644 index 0000000..47c8acc --- /dev/null +++ b/tests/Feature/AiModelSelectorTest.php @@ -0,0 +1,73 @@ +makeCompany(['ai' => ['claude_key' => 'sk']]); + + $this->assertEquals('claude-sonnet-4-6', $svc->modelFor('claude', $company)); + $this->assertEquals('gpt-4o-mini', $svc->modelFor('gpt', $company)); + $this->assertEquals('gemini-1.5-flash', $svc->modelFor('gemini', $company)); + } + + public function test_tenant_override_wins(): void + { + $svc = app(AiAssistantService::class); + $company = $this->makeCompany([ + 'ai' => [ + 'claude_key' => 'sk', + 'models' => [ + 'claude' => 'claude-opus-4-7', + 'gpt' => 'gpt-4o', + ], + ], + ]); + + $this->assertEquals('claude-opus-4-7', $svc->modelFor('claude', $company)); + $this->assertEquals('gpt-4o', $svc->modelFor('gpt', $company)); + // Provider without override → default. + $this->assertEquals('gemini-1.5-flash', $svc->modelFor('gemini', $company)); + } + + public function test_unknown_provider_falls_back(): void + { + $svc = app(AiAssistantService::class); + $company = $this->makeCompany([]); + $this->assertEquals('claude-sonnet-4-6', $svc->modelFor('xyz', $company)); + } + + public function test_options_dictionary_keys_match_defaults(): void + { + foreach (AiAssistantService::MODEL_DEFAULTS as $provider => $default) { + $this->assertArrayHasKey( + $default, + AiAssistantService::MODEL_OPTIONS[$provider], + "default '$default' for $provider is not in MODEL_OPTIONS" + ); + } + } + + private function makeCompany(array $settings): Company + { + $plan = Plan::firstOrCreate(['slug' => 'test'], ['name' => 'T', 'price' => 0, 'features' => []]); + $company = Company::create([ + 'plan_id' => $plan->id, 'slug' => 'mdl-' . uniqid(), + 'name' => 'Model Co', 'status' => 'active', 'settings' => $settings, + ]); + app(TenantManager::class)->setCurrent($company); + return $company; + } +}