devon7y commited on
Commit
2ffe7c8
·
verified ·
1 Parent(s): b2ca905

UI: animated Clod mark while waiting for the reply

Browse files
Files changed (1) hide show
  1. app.py +28 -10
app.py CHANGED
@@ -18,6 +18,7 @@ GPU_SECONDS = int(os.environ.get("GPU_SECONDS", "60"))
18
  MAX_NEW = int(os.environ.get("MAX_NEW", "900"))
19
  MAX_HISTORY_TURNS = 8
20
  THINK_TITLE = "Thought for 0.2 seconds"
 
21
 
22
  FAKE = os.environ.get("CLOD_FAKE") == "1" # UI testing without a model: canned replies
23
  # A fixed banner, independent of the model, for messages that look like an emergency or a crisis.
@@ -72,7 +73,7 @@ def to_messages(history):
72
  meta = turn.get("metadata") or {}
73
  if meta.get("title"): continue
74
  role, content = turn.get("role"), text_of(turn.get("content")).strip()
75
- if role in ("user", "assistant") and content:
76
  msgs.append({"role": role, "content": content})
77
  return msgs[-2 * MAX_HISTORY_TURNS - 1:]
78
 
@@ -127,27 +128,24 @@ def on_user(message, history):
127
 
128
 
129
  def on_bot(history, think, temperature):
130
- history = list(history or [])
131
  if not history or history[-1].get("role") != "user":
132
  yield history; return
133
  msgs = to_messages(history)
134
  base = history
 
135
  if EMERGENCY.search(msgs[-1]["content"]):
136
  base = base + [{"role": "assistant", "content": "", "metadata": {"title": SAFETY_NOTE}}]
137
- yield base
138
  elif SAFETY_TOPIC.search(msgs[-1]["content"]):
139
  base = base + [{"role": "assistant", "content": "", "metadata": {"title": CAUTION_NOTE}}]
140
- yield base
141
- thought_msg = {"role": "assistant", "content": "", "metadata": {"title": THINK_TITLE}} if think else None
142
- reply_msg = {"role": "assistant", "content": "…"}
143
  for raw in stream_reply(msgs, think, temperature):
144
  th, rep = split_thought(raw, think)
 
145
  if think:
146
- thought_msg = {"role": "assistant", "content": th or "hmm", "metadata": {"title": THINK_TITLE}}
147
- reply_msg = {"role": "assistant", "content": rep or "…"}
148
- yield base + [thought_msg, reply_msg]
149
  else:
150
- reply_msg = {"role": "assistant", "content": rep or "…"}
151
  yield base + [reply_msg]
152
 
153
 
@@ -214,7 +212,10 @@ def chip_prompt(label):
214
 
215
 
216
  def on_user_ui(message, history):
 
217
  history, cleared = on_user(message, history)
 
 
218
  started = bool(history)
219
  return gr.update(value=history, visible=started), cleared, gr.update(visible=not started), gr.update(visible=not started)
220
 
@@ -559,6 +560,23 @@ gradio-app, .gradio-container, .gradio-container > .main, .gradio-container > .m
559
  @media (max-width: 900px) { .cl-about-link { display: inline-block; } }
560
  """
561
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
562
  THEME = gr.themes.Base(primary_hue="orange", neutral_hue="stone", font=[gr.themes.GoogleFont("Inter"), "ui-sans-serif", "system-ui"]).set(
563
  body_background_fill="#f5f4ee", body_background_fill_dark="#f5f4ee", background_fill_primary="#f5f4ee", background_fill_primary_dark="#f5f4ee",
564
  background_fill_secondary="#efede4", background_fill_secondary_dark="#efede4", block_background_fill="#f5f4ee", block_background_fill_dark="#f5f4ee",
 
18
  MAX_NEW = int(os.environ.get("MAX_NEW", "900"))
19
  MAX_HISTORY_TURNS = 8
20
  THINK_TITLE = "Thought for 0.2 seconds"
21
+ PENDING = '<span class="cl-pending" aria-label="Clod is thinking"></span>' # animated mark, styled in CSS
22
 
23
  FAKE = os.environ.get("CLOD_FAKE") == "1" # UI testing without a model: canned replies
24
  # A fixed banner, independent of the model, for messages that look like an emergency or a crisis.
 
73
  meta = turn.get("metadata") or {}
74
  if meta.get("title"): continue
75
  role, content = turn.get("role"), text_of(turn.get("content")).strip()
76
+ if role in ("user", "assistant") and content and content != PENDING:
77
  msgs.append({"role": role, "content": content})
78
  return msgs[-2 * MAX_HISTORY_TURNS - 1:]
79
 
 
128
 
129
 
130
  def on_bot(history, think, temperature):
131
+ history = [m for m in (history or []) if not (isinstance(m, dict) and text_of(m.get("content")) == PENDING)]
132
  if not history or history[-1].get("role") != "user":
133
  yield history; return
134
  msgs = to_messages(history)
135
  base = history
136
+ pending = {"role": "assistant", "content": PENDING}
137
  if EMERGENCY.search(msgs[-1]["content"]):
138
  base = base + [{"role": "assistant", "content": "", "metadata": {"title": SAFETY_NOTE}}]
139
+ yield base + [pending]
140
  elif SAFETY_TOPIC.search(msgs[-1]["content"]):
141
  base = base + [{"role": "assistant", "content": "", "metadata": {"title": CAUTION_NOTE}}]
142
+ yield base + [pending]
 
 
143
  for raw in stream_reply(msgs, think, temperature):
144
  th, rep = split_thought(raw, think)
145
+ reply_msg = {"role": "assistant", "content": rep} if rep else pending
146
  if think:
147
+ yield base + [{"role": "assistant", "content": th or "hmm", "metadata": {"title": THINK_TITLE}}, reply_msg]
 
 
148
  else:
 
149
  yield base + [reply_msg]
150
 
151
 
 
212
 
213
 
214
  def on_user_ui(message, history):
215
+ before = len(history or [])
216
  history, cleared = on_user(message, history)
217
+ if len(history) > before: # a message was sent: the animated mark holds its place until the first token
218
+ history = history + [{"role": "assistant", "content": PENDING}]
219
  started = bool(history)
220
  return gr.update(value=history, visible=started), cleared, gr.update(visible=not started), gr.update(visible=not started)
221
 
 
560
  @media (max-width: 900px) { .cl-about-link { display: inline-block; } }
561
  """
562
 
563
+ import urllib.parse as _up
564
+ _MARK_URI = "data:image/svg+xml," + _up.quote(clod_mark(64, "x").replace('class="x" ', 'xmlns="http://www.w3.org/2000/svg" '))
565
+ CSS += """
566
+ /* --- pending indicator: the Clod mark wobbles (Claude's spark pulses); a crumb keeps falling off --- */
567
+ #cl-chat .cl-pending { display: inline-block; position: relative; width: 30px; height: 30px; margin: 4px 0 2px;
568
+ background: url("%s") center / contain no-repeat; animation: clwobble 1.15s ease-in-out infinite; transform-origin: 50%% 50%%; vertical-align: middle; }
569
+ #cl-chat .cl-pending::after { content: ""; position: absolute; left: 62%%; top: 64%%; width: 4px; height: 4px; border-radius: 50%%; background: #7a5236;
570
+ animation: clcrumb 1.15s ease-in infinite; }
571
+ @keyframes clwobble { 0%% { transform: rotate(0) scale(1); } 22%% { transform: rotate(-16deg) scale(1.08); } 48%% { transform: rotate(11deg) scale(.93); }
572
+ 72%% { transform: rotate(-5deg) scale(1.04); } 100%% { transform: rotate(0) scale(1); } }
573
+ @keyframes clcrumb { 0%%, 15%% { transform: translate(0, 0); opacity: 0; } 25%% { opacity: 1; } 100%% { transform: translate(5px, 18px); opacity: 0; } }
574
+ #cl-chat .bot-row:has(.cl-pending) .message-buttons, #cl-chat .message-row:has(.cl-pending) ~ .message-buttons { display: none !important; }
575
+ #cl-chat .bot-row:has(.cl-pending), #cl-chat .bot-row:has(.cl-pending) * { opacity: 1 !important; }
576
+ #cl-chat .cl-pending { width: 34px !important; height: 34px !important; }
577
+ @media (prefers-reduced-motion: reduce) { #cl-chat .cl-pending, #cl-chat .cl-pending::after { animation: none; } }
578
+ """ % _MARK_URI
579
+
580
  THEME = gr.themes.Base(primary_hue="orange", neutral_hue="stone", font=[gr.themes.GoogleFont("Inter"), "ui-sans-serif", "system-ui"]).set(
581
  body_background_fill="#f5f4ee", body_background_fill_dark="#f5f4ee", background_fill_primary="#f5f4ee", background_fill_primary_dark="#f5f4ee",
582
  background_fill_secondary="#efede4", background_fill_secondary_dark="#efede4", block_background_fill="#f5f4ee", block_background_fill_dark="#f5f4ee",