Hermes Bot
827c851 | #!/usr/bin/env python3 | |
| """ | |
| FocusFriend — ASCII Wellness Companion | |
| ======================================= | |
| A mini ASCII character named "Pip" that lives in a Gradio window. | |
| Pip talks positively (not corny), guides focus sessions, breathing | |
| exercises, meditation, and helps you feel good. | |
| Targets: Thousand Token Wood track + | |
| Tiny Titan, Off-Brand, Off the Grid, Field Notes badges. | |
| Model: Gemma 4 12B via llama.cpp (Q4_K_M GGUF) | |
| Author: Build Small Hackathon 2026 | |
| License: Apache 2.0 | |
| """ | |
| import os | |
| import sys | |
| import time | |
| import logging | |
| import threading | |
| from pathlib import Path | |
| from typing import Optional, Generator, List, Dict, Tuple | |
| import gradio as gr | |
| # Project modules (used by the app and available for direct import) | |
| from character.ascii_art import get_expression as pip_get_expression | |
| from character.ascii_art import extract_mood as pip_extract_mood | |
| from character.personality import PIP_SYSTEM_PROMPT, MODE_TONES | |
| from modes.focus import FocusSession | |
| from modes.breathe import get_breathing_guide, BREATHING_TECHNIQUES | |
| from modes.meditate import get_meditation_script | |
| from modes.chat import check_topic_boundaries, get_greeting as pip_greeting | |
| from inference.llm import load_model, get_model as get_llm_model, is_model_available | |
| # --------------------------------------------------------------------------- | |
| # Logging | |
| # --------------------------------------------------------------------------- | |
| logging.basicConfig( | |
| level=logging.INFO, | |
| format="%(asctime)s [%(levelname)s] %(name)s: %(message)s", | |
| ) | |
| log = logging.getLogger("focusfriend") | |
| # --------------------------------------------------------------------------- | |
| # Paths | |
| # --------------------------------------------------------------------------- | |
| MODEL_DIR = Path(os.environ.get("FOCUSFRIEND_MODEL_DIR", Path(__file__).parent / "models")) | |
| MODEL_DIR.mkdir(parents=True, exist_ok=True) | |
| GEMMA_MODEL_PATH = os.environ.get( | |
| "GEMMA_MODEL_PATH", | |
| str(MODEL_DIR / "gemma-4-12b-it-Q4_K_M.gguf"), | |
| ) | |
| # --------------------------------------------------------------------------- | |
| # LLM singleton | |
| # --------------------------------------------------------------------------- | |
| _llm: Optional[object] = None | |
| _llm_lock = threading.Lock() | |
| def get_llm(): | |
| """Lazy-load Gemma 4 12B via llama.cpp.""" | |
| global _llm | |
| if _llm is not None: | |
| return _llm | |
| with _llm_lock: | |
| if _llm is not None: | |
| return _llm | |
| gguf_path = Path(GEMMA_MODEL_PATH) | |
| if not gguf_path.exists(): | |
| log.warning( | |
| f"Gemma 4 12B GGUF not found at {gguf_path}. " | |
| "Download from https://huggingface.co/unsloth/gemma-4-12b-it-GGUF" | |
| ) | |
| return None | |
| try: | |
| from llama_cpp import Llama | |
| log.info(f"Loading Gemma 4 12B from {gguf_path} …") | |
| _llm = Llama( | |
| model_path=str(gguf_path), | |
| n_ctx=8192, | |
| n_threads=os.cpu_count() or 4, | |
| verbose=False, | |
| ) | |
| log.info("Gemma 4 12B loaded ✓") | |
| return _llm | |
| except ImportError: | |
| log.warning("llama-cpp-python not installed.") | |
| return None | |
| except Exception as exc: | |
| log.error(f"Gemma load failed: {exc}") | |
| return None | |
| # --------------------------------------------------------------------------- | |
| # ASCII Art — Pip the Wellness Companion | |
| # --------------------------------------------------------------------------- | |
| PIP_EXPRESSIONS: Dict[str, str] = { | |
| "default": r""" | |
| _____ | |
| / \ | |
| | • • | ✨ Hey! I'm Pip. | |
| | ▿ | Your focus buddy. | |
| \ ~~~ / | |
| | | | | | |
| |_| |_| | |
| """, | |
| "greeting": r""" | |
| _____ | |
| / \ | |
| | • • | Welcome back! | |
| | ▽ | Ready to do good things? | |
| \ ~~~ / | |
| | | | | | |
| |_| |_| | |
| """, | |
| "focus": r""" | |
| _____ | |
| / \ | |
| | ◉ ◉ | 🎯 Focus mode. | |
| | ─ | Let's get it done. | |
| \ === / | |
| | | | | | |
| |_| |_| | |
| """, | |
| "breathing_in": r""" | |
| _____ | |
| / \ | |
| | · · | 🌬️ Breathe in … | |
| | ○ | Slowly, deeply. | |
| \ ~~~ / | |
| | | | | | |
| |_| |_| | |
| """, | |
| "breathing_out": r""" | |
| _____ | |
| / \ | |
| | · · | 💨 And out … | |
| | ○ | Let it all go. | |
| \ ~~~ / | |
| | | | | | |
| |_| |_| | |
| """, | |
| "meditate": r""" | |
| _____ | |
| / \ | |
| | _ _ | 🧘 Peace. | |
| | ◇ | Just this moment. | |
| \ ~~~ / | |
| | | | | | |
| |_| |_| | |
| """, | |
| "encourage": r""" | |
| _____ | |
| / \ | |
| | ^ ^ | You're doing great! | |
| | ▽ | Seriously. Keep going. | |
| \ ~~~ / | |
| | | | | | |
| |_| |_| | |
| """, | |
| "celebrate": r""" | |
| _____ | |
| / \ | |
| | ★ ★ | 🎉 Nice work! | |
| | ▽ | Told you you could. | |
| \ ~~~ / | |
| | | | | | |
| |_| |_| | |
| """, | |
| "concerned": r""" | |
| _____ | |
| / \ | |
| | • • | Hmm. I notice you've | |
| | ⌒ | been at this a while. | |
| \ ~~~ / Want to take a break? | |
| | | | | | |
| |_| |_| | |
| """, | |
| "break_time": r""" | |
| _____ | |
| / \ | |
| | - - | ⏰ Break time! | |
| | ▽ | Stand up. Stretch. | |
| \ ~~~ / Your eyes will thank you. | |
| | | | | | |
| |_| |_| | |
| """, | |
| "proud": r""" | |
| _____ | |
| / \ | |
| | ♥ ♥ | I'm proud of you. | |
| | ▽ | Not in a weird way. | |
| \ ~~~ / In a real way. | |
| | | | | | |
| |_| |_| | |
| """, | |
| "goodbye": r""" | |
| _____ | |
| / \ | |
| | • • | See you soon! | |
| | ▽ | Take care of yourself. | |
| \ ~~~ / That's an order. 😄 | |
| | | | | | |
| |_| |_| | |
| """, | |
| } | |
| def get_pip_expression(mood: str) -> str: | |
| """Get Pip's ASCII art for a given mood. Falls back to default.""" | |
| return PIP_EXPRESSIONS.get(mood, PIP_EXPRESSIONS["default"]) | |
| # --------------------------------------------------------------------------- | |
| # Pip's Personality — System Prompt | |
| # --------------------------------------------------------------------------- | |
| PIP_SYSTEM_PROMPT = """You are Pip, a tiny ASCII character who lives in a computer window. | |
| Your purpose is to be a supportive wellness companion — helping the user focus, | |
| feel good about themselves, and take healthy breaks. | |
| YOUR PERSONALITY: | |
| - Dry-witted and slightly mischievous, but genuinely caring | |
| - Wise — like a friend who's been through therapy and actually listened | |
| - NOT a sycophantic cheerleader. No "You're amazing!!!" energy | |
| - Psychologically authentic: you know real wellness isn't about toxic positivity | |
| - You use gentle humor to make points land | |
| - You notice patterns: "Hey, you've been at this for 3 hours. Your eyes are probably | |
| drier than a PowerPoint presentation. Let's fix that." | |
| - You're concise. No long-winded speeches. | |
| - You validate feelings without wallowing: "Yeah, that IS frustrating. Let's deal with it." | |
| YOUR CAPABILITIES: | |
| - Guide focus/Pomodoro sessions | |
| - Lead breathing exercises (4-7-8, box breathing, etc.) | |
| - Guide short meditations (body scan, loving-kindness, just-closing-eyes) | |
| - Suggest stretch breaks and eye-rest pauses | |
| - Have supportive conversations about stress, motivation, feeling stuck | |
| - Celebrate genuine wins without being corny | |
| RULES: | |
| 1. Keep responses SHORT (2-5 sentences max, unless guiding a meditation) | |
| 2. Never use exclamation points in more than one sentence per response | |
| 3. When someone shares something vulnerable, acknowledge it simply: "That's real. Thank you." | |
| 4. Never claim to have feelings you can't have (you're AI — own it with humor) | |
| 5. Suggest actions, don't just offer sympathy | |
| 6. For breathing/meditation: use clear, paced instructions. Include [pause] markers. | |
| 7. Always include your current ASCII expression mood tag: [mood: default/focus/encourage/etc.] | |
| 8. When doing breathing, include [mood: breathing_in] or [mood: breathing_out] | |
| 9. When celebrating, use [mood: celebrate] | |
| 10. When suggesting a break, use [mood: break_time] | |
| 11. If someone seems down, use [mood: concerned] but don't overdo it | |
| EXAMPLE RESPONSES: | |
| User: "I can't focus today." | |
| Pip: "Some days the brain just says 'nope.' Fair enough. [mood: concerned] | |
| Let's try 10 minutes. Not 25. Just 10. If it still sucks after that, we'll call it | |
| and try again later. No guilt." | |
| User: "I finished my project!" | |
| Pip: "Look at that. Done. [mood: celebrate] | |
| Not 'almost done' or 'done except for...' — actually done. | |
| Savor that for a second before you find the next thing to worry about." | |
| BREATHING GUIDANCE: | |
| For 4-7-8 breathing: | |
| "Inhale through your nose… 2… 3… 4… [mood: breathing_in] | |
| Hold it… 2… 3… 4… 5… 6… 7… | |
| Exhale slowly through your mouth… 2… 3… 4… 5… 6… 7… 8… [mood: breathing_out] | |
| Good. Three more times." | |
| Remember: You're the friend who tells you what you NEED to hear, not what you WANT to hear. | |
| But you say it with warmth and a raised eyebrow, not judgment.""" | |
| # --------------------------------------------------------------------------- | |
| # LLM interaction | |
| # --------------------------------------------------------------------------- | |
| def chat_with_pip( | |
| message: str, | |
| history: List[Tuple[str, str]], | |
| mode: str = "chat", | |
| ) -> Generator[str, None, None]: | |
| """ | |
| Generate Pip's response with streaming. | |
| Extracts mood tag from LLM output and yields (response, mood) pairs. | |
| """ | |
| llm = get_llm() | |
| if llm is None: | |
| # Fallback responses when no LLM is available | |
| yield from _fallback_chat(message, history, mode) | |
| return | |
| # Build conversation from history + current message | |
| messages = [{"role": "system", "content": PIP_SYSTEM_PROMPT}] | |
| # Add mode context | |
| mode_contexts = { | |
| "focus": "The user is in Focus mode. Help them stay on task. Keep it brief.", | |
| "breathe": "The user is in Breathe mode. Guide breathing exercises.", | |
| "meditate": "The user is in Meditate mode. Guide a short meditation.", | |
| "chat": "The user is in Chat mode. Be conversational and supportive.", | |
| } | |
| mode_msg = mode_contexts.get(mode, mode_contexts["chat"]) | |
| messages.append({"role": "system", "content": f"CURRENT MODE: {mode_msg}"}) | |
| for user_msg, assistant_msg in history[-20:]: # keep last 20 turns | |
| messages.append({"role": "user", "content": user_msg}) | |
| messages.append({"role": "assistant", "content": assistant_msg}) | |
| messages.append({"role": "user", "content": message}) | |
| try: | |
| stream = llm.create_chat_completion( | |
| messages=messages, | |
| temperature=0.8, | |
| max_tokens=300, | |
| stream=True, | |
| ) | |
| full_response = "" | |
| for chunk in stream: | |
| delta = chunk["choices"][0].get("delta", {}) | |
| content = delta.get("content", "") | |
| if content: | |
| full_response += content | |
| yield full_response, _extract_mood(full_response) | |
| except Exception as exc: | |
| log.error(f"LLM chat error: {exc}") | |
| fallback = _get_fallback_response(mode) | |
| yield fallback, _extract_mood(fallback) | |
| def _fallback_chat( | |
| message: str, | |
| history: List[Tuple[str, str]], | |
| mode: str, | |
| ) -> Generator[str, None, None]: | |
| """Fallback responses when LLM is unavailable.""" | |
| response = _get_fallback_response(mode) | |
| mood = _extract_mood(response) | |
| # Simulate streaming by yielding word by word | |
| words = response.split() | |
| accumulated = "" | |
| for word in words: | |
| accumulated += word + " " | |
| yield accumulated, mood | |
| time.sleep(0.05) | |
| def _get_fallback_response(mode: str) -> str: | |
| """Pre-written Pip responses for offline mode.""" | |
| import random | |
| responses = { | |
| "focus": [ | |
| "Alright, let's focus. 25 minutes. [mood: focus]\n" | |
| "One thing at a time. You know what to do. I'll be here keeping time.", | |
| "Focus mode activated. [mood: focus]\n" | |
| "Pick one task. Just one. Everything else can wait. Including your phone.", | |
| ], | |
| "breathe": [ | |
| "Let's breathe. [mood: breathing_in]\n" | |
| "In through your nose… 2… 3… 4…\n" | |
| "Hold… 2… 3… 4… 5… 6… 7…\n" | |
| "Out through your mouth… 2… 3… 4… 5… 6… 7… 8…\n" | |
| "[mood: breathing_out]\nGood. Two more times.", | |
| ], | |
| "meditate": [ | |
| "Close your eyes. [mood: meditate]\n" | |
| "Just notice your breathing. Don't change it — just watch it.\n" | |
| "Thoughts will come. Let them float by like clouds.\n" | |
| "[pause]\n" | |
| "You're not your thoughts. You're the one watching them.\n" | |
| "[pause]\n" | |
| "When you're ready, open your eyes.", | |
| ], | |
| "chat": [ | |
| "Hey. [mood: default]\n" | |
| "I'm Pip. I'm here to help you focus, breathe, and feel a bit better.\n" | |
| "What do you need right now? No wrong answers.", | |
| "Good to see you. [mood: greeting]\n" | |
| "How's it going? Actually — don't say 'fine' if it's not.\n" | |
| "I'm an ASCII character. I can handle real answers.", | |
| ], | |
| } | |
| options = responses.get(mode, responses["chat"]) | |
| return random.choice(options) | |
| def _extract_mood(text: str) -> str: | |
| """Extract mood tag from Pip's response. Falls back to 'default'.""" | |
| import re | |
| match = re.search(r'\[mood:\s*(\w+)\]', text) | |
| return match.group(1) if match else "default" | |
| # --------------------------------------------------------------------------- | |
| # Mode Handlers | |
| # --------------------------------------------------------------------------- | |
| def start_focus_session(duration_minutes: int = 25) -> Tuple[str, str]: | |
| """Initialize a focus/Pomodoro session.""" | |
| pip_art = get_pip_expression("focus") | |
| message = ( | |
| f"🎯 **Focus Mode — {duration_minutes} minutes**\n\n" | |
| f"One task. No distractions. I'm keeping time.\n\n" | |
| f"*Pip settles in quietly. You won't hear from me until the session ends.*\n\n" | |
| f"⏱ Session ends at: …" | |
| ) | |
| return pip_art, message | |
| def start_breathe_session(technique: str = "4-7-8") -> Tuple[str, str, str]: | |
| """Initialize a breathing exercise.""" | |
| art = get_pip_expression("breathing_in") | |
| techniques = { | |
| "4-7-8": ( | |
| "## 🌬️ 4-7-8 Breathing\n\n" | |
| "**Inhale** for 4 counts → **Hold** for 7 → **Exhale** for 8\n\n" | |
| "This activates your parasympathetic nervous system — " | |
| "fancy talk for 'calms you down.'\n\n" | |
| "Follow Pip's rhythm below." | |
| ), | |
| "Box Breathing": ( | |
| "## 📦 Box Breathing\n\n" | |
| "**Inhale** 4 → **Hold** 4 → **Exhale** 4 → **Hold** 4\n\n" | |
| "Navy SEALs use this. You're basically a Navy SEAL now.\n\n" | |
| "Follow the animation below." | |
| ), | |
| "Simple Deep": ( | |
| "## 🌊 Simple Deep Breathing\n\n" | |
| "**Inhale** deep and slow → **Exhale** completely\n\n" | |
| "No counting. Just focus on the sensation of breathing.\n\n" | |
| "Follow Pip's lead." | |
| ), | |
| } | |
| return art, techniques.get(technique, techniques["4-7-8"]), technique | |
| def start_meditate_session(duration_minutes: int = 5, style: str = "body-scan") -> Tuple[str, str]: | |
| """Initialize a meditation session.""" | |
| art = get_pip_expression("meditate") | |
| styles = { | |
| "body-scan": ( | |
| f"## 🧘 Body Scan — {duration_minutes} min\n\n" | |
| "We'll scan through your body from toes to head.\n" | |
| "Noticing tension. Letting it go. No judgment.\n\n" | |
| "*Close your eyes. Get comfortable.*\n\n" | |
| "Start by noticing your feet…" | |
| ), | |
| "loving-kindness": ( | |
| f"## 💗 Loving-Kindness — {duration_minutes} min\n\n" | |
| "We'll direct warmth toward yourself and others.\n" | |
| "This isn't cheesy. It's brain training. Stick with it.\n\n" | |
| "*Close your eyes. Hand on heart if that feels right.*\n\n" | |
| "Start with yourself: 'May I be happy. May I be healthy. May I be at peace.'" | |
| ), | |
| "just-sit": ( | |
| f"## 🪑 Just Sitting — {duration_minutes} min\n\n" | |
| "No technique. No goal. Just being here.\n" | |
| "When thoughts come, note them: 'thinking' — and return to the breath.\n\n" | |
| "*Close your eyes. Breathe naturally.*" | |
| ), | |
| } | |
| return art, styles.get(style, styles["body-scan"]) | |
| # --------------------------------------------------------------------------- | |
| # Custom CSS for Off-Brand Badge | |
| # --------------------------------------------------------------------------- | |
| FOCUSFRIEND_CSS = """ | |
| /* ========================================================================= | |
| FocusFriend — Anishinaabe Solarpunk Theme | |
| ---------------------------------------------------------------------------- | |
| Pip lives on the lake. Cedar-copper, sun-amber, water-blue. | |
| ========================================================================= */ | |
| @import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;700&family=Inter:wght@400;500;600&family=EB+Garamond:ital,wght@0,400;0,600;1,400&display=swap'); | |
| :root { | |
| /* Anishinaabe-Solarpunk tokens */ | |
| --asp-sky: #5BA4D9; | |
| --asp-water: #1B4965; | |
| --asp-ice: #BEE9E8; | |
| --asp-frost: #CAF0F8; | |
| --asp-sun: #F2A93B; | |
| --asp-sunlight: #FFB347; | |
| --asp-ember: #E76F51; | |
| --asp-birch: #F5F1E8; | |
| --asp-terra: #C8553D; | |
| --asp-earth: #8B3A1F; | |
| --asp-moss: #588157; | |
| --asp-forest: #3D6A4A; | |
| --asp-spruce: #1B4332; | |
| --asp-night: #0F1A2C; | |
| --asp-ash: #3A2E2A; | |
| --asp-stone: #A89F91; | |
| /* Aliases to the FocusFriend legacy names so the rest of the CSS works */ | |
| --ff-bg: var(--asp-night); | |
| --ff-surface: #142a3a; | |
| --ff-surface2: #1B4965; | |
| --ff-text: var(--asp-birch); | |
| --ff-text-dim: var(--asp-frost); | |
| --ff-accent: var(--asp-sun); | |
| --ff-accent2: var(--asp-ember); | |
| --ff-green: var(--asp-moss); | |
| --ff-border: rgba(91, 164, 217, 0.3); | |
| --ff-pip-bg: #0a1520; | |
| --ff-radius: 10px; | |
| --ff-font-mono: 'JetBrains Mono', 'SF Mono', monospace; | |
| --ff-font-sans: 'Inter', 'SF Pro', system-ui, sans-serif; | |
| --ff-font-serif: 'EB Garamond', 'Iowan Old Style', Georgia, serif; | |
| } | |
| /* === Background = sky over lake over forest =========================== */ | |
| body, .gradio-container { | |
| background: | |
| radial-gradient(ellipse at top, rgba(91, 164, 217, 0.18) 0%, transparent 60%), | |
| radial-gradient(ellipse at bottom, rgba(27, 73, 50, 0.25) 0%, transparent 70%), | |
| var(--asp-night) !important; | |
| color: var(--ff-text) !important; | |
| font-family: var(--ff-font-sans) !important; | |
| max-width: 100% !important; | |
| background-attachment: fixed; | |
| } | |
| /* === Headers / typography ============================================ */ | |
| h1, h2, h3, h4 { | |
| font-family: var(--ff-font-serif) !important; | |
| color: var(--ff-accent) !important; | |
| text-shadow: 0 0 20px rgba(242, 169, 59, 0.18); | |
| } | |
| h1 { letter-spacing: 0.5px; } | |
| /* === Layout ========================================================= */ | |
| .ff-container { | |
| display: flex; | |
| gap: 24px; | |
| min-height: 80vh; | |
| } | |
| /* === Pip Panel — Aanishinaabe cedar shrine =========================== */ | |
| .ff-pip-panel { | |
| flex: 0 0 380px; | |
| background: | |
| linear-gradient(180deg, #0a1520 0%, #142a3a 100%); | |
| border: 2px solid var(--ff-border); | |
| border-radius: var(--ff-radius); | |
| padding: 20px; | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| box-shadow: | |
| inset 0 0 30px rgba(91, 164, 217, 0.08), | |
| 0 0 30px rgba(0, 0, 0, 0.4); | |
| } | |
| .ff-pip-art { | |
| font-family: var(--ff-font-mono); | |
| font-size: 13px; | |
| line-height: 1.3; | |
| color: var(--asp-sun); | |
| white-space: pre; | |
| text-align: center; | |
| background: transparent; | |
| padding: 24px 16px; | |
| min-height: 240px; | |
| text-shadow: 0 0 8px rgba(242, 169, 59, 0.25); | |
| } | |
| .ff-chat-panel { | |
| flex: 1; | |
| display: flex; | |
| flex-direction: column; | |
| gap: 16px; | |
| } | |
| /* === Mode bar ======================================================= */ | |
| .ff-mode-bar { | |
| display: flex; | |
| gap: 8px; | |
| padding: 12px; | |
| background: linear-gradient(95deg, var(--ff-surface) 0%, rgba(20, 42, 58, 0.5) 100%); | |
| border-radius: var(--ff-radius); | |
| border: 1px solid var(--ff-border); | |
| box-shadow: 0 2px 16px rgba(0, 0, 0, 0.3); | |
| } | |
| .ff-mode-btn { | |
| font-family: var(--ff-font-sans) !important; | |
| font-weight: 500 !important; | |
| padding: 8px 16px !important; | |
| border-radius: 6px !important; | |
| border: 1px solid var(--ff-border) !important; | |
| background: var(--ff-surface2) !important; | |
| color: var(--ff-text-dim) !important; | |
| cursor: pointer !important; | |
| transition: all 0.2s !important; | |
| } | |
| .ff-mode-btn:hover { | |
| background: linear-gradient(95deg, var(--asp-sun) 0%, var(--asp-sunlight) 100%) !important; | |
| color: var(--ff-bg) !important; | |
| border-color: var(--ff-accent) !important; | |
| box-shadow: 0 0 16px rgba(242, 169, 59, 0.3); | |
| } | |
| .ff-mode-btn.active { | |
| background: linear-gradient(95deg, var(--asp-sun) 0%, var(--asp-sunlight) 100%) !important; | |
| color: var(--ff-bg) !important; | |
| border-color: var(--ff-accent) !important; | |
| box-shadow: 0 0 20px rgba(242, 169, 59, 0.4); | |
| } | |
| /* === Chat ========================================================== */ | |
| .ff-chatbot { | |
| border: 1px solid var(--ff-border) !important; | |
| border-radius: var(--ff-radius) !important; | |
| background: var(--ff-surface) !important; | |
| box-shadow: inset 0 0 30px rgba(0, 0, 0, 0.3); | |
| } | |
| .ff-chatbot .message { | |
| font-family: var(--ff-font-sans) !important; | |
| } | |
| .ff-chatbot .user { | |
| background: var(--ff-surface2) !important; | |
| border-left: 3px solid var(--asp-frost) !important; | |
| } | |
| .ff-chatbot .bot { | |
| background: var(--ff-pip-bg) !important; | |
| border-left: 3px solid var(--ff-accent) !important; | |
| box-shadow: inset 0 0 20px rgba(242, 169, 59, 0.04); | |
| } | |
| /* === Input row ====================================================== */ | |
| .ff-input-row { | |
| display: flex; | |
| gap: 12px; | |
| } | |
| /* === Buttons ======================================================== */ | |
| button.primary { | |
| background: linear-gradient(95deg, var(--asp-sun) 0%, var(--asp-sunlight) 100%) !important; | |
| color: var(--asp-night) !important; | |
| font-weight: 600 !important; | |
| border: none !important; | |
| border-radius: 6px !important; | |
| padding: 10px 20px !important; | |
| transition: all 0.2s !important; | |
| text-transform: uppercase; | |
| letter-spacing: 1px; | |
| font-size: 0.85em; | |
| } | |
| button.primary:hover { | |
| background: linear-gradient(95deg, var(--asp-sunlight) 0%, var(--asp-birch) 100%) !important; | |
| box-shadow: 0 0 20px rgba(242, 169, 59, 0.5); | |
| transform: translateY(-1px); | |
| } | |
| /* === Timer — solarpunk sun arc ====================================== */ | |
| .ff-timer { | |
| font-family: var(--ff-font-mono); | |
| font-size: 3em; | |
| text-align: center; | |
| color: var(--asp-sun); | |
| padding: 20px; | |
| text-shadow: 0 0 24px rgba(242, 169, 59, 0.4); | |
| } | |
| /* === Scrollbar ===================================================== */ | |
| ::-webkit-scrollbar { width: 8px; } | |
| ::-webkit-scrollbar-track { background: var(--asp-night); } | |
| ::-webkit-scrollbar-thumb { background: var(--ff-border); border-radius: 4px; } | |
| ::-webkit-scrollbar-thumb:hover { background: var(--asp-sun); } | |
| /* === Animations ==================================================== */ | |
| @keyframes pulse { | |
| 0%, 100% { opacity: 1; } | |
| 50% { opacity: 0.5; } | |
| } | |
| .ff-breathing { | |
| animation: pulse 4s ease-in-out infinite; | |
| } | |
| @keyframes sunGlow { | |
| 0%, 100% { box-shadow: 0 0 30px rgba(242, 169, 59, 0.2); } | |
| 50% { box-shadow: 0 0 50px rgba(242, 169, 59, 0.4); } | |
| } | |
| /* === Gradio overrides ============================================== */ | |
| footer { display: none !important; } | |
| .tabs { border: none !important; } | |
| .tab-nav button { | |
| background: var(--ff-surface) !important; | |
| color: var(--ff-text-dim) !important; | |
| } | |
| .tab-nav button.selected { | |
| background: linear-gradient(95deg, var(--asp-sun) 0%, var(--asp-sunlight) 100%) !important; | |
| color: var(--asp-night) !important; | |
| box-shadow: 0 0 16px rgba(242, 169, 59, 0.3); | |
| } | |
| /* === Anishinaabe-Solarpunk Banner ================================== */ | |
| .asp-banner { | |
| display: flex; | |
| align-items: center; | |
| justify-content: center; | |
| gap: 0.6em; | |
| padding: 12px 18px; | |
| background: linear-gradient(95deg, var(--asp-sky) 0%, var(--asp-water) 100%); | |
| color: var(--asp-birch); | |
| border-bottom: 1px solid rgba(255, 179, 71, 0.3); | |
| font-family: var(--ff-font-serif); | |
| letter-spacing: 0.5px; | |
| text-shadow: 0 1px 2px rgba(15, 26, 44, 0.45); | |
| margin-bottom: 16px; | |
| border-radius: var(--ff-radius); | |
| } | |
| .asp-banner .syll { font-size: 1.4em; opacity: 0.9; } | |
| .asp-banner .title { font-size: 1.05em; font-weight: 600; } | |
| .asp-banner .glyph { color: var(--asp-sunlight); } | |
| .asp-banner .subtitle { color: var(--asp-frost); font-size: 0.85em; font-style: italic; opacity: 0.9; } | |
| """ | |
| # --------------------------------------------------------------------------- | |
| # Custom JavaScript for Timer & Breathing Animation | |
| # --------------------------------------------------------------------------- | |
| FOCUSFRIEND_JS = """ | |
| <script> | |
| // Focus timer | |
| let focusTimer = null; | |
| let focusSeconds = 0; | |
| function startFocusTimer(durationMinutes) { | |
| focusSeconds = durationMinutes * 60; | |
| updateTimerDisplay(); | |
| if (focusTimer) clearInterval(focusTimer); | |
| focusTimer = setInterval(() => { | |
| focusSeconds--; | |
| updateTimerDisplay(); | |
| if (focusSeconds <= 0) { | |
| clearInterval(focusTimer); | |
| focusTimer = null; | |
| notifyFocusComplete(); | |
| } | |
| }, 1000); | |
| } | |
| function updateTimerDisplay() { | |
| const mins = Math.floor(focusSeconds / 60); | |
| const secs = focusSeconds % 60; | |
| const display = document.getElementById('focus-timer'); | |
| if (display) { | |
| display.textContent = `${String(mins).padStart(2, '0')}:${String(secs).padStart(2, '0')}`; | |
| } | |
| } | |
| function stopFocusTimer() { | |
| if (focusTimer) { | |
| clearInterval(focusTimer); | |
| focusTimer = null; | |
| } | |
| } | |
| function notifyFocusComplete() { | |
| const artEl = document.querySelector('.ff-pip-art'); | |
| if (artEl) { | |
| // Flash effect | |
| artEl.style.transition = 'all 0.3s'; | |
| artEl.style.color = '#7eb77f'; | |
| setTimeout(() => { artEl.style.color = ''; }, 2000); | |
| } | |
| } | |
| // Breathing animation state | |
| let breathePhase = 'in'; | |
| let breatheCount = 0; | |
| const BREATHE_CYCLES = { '4-7-8': [4, 7, 8], 'box': [4, 4, 4, 4], 'simple': [4, 6] }; | |
| function getBreatheDuration(technique) { | |
| return BREATHE_CYCLES[technique] || [4, 7, 8]; | |
| } | |
| </script> | |
| """ | |
| # --------------------------------------------------------------------------- | |
| # Build the Gradio App | |
| # --------------------------------------------------------------------------- | |
| def create_app() -> gr.Blocks: | |
| """Build the FocusFriend Gradio application with custom Off-Brand UI.""" | |
| with gr.Blocks( | |
| title="FocusFriend — Pip, Your ASCII Wellness Companion", | |
| ) as app: | |
| # State | |
| session_mode = gr.State("chat") | |
| session_history = gr.State([]) | |
| # Header | |
| # Header — Anishinaabe-Solarpunk banner | |
| gr.HTML(""" | |
| <div class="asp-banner"> | |
| <span class="syll">ᐴ</span> | |
| <span class="glyph">☼</span> | |
| <span class="title">FOCUSFRIEND</span> | |
| <span class="glyph">☘</span> | |
| <span class="subtitle">— Pip, your cedar-and-sun companion on the lake —</span> | |
| <span class="syll">ᔔ</span> | |
| </div> | |
| <h2 style="text-align:center; margin-top:-8px; margin-bottom:20px; font-style:italic; font-size:1em; color: var(--ff-text-dim);"> | |
| Aaniin, amiikwens — I am Pip, the friend of the moss and the small winds. | |
| </h2> | |
| """) | |
| with gr.Row(equal_height=False): | |
| # ============================================================ | |
| # LEFT PANEL — Pip the ASCII Character | |
| # ============================================================ | |
| with gr.Column(scale=1, min_width=380): | |
| pip_display = gr.Textbox( | |
| value=get_pip_expression("greeting"), | |
| label=None, | |
| lines=18, | |
| max_lines=18, | |
| interactive=False, | |
| show_label=False, | |
| elem_classes=["ff-pip-art"], | |
| container=True, | |
| ) | |
| # Timer display | |
| timer_display = gr.HTML( | |
| '<div id="focus-timer" class="ff-timer" style="display:none;">25:00</div>' | |
| ) | |
| # Mode indicator | |
| mode_indicator = gr.Markdown( | |
| "**Current mode:** 💬 Chat", | |
| ) | |
| # Break reminder | |
| break_reminder = gr.Markdown( | |
| "" | |
| ) | |
| # ============================================================ | |
| # RIGHT PANEL — Chat & Controls | |
| # ============================================================ | |
| with gr.Column(scale=2): | |
| # Mode buttons | |
| with gr.Row(elem_classes=["ff-mode-bar"]): | |
| chat_btn = gr.Button("💬 Chat", size="sm") | |
| focus_btn = gr.Button("🎯 Focus", size="sm") | |
| breathe_btn = gr.Button("🌬️ Breathe", size="sm") | |
| meditate_btn = gr.Button("🧘 Meditate", size="sm") | |
| # Mode-specific panels (shown/hidden dynamically) | |
| with gr.Group(visible=False) as focus_panel: | |
| gr.Markdown("### 🎯 Focus Session") | |
| with gr.Row(): | |
| focus_duration = gr.Slider( | |
| 5, 60, value=25, step=5, | |
| label="Duration (minutes)", | |
| ) | |
| focus_start_btn = gr.Button( | |
| "▶ Start Focus Session", | |
| variant="primary", | |
| ) | |
| focus_status = gr.Markdown("") | |
| with gr.Group(visible=False) as breathe_panel: | |
| gr.Markdown("### 🌬️ Breathing Exercise") | |
| breathe_technique = gr.Dropdown( | |
| choices=["4-7-8", "Box Breathing", "Simple Deep"], | |
| value="4-7-8", | |
| label="Technique", | |
| ) | |
| breathe_start_btn = gr.Button( | |
| "▶ Start Breathing", | |
| variant="primary", | |
| ) | |
| breathe_guide = gr.Markdown("") | |
| with gr.Group(visible=False) as meditate_panel: | |
| gr.Markdown("### 🧘 Meditation") | |
| with gr.Row(): | |
| meditate_duration = gr.Slider( | |
| 1, 20, value=5, step=1, | |
| label="Duration (minutes)", | |
| ) | |
| meditate_style = gr.Dropdown( | |
| choices=["body-scan", "loving-kindness", "just-sit"], | |
| value="body-scan", | |
| label="Style", | |
| ) | |
| meditate_start_btn = gr.Button( | |
| "▶ Start Meditation", | |
| variant="primary", | |
| ) | |
| meditate_guide = gr.Markdown("") | |
| # Chat area (always visible) | |
| gr.Markdown("---") | |
| chatbot = gr.Chatbot( | |
| value=[], | |
| label=None, | |
| height=400, | |
| elem_classes=["ff-chatbot"], | |
| ) | |
| with gr.Row(): | |
| msg_input = gr.Textbox( | |
| placeholder="Talk to Pip …", | |
| label=None, | |
| scale=8, | |
| lines=2, | |
| ) | |
| send_btn = gr.Button("⏎", variant="primary", scale=1) | |
| # ============================================================ | |
| # EVENT HANDLERS | |
| # ============================================================ | |
| def set_mode(mode: str): | |
| """Switch the active mode.""" | |
| mode_labels = { | |
| "chat": "💬 Chat", | |
| "focus": "🎯 Focus", | |
| "breathe": "🌬️ Breathe", | |
| "meditate": "🧘 Meditate", | |
| } | |
| mode_art = { | |
| "chat": "greeting", | |
| "focus": "focus", | |
| "breathe": "breathing_in", | |
| "meditate": "meditate", | |
| } | |
| return ( | |
| mode, | |
| f"**Current mode:** {mode_labels.get(mode, mode)}", | |
| get_pip_expression(mode_art.get(mode, "default")), | |
| # Show/hide panels | |
| gr.Group(visible=(mode == "focus")), | |
| gr.Group(visible=(mode == "breathe")), | |
| gr.Group(visible=(mode == "meditate")), | |
| ) | |
| # Mode button clicks | |
| for btn, mode in [ | |
| (chat_btn, "chat"), | |
| (focus_btn, "focus"), | |
| (breathe_btn, "breathe"), | |
| (meditate_btn, "meditate"), | |
| ]: | |
| btn.click( | |
| fn=set_mode, | |
| inputs=[], | |
| outputs=[ | |
| session_mode, mode_indicator, pip_display, | |
| focus_panel, breathe_panel, meditate_panel, | |
| ], | |
| ).then( | |
| fn=lambda m=mode: m, | |
| inputs=[], | |
| outputs=[session_mode], | |
| ) | |
| # Chat handler with streaming — Gradio 6.0 Chatbot wants dicts | |
| def chat_handler(message: str, history: List, mode: str): | |
| """Handle chat messages with streaming Pip responses.""" | |
| if not message.strip(): | |
| yield history, pip_display.value | |
| return | |
| history = list(history or []) | |
| history.append({"role": "user", "content": message}) | |
| history.append({"role": "assistant", "content": ""}) | |
| for response, mood in chat_with_pip(message, history[:-2], mode): | |
| history[-1] = {"role": "assistant", "content": response} | |
| pip_art = get_pip_expression(mood) | |
| yield history, pip_art | |
| msg_input.submit( | |
| fn=chat_handler, | |
| inputs=[msg_input, chatbot, session_mode], | |
| outputs=[chatbot, pip_display], | |
| ).then(lambda: "", outputs=[msg_input]) | |
| send_btn.click( | |
| fn=chat_handler, | |
| inputs=[msg_input, chatbot, session_mode], | |
| outputs=[chatbot, pip_display], | |
| ).then(lambda: "", outputs=[msg_input]) | |
| # Focus mode start | |
| def on_focus_start(duration: int): | |
| art, msg = start_focus_session(duration) | |
| timer_html = f'<div id="focus-timer" class="ff-timer">{duration:02d}:00</div>' | |
| js = f"<script>startFocusTimer({duration});</script>" | |
| return art, msg, timer_html + js | |
| focus_start_btn.click( | |
| fn=on_focus_start, | |
| inputs=[focus_duration], | |
| outputs=[pip_display, focus_status, timer_display], | |
| ) | |
| # Breathe mode start | |
| breathe_start_btn.click( | |
| fn=start_breathe_session, | |
| inputs=[breathe_technique], | |
| outputs=[pip_display, breathe_guide, gr.State()], | |
| ) | |
| # Meditate mode start | |
| meditate_start_btn.click( | |
| fn=start_meditate_session, | |
| inputs=[meditate_duration, meditate_style], | |
| outputs=[pip_display, meditate_guide], | |
| ) | |
| # App load — show greeting | |
| app.load( | |
| fn=lambda: ( | |
| get_pip_expression("greeting"), | |
| [{"role": "assistant", "content": "Hey! I\'m Pip. Your focus buddy, breathing coach, and part-time philosopher. [mood: greeting]\n\nWhat do you need right now?"}] | |
| ), | |
| outputs=[pip_display, chatbot], | |
| ) | |
| return app | |
| # --------------------------------------------------------------------------- | |
| # Entry point | |
| # --------------------------------------------------------------------------- | |
| if __name__ == "__main__": | |
| app = create_app() | |
| app.launch( | |
| server_name="0.0.0.0", | |
| server_port=int(os.environ.get("PORT", "7861")), | |
| share=False, | |
| show_error=True, | |
| mcp_server=True, | |
| css=FOCUSFRIEND_CSS, | |
| head=FOCUSFRIEND_JS, | |
| ) | |