Spaces:
Running on Zero
Running on Zero
| """NeuroBait UI — hybrid: a standard AI-chat experience (``gr.ChatInterface``, | |
| DeepSeek/Perplexity-ish) carrying NeuroBait's identity. | |
| gradio-only (no torch/spaces) so the UI can be built and smoke-tested without the | |
| model stack. Theme + CSS are applied at ``launch()`` because Gradio 6 moved | |
| ``theme``/``css``/``js`` off the Blocks/ChatInterface constructor — see the | |
| deprecation warning we hit in the Space logs. | |
| Dark-only, earthy, calm sage-teal accent (gentler / more sensory-safe for ADHD | |
| than vivid green). The palette is set straight on ``:root`` and mapped onto | |
| Gradio's core CSS vars unconditionally, so the app is dark with no dependence on | |
| JS or HF's ``?__theme``. | |
| UX notes for ADHD users: | |
| - Fewer decisions: mood is 4 choices, examples are single-click starting points. | |
| - No shame framing: copy is warm, safety scope is visible, no streaks/deadlines. | |
| - Consistent language: the Gradio footer is forced to English so the whole app | |
| feels like one voice, regardless of browser locale. | |
| """ | |
| from __future__ import annotations | |
| import base64 | |
| from pathlib import Path | |
| import gradio as gr | |
| MOODS = ["Calm", "Tired", "Anxious", "Focused"] | |
| THEME = gr.themes.Soft(primary_hue="teal", neutral_hue="slate") | |
| TITLE = "" | |
| MARK_PATH = Path(__file__).with_name("neurobait-mark.png") | |
| DESCRIPTION = ( | |
| "ADHD-friendly companion. " | |
| "A warm space and a gentle boost for your everyday. " | |
| "No red pen · no pressure · no bullet points paralysis. " | |
| "Not diagnostic" | |
| ) | |
| # Force the Gradio shell footer to English so it does not clash with the app's | |
| # English copy when the viewer's browser locale is Indonesian. | |
| I18N_FOOTER = { | |
| "use_via_api": "Use via API", | |
| "built_with_gradio": "Built with Gradio", | |
| "settings": "Settings", | |
| } | |
| I18N = gr.I18n( | |
| en=I18N_FOOTER, | |
| id=I18N_FOOTER, | |
| ) | |
| MARK_SRC = f"data:image/png;base64,{base64.b64encode(MARK_PATH.read_bytes()).decode('ascii')}" | |
| HEADER_HTML = f""" | |
| <header class="nb-header"> | |
| <img src="{MARK_SRC}" alt="" class="nb-mark" /> | |
| <div> | |
| <div class="nb-wordmark">NeuroBait</div> | |
| <div class="nb-kicker">task initiation companion</div> | |
| </div> | |
| </header> | |
| """ | |
| FOOTER_HTML = """ | |
| <div class="nb-footer-links"> | |
| <a href="/?view=api">Use via API</a> | |
| <span>·</span> | |
| <a href="https://gradio.app" target="_blank" rel="noreferrer">Built with Gradio</a> | |
| <span>·</span> | |
| <span>Settings</span> | |
| </div> | |
| """ | |
| PLACEHOLDER = "What's on your mind? No need to be tidy." | |
| EXAMPLES = [ | |
| ["There's something I've been meaning to get to, and it keeps slipping by."], | |
| ["My space feels a bit cluttered today."], | |
| ["My mind feels a little foggy right now."], | |
| ] | |
| CSS = """ | |
| /* Earthy, always-dark palette on :root (no .dark dependency), with a calm muted | |
| sage-teal accent instead of vivid green. */ | |
| :root { | |
| --forest: #6fb6a2; | |
| --sage: #5da894; | |
| --mint: #20362f; | |
| --cream: #1d2128; | |
| --linen: #15181d; | |
| --sand: #2e333b; | |
| --stone: #aeb6bf; | |
| --charcoal: #eef1f4; | |
| --radius: 10px; | |
| --brand-grad: linear-gradient(135deg, #5da894, #4f9582); | |
| } | |
| /* map Gradio core theme vars onto our dark palette so built-in components render | |
| dark even when Gradio's own .dark class is off */ | |
| body, .gradio-container, gradio-app, .dark { | |
| --body-background-fill: var(--linen); | |
| --background-fill-primary: var(--cream); | |
| --background-fill-secondary: var(--linen); | |
| --block-background-fill: var(--cream); | |
| --block-label-background-fill: var(--cream); | |
| --block-border-color: var(--sand); | |
| --border-color-primary: var(--sand); | |
| --body-text-color: var(--charcoal); | |
| --body-text-color-subdued: var(--stone); | |
| --input-background-fill: var(--cream); | |
| --input-border-color: var(--sand); | |
| --button-secondary-background-fill: var(--cream); | |
| --button-secondary-text-color: var(--charcoal); | |
| } | |
| body, .gradio-container { | |
| background: var(--linen) !important; | |
| color: var(--charcoal) !important; | |
| font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji" !important; | |
| } | |
| /* keep the conversation in a comfortable centered column like a modern chat app */ | |
| .gradio-container { max-width: 960px !important; margin: 0 auto !important; } | |
| h1 { letter-spacing: -0.5px; font-weight: 800; } | |
| .nb-header { | |
| display: flex; | |
| align-items: center; | |
| justify-content: flex-start; | |
| gap: 12px; | |
| padding: 1rem 0 0.4rem; | |
| } | |
| .nb-mark { | |
| width: 42px; | |
| height: 42px; | |
| object-fit: contain; | |
| flex: 0 0 auto; | |
| } | |
| .nb-wordmark { | |
| font-size: 1.24rem; | |
| line-height: 1.05; | |
| font-weight: 780; | |
| letter-spacing: 0; | |
| color: var(--charcoal); | |
| } | |
| .nb-kicker { | |
| margin-top: 3px; | |
| color: var(--stone); | |
| font-size: 0.78rem; | |
| line-height: 1.2; | |
| } | |
| /* user bubble gets the brand accent, assistant stays flat/quiet */ | |
| .message.user, [data-testid="user"] { | |
| background: var(--brand-grad) !important; color: #fff !important; border: none !important; | |
| } | |
| .message.bot, [data-testid="bot"] { | |
| background: var(--cream) !important; border: 1px solid var(--sand) !important; color: var(--charcoal) !important; | |
| } | |
| /* rounded, calm input + primary button */ | |
| textarea, input[type="text"] { | |
| border-radius: var(--radius) !important; background: var(--cream) !important; | |
| border: 1px solid var(--sand) !important; color: var(--charcoal) !important; | |
| } | |
| button.primary, .submit-button, [variant="primary"] { | |
| background: var(--brand-grad) !important; border: none !important; color: #fff !important; | |
| } | |
| /* ADHD-friendly examples: render as calm chips instead of a data table */ | |
| .examples table, | |
| [data-testid="examples"] table, | |
| .examples-wrap table { | |
| display: block !important; | |
| width: 100% !important; | |
| } | |
| .examples table thead, | |
| [data-testid="examples"] table thead, | |
| .examples-wrap table thead { | |
| display: none !important; | |
| } | |
| .examples table tbody, | |
| [data-testid="examples"] table tbody, | |
| .examples-wrap table tbody { | |
| display: flex !important; | |
| flex-wrap: wrap !important; | |
| gap: 8px !important; | |
| } | |
| .examples table tr, | |
| [data-testid="examples"] table tr, | |
| .examples-wrap table tr { | |
| display: inline-flex !important; | |
| } | |
| .examples table td, | |
| [data-testid="examples"] table td, | |
| .examples-wrap table td { | |
| background: var(--cream) !important; | |
| border: 1px solid var(--sand) !important; | |
| border-radius: 999px !important; | |
| padding: 8px 14px !important; | |
| color: var(--charcoal) !important; | |
| cursor: pointer !important; | |
| font-size: 0.9rem !important; | |
| line-height: 1.4 !important; | |
| transition: background 0.15s ease, border-color 0.15s ease !important; | |
| } | |
| .examples table td:hover, | |
| [data-testid="examples"] table td:hover, | |
| .examples-wrap table td:hover { | |
| background: var(--mint) !important; | |
| border-color: var(--forest) !important; | |
| } | |
| /* Make the empty-state placeholder feel centered and readable */ | |
| .chatbot .placeholder, | |
| [data-testid="chatbot"] .placeholder { | |
| text-align: center !important; | |
| display: flex !important; | |
| align-items: center !important; | |
| justify-content: center !important; | |
| height: 100% !important; | |
| opacity: 0.9 !important; | |
| } | |
| /* Safety scope footer at the bottom of the app */ | |
| .gradio-container::after { | |
| content: "Not a medical device · not a therapist · just a gentle nudge"; | |
| display: block; | |
| text-align: center; | |
| color: var(--stone); | |
| font-size: 0.75rem; | |
| padding: 1.5rem 0 1rem; | |
| margin-top: 1rem; | |
| border-top: 1px solid var(--sand); | |
| } | |
| .nb-footer-links { | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| gap: 9px; | |
| color: var(--stone); | |
| font-size: 0.8rem; | |
| margin: 1.75rem 0 0.25rem; | |
| } | |
| .nb-footer-links a, | |
| .nb-footer-links span { | |
| color: var(--stone) !important; | |
| text-decoration: none !important; | |
| } | |
| .nb-footer-links a:hover { | |
| color: var(--charcoal) !important; | |
| } | |
| """ | |
| JS = """ | |
| () => { | |
| const replacements = [ | |
| ["Gunakan melalui API", "Use via API"], | |
| ["Dibuat dengan Gradio", "Built with Gradio"], | |
| ["Pengaturan", "Settings"], | |
| ]; | |
| const normalizeFooter = () => { | |
| const walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT); | |
| const textNodes = []; | |
| while (walker.nextNode()) textNodes.push(walker.currentNode); | |
| textNodes.forEach((node) => { | |
| let text = node.nodeValue; | |
| replacements.forEach(([from, to]) => { | |
| text = text.replaceAll(from, to); | |
| }); | |
| node.nodeValue = text; | |
| }); | |
| document.querySelectorAll("a, button, span").forEach((node) => { | |
| const label = node.getAttribute?.("aria-label"); | |
| if (label) { | |
| let next = label; | |
| replacements.forEach(([from, to]) => { | |
| next = next.replaceAll(from, to); | |
| }); | |
| node.setAttribute("aria-label", next); | |
| } | |
| const title = node.getAttribute?.("title"); | |
| if (title) { | |
| let next = title; | |
| replacements.forEach(([from, to]) => { | |
| next = next.replaceAll(from, to); | |
| }); | |
| node.setAttribute("title", next); | |
| } | |
| }); | |
| }; | |
| for (let i = 0; i < 20; i += 1) { | |
| setTimeout(normalizeFooter, i * 250); | |
| } | |
| new MutationObserver(normalizeFooter).observe(document.body, { | |
| childList: true, | |
| subtree: true, | |
| characterData: true, | |
| }); | |
| } | |
| """ | |
| def message_text(content) -> str: | |
| if isinstance(content, str): | |
| return content.strip() | |
| if isinstance(content, list): | |
| parts = [] | |
| for item in content: | |
| if isinstance(item, dict): | |
| text = item.get("text") | |
| if isinstance(text, str): | |
| parts.append(text) | |
| return " ".join(part.strip() for part in parts if part.strip()).strip() | |
| return "" | |
| def build_demo(respond_fn): | |
| """Build the NeuroBait chat. ``respond_fn`` signature (streaming generator ok): | |
| respond_fn(message: str, history: list[dict], mood: str) -> str | generator[str] | |
| """ | |
| with gr.Blocks(fill_height=True) as demo: | |
| gr.HTML(HEADER_HTML) | |
| mood = gr.Radio( | |
| MOODS, | |
| value="Calm", | |
| label="🌳 How are you feeling?", | |
| info="Pick the one that feels closest right now.", | |
| render=False, | |
| ) | |
| chatbot = gr.Chatbot( | |
| height=560, | |
| show_label=False, | |
| placeholder=PLACEHOLDER, | |
| avatar_images=(None, None), | |
| render=False, | |
| ) | |
| gr.ChatInterface( | |
| fn=respond_fn, | |
| chatbot=chatbot, | |
| additional_inputs=[mood], | |
| additional_inputs_accordion="🌳 How are you feeling?", | |
| title=TITLE, | |
| description=DESCRIPTION, | |
| examples=EXAMPLES, | |
| cache_examples=False, | |
| save_history=True, | |
| fill_height=True, | |
| autoscroll=True, | |
| ) | |
| gr.HTML(FOOTER_HTML) | |
| return demo | |