peteryej Claude Opus 4.8 commited on
Commit
d7d04f6
·
1 Parent(s): daa09b4

Fix chat styling, add clickable emotions, and dev ergonomics

Browse files

UI:
- Apply custom CSS/theme via mount_gradio_app (Gradio 6 reads them there,
not on gr.Blocks), so the grey message panels actually disappear
- Hide the chatbot's top-right Clear/trash button
- Turn the emotion legend into clickable chip-buttons: tapping an emotion
makes it chime in directly on the conversation so far
- Move "Start fresh" above the conversation box

Agents:
- Feed readable, name-tagged transcripts to the model instead of raw HTML
- Widen history context window from 8 to 100 turns
- Fall back to canned lines (not the string "None") on empty model replies

Dev:
- Load .env via python-dotenv if present
- DEV=1 enables uvicorn hot reload

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

Files changed (3) hide show
  1. agents.py +40 -7
  2. app.py +172 -41
  3. requirements.txt +1 -0
agents.py CHANGED
@@ -12,6 +12,7 @@ back to a lightweight keyword-based simulation so the UI is always usable.
12
 
13
  from __future__ import annotations
14
 
 
15
  import json
16
  import re
17
  from concurrent.futures import ThreadPoolExecutor
@@ -22,6 +23,8 @@ CHIME_MAX_TOKENS = 120
22
  REFLECT_MAX_TOKENS = 160
23
  TEMPERATURE = 0.7
24
  TOP_P = 0.95
 
 
25
 
26
 
27
  def _get(obj, key: str, default=None):
@@ -51,6 +54,8 @@ def _chat_text(
51
  message = _get(choice, "message")
52
  if message is not None:
53
  content = _get(message, "content", "")
 
 
54
  if isinstance(content, list):
55
  return "".join(str(_get(part, "text", part)) for part in content).strip()
56
  return str(content).strip()
@@ -58,13 +63,40 @@ def _chat_text(
58
  return str(_get(choice, "text", "")).strip()
59
 
60
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
61
  def _history_text(history: list[dict]) -> str:
62
- """Flatten recent conversation into a short transcript for context."""
63
- lines = []
64
- for turn in history[-8:]:
65
- who = "You" if turn["role"] == "user" else turn.get("name", "Emotion")
66
- lines.append(f"{who}: {turn['content']}")
67
- return "\n".join(lines)
68
 
69
 
70
  # --------------------------------------------------------------------------- #
@@ -148,7 +180,7 @@ def emotion_reply(key: str, message: str, history: list[dict], client) -> str:
148
  f"React as {emo.name}."
149
  )
150
  try:
151
- return _chat_text(
152
  client,
153
  [
154
  {"role": "system", "content": system},
@@ -156,6 +188,7 @@ def emotion_reply(key: str, message: str, history: list[dict], client) -> str:
156
  ],
157
  max_tokens=CHIME_MAX_TOKENS,
158
  )
 
159
  except Exception:
160
  return _fallback_line(key, message)
161
 
 
12
 
13
  from __future__ import annotations
14
 
15
+ import html
16
  import json
17
  import re
18
  from concurrent.futures import ThreadPoolExecutor
 
23
  REFLECT_MAX_TOKENS = 160
24
  TEMPERATURE = 0.7
25
  TOP_P = 0.95
26
+ # How many of the most recent turns to feed back to the agents as context.
27
+ HISTORY_TURNS = 100
28
 
29
 
30
  def _get(obj, key: str, default=None):
 
54
  message = _get(choice, "message")
55
  if message is not None:
56
  content = _get(message, "content", "")
57
+ if content is None:
58
+ content = ""
59
  if isinstance(content, list):
60
  return "".join(str(_get(part, "text", part)) for part in content).strip()
61
  return str(content).strip()
 
63
  return str(_get(choice, "text", "")).strip()
64
 
65
 
66
+ _TAG_RE = re.compile(r"<[^>]+>")
67
+
68
+
69
+ def _readable(turn: dict) -> str:
70
+ """Turn one stored chat entry into a clean 'Speaker: text' line.
71
+
72
+ User messages are already plain text. Assistant entries are the HTML
73
+ bubbles produced in app.py, so we strip the markup and recover the
74
+ emotion's name (the bubble's first inner line) as the speaker label.
75
+ """
76
+ role = turn.get("role")
77
+ content = str(turn.get("content", ""))
78
+
79
+ if role == "user":
80
+ text = content.strip()
81
+ return f"You: {text}" if text else ""
82
+
83
+ # Assistant bubble: split on tags, then unescape, to get [label, body...].
84
+ parts = _TAG_RE.sub("\n", content.replace("<br>", " ").replace("<br/>", " "))
85
+ chunks = [html.unescape(p).strip() for p in parts.splitlines()]
86
+ chunks = [c for c in chunks if c]
87
+ if not chunks:
88
+ return ""
89
+ if len(chunks) == 1:
90
+ return f"Emotions: {chunks[0]}"
91
+ label = chunks[0] # e.g. "😨 Fear" or "A gentle reflection"
92
+ body = " ".join(chunks[1:])
93
+ return f"{label}: {body}"
94
+
95
+
96
  def _history_text(history: list[dict]) -> str:
97
+ """Flatten recent conversation into a clean, readable transcript."""
98
+ lines = [_readable(turn) for turn in history[-HISTORY_TURNS:]]
99
+ return "\n".join(line for line in lines if line)
 
 
 
100
 
101
 
102
  # --------------------------------------------------------------------------- #
 
180
  f"React as {emo.name}."
181
  )
182
  try:
183
+ text = _chat_text(
184
  client,
185
  [
186
  {"role": "system", "content": system},
 
188
  ],
189
  max_tokens=CHIME_MAX_TOKENS,
190
  )
191
+ return text or _fallback_line(key, message)
192
  except Exception:
193
  return _fallback_line(key, message)
194
 
app.py CHANGED
@@ -11,6 +11,7 @@ from __future__ import annotations
11
 
12
  import html
13
  import os
 
14
 
15
  from authlib.integrations.starlette_client import OAuth
16
  from fastapi import FastAPI, Request
@@ -20,15 +21,25 @@ from huggingface_hub import InferenceClient
20
  from starlette.middleware.sessions import SessionMiddleware
21
  import uvicorn
22
 
23
- from agents import run_turn
24
  from emotions import EMOTIONS, EMOTION_ORDER
25
 
 
 
 
 
 
 
 
 
 
 
26
  GOOGLE_CLIENT_ID = os.environ.get("GOOGLE_CLIENT_ID")
27
  GOOGLE_CLIENT_SECRET = os.environ.get("GOOGLE_CLIENT_SECRET")
28
  GOOGLE_ALLOWED_DOMAIN = os.environ.get("GOOGLE_ALLOWED_DOMAIN", "").lstrip("@")
29
  GOOGLE_AUTH_ENABLED = bool(GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET)
30
  SESSION_SECRET = os.environ.get("SESSION_SECRET", "inside-out-dev-session-secret")
31
- HF_MODEL = "openai/gpt-oss-20b"
32
  SHOW_LOGIN = os.environ.get("SHOW_LOGIN", "").lower() in {"1", "true", "yes"}
33
 
34
  oauth = OAuth()
@@ -61,46 +72,112 @@ CSS = """
61
  -webkit-background-clip: text; background-clip: text; color: transparent;
62
  }
63
  #subtitle { text-align:center; color:#6b6480; font-size:1.02rem; margin-bottom:10px; }
64
- #legend { display:flex; flex-wrap:wrap; gap:8px; justify-content:center; margin:4px 0 14px; }
65
- .emo-chip {
66
- display:inline-flex; align-items:center; gap:6px;
67
- padding:6px 13px; border-radius:999px; font-size:0.86rem; font-weight:600;
68
- color:#3a3550; background:#ffffffcc; border:1.5px solid var(--c);
69
- box-shadow:0 2px 8px rgba(120,100,180,0.10);
 
 
 
 
 
 
 
 
70
  backdrop-filter: blur(4px);
 
71
  }
72
- .emo-chip .dot { width:10px; height:10px; border-radius:50%; background:var(--c);
73
- box-shadow:0 0 10px var(--c); }
74
- .gr-chatbot, #chat { border:none !important; background:transparent !important; }
75
- #chat .message-row { padding: 2px 0 !important; }
76
- #chat .message,
77
- #chat .message-content,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
78
  #chat .bot,
79
- #chat .user {
 
 
 
 
80
  background: transparent !important;
81
  border: 0 !important;
82
  box-shadow: none !important;
83
- }
84
- #chat .message-content {
85
  padding: 0 !important;
86
  }
87
- #chat button[aria-label*="copy" i] {
88
- display: none !important;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
89
  }
90
  footer { display:none !important; }
91
  #reflect-note { color:#8a83a0; font-size:0.85rem; text-align:center; margin-top:6px; }
92
  """
93
 
94
-
95
- def legend_html() -> str:
96
- chips = []
97
- for k in EMOTION_ORDER:
98
- e = EMOTIONS[k]
99
- chips.append(
100
- f'<div class="emo-chip" style="--c:{e.color}">'
101
- f'<span class="dot"></span>{e.emoji} {e.name}</div>'
102
- )
103
- return '<div id="legend">' + "".join(chips) + "</div>"
104
 
105
 
106
  def _safe_html(text: str) -> str:
@@ -134,6 +211,20 @@ def reflect_bubble(text: str, allow_html: bool = False) -> dict:
134
  return {"role": "assistant", "content": content}
135
 
136
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
137
  def respond(
138
  message: str,
139
  chat_history: list[dict] | None,
@@ -143,10 +234,7 @@ def respond(
143
  if not message:
144
  return "", chat_history
145
 
146
- client = None
147
- token = os.environ.get("HF_TOKEN")
148
- if token:
149
- client = InferenceClient(token=token, model=HF_MODEL)
150
 
151
  chat_history = chat_history + [{"role": "user", "content": message}]
152
 
@@ -157,6 +245,25 @@ def respond(
157
  return "", chat_history
158
 
159
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
160
  def greeting() -> list[dict]:
161
  text = (
162
  "Hi there. This is a safe little space inside your head.<br>"
@@ -192,7 +299,24 @@ def create_demo(show_login: bool = False) -> gr.Blocks:
192
  '<div id="subtitle">Share what\'s on your mind, and let your emotions '
193
  'chime in to help you discover how you really feel.</div>'
194
  )
195
- gr.HTML(legend_html())
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
196
 
197
  chatbot = gr.Chatbot(
198
  value=greeting(),
@@ -204,7 +328,7 @@ def create_demo(show_login: bool = False) -> gr.Blocks:
204
  group_consecutive_messages=False,
205
  )
206
 
207
- with gr.Row():
208
  msg = gr.Textbox(
209
  placeholder="What's on your mind today?",
210
  show_label=False,
@@ -214,17 +338,14 @@ def create_demo(show_login: bool = False) -> gr.Blocks:
214
  )
215
  send = gr.Button("Share", variant="primary", scale=1)
216
 
217
- with gr.Accordion("Settings", open=False):
218
- clear = gr.Button("Start fresh")
219
-
220
  gr.HTML('<div id="reflect-note">Made with love.</div>')
221
 
222
  send.click(respond, [msg, chatbot], [msg, chatbot])
223
  msg.submit(respond, [msg, chatbot], [msg, chatbot])
224
  clear.click(lambda: greeting(), None, chatbot)
 
 
225
 
226
- blocks.theme = THEME
227
- blocks.css = CSS
228
  return blocks
229
 
230
 
@@ -307,10 +428,20 @@ app = gr.mount_gradio_app(
307
  server,
308
  demo,
309
  path="/",
 
 
310
  auth_dependency=_google_user if GOOGLE_AUTH_ENABLED else None,
311
  ssr_mode=False,
312
  )
313
 
314
 
315
  if __name__ == "__main__":
316
- uvicorn.run(app, host="0.0.0.0", port=int(os.environ.get("PORT", "7860")))
 
 
 
 
 
 
 
 
 
11
 
12
  import html
13
  import os
14
+ from functools import partial
15
 
16
  from authlib.integrations.starlette_client import OAuth
17
  from fastapi import FastAPI, Request
 
21
  from starlette.middleware.sessions import SessionMiddleware
22
  import uvicorn
23
 
24
+ from agents import emotion_reply, run_turn
25
  from emotions import EMOTIONS, EMOTION_ORDER
26
 
27
+ # Load variables from a local .env file if python-dotenv is available, so that
28
+ # HF_TOKEN, Google OAuth creds, etc. don't have to be exported by hand. Real
29
+ # shell environment variables still take precedence.
30
+ try:
31
+ from dotenv import load_dotenv
32
+
33
+ load_dotenv()
34
+ except ImportError:
35
+ pass
36
+
37
  GOOGLE_CLIENT_ID = os.environ.get("GOOGLE_CLIENT_ID")
38
  GOOGLE_CLIENT_SECRET = os.environ.get("GOOGLE_CLIENT_SECRET")
39
  GOOGLE_ALLOWED_DOMAIN = os.environ.get("GOOGLE_ALLOWED_DOMAIN", "").lstrip("@")
40
  GOOGLE_AUTH_ENABLED = bool(GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET)
41
  SESSION_SECRET = os.environ.get("SESSION_SECRET", "inside-out-dev-session-secret")
42
+ HF_MODEL = "Qwen/Qwen3.6-27B:ovhcloud" # "google/gemma-4-26B-A4B-it:deepinfra" # "openai/gpt-oss-20b"
43
  SHOW_LOGIN = os.environ.get("SHOW_LOGIN", "").lower() in {"1", "true", "yes"}
44
 
45
  oauth = OAuth()
 
72
  -webkit-background-clip: text; background-clip: text; color: transparent;
73
  }
74
  #subtitle { text-align:center; color:#6b6480; font-size:1.02rem; margin-bottom:10px; }
75
+ #legend-hint { text-align:center; color:#9a93ad; font-size:0.82rem; margin:2px 0 6px; }
76
+ /* Emotion chips are now clickable buttons. */
77
+ #legend {
78
+ display:flex !important; flex-wrap:wrap; gap:8px;
79
+ justify-content:center; margin:4px 0 14px;
80
+ background:transparent !important; border:0 !important;
81
+ }
82
+ #legend .emo-btn {
83
+ flex:0 0 auto !important; width:auto !important; min-width:0 !important;
84
+ padding:6px 14px !important; border-radius:999px !important;
85
+ font-size:0.86rem !important; font-weight:600 !important;
86
+ color:#3a3550 !important; background:#ffffffcc !important;
87
+ border:1.5px solid #d7cdef !important;
88
+ box-shadow:0 2px 8px rgba(120,100,180,0.10) !important;
89
  backdrop-filter: blur(4px);
90
+ transition: transform .12s ease, box-shadow .12s ease;
91
  }
92
+ #legend .emo-btn:hover {
93
+ transform: translateY(-1px);
94
+ box-shadow:0 5px 14px rgba(120,100,180,0.22) !important;
95
+ }
96
+ /* --- Chat surface: no grey boxes, let the bubbles breathe --- */
97
+ .gr-chatbot, #chat {
98
+ border:none !important;
99
+ background:transparent !important;
100
+ box-shadow:none !important;
101
+ }
102
+ /* Neutralise the grey bubble fill at the source: whichever wrapper class
103
+ this gradio version uses, none of them can paint the grey anymore. */
104
+ #chat, #chat * { --background-fill-secondary: transparent !important; }
105
+
106
+ #chat .bubble-wrap,
107
+ #chat .bot-row,
108
+ #chat .user-row,
109
+ #chat .message-wrap,
110
+ #chat .panel,
111
+ #chat .bubble { background: transparent !important; }
112
+ #chat .message-row { padding: 4px 0 !important; }
113
+
114
+ /* Bot / assistant messages: the colored inner cards stand on their own */
115
  #chat .bot,
116
+ #chat .message.bot,
117
+ #chat .bubble,
118
+ #chat .bot .message-content,
119
+ #chat .message,
120
+ #chat .message-content {
121
  background: transparent !important;
122
  border: 0 !important;
123
  box-shadow: none !important;
 
 
124
  padding: 0 !important;
125
  }
126
+
127
+ /* User messages: a soft glowing purple bubble */
128
+ #chat .user,
129
+ #chat .message.user {
130
+ background: linear-gradient(135deg, #a78bfa 0%, #8b5cf6 100%) !important;
131
+ color: #fff !important;
132
+ border: 0 !important;
133
+ border-radius: 16px 16px 4px 16px !important;
134
+ box-shadow: 0 6px 18px rgba(139,92,246,0.30) !important;
135
+ padding: 9px 14px !important;
136
+ max-width: 78%;
137
+ }
138
+ #chat .user *,
139
+ #chat .message.user * { color: #fff !important; background: transparent !important; }
140
+
141
+ #chat button[aria-label*="copy" i],
142
+ #chat button[aria-label*="clear" i] { display: none !important; }
143
+
144
+ /* --- Composer: a floating rounded pill --- */
145
+ #composer {
146
+ gap: 8px !important;
147
+ background: #ffffffcc;
148
+ border: 1.5px solid #e7dcff;
149
+ border-radius: 999px;
150
+ padding: 6px 8px 6px 18px;
151
+ box-shadow: 0 8px 24px rgba(140,110,210,0.12);
152
+ backdrop-filter: blur(6px);
153
+ align-items: center;
154
+ }
155
+ #composer textarea, #composer input[type="text"] {
156
+ background: transparent !important;
157
+ border: 0 !important;
158
+ box-shadow: none !important;
159
+ font-size: 1rem !important;
160
+ resize: none !important;
161
+ }
162
+ #composer button {
163
+ border-radius: 999px !important;
164
+ border: 0 !important;
165
+ background: linear-gradient(135deg, #f59e42, #f0533b) !important;
166
+ color: #fff !important;
167
+ font-weight: 700 !important;
168
+ box-shadow: 0 4px 12px rgba(240,83,59,0.30) !important;
169
  }
170
  footer { display:none !important; }
171
  #reflect-note { color:#8a83a0; font-size:0.85rem; text-align:center; margin-top:6px; }
172
  """
173
 
174
+ # Tint each emotion chip-button with its own color.
175
+ CSS += "".join(
176
+ f"#emo-btn-{k} {{ border-color:{EMOTIONS[k].color} !important; "
177
+ f"color:{EMOTIONS[k].color} !important; }}\n"
178
+ f"#emo-btn-{k}:hover {{ background:{EMOTIONS[k].color}14 !important; }}\n"
179
+ for k in EMOTION_ORDER
180
+ )
 
 
 
181
 
182
 
183
  def _safe_html(text: str) -> str:
 
211
  return {"role": "assistant", "content": content}
212
 
213
 
214
+ def _make_client():
215
+ """Build the HF inference client, or None when no token is configured."""
216
+ token = os.environ.get("HF_TOKEN")
217
+ return InferenceClient(token=token, model=HF_MODEL) if token else None
218
+
219
+
220
+ def _last_user_message(chat_history: list[dict]) -> str:
221
+ """Most recent thing the person actually typed, for context."""
222
+ for turn in reversed(chat_history):
223
+ if turn.get("role") == "user":
224
+ return str(turn.get("content", "")).strip()
225
+ return ""
226
+
227
+
228
  def respond(
229
  message: str,
230
  chat_history: list[dict] | None,
 
234
  if not message:
235
  return "", chat_history
236
 
237
+ client = _make_client()
 
 
 
238
 
239
  chat_history = chat_history + [{"role": "user", "content": message}]
240
 
 
245
  return "", chat_history
246
 
247
 
248
+ def chime(emo_key: str, chat_history: list[dict] | None):
249
+ """The user tapped an emotion chip - let that emotion speak up directly.
250
+
251
+ It reacts to the conversation so far (the most recent thing the person
252
+ said, plus recent history). No orchestrator, no reflection - just this one
253
+ emotion chiming in.
254
+ """
255
+ chat_history = list(chat_history or [])
256
+ client = _make_client()
257
+
258
+ context = _last_user_message(chat_history)
259
+ if not context:
260
+ context = "They haven't said anything yet - gently invite them to share."
261
+
262
+ text = emotion_reply(emo_key, context, chat_history, client)
263
+ chat_history.append(bubble(emo_key, text))
264
+ return chat_history
265
+
266
+
267
  def greeting() -> list[dict]:
268
  text = (
269
  "Hi there. This is a safe little space inside your head.<br>"
 
299
  '<div id="subtitle">Share what\'s on your mind, and let your emotions '
300
  'chime in to help you discover how you really feel.</div>'
301
  )
302
+ gr.HTML(
303
+ '<div id="legend-hint">Tap an emotion to let it speak up</div>'
304
+ )
305
+ emo_buttons: dict[str, gr.Button] = {}
306
+ with gr.Row(elem_id="legend"):
307
+ for k in EMOTION_ORDER:
308
+ e = EMOTIONS[k]
309
+ emo_buttons[k] = gr.Button(
310
+ f"{e.emoji} {e.name}",
311
+ elem_id=f"emo-btn-{k}",
312
+ elem_classes="emo-btn",
313
+ size="sm",
314
+ variant="secondary",
315
+ )
316
+
317
+ clear = gr.Button(
318
+ "Start fresh", size="sm", variant="secondary", elem_id="start-fresh"
319
+ )
320
 
321
  chatbot = gr.Chatbot(
322
  value=greeting(),
 
328
  group_consecutive_messages=False,
329
  )
330
 
331
+ with gr.Row(elem_id="composer"):
332
  msg = gr.Textbox(
333
  placeholder="What's on your mind today?",
334
  show_label=False,
 
338
  )
339
  send = gr.Button("Share", variant="primary", scale=1)
340
 
 
 
 
341
  gr.HTML('<div id="reflect-note">Made with love.</div>')
342
 
343
  send.click(respond, [msg, chatbot], [msg, chatbot])
344
  msg.submit(respond, [msg, chatbot], [msg, chatbot])
345
  clear.click(lambda: greeting(), None, chatbot)
346
+ for k, btn in emo_buttons.items():
347
+ btn.click(partial(chime, k), chatbot, chatbot)
348
 
 
 
349
  return blocks
350
 
351
 
 
428
  server,
429
  demo,
430
  path="/",
431
+ theme=THEME,
432
+ css=CSS,
433
  auth_dependency=_google_user if GOOGLE_AUTH_ENABLED else None,
434
  ssr_mode=False,
435
  )
436
 
437
 
438
  if __name__ == "__main__":
439
+ # Set DEV=1 to hot-reload on file changes (uvicorn watches the source and
440
+ # re-imports the module, so CSS, callbacks, and the FastAPI mount all refresh).
441
+ dev = os.environ.get("DEV", "").lower() in {"1", "true", "yes"}
442
+ uvicorn.run(
443
+ "app:app" if dev else app,
444
+ host="0.0.0.0",
445
+ port=int(os.environ.get("PORT", "7860")),
446
+ reload=dev,
447
+ )
requirements.txt CHANGED
@@ -3,4 +3,5 @@ fastapi>=0.115
3
  gradio>=6.0
4
  huggingface_hub>=0.22.2
5
  itsdangerous>=2.1
 
6
  uvicorn>=0.30
 
3
  gradio>=6.0
4
  huggingface_hub>=0.22.2
5
  itsdangerous>=2.1
6
+ python-dotenv>=1.0
7
  uvicorn>=0.30