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:
@@ -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)
|
||||
|
||||
# S3/MinIO configuration
|
||||
@@ -2782,20 +2782,50 @@ def get_ai_configuration(
|
||||
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)
|
||||
def create_ai_configuration(
|
||||
config: schemas.AIConfigurationCreate,
|
||||
db: Session = Depends(get_db),
|
||||
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":
|
||||
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})
|
||||
|
||||
# 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
|
||||
if not model_name:
|
||||
if config.provider == "openai":
|
||||
@@ -2807,19 +2837,31 @@ def create_ai_configuration(
|
||||
else:
|
||||
model_name = "default"
|
||||
|
||||
# Crear nueva configuración
|
||||
new_config = models.AIConfiguration(
|
||||
provider=config.provider,
|
||||
api_key=config.api_key,
|
||||
model_name=model_name,
|
||||
is_active=True
|
||||
)
|
||||
if existing_config:
|
||||
# Actualizar configuración existente
|
||||
# Solo actualizar API key si se proporciona una nueva (no vacía)
|
||||
if config.api_key and config.api_key.strip():
|
||||
existing_config.api_key = config.api_key
|
||||
existing_config.model_name = model_name
|
||||
existing_config.is_active = True # Activar este proveedor
|
||||
db.commit()
|
||||
db.refresh(existing_config)
|
||||
return existing_config
|
||||
else:
|
||||
# Crear nueva configuración (requiere API key)
|
||||
if not config.api_key or not config.api_key.strip():
|
||||
raise HTTPException(status_code=400, detail="API key es requerida para nuevo proveedor")
|
||||
|
||||
db.add(new_config)
|
||||
db.commit()
|
||||
db.refresh(new_config)
|
||||
|
||||
return new_config
|
||||
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)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "checklist-frontend",
|
||||
"private": true,
|
||||
"version": "1.2.5",
|
||||
"version": "1.2.6",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Service Worker para PWA con detección de actualizaciones
|
||||
// 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 = [
|
||||
'/',
|
||||
'/index.html'
|
||||
|
||||
@@ -506,6 +506,14 @@ function SettingsTab({ user }) {
|
||||
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(() => {
|
||||
const fetchLogo = async () => {
|
||||
try {
|
||||
@@ -541,7 +549,7 @@ function SettingsTab({ user }) {
|
||||
setAvailableModels(models);
|
||||
}
|
||||
|
||||
// Cargar configuración actual
|
||||
// Cargar configuración activa
|
||||
const configRes = await fetch(`${API_URL}/api/ai/configuration`, {
|
||||
headers: { 'Authorization': `Bearer ${token}` }
|
||||
});
|
||||
@@ -549,6 +557,7 @@ function SettingsTab({ user }) {
|
||||
if (configRes.ok) {
|
||||
const config = await configRes.json();
|
||||
setAiConfig(config);
|
||||
setActiveProvider(config.provider);
|
||||
setFormData({
|
||||
provider: config.provider || 'openai',
|
||||
api_key: config.api_key || '',
|
||||
@@ -557,6 +566,30 @@ function SettingsTab({ user }) {
|
||||
} else if (configRes.status === 404) {
|
||||
// No hay configuración guardada, usar valores por defecto
|
||||
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) {
|
||||
console.error('Error loading settings:', error);
|
||||
@@ -647,30 +680,48 @@ function SettingsTab({ user }) {
|
||||
<div className="flex gap-4 mt-4">
|
||||
<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'}`}
|
||||
>
|
||||
<div className="text-4xl mb-2">🤖</div>
|
||||
<div className="font-semibold">OpenAI</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
|
||||
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'}`}
|
||||
>
|
||||
<div className="text-4xl mb-2">🧠</div>
|
||||
<div className="font-semibold">Anthropic Claude</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
|
||||
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'}`}
|
||||
>
|
||||
<div className="text-4xl mb-2">✨</div>
|
||||
<div className="font-semibold">Google Gemini</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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -680,13 +731,19 @@ function SettingsTab({ user }) {
|
||||
<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'}
|
||||
</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
|
||||
type="password"
|
||||
value={formData.api_key}
|
||||
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"
|
||||
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">
|
||||
{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="space-y-3">
|
||||
{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'}`}>
|
||||
<input
|
||||
type="radio"
|
||||
name="model_name"
|
||||
value={model.id}
|
||||
checked={formData.model_name === model.id}
|
||||
onChange={() => setFormData({ ...formData, model_name: model.id })}
|
||||
className="form-radio text-indigo-600"
|
||||
/>
|
||||
<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>
|
||||
))}
|
||||
{filteredModels.map((model) => {
|
||||
// Solo marcar como checked si este proveedor está activo Y es el modelo activo
|
||||
const isActiveModel = activeProvider === formData.provider && formData.model_name === model.id;
|
||||
|
||||
return (
|
||||
<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'}`}>
|
||||
<input
|
||||
type="radio"
|
||||
name="model_name"
|
||||
value={model.id}
|
||||
checked={isActiveModel}
|
||||
onChange={() => setFormData({ ...formData, model_name: model.id })}
|
||||
className="form-radio text-indigo-600"
|
||||
/>
|
||||
<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>
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
<p className="text-xs text-indigo-300 font-medium hover:text-indigo-200">
|
||||
Ayutec v1.2.5
|
||||
Ayutec v1.2.6
|
||||
</p>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user