feat: multi-provider AI with per-company override and Claude support
- Global settings: custom AI panel with OpenAI/Gemini/Claude each with API key, model selector, and live test-connection button (no save needed) - Per-company IA tab: same three providers + fallback to global when empty - AiBot: reads per-company config_json first, falls back to env() global - Added callClaude() for Anthropic Messages API - More OpenAI models: gpt-4.1, gpt-4.1-mini - test-ai endpoint: accepts provider/api_key/model from POST for live test Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
b82fcf6aaa
commit
2b859e3dc0
+187
-44
@@ -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 '</div></div></div>';
|
||||
}
|
||||
echo <<<HTML
|
||||
<!-- IA Global custom card (inside form, saves with the rest) -->
|
||||
<div class="card">
|
||||
<div class="card-h">🤖 Inteligencia Artificial (Global)</div>
|
||||
<div class="card-b">
|
||||
<div class="form-grid">
|
||||
<div>
|
||||
<label class="lbl">Proveedor por defecto</label>
|
||||
<select class="inp-sel" name="ai_provider" id="g_ai_provider" onchange="gAiChange(this.value)" style="width:100%">
|
||||
<option value="openai"{$gSelOai}>OpenAI (GPT)</option>
|
||||
<option value="gemini"{$gSelGem}>Google Gemini</option>
|
||||
<option value="claude"{$gSelCla}>Anthropic Claude</option>
|
||||
<option value="mock"{$gSelMock}>Mock (simulado)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="lbl">Máximo de tokens</label>
|
||||
<input class="inp" type="number" name="ai_max_tokens" value="{$gMaxTok}" placeholder="500">
|
||||
</div>
|
||||
<div class="fw">
|
||||
<label class="lbl">System Prompt por defecto</label>
|
||||
<textarea class="inp" name="ai_default_prompt" rows="3" placeholder="Eres un asistente...">{$gPrompt}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- OpenAI -->
|
||||
<div id="g_oai" style="margin-top:14px;padding:14px;background:#f8f9fb;border-radius:8px;border:1px solid #e5e7eb;{$gShowOai}">
|
||||
<div style="font-weight:700;font-size:13px;margin-bottom:10px">🔵 OpenAI</div>
|
||||
<div class="form-grid">
|
||||
<div>
|
||||
<label class="lbl">API Key</label>
|
||||
<input class="inp" type="password" name="openai_api_key" id="g_oai_key" value="{$gOaiKey}" placeholder="sk-...">
|
||||
</div>
|
||||
<div>
|
||||
<label class="lbl">Modelo</label>
|
||||
<select class="inp-sel" name="openai_model" id="g_oai_model" style="width:100%">
|
||||
<option value="gpt-4o-mini"{$sOaiMini}>GPT-4o Mini</option>
|
||||
<option value="gpt-4.1-mini"{$sOai41m}>GPT-4.1 Mini</option>
|
||||
<option value="gpt-4o"{$sOai4o}>GPT-4o</option>
|
||||
<option value="gpt-4.1"{$sOai41}>GPT-4.1</option>
|
||||
<option value="gpt-3.5-turbo"{$sOai35}>GPT-3.5 Turbo</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-top:10px">
|
||||
<button type="button" class="btn-primary" onclick="testProvider('openai','g_oai_key','g_oai_model','g_oai_res')" style="font-size:12px;padding:6px 14px">Probar conexión</button>
|
||||
<span id="g_oai_res" style="font-size:12px;margin-left:10px"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Gemini -->
|
||||
<div id="g_gem" style="margin-top:14px;padding:14px;background:#f8f9fb;border-radius:8px;border:1px solid #e5e7eb;{$gShowGem}">
|
||||
<div style="font-weight:700;font-size:13px;margin-bottom:10px">🟢 Google Gemini</div>
|
||||
<div class="form-grid">
|
||||
<div>
|
||||
<label class="lbl">API Key</label>
|
||||
<input class="inp" type="password" name="gemini_api_key" id="g_gem_key" value="{$gGemKey}" placeholder="AIza...">
|
||||
</div>
|
||||
<div>
|
||||
<label class="lbl">Modelo</label>
|
||||
<select class="inp-sel" name="gemini_model" id="g_gem_model" style="width:100%">
|
||||
<option value="gemini-2.5-flash"{$sGemF25}>Gemini 2.5 Flash</option>
|
||||
<option value="gemini-2.0-flash"{$sGemF20}>Gemini 2.0 Flash</option>
|
||||
<option value="gemini-1.5-flash"{$sGemF15}>Gemini 1.5 Flash</option>
|
||||
<option value="gemini-1.5-pro"{$sGemP15}>Gemini 1.5 Pro</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-top:10px">
|
||||
<button type="button" class="btn-primary" onclick="testProvider('gemini','g_gem_key','g_gem_model','g_gem_res')" style="font-size:12px;padding:6px 14px">Probar conexión</button>
|
||||
<span id="g_gem_res" style="font-size:12px;margin-left:10px"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Claude -->
|
||||
<div id="g_cla" style="margin-top:14px;padding:14px;background:#f8f9fb;border-radius:8px;border:1px solid #e5e7eb;{$gShowCla}">
|
||||
<div style="font-weight:700;font-size:13px;margin-bottom:10px">🟣 Anthropic Claude</div>
|
||||
<div class="form-grid">
|
||||
<div>
|
||||
<label class="lbl">API Key</label>
|
||||
<input class="inp" type="password" name="claude_api_key" id="g_cla_key" value="{$gClaKey}" placeholder="sk-ant-...">
|
||||
</div>
|
||||
<div>
|
||||
<label class="lbl">Modelo</label>
|
||||
<select class="inp-sel" name="claude_model" id="g_cla_model" style="width:100%">
|
||||
<option value="claude-haiku-4-5"{$sClaH}>Claude Haiku 4.5 (rápido)</option>
|
||||
<option value="claude-sonnet-5"{$sClaS}>Claude Sonnet 5</option>
|
||||
<option value="claude-opus-4-8"{$sClaO}>Claude Opus 4.8</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-top:10px">
|
||||
<button type="button" class="btn-primary" onclick="testProvider('claude','g_cla_key','g_cla_model','g_cla_res')" style="font-size:12px;padding:6px 14px">Probar conexión</button>
|
||||
<span id="g_cla_res" style="font-size:12px;margin-left:10px"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="btn-row">
|
||||
<a href="/admin/dashboard" class="btn-out" style="background:#ddd;color:#333;padding:10px 24px;border-radius:8px;text-decoration:none;font-size:14px">Cancelar</a>
|
||||
<button type="submit" class="btn-primary">Guardar configuración</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="card" style="margin-top:16px">
|
||||
<div class="card-h">Probar conexión IA</div>
|
||||
<div class="card-b">
|
||||
<div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap">
|
||||
<button type="button" class="btn-primary" onclick="testAiConn()" id="btnTestAi">Probar conexión</button>
|
||||
<span id="aiTestResult" style="font-size:13px"></span>
|
||||
</div>
|
||||
<small style="margin-top:8px;color:#888">Guarda primero la configuración, luego prueba la conexión. Se enviará un mensaje de prueba al proveedor activo.</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="version-info">Los cambios se aplican inmediatamente. Los valores se almacenan en la base de datos.</div>
|
||||
</div>
|
||||
<script>
|
||||
function testAiConn() {
|
||||
var btn = document.getElementById('btnTestAi');
|
||||
var res = document.getElementById('aiTestResult');
|
||||
btn.disabled = true;
|
||||
res.textContent = 'Probando...';
|
||||
res.style.color = '#666';
|
||||
fetch('/admin/settings/test-ai', {method:'POST', headers:{'Content-Type':'application/x-www-form-urlencoded'}, body:''})
|
||||
function gAiChange(v) {
|
||||
document.getElementById('g_oai').style.display = (v === 'openai' || !v) ? '' : 'none';
|
||||
document.getElementById('g_gem').style.display = v === 'gemini' ? '' : 'none';
|
||||
document.getElementById('g_cla').style.display = v === 'claude' ? '' : 'none';
|
||||
}
|
||||
function testProvider(provider, keyId, modelId, resId) {
|
||||
var key = document.getElementById(keyId)?.value || '';
|
||||
var model = document.getElementById(modelId)?.value || '';
|
||||
var res = document.getElementById(resId);
|
||||
res.textContent = 'Probando...'; res.style.color = '#666';
|
||||
var body = 'provider=' + encodeURIComponent(provider) + '&api_key=' + encodeURIComponent(key) + '&model=' + encodeURIComponent(model);
|
||||
fetch('/admin/settings/test-ai', {method:'POST', headers:{'Content-Type':'application/x-www-form-urlencoded'}, body: body})
|
||||
.then(function(r){return r.json();})
|
||||
.then(function(d){
|
||||
if (d.ok) {
|
||||
res.textContent = 'Conexion exitosa (' + d.provider + '): ' + d.response;
|
||||
res.style.color = '#16a34a';
|
||||
} else {
|
||||
res.textContent = 'Error (' + d.provider + '): ' + d.error;
|
||||
res.style.color = '#dc2626';
|
||||
}
|
||||
if (d.ok) { res.textContent = '✅ OK: ' + d.response; res.style.color = '#16a34a'; }
|
||||
else { res.textContent = '❌ ' + d.error; res.style.color = '#dc2626'; }
|
||||
})
|
||||
.catch(function(e){ res.textContent = 'Error de red'; res.style.color = '#dc2626'; })
|
||||
.finally(function(){ btn.disabled = false; });
|
||||
.catch(function(){ res.textContent = 'Error de red'; res.style.color = '#dc2626'; });
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
@@ -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;
|
||||
<option value="">Usar configuración global</option>
|
||||
<option value="openai"{$aiProviderOpenai}>OpenAI (GPT)</option>
|
||||
<option value="gemini"{$aiProviderGemini}>Google Gemini</option>
|
||||
<option value="claude"{$aiProviderClaude}>Anthropic Claude</option>
|
||||
<option value="mock"{$aiProviderMock}>Mock (simulado)</option>
|
||||
</select>
|
||||
<div style="font-size:11px;color:#7a8291;margin-top:3px">Si no se selecciona, usa el proveedor de Configuración General</div>
|
||||
@@ -3542,7 +3664,9 @@ HTML;
|
||||
<label>Modelo</label>
|
||||
<select name="ai_model">
|
||||
<option value="gpt-4o-mini"{$aiModelMini}>GPT-4o Mini</option>
|
||||
<option value="gpt-4.1-mini"{$aiModel41mini}>GPT-4.1 Mini</option>
|
||||
<option value="gpt-4o"{$aiModel4o}>GPT-4o</option>
|
||||
<option value="gpt-4.1"{$aiModel41}>GPT-4.1</option>
|
||||
<option value="gpt-3.5-turbo"{$aiModel35}>GPT-3.5 Turbo</option>
|
||||
</select>
|
||||
</div>
|
||||
@@ -3568,6 +3692,24 @@ HTML;
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Claude fields -->
|
||||
<div id="ai-claude-fields" style="{$showClaude}">
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label>API Key Anthropic</label>
|
||||
<input type="password" name="claude_api_key" value="{$claudeApiKeyVal}" placeholder="sk-ant-...">
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Modelo Claude</label>
|
||||
<select name="claude_model">
|
||||
<option value="claude-haiku-4-5"{$claudeModelH}>Claude Haiku 4.5 (rápido)</option>
|
||||
<option value="claude-sonnet-5"{$claudeModelS}>Claude Sonnet 5</option>
|
||||
<option value="claude-opus-4-8"{$claudeModelO}>Claude Opus 4.8</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row" style="margin-top:4px">
|
||||
<div class="form-group">
|
||||
<label>Temperatura ({$aiTempVal})</label>
|
||||
@@ -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() {
|
||||
|
||||
+40
-22
@@ -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.']);
|
||||
}
|
||||
})()],
|
||||
|
||||
|
||||
+65
-7
@@ -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);
|
||||
|
||||
@@ -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.',
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user