Files
Lizandro GuarnizoandClaude Opus 5 b125ceef95 fix(umind): la config de IA de un cliente podía atender tareas del sistema
Auditando el aislamiento apareció el agujero al revés del que se buscaba: no
un cliente leyendo datos de otro, sino la cuenta de IA de un cliente pagando
trabajo nuestro.

GetAiConfigForService recorre las configs activas y devuelve la primera sin
módulo asignado. Las configs de cliente no llevan módulo — ninguna lo lleva, es
parte del diseño — así que caían justo en ese fallback. Con un cliente que
hubiera conectado su cuenta, su clave terminaba clasificando correos de
soporte, importando plantillas o atendiendo la vCard. Ninguno de los dos se
enteraba: la respuesta llegaba igual y la factura le llegaba a él.

Todos los resolvedores globales filtran ahora tenant_id IS NULL. Un test lo
verifica sobre el código de cada uno, porque son consultas a base y acá no hay
una.

El de embeddings además no podía ser de cliente por otra razón: los vectores de
todos los agentes tienen que salir del mismo modelo o la similitud coseno entre
ellos no significa nada. Un cliente con su propio modelo de embeddings rompía
su propia búsqueda sin un solo error visible.

Del alcance entre clientes, que era lo que se auditaba: los 39 handlers de
uMind validan, y el CRUD de espacios y planes ni siquiera se monta en las rutas
del portal. Lo que faltaba era prueba: UmindScopeDe distingue "staff" de
"cliente sin espacios" por nil contra slice vacío, y esa diferencia no tenía
un solo test. Ahora la cubre uno que además falla si se invierte el fail-closed
de una ruta sin scope — probado inyectando las dos fugas.

Y dos cosas que quedaban colgando:

En /app/ai-config toda config de cliente se mostraba como "Global", que es
justo lo que no es. Ahora dice de qué espacio es, por nombre.

El consumo de una cuenta propia se registraba con el costo del plan. Se sigue
midiendo —el cliente quiere ver cuánto usa su asistente— pero con costo cero y
marcado como cuenta propia: cobrarlo también sería cobrar dos veces lo mismo.
En la pantalla de consumo aparece "va por tu cuenta de IA" en vez de un "$0"
que parecería un error. El OCR y la transcripción siguen costando: son
servicios nuestros, los use quien los use.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-20 23:15:28 -05:00

423 lines
26 KiB
HTML

<!-- Vista: Configuraciones de IA — Qwen, OpenAI, etc. -->
<div x-data="aiConfigApp()" x-init="init()" @keydown.escape.window="closeModal()" class="bg-white rounded-lg shadow">
<div x-show="loading" class="fixed inset-0 bg-gray-800 bg-opacity-75 flex justify-center items-center z-50">
<img src="../img/loading.gif" alt="Cargando..." class="w-16 h-16" />
</div>
<div class="container mx-auto p-6 w-full">
<!-- Header -->
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4 mb-6">
<div>
<h1 class="text-2xl font-bold">Configuraciones de IA</h1>
<p class="text-xs text-slate-500 mt-0.5">Gestiona las claves de API para Qwen, OpenAI y otros proveedores de IA.</p>
</div>
<button @click="openAdd()"
class="flex items-center gap-2 text-white text-sm font-medium px-4 py-2 rounded-lg"
style="background-color:#8eb02f"
onmouseover="this.style.backgroundColor='#6d8c24'"
onmouseout="this.style.backgroundColor='#8eb02f'">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 4v16m8-8H4"/>
</svg>
Nueva configuración
</button>
</div>
<!-- Alerta -->
<div x-show="errorMsg" x-cloak class="mb-4 p-3 bg-red-50 border border-red-200 rounded-lg text-sm text-red-700" x-text="errorMsg"></div>
<div x-show="successMsg" x-cloak class="mb-4 p-3 bg-green-50 border border-green-200 rounded-lg text-sm text-green-700" x-text="successMsg"></div>
<!-- Búsqueda -->
<div class="flex flex-col sm:flex-row gap-3 mb-5">
<input x-model="search" @keyup.enter="load()" type="text" placeholder="Buscar por nombre o provider..."
class="border border-gray-300 rounded-lg px-3 py-2 text-sm flex-1 focus:outline-none focus:ring-1 focus:ring-[#8eb02f]" />
<button @click="load()" :disabled="loading"
class="flex items-center gap-2 px-4 py-2 rounded-lg border border-gray-300 text-sm hover:bg-gray-50 transition disabled:opacity-40">
<svg xmlns="http://www.w3.org/2000/svg" class="h-4 w-4" :class="loading && 'animate-spin'" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M16.023 9.348h4.992v-.001M2.985 19.644v-4.992m0 0h4.992m-4.993 0 3.181 3.183a8.25 8.25 0 0 0 13.803-3.7M4.031 9.865a8.25 8.25 0 0 1 13.803-3.7l3.181 3.182m0-4.991v4.99"/>
</svg>
<span x-text="loading ? 'Cargando...' : 'Buscar'"></span>
</button>
</div>
<!-- Tabla -->
<div class="overflow-x-auto">
<table class="table-auto w-full text-sm">
<thead class="border-b border-gray-200 text-left text-xs font-semibold text-gray-500 uppercase">
<tr>
<th class="py-2 px-3">Nombre</th>
<th class="py-2 px-3">Módulo</th>
<th class="py-2 px-3">Provider</th>
<th class="py-2 px-3">Modelo</th>
<th class="py-2 px-3">API Key</th>
<th class="py-2 px-3">Estado</th>
<th class="py-2 px-3 text-right">Acciones</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-100">
<template x-if="items.length === 0">
<tr><td colspan="7" class="py-8 text-center text-gray-400">Sin configuraciones</td></tr>
</template>
<template x-for="item in items" :key="item.ID">
<tr class="hover:bg-gray-50 transition">
<td class="py-2 px-3 font-medium" x-text="item.nombre"></td>
<td class="py-2 px-3">
<!-- Una config de cliente no tiene módulo, así que
antes se mostraba como "Global" — que es
justo lo que no es. -->
<template x-if="item.tenant_id">
<span class="px-2 py-0.5 rounded-full text-xs font-semibold bg-indigo-100 text-indigo-700"
x-text="'Cliente: ' + (item.tenant_nombre || ('espacio ' + item.tenant_id))"></span>
</template>
<template x-if="!item.modulo && !item.tenant_id">
<span class="px-2 py-0.5 rounded-full text-xs font-semibold bg-gray-100 text-gray-500">Global</span>
</template>
<template x-if="item.modulo && !item.tenant_id">
<div class="flex flex-wrap gap-1">
<template x-for="m in item.modulo.split(',').filter(x => x.trim())" :key="m">
<span class="px-2 py-0.5 rounded-full text-xs font-semibold"
:class="{
'bg-blue-100 text-blue-700': m.trim() === 'landing',
'bg-[#e9f0cf] text-[#5a7a1e]': m.trim() === 'query_runner',
'bg-purple-100 text-purple-700': m.trim() === 'ia',
'bg-pink-100 text-pink-700': m.trim() === 'whisper',
'bg-indigo-100 text-indigo-700': m.trim() === 'umind_embeddings'
}"
x-text="m.trim() === 'landing' ? 'Landing' : m.trim() === 'query_runner' ? 'Query Runner' : m.trim() === 'ia' ? 'IA / vCard' : m.trim() === 'whisper' ? 'Whisper' : m.trim() === 'umind_embeddings' ? 'uMind (embeddings)' : m.trim()">
</span>
</template>
</div>
</template>
</td>
<td class="py-2 px-3">
<span class="px-2 py-0.5 rounded-full text-xs font-semibold"
:class="{
'bg-purple-100 text-purple-700': item.provider === 'qwen',
'bg-green-100 text-green-700': item.provider === 'openai',
'bg-orange-100 text-orange-700': item.provider === 'anthropic',
'bg-blue-100 text-blue-700': item.provider === 'ollama',
'bg-yellow-100 text-yellow-700': item.provider === 'gemini',
'bg-gray-100 text-gray-700': !['qwen','openai','anthropic','ollama','gemini'].includes(item.provider)
}" x-text="item.provider"></span>
</td>
<td class="py-2 px-3 text-gray-500 font-mono text-xs" x-text="item.model_name || '—'"></td>
<td class="py-2 px-3 font-mono text-xs text-gray-400" x-text="item.api_key_hint"></td>
<td class="py-2 px-3">
<span :class="item.is_active ? 'bg-green-100 text-green-700' : 'bg-gray-100 text-gray-500'"
class="px-2 py-0.5 rounded-full text-xs font-semibold"
x-text="item.is_active ? 'Activo' : 'Inactivo'"></span>
</td>
<td class="py-2 px-3 text-right">
<div class="flex justify-end gap-2">
<button @click="testConfig(item.ID)"
class="text-xs text-green-600 hover:text-green-800 font-medium transition">Probar</button>
<button @click="openEdit(item)"
class="text-xs text-blue-600 hover:text-blue-800 font-medium transition">Editar</button>
<button @click="confirmDelete(item.ID)"
class="text-xs text-red-500 hover:text-red-700 font-medium transition">Eliminar</button>
</div>
</td>
</tr>
</template>
</tbody>
</table>
</div>
<!-- Paginación -->
<div x-show="totalPages > 1" class="flex justify-center gap-1 mt-5">
<template x-for="p in totalPages" :key="p">
<button @click="goPage(p)" :class="p === page ? 'bg-[#8eb02f] text-white' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'"
class="w-8 h-8 rounded text-sm font-medium transition" x-text="p"></button>
</template>
</div>
</div>
<!-- Modal crear / editar -->
<div x-show="showModal" x-cloak class="fixed inset-0 bg-black/50 flex items-center justify-center z-40 p-4">
<div @click.outside="closeModal()" class="bg-white rounded-xl shadow-xl w-full max-w-lg p-6">
<h2 class="text-lg font-bold mb-4" x-text="editItem ? 'Editar configuración' : 'Nueva configuración de IA'"></h2>
<form @submit.prevent="save()">
<div class="grid grid-cols-1 gap-4">
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">Nombre *</label>
<input x-model="form.nombre" type="text" required placeholder="Ej: Qwen 2.5 Producción"
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-[#8eb02f]" />
</div>
<div class="grid grid-cols-2 gap-3">
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">Provider *</label>
<select x-model="form.provider" required
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-[#8eb02f]">
<option value="">Seleccionar...</option>
<option value="qwen">Qwen (Alibaba)</option>
<option value="openai">OpenAI</option>
<option value="anthropic">Anthropic</option>
<option value="groq">Groq</option>
<option value="ollama">Ollama (local)</option>
<option value="gemini">Google Gemini</option>
<option value="otro">Otro</option>
</select>
</div>
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">Modelo</label>
<input x-model="form.model_name" type="text" placeholder="Ej: gemini-2.0-flash"
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-[#8eb02f]" />
</div>
</div>
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">
API Key <span x-show="editItem" class="text-gray-400">(dejar vacío para no cambiar)</span> <span x-show="!editItem">*</span>
</label>
<input x-model="form.api_key" type="password" autocomplete="new-password"
:required="!editItem"
placeholder="sk-..."
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-[#8eb02f] font-mono" />
</div>
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">Base URL
<span x-show="form.provider !== 'ollama' && form.provider !== 'gemini'" class="text-gray-400">(opcional — dejar vacío para usar el default del provider)</span>
<span x-show="form.provider === 'ollama'" class="text-blue-500">* requerido — incluir /v1 al final</span>
<span x-show="form.provider === 'gemini'" class="text-gray-400">(fijo — no necesita)</span>
</label>
<input x-model="form.base_url" type="url"
:placeholder="form.provider === 'ollama' ? 'http://10.0.1.15:11434/v1' : form.provider === 'gemini' ? 'https://generativelanguage.googleapis.com' : 'https://dashscope.aliyuncs.com/compatible-mode/v1'"
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-[#8eb02f]" />
<p x-show="form.provider === 'ollama'" x-cloak class="text-[10px] text-blue-500 mt-1">
Interna (red Coolify): <code>http://10.0.1.15:11434/v1</code> — Pública: <code>https://ollama.u-s.app/v1</code>
</p>
<p x-show="form.provider === 'gemini'" x-cloak class="text-[10px] text-yellow-600 mt-1">
Usa <code>https://generativelanguage.googleapis.com/v1beta/models/{model}:streamGenerateContent?alt=sse</code>. Solo necesitas el API Key.
</p>
</div>
<div>
<label class="block text-xs font-medium text-gray-600 mb-1">Notas</label>
<textarea x-model="form.notes" rows="2" placeholder="Uso, límites, etc."
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-[#8eb02f]"></textarea>
</div>
<div class="flex items-center gap-2">
<input x-model="form.is_active" type="checkbox" id="ai_is_active" class="rounded" />
<label for="ai_is_active" class="text-sm text-gray-700">Activo</label>
</div>
<div class="border border-gray-200 rounded-lg p-3 bg-gray-50">
<label class="flex items-center gap-2 cursor-pointer select-none">
<input x-model="form.es_agente_bot" type="checkbox" class="rounded text-[#8eb02f] focus:ring-[#8eb02f]">
<span class="text-sm text-gray-700">Es el cerebro del agente (bot de Telegram y chat del panel)</span>
</label>
<p class="text-[11px] text-gray-400 mt-1 ml-6">
Solo una config puede serlo: al marcar esta, se desmarca la anterior.
</p>
<div x-show="form.es_agente_bot" x-cloak class="mt-2 ml-6">
<label class="block text-xs font-medium text-gray-600 mb-1">Bot de Telegram (opcional)</label>
<select x-model="form.telegram_config_id"
class="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-[#8eb02f]">
<option value="">Sin bot asignado</option>
<template x-for="t in telegramConfigs" :key="t.ID">
<option :value="t.ID" x-text="t.nombre"></option>
</template>
</select>
</div>
</div>
<div>
<label class="block text-xs font-medium text-gray-600 mb-2">Módulo / Servicio</label>
<div class="border border-gray-200 rounded-lg p-3 space-y-2 bg-gray-50">
<label class="flex items-center gap-2 cursor-pointer select-none">
<input type="checkbox"
:checked="form.modulos.length === 0"
@change="form.modulos = []"
class="rounded text-[#8eb02f] focus:ring-[#8eb02f]">
<span class="text-sm text-gray-700">Global</span>
<span class="text-[10px] text-gray-400 ml-1">— fallback para todos los servicios</span>
</label>
<template x-for="opt in moduleOptions" :key="opt.value">
<label class="flex items-center gap-2 cursor-pointer select-none">
<input type="checkbox"
:value="opt.value"
:checked="form.modulos.includes(opt.value)"
@change="toggleModulo(opt.value)"
class="rounded text-[#8eb02f] focus:ring-[#8eb02f]">
<span class="text-sm text-gray-700" x-text="opt.label"></span>
</label>
</template>
</div>
<p class="text-[10px] text-gray-400 mt-1">
"Global" actúa de fallback. Si seleccionas servicios específicos, solo se usará para ellos.
</p>
</div>
</div>
<div x-show="formError" class="mt-3 p-2 bg-red-50 border border-red-200 rounded text-xs text-red-600" x-text="formError"></div>
<div class="flex justify-end gap-3 mt-5">
<button type="button" @click="closeModal()"
class="px-4 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50 transition">Cancelar</button>
<button type="submit" :disabled="saving"
class="px-4 py-2 text-sm text-white rounded-lg transition disabled:opacity-50"
style="background-color:#8eb02f"
onmouseover="this.style.backgroundColor='#6d8c24'"
onmouseout="this.style.backgroundColor='#8eb02f'">
<span x-text="saving ? 'Guardando...' : (editItem ? 'Actualizar' : 'Crear')"></span>
</button>
</div>
</form>
</div>
</div>
<!-- Modal resultado test -->
<div x-show="testResult" x-cloak class="fixed inset-0 bg-black/50 flex items-center justify-center z-40 p-4">
<div @click.outside="testResult = null" class="bg-white rounded-xl shadow-xl w-full max-w-md p-6">
<div class="flex items-center gap-3 mb-3">
<span x-show="testResult && testResult.ok" class="text-2xl"></span>
<span x-show="testResult && !testResult.ok" class="text-2xl"></span>
<h2 class="text-lg font-bold" x-text="testResult && testResult.ok ? 'Conexión exitosa' : 'Error de conexión'"></h2>
</div>
<pre x-show="testResult" class="text-xs bg-gray-50 border border-gray-200 rounded-lg p-3 overflow-x-auto max-h-60"
x-text="JSON.stringify(testResult, null, 2)"></pre>
<div class="flex justify-end mt-4">
<button @click="testResult = null" class="px-4 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50">Cerrar</button>
</div>
</div>
</div>
<!-- Modal confirmar eliminación -->
<div x-show="deleteId" x-cloak class="fixed inset-0 bg-black/50 flex items-center justify-center z-40 p-4">
<div class="bg-white rounded-xl shadow-xl w-full max-w-sm p-6 text-center">
<svg xmlns="http://www.w3.org/2000/svg" class="h-10 w-10 text-red-500 mx-auto mb-3" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 9v3.75m-9.303 3.376c-.866 1.5.217 3.374 1.948 3.374h14.71c1.73 0 2.813-1.874 1.948-3.374L13.949 3.378c-.866-1.5-3.032-1.5-3.898 0L2.697 16.126zM12 15.75h.007v.008H12v-.008z" />
</svg>
<p class="text-gray-700 font-semibold mb-1">¿Eliminar configuración?</p>
<p class="text-xs text-gray-500 mb-5">Esta acción no se puede deshacer.</p>
<div class="flex justify-center gap-3">
<button @click="deleteId = null" class="px-4 py-2 text-sm border border-gray-300 rounded-lg hover:bg-gray-50">Cancelar</button>
<button @click="doDelete()" :disabled="saving"
class="px-4 py-2 text-sm bg-red-600 text-white rounded-lg hover:bg-red-700 transition disabled:opacity-50">
<span x-text="saving ? 'Eliminando...' : 'Eliminar'"></span>
</button>
</div>
</div>
</div>
</div>
<script>
function aiConfigApp() {
return {
loading: false, saving: false,
items: [], total: 0, totalPages: 1, page: 1,
search: '',
showModal: false, editItem: null, deleteId: null, testResult: null,
errorMsg: '', successMsg: '', formError: '',
form: { nombre: '', provider: '', api_key: '', base_url: '', model_name: '', is_active: true, notes: '', modulos: [], es_agente_bot: false, telegram_config_id: '' },
telegramConfigs: [],
moduleOptions: [
{ value: 'landing', label: 'Landing Generator' },
{ value: 'query_runner', label: 'Query Runner SQL' },
{ value: 'ia', label: 'IA general (vCard, soporte, chat del panel)' },
{ value: 'plantillas', label: 'Plantillas de documento (importar con IA)' },
{ value: 'whisper', label: 'Transcripción de audio (Whisper)' },
{ value: 'umind_embeddings', label: 'uMind — embeddings (RAG del widget)' },
],
async init() {
try {
const r = await fetch('/app/loadtelegram')
const d = await r.json()
this.telegramConfigs = d.registros || d.items || d || []
} catch { this.telegramConfigs = [] }
await this.load()
},
async load() {
this.loading = true
this.errorMsg = ''
const res = await fetch(`/app/ai-config/list?page=${this.page}&search=${encodeURIComponent(this.search)}`)
const data = await res.json()
this.loading = false
if (!res.ok) { this.errorMsg = data.error || 'Error cargando datos'; return }
this.items = data.items || []
this.total = data.total
this.totalPages = data.totalPages
},
goPage(p) { this.page = p; this.load() },
toggleModulo(value) {
const idx = this.form.modulos.indexOf(value)
if (idx >= 0) {
this.form.modulos.splice(idx, 1)
} else {
this.form.modulos.push(value)
}
},
openAdd() {
this.editItem = null
this.form = { nombre: '', provider: 'qwen', api_key: '', base_url: '', model_name: 'qwen2.5-72b-instruct', is_active: true, notes: '', modulos: [], es_agente_bot: false, telegram_config_id: '' }
this.formError = ''
this.showModal = true
},
openEdit(item) {
this.editItem = item
const modulos = item.modulo
? item.modulo.split(',').map(s => s.trim()).filter(s => s)
: []
this.form = { nombre: item.nombre, provider: item.provider, api_key: '', base_url: item.base_url, model_name: item.model_name, is_active: item.is_active, notes: item.notes, modulos, es_agente_bot: !!item.es_agente_bot, telegram_config_id: item.telegram_config_id || '' }
this.formError = ''
this.showModal = true
},
closeModal() { this.showModal = false; this.editItem = null; this.formError = '' },
async save() {
this.saving = true; this.formError = ''
const payload = { ...this.form, modulo: this.form.modulos.join(',') }
delete payload.modulos
payload.telegram_config_id = payload.telegram_config_id ? parseInt(payload.telegram_config_id) : null
const url = this.editItem ? `/app/ai-config/${this.editItem.ID}` : '/app/ai-config'
const method = this.editItem ? 'PUT' : 'POST'
const res = await fetch(url, { method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) })
const data = await res.json()
this.saving = false
if (!res.ok) { this.formError = data.error || 'Error guardando'; return }
this.closeModal()
this.showSuccess(this.editItem ? 'Configuración actualizada' : 'Configuración creada')
await this.load()
},
confirmDelete(id) { this.deleteId = id },
async doDelete() {
this.saving = true
const res = await fetch(`/app/ai-config/${this.deleteId}`, { method: 'DELETE' })
this.saving = false
this.deleteId = null
if (!res.ok) { this.errorMsg = 'Error eliminando'; return }
this.showSuccess('Configuración eliminada')
await this.load()
},
async testConfig(id) {
this.loading = true
const res = await fetch(`/app/ai-config/${id}/test`)
this.loading = false
this.testResult = await res.json()
},
showSuccess(msg) {
this.successMsg = msg
setTimeout(() => { this.successMsg = '' }, 3000)
}
}
}
</script>