AndyBot / ui.py
AndresMV's picture
Upload folder using huggingface_hub
76996f8 verified
Raw History Blame Contribute Delete
21.2 kB
# =============================================================================
# ui.py β€” Interfaz Gradio para CV Interactivo Andres Marcelo Villarreal (VersiΓ³n Avatar)
# =============================================================================
import gradio as gr
from gradio.themes import Base
import os
import json
with open("me/profile.json") as f:
profile = json.load(f)
# ── ConfiguraciΓ³n de Preguntas y Rutas ──────────────────────────────────────────
# SUGGESTED_QUESTIONS = [
# "ΒΏCuΓ‘l es tu experiencia con sistemas crΓ­ticos?",
# "ΒΏQuΓ© stack tecnolΓ³gico dominas actualmente?",
# "ΒΏCΓ³mo gestionas un equipo Γ‘gil como Scrum Master?",
# "CuΓ©ntame sobre tus proyectos de IA y automatizaciΓ³n.",
# "ΒΏQuΓ© certificaciones tienes en Cloud e IA?",
# ]
# Definimos la ruta del avatar (usando ruta absoluta para asegurar la carga en Gradio)
AVATAR_PATH = os.path.abspath(os.path.join("assets", "avatar.png"))
# Generic user avatar URL (Mystery Person de Gravatar)
AVATAR_USER = "https://www.gravatar.com/avatar/00000000000000000000000000000000?d=mp&f=y"
# Tu avatar local para el Bot
AVATAR_BOT = os.path.join("assets", "avatar.png")
# ── CSS Maestro (Responsivo, Estilo Matrix + Avatar) ───────────────────────
CUSTOM_CSS = """
/* Variables de Estilo */
:root {
--bg-primary: #0f172a; /* azul oscuro elegante */
--bg-secondary: #111827; /* panel */
--border: #1f2937;
--text-primary: #f9fafb;
--text-secondary: #9ca3af;
--accent: #3b82f6; /* azul moderno */
--accent-soft: rgba(59,130,246,0.1);
--success: #10b981;
}
/* ─────────────────────────────────────────────────────────────────
FIX CRÍTICO MOBILE #1 β€” Scroll global en iframe de HF Spaces
El iframe de Hugging Face bloquea el scroll tΓ‘ctil si html/body
tienen overflow implΓ­cito. Forzamos el modelo correcto.
───────────────────────────────────────────────────────────────── */
html {
height: 100%;
overflow-y: auto !important;
-webkit-overflow-scrolling: touch !important;
background-color: #0f172a !important;
}
body {
min-height: 100%;
overflow-x: hidden !important;
overflow-y: auto !important;
-webkit-overflow-scrolling: touch !important;
color: #f9fafb !important;
background-color: #0f172a !important;
}
/* ─────────────────────────────────────────────────────────────────
FIX CRÍTICO MOBILE #2 β€” Scroll tΓ‘ctil en el chatbot
Gradio envuelve el historial en varios divs anidados.
Cubrimos todos los selectores conocidos de Gradio 3.x / 4.x.
───────────────────────────────────────────────────────────────── */
#chatbot-container,
#chatbot-container > div,
#chatbot-container .wrap,
#chatbot-container .scroll-hide,
#chatbot-container [data-testid="bot"],
#chatbot-container .chatbot {
overflow-y: auto !important;
overflow-x: hidden !important;
-webkit-overflow-scrolling: touch !important; /* iOS momentum scroll */
touch-action: pan-y !important; /* Permite scroll vertical tΓ‘ctil */
overscroll-behavior: contain !important; /* Evita que el scroll se propague al iframe padre */
}
/* ─────────────────────────────────────────────────────────────────
FIX CRÍTICO MOBILE #3 β€” Tablas en modo "espagueti"
El problema: en mΓ³vil real, el contenedor del mensaje NO tiene
overflow:auto, por lo que la tabla empuja el layout y se rompe.
SoluciΓ³n en dos capas:
a) El mensaje burbuja del bot se convierte en contenedor
scrollable horizontal.
b) La tabla se fuerza a bloque con overflow-x:auto propio.
Usamos selectores amplios porque Gradio puede generar:
.prose > table, .message > table, .bot > table, etc.
───────────────────────────────────────────────────────────────── */
/* Capa a: burbuja del bot como contenedor scrollable */
#chatbot-container .message.bot,
#chatbot-container .bot-row .message,
#chatbot-container [data-testid="bot"] .message,
#chatbot-container .bubble-wrap,
#chatbot-container .prose {
overflow-x: auto !important;
-webkit-overflow-scrolling: touch !important;
max-width: 100% !important;
/* Evita que el texto del bot empuje el layout */
word-break: break-word !important;
overflow-wrap: break-word !important;
}
/* Capa b: la tabla en sΓ­, scrollable y con ancho mΓ­nimo por columna */
#chatbot-container table,
#chatbot-container .prose table,
#chatbot-container .message table,
#chatbot-container .bot table {
display: block !important;
width: max-content !important; /* Se expande al ancho real de su contenido */
max-width: 100% !important;
overflow-x: auto !important;
-webkit-overflow-scrolling: touch !important;
touch-action: pan-x pan-y !important;
border-collapse: collapse !important;
margin: 12px 0 !important;
/* Scrollbar Matrix-style */
scrollbar-width: thin;
scrollbar-color: var(--accent) transparent;
}
#chatbot-container table::-webkit-scrollbar { height: 4px; }
#chatbot-container table::-webkit-scrollbar-thumb {
background: var(--accent-dim);
border-radius: 10px;
}
/* Celdas: ancho mΓ­nimo para que el contenido respire */
#chatbot-container table th,
#chatbot-container table td {
min-width: 120px !important;
padding: 8px 12px !important;
white-space: normal !important;
word-break: normal !important;
border: 1px solid var(--border) !important;
vertical-align: top !important;
font-size: 0.82rem !important;
line-height: 1.4 !important;
}
#chatbot-container table th {
background: rgba(0, 255, 65, 0.1) !important;
color: var(--accent) !important;
text-transform: uppercase;
letter-spacing: 1px;
white-space: nowrap !important; /* Cabeceras en una lΓ­nea para anclar el layout */
}
/* ─────────────────────────────────────────────────────────────────
FIX MOBILE #4 β€” Input area y teclado virtual (iOS / Android)
En mΓ³vil, el teclado virtual reduce el viewport y el input
queda oculto. position:sticky lo mantiene visible.
───────────────────────────────────────────────────────────────── */
#input-area {
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 12px;
padding: 12px;
}
/* Evitar zoom automΓ‘tico en iOS al hacer focus (font-size < 16px dispara zoom) */
#msg-input textarea {
background: #020617 !important;
border: 1px solid var(--border) !important;
color: var(--text-primary) !important;
font-size: 16px !important;
}
/* ─────────────────────────────────────────────────────────────────
ESTILOS BASE (sin cambios respecto a la versiΓ³n original)
───────────────────────────────────────────────────────────────── */
.gradio-container {
background: var(--bg-primary) !important;
width: 100% !important;
max-width: 1000px !important;
margin: 0 auto !important;
font-family: 'Inter', sans-serif !important;
color: var(--text-primary) !important;
}
.contain { padding: 0 !important; }
.gap { gap: 15px !important; }
#cv-header {
background: var(--bg-secondary);
border: 1px solid var(--border);
border-radius: 16px;
padding: 32px;
text-align: left;
}
#header-avatar {
width: var(--avatar-size);
height: var(--avatar-size);
border-radius: 50%;
object-fit: cover;
border: 3px solid var(--accent);
box-shadow: 0 0 25px rgba(0,255,65,0.5);
margin-bottom: 20px;
}
#header-name {
font-size: 2rem;
font-weight: 700;
color: var(--text-primary);
}
#header-title {
color: var(--text-secondary);
margin-top: 6px;
}
.badge-container {
display: flex;
flex-wrap: wrap;
gap: 8px;
justify-content: center;
}
.skill-badge {
background: var(--accent-soft);
color: var(--accent);
border-radius: 999px;
padding: 6px 12px;
font-size: 0.8rem;
border: none;
}
.message.user {
background: var(--accent);
color: white;
border-radius: 12px;
}
.message.bot {
background: #1e293b;
color: var(--text-primary);
border-radius: 12px;
}
#chatbot-container {
background: var(--bg-secondary) !important;
border-radius: 16px !important;
border: 1px solid var(--border) !important;
padding: 10px;
}
#chatbot-container .message {
font-size: 0.95rem !important;
max-width: 100% !important; /* Burbujas no se salen del contenedor */
box-sizing: border-box !important;
color: #f9fafb !important;
}
#suggestions-row {
display: flex !important;
flex-wrap: nowrap !important;
overflow-x: auto !important;
gap: 10px !important;
padding: 5px 0 !important;
scrollbar-width: none;
}
#suggestions-row::-webkit-scrollbar { display: none; }
.suggestion-btn {
flex: 0 0 auto !important;
background: #1a1a1a !important;
border: 1px solid var(--border) !important;
color: var(--text-secondary) !important;
border-radius: 20px !important;
padding: 5px 15px !important;
transition: all 0.3s ease !important;
}
.suggestion-btn:hover {
border-color: var(--accent) !important;
color: var(--accent) !important;
box-shadow: var(--accent-glow);
}
#send-btn {
background: var(--accent) !important;
color: white !important;
border-radius: 10px !important;
font-weight: 500;
}
#send-btn:hover {
background: #2563eb !important;
}
#clear-btn {
background: transparent !important;
color: #ff5555 !important;
border: 1px solid #442222 !important;
border-radius: 8px !important;
}
/* ─────────────────────────────────────────────────────────────────
RESPONSIVE BREAKPOINTS
───────────────────────────────────────────────────────────────── */
@media (max-width: 600px) {
:root { --avatar-size: 80px; }
.gradio-container { width: 100% !important; padding: 0 !important; }
#cv-header { padding: 20px 10px; border-radius: 0; }
#input-area { border-radius: 0; padding: 12px; }
/* En mΓ³vil las burbujas ocupan casi todo el ancho */
#chatbot-container .message {
max-width: 92% !important;
}
/* Reducir altura del chatbot en pantallas pequeΓ±as
para que el input quede siempre visible sin scroll de pΓ‘gina */
#chatbot-container {
height: 55dvh !important; /* dvh: dynamic viewport height, descuenta el teclado */
min-height: 280px !important;
border-radius: 0 !important;
}
/* Celdas aΓΊn mΓ‘s compactas en mΓ³vil */
#chatbot-container table th,
#chatbot-container table td {
min-width: 90px !important;
font-size: 0.75rem !important;
padding: 6px 8px !important;
}
}
@media (max-width: 380px) {
:root { --avatar-size: 68px; }
#header-name { letter-spacing: 2px; }
}
"""
# ── HTML de Componentes (Ahora con la imagen) ───────────────────────────
# Usamos file/ prefixed path para Gradio local.
def get_header_html(profile):
return f"""
<div id="cv-header">
<h1 id="header-name">{profile['name']}</h1>
<p id="header-title">{profile['title']}</p>
<div class="badge-container">
{''.join(f'<span class="skill-badge">{skill}</span>' for skill in profile['skills'])}
</div>
</div>
"""
def get_footer_html(profile):
return f"""
<div style="text-align: center; color: #333; font-size: 0.7rem; margin-top: 15px; font-family: sans-serif;">
CV INTERACTIVO β€’ {profile['name']} β€’ AI Agent
</div>
"""
# ── ConstrucciΓ³n de la UI ──────────────────────────────────────────────────
def build_ui(chat_fn):
# ── JS: Viewport meta + table-wrapper inyectados en runtime ────────────
# Gradio en HF Spaces no expone el <head> directamente.
# gr.HTML con <script> se ejecuta dentro del body pero es suficiente
# para aΓ±adir el viewport meta (si no existe) y envolver tablas.
MOBILE_FIX_JS = """
<script>
(function() {
/* 1. Viewport meta β€” crΓ­tico para escala correcta en mΓ³vil real */
if (!document.querySelector('meta[name="viewport"]')) {
var m = document.createElement('meta');
m.name = 'viewport';
m.content = 'width=device-width, initial-scale=1.0';
document.head.appendChild(m);
}
/* 2. Envolver tablas en un div scrollable.
CSS puede hacer overflow:auto en la tabla, pero en algunos builds
de Gradio el elemento padre tiene overflow:hidden implΓ­cito.
Envolvemos cada tabla con un div dedicado que hace scroll. */
function wrapTables() {
var chatbot = document.getElementById('chatbot-container');
if (!chatbot) return;
chatbot.querySelectorAll('table').forEach(function(table) {
if (table.parentElement.classList.contains('tbl-scroll-wrap')) return;
var wrap = document.createElement('div');
wrap.className = 'tbl-scroll-wrap';
wrap.style.cssText = [
'overflow-x:auto',
'-webkit-overflow-scrolling:touch',
'touch-action:pan-x pan-y',
'max-width:100%',
'margin:12px 0',
'border-radius:4px'
].join(';');
table.parentNode.insertBefore(wrap, table);
wrap.appendChild(table);
table.style.margin = '0';
});
}
/* 3. Observer: detecta nuevas burbujas del bot y envuelve sus tablas */
var observer = new MutationObserver(function(mutations) {
mutations.forEach(function(m) {
if (m.addedNodes.length) wrapTables();
});
});
/* Esperamos a que Gradio monte el DOM */
document.addEventListener('DOMContentLoaded', function() {
var target = document.getElementById('chatbot-container') || document.body;
observer.observe(target, { childList: true, subtree: true });
wrapTables();
});
/* Fallback por si DOMContentLoaded ya paso */
if (document.readyState !== 'loading') {
setTimeout(function() {
var target = document.getElementById('chatbot-container') || document.body;
observer.observe(target, { childList: true, subtree: true });
wrapTables();
}, 500);
}
})();
</script>
"""
with gr.Blocks(
css=CUSTOM_CSS,
title=f"{profile['name']} β€” CV Interactivo",
theme=Base()
) as app:
# 0. JS de correcciones mobile (se inyecta primero, antes del DOM visible)
gr.HTML(MOBILE_FIX_JS)
# 1. Encabezado (con Avatar)
gr.HTML(get_header_html(profile))
# 2. Chatbot
chatbot = gr.Chatbot(
value=[{"role": "assistant", "content": f"Β‘Hola! Soy un asistente basado en el perfil de {profile['name']}. ΒΏQuΓ© te gustarΓ­a saber?"}],
elem_id="chatbot-container",
height=450,
show_label=False,
# avatar_images=(
# None, # usuario β€” sin avatar
# # None, # bot β€” sin avatar (usamos ::before CSS)
# # Para habilitar tu foto real descomenta:
# "assets/avatar.jpg",
# ),
avatar_images=(AVATAR_USER, AVATAR_BOT), # Orden: (Usuario, Bot)
# type="messages",
)
# # 3. Sugerencias
# with gr.Row(elem_id="suggestions-row"):
# for q in SUGGESTED_QUESTIONS:
# btn = gr.Button(q, elem_classes=["suggestion-btn"], size="sm")
# # LΓ³gica para autocompletar el textbox
# btn.click(fn=lambda x=q: x, outputs=[gr.State(q)]).then(
# fn=None, js=f"(q) => {{ document.querySelector('#msg-input textarea').value = '{q}'; }}"
# )
# 4. Área de Entrada
with gr.Column(elem_id="input-area"):
msg = gr.Textbox(
placeholder="Escribe tu pregunta aquΓ­ (ej. ΒΏCuΓ‘l es su experiencia en Java?)...",
show_label=False,
lines=1,
elem_id="msg-input",
max_lines=3,
autofocus=True
)
with gr.Row():
# clear_btn = gr.Button("β†Ί LIMPIAR", elem_id="clear-btn", scale=1)
send_btn = gr.Button("ENVIAR", elem_id="send-btn", scale=4)
gr.HTML(get_footer_html(profile))
# ── LΓ³gica de InteracciΓ³n ──
# def respond(message, history):
# if not message or message.strip() == "":
# return "", history
# # Obtener respuesta del agente
# reply = chat_fn(message, history)
# # Actualizar historial
# history.append({"role": "user", "content": message})
# history.append({"role": "assistant", "content": reply})
# return "", history
def user_message(message, history):
# Agrega el mensaje del usuario a la historia inmediatamente
# y devuelve un string vacΓ­o para limpiar el input
return "", history + [{"role": "user", "content": message}]
def bot_message(history, chat_fn):
# Toma el ΓΊltimo mensaje del usuario (ya presente en history)
user_query = history[-1]["content"]
# Llama a tu funciΓ³n de IA
bot_response = chat_fn(user_query, history[:-1])
# Agrega la respuesta del bot a la historia
history.append({"role": "assistant", "content": bot_response})
return history
# Eventos de envΓ­o
# --- LΓ³gica de InteracciΓ³n Fluida ---
# 1. Definimos quΓ© pasa cuando el usuario envΓ­a
submit_event = msg.submit(
fn=user_message,
inputs=[msg, chatbot],
outputs=[msg, chatbot],
queue=False # Importante para que sea instantΓ‘neo
).then(
fn=lambda h: bot_message(h, chat_fn),
inputs=[chatbot],
outputs=[chatbot]
)
# 2. Lo mismo para el botΓ³n de enviar
send_btn.click(
fn=user_message,
inputs=[msg, chatbot],
outputs=[msg, chatbot],
queue=False
).then(
fn=lambda h: bot_message(h, chat_fn),
inputs=[chatbot],
outputs=[chatbot]
)
# msg.submit(respond, [msg, chatbot], [msg, chatbot])
# send_btn.click(respond, [msg, chatbot], [msg, chatbot])
# Evento de limpieza
# clear_btn.click(
# lambda: (None, [{"role": "assistant", "content": "ConversaciΓ³n reiniciada. ΒΏEn quΓ© mΓ‘s puedo ayudarte?"}]),
# outputs=[msg, chatbot]
# )
return app
# Testing Standalone
if __name__ == "__main__":
def mock_chat(msg, hist): return f"Has preguntado: **{msg}**. Esta es una respuesta de prueba simulando al agente."
# # IMPORTANTE: AVISAR A GRADIO QUE PERMITA EL ACCESO A LA CARPETA 'assets'
# build_ui(mock_chat).launch(allowed_paths=[os.path.dirname(AVATAR_PATH)])