# ============================================================================= # 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"""

{profile['name']}

{profile['title']}

{''.join(f'{skill}' for skill in profile['skills'])}
""" def get_footer_html(profile): return f"""
CV INTERACTIVO • {profile['name']} • AI Agent
""" # ── 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 directamente. # gr.HTML con """ 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)])