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)
|
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)
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user