feat: AI model selector + i18n nav labels (RU/EN) on new modules

AI model selector:
- AiAssistantService::MODEL_DEFAULTS and MODEL_OPTIONS const tables (3 picks per
  provider: Claude Opus 4.7 / Sonnet 4.6 / Haiku 4.5, OpenAI 4o / 4o-mini,
  Gemini 1.5 Pro / Flash). Default upgraded from Sonnet 4.5 → Sonnet 4.6.
- modelFor(provider, company?) resolves tenant override > global default.
- All 8 hardcoded model strings replaced with modelFor() across callClaude
  (chat with tool-use), callOpenAI, callGemini (chat), postClaude/postOpenAI/
  postGemini (single-shot), and OcrInvoiceService.
- Settings page adds 3 model selectors per provider with persistence at
  settings.ai.models.{claude,gpt,gemini}.

i18n nav labels:
- TireSet / Bodyshop / Subcontractor / SubcontractJob / PricingCoefficient /
  ShopCustomer resources: getNavigationLabel / getNavigationGroup /
  getModelLabel / getPluralModelLabel return __()-wrapped strings.
- 20 keys added to lang/ru.json and lang/en.json.

Tests (4 new): default model, tenant override wins, unknown provider falls
back to claude default, options dictionary contains each default key.

Full suite: 134 passed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-03 06:23:21 +00:00
parent 3da1f5412a
commit 0e3f9e8bca
12 changed files with 301 additions and 13 deletions
+39 -8
View File
@@ -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,
]];
}