Papajams commited on
Commit
ac350cb
·
verified ·
1 Parent(s): 6935f45

feat: demo persona, TTS, Modal, agent trace, Field Notes

Browse files
Files changed (5) hide show
  1. .gitattributes +1 -0
  2. FIELD_NOTES.md +2 -2
  3. README.md +3 -2
  4. app.py +99 -33
  5. demo.mp4 +3 -0
.gitattributes CHANGED
@@ -38,3 +38,4 @@ audio/voices/day-02-partner.wav filter=lfs diff=lfs merge=lfs -text
38
  audio/voices/day-03-mentor.wav filter=lfs diff=lfs merge=lfs -text
39
  audio/voices/day-04-shadow.wav filter=lfs diff=lfs merge=lfs -text
40
  audio/voices/today-threshold.wav filter=lfs diff=lfs merge=lfs -text
 
 
38
  audio/voices/day-03-mentor.wav filter=lfs diff=lfs merge=lfs -text
39
  audio/voices/day-04-shadow.wav filter=lfs diff=lfs merge=lfs -text
40
  audio/voices/today-threshold.wav filter=lfs diff=lfs merge=lfs -text
41
+ demo.mp4 filter=lfs diff=lfs merge=lfs -text
FIELD_NOTES.md CHANGED
@@ -1,7 +1,7 @@
1
  # Field Notes — FutureSelves for Build Small
2
 
3
  **Hackathon:** [Gradio + Hugging Face Build Small](https://huggingface.co/build-small-hackathon), June 2026
4
- **Submission:** [papajams-futureselves](https://papajams-futureselves.hf.space) · [source](https://github.com/udingethe/futureselves/tree/main/hf-space)
5
  **Author:** [@udingethe](https://github.com/udingethe)
6
 
7
  ## What we built
@@ -62,7 +62,7 @@ The badges we do claim have artifacts in the repo: the custom UI is in `app.py`,
62
 
63
  If you want to evaluate the build fairly, here is the order that makes the most sense:
64
 
65
- 1. Land on the [Live Space](https://papajams-futureselves.hf.space).
66
  2. Click **✦ Try Maya's example** on the first screen.
67
  3. Read Maya's transmission. Click play on the audio.
68
  4. Scroll to the memory log. Click the play chips beside the past 4 transmissions.
 
1
  # Field Notes — FutureSelves for Build Small
2
 
3
  **Hackathon:** [Gradio + Hugging Face Build Small](https://huggingface.co/build-small-hackathon), June 2026
4
+ **Submission:** [Papajams/futureselves](https://huggingface.co/spaces/Papajams/futureselves) · [source](https://github.com/udingethe/futureselves/tree/main/hf-space)
5
  **Author:** [@udingethe](https://github.com/udingethe)
6
 
7
  ## What we built
 
62
 
63
  If you want to evaluate the build fairly, here is the order that makes the most sense:
64
 
65
+ 1. Land on the [Live Space](https://huggingface.co/spaces/Papajams/futureselves).
66
  2. Click **✦ Try Maya's example** on the first screen.
67
  3. Read Maya's transmission. Click play on the audio.
68
  4. Scroll to the memory log. Click the play chips beside the past 4 transmissions.
README.md CHANGED
@@ -78,10 +78,11 @@ python app.py
78
 
79
  ## Links
80
 
81
- - **[Live Space](https://papajams-futureselves.hf.space)** — deployed on T4 GPU
82
  - [Source (monorepo)](https://github.com/udingethe/futureselves/tree/main/hf-space)
 
83
  - [Demo walkthrough](FIELD_NOTES.md#the-artifact-end-to-end) — step-by-step guide for judges
 
84
  - [Agent trace](traces/agent-trace.jsonl) — open record of every transmission
85
  - [Field Notes blog post](FIELD_NOTES.md) — what we built and what we learned
86
- - [Agent trace](traces/agent-trace.jsonl) — open record of every transmission
87
  - [Modal app](traces/modal_app.py) — serverless persona summarizer
 
78
 
79
  ## Links
80
 
81
+ - **[Live Space](https://huggingface.co/spaces/Papajams/futureselves)** — deployed on T4 GPU
82
  - [Source (monorepo)](https://github.com/udingethe/futureselves/tree/main/hf-space)
83
+ - [Demo video](demo.mp4) — 2-minute walkthrough
84
  - [Demo walkthrough](FIELD_NOTES.md#the-artifact-end-to-end) — step-by-step guide for judges
85
+ - [Social post](https://medium.com/@ungethe/building-small-9aea8bf5236e) — Medium article
86
  - [Agent trace](traces/agent-trace.jsonl) — open record of every transmission
87
  - [Field Notes blog post](FIELD_NOTES.md) — what we built and what we learned
 
88
  - [Modal app](traces/modal_app.py) — serverless persona summarizer
app.py CHANGED
@@ -13,7 +13,6 @@ Tiny Titan, Best Agent, Off Brand, Best Demo, Bonus Quest Champion.
13
 
14
  from __future__ import annotations
15
 
16
- import json
17
  import json
18
  import logging
19
  import os
@@ -23,6 +22,7 @@ import threading
23
  import uuid
24
  from dataclasses import dataclass, field, asdict
25
  from datetime import date
 
26
  from pathlib import Path
27
  from typing import Any, Optional
28
 
@@ -90,7 +90,15 @@ def _load_llm():
90
  return _LLM
91
 
92
 
93
- def _generate_with_llm(context: GenerationContext, cast_member: CastMember, local_now: str) -> GeneratedTransmission:
 
 
 
 
 
 
 
 
94
  try:
95
  model, tokenizer = _load_llm()
96
  prompt = build_prompt(context, cast_member)
@@ -111,10 +119,11 @@ def _generate_with_llm(context: GenerationContext, cast_member: CastMember, loca
111
  decoded = tokenizer.decode(outputs[0][inputs["input_ids"].shape[1]:], skip_special_tokens=True).strip()
112
  parsed = parse_transmission(decoded)
113
  if parsed:
114
- return parsed
 
115
  except Exception as exc:
116
  logger.warning("LLM failed: %s", exc)
117
- return fallback_transmission(context, cast_member)
118
 
119
 
120
  # ─── State ───────────────────────────────────────────────────────────────────
@@ -1209,6 +1218,25 @@ def _audio_module(label: str, audio_path: str) -> str:
1209
  </div>"""
1210
 
1211
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1212
  def _share_module(title: str, body: str, action: str, cliff: str) -> str:
1213
  """A copy-to-clipboard button for the transmission, styled as an instrument.
1214
 
@@ -1257,19 +1285,22 @@ def _memory_log(state: AppState, *, limit: int = 8) -> str:
1257
  f'<a class="audio-chip" href="/file={staged}" target="_blank" rel="noopener" '
1258
  f'title="play voice">▶</a>'
1259
  )
 
1260
  rows.append(
1261
  f'<div class="memory-row memory-entry">'
1262
- f'<span class="ts">{t.date_key}</span>'
1263
- f'<span class="body"><em>&ldquo;{t.title}&rdquo;</em></span>'
1264
- f'<span class="tag">{CAST_MEMBER_NAMES.get(t.cast_member, ("", ""))[0] or t.cast_member}{audio_chip}</span>'
1265
  f'</div>'
1266
  )
1267
  for c in reversed(state.recent_choices[-limit:]):
 
 
1268
  rows.append(
1269
  f'<div class="memory-row memory-entry">'
1270
- f'<span class="ts">{c.date_key}</span>'
1271
- f'<span class="body">{c.prompt}</span>'
1272
- f'<span class="tag choice-{c.choice}">{c.choice}</span>'
1273
  f'</div>'
1274
  )
1275
  return f"""<div class="memory-log">
@@ -1317,12 +1348,14 @@ def _render_persona_card() -> str:
1317
  if not summaries:
1318
  return '<span class="k">no persona summary yet — generate one via the demo button</span>'
1319
  last = summaries[-1]
1320
- src = last.get("source", "?")
 
 
1321
  return (
1322
  f'<em style="color:var(--paper);font-family:Fraunces,serif;font-size:14px;line-height:1.6;">'
1323
- f'&ldquo;{last.get("summary", "")}&rdquo;</em>'
1324
  f'<div style="margin-top:8px;font-size:9px;letter-spacing:.18em;text-transform:uppercase;color:var(--paper-mute);">'
1325
- f'— {last.get("persona_name", "?")} · via {src}</div>'
1326
  )
1327
 
1328
 
@@ -1342,10 +1375,9 @@ def _context_note(html: str) -> str:
1342
  # ─── App logic (async gen helper) ─────────────────────────────────────────────
1343
 
1344
  def _gen_async(state: AppState, context: GenerationContext, cm: CastMember, now_str: str):
1345
- start = time.time() if 'time' in dir() else 0
1346
  import time as _time
1347
  start = _time.time()
1348
- result = _generate_with_llm(context, cm, now_str)
1349
  state.today_transmission = result
1350
  # Try local Kokoro first. If it fails (Space, missing deps, etc.) and we
1351
  # have a matching pre-rendered voice sample for the cast member, fall
@@ -1364,9 +1396,8 @@ def _gen_async(state: AppState, context: GenerationContext, cm: CastMember, now_
1364
  from transmission import build_prompt, get_system_prompt
1365
  sys_p = get_system_prompt(context.persona.timeline_divergence_score)
1366
  usr_p = build_prompt(context, cm)
1367
- # Build a coarse parsed_output mirror
1368
  parsed = {
1369
- "title": result.title, "text": result.text[:200],
1370
  "actionPrompt": result.action_prompt, "cliffhanger": result.cliffhanger,
1371
  } if result else None
1372
  # Run note extraction (Nemotron-Parse or keyword fallback) for the trace
@@ -1390,6 +1421,7 @@ def _gen_async(state: AppState, context: GenerationContext, cm: CastMember, now_
1390
  parsed_output=parsed,
1391
  insights=insights,
1392
  duration_ms=duration_ms,
 
1393
  )
1394
  except Exception as exc:
1395
  logger.warning("Failed to log agent trace: %s", exc)
@@ -1480,8 +1512,12 @@ def _render_home(state: AppState) -> str:
1480
  body += _signal_path("onboard")
1481
  return body
1482
  p = state.persona
1483
- eyebrow = _chamber_eyebrow("line idle", f"{p.name or 'you'} · {p.city or 'everywhere'}")
1484
- title = _chamber_title(f"Ready when you are, <em>{p.name}</em>.")
 
 
 
 
1485
  continuity_hint = ""
1486
  if state.recent_choices:
1487
  last_choice = state.recent_choices[-1]
@@ -1494,8 +1530,8 @@ def _render_home(state: AppState) -> str:
1494
  continuity_hint = f" The line has been listening since you last chose to {choice_labels.get(last_choice.choice, 'continue')}."
1495
  body_html = _chamber_body(
1496
  f"Check in with one word to open the line. "
1497
- f"You are in <em>{p.current_chapter or 'a chapter still forming'}</em>, "
1498
- f"and avoiding <em>{p.avoiding or 'something you keep circling'}</em>."
1499
  f"{continuity_hint}"
1500
  )
1501
  callout = ""
@@ -1503,11 +1539,12 @@ def _render_home(state: AppState) -> str:
1503
  last = state.recent_transmissions[-1]
1504
  callout = _chamber_callout(
1505
  "last transmission",
1506
- f'<em>&ldquo;{last.title}&rdquo;</em> · {last.date_key}'
1507
  )
 
1508
  meta = _chamber_meta([
1509
- ("arc", p.primary_arc or "—"),
1510
- ("chapter", p.current_chapter[:40] + "…" if p.current_chapter and len(p.current_chapter) > 40 else (p.current_chapter or "—")),
1511
  ])
1512
  out = _identity_strip(state)
1513
  out += _signal_chamber(eyebrow, title, body_html, meta_html=meta, callout_html=callout)
@@ -1519,8 +1556,9 @@ def _render_home(state: AppState) -> str:
1519
 
1520
 
1521
  def _render_awaiting(state: AppState) -> str:
1522
- eyebrow = _chamber_eyebrow("signal locked", f'word · {state.check_in_word or "—"}')
1523
- title = _chamber_title(f'<em>&ldquo;{state.check_in_word}&rdquo;</em> received.')
 
1524
  body_html = _chamber_body(
1525
  "The note is being read. The line is open. "
1526
  "Open the line below to receive your transmission."
@@ -1560,16 +1598,25 @@ def _render_transmission(state: AppState) -> str:
1560
  if not t:
1561
  return _render_home(state)
1562
  label = CAST_MEMBER_NAMES.get(state.today_cast or "future_self", ("", ""))[0] or "Future Self"
 
 
 
 
1563
  eyebrow = _chamber_eyebrow("transmission received", label)
1564
- title = _chamber_title(f'<em>{t.title}</em>')
1565
- body_html = _chamber_body(t.text)
1566
- actions = _audio_module(label, state.today_audio) if state.today_audio else ""
 
 
 
 
 
1567
  actions += _share_module(t.title, t.text, t.action_prompt, t.cliffhanger)
1568
  # The chamber holds the audio module + tonight's move. Tomorrow's cliffhanger
1569
  # is shown as a second anchor line below the chamber so the page has a clear
1570
  # rhythm: text → audio + tonight → tomorrow → constellation → memory log.
1571
- tonight = _chamber_callout("tonight's move", t.action_prompt)
1572
- tomorrow = _chamber_callout("tomorrow", t.cliffhanger)
1573
  out = _identity_strip(state)
1574
  out += _signal_chamber(eyebrow, title, body_html, actions_html=actions, callout_html=tonight)
1575
  out += tomorrow
@@ -2013,9 +2060,28 @@ setupInteractions();
2013
  inputs=[word, note, state], outputs=[content, browser_state, state],
2014
  )
2015
 
2016
- # Wire generate
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2017
  generate_btn.click(
2018
- fn=lambda s: (_render_generating(s.today_cast or "future_self"), s.to_dict(), s) if (setattr(s, 'generating', True), setattr(s, 'generation_done', False), setattr(s, 'today_audio', ''), setattr(s, 'today_cast', _choose_cast(s)), threading.Thread(target=_gen_async, args=(s, s.to_context(), s.today_cast, date.today().strftime("%Y-%m-%d %H:%M")), daemon=True).start()) else (None, None, None),
2019
  inputs=[state], outputs=[content, browser_state, state],
2020
  )
2021
 
 
13
 
14
  from __future__ import annotations
15
 
 
16
  import json
17
  import logging
18
  import os
 
22
  import uuid
23
  from dataclasses import dataclass, field, asdict
24
  from datetime import date
25
+ from html import escape as html_escape
26
  from pathlib import Path
27
  from typing import Any, Optional
28
 
 
90
  return _LLM
91
 
92
 
93
+ def _generate_with_llm(context: GenerationContext, cast_member: CastMember, local_now: str) -> tuple[GeneratedTransmission, str]:
94
+ """Generate a transmission and report which path produced it.
95
+
96
+ Returns (transmission, source) where source is one of:
97
+ - "live" : MiniCPM produced valid JSON we could parse
98
+ - "parse-fail": MiniCPM ran but its output couldn't be parsed
99
+ - "llm-error": LLM call raised (OOM, network, missing model)
100
+ The agent trace records this so judges can audit live vs. fallback.
101
+ """
102
  try:
103
  model, tokenizer = _load_llm()
104
  prompt = build_prompt(context, cast_member)
 
119
  decoded = tokenizer.decode(outputs[0][inputs["input_ids"].shape[1]:], skip_special_tokens=True).strip()
120
  parsed = parse_transmission(decoded)
121
  if parsed:
122
+ return parsed, "live"
123
+ return fallback_transmission(context, cast_member), "parse-fail"
124
  except Exception as exc:
125
  logger.warning("LLM failed: %s", exc)
126
+ return fallback_transmission(context, cast_member), "llm-error"
127
 
128
 
129
  # ─── State ───────────────────────────────────────────────────────────────────
 
1218
  </div>"""
1219
 
1220
 
1221
+ def _audio_dropped_module(label: str) -> str:
1222
+ """Visible callout when both Kokoro and the pre-rendered fallback audio failed.
1223
+
1224
+ Without this, the chamber silently renders text-only and the user assumes
1225
+ the demo is broken. Make the failure mode part of the product voice.
1226
+ """
1227
+ label_safe = html_escape(label)
1228
+ return f"""<div style="margin-top:20px;padding:14px 16px;border:1px solid var(--line);border-left:2px solid var(--violet);border-radius:2px;background:rgba(122,108,199,.06);">
1229
+ <div style="display:flex;align-items:center;gap:10px;margin-bottom:6px;">
1230
+ <span style="width:6px;height:6px;border-radius:50%;background:var(--violet);box-shadow:0 0 8px rgba(122,108,199,.6);"></span>
1231
+ <span style="font-size:9px;letter-spacing:.22em;text-transform:uppercase;color:var(--violet)">voice line dropped</span>
1232
+ <span style="font-size:9px;letter-spacing:.18em;text-transform:uppercase;color:var(--paper-mute)">· {label_safe} could not be tuned</span>
1233
+ </div>
1234
+ <div style="font-family:Fraunces,serif;font-style:italic;font-size:13px;line-height:1.5;color:var(--paper-mute);">
1235
+ The text arrived but the voice did not. The transmission is still legible above.
1236
+ </div>
1237
+ </div>"""
1238
+
1239
+
1240
  def _share_module(title: str, body: str, action: str, cliff: str) -> str:
1241
  """A copy-to-clipboard button for the transmission, styled as an instrument.
1242
 
 
1285
  f'<a class="audio-chip" href="/file={staged}" target="_blank" rel="noopener" '
1286
  f'title="play voice">▶</a>'
1287
  )
1288
+ cast_label = CAST_MEMBER_NAMES.get(t.cast_member, ("", ""))[0] or t.cast_member
1289
  rows.append(
1290
  f'<div class="memory-row memory-entry">'
1291
+ f'<span class="ts">{html_escape(t.date_key)}</span>'
1292
+ f'<span class="body"><em>&ldquo;{html_escape(t.title)}&rdquo;</em></span>'
1293
+ f'<span class="tag">{html_escape(cast_label)}{audio_chip}</span>'
1294
  f'</div>'
1295
  )
1296
  for c in reversed(state.recent_choices[-limit:]):
1297
+ # c.choice is enum-like (toward/steady/release/repair) but escape for safety.
1298
+ choice_safe = html_escape(c.choice)
1299
  rows.append(
1300
  f'<div class="memory-row memory-entry">'
1301
+ f'<span class="ts">{html_escape(c.date_key)}</span>'
1302
+ f'<span class="body">{html_escape(c.prompt)}</span>'
1303
+ f'<span class="tag choice-{choice_safe}">{choice_safe}</span>'
1304
  f'</div>'
1305
  )
1306
  return f"""<div class="memory-log">
 
1348
  if not summaries:
1349
  return '<span class="k">no persona summary yet — generate one via the demo button</span>'
1350
  last = summaries[-1]
1351
+ src = html_escape(last.get("source", "?"))
1352
+ summary = html_escape(last.get("summary", ""))
1353
+ name = html_escape(last.get("persona_name", "?"))
1354
  return (
1355
  f'<em style="color:var(--paper);font-family:Fraunces,serif;font-size:14px;line-height:1.6;">'
1356
+ f'&ldquo;{summary}&rdquo;</em>'
1357
  f'<div style="margin-top:8px;font-size:9px;letter-spacing:.18em;text-transform:uppercase;color:var(--paper-mute);">'
1358
+ f'— {name} · via {src}</div>'
1359
  )
1360
 
1361
 
 
1375
  # ─── App logic (async gen helper) ─────────────────────────────────────────────
1376
 
1377
  def _gen_async(state: AppState, context: GenerationContext, cm: CastMember, now_str: str):
 
1378
  import time as _time
1379
  start = _time.time()
1380
+ result, source = _generate_with_llm(context, cm, now_str)
1381
  state.today_transmission = result
1382
  # Try local Kokoro first. If it fails (Space, missing deps, etc.) and we
1383
  # have a matching pre-rendered voice sample for the cast member, fall
 
1396
  from transmission import build_prompt, get_system_prompt
1397
  sys_p = get_system_prompt(context.persona.timeline_divergence_score)
1398
  usr_p = build_prompt(context, cm)
 
1399
  parsed = {
1400
+ "title": result.title, "text": result.text,
1401
  "actionPrompt": result.action_prompt, "cliffhanger": result.cliffhanger,
1402
  } if result else None
1403
  # Run note extraction (Nemotron-Parse or keyword fallback) for the trace
 
1421
  parsed_output=parsed,
1422
  insights=insights,
1423
  duration_ms=duration_ms,
1424
+ source=source,
1425
  )
1426
  except Exception as exc:
1427
  logger.warning("Failed to log agent trace: %s", exc)
 
1512
  body += _signal_path("onboard")
1513
  return body
1514
  p = state.persona
1515
+ name_safe = html_escape(p.name or "you")
1516
+ city_safe = html_escape(p.city or "everywhere")
1517
+ chapter_safe = html_escape(p.current_chapter or "a chapter still forming")
1518
+ avoiding_safe = html_escape(p.avoiding or "something you keep circling")
1519
+ eyebrow = _chamber_eyebrow("line idle", f"{name_safe} · {city_safe}")
1520
+ title = _chamber_title(f"Ready when you are, <em>{name_safe}</em>.")
1521
  continuity_hint = ""
1522
  if state.recent_choices:
1523
  last_choice = state.recent_choices[-1]
 
1530
  continuity_hint = f" The line has been listening since you last chose to {choice_labels.get(last_choice.choice, 'continue')}."
1531
  body_html = _chamber_body(
1532
  f"Check in with one word to open the line. "
1533
+ f"You are in <em>{chapter_safe}</em>, "
1534
+ f"and avoiding <em>{avoiding_safe}</em>."
1535
  f"{continuity_hint}"
1536
  )
1537
  callout = ""
 
1539
  last = state.recent_transmissions[-1]
1540
  callout = _chamber_callout(
1541
  "last transmission",
1542
+ f'<em>&ldquo;{html_escape(last.title)}&rdquo;</em> · {html_escape(last.date_key)}'
1543
  )
1544
+ chapter_truncated = p.current_chapter[:40] + "…" if p.current_chapter and len(p.current_chapter) > 40 else (p.current_chapter or "—")
1545
  meta = _chamber_meta([
1546
+ ("arc", html_escape(p.primary_arc or "—")),
1547
+ ("chapter", html_escape(chapter_truncated)),
1548
  ])
1549
  out = _identity_strip(state)
1550
  out += _signal_chamber(eyebrow, title, body_html, meta_html=meta, callout_html=callout)
 
1556
 
1557
 
1558
  def _render_awaiting(state: AppState) -> str:
1559
+ word_safe = html_escape(state.check_in_word or "—")
1560
+ eyebrow = _chamber_eyebrow("signal locked", f'word · {word_safe}')
1561
+ title = _chamber_title(f'<em>&ldquo;{word_safe}&rdquo;</em> received.')
1562
  body_html = _chamber_body(
1563
  "The note is being read. The line is open. "
1564
  "Open the line below to receive your transmission."
 
1598
  if not t:
1599
  return _render_home(state)
1600
  label = CAST_MEMBER_NAMES.get(state.today_cast or "future_self", ("", ""))[0] or "Future Self"
1601
+ title_safe = html_escape(t.title or "")
1602
+ text_safe = html_escape(t.text or "")
1603
+ action_safe = html_escape(t.action_prompt or "")
1604
+ cliff_safe = html_escape(t.cliffhanger or "")
1605
  eyebrow = _chamber_eyebrow("transmission received", label)
1606
+ title = _chamber_title(f'<em>{title_safe}</em>')
1607
+ body_html = _chamber_body(text_safe)
1608
+ actions = ""
1609
+ if state.today_audio:
1610
+ actions = _audio_module(label, state.today_audio)
1611
+ elif state.generation_done:
1612
+ actions = _audio_dropped_module(label)
1613
+ # Share module gets raw text — it's payload-encoded for clipboard, not HTML-interpolated.
1614
  actions += _share_module(t.title, t.text, t.action_prompt, t.cliffhanger)
1615
  # The chamber holds the audio module + tonight's move. Tomorrow's cliffhanger
1616
  # is shown as a second anchor line below the chamber so the page has a clear
1617
  # rhythm: text → audio + tonight → tomorrow → constellation → memory log.
1618
+ tonight = _chamber_callout("tonight's move", action_safe)
1619
+ tomorrow = _chamber_callout("tomorrow", cliff_safe)
1620
  out = _identity_strip(state)
1621
  out += _signal_chamber(eyebrow, title, body_html, actions_html=actions, callout_html=tonight)
1622
  out += tomorrow
 
2060
  inputs=[word, note, state], outputs=[content, browser_state, state],
2061
  )
2062
 
2063
+ # Wire generate.
2064
+ # In-flight guard: if a generation thread is already running
2065
+ # (state.generating True and not yet done), ignore subsequent
2066
+ # clicks instead of spawning a second _gen_async thread that
2067
+ # would race the first one on state mutations.
2068
+ def _handle_generate(s: AppState):
2069
+ if s.generating and not s.generation_done:
2070
+ return _render_generating(s.today_cast or "future_self"), s.to_dict(), s
2071
+ s.generating = True
2072
+ s.generation_done = False
2073
+ s.today_audio = ""
2074
+ s.today_transmission = None
2075
+ s.today_cast = _choose_cast(s)
2076
+ threading.Thread(
2077
+ target=_gen_async,
2078
+ args=(s, s.to_context(), s.today_cast, date.today().strftime("%Y-%m-%d %H:%M")),
2079
+ daemon=True,
2080
+ ).start()
2081
+ return _render_generating(s.today_cast or "future_self"), s.to_dict(), s
2082
+
2083
  generate_btn.click(
2084
+ fn=_handle_generate,
2085
  inputs=[state], outputs=[content, browser_state, state],
2086
  )
2087
 
demo.mp4 ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:911b01ea68ca914f786b9733204412e31523dc49573d955dfb575d2460e6f2fb
3
+ size 58375570