"""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
# Load variables from a local .env file if python-dotenv is available, so that
# HF_TOKEN, Google OAuth creds, etc. don't have to be exported by hand. Real
# shell environment variables still take precedence.
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" # "openai/gpt-oss-20b", "Qwen/Qwen3.6-27B:ovhcloud"
SHOW_LOGIN = os.environ.get("SHOW_LOGIN", "").lower() in {"1", "true", "yes"}
# When True, talk to a local llama.cpp server (OpenAI-compatible API) instead of
# the hosted Hugging Face Inference API. Defaults to False; override with the
# env var LOCAL_SERVING=true. Start the server with, e.g.:
# llama-server -hf ggml-org/gemma-4-26b-a4b-it-GGUF:Q4_K_M \
# --jinja --reasoning-budget 0 -ngl 99 -c 8192 --port 8080
# NOTE: --reasoning-budget 0 is required for Gemma 4 - without it the model
# stays in "thinking" mode and returns empty content (replies fall back to
# canned demo lines). If port 8080 is taken, use another port and set
# LOCAL_LLM_BASE_URL to match (e.g. http://localhost:8088/v1).
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") # "gemma-4-26b-a4b-it"
# Whether the served model is a reasoning/"thinking" model. When False (default)
# and we serve locally, llama.cpp is started with --reasoning-budget 0 so the
# model answers directly. When True, the model is allowed to think and the agent
# layer strips the chain-of-thought from replies. Override with USE_REASONING=true.
USE_REASONING = os.environ.get("USE_REASONING", "false").lower() in {"1", "true", "yes"}
# Where to look for local GGUF weights, and the llama.cpp server binary, used
# when LOCAL_SERVING auto-serves the model. LOCAL_MODEL_PATH pins an exact GGUF.
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")
)
# Startup diagnostic (boolean only - never logs the secret value) so you can
# confirm from the Space logs whether the HF_TOKEN secret reached the app.
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; }
"""
# Tint each emotion chip-button with its own color.
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", "
")
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'
Set GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET, then restart the app.
" '' ) 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("{html.escape(email)} is not in the allowed domain.
", 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__": # Set DEV=1 to hot-reload on file changes (uvicorn watches the source and # re-imports the module, so CSS, callbacks, and the FastAPI mount all refresh). 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, )