| """Inside Out - a chat where your emotions chime in. |
| |
| A warm little app, inspired by Pixar's Inside Out, where several emotion agents |
| react to whatever is on your mind. The goal isn't to give advice - it's to help |
| you notice and name what you're actually feeling. |
| |
| Run: python app.py |
| """ |
|
|
| from __future__ import annotations |
|
|
| import atexit |
| import html |
| import os |
| import re |
| import subprocess |
| import time |
| import urllib.request |
| from functools import partial |
| from urllib.parse import urlparse |
|
|
| from authlib.integrations.starlette_client import OAuth |
| from fastapi import FastAPI, Request |
| from fastapi.responses import HTMLResponse, RedirectResponse |
| import gradio as gr |
| from huggingface_hub import InferenceClient |
| from starlette.middleware.sessions import SessionMiddleware |
| import uvicorn |
|
|
| from agents import emotion_reply, run_turn |
| from emotions import EMOTIONS, EMOTION_ORDER |
|
|
| |
| |
| |
| try: |
| from dotenv import load_dotenv |
|
|
| load_dotenv() |
| except ImportError: |
| pass |
|
|
| GOOGLE_CLIENT_ID = os.environ.get("GOOGLE_CLIENT_ID") |
| GOOGLE_CLIENT_SECRET = os.environ.get("GOOGLE_CLIENT_SECRET") |
| GOOGLE_ALLOWED_DOMAIN = os.environ.get("GOOGLE_ALLOWED_DOMAIN", "").lstrip("@") |
| GOOGLE_AUTH_ENABLED = bool(GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET) |
| SESSION_SECRET = os.environ.get("SESSION_SECRET", "inside-out-dev-session-secret") |
| HF_MODEL = "google/gemma-4-26B-A4B-it:deepinfra" |
| SHOW_LOGIN = os.environ.get("SHOW_LOGIN", "").lower() in {"1", "true", "yes"} |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| LOCAL_SERVING = os.environ.get("LOCAL_SERVING", "false").lower() in {"1", "true", "yes"} |
| LOCAL_LLM_BASE_URL = os.environ.get("LOCAL_LLM_BASE_URL", "http://localhost:8088/v1") |
| LOCAL_LLM_MODEL = os.environ.get("LOCAL_LLM_MODEL", "nemotron-3-nano-omni-30b-a3b-reasoning") |
|
|
| |
| |
| |
| |
| USE_REASONING = os.environ.get("USE_REASONING", "false").lower() in {"1", "true", "yes"} |
|
|
| |
| |
| LOCAL_MODELS_DIR = os.environ.get("LOCAL_MODELS_DIR", os.path.expanduser("~/models")) |
| LOCAL_MODEL_PATH = os.environ.get("LOCAL_MODEL_PATH", "") |
| LLAMA_SERVER_BIN = os.environ.get( |
| "LLAMA_SERVER_BIN", os.path.expanduser("~/tools/llama.cpp/build/bin/llama-server") |
| ) |
|
|
| |
| |
| print( |
| f"[inside-out] startup: serving=" |
| f"{'local-llama.cpp' if LOCAL_SERVING else 'hf-inference'} " |
| f"| reasoning={'on' if USE_REASONING else 'off'} " |
| f"| HF_TOKEN present: {bool(os.environ.get('HF_TOKEN'))} " |
| f"| model: {LOCAL_LLM_MODEL if LOCAL_SERVING else HF_MODEL}", |
| flush=True, |
| ) |
|
|
| oauth = OAuth() |
| if GOOGLE_AUTH_ENABLED: |
| oauth.register( |
| name="google", |
| client_id=GOOGLE_CLIENT_ID, |
| client_secret=GOOGLE_CLIENT_SECRET, |
| server_metadata_url="https://accounts.google.com/.well-known/openid-configuration", |
| client_kwargs={"scope": "openid email profile"}, |
| ) |
|
|
| THEME = gr.themes.Soft( |
| primary_hue="purple", |
| secondary_hue="yellow", |
| neutral_hue="slate", |
| font=[gr.themes.GoogleFont("Quicksand"), "ui-rounded", "system-ui", "sans-serif"], |
| ) |
|
|
| CSS = """ |
| .gradio-container { |
| background: radial-gradient(1200px 600px at 20% -10%, #fef3c7 0%, transparent 55%), |
| radial-gradient(1000px 700px at 90% 0%, #e9d5ff 0%, transparent 50%), |
| linear-gradient(160deg, #fdfbff 0%, #f3f0ff 100%) !important; |
| } |
| #title-wrap { text-align: center; margin: 6px 0 2px; } |
| #title-wrap h1 { |
| font-size: 2.5rem; font-weight: 700; margin-bottom: 2px; |
| background: linear-gradient(90deg,#f59e42,#f0533b,#9b6dd6,#5b8def,#ffd93b); |
| -webkit-background-clip: text; background-clip: text; color: transparent; |
| } |
| #subtitle { text-align:center; color:#6b6480; font-size:1.02rem; margin-bottom:10px; } |
| #legend-hint { text-align:center; color:#9a93ad; font-size:0.82rem; margin:2px 0 6px; } |
| /* Emotion chips are now clickable buttons. */ |
| #legend { |
| display:flex !important; flex-wrap:wrap; gap:8px; |
| justify-content:center; margin:4px 0 14px; |
| background:transparent !important; border:0 !important; |
| } |
| #legend .emo-btn { |
| flex:0 0 auto !important; width:auto !important; min-width:0 !important; |
| padding:6px 14px !important; border-radius:999px !important; |
| font-size:0.86rem !important; font-weight:600 !important; |
| color:#3a3550 !important; background:#ffffffcc !important; |
| border:1.5px solid #d7cdef !important; |
| box-shadow:0 2px 8px rgba(120,100,180,0.10) !important; |
| backdrop-filter: blur(4px); |
| transition: transform .12s ease, box-shadow .12s ease; |
| } |
| #legend .emo-btn:hover { |
| transform: translateY(-1px); |
| box-shadow:0 5px 14px rgba(120,100,180,0.22) !important; |
| } |
| /* --- Chat surface: no grey boxes, let the bubbles breathe --- */ |
| .gr-chatbot, #chat { |
| border:none !important; |
| background:transparent !important; |
| box-shadow:none !important; |
| } |
| /* Neutralise the grey bubble fill at the source: whichever wrapper class |
| this gradio version uses, none of them can paint the grey anymore. */ |
| #chat, #chat * { --background-fill-secondary: transparent !important; } |
| |
| #chat .bubble-wrap, |
| #chat .bot-row, |
| #chat .user-row, |
| #chat .message-wrap, |
| #chat .panel, |
| #chat .bubble { background: transparent !important; } |
| #chat .message-row { padding: 4px 0 !important; } |
| |
| /* Bot / assistant messages: the colored inner cards stand on their own */ |
| #chat .bot, |
| #chat .message.bot, |
| #chat .bubble, |
| #chat .bot .message-content, |
| #chat .message, |
| #chat .message-content { |
| background: transparent !important; |
| border: 0 !important; |
| box-shadow: none !important; |
| padding: 0 !important; |
| } |
| |
| /* User messages: a soft glowing purple bubble */ |
| #chat .user, |
| #chat .message.user { |
| background: linear-gradient(135deg, #a78bfa 0%, #8b5cf6 100%) !important; |
| color: #fff !important; |
| border: 0 !important; |
| border-radius: 16px 16px 4px 16px !important; |
| box-shadow: 0 6px 18px rgba(139,92,246,0.30) !important; |
| padding: 9px 14px !important; |
| max-width: 78%; |
| } |
| #chat .user *, |
| #chat .message.user * { color: #fff !important; background: transparent !important; } |
| |
| #chat button[aria-label*="copy" i], |
| #chat button[aria-label*="clear" i] { display: none !important; } |
| |
| /* --- Composer: a floating rounded pill --- */ |
| #composer { |
| gap: 8px !important; |
| background: #ffffffcc; |
| border: 1.5px solid #e7dcff; |
| border-radius: 999px; |
| padding: 6px 8px 6px 18px; |
| box-shadow: 0 8px 24px rgba(140,110,210,0.12); |
| backdrop-filter: blur(6px); |
| align-items: center; |
| } |
| #composer textarea, #composer input[type="text"] { |
| background: transparent !important; |
| border: 0 !important; |
| box-shadow: none !important; |
| font-size: 1rem !important; |
| resize: none !important; |
| } |
| #composer button { |
| border-radius: 999px !important; |
| border: 0 !important; |
| background: linear-gradient(135deg, #f59e42, #f0533b) !important; |
| color: #fff !important; |
| font-weight: 700 !important; |
| box-shadow: 0 4px 12px rgba(240,83,59,0.30) !important; |
| } |
| footer { display:none !important; } |
| #reflect-note { color:#8a83a0; font-size:0.85rem; text-align:center; margin-top:6px; } |
| """ |
|
|
| |
| CSS += "".join( |
| f"#emo-btn-{k} {{ border-color:{EMOTIONS[k].color} !important; " |
| f"color:{EMOTIONS[k].color} !important; }}\n" |
| f"#emo-btn-{k}:hover {{ background:{EMOTIONS[k].color}14 !important; }}\n" |
| for k in EMOTION_ORDER |
| ) |
|
|
|
|
| def _safe_html(text: str) -> str: |
| return html.escape(text).replace("\n", "<br>") |
|
|
|
|
| def bubble(emo_key: str, text: str) -> dict: |
| """Render one emotion's line as a styled assistant message.""" |
| e = EMOTIONS[emo_key] |
| safe_text = _safe_html(text) |
| content = ( |
| f'<div style="border-left:5px solid {e.color}; background:{e.color}1A; ' |
| f'padding:9px 13px; border-radius:12px; margin:2px 0;">' |
| f'<div style="font-weight:700; color:{e.color}; font-size:0.9rem; ' |
| f'margin-bottom:2px;">{e.emoji} {e.name}</div>' |
| f'<div style="color:#2f2b40; line-height:1.45;">{safe_text}</div></div>' |
| ) |
| return {"role": "assistant", "content": content} |
|
|
|
|
| def reflect_bubble(text: str, allow_html: bool = False) -> dict: |
| rendered_text = text if allow_html else _safe_html(text) |
| content = ( |
| '<div style="background:linear-gradient(135deg,#ffffff,#f6f1ff); ' |
| 'border:1px dashed #c7b8f0; padding:11px 15px; border-radius:14px; ' |
| 'margin:6px 0 2px; box-shadow:0 3px 14px rgba(150,120,220,0.12);">' |
| '<div style="font-weight:700; color:#8b5cf6; font-size:0.86rem; ' |
| 'margin-bottom:3px;">A gentle reflection</div>' |
| f'<div style="color:#3a3550; line-height:1.5;">{rendered_text}</div></div>' |
| ) |
| return {"role": "assistant", "content": content} |
|
|
|
|
| def find_local_gguf(model_id: str) -> str | None: |
| """Find a local .gguf for a model, or None if not available locally. |
| |
| LOCAL_MODEL_PATH wins if set. Otherwise we walk LOCAL_MODELS_DIR and pick the |
| file whose name shares the most tokens with the model id (so e.g. |
| 'nvidia/NVIDIA-Nemotron-3-Nano-30B-A3B-BF16' matches a local |
| 'NVIDIA-Nemotron-3-Nano-Omni-30B-A3B-Reasoning-*.gguf'). |
| """ |
| if LOCAL_MODEL_PATH: |
| return LOCAL_MODEL_PATH if os.path.isfile(LOCAL_MODEL_PATH) else None |
| if not os.path.isdir(LOCAL_MODELS_DIR): |
| return None |
| name = model_id.split(":", 1)[0].rsplit("/", 1)[-1].lower() |
| tokens = [t for t in re.split(r"[^a-z0-9]+", name) if t] |
| best, best_score = None, 0 |
| for root, _dirs, files in os.walk(LOCAL_MODELS_DIR): |
| for fname in files: |
| low = fname.lower() |
| if not low.endswith(".gguf") or "mmproj" in low: |
| continue |
| score = sum(1 for t in tokens if t in low) |
| if score > best_score: |
| best, best_score = os.path.join(root, fname), score |
| |
| return best if best_score >= max(2, len(tokens) // 2) else None |
|
|
|
|
| _local_server_proc = None |
|
|
|
|
| def _server_healthy() -> bool: |
| health_url = LOCAL_LLM_BASE_URL.rsplit("/v1", 1)[0].rstrip("/") + "/health" |
| try: |
| with urllib.request.urlopen(health_url, timeout=2) as resp: |
| return resp.status == 200 |
| except Exception: |
| return False |
|
|
|
|
| def ensure_local_server() -> None: |
| """When LOCAL_SERVING, make sure a llama.cpp server is up for the model. |
| |
| No-op if one is already responding (so uvicorn hot-reload won't double-spawn). |
| Launches llama-server with the locally-discovered GGUF, disabling the |
| reasoning budget unless USE_REASONING is set. |
| """ |
| global _local_server_proc |
| if not LOCAL_SERVING or _server_healthy(): |
| return |
| gguf = find_local_gguf(HF_MODEL) |
| if not gguf: |
| print( |
| f"[inside-out] LOCAL_SERVING on but no local GGUF for {HF_MODEL!r} " |
| f"in {LOCAL_MODELS_DIR} (set LOCAL_MODEL_PATH). Falling back to demo.", |
| flush=True, |
| ) |
| return |
| if not os.path.isfile(LLAMA_SERVER_BIN): |
| print(f"[inside-out] llama-server not found at {LLAMA_SERVER_BIN}", flush=True) |
| return |
| port = str(urlparse(LOCAL_LLM_BASE_URL).port or 8088) |
| cmd = [ |
| LLAMA_SERVER_BIN, "-m", gguf, "--jinja", |
| "-ngl", "99", "-c", "8192", "--host", "127.0.0.1", "--port", port, |
| ] |
| if not USE_REASONING: |
| cmd += ["--reasoning-budget", "0"] |
| print( |
| f"[inside-out] starting llama.cpp (reasoning={'on' if USE_REASONING else 'off'}): " |
| f"{os.path.basename(gguf)} on :{port}", |
| flush=True, |
| ) |
| env = dict(os.environ, LD_LIBRARY_PATH=os.path.dirname(LLAMA_SERVER_BIN) |
| + os.pathsep + os.environ.get("LD_LIBRARY_PATH", "")) |
| _local_server_proc = subprocess.Popen(cmd, env=env) |
| atexit.register(lambda: _local_server_proc and _local_server_proc.terminate()) |
| for _ in range(240): |
| if _server_healthy(): |
| print("[inside-out] llama.cpp server is ready", flush=True) |
| return |
| if _local_server_proc.poll() is not None: |
| print("[inside-out] llama.cpp server exited during startup", flush=True) |
| return |
| time.sleep(1) |
| print("[inside-out] timed out waiting for llama.cpp server", flush=True) |
|
|
|
|
| def _make_client(): |
| """Build the inference client. |
| |
| - LOCAL_SERVING: ensure a local llama.cpp server is running and point at it |
| (OpenAI-compatible), no HF token needed. |
| - otherwise: the hosted HF Inference API, or None when no token is set |
| (which makes the app fall back to offline demo replies). |
| """ |
| if LOCAL_SERVING: |
| ensure_local_server() |
| return InferenceClient( |
| base_url=LOCAL_LLM_BASE_URL, |
| api_key=os.environ.get("LOCAL_LLM_API_KEY", "sk-no-key-needed"), |
| ) |
| token = os.environ.get("HF_TOKEN") |
| return InferenceClient(token=token, model=HF_MODEL) if token else None |
|
|
|
|
| def _last_user_message(chat_history: list[dict]) -> str: |
| """Most recent thing the person actually typed, for context.""" |
| for turn in reversed(chat_history): |
| if turn.get("role") == "user": |
| return str(turn.get("content", "")).strip() |
| return "" |
|
|
|
|
| def respond( |
| message: str, |
| chat_history: list[dict] | None, |
| ): |
| message = (message or "").strip() |
| chat_history = list(chat_history or []) |
| if not message: |
| return "", chat_history |
|
|
| client = _make_client() |
|
|
| chat_history = chat_history + [{"role": "user", "content": message}] |
|
|
| replies, reflection = run_turn(message, chat_history[:-1], client=client) |
| for key, text in replies: |
| chat_history.append(bubble(key, text)) |
| chat_history.append(reflect_bubble(reflection)) |
| return "", chat_history |
|
|
|
|
| def chime(emo_key: str, chat_history: list[dict] | None): |
| """The user tapped an emotion chip - let that emotion speak up directly. |
| |
| It reacts to the conversation so far (the most recent thing the person |
| said, plus recent history). No orchestrator, no reflection - just this one |
| emotion chiming in. |
| """ |
| chat_history = list(chat_history or []) |
| client = _make_client() |
|
|
| context = _last_user_message(chat_history) |
| if not context: |
| context = "They haven't said anything yet - gently invite them to share." |
|
|
| text = emotion_reply(emo_key, context, chat_history, client) |
| chat_history.append(bubble(emo_key, text)) |
| return chat_history |
|
|
|
|
| def greeting() -> list[dict]: |
| text = ( |
| "Hi there. This is a safe little space inside your head.<br>" |
| "Tell me what's on your mind - a thought, a moment, anything - and your " |
| "emotions will each chime in. There are no wrong feelings here." |
| ) |
| return [reflect_bubble(text, allow_html=True)] |
|
|
|
|
| def create_demo(show_login: bool = False) -> gr.Blocks: |
| with gr.Blocks(title="Inside Out - Chat with your emotions") as blocks: |
| if show_login: |
| with gr.Sidebar(): |
| if GOOGLE_AUTH_ENABLED: |
| gr.HTML( |
| '<a href="/logout" style="display:inline-block; padding:8px 12px; ' |
| 'border-radius:999px; border:1px solid #d8c7ff; ' |
| 'background:#ffffffcc; color:#6d28d9; font-weight:700; ' |
| 'text-decoration:none;">Sign out</a>' |
| ) |
| else: |
| gr.Markdown( |
| "Google login is off locally. Set `GOOGLE_CLIENT_ID` and " |
| "`GOOGLE_CLIENT_SECRET` to require Google sign-in." |
| ) |
| gr.Markdown( |
| f"Set `HF_TOKEN` to let `{HF_MODEL}` generate emotion responses. " |
| "Without it, the app uses demo replies." |
| ) |
|
|
| gr.HTML( |
| '<div id="title-wrap"><h1>Inside Out</h1></div>' |
| '<div id="subtitle">Share what\'s on your mind, and let your emotions ' |
| 'chime in to help you discover how you really feel.</div>' |
| ) |
| gr.HTML( |
| '<div id="legend-hint">Tap an emotion to let it speak up</div>' |
| ) |
| emo_buttons: dict[str, gr.Button] = {} |
| with gr.Row(elem_id="legend"): |
| for k in EMOTION_ORDER: |
| e = EMOTIONS[k] |
| emo_buttons[k] = gr.Button( |
| f"{e.emoji} {e.name}", |
| elem_id=f"emo-btn-{k}", |
| elem_classes="emo-btn", |
| size="sm", |
| variant="secondary", |
| ) |
|
|
| clear = gr.Button( |
| "Start fresh", size="sm", variant="secondary", elem_id="start-fresh" |
| ) |
|
|
| chatbot = gr.Chatbot( |
| value=greeting(), |
| elem_id="chat", |
| height=460, |
| show_label=False, |
| sanitize_html=False, |
| buttons=[], |
| group_consecutive_messages=False, |
| ) |
|
|
| with gr.Row(elem_id="composer"): |
| msg = gr.Textbox( |
| placeholder="What's on your mind today?", |
| show_label=False, |
| scale=8, |
| autofocus=True, |
| container=False, |
| ) |
| send = gr.Button("Share", variant="primary", scale=1) |
|
|
| gr.HTML('<div id="reflect-note">Made with love.</div>') |
|
|
| send.click(respond, [msg, chatbot], [msg, chatbot]) |
| msg.submit(respond, [msg, chatbot], [msg, chatbot]) |
| clear.click(lambda: greeting(), None, chatbot) |
| for k, btn in emo_buttons.items(): |
| btn.click(partial(chime, k), chatbot, chatbot) |
|
|
| return blocks |
|
|
|
|
| demo = create_demo(show_login=SHOW_LOGIN) |
|
|
|
|
| def _google_user(request: Request) -> str | None: |
| user = request.session.get("google_user") |
| if not isinstance(user, dict): |
| return None |
| email = user.get("email") |
| return str(email) if email else None |
|
|
|
|
| server = FastAPI() |
|
|
|
|
| @server.middleware("http") |
| async def redirect_unauthenticated_root(request: Request, call_next): |
| if GOOGLE_AUTH_ENABLED and request.method == "GET" and request.url.path == "/": |
| user = request.session.get("google_user") |
| if not user: |
| return RedirectResponse(url="/login") |
| return await call_next(request) |
|
|
|
|
| server.add_middleware( |
| SessionMiddleware, |
| secret_key=SESSION_SECRET, |
| same_site="lax", |
| https_only=os.environ.get("COOKIE_SECURE", "").lower() in {"1", "true", "yes"}, |
| ) |
|
|
|
|
| @server.get("/login") |
| async def google_login(request: Request): |
| if not GOOGLE_AUTH_ENABLED: |
| return HTMLResponse( |
| "<h1>Google login is not configured</h1>" |
| "<p>Set GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET, then restart the app.</p>" |
| '<p><a href="/">Continue to local demo</a></p>' |
| ) |
| redirect_uri = request.url_for("google_auth_callback") |
| return await oauth.google.authorize_redirect(request, redirect_uri) |
|
|
|
|
| @server.get("/auth/callback") |
| async def google_auth_callback(request: Request): |
| token = await oauth.google.authorize_access_token(request) |
| userinfo = token.get("userinfo") |
| if userinfo is None: |
| userinfo = await oauth.google.userinfo(token=token) |
|
|
| email = userinfo.get("email") |
| if not email: |
| return HTMLResponse("<h1>Google login did not return an email address.</h1>", status_code=400) |
|
|
| domain = email.rsplit("@", 1)[-1] |
| if GOOGLE_ALLOWED_DOMAIN and domain != GOOGLE_ALLOWED_DOMAIN: |
| return HTMLResponse( |
| f"<h1>Access denied</h1><p>{html.escape(email)} is not in the allowed domain.</p>", |
| status_code=403, |
| ) |
|
|
| request.session["google_user"] = { |
| "email": email, |
| "name": userinfo.get("name") or email, |
| "picture": userinfo.get("picture"), |
| } |
| return RedirectResponse(url="/") |
|
|
|
|
| @server.get("/logout") |
| async def google_logout(request: Request): |
| request.session.clear() |
| return RedirectResponse(url="/login" if GOOGLE_AUTH_ENABLED else "/") |
|
|
|
|
| app = gr.mount_gradio_app( |
| server, |
| demo, |
| path="/", |
| theme=THEME, |
| css=CSS, |
| auth_dependency=_google_user if GOOGLE_AUTH_ENABLED else None, |
| ssr_mode=False, |
| ) |
|
|
|
|
| if __name__ == "__main__": |
| |
| |
| dev = os.environ.get("DEV", "").lower() in {"1", "true", "yes"} |
| uvicorn.run( |
| "app:app" if dev else app, |
| host="0.0.0.0", |
| port=int(os.environ.get("PORT", "7860")), |
| reload=dev, |
| ) |