Spaces:
Running on Zero
Running on Zero
Commit ·
07067e7
1
Parent(s): 2aa7aa5
feat: client-timed idle that resets on any interaction (60s)
Browse files- app.py +15 -13
- styles.css +3 -0
- 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 |
-
"""
|
| 296 |
-
escalating plead -> hurt -> hostile — and each nudge lowers the
|
| 297 |
-
sours the tone.
|
| 298 |
-
|
| 299 |
-
#
|
| 300 |
-
#
|
| 301 |
pending = bool(history) and "hollow-typing" in str(history[-1].get("content", ""))
|
| 302 |
-
if
|
| 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 |
-
|
| 721 |
-
|
| 722 |
-
|
| 723 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 335 |
-
import time as _t
|
| 336 |
monkeypatch.setattr(app, "speak", lambda text: None)
|
| 337 |
-
|
| 338 |
-
|
|
|
|
| 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
|