- Nueva config "whisper" reutilizando /app/ai-config (credenciales/endpoint
validables) y servicio de transcripción compatible con OpenAI/Groq.
- El webhook de Telegram ahora detecta notas de voz/audio, las transcribe
y procesa el texto resultante como si el usuario lo hubiera escrito.
- Fix: subir un archivo adjunto en un comentario de tarea sin escribir
texto era rechazado por el backend ("contenido requerido") aunque el
frontend sí lo permitía.
384 lines
23 KiB
HTML
384 lines
23 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">
|
|
<template x-if="!item.modulo">
|
|
<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">
|
|
<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'
|
|
}"
|
|
x-text="m.trim() === 'landing' ? 'Landing' : m.trim() === 'query_runner' ? 'Query Runner' : m.trim() === 'ia' ? 'IA / vCard' : m.trim() === 'whisper' ? 'Whisper' : 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>
|
|
<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: [] },
|
|
|
|
moduleOptions: [
|
|
{ value: 'landing', label: 'Landing Generator' },
|
|
{ value: 'query_runner', label: 'Query Runner SQL' },
|
|
{ value: 'ia', label: 'IA / vCard' },
|
|
{ value: 'whisper', label: 'Transcripción de audio (Whisper)' },
|
|
],
|
|
|
|
async init() { 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: [] }
|
|
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 }
|
|
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
|
|
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>
|