Spaces:
Running
Running
| # ============================================================================= | |
| # ui.py — Interfaz Gradio para CV Interactivo Ángel Nácar (Versión Avatar) | |
| # ============================================================================= | |
| import gradio as gr | |
| import os | |
| # ── 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.jpg")) | |
| # 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.jpg") | |
| # ── CSS Maestro (Responsivo, Estilo Matrix + Avatar) ─────────────────────── | |
| CUSTOM_CSS = """ | |
| /* Variables de Estilo */ | |
| :root { | |
| --matrix-green: #00ff41; | |
| --matrix-green-dim: #00c832; | |
| --matrix-dark: #0d0d0d; | |
| --matrix-panel: #111411; | |
| --matrix-border: #1a2e1a; | |
| --text-primary: #e8f5e9; | |
| --text-secondary: #4caf50; | |
| --accent-glow: 0 0 15px rgba(0,255,65,0.2); | |
| --avatar-size: 120px; | |
| } | |
| /* ───────────────────────────────────────────────────────────────── | |
| 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; | |
| } | |
| body { | |
| min-height: 100%; | |
| overflow-x: hidden !important; | |
| overflow-y: auto !important; | |
| -webkit-overflow-scrolling: touch !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(--matrix-green) transparent; | |
| } | |
| #chatbot-container table::-webkit-scrollbar { height: 4px; } | |
| #chatbot-container table::-webkit-scrollbar-thumb { | |
| background: var(--matrix-green-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(--matrix-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(--matrix-green) !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(--matrix-panel); | |
| padding: 20px; | |
| border: 1px solid var(--matrix-border); | |
| border-radius: 12px; | |
| /* Sticky para que el teclado no lo entierre */ | |
| position: sticky !important; | |
| bottom: 0 !important; | |
| z-index: 10 !important; | |
| } | |
| /* Evitar zoom automático en iOS al hacer focus (font-size < 16px dispara zoom) */ | |
| #msg-input textarea { | |
| background: #000 !important; | |
| border: 1px solid var(--matrix-border) !important; | |
| color: var(--text-primary) !important; | |
| font-size: 16px !important; /* ≥16px = sin zoom en iOS Safari */ | |
| -webkit-text-size-adjust: 100% !important; | |
| } | |
| /* ───────────────────────────────────────────────────────────────── | |
| ESTILOS BASE (sin cambios respecto a la versión original) | |
| ───────────────────────────────────────────────────────────────── */ | |
| .gradio-container { | |
| background: var(--matrix-dark) !important; | |
| max-width: 1400px !important; | |
| width: 95% !important; | |
| margin: 0 auto !important; | |
| padding: 10px 0 !important; | |
| font-family: 'JetBrains Mono', monospace !important; | |
| } | |
| .contain { padding: 0 !important; } | |
| .gap { gap: 15px !important; } | |
| #cv-header { | |
| width: 100% !important; | |
| text-align: center; | |
| padding: 40px 20px; | |
| border: 1px solid var(--matrix-border); | |
| border-radius: 12px; | |
| background: var(--matrix-panel); | |
| box-shadow: var(--accent-glow); | |
| margin-bottom: 5px; | |
| box-sizing: border-box; | |
| } | |
| #header-avatar { | |
| width: var(--avatar-size); | |
| height: var(--avatar-size); | |
| border-radius: 50%; | |
| object-fit: cover; | |
| border: 3px solid var(--matrix-green); | |
| box-shadow: 0 0 25px rgba(0,255,65,0.5); | |
| margin-bottom: 20px; | |
| } | |
| #header-name { | |
| font-size: clamp(1.8rem, 5vw, 2.8rem); | |
| font-weight: 800; | |
| color: var(--matrix-green); | |
| letter-spacing: 4px; | |
| text-shadow: 0 0 20px rgba(0,255,65,0.4); | |
| margin: 0; | |
| } | |
| #header-title { | |
| font-size: clamp(0.9rem, 2.5vw, 1.2rem); | |
| color: var(--text-secondary); | |
| margin-top: 10px; | |
| margin-bottom: 20px; | |
| letter-spacing: 2px; | |
| text-transform: uppercase; | |
| } | |
| .badge-container { | |
| display: flex; | |
| flex-wrap: wrap; | |
| gap: 8px; | |
| justify-content: center; | |
| } | |
| .skill-badge { | |
| font-size: 0.75rem; | |
| padding: 4px 12px; | |
| border: 1px solid var(--matrix-green-dim); | |
| border-radius: 20px; | |
| color: var(--matrix-green); | |
| background: rgba(0,255,65,0.05); | |
| } | |
| #chatbot-container { | |
| background: var(--matrix-panel) !important; | |
| border: 1px solid var(--matrix-border) !important; | |
| border-radius: 12px !important; | |
| } | |
| #chatbot-container .message { | |
| font-size: 0.95rem !important; | |
| max-width: 100% !important; /* Burbujas no se salen del contenedor */ | |
| box-sizing: border-box !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(--matrix-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(--matrix-green) !important; | |
| color: var(--matrix-green) !important; | |
| box-shadow: var(--accent-glow); | |
| } | |
| #send-btn { | |
| background: var(--matrix-green) !important; | |
| color: black !important; | |
| font-weight: bold !important; | |
| border-radius: 8px !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. | |
| HEADER_HTML = f""" | |
| <div id="cv-header"> | |
| <h1 id="header-name">ÁNGEL NÁCAR</h1> | |
| <p id="header-title">Senior Software Developer • Scrum Master • AI Engineer</p> | |
| <div class="badge-container"> | |
| <span class="skill-badge">Java</span> | |
| <span class="skill-badge">Python</span> | |
| <span class="skill-badge">AWS</span> | |
| <span class="skill-badge">Oracle PL/SQL</span> | |
| <span class="skill-badge">LLM Agents</span> | |
| <span class="skill-badge">Madrid</span> | |
| </div> | |
| </div> | |
| """ | |
| FOOTER_HTML = """ | |
| <div style="text-align: center; color: #333; font-size: 0.7rem; margin-top: 15px; font-family: sans-serif;"> | |
| [ SYS ] CV INTERACTIVO v2.3 • GROQ + OLLAMA HYBRID PIPELINE | |
| </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, maximum-scale=1.0, user-scalable=no'; | |
| 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="Ángel Nácar — CV Interactivo", | |
| theme=gr.themes.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(HEADER_HTML) | |
| # 2. Chatbot | |
| chatbot = gr.Chatbot( | |
| value=[{"role": "assistant", "content": "¡Hola! Soy Mebot, un proyecto agéntico de Ángel Nácar. ¿Qué te gustaría saber sobre él?"}], | |
| 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(FOOTER_HTML) | |
| # ── 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)]) |