Resumen de Cambios Implementados

Backend v1.2.1
Mejoras en gestión de API keys multi-proveedor:

Nuevo endpoint /api/ai/api-keys: Retorna todas las API keys guardadas por proveedor (enmascaradas para seguridad)

Formato: {"openai": {"has_key": true, "masked_key": "sk-proj...xyz", "is_active": false}}
Solo administradores pueden acceder
Endpoint /api/ai/configuration mejorado:

Ahora preserva API keys existentes cuando se cambia de proveedor
Si ya existe configuración para un proveedor, solo actualiza el modelo y activa ese proveedor
Solo requiere API key nueva si no existe configuración previa para ese proveedor
Validación: no acepta API keys vacías para nuevos proveedores
Persistencia de configuraciones:

Cada proveedor (OpenAI, Anthropic, Gemini) mantiene su registro en la base de datos
Solo uno tiene is_active=True a la vez
Al cambiar de proveedor, se desactiva el anterior pero NO se elimina
Frontend v1.2.6
UX mejorada para configuración de IA:

Indicadores visuales en botones de proveedor:

Badge "✓ ACTIVO" en verde para el proveedor actualmente activo
Badge "Configurado" en gris para proveedores con API key guardada pero inactivos
Sin badges para proveedores no configurados
Selector de modelos inteligente:

Solo muestra modelo seleccionado si el proveedor está activo
Al hacer click en un proveedor inactivo, NO se pre-selecciona ningún modelo
Solo al GUARDAR se activa el proveedor con el modelo seleccionado
Input de API key con contexto:

Muestra key enmascarada si ya existe: ✓ Ya tienes una API key guardada: sk-proj...xyz
Permite dejar vacío para mantener la key actual
Solo requiere key nueva si el proveedor no tiene una guardada
Flujo de trabajo mejorado:

Click en proveedor → Cambia tab de formulario
Si ya tiene key guardada → Se muestra enmascarada, puede mantenerla
Seleccionar modelo → Click en "Guardar Configuración"
Solo entonces se ACTIVA ese proveedor y modelo
Beneficios
No re-ingresar API keys: Al cambiar entre proveedores, las keys se preservan
Claridad visual: Solo el proveedor activo muestra badge verde y modelo seleccionado
Seguridad: API keys enmascaradas en la UI (sk-proj...xyz)
Flexibilidad: Configurar los 3 proveedores y cambiar entre ellos sin perder configuración
Versiones actualizadas:

Backend: 1.2.0 → 1.2.1
Frontend: 1.2.5 → 1.2.6
Service Worker: cache v1.2.6
This commit is contained in:
2025-12-04 11:52:38 -03:00
parent 24eb039302
commit 7f2e9add29
5 changed files with 145 additions and 41 deletions

View File

@@ -276,7 +276,7 @@ def extract_pdf_text_smart(pdf_content: bytes, max_chars: int = None) -> dict:
} }
BACKEND_VERSION = "1.2.0" BACKEND_VERSION = "1.2.1"
app = FastAPI(title="Checklist Inteligente API", version=BACKEND_VERSION) app = FastAPI(title="Checklist Inteligente API", version=BACKEND_VERSION)
# S3/MinIO configuration # S3/MinIO configuration
@@ -2782,20 +2782,50 @@ def get_ai_configuration(
return config return config
@app.get("/api/ai/api-keys")
def get_all_api_keys(
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user)
):
"""Obtener todas las API keys guardadas (sin mostrar las keys completas)"""
if current_user.role != "admin":
raise HTTPException(status_code=403, detail="Solo administradores pueden ver API keys")
configs = db.query(models.AIConfiguration).all()
result = {}
for config in configs:
# Solo devolver si tiene API key guardada (enmascarada)
if config.api_key:
masked_key = config.api_key[:8] + "..." + config.api_key[-4:] if len(config.api_key) > 12 else "***"
result[config.provider] = {
"has_key": True,
"masked_key": masked_key,
"is_active": config.is_active
}
return result
@app.post("/api/ai/configuration", response_model=schemas.AIConfiguration) @app.post("/api/ai/configuration", response_model=schemas.AIConfiguration)
def create_ai_configuration( def create_ai_configuration(
config: schemas.AIConfigurationCreate, config: schemas.AIConfigurationCreate,
db: Session = Depends(get_db), db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user) current_user: models.User = Depends(get_current_user)
): ):
"""Crear o actualizar configuración de IA""" """Crear o actualizar configuración de IA - ACTIVA el proveedor seleccionado"""
if current_user.role != "admin": if current_user.role != "admin":
raise HTTPException(status_code=403, detail="Solo administradores pueden configurar IA") raise HTTPException(status_code=403, detail="Solo administradores pueden configurar IA")
# Desactivar configuraciones anteriores # Desactivar TODAS las configuraciones
db.query(models.AIConfiguration).update({"is_active": False}) db.query(models.AIConfiguration).update({"is_active": False})
# Determinar modelo por defecto según el proveedor si no se especifica # Buscar si ya existe configuración para este proveedor
existing_config = db.query(models.AIConfiguration).filter(
models.AIConfiguration.provider == config.provider
).first()
# Determinar modelo por defecto si no se especifica
model_name = config.model_name model_name = config.model_name
if not model_name: if not model_name:
if config.provider == "openai": if config.provider == "openai":
@@ -2807,19 +2837,31 @@ def create_ai_configuration(
else: else:
model_name = "default" model_name = "default"
# Crear nueva configuración if existing_config:
new_config = models.AIConfiguration( # Actualizar configuración existente
provider=config.provider, # Solo actualizar API key si se proporciona una nueva (no vacía)
api_key=config.api_key, if config.api_key and config.api_key.strip():
model_name=model_name, existing_config.api_key = config.api_key
is_active=True existing_config.model_name = model_name
) existing_config.is_active = True # Activar este proveedor
db.commit()
db.add(new_config) db.refresh(existing_config)
db.commit() return existing_config
db.refresh(new_config) else:
# Crear nueva configuración (requiere API key)
return new_config if not config.api_key or not config.api_key.strip():
raise HTTPException(status_code=400, detail="API key es requerida para nuevo proveedor")
new_config = models.AIConfiguration(
provider=config.provider,
api_key=config.api_key,
model_name=model_name,
is_active=True # Activar este proveedor
)
db.add(new_config)
db.commit()
db.refresh(new_config)
return new_config
@app.put("/api/ai/configuration/{config_id}", response_model=schemas.AIConfiguration) @app.put("/api/ai/configuration/{config_id}", response_model=schemas.AIConfiguration)

View File

@@ -1,7 +1,7 @@
{ {
"name": "checklist-frontend", "name": "checklist-frontend",
"private": true, "private": true,
"version": "1.2.5", "version": "1.2.6",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",

View File

@@ -1,6 +1,6 @@
// Service Worker para PWA con detección de actualizaciones // Service Worker para PWA con detección de actualizaciones
// IMPORTANTE: Actualizar esta versión cada vez que se despliegue una nueva versión // IMPORTANTE: Actualizar esta versión cada vez que se despliegue una nueva versión
const CACHE_NAME = 'ayutec-v1.2.5'; const CACHE_NAME = 'ayutec-v1.2.6';
const urlsToCache = [ const urlsToCache = [
'/', '/',
'/index.html' '/index.html'

View File

@@ -505,6 +505,14 @@ function SettingsTab({ user }) {
api_key: '', api_key: '',
model_name: 'gpt-4o' model_name: 'gpt-4o'
}); });
// Estado para guardar todas las API keys y proveedor activo
const [savedApiKeys, setSavedApiKeys] = useState({
openai: '',
anthropic: '',
gemini: ''
});
const [activeProvider, setActiveProvider] = useState(null); // Proveedor actualmente activo
useEffect(() => { useEffect(() => {
const fetchLogo = async () => { const fetchLogo = async () => {
@@ -541,7 +549,7 @@ function SettingsTab({ user }) {
setAvailableModels(models); setAvailableModels(models);
} }
// Cargar configuración actual // Cargar configuración activa
const configRes = await fetch(`${API_URL}/api/ai/configuration`, { const configRes = await fetch(`${API_URL}/api/ai/configuration`, {
headers: { 'Authorization': `Bearer ${token}` } headers: { 'Authorization': `Bearer ${token}` }
}); });
@@ -549,6 +557,7 @@ function SettingsTab({ user }) {
if (configRes.ok) { if (configRes.ok) {
const config = await configRes.json(); const config = await configRes.json();
setAiConfig(config); setAiConfig(config);
setActiveProvider(config.provider);
setFormData({ setFormData({
provider: config.provider || 'openai', provider: config.provider || 'openai',
api_key: config.api_key || '', api_key: config.api_key || '',
@@ -557,6 +566,30 @@ function SettingsTab({ user }) {
} else if (configRes.status === 404) { } else if (configRes.status === 404) {
// No hay configuración guardada, usar valores por defecto // No hay configuración guardada, usar valores por defecto
console.log('No hay configuración de IA guardada'); console.log('No hay configuración de IA guardada');
setActiveProvider(null);
}
// Cargar todas las API keys guardadas
const keysRes = await fetch(`${API_URL}/api/ai/api-keys`, {
headers: { 'Authorization': `Bearer ${token}` }
});
if (keysRes.ok) {
const keys = await keysRes.json();
const newSavedKeys = {
openai: '',
anthropic: '',
gemini: ''
};
// Las keys vienen enmascaradas, solo indicamos que existen
Object.keys(keys).forEach(provider => {
if (keys[provider].has_key) {
newSavedKeys[provider] = keys[provider].masked_key;
}
});
setSavedApiKeys(newSavedKeys);
} }
} catch (error) { } catch (error) {
console.error('Error loading settings:', error); console.error('Error loading settings:', error);
@@ -647,30 +680,48 @@ function SettingsTab({ user }) {
<div className="flex gap-4 mt-4"> <div className="flex gap-4 mt-4">
<button <button
type="button" type="button"
onClick={() => setFormData({ ...formData, provider: 'openai', model_name: 'gpt-4o' })} onClick={() => setFormData({ ...formData, provider: 'openai' })}
className={`p-4 border-2 rounded-lg transition ${formData.provider === 'openai' ? 'border-indigo-500 bg-indigo-50' : 'border-gray-300 hover:border-gray-400'}`} className={`p-4 border-2 rounded-lg transition ${formData.provider === 'openai' ? 'border-indigo-500 bg-indigo-50' : 'border-gray-300 hover:border-gray-400'}`}
> >
<div className="text-4xl mb-2">🤖</div> <div className="text-4xl mb-2">🤖</div>
<div className="font-semibold">OpenAI</div> <div className="font-semibold">OpenAI</div>
<div className="text-xs text-gray-600 mt-1">GPT-4, GPT-4 Vision</div> <div className="text-xs text-gray-600 mt-1">GPT-4, GPT-4 Vision</div>
{activeProvider === 'openai' && (
<div className="mt-2 text-xs font-bold text-green-600"> ACTIVO</div>
)}
{savedApiKeys.openai && activeProvider !== 'openai' && (
<div className="mt-2 text-xs text-gray-500">Configurado</div>
)}
</button> </button>
<button <button
type="button" type="button"
onClick={() => setFormData({ ...formData, provider: 'anthropic', model_name: 'claude-sonnet-4-5' })} onClick={() => setFormData({ ...formData, provider: 'anthropic' })}
className={`p-4 border-2 rounded-lg transition ${formData.provider === 'anthropic' ? 'border-purple-500 bg-purple-50' : 'border-gray-300 hover:border-gray-400'}`} className={`p-4 border-2 rounded-lg transition ${formData.provider === 'anthropic' ? 'border-purple-500 bg-purple-50' : 'border-gray-300 hover:border-gray-400'}`}
> >
<div className="text-4xl mb-2">🧠</div> <div className="text-4xl mb-2">🧠</div>
<div className="font-semibold">Anthropic Claude</div> <div className="font-semibold">Anthropic Claude</div>
<div className="text-xs text-gray-600 mt-1">Sonnet, Opus, Haiku</div> <div className="text-xs text-gray-600 mt-1">Sonnet, Opus, Haiku</div>
{activeProvider === 'anthropic' && (
<div className="mt-2 text-xs font-bold text-green-600"> ACTIVO</div>
)}
{savedApiKeys.anthropic && activeProvider !== 'anthropic' && (
<div className="mt-2 text-xs text-gray-500">Configurado</div>
)}
</button> </button>
<button <button
type="button" type="button"
onClick={() => setFormData({ ...formData, provider: 'gemini', model_name: 'gemini-2.5-pro' })} onClick={() => setFormData({ ...formData, provider: 'gemini' })}
className={`p-4 border-2 rounded-lg transition ${formData.provider === 'gemini' ? 'border-blue-500 bg-blue-50' : 'border-gray-300 hover:border-gray-400'}`} className={`p-4 border-2 rounded-lg transition ${formData.provider === 'gemini' ? 'border-blue-500 bg-blue-50' : 'border-gray-300 hover:border-gray-400'}`}
> >
<div className="text-4xl mb-2"></div> <div className="text-4xl mb-2"></div>
<div className="font-semibold">Google Gemini</div> <div className="font-semibold">Google Gemini</div>
<div className="text-xs text-gray-600 mt-1">Gemini Pro, Flash</div> <div className="text-xs text-gray-600 mt-1">Gemini Pro, Flash</div>
{activeProvider === 'gemini' && (
<div className="mt-2 text-xs font-bold text-green-600"> ACTIVO</div>
)}
{savedApiKeys.gemini && activeProvider !== 'gemini' && (
<div className="mt-2 text-xs text-gray-500">Configurado</div>
)}
</button> </button>
</div> </div>
</div> </div>
@@ -680,13 +731,19 @@ function SettingsTab({ user }) {
<label className="block text-sm font-medium text-gray-700 mb-2"> <label className="block text-sm font-medium text-gray-700 mb-2">
{formData.provider === 'openai' ? 'OpenAI API Key' : formData.provider === 'anthropic' ? 'Anthropic API Key' : 'Google AI API Key'} {formData.provider === 'openai' ? 'OpenAI API Key' : formData.provider === 'anthropic' ? 'Anthropic API Key' : 'Google AI API Key'}
</label> </label>
{savedApiKeys[formData.provider] && (
<div className="mb-2 text-xs text-green-600">
Ya tienes una API key guardada: {savedApiKeys[formData.provider]}
<span className="text-gray-500 ml-2">(Deja vacío para mantener la actual o ingresa una nueva)</span>
</div>
)}
<input <input
type="password" type="password"
value={formData.api_key} value={formData.api_key}
onChange={(e) => setFormData({ ...formData, api_key: e.target.value })} onChange={(e) => setFormData({ ...formData, api_key: e.target.value })}
className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent" className="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder={formData.provider === 'openai' ? 'sk-...' : formData.provider === 'anthropic' ? 'sk-ant-...' : 'AIza...'} placeholder={formData.provider === 'openai' ? 'sk-...' : formData.provider === 'anthropic' ? 'sk-ant-...' : 'AIza...'}
required required={!savedApiKeys[formData.provider]}
/> />
<p className="text-xs text-gray-500 mt-2"> <p className="text-xs text-gray-500 mt-2">
{formData.provider === 'openai' ? ( {formData.provider === 'openai' ? (
@@ -707,22 +764,27 @@ function SettingsTab({ user }) {
<div className="text-gray-500">No hay modelos disponibles para {formData.provider}</div> <div className="text-gray-500">No hay modelos disponibles para {formData.provider}</div>
) : ( ) : (
<div className="space-y-3"> <div className="space-y-3">
{filteredModels.map((model) => ( {filteredModels.map((model) => {
<label key={model.id} className={`flex items-center gap-3 p-3 border rounded-lg cursor-pointer transition ${formData.model_name === model.id ? 'border-indigo-500 bg-indigo-50' : 'border-gray-300 hover:border-gray-400'}`}> // Solo marcar como checked si este proveedor está activo Y es el modelo activo
<input const isActiveModel = activeProvider === formData.provider && formData.model_name === model.id;
type="radio"
name="model_name" return (
value={model.id} <label key={model.id} className={`flex items-center gap-3 p-3 border rounded-lg cursor-pointer transition ${isActiveModel ? 'border-indigo-500 bg-indigo-50' : 'border-gray-300 hover:border-gray-400'}`}>
checked={formData.model_name === model.id} <input
onChange={() => setFormData({ ...formData, model_name: model.id })} type="radio"
className="form-radio text-indigo-600" name="model_name"
/> value={model.id}
<div className="flex-1"> checked={isActiveModel}
<div className="font-semibold text-gray-900">{model.name}</div> onChange={() => setFormData({ ...formData, model_name: model.id })}
<div className="text-xs text-gray-500 mt-1">{model.description}</div> className="form-radio text-indigo-600"
</div> />
</label> <div className="flex-1">
))} <div className="font-semibold text-gray-900">{model.name}</div>
<div className="text-xs text-gray-500 mt-1">{model.description}</div>
</div>
</label>
);
})}
</div> </div>
)} )}
</div> </div>

View File

@@ -153,7 +153,7 @@ export default function Sidebar({ user, activeTab, setActiveTab, sidebarOpen, se
className="w-10 h-10 object-contain bg-white rounded p-1" className="w-10 h-10 object-contain bg-white rounded p-1"
/> />
<p className="text-xs text-indigo-300 font-medium hover:text-indigo-200"> <p className="text-xs text-indigo-300 font-medium hover:text-indigo-200">
Ayutec v1.2.5 Ayutec v1.2.6
</p> </p>
</a> </a>
</div> </div>