feat(umind): que el cliente conecte su propia cuenta de IA
El modelo soportaba una config de IA por espacio desde hace varias fases, pero no había forma de cargarla: ni el cliente desde el portal, ni el staff por él. Quedaba como una columna que solo se podía llenar tocando la base a mano. Ahora hay una pantalla — "Tu IA", desde la lista de agentes — donde se conecta una cuenta de OpenAI, Anthropic, Gemini, Groq, DeepSeek, Qwen u Ollama. Con el botón "Probar", que manda una consulta real: una clave vencida se descubre ahí y no cuando un cliente escribe y no le contestan. El aislamiento es la parte delicada, y va en tres capas: Las configs globales son del staff. Un cliente puede usarlas —le aparecen en el selector— pero no editarlas ni borrarlas; el acceso corta antes de mirar permisos, así que ni siquiera se le confirma que existen. Sin eso, cualquiera podría cambiarle el proveedor de IA a todos los demás o dejarlos sin servicio. El tenant sale del alcance ya validado, nunca del body. Si viniera del cuerpo de la petición, bastaría con cambiar el número para colgarle una config a otro cliente, y con ella una clave que no es suya. El tenant no se puede mover en una edición, por lo mismo al revés: sería regalarle la propia. La clave se guarda cifrada y no vuelve nunca al navegador — ni al dueño. Solo sus últimos cuatro caracteres, que alcanzan para reconocer cuál cargó. Borrar está bloqueado si hay agentes usándola: si no, quedarían apuntando a algo inexistente y cayendo al proveedor global sin que nadie se entere. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
195590f5e4
commit
926bbde9fb
@@ -3,6 +3,7 @@ import Home from './views/Home.vue'
|
||||
import TenantAgentes from './views/TenantAgentes.vue'
|
||||
import AgenteDetail from './views/AgenteDetail.vue'
|
||||
import Uso from './views/Uso.vue'
|
||||
import AiPropia from './views/AiPropia.vue'
|
||||
import { contexto } from './lib/contexto.js'
|
||||
|
||||
const router = createRouter({
|
||||
@@ -11,6 +12,7 @@ const router = createRouter({
|
||||
{ path: '/', name: 'home', component: Home },
|
||||
{ path: '/tenants/:id', name: 'tenant-agentes', component: TenantAgentes, props: true },
|
||||
{ path: '/tenants/:id/uso', name: 'uso', component: Uso, props: true },
|
||||
{ path: '/tenants/:id/ia', name: 'ai-propia', component: AiPropia, props: true },
|
||||
{
|
||||
path: '/tenants/:tenantId/agentes/:agenteId',
|
||||
name: 'agente-detail',
|
||||
|
||||
@@ -0,0 +1,255 @@
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue'
|
||||
import { api } from '../lib/api.js'
|
||||
import { apiUmind } from '../lib/contexto.js'
|
||||
import UiEmptyState from '../components/ui/UiEmptyState.vue'
|
||||
|
||||
const props = defineProps({ id: { type: String, required: true } })
|
||||
|
||||
const items = ref([])
|
||||
const cargando = ref(true)
|
||||
const error = ref('')
|
||||
const showForm = ref(false)
|
||||
const editando = ref(null)
|
||||
const guardando = ref(false)
|
||||
const probando = ref(null)
|
||||
const resultado = ref({})
|
||||
|
||||
// Los proveedores que acepta el backend. El texto de ayuda de cada uno importa
|
||||
// más que la lista: nadie recuerda de memoria el nombre exacto de un modelo.
|
||||
const proveedores = [
|
||||
{ valor: 'openai', nombre: 'OpenAI', modelo: 'gpt-4o-mini', donde: 'platform.openai.com/api-keys' },
|
||||
{ valor: 'anthropic', nombre: 'Anthropic (Claude)', modelo: 'claude-sonnet-4-20250514', donde: 'console.anthropic.com' },
|
||||
{ valor: 'gemini', nombre: 'Google Gemini', modelo: 'gemini-2.0-flash', donde: 'aistudio.google.com/apikey' },
|
||||
{ valor: 'groq', nombre: 'Groq', modelo: 'llama-3.3-70b-versatile', donde: 'console.groq.com/keys' },
|
||||
{ valor: 'deepseek', nombre: 'DeepSeek', modelo: 'deepseek-chat', donde: 'platform.deepseek.com' },
|
||||
{ valor: 'qwen', nombre: 'Qwen (Alibaba)', modelo: 'qwen2.5-72b-instruct', donde: 'dashscope.console.aliyun.com' },
|
||||
{ valor: 'ollama', nombre: 'Ollama (tu servidor)', modelo: 'llama3.1', donde: '' },
|
||||
]
|
||||
|
||||
function vacio() {
|
||||
return { nombre: '', provider: 'openai', api_key: '', base_url: '', model_name: '' }
|
||||
}
|
||||
|
||||
const form = ref(vacio())
|
||||
|
||||
function proveedorDe(valor) {
|
||||
return proveedores.find((p) => p.valor === valor) || proveedores[0]
|
||||
}
|
||||
|
||||
async function cargar() {
|
||||
cargando.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const r = await api.get(apiUmind(`/umind/ai-propia?tenant_id=${props.id}`))
|
||||
items.value = r.items || []
|
||||
} catch (e) {
|
||||
error.value = e.message
|
||||
} finally {
|
||||
cargando.value = false
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => props.id, cargar, { immediate: true })
|
||||
|
||||
function nueva() {
|
||||
editando.value = null
|
||||
form.value = vacio()
|
||||
showForm.value = true
|
||||
}
|
||||
|
||||
function editar(c) {
|
||||
editando.value = c
|
||||
// La clave no vuelve del servidor: se deja en blanco y solo se manda si la
|
||||
// persona escribe una nueva.
|
||||
form.value = {
|
||||
nombre: c.nombre,
|
||||
provider: c.provider,
|
||||
api_key: '',
|
||||
base_url: c.base_url || '',
|
||||
model_name: c.model_name || '',
|
||||
}
|
||||
showForm.value = true
|
||||
}
|
||||
|
||||
async function guardar() {
|
||||
guardando.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const cuerpo = { tenant_id: Number(props.id), ...form.value }
|
||||
if (editando.value) {
|
||||
await api.put(apiUmind(`/umind/ai-propia/${editando.value.ID}`), cuerpo)
|
||||
} else {
|
||||
await api.post(apiUmind('/umind/ai-propia'), cuerpo)
|
||||
}
|
||||
showForm.value = false
|
||||
await cargar()
|
||||
} catch (e) {
|
||||
error.value = e.message
|
||||
} finally {
|
||||
guardando.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Probar contra el proveedor de verdad. Una clave vencida no se nota hasta que
|
||||
// alguien escribe: esto lo adelanta.
|
||||
async function probar(c) {
|
||||
probando.value = c.ID
|
||||
resultado.value = { ...resultado.value, [c.ID]: null }
|
||||
try {
|
||||
const r = await api.post(apiUmind(`/umind/ai-propia/${c.ID}/probar`), {})
|
||||
resultado.value = { ...resultado.value, [c.ID]: { ok: true, texto: r.respuesta } }
|
||||
} catch (e) {
|
||||
resultado.value = { ...resultado.value, [c.ID]: { ok: false, texto: e.message } }
|
||||
} finally {
|
||||
probando.value = null
|
||||
}
|
||||
}
|
||||
|
||||
async function borrar(c) {
|
||||
if (!confirm(`¿Eliminar "${c.nombre}"?`)) return
|
||||
error.value = ''
|
||||
try {
|
||||
await api.del(apiUmind(`/umind/ai-propia/${c.ID}`))
|
||||
await cargar()
|
||||
} catch (e) {
|
||||
error.value = e.message
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex items-start justify-between gap-4 mb-6">
|
||||
<div>
|
||||
<router-link :to="`/tenants/${id}`" class="text-sm text-tenue hover:text-texto">← Agentes</router-link>
|
||||
<h1 class="text-xl font-semibold text-texto mt-1">Tu cuenta de IA</h1>
|
||||
<p class="text-sm text-tenue mt-1 max-w-xl">
|
||||
Podés usar tu propia cuenta de OpenAI, Claude o Gemini en vez de la nuestra.
|
||||
El consumo se factura directo con el proveedor y no pasa por tu plan.
|
||||
</p>
|
||||
</div>
|
||||
<button class="btn-primary shrink-0" @click="nueva">+ Conectar cuenta</button>
|
||||
</div>
|
||||
|
||||
<p v-if="error" class="text-sm text-red-600 dark:text-red-400 mb-4">{{ error }}</p>
|
||||
|
||||
<div v-if="cargando" class="text-sm text-tenue">Cargando…</div>
|
||||
|
||||
<UiEmptyState
|
||||
v-else-if="items.length === 0"
|
||||
class="card"
|
||||
titulo="Estás usando la IA de uMind"
|
||||
detalle="Funciona sin que hagas nada. Conectá tu propia cuenta solo si querés facturar el consumo por tu lado o usar un modelo puntual."
|
||||
>
|
||||
<button class="btn-primary" @click="nueva">Conectar mi cuenta</button>
|
||||
</UiEmptyState>
|
||||
|
||||
<div v-else class="card divide-y divide-borde">
|
||||
<div v-for="c in items" :key="c.ID" class="p-4">
|
||||
<div class="flex items-start justify-between gap-4">
|
||||
<div class="min-w-0">
|
||||
<div class="text-sm text-texto font-medium">{{ c.nombre }}</div>
|
||||
<div class="text-xs text-tenue mt-0.5">
|
||||
{{ proveedorDe(c.provider).nombre }} · {{ c.model_name }} ·
|
||||
<span class="font-mono">{{ c.api_key_pista }}</span>
|
||||
</div>
|
||||
<p
|
||||
v-if="resultado[c.ID]"
|
||||
class="text-xs mt-1.5"
|
||||
:class="resultado[c.ID].ok ? 'text-brand' : 'text-red-600 dark:text-red-400'"
|
||||
>
|
||||
{{ resultado[c.ID].ok ? `Responde bien — dijo "${resultado[c.ID].texto}"` : resultado[c.ID].texto }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-3 shrink-0 text-sm">
|
||||
<button
|
||||
class="text-brand hover:underline disabled:opacity-40"
|
||||
:disabled="probando === c.ID"
|
||||
@click="probar(c)"
|
||||
>{{ probando === c.ID ? 'Probando…' : 'Probar' }}</button>
|
||||
<button class="text-tenue hover:text-texto" @click="editar(c)">Editar</button>
|
||||
<button class="text-red-500 hover:text-red-700" @click="borrar(c)">Eliminar</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="items.length" class="text-xs text-tenue mt-3">
|
||||
Para que un agente la use, elegila en su campo <strong>Config de IA</strong>.
|
||||
</p>
|
||||
|
||||
<!-- Alta / edición -->
|
||||
<div
|
||||
v-if="showForm"
|
||||
class="fixed inset-0 bg-black/40 flex items-center justify-center p-4 z-50"
|
||||
@click.self="showForm = false"
|
||||
>
|
||||
<div class="card w-full max-w-lg p-6 max-h-[calc(100vh-2rem)] overflow-y-auto">
|
||||
<h2 class="font-semibold text-texto mb-4">
|
||||
{{ editando ? 'Editar cuenta de IA' : 'Conectar tu cuenta de IA' }}
|
||||
</h2>
|
||||
|
||||
<form class="space-y-3" @submit.prevent="guardar">
|
||||
<div>
|
||||
<label class="label">Nombre</label>
|
||||
<input v-model="form.nombre" required placeholder="ej: Mi cuenta de OpenAI" class="input" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="label">Proveedor</label>
|
||||
<select v-model="form.provider" class="input">
|
||||
<option v-for="p in proveedores" :key="p.valor" :value="p.valor">{{ p.nombre }}</option>
|
||||
</select>
|
||||
<p v-if="proveedorDe(form.provider).donde" class="text-xs text-tenue mt-1">
|
||||
La clave se saca de <span class="font-mono">{{ proveedorDe(form.provider).donde }}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="label">Modelo</label>
|
||||
<input
|
||||
v-model="form.model_name"
|
||||
required
|
||||
:placeholder="`ej: ${proveedorDe(form.provider).modelo}`"
|
||||
class="input font-mono"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="form.provider !== 'ollama'">
|
||||
<label class="label">
|
||||
Clave de API
|
||||
<span v-if="editando" class="text-tenue font-normal">— dejala vacía para no cambiarla</span>
|
||||
</label>
|
||||
<input
|
||||
v-model="form.api_key"
|
||||
type="password"
|
||||
autocomplete="new-password"
|
||||
:required="!editando"
|
||||
placeholder="sk-..."
|
||||
class="input font-mono"
|
||||
/>
|
||||
<p class="text-xs text-tenue mt-1">Se guarda cifrada y no se vuelve a mostrar.</p>
|
||||
</div>
|
||||
|
||||
<div v-if="form.provider === 'ollama'">
|
||||
<label class="label">URL de tu servidor</label>
|
||||
<input
|
||||
v-model="form.base_url"
|
||||
required
|
||||
placeholder="https://ollama.tudominio.com/v1"
|
||||
class="input font-mono"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-2 pt-2">
|
||||
<button type="button" class="btn-ghost" @click="showForm = false">Cancelar</button>
|
||||
<button type="submit" class="btn-primary" :disabled="guardando">
|
||||
{{ guardando ? 'Guardando…' : 'Guardar' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -167,7 +167,8 @@ watch(() => props.id, cargar, { immediate: true })
|
||||
<h1 class="text-xl font-semibold text-texto">{{ tenant.nombre }}</h1>
|
||||
<p class="text-xs text-tenue mt-1">{{ tenant.dominios_permitidos || 'sin dominios configurados' }}</p>
|
||||
</div>
|
||||
<router-link :to="`/tenants/${tenantId ?? id}/uso`" class="btn-ghost">📊 Consumo</router-link>
|
||||
<router-link :to="`/tenants/${tenantId ?? id}/ia`" class="btn-ghost">Tu IA</router-link>
|
||||
<router-link :to="`/tenants/${tenantId ?? id}/uso`" class="btn-ghost">📊 Consumo</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user