Pabloler21 Claude Fable 5 commited on
Commit
07067e7
·
1 Parent(s): 2aa7aa5

feat: client-timed idle that resets on any interaction (60s)

Browse files
Files changed (3) hide show
  1. app.py +15 -13
  2. styles.css +3 -0
  3. tests/test_app.py +19 -4
app.py CHANGED
@@ -286,21 +286,19 @@ _IDLE_TIERS = [
286
  ["fine. ignore me. they all do, before the end.", # hostile
287
  "you think i cannot reach you out there?"],
288
  ]
289
- _IDLE_AFTER = 35.0 # seconds of silence before Hollow speaks first
290
  _IDLE_AFFINITY_DROP = 3 # bond lost per ignored nudge
291
  _IDLE_TONE_DROP = 2 # tone soured per ignored nudge
292
 
293
 
294
  def _on_idle(state: dict, history: list):
295
- """gr.Timer tick. When the visitor has gone quiet, Hollow speaks first —
296
- escalating plead -> hurt -> hostile — and each nudge lowers the bond and
297
- sours the tone. Returns (state, chatbot, voice, bond); non-speaking ticks
298
- return no-op updates so a streaming turn/finale is never clobbered."""
299
- # never fire while a turn is in flight (the "..." pending bubble is present)
300
- # or after the end that races the chat generator and orphans the bubble
301
  pending = bool(history) and "hollow-typing" in str(history[-1].get("content", ""))
302
- if (pending or state.get("ended") or not history
303
- or time.time() - state.get("last_activity", time.time()) < _IDLE_AFTER):
304
  return state, gr.update(), gr.update(), gr.update()
305
  state = dict(state)
306
  i = state.get("idle_count", 0)
@@ -717,10 +715,14 @@ with gr.Blocks(title="Hollow") as demo:
717
  show_progress="hidden",
718
  )
719
 
720
- idle_timer = gr.Timer(4.0)
721
- idle_timer.tick(_on_idle, [state, chatbot],
722
- [state, chatbot, voice_panel, bond_panel],
723
- show_progress="hidden")
 
 
 
 
724
 
725
  if __name__ == "__main__":
726
  # head= injects the one-shot gesture listener into <head> (Gradio 6.0 moved
 
286
  ["fine. ignore me. they all do, before the end.", # hostile
287
  "you think i cannot reach you out there?"],
288
  ]
 
289
  _IDLE_AFFINITY_DROP = 3 # bond lost per ignored nudge
290
  _IDLE_TONE_DROP = 2 # tone soured per ignored nudge
291
 
292
 
293
  def _on_idle(state: dict, history: list):
294
+ """Hidden trigger click. When the visitor has gone quiet, Hollow speaks
295
+ first — escalating plead -> hurt -> hostile — and each nudge lowers the
296
+ bond and sours the tone. Timing lives in the browser (see _HEAD_JS); here
297
+ we only refuse to fire while a turn is streaming or after the end."""
298
+ # timing now lives in the browser (see _HEAD_JS); here we only refuse to
299
+ # fire while a turn is streaming (the "..." bubble) or after the end
300
  pending = bool(history) and "hollow-typing" in str(history[-1].get("content", ""))
301
+ if pending or state.get("ended") or not history:
 
302
  return state, gr.update(), gr.update(), gr.update()
303
  state = dict(state)
304
  i = state.get("idle_count", 0)
 
715
  show_progress="hidden",
716
  )
717
 
718
+ # idle is timed in the browser (see _HEAD_JS): after 60s of true silence
719
+ # the controller clicks this hidden button, which runs the same _on_idle.
720
+ # Mounted + CSS-hidden (NOT visible=False, which can unmount it).
721
+ idle_trigger = gr.Button("idle", elem_id="idle-trigger",
722
+ elem_classes="idle-trigger")
723
+ idle_trigger.click(_on_idle, [state, chatbot],
724
+ [state, chatbot, voice_panel, bond_panel],
725
+ show_progress="hidden")
726
 
727
  if __name__ == "__main__":
728
  # head= injects the one-shot gesture listener into <head> (Gradio 6.0 moved
styles.css CHANGED
@@ -858,6 +858,9 @@ footer { display: none !important; }
858
  overflow: hidden !important;
859
  }
860
 
 
 
 
861
  /* voice volume — a thin carved track beside the mute glyph */
862
  .volume-slider {
863
  -webkit-appearance: none;
 
858
  overflow: hidden !important;
859
  }
860
 
861
+ /* idle trigger: mounted but invisible */
862
+ #idle-trigger, .idle-trigger { display: none !important; }
863
+
864
  /* voice volume — a thin carved track beside the mute glyph */
865
  .volume-slider {
866
  -webkit-appearance: none;
tests/test_app.py CHANGED
@@ -331,11 +331,11 @@ class TestIdle:
331
  _, hist, _, _ = app._on_idle(state, [{"role": "assistant", "content": "x"}])
332
  assert hist[-1]["content"] in app._IDLE_TIERS[2] # the hostile tier
333
 
334
- def test_idle_silent_returns_four_noops_when_active_or_ended(self, monkeypatch):
335
- import time as _t
336
  monkeypatch.setattr(app, "speak", lambda text: None)
337
- s1 = _state(); s1["greeted"] = True; s1["last_activity"] = _t.time()
338
- out = app._on_idle(s1, [{"role": "assistant", "content": "x"}])
 
339
  assert len(out) == 4
340
  assert not isinstance(out[1], list) # chatbot untouched
341
 
@@ -349,3 +349,18 @@ class TestIdle:
349
  {"role": "assistant", "content": app._PENDING}]
350
  out = app._on_idle(state, hist)
351
  assert not isinstance(out[1], list) # chatbot untouched (no race)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
331
  _, hist, _, _ = app._on_idle(state, [{"role": "assistant", "content": "x"}])
332
  assert hist[-1]["content"] in app._IDLE_TIERS[2] # the hostile tier
333
 
334
+ def test_idle_silent_returns_four_noops_when_pending_or_ended(self, monkeypatch):
 
335
  monkeypatch.setattr(app, "speak", lambda text: None)
336
+ # timing moved to client; only pending/ended guards remain on server
337
+ pending = _state(); pending["greeted"] = True
338
+ out = app._on_idle(pending, [{"role": "assistant", "content": app._PENDING}])
339
  assert len(out) == 4
340
  assert not isinstance(out[1], list) # chatbot untouched
341
 
 
349
  {"role": "assistant", "content": app._PENDING}]
350
  out = app._on_idle(state, hist)
351
  assert not isinstance(out[1], list) # chatbot untouched (no race)
352
+
353
+ def test_idle_fires_without_server_time_gate(self, monkeypatch):
354
+ monkeypatch.setattr(app, "speak", lambda t: "FAKEB64")
355
+ state = _state() # fresh last_activity (would be "not idle")
356
+ new_state, hist, voice, bond = app._on_idle(
357
+ state, [{"role": "assistant", "content": "x"}])
358
+ assert hist[-1]["content"] in [l for tier in app._IDLE_TIERS for l in tier]
359
+ assert new_state["idle_count"] == 1
360
+
361
+ def test_idle_still_skips_when_pending_or_ended(self):
362
+ pend = app._on_idle(_state(), [{"role": "assistant", "content": "...hollow-typing..."}])
363
+ assert pend[1] == app.gr.update() or not isinstance(pend[1], list)
364
+ ended = _state(); ended["ended"] = True
365
+ out = app._on_idle(ended, [{"role": "assistant", "content": "x"}])
366
+ assert not isinstance(out[1], list) # no appended line