diff --git a/admin/DashboardController.php b/admin/DashboardController.php index effe798..08ac264 100644 --- a/admin/DashboardController.php +++ b/admin/DashboardController.php @@ -2298,17 +2298,38 @@ HTML; ['key' => 'whatsapp_default_phone_number_id', 'label' => 'Phone Number ID (default)', 'type' => 'text', 'placeholder' => '384710295638401'], ['key' => 'verify_hmac_signature', 'label' => 'Validar firma HMAC de Meta', 'type' => 'checkbox', 'placeholder' => ''], ], - 'Inteligencia Artificial' => [ - ['key' => 'ai_provider', 'label' => 'Proveedor', 'type' => 'select', 'options' => ['mock' => 'Mock (simulado)', 'openai' => 'OpenAI', 'gemini' => 'Google Gemini']], - ['key' => 'openai_api_key', 'label' => 'OpenAI API Key', 'type' => 'password', 'placeholder' => 'sk-...'], - ['key' => 'openai_model', 'label' => 'Modelo OpenAI', 'type' => 'select', 'options' => ['gpt-4o-mini' => 'GPT-4o Mini', 'gpt-4o' => 'GPT-4o', 'gpt-3.5-turbo' => 'GPT-3.5 Turbo']], - ['key' => 'gemini_api_key', 'label' => 'Google Gemini API Key', 'type' => 'password', 'placeholder' => 'AIza...'], - ['key' => 'gemini_model', 'label' => 'Modelo Gemini', 'type' => 'select', 'options' => ['gemini-2.5-flash' => 'Gemini 2.5 Flash', 'gemini-2.0-flash' => 'Gemini 2.0 Flash', 'gemini-1.5-flash' => 'Gemini 1.5 Flash', 'gemini-1.5-pro' => 'Gemini 1.5 Pro']], - ['key' => 'ai_max_tokens', 'label' => 'Máximo de tokens', 'type' => 'number', 'placeholder' => '500'], - ['key' => 'ai_default_prompt', 'label' => 'System Prompt por defecto', 'type' => 'textarea', 'placeholder' => 'Eres un asistente...'], - ], ]; + // ── Valores globales de IA para el panel custom ──────────────────────── + $gAiProvider = $settings['ai_provider'] ?? 'openai'; + $gOaiKey = self::h($settings['openai_api_key'] ?? ''); + $gOaiModel = $settings['openai_model'] ?? 'gpt-4o-mini'; + $gGemKey = self::h($settings['gemini_api_key'] ?? ''); + $gGemModel = $settings['gemini_model'] ?? 'gemini-2.5-flash'; + $gClaKey = self::h($settings['claude_api_key'] ?? ''); + $gClaModel = $settings['claude_model'] ?? 'claude-haiku-4-5'; + $gMaxTok = self::h($settings['ai_max_tokens'] ?? '500'); + $gPrompt = self::h($settings['ai_default_prompt'] ?? ''); + $gSelOai = $gAiProvider === 'openai' ? ' selected' : ''; + $gSelGem = $gAiProvider === 'gemini' ? ' selected' : ''; + $gSelCla = $gAiProvider === 'claude' ? ' selected' : ''; + $gSelMock = $gAiProvider === 'mock' ? ' selected' : ''; + $gShowOai = ($gAiProvider === 'openai' || $gAiProvider === '') ? '' : 'display:none'; + $gShowGem = $gAiProvider === 'gemini' ? '' : 'display:none'; + $gShowCla = $gAiProvider === 'claude' ? '' : 'display:none'; + $sOaiMini = $gOaiModel === 'gpt-4o-mini' ? ' selected' : ''; + $sOai41m = $gOaiModel === 'gpt-4.1-mini' ? ' selected' : ''; + $sOai4o = $gOaiModel === 'gpt-4o' ? ' selected' : ''; + $sOai41 = $gOaiModel === 'gpt-4.1' ? ' selected' : ''; + $sOai35 = $gOaiModel === 'gpt-3.5-turbo' ? ' selected' : ''; + $sGemF25 = $gGemModel === 'gemini-2.5-flash' ? ' selected' : ''; + $sGemF20 = $gGemModel === 'gemini-2.0-flash' ? ' selected' : ''; + $sGemF15 = $gGemModel === 'gemini-1.5-flash' ? ' selected' : ''; + $sGemP15 = $gGemModel === 'gemini-1.5-pro' ? ' selected' : ''; + $sClaH = $gClaModel === 'claude-haiku-4-5' ? ' selected' : ''; + $sClaS = $gClaModel === 'claude-sonnet-5' ? ' selected' : ''; + $sClaO = $gClaModel === 'claude-opus-4-8' ? ' selected' : ''; + $versionHash = substr(sha1_file(__DIR__ . '/../.env'), 0, 8); http_response_code(200); @@ -2369,45 +2390,131 @@ HTML; echo ''; } echo << +
+
🤖 Inteligencia Artificial (Global)
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
🔵 OpenAI
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
🟢 Google Gemini
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
🟣 Anthropic Claude
+
+
+ + +
+
+ + +
+
+
+ + +
+
+
+
+
Cancelar
-
-
Probar conexión IA
-
-
- - -
- Guarda primero la configuración, luego prueba la conexión. Se enviará un mensaje de prueba al proveedor activo. -
-
-
Los cambios se aplican inmediatamente. Los valores se almacenan en la base de datos.
@@ -3051,19 +3158,33 @@ HTML; } $aiProviderOpenai = $aiProviderVal === 'openai' ? ' selected' : ''; $aiProviderGemini = $aiProviderVal === 'gemini' ? ' selected' : ''; + $aiProviderClaude = $aiProviderVal === 'claude' ? ' selected' : ''; $aiProviderMock = $aiProviderVal === 'mock' ? ' selected' : ''; $openaiApiKeyVal = self::h($config['openai_api_key'] ?? ''); + $aiModelVal = $config['ai_model'] ?? 'gpt-4o-mini'; + $aiModelMini = $aiModelVal === 'gpt-4o-mini' ? ' selected' : ''; + $aiModel4o = $aiModelVal === 'gpt-4o' ? ' selected' : ''; + $aiModel41 = $aiModelVal === 'gpt-4.1' ? ' selected' : ''; + $aiModel41mini = $aiModelVal === 'gpt-4.1-mini' ? ' selected' : ''; + $aiModel35 = $aiModelVal === 'gpt-3.5-turbo'? ' selected' : ''; $geminiApiKeyVal = self::h($config['gemini_api_key'] ?? ''); $geminiModelVal = $config['gemini_model'] ?? 'gemini-2.5-flash'; $geminiModelF25 = $geminiModelVal === 'gemini-2.5-flash' ? ' selected' : ''; $geminiModelF20 = $geminiModelVal === 'gemini-2.0-flash' ? ' selected' : ''; $geminiModelF15 = $geminiModelVal === 'gemini-1.5-flash' ? ' selected' : ''; $geminiModelP15 = $geminiModelVal === 'gemini-1.5-pro' ? ' selected' : ''; + $claudeApiKeyVal = self::h($config['claude_api_key'] ?? ''); + $claudeModelVal = $config['claude_model'] ?? 'claude-haiku-4-5'; + $claudeModelH = $claudeModelVal === 'claude-haiku-4-5' ? ' selected' : ''; + $claudeModelS = $claudeModelVal === 'claude-sonnet-5' ? ' selected' : ''; + $claudeModelO = $claudeModelVal === 'claude-opus-4-8' ? ' selected' : ''; // Determine which provider panel to show initially $showOpenai = ($aiProviderVal === 'openai' || $aiProviderVal === '') ? 'display:flex;flex-direction:column;gap:10px' : 'display:none'; $showGemini = $aiProviderVal === 'gemini' ? 'display:flex;flex-direction:column;gap:10px' : 'display:none'; + $showClaude = $aiProviderVal === 'claude' + ? 'display:flex;flex-direction:column;gap:10px' : 'display:none'; // Per-category config HTML $perTypeConfig = $config['per_type'] ?? []; @@ -3526,6 +3647,7 @@ HTML; +
Si no se selecciona, usa el proveedor de Configuración General
@@ -3542,7 +3664,9 @@ HTML; @@ -3568,6 +3692,24 @@ HTML; + +
+
+
+ + +
+
+ + +
+
+
+
@@ -3955,14 +4097,15 @@ function addCapField(containerId) { } function aiProviderChange(v) { - const oai = document.getElementById('ai-openai-fields'); - const gem = document.getElementById('ai-gemini-fields'); - if (oai) oai.style.display = (v === 'openai' || v === '') ? 'flex' : 'none'; - if (oai) oai.style.flexDirection = 'column'; - if (oai) oai.style.gap = '10px'; - if (gem) gem.style.display = v === 'gemini' ? 'flex' : 'none'; - if (gem) gem.style.flexDirection = 'column'; - if (gem) gem.style.gap = '10px'; + const panels = {openai: 'ai-openai-fields', gemini: 'ai-gemini-fields', claude: 'ai-claude-fields'}; + Object.entries(panels).forEach(([key, id]) => { + const el = document.getElementById(id); + if (!el) return; + const show = (key === 'openai' && (v === 'openai' || v === '')) || v === key; + el.style.display = show ? 'flex' : 'none'; + el.style.flexDirection = 'column'; + el.style.gap = '10px'; + }); } function updatePreview() { diff --git a/public/index.php b/public/index.php index dfa1baa..5cb5ed3 100644 --- a/public/index.php +++ b/public/index.php @@ -540,6 +540,10 @@ $routes = [ if ($geminiKey !== '') $config['gemini_api_key'] = $geminiKey; $geminiModel = trim($_POST['gemini_model'] ?? ''); if ($geminiModel !== '') $config['gemini_model'] = $geminiModel; + $claudeKey = trim($_POST['claude_api_key'] ?? ''); + if ($claudeKey !== '') $config['claude_api_key'] = $claudeKey; + $claudeModel = trim($_POST['claude_model'] ?? ''); + if ($claudeModel !== '') $config['claude_model'] = $claudeModel; // per_type is managed exclusively via /admin/bot-config/save-per-type — copy as-is from DB if (isset($existingConfig['per_type'])) { @@ -563,6 +567,7 @@ $routes = [ // Preserve any DB keys the form doesn't explicitly manage (welcome_menu, per_type, etc.) $formManagedKeys = ['commands', 'menus', 'flows', 'ai_prompt', 'ai_model', 'ai_temperature', 'ai_provider', 'openai_api_key', 'gemini_api_key', 'gemini_model', + 'claude_api_key', 'claude_model', 'ai_for_media', 'greeting', 'fallback', 'approval_webhook', 'ignore_prefixes', 'welcome_menu', 'per_type']; foreach ($existingConfig as $k => $v) { @@ -622,7 +627,8 @@ $routes = [ $allowed = [ 'whatsapp_access_token', 'whatsapp_app_secret', 'whatsapp_verify_token', 'whatsapp_business_account_id', 'whatsapp_default_phone_number_id', - 'ai_provider', 'openai_api_key', 'openai_model', 'gemini_api_key', 'gemini_model', 'ai_max_tokens', 'ai_default_prompt', + 'ai_provider', 'openai_api_key', 'openai_model', 'gemini_api_key', 'gemini_model', + 'claude_api_key', 'claude_model', 'ai_max_tokens', 'ai_default_prompt', 'verify_hmac_signature', ]; // Checkbox: si no está presente, desactivar @@ -652,32 +658,44 @@ $routes = [ ['POST', '/admin/settings/test-ai', fn() => (function () { SessionAuth::require(); - $provider = Settings::get('ai_provider', 'mock'); + // Accept provider/api_key/model from POST (live test), or fall back to saved settings + $provider = trim($_POST['provider'] ?? '') ?: Settings::get('ai_provider', 'mock'); + $apiKey = trim($_POST['api_key'] ?? ''); + $model = trim($_POST['model'] ?? ''); + if ($provider === 'gemini') { - $apiKey = Settings::get('gemini_api_key', ''); - $model = Settings::get('gemini_model', 'gemini-2.0-flash'); - if ($apiKey === '') { jsonResponse(200, ['ok' => false, 'provider' => 'gemini', 'error' => 'API Key no configurada. Guarda primero.']); return; } - $url = "https://generativelanguage.googleapis.com/v1beta/models/{$model}:generateContent?key={$apiKey}"; - $payload = json_encode(['contents' => [['role' => 'user', 'parts' => [['text' => 'Responde solo: OK']]]]]); - $ch = curl_init($url); - curl_setopt_array($ch, [CURLOPT_POST => true, CURLOPT_POSTFIELDS => $payload, CURLOPT_HTTPHEADER => ['Content-Type: application/json'], CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 15]); + if ($apiKey === '') $apiKey = Settings::get('gemini_api_key', ''); + if ($model === '') $model = Settings::get('gemini_model', 'gemini-2.5-flash'); + if ($apiKey === '') { jsonResponse(200, ['ok' => false, 'provider' => 'gemini', 'error' => 'API Key vacía']); return; } + $url = "https://generativelanguage.googleapis.com/v1beta/models/{$model}:generateContent?key={$apiKey}"; + $body = json_encode(['contents' => [['role' => 'user', 'parts' => [['text' => 'Responde solo: OK']]]]]); + $ch = curl_init($url); + curl_setopt_array($ch, [CURLOPT_POST => true, CURLOPT_POSTFIELDS => $body, CURLOPT_HTTPHEADER => ['Content-Type: application/json'], CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 15]); $resp = curl_exec($ch); $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); $err = curl_error($ch); curl_close($ch); - if ($code !== 200) { $msg = $err ?: (json_decode($resp, true)['error']['message'] ?? "HTTP {$code}"); jsonResponse(200, ['ok' => false, 'provider' => 'gemini', 'error' => $msg]); return; } - $text = json_decode($resp, true)['candidates'][0]['content']['parts'][0]['text'] ?? '(sin respuesta)'; - jsonResponse(200, ['ok' => true, 'provider' => 'gemini', 'response' => trim($text)]); + if ($code !== 200) { jsonResponse(200, ['ok' => false, 'provider' => 'gemini', 'error' => $err ?: (json_decode($resp, true)['error']['message'] ?? "HTTP {$code}")]); return; } + jsonResponse(200, ['ok' => true, 'provider' => 'gemini', 'response' => trim(json_decode($resp, true)['candidates'][0]['content']['parts'][0]['text'] ?? '(sin respuesta)')]); } elseif ($provider === 'openai') { - $apiKey = Settings::get('openai_api_key', ''); - $model = Settings::get('openai_model', 'gpt-4o-mini'); - if ($apiKey === '') { jsonResponse(200, ['ok' => false, 'provider' => 'openai', 'error' => 'API Key no configurada. Guarda primero.']); return; } - $payload = json_encode(['model' => $model, 'messages' => [['role' => 'user', 'content' => 'Responde solo: OK']], 'max_tokens' => 5]); - $ch = curl_init('https://api.openai.com/v1/chat/completions'); - curl_setopt_array($ch, [CURLOPT_POST => true, CURLOPT_POSTFIELDS => $payload, CURLOPT_HTTPHEADER => ['Content-Type: application/json', 'Authorization: Bearer ' . $apiKey], CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 15]); + if ($apiKey === '') $apiKey = Settings::get('openai_api_key', ''); + if ($model === '') $model = Settings::get('openai_model', 'gpt-4o-mini'); + if ($apiKey === '') { jsonResponse(200, ['ok' => false, 'provider' => 'openai', 'error' => 'API Key vacía']); return; } + $body = json_encode(['model' => $model, 'messages' => [['role' => 'user', 'content' => 'Responde solo: OK']], 'max_tokens' => 5]); + $ch = curl_init('https://api.openai.com/v1/chat/completions'); + curl_setopt_array($ch, [CURLOPT_POST => true, CURLOPT_POSTFIELDS => $body, CURLOPT_HTTPHEADER => ['Content-Type: application/json', 'Authorization: Bearer ' . $apiKey], CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 15]); $resp = curl_exec($ch); $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); $err = curl_error($ch); curl_close($ch); - if ($code !== 200) { $msg = $err ?: (json_decode($resp, true)['error']['message'] ?? "HTTP {$code}"); jsonResponse(200, ['ok' => false, 'provider' => 'openai', 'error' => $msg]); return; } - $text = json_decode($resp, true)['choices'][0]['message']['content'] ?? '(sin respuesta)'; - jsonResponse(200, ['ok' => true, 'provider' => 'openai', 'response' => trim($text)]); + if ($code !== 200) { jsonResponse(200, ['ok' => false, 'provider' => 'openai', 'error' => $err ?: (json_decode($resp, true)['error']['message'] ?? "HTTP {$code}")]); return; } + jsonResponse(200, ['ok' => true, 'provider' => 'openai', 'response' => trim(json_decode($resp, true)['choices'][0]['message']['content'] ?? '(sin respuesta)')]); + } elseif ($provider === 'claude') { + if ($apiKey === '') $apiKey = Settings::get('claude_api_key', ''); + if ($model === '') $model = Settings::get('claude_model', 'claude-haiku-4-5'); + if ($apiKey === '') { jsonResponse(200, ['ok' => false, 'provider' => 'claude', 'error' => 'API Key vacía']); return; } + $body = json_encode(['model' => $model, 'max_tokens' => 10, 'messages' => [['role' => 'user', 'content' => 'Responde solo: OK']]]); + $ch = curl_init('https://api.anthropic.com/v1/messages'); + curl_setopt_array($ch, [CURLOPT_POST => true, CURLOPT_POSTFIELDS => $body, CURLOPT_HTTPHEADER => ['Content-Type: application/json', 'x-api-key: ' . $apiKey, 'anthropic-version: 2023-06-01'], CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 15]); + $resp = curl_exec($ch); $code = curl_getinfo($ch, CURLINFO_HTTP_CODE); $err = curl_error($ch); curl_close($ch); + if ($code !== 200) { jsonResponse(200, ['ok' => false, 'provider' => 'claude', 'error' => $err ?: (json_decode($resp, true)['error']['message'] ?? "HTTP {$code}")]); return; } + jsonResponse(200, ['ok' => true, 'provider' => 'claude', 'response' => trim(json_decode($resp, true)['content'][0]['text'] ?? '(sin respuesta)')]); } else { - jsonResponse(200, ['ok' => true, 'provider' => 'mock', 'response' => 'Mock activo — no hay proveedor real configurado.']); + jsonResponse(200, ['ok' => true, 'provider' => 'mock', 'response' => 'Mock activo.']); } })()], diff --git a/services/AiBot.php b/services/AiBot.php index 6ff7e57..2a751cd 100644 --- a/services/AiBot.php +++ b/services/AiBot.php @@ -79,12 +79,15 @@ class AiBot private static function callLlm(string $systemPrompt, int $ctxId, array $company): ?array { - $provider = env('AI_PROVIDER', 'openai'); + $cfg = self::getConfig($company); + // Per-company provider overrides global; empty = use global + $provider = $cfg['ai_provider'] ?? env('AI_PROVIDER', 'openai'); $history = ConversationContext::getAiHistory($ctxId); return match ($provider) { 'openai' => self::callOpenAI($systemPrompt, $history, $company), - 'gemini' => self::callGemini($systemPrompt, $history), + 'gemini' => self::callGemini($systemPrompt, $history, $company), + 'claude' => self::callClaude($systemPrompt, $history, $company), 'mock' => self::mockResponse($history), default => self::callOpenAI($systemPrompt, $history, $company), }; @@ -92,12 +95,13 @@ class AiBot private static function callOpenAI(string $systemPrompt, array $history, array $company): ?array { - $apiKey = env('OPENAI_API_KEY', ''); + $cfg = self::getConfig($company); + $apiKey = $cfg['openai_api_key'] ?? env('OPENAI_API_KEY', ''); if ($apiKey === '') { return null; } - $model = env('OPENAI_MODEL', 'gpt-4o-mini'); + $model = $cfg['ai_model'] ?? env('OPENAI_MODEL', 'gpt-4o-mini'); $messages = [ ['role' => 'system', 'content' => $systemPrompt], @@ -145,12 +149,13 @@ class AiBot return $text !== null ? ['content' => $text] : null; } - private static function callGemini(string $systemPrompt, array $history): ?array + private static function callGemini(string $systemPrompt, array $history, array $company = []): ?array { - $apiKey = env('GEMINI_API_KEY', ''); + $cfg = self::getConfig($company); + $apiKey = $cfg['gemini_api_key'] ?? env('GEMINI_API_KEY', ''); if ($apiKey === '') return null; - $model = env('GEMINI_MODEL', 'gemini-2.5-flash'); + $model = $cfg['gemini_model'] ?? env('GEMINI_MODEL', 'gemini-2.5-flash'); $maxTokens = (int)env('AI_MAX_TOKENS', '500'); // Gemini usa "contents" con roles "user"/"model" @@ -196,6 +201,59 @@ class AiBot return $text !== null ? ['content' => $text] : null; } + private static function callClaude(string $systemPrompt, array $history, array $company = []): ?array + { + $cfg = self::getConfig($company); + $apiKey = $cfg['claude_api_key'] ?? env('CLAUDE_API_KEY', ''); + if ($apiKey === '') return null; + + $model = $cfg['claude_model'] ?? env('CLAUDE_MODEL', 'claude-haiku-4-5'); + $maxTokens = (int)env('AI_MAX_TOKENS', '500'); + + $messages = []; + foreach ($history as $msg) { + $messages[] = ['role' => $msg['role'] ?? 'user', 'content' => $msg['content'] ?? '']; + } + if (empty($messages)) { + $messages[] = ['role' => 'user', 'content' => '']; + } + + $payload = json_encode([ + 'model' => $model, + 'max_tokens' => $maxTokens, + 'system' => $systemPrompt, + 'messages' => $messages, + ]); + + $ch = curl_init('https://api.anthropic.com/v1/messages'); + curl_setopt_array($ch, [ + CURLOPT_POST => true, + CURLOPT_POSTFIELDS => $payload, + CURLOPT_HTTPHEADER => [ + 'Content-Type: application/json', + 'x-api-key: ' . $apiKey, + 'anthropic-version: 2023-06-01', + ], + CURLOPT_RETURNTRANSFER => true, + CURLOPT_TIMEOUT => 30, + ]); + + $response = curl_exec($ch); + $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE); + $error = curl_error($ch); + curl_close($ch); + + if ($httpCode !== 200 || $response === false) { + WpWebhook::log('ERROR', "Claude API error: {$error} HTTP:{$httpCode}"); + return null; + } + + $data = json_decode($response, true); + $text = $data['content'][0]['text'] ?? null; + + return $text !== null ? ['content' => $text] : null; + } + private static function mockResponse(array $history): array { $last = end($history); diff --git a/setup/migrate.php b/setup/migrate.php index 67800bb..8cc7b1b 100644 --- a/setup/migrate.php +++ b/setup/migrate.php @@ -201,6 +201,8 @@ $seedSettings = [ 'openai_model' => 'gpt-4o-mini', 'gemini_api_key' => '', 'gemini_model' => 'gemini-2.5-flash', + 'claude_api_key' => '', + 'claude_model' => 'claude-haiku-4-5', 'ai_max_tokens' => '500', 'ai_default_prompt' => 'Eres un asistente virtual de Palmas360. Responde de forma amable y profesional.', ];