Spaces:
Running
Running
| from __future__ import annotations | |
| import gradio as gr | |
| from config import CONFIG | |
| THEME = gr.themes.Soft( | |
| primary_hue="orange", | |
| secondary_hue="emerald", | |
| neutral_hue="stone", | |
| text_size=gr.themes.sizes.text_lg, | |
| spacing_size=gr.themes.sizes.spacing_lg, | |
| radius_size=gr.themes.sizes.radius_lg, | |
| font=[gr.themes.GoogleFont("Atkinson Hyperlegible"), "system-ui", "sans-serif"], | |
| ).set( | |
| body_background_fill="linear-gradient(160deg, #fdf6ec 0%, #f6e7d2 100%)", | |
| body_text_color="#3d352e", | |
| block_background_fill="#fffdf9", | |
| block_border_width="0px", | |
| block_shadow="0 10px 34px rgba(61, 53, 46, 0.08)", | |
| block_radius="26px", | |
| button_primary_background_fill="#e07a5f", | |
| button_primary_background_fill_hover="#cf6a4f", | |
| button_primary_text_color="#ffffff", | |
| button_large_radius="40px", | |
| ) | |
| CSS = """ | |
| .gradio-container { max-width: 760px !important; margin: 0 auto !important; } | |
| footer, .footer { display: none !important; } | |
| /* Header */ | |
| #amigo-head { text-align: center; margin: 8px 0 2px; } | |
| #amigo-head h1 { font-size: 40px; margin: 0; color: #3d352e; } | |
| #amigo-head p { font-size: 22px; margin: 4px 0 0; color: #8a7a68; } | |
| /* Reactive avatar */ | |
| .amigo-avatar { display: flex; flex-direction: column; align-items: center; | |
| gap: 14px; padding: 18px 0 6px; } | |
| .amigo-avatar .orb { | |
| width: 150px; height: 150px; border-radius: 50%; | |
| display: flex; align-items: center; justify-content: center; | |
| font-size: 76px; background: radial-gradient(circle at 35% 30%, #ffe6c7, #e9b48a); | |
| box-shadow: 0 12px 30px rgba(224, 122, 95, 0.30); | |
| transition: transform .25s ease, box-shadow .25s ease; } | |
| .amigo-avatar .cap { font-size: 24px; color: #6b5d4d; font-weight: 600; } | |
| /* per-state colors + motion */ | |
| .amigo-avatar.is-listening .orb { | |
| background: radial-gradient(circle at 35% 30%, #d7efe2, #81b29a); | |
| animation: pulse 1.2s ease-in-out infinite; } | |
| .amigo-avatar.is-thinking .orb { | |
| background: radial-gradient(circle at 35% 30%, #fdeccb, #e6c684); | |
| animation: sway 1.6s ease-in-out infinite; } | |
| .amigo-avatar.is-talking .orb { | |
| background: radial-gradient(circle at 35% 30%, #ffd9c4, #e07a5f); | |
| animation: pulse .7s ease-in-out infinite; } | |
| @keyframes pulse { | |
| 0%,100% { transform: scale(1); box-shadow: 0 12px 30px rgba(224,122,95,.30); } | |
| 50% { transform: scale(1.07); box-shadow: 0 16px 40px rgba(224,122,95,.45); } } | |
| @keyframes sway { | |
| 0%,100% { transform: rotate(-4deg); } 50% { transform: rotate(4deg); } } | |
| /* Chat bubbles: big and warm */ | |
| #amigo-chat .message { font-size: 23px !important; line-height: 1.55 !important; | |
| border-radius: 22px !important; padding: 14px 18px !important; } | |
| #amigo-chat .message.user { background: #e07a5f !important; color: #fff !important; } | |
| #amigo-chat .message.bot { background: #f1e7d6 !important; color: #3d352e !important; } | |
| /* Tap-to-talk: turn Gradio's audio widget into one big round button. | |
| The avatar already says what to do, so we hide every other control. */ | |
| #amigo-mic { border: none !important; background: transparent !important; | |
| box-shadow: none !important; } | |
| #amigo-mic [data-testid="block-label"], | |
| #amigo-mic .icon-button-wrapper, | |
| #amigo-mic .pause-button, | |
| #amigo-mic .resume-button, | |
| #amigo-mic .mic-select, | |
| #amigo-mic .microphone, | |
| #amigo-mic [data-testid="recording-waveform"], | |
| #amigo-mic [data-testid="status-tracker"] { display: none !important; } | |
| #amigo-mic .controls, #amigo-mic .controls .wrapper { | |
| display: flex !important; justify-content: center !important; | |
| align-items: center !important; border: none !important; background: none !important; } | |
| /* The icon is a white SVG background (a mic / a square), which `!important` | |
| wins reliably. We hide the button's text and both pseudo-elements, so none | |
| of Gradio's record-dot or waveform marks leak through. We never touch | |
| `display`, so Gradio's own show/hide of record-vs-stop keeps working. */ | |
| #amigo-mic .record-button > *, | |
| #amigo-mic .record-button::before, #amigo-mic .record-button::after, | |
| #amigo-mic .stop-button > *, #amigo-mic .stop-button-paused > *, | |
| #amigo-mic .stop-button::before, #amigo-mic .stop-button::after, | |
| #amigo-mic .stop-button-paused::before, #amigo-mic .stop-button-paused::after { | |
| display: none !important; } | |
| /* idle: a big green "talk" circle with a white mic */ | |
| #amigo-mic .record-button { | |
| width: 150px !important; height: 150px !important; border-radius: 50% !important; | |
| border: none !important; color: transparent !important; font-size: 0 !important; | |
| background-color: #81b29a !important; | |
| background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%23ffffff'%3E%3Cpath d='M12 15a3 3 0 0 0 3-3V6a3 3 0 0 0-6 0v6a3 3 0 0 0 3 3z'/%3E%3Cpath d='M19 12a7 7 0 0 1-14 0H3a9 9 0 0 0 8 8.94V23h2v-2.06A9 9 0 0 0 21 12z'/%3E%3C/svg%3E") !important; | |
| background-repeat: no-repeat !important; background-position: center !important; | |
| background-size: 62px 62px !important; | |
| box-shadow: 0 10px 28px rgba(129, 178, 154, .45) !important; | |
| transition: transform .15s ease; } | |
| #amigo-mic .record-button:hover { transform: scale(1.05); } | |
| /* recording: a big red, pulsing "stop" circle with a white square */ | |
| #amigo-mic .stop-button, #amigo-mic .stop-button-paused { | |
| width: 150px !important; height: 150px !important; border-radius: 50% !important; | |
| border: none !important; color: transparent !important; font-size: 0 !important; | |
| background-color: #e07a5f !important; | |
| background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%23ffffff'%3E%3Crect x='7' y='7' width='10' height='10' rx='2'/%3E%3C/svg%3E") !important; | |
| background-repeat: no-repeat !important; background-position: center !important; | |
| background-size: 58px 58px !important; | |
| box-shadow: 0 10px 28px rgba(224, 122, 95, .5) !important; | |
| animation: pulse .9s ease-in-out infinite; } | |
| /* Transcript: a soft reading aid, not a stark white void */ | |
| #amigo-chat { background: #fffdf9 !important; border-radius: 22px !important; } | |
| /* Reply player: present (so autoplay works) but small and quiet */ | |
| #amigo-reply { opacity: .5; transform: scale(.85); } | |
| #amigo-reply label { font-size: 15px !important; } | |
| """ | |
| # CSS class per state (language-independent); icon and caption come from the pack. | |
| _CLASS = { | |
| "idle": "", "listening": "is-listening", | |
| "thinking": "is-thinking", "talking": "is-talking", | |
| } | |
| def avatar(state: str, caption: str | None = None) -> str: | |
| """Render the avatar orb HTML for a turn state, with an optional caption.""" | |
| icon, default_caption = CONFIG.pack["ui"]["states"].get( | |
| state, CONFIG.pack["ui"]["states"]["idle"] | |
| ) | |
| text = caption or default_caption | |
| return ( | |
| f'<div class="amigo-avatar {_CLASS.get(state, "")}">' | |
| f'<div class="orb">{icon}</div>' | |
| f'<div class="cap">{text}</div></div>' | |
| ) | |