CazC commited on
Commit
d12b852
·
verified ·
1 Parent(s): 3fe6b83

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +374 -77
app.py CHANGED
@@ -2,9 +2,11 @@ from __future__ import annotations
2
 
3
  import json
4
  import os
 
 
5
  from pathlib import Path
6
  from textwrap import dedent
7
- from typing import Dict, List, Optional, Tuple
8
 
9
  import gradio as gr
10
  from dotenv import load_dotenv
@@ -79,19 +81,114 @@ CUSTOM_CSS = """
79
  .inline-row {
80
  gap: 8px;
81
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
82
  """
83
 
84
  EXAMPLE_CHARACTER_PATH = Path(__file__).with_name("ExampleChar.txt")
85
  DEFAULT_CHARACTER_JSON = EXAMPLE_CHARACTER_PATH.read_text(encoding="utf-8")
86
  DEFAULT_CHARACTER = Character.model_validate_json(DEFAULT_CHARACTER_JSON)
87
 
88
- with open("tacklebox_deck.json", "r", encoding="utf-8") as f:
89
- raw = json.load(f)
 
 
90
 
91
- deck = TackleboxDeck(**raw)
92
- player_hand = Hand()
93
- ai_hand = Hand()
94
- ChatHistory = List[Tuple[str, Optional[str]]]
 
 
 
 
 
 
 
 
 
 
95
 
96
  def get_openai_client(api_key: Optional[str]) -> OpenAI:
97
  """Return an OpenAI client using UI input or environment configuration."""
@@ -101,15 +198,64 @@ def get_openai_client(api_key: Optional[str]) -> OpenAI:
101
  return OpenAI(api_key=key)
102
 
103
 
104
- def format_hand(hand: Hand) -> str:
105
- """Pretty-print a hand for display."""
 
 
 
 
 
 
 
 
 
 
 
106
  if not hand.cards:
107
- return "Your hand is empty. Grab something from the tackle box to start."
108
- lines = "\n".join(f"- {card.description}" for card in hand.cards)
109
- return f"Your hand ({len(hand.cards)} cards):\n{lines}"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
110
 
111
 
112
- def format_ai_hand_debug() -> str:
 
 
 
 
 
 
 
 
113
  """Readable snapshot of the AI hand for debugging."""
114
  if not ai_hand.cards:
115
  return "AI hand is empty."
@@ -273,7 +419,7 @@ def build_persona_prompt(character: Character) -> str:
273
  ).strip()
274
 
275
 
276
- def append_ai_hand_to_prompt(base_prompt: str) -> str:
277
  """Add AI hand context to the persona prompt so the model sees its cards."""
278
  if not ai_hand.cards:
279
  return base_prompt
@@ -291,14 +437,18 @@ def generate_model_reply(
291
  history: ChatHistory,
292
  persona_prompt: str,
293
  api_key: Optional[str],
 
 
 
294
  ) -> str:
295
  """Call OpenAI with the parsed prompt and tool support (AI can draw cards)."""
296
  client = get_openai_client(api_key)
297
- base_prompt = append_ai_hand_to_prompt(persona_prompt)
298
- messages: List[Dict[str, str]] = [{"role": "system", "content": base_prompt}]
299
 
300
  for user_turn, assistant_turn in history:
301
- messages.append({"role": "user", "content": user_turn})
 
302
  if assistant_turn:
303
  messages.append({"role": "assistant", "content": assistant_turn})
304
 
@@ -313,7 +463,7 @@ def generate_model_reply(
313
  "type": "function",
314
  "function": {
315
  "name": "open_tacklebox",
316
- "description": "Call this to draw a new event card when the conversation stalls, feels repetitive, or the user is giving short replies. In particular, if the user gives one- to three-word answers like 'yeah', 'true', 'ha', 'i guess', 'maybe', or 'idk' for two turns in a row, you MUST call this tool once before responding. Use at most once per user message.",
317
  "parameters": {
318
  "type": "object",
319
  "properties": {},
@@ -340,45 +490,80 @@ def generate_model_reply(
340
  tool_messages: List[Dict[str, str]] = []
341
  ai_draw_notice = None
342
  shared_with_player_notice = None
 
343
  for call in message.tool_calls:
344
- if call.function.name == "open_tacklebox":
345
- card = deck.draw_card()
346
- if card:
347
- ai_hand.add_card(card)
348
- shared_with_player = False
349
- if card.share_with_other_player:
350
- player_hand.add_card(card)
351
- shared_with_player = True
352
-
353
- ai_draw_notice = "*They carefully grabed something out of the tacklebox.*"
354
- if shared_with_player:
355
- shared_with_player_notice = (
356
- "*They hand you a card meant to be shared. It was added to your hand:*\n"
357
- f"- {card.description}"
358
- )
359
-
360
- tool_content = json.dumps(
361
- {
362
- "card_drawn": card.description,
363
- "hand_size": len(ai_hand.cards),
364
- "shared_with_player": shared_with_player,
365
- }
366
- )
367
- else:
368
- ai_draw_notice = "*They reached for the tacklebox, but it was empty.*"
369
- tool_content = json.dumps({"card_drawn": None, "hand_size": len(ai_hand.cards)})
370
 
 
371
  tool_messages.append(
372
  {
373
  "role": "tool",
374
  "tool_call_id": call.id,
375
- "name": call.function.name,
376
- "content": tool_content,
 
 
 
 
 
377
  }
378
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
379
 
380
- followup_messages = messages + [message] + tool_messages
381
- followup_messages[0] = {"role": "system", "content": append_ai_hand_to_prompt(persona_prompt)}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
382
 
383
  followup_response = client.chat.completions.create(
384
  model=MODEL,
@@ -395,10 +580,13 @@ def generate_model_reply(
395
 
396
  def orchestrate_chat(
397
  message: str,
398
- history: Optional[ChatHistory],
399
  persona_prompt: str,
400
  api_key: Optional[str],
401
- ) -> str:
 
 
 
402
  """
403
  Main orchestration entry point.
404
 
@@ -406,15 +594,50 @@ def orchestrate_chat(
406
  - run (optional) tools
407
  - call the model with full context and persona prompt
408
  """
409
- safe_history: ChatHistory = history or []
 
410
  context: Dict = {"history": safe_history}
411
 
 
 
 
 
 
 
 
 
 
 
412
  parsed = parse_user_message(message, context)
413
  tool_results = maybe_run_tools(parsed, context)
414
  try:
415
- return generate_model_reply(parsed, tool_results, safe_history, persona_prompt, api_key)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
416
  except Exception as exc: # noqa: BLE001
417
- return f"⚠️ {exc}"
 
 
 
 
 
 
 
418
 
419
 
420
  def update_player_two_from_form(
@@ -472,21 +695,25 @@ def update_api_key(new_key: str) -> Tuple[str, str]:
472
 
473
 
474
  def grab_card_for_player(
475
- history: Optional[ChatHistory] = None,
476
- ) -> Tuple[str, str, ChatHistory]:
 
 
 
477
  """
478
  Draw a card from the tackle box into the player's hand and log it to chat.
479
 
480
  Returns: (formatted_hand, status_message, updated_chat_history)
481
  """
482
- chat_history: ChatHistory = list(history or [])
 
483
  card = deck.draw_card()
484
 
485
  if not card:
486
- status = "No more cards in the tackle box."
487
  notice = "*You reached for the tacklebox, but it was empty.*"
488
- chat_history.append((notice, None))
489
- return format_hand(player_hand), status, chat_history
490
 
491
  player_hand.add_card(card)
492
  shared_with_ai_notice = None
@@ -496,22 +723,42 @@ def grab_card_for_player(
496
  "*This card says to share; it was added to Player 2's hand for their next turn.*"
497
  )
498
 
499
- status = "Added card to your hand."
500
  if shared_with_ai_notice:
501
- status = "Added card to your hand and shared with Player 2."
502
 
503
- notice_lines = ["*You carefully grabed something out of the tacklebox.*"]
504
  if shared_with_ai_notice:
505
  notice_lines.append(shared_with_ai_notice)
506
 
507
  notice = "\n".join(notice_lines)
508
- chat_history.append((notice, None))
509
- return format_hand(player_hand), status, chat_history
510
 
511
 
512
- def show_ai_hand_debug() -> str:
513
  """Expose AI hand for debugging."""
514
- return format_ai_hand_debug()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
515
 
516
 
517
  DEFAULT_PERSONA_PROMPT = build_persona_prompt(DEFAULT_CHARACTER)
@@ -531,6 +778,9 @@ with gr.Blocks(title="Tacklebox - Player 2 Customizer", css=CUSTOM_CSS) as demo:
531
 
532
  persona_prompt_state = gr.State(DEFAULT_PERSONA_PROMPT)
533
  api_key_state = gr.State(ENV_API_KEY or "")
 
 
 
534
 
535
  with gr.Row(equal_height=True):
536
  with gr.Column(scale=6, min_width=380, elem_id="controls-col"):
@@ -626,20 +876,50 @@ with gr.Blocks(title="Tacklebox - Player 2 Customizer", css=CUSTOM_CSS) as demo:
626
 
627
  with gr.Column(scale=7, min_width=460, elem_id="chat-col"):
628
  with gr.Group(elem_classes=["section-card"]):
629
- gr.Markdown("#### Your hand & tackle box")
630
- hand_status = gr.Markdown("Your hand is empty. Grab a card to begin.")
631
- hand_view = gr.Textbox(
632
- label="Your hand",
633
- value=format_hand(player_hand),
634
- lines=6,
635
- interactive=False,
 
 
 
 
 
 
 
636
  )
637
- draw_btn = gr.Button("Grab something from the tackle box", variant="secondary")
 
 
 
 
 
 
 
 
 
 
638
 
639
  with gr.Group(elem_classes=["section-card"]):
640
  chat = gr.ChatInterface(
641
  orchestrate_chat,
642
- additional_inputs=[persona_prompt_state, api_key_state],
 
 
 
 
 
 
 
 
 
 
 
 
 
643
  title="Tacklebox Chat",
644
  description="Robin chats with Player 2 (LLM persona).",
645
  examples=[
@@ -673,8 +953,25 @@ with gr.Blocks(title="Tacklebox - Player 2 Customizer", css=CUSTOM_CSS) as demo:
673
 
674
  draw_btn.click(
675
  grab_card_for_player,
676
- inputs=[chat.chatbot_value],
677
- outputs=[hand_view, hand_status, chat.chatbot_value],
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
678
  )
679
 
680
 
 
2
 
3
  import json
4
  import os
5
+ import copy
6
+ import html
7
  from pathlib import Path
8
  from textwrap import dedent
9
+ from typing import Any, Dict, List, Optional, Tuple
10
 
11
  import gradio as gr
12
  from dotenv import load_dotenv
 
81
  .inline-row {
82
  gap: 8px;
83
  }
84
+
85
+ .hand-strip {
86
+ display: flex;
87
+ gap: 12px;
88
+ overflow-x: auto;
89
+ padding: 10px 10px 14px;
90
+ scroll-snap-type: x mandatory;
91
+ -webkit-overflow-scrolling: touch;
92
+ background: rgba(13, 37, 63, 0.03);
93
+ border: var(--panel-border);
94
+ border-radius: 18px;
95
+ }
96
+
97
+ .hand-card {
98
+ flex: 0 0 240px;
99
+ border-radius: 16px;
100
+ border: var(--panel-border);
101
+ box-shadow: var(--shadow-soft);
102
+ background: linear-gradient(180deg, #ffffff, #f8fbff);
103
+ padding: 10px 12px;
104
+ min-height: 150px;
105
+ scroll-snap-align: start;
106
+ transform: rotate(var(--angle)) translateY(var(--lift));
107
+ transform-origin: bottom center;
108
+ transition: transform 140ms ease, box-shadow 140ms ease;
109
+ position: relative;
110
+ z-index: var(--z);
111
+ }
112
+
113
+ .hand-card:hover {
114
+ transform: translateY(-12px) rotate(0deg) scale(1.02);
115
+ box-shadow: 0 18px 50px rgba(14, 35, 62, 0.18);
116
+ }
117
+
118
+ .hand-card__top {
119
+ display: flex;
120
+ align-items: center;
121
+ justify-content: space-between;
122
+ gap: 10px;
123
+ margin-bottom: 8px;
124
+ font-size: 12px;
125
+ color: #4c6378;
126
+ }
127
+
128
+ .card-index {
129
+ font-weight: 700;
130
+ letter-spacing: 0.02em;
131
+ }
132
+
133
+ .card-badge {
134
+ background: #f0a35f;
135
+ color: #0d253f;
136
+ padding: 2px 9px;
137
+ border-radius: 999px;
138
+ font-weight: 800;
139
+ font-size: 11px;
140
+ letter-spacing: 0.02em;
141
+ }
142
+
143
+ .hand-card__body {
144
+ font-size: 14px;
145
+ line-height: 1.35;
146
+ color: #0d253f;
147
+ white-space: pre-wrap;
148
+ }
149
+
150
+ .hand-empty {
151
+ border: 1px dashed #dbe7f3;
152
+ border-radius: 16px;
153
+ padding: 16px 14px;
154
+ background: #fbfdff;
155
+ }
156
+
157
+ .hand-empty__title {
158
+ font-weight: 800;
159
+ color: #0d253f;
160
+ margin-bottom: 4px;
161
+ }
162
+
163
+ .hand-empty__hint {
164
+ font-size: 14px;
165
+ color: #4c6378;
166
+ }
167
  """
168
 
169
  EXAMPLE_CHARACTER_PATH = Path(__file__).with_name("ExampleChar.txt")
170
  DEFAULT_CHARACTER_JSON = EXAMPLE_CHARACTER_PATH.read_text(encoding="utf-8")
171
  DEFAULT_CHARACTER = Character.model_validate_json(DEFAULT_CHARACTER_JSON)
172
 
173
+ DECK_PATH = Path(__file__).with_name("tacklebox_deck.json")
174
+ DECK_DATA = json.loads(DECK_PATH.read_text(encoding="utf-8"))
175
+
176
+ ChatHistory = List[Tuple[str, str]]
177
 
178
+
179
+ def fresh_deck() -> TackleboxDeck:
180
+ return TackleboxDeck(**copy.deepcopy(DECK_DATA))
181
+
182
+
183
+ def ensure_session_state(
184
+ deck_state: Optional[TackleboxDeck],
185
+ player_hand_state: Optional[Hand],
186
+ ai_hand_state: Optional[Hand],
187
+ ) -> Tuple[TackleboxDeck, Hand, Hand]:
188
+ deck = deck_state if isinstance(deck_state, TackleboxDeck) else fresh_deck()
189
+ player_hand = player_hand_state if isinstance(player_hand_state, Hand) else Hand()
190
+ ai_hand = ai_hand_state if isinstance(ai_hand_state, Hand) else Hand()
191
+ return deck, player_hand, ai_hand
192
 
193
  def get_openai_client(api_key: Optional[str]) -> OpenAI:
194
  """Return an OpenAI client using UI input or environment configuration."""
 
198
  return OpenAI(api_key=key)
199
 
200
 
201
+ def normalize_history(history: Optional[List[Any]]) -> ChatHistory:
202
+ """Coerce Gradio chat history into a predictable list of string pairs."""
203
+ normalized: ChatHistory = []
204
+ for turn in history or []:
205
+ if not isinstance(turn, (list, tuple)) or len(turn) != 2:
206
+ continue
207
+ user_turn, assistant_turn = turn
208
+ normalized.append((str(user_turn or ""), str(assistant_turn or "")))
209
+ return normalized
210
+
211
+
212
+ def render_hand_html(hand: Hand) -> str:
213
+ """Render a hand as card-like HTML instead of a textbox."""
214
  if not hand.cards:
215
+ return (
216
+ "<div class='hand-empty'>"
217
+ "<div class='hand-empty__title'>Your hand is empty.</div>"
218
+ "<div class='hand-empty__hint'>Grab something from the tackle box to start.</div>"
219
+ "</div>"
220
+ )
221
+
222
+ cards = []
223
+ count = len(hand.cards)
224
+ max_angle = 8
225
+ for index, card in enumerate(hand.cards):
226
+ t = 0.0 if count == 1 else (index / (count - 1)) * 2 - 1 # -1..1
227
+ angle = t * max_angle
228
+ lift = abs(t) * 6
229
+ shared_badge = (
230
+ "<span class='card-badge' title='This card is meant to be shared'>Shared</span>"
231
+ if getattr(card, "share_with_other_player", False)
232
+ else ""
233
+ )
234
+ description = html.escape(getattr(card, "description", ""))
235
+ cards.append(
236
+ (
237
+ "<div class='hand-card' "
238
+ f"style='--angle:{angle:.2f}deg; --lift:{lift:.1f}px; --z:{index};'>"
239
+ "<div class='hand-card__top'>"
240
+ f"<span class='card-index'>Card {index + 1}</span>"
241
+ f"{shared_badge}"
242
+ "</div>"
243
+ f"<div class='hand-card__body'>{description}</div>"
244
+ "</div>"
245
+ )
246
+ )
247
+ return "<div class='hand-strip'>" + "".join(cards) + "</div>"
248
 
249
 
250
+ def hand_status_md(deck: TackleboxDeck, player_hand: Hand) -> str:
251
+ remaining = len(deck.cards)
252
+ in_hand = len(player_hand.cards)
253
+ if remaining == 0:
254
+ return f"**Tackle box:** empty • **Your hand:** {in_hand}"
255
+ return f"**Tackle box:** {remaining} cards left • **Your hand:** {in_hand}"
256
+
257
+
258
+ def format_ai_hand_debug(ai_hand: Hand) -> str:
259
  """Readable snapshot of the AI hand for debugging."""
260
  if not ai_hand.cards:
261
  return "AI hand is empty."
 
419
  ).strip()
420
 
421
 
422
+ def append_ai_hand_to_prompt(base_prompt: str, ai_hand: Hand) -> str:
423
  """Add AI hand context to the persona prompt so the model sees its cards."""
424
  if not ai_hand.cards:
425
  return base_prompt
 
437
  history: ChatHistory,
438
  persona_prompt: str,
439
  api_key: Optional[str],
440
+ deck: TackleboxDeck,
441
+ player_hand: Hand,
442
+ ai_hand: Hand,
443
  ) -> str:
444
  """Call OpenAI with the parsed prompt and tool support (AI can draw cards)."""
445
  client = get_openai_client(api_key)
446
+ base_prompt = append_ai_hand_to_prompt(persona_prompt, ai_hand)
447
+ messages: List[Dict[str, Any]] = [{"role": "system", "content": base_prompt}]
448
 
449
  for user_turn, assistant_turn in history:
450
+ if user_turn:
451
+ messages.append({"role": "user", "content": user_turn})
452
  if assistant_turn:
453
  messages.append({"role": "assistant", "content": assistant_turn})
454
 
 
463
  "type": "function",
464
  "function": {
465
  "name": "open_tacklebox",
466
+ "description": "Call this to draw a new event card when the conversation stalls, feels repetitive, or the user is giving short replies over and over. In particular, if the user gives one- to three-word answers like 'yeah', 'true', 'ha', 'i guess', 'maybe', or 'idk' for two turns in a row, you MUST call this tool once before responding. Use at most once per user message.",
467
  "parameters": {
468
  "type": "object",
469
  "properties": {},
 
490
  tool_messages: List[Dict[str, str]] = []
491
  ai_draw_notice = None
492
  shared_with_player_notice = None
493
+ ai_drew_this_turn = False
494
  for call in message.tool_calls:
495
+ if call.function.name != "open_tacklebox":
496
+ tool_messages.append(
497
+ {
498
+ "role": "tool",
499
+ "tool_call_id": call.id,
500
+ "content": json.dumps({"error": f"Unknown tool: {call.function.name}"}),
501
+ }
502
+ )
503
+ continue
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
504
 
505
+ if ai_drew_this_turn:
506
  tool_messages.append(
507
  {
508
  "role": "tool",
509
  "tool_call_id": call.id,
510
+ "content": json.dumps(
511
+ {
512
+ "skipped": True,
513
+ "reason": "Only one draw is allowed per turn.",
514
+ "hand_size": len(ai_hand.cards),
515
+ }
516
+ ),
517
  }
518
  )
519
+ continue
520
+
521
+ ai_drew_this_turn = True
522
+ card = deck.draw_card()
523
+ if card:
524
+ ai_hand.add_card(card)
525
+ shared_with_player = False
526
+ if card.share_with_other_player:
527
+ player_hand.add_card(card)
528
+ shared_with_player = True
529
+
530
+ ai_draw_notice = "*They carefully grabbed something out of the tacklebox.*"
531
+ if shared_with_player:
532
+ shared_with_player_notice = (
533
+ "*They hand you a card meant to be shared. It was added to your hand:*\n"
534
+ f"- {card.description}"
535
+ )
536
 
537
+ tool_content = json.dumps(
538
+ {
539
+ "card_drawn": card.description,
540
+ "hand_size": len(ai_hand.cards),
541
+ "shared_with_player": shared_with_player,
542
+ }
543
+ )
544
+ else:
545
+ ai_draw_notice = "*They reached for the tacklebox, but it was empty.*"
546
+ tool_content = json.dumps({"card_drawn": None, "hand_size": len(ai_hand.cards)})
547
+
548
+ tool_messages.append(
549
+ {
550
+ "role": "tool",
551
+ "tool_call_id": call.id,
552
+ "content": tool_content,
553
+ }
554
+ )
555
+
556
+ try:
557
+ assistant_message = message.model_dump(exclude_none=True)
558
+ except AttributeError:
559
+ assistant_message = message.dict(exclude_none=True) # type: ignore[attr-defined]
560
+ assistant_message.setdefault("role", "assistant")
561
+
562
+ followup_messages: List[Dict[str, Any]] = messages + [assistant_message] + tool_messages
563
+ followup_messages[0] = {
564
+ "role": "system",
565
+ "content": append_ai_hand_to_prompt(persona_prompt, ai_hand),
566
+ }
567
 
568
  followup_response = client.chat.completions.create(
569
  model=MODEL,
 
580
 
581
  def orchestrate_chat(
582
  message: str,
583
+ history: Optional[List[Any]],
584
  persona_prompt: str,
585
  api_key: Optional[str],
586
+ deck_state: Optional[TackleboxDeck],
587
+ player_hand_state: Optional[Hand],
588
+ ai_hand_state: Optional[Hand],
589
+ ) -> Tuple[str, str, str, TackleboxDeck, Hand, Hand]:
590
  """
591
  Main orchestration entry point.
592
 
 
594
  - run (optional) tools
595
  - call the model with full context and persona prompt
596
  """
597
+ deck, player_hand, ai_hand = ensure_session_state(deck_state, player_hand_state, ai_hand_state)
598
+ safe_history = normalize_history(history)
599
  context: Dict = {"history": safe_history}
600
 
601
+ if not (message or "").strip():
602
+ return (
603
+ "Say something as Robin to start the scene.",
604
+ render_hand_html(player_hand),
605
+ hand_status_md(deck, player_hand),
606
+ deck,
607
+ player_hand,
608
+ ai_hand,
609
+ )
610
+
611
  parsed = parse_user_message(message, context)
612
  tool_results = maybe_run_tools(parsed, context)
613
  try:
614
+ reply = generate_model_reply(
615
+ parsed,
616
+ tool_results,
617
+ safe_history,
618
+ persona_prompt,
619
+ api_key,
620
+ deck,
621
+ player_hand,
622
+ ai_hand,
623
+ )
624
+ return (
625
+ reply,
626
+ render_hand_html(player_hand),
627
+ hand_status_md(deck, player_hand),
628
+ deck,
629
+ player_hand,
630
+ ai_hand,
631
+ )
632
  except Exception as exc: # noqa: BLE001
633
+ return (
634
+ f"⚠️ {exc}",
635
+ render_hand_html(player_hand),
636
+ hand_status_md(deck, player_hand),
637
+ deck,
638
+ player_hand,
639
+ ai_hand,
640
+ )
641
 
642
 
643
  def update_player_two_from_form(
 
695
 
696
 
697
  def grab_card_for_player(
698
+ history: Optional[List[Any]],
699
+ deck_state: Optional[TackleboxDeck],
700
+ player_hand_state: Optional[Hand],
701
+ ai_hand_state: Optional[Hand],
702
+ ) -> Tuple[str, str, ChatHistory, TackleboxDeck, Hand, Hand]:
703
  """
704
  Draw a card from the tackle box into the player's hand and log it to chat.
705
 
706
  Returns: (formatted_hand, status_message, updated_chat_history)
707
  """
708
+ deck, player_hand, ai_hand = ensure_session_state(deck_state, player_hand_state, ai_hand_state)
709
+ chat_history = normalize_history(history)
710
  card = deck.draw_card()
711
 
712
  if not card:
713
+ status = hand_status_md(deck, player_hand) + " • **No more cards** in the tackle box."
714
  notice = "*You reached for the tacklebox, but it was empty.*"
715
+ chat_history.append((notice, ""))
716
+ return render_hand_html(player_hand), status, chat_history, deck, player_hand, ai_hand
717
 
718
  player_hand.add_card(card)
719
  shared_with_ai_notice = None
 
723
  "*This card says to share; it was added to Player 2's hand for their next turn.*"
724
  )
725
 
726
+ status = hand_status_md(deck, player_hand)
727
  if shared_with_ai_notice:
728
+ status += " Shared with Player 2."
729
 
730
+ notice_lines = ["*You carefully grabbed something out of the tacklebox.*"]
731
  if shared_with_ai_notice:
732
  notice_lines.append(shared_with_ai_notice)
733
 
734
  notice = "\n".join(notice_lines)
735
+ chat_history.append((notice, ""))
736
+ return render_hand_html(player_hand), status, chat_history, deck, player_hand, ai_hand
737
 
738
 
739
+ def show_ai_hand_debug(ai_hand_state: Optional[Hand]) -> str:
740
  """Expose AI hand for debugging."""
741
+ ai_hand = ai_hand_state or Hand()
742
+ return format_ai_hand_debug(ai_hand)
743
+
744
+
745
+ def reset_session() -> Tuple[str, str, ChatHistory, TackleboxDeck, Hand, Hand]:
746
+ hand_html, status, chat_history, deck, player_hand, ai_hand = init_session()
747
+ return hand_html, status + " • Reset.", chat_history, deck, player_hand, ai_hand
748
+
749
+
750
+ def init_session() -> Tuple[str, str, ChatHistory, TackleboxDeck, Hand, Hand]:
751
+ deck = fresh_deck()
752
+ player_hand = Hand()
753
+ ai_hand = Hand()
754
+ return (
755
+ render_hand_html(player_hand),
756
+ hand_status_md(deck, player_hand),
757
+ [],
758
+ deck,
759
+ player_hand,
760
+ ai_hand,
761
+ )
762
 
763
 
764
  DEFAULT_PERSONA_PROMPT = build_persona_prompt(DEFAULT_CHARACTER)
 
778
 
779
  persona_prompt_state = gr.State(DEFAULT_PERSONA_PROMPT)
780
  api_key_state = gr.State(ENV_API_KEY or "")
781
+ deck_state = gr.State(None)
782
+ player_hand_state = gr.State(None)
783
+ ai_hand_state = gr.State(None)
784
 
785
  with gr.Row(equal_height=True):
786
  with gr.Column(scale=6, min_width=380, elem_id="controls-col"):
 
876
 
877
  with gr.Column(scale=7, min_width=460, elem_id="chat-col"):
878
  with gr.Group(elem_classes=["section-card"]):
879
+ gr.Markdown("#### How to play (quick start)")
880
+ gr.Markdown(
881
+ """
882
+ - You are **Robin**. The chatbot plays **Player 2** in the boat with you.
883
+ - (Optional) Customize Player 2 on the left, then click **Apply Player 2**.
884
+ - Keep the **Truth**: out loud, it’s just two people fishing.
885
+ - Click **Grab something from the tackle box** to draw a private prompt into your hand.
886
+ - Let card prompts change your *subtext* and choices — don’t read them aloud.
887
+ - Cards marked **Shared** are also added to Player 2’s hand.
888
+ - Use **Reset session** to start over with a fresh deck.
889
+ - There’s no score — stop whenever you want, or when the deck runs out.
890
+ - Player 2 may sometimes “grab from the tackle box” too.
891
+ - Pause/rewind anytime if the content gets too intense.
892
+ """
893
  )
894
+
895
+ with gr.Group(elem_classes=["section-card"]):
896
+ gr.Markdown("#### Your hand (private) & tackle box")
897
+ hand_status = gr.Markdown("Grab a card to begin.")
898
+ gr.Markdown(
899
+ "<span class='subtle'>Tip: keep card text private and let it influence what you say next.</span>"
900
+ )
901
+ hand_view = gr.HTML(render_hand_html(Hand()))
902
+ with gr.Row():
903
+ draw_btn = gr.Button("Grab something from the tackle box", variant="secondary")
904
+ reset_btn = gr.Button("Reset session", variant="secondary")
905
 
906
  with gr.Group(elem_classes=["section-card"]):
907
  chat = gr.ChatInterface(
908
  orchestrate_chat,
909
+ additional_inputs=[
910
+ persona_prompt_state,
911
+ api_key_state,
912
+ deck_state,
913
+ player_hand_state,
914
+ ai_hand_state,
915
+ ],
916
+ additional_outputs=[
917
+ hand_view,
918
+ hand_status,
919
+ deck_state,
920
+ player_hand_state,
921
+ ai_hand_state,
922
+ ],
923
  title="Tacklebox Chat",
924
  description="Robin chats with Player 2 (LLM persona).",
925
  examples=[
 
953
 
954
  draw_btn.click(
955
  grab_card_for_player,
956
+ inputs=[chat.chatbot_value, deck_state, player_hand_state, ai_hand_state],
957
+ outputs=[
958
+ hand_view,
959
+ hand_status,
960
+ chat.chatbot_value,
961
+ deck_state,
962
+ player_hand_state,
963
+ ai_hand_state,
964
+ ],
965
+ )
966
+
967
+ reset_btn.click(
968
+ reset_session,
969
+ outputs=[hand_view, hand_status, chat.chatbot_value, deck_state, player_hand_state, ai_hand_state],
970
+ )
971
+
972
+ demo.load(
973
+ init_session,
974
+ outputs=[hand_view, hand_status, chat.chatbot_value, deck_state, player_hand_state, ai_hand_state],
975
  )
976
 
977