File size: 22,575 Bytes
4ae4ae8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
"""REFRAME β€” Live cognitive restructuring studio.

Main Gradio 6 application. Dual-panel: chat + live thought card.
"""

# ZeroGPU: `spaces` MUST be imported before torch (it patches torch for GPU
# scheduling). On a ZeroGPU Space the real package is present; locally it isn't,
# so we fall back to a no-op shim and every Space branch becomes dead code.
try:
    import spaces  # noqa: E402  (intentionally first β€” before torch)
except ImportError:
    class _SpacesShim:
        @staticmethod
        def GPU(*args, **kwargs):
            # Support both @spaces.GPU and @spaces.GPU(duration=...)
            if len(args) == 1 and callable(args[0]) and not kwargs:
                return args[0]
            def _decorator(fn):
                return fn
            return _decorator

    spaces = _SpacesShim()

import os
import warnings
from pathlib import Path

import gradio as gr

import config
import inference
from atmosphere import detect_emotion
from card_engine import ActiveCard, CardState, reset_card, should_show_card, update_card
from components.card_deck import render_deck
from components.thought_card import render_card, render_empty_card, render_intro_card
from crisis import detect_crisis, get_crisis_banner_html, get_crisis_response
from patterns import get_patterns_html
from prompts import build_system_prompt, get_greeting
from session import (
    Experiment,
    SessionState,
    ThoughtCard,
    deserialize_session,
    get_session_context,
    save_card,
    save_experiment,
    serialize_session,
    start_new_session,
)

# --- Optional STT import (fail-safe) ---
try:
    import stt
    _stt_available = config.STT_ENABLED and stt.is_available()
except ImportError:
    _stt_available = False

# Silence a noisy (harmless) third-party deprecation: Gradio 6.18 uses Starlette's
# old HTTP_422_UNPROCESSABLE_ENTITY constant (renamed to *_CONTENT). Fires on every
# request. Remove once Gradio ships a fix.
warnings.filterwarnings("ignore", message=r".*HTTP_422_UNPROCESSABLE_ENTITY.*")

# Load CSS
CSS_PATH = Path(__file__).parent / "static" / "theme.css"
CUSTOM_CSS = CSS_PATH.read_text() if CSS_PATH.exists() else ""


def _model_badges_html() -> str:
    """Header status pills β€” [label] Β· [exact id], ordered Modal β†’ Gemma β†’ Cohere."""
    stt_lower = config.STT_MODEL.lower()
    if "cohere" in stt_lower:
        stt_name, stt_cls = "Cohere", "model-badge badge-cohere"
    elif "whisper" in stt_lower:
        stt_name, stt_cls = "Whisper", "model-badge"
    else:
        stt_name, stt_cls = "STT", "model-badge"
    return f"""
    <div class="model-badges">
      <span class="model-badge badge-modal" title="Training platform Β· single GPU">
        <span class="badge-dot"></span>Modal Β· NVIDIA H100 80GB
      </span>
      <span class="model-badge" title="Runtime: {config.OLLAMA_MODEL} (Ollama / llama.cpp)">
        <span class="badge-dot"></span>Gemma 4 12B Β· google/gemma-4-12B-it Β· QLoRA fine-tuned
      </span>
      <span class="{stt_cls}" title="Speech-to-text (active)">
        <span class="badge-dot"></span>{stt_name} Β· {config.STT_MODEL}
      </span>
    </div>
    """


def _models_card_html() -> str:
    """Tech-stack / models showcase for the right pane (prize transparency)."""
    return """
    <div class="stack-card">
      <div class="stack-summary">
        A fine-tuned Gemma model that helps you reframe thoughts β€” trained in the cloud, runs locally.
      </div>
      <div class="stack-line"><b>Gemma 4 12B</b> β€” Google's open LLM, fine-tuned on mental-health counseling data</div>
      <div class="stack-line"><b>QLoRA + unsloth</b> β€” efficient, low-cost fine-tuning</div>
      <div class="stack-line"><b>Modal Β· H100 80GB</b> β€” cloud GPU the training ran on</div>
      <div class="stack-line"><b>GGUF Q4_K_M</b> β€” compact, quantized model file</div>
      <div class="stack-line"><b>llama.cpp</b> β€” runs the model locally, low latency</div>
      <div class="stack-line"><b>Ollama</b> β€” serves llama.cpp as a local API</div>
      <div class="stack-line"><b>Cohere Transcribe</b> β€” turns your voice into text</div>
      <div class="stack-line"><b>Gradio</b> β€” the app's web interface</div>
      <div class="stack-line"><b>Fine-tuning datasets</b> β€” mental-health counseling, empathetic dialogue &amp; crisis responses
        <ul class="stack-list">
          <li><a href="https://huggingface.co/datasets/ShenLab/MentalChat16K" target="_blank">MentalChat16K</a></li>
          <li><a href="https://huggingface.co/datasets/Amod/mental_health_counseling_conversations" target="_blank">Mental Health Counseling Conversations</a></li>
          <li><a href="https://huggingface.co/datasets/nbertagnolli/counsel-chat" target="_blank">CounselChat</a></li>
          <li><a href="https://huggingface.co/datasets/Estwld/empathetic_dialogues_llm" target="_blank">EmpatheticDialogues</a></li>
          <li><a href="https://huggingface.co/datasets/arnaiztech/llms-mental-health-crisis-responses" target="_blank">Mental-Health Crisis Responses</a></li>
        </ul>
      </div>
    </div>
    """

# --- Status animation HTML (wave for STT, dots for thinking) ---
WAVE_HTML = """
<div style="display:flex;align-items:center;gap:8px;padding:8px 12px;
            background:#1a1a2e;border-radius:8px;border:1px solid #2a2a4a;">
  <div style="display:flex;gap:3px;align-items:end;height:20px;">
    <span style="width:3px;background:#a78bfa;border-radius:2px;
                 animation:wave 0.8s ease-in-out infinite;height:8px;"></span>
    <span style="width:3px;background:#a78bfa;border-radius:2px;
                 animation:wave 0.8s ease-in-out 0.1s infinite;height:14px;"></span>
    <span style="width:3px;background:#a78bfa;border-radius:2px;
                 animation:wave 0.8s ease-in-out 0.2s infinite;height:20px;"></span>
    <span style="width:3px;background:#a78bfa;border-radius:2px;
                 animation:wave 0.8s ease-in-out 0.3s infinite;height:14px;"></span>
    <span style="width:3px;background:#a78bfa;border-radius:2px;
                 animation:wave 0.8s ease-in-out 0.4s infinite;height:8px;"></span>
  </div>
  <span style="color:#c4b5fd;font-size:0.85rem;">Transcribing your voice...</span>
</div>
<style>
@keyframes wave{0%,100%{transform:scaleY(0.4)}50%{transform:scaleY(1)}}
</style>
"""

DOTS_HTML = """
<div style="display:flex;align-items:center;gap:8px;padding:8px 12px;
            background:#1a1a2e;border-radius:8px;border:1px solid #2a2a4a;">
  <div style="display:flex;gap:4px;">
    <span style="width:6px;height:6px;background:#8899aa;border-radius:50%;
                 animation:dots 1.2s ease-in-out infinite;"></span>
    <span style="width:6px;height:6px;background:#8899aa;border-radius:50%;
                 animation:dots 1.2s ease-in-out 0.2s infinite;"></span>
    <span style="width:6px;height:6px;background:#8899aa;border-radius:50%;
                 animation:dots 1.2s ease-in-out 0.4s infinite;"></span>
  </div>
  <span style="color:#8899aa;font-size:0.85rem;">Thinking...</span>
</div>
<style>
@keyframes dots{0%,100%{opacity:0.3;transform:scale(0.8)}50%{opacity:1;transform:scale(1.2)}}
</style>
"""


def _show_wave():
    return gr.update(value=WAVE_HTML, visible=True)


def _show_dots():
    return gr.update(value=DOTS_HTML, visible=True)


def _hide_status():
    return gr.update(value="", visible=False)


@spaces.GPU(duration=120)
def _transcribe_and_clear(audio_filepath):
    """Transcribe audio, return (text_for_input, None_to_clear_audio).

    On a ZeroGPU Space this runs in a GPU-allocated fork (Cohere on CUDA);
    locally @spaces.GPU is a no-op shim and STT runs on XPU/CPU.
    """
    if not audio_filepath or not _stt_available:
        return "Couldn't hear that β€” try typing instead.", None
    text = stt.transcribe(audio_filepath)
    if not text:
        return "Couldn't hear that β€” try typing instead.", None
    return text, None


def respond(
    history: list[dict],
    session_json: str,
    card_state_json: str,
):
    """Main chat response handler with streaming.

    Yields: (history, card_html, atmosphere_class, crisis_html, session_json, card_state_json)
    """
    # Get the user message from the last history entry (already added by user_submit)
    if not history:
        yield history, render_empty_card(), "atmosphere-neutral", "", session_json, card_state_json
        return

    last_entry = history[-1]
    # Handle both dict format and possible list/tuple format
    if isinstance(last_entry, dict):
        raw_content = last_entry.get("content", "")
    elif isinstance(last_entry, (list, tuple)):
        raw_content = last_entry[0] if last_entry else ""
    else:
        raw_content = str(last_entry)

    # Content may be a list of part-dicts in Gradio 6, e.g. {"text": ..., "type": "text"}.
    # Extract the text field rather than str()-ing the whole dict.
    if isinstance(raw_content, list):
        parts = []
        for part in raw_content:
            if isinstance(part, dict):
                parts.append(str(part.get("text", "")))
            elif part:
                parts.append(str(part))
        user_message = " ".join(p for p in parts if p).strip()
    else:
        user_message = str(raw_content) if raw_content else ""

    if not user_message.strip():
        yield history, render_empty_card(), "atmosphere-neutral", "", session_json, card_state_json
        return

    # Deserialize state
    session = deserialize_session(session_json)
    active_card = _deserialize_card_state(card_state_json)

    # Build system prompt with session context
    context = get_session_context(session)
    system_prompt = build_system_prompt(context)

    # Check for crisis
    crisis_html = ""
    if detect_crisis(user_message):
        crisis_html = get_crisis_banner_html()

    # Stream model response (history already has user message from user_submit)
    full_response = ""
    for partial in inference.stream_response(user_message, history[:-1], system_prompt):
        full_response = partial

        # Update card engine
        active_card = update_card(active_card, full_response, user_message)
        card_html_val = render_card(active_card) if should_show_card(active_card) else render_empty_card()

        # Detect atmosphere
        atmos = detect_emotion(user_message + " " + full_response)

        # Build current history with streaming response
        current_history = history + [{"role": "assistant", "content": full_response}]

        yield (
            current_history,
            card_html_val,
            atmos,
            crisis_html,
            serialize_session(session),
            _serialize_card_state(active_card),
        )

    # If card completed, save it
    if active_card.state == CardState.COMPLETE:
        session = save_card(session, active_card.card)
        active_card = reset_card()

    # Final yield
    card_html_val = render_card(active_card) if should_show_card(active_card) else render_empty_card()
    final_history = history + [{"role": "assistant", "content": full_response}]
    yield (
        final_history,
        card_html_val,
        detect_emotion(user_message + " " + full_response),
        crisis_html,
        serialize_session(session),
        _serialize_card_state(active_card),
    )


def initialize_session(session_json: str):
    """Called on app load β€” initialize or resume session."""
    session = deserialize_session(session_json)
    session = start_new_session(session)
    greeting = get_greeting(get_session_context(session))
    history = [{"role": "assistant", "content": greeting}]
    return history, serialize_session(session)


def get_deck_html(session_json: str) -> str:
    """Render the card deck from session state."""
    session = deserialize_session(session_json)
    return render_deck(session.cards)


def get_progress_html(session_json: str) -> str:
    """Render the patterns/progress panel."""
    session = deserialize_session(session_json)
    return get_patterns_html(session)


def save_current_card(session_json: str, card_state_json: str):
    """Manually save the current card to deck."""
    session = deserialize_session(session_json)
    active_card = _deserialize_card_state(card_state_json)

    if active_card.state != CardState.IDLE and active_card.card.automatic_thought:
        session = save_card(session, active_card.card)
        active_card = reset_card()

    return (
        serialize_session(session),
        _serialize_card_state(active_card),
        render_empty_card(),
        render_deck(session.cards),
    )


# --- Card state serialization helpers ---

def _serialize_card_state(active: ActiveCard) -> str:
    """Serialize ActiveCard to JSON string."""
    import json
    from dataclasses import asdict
    return json.dumps({
        "state": active.state.value,
        "card": asdict(active.card),
        "turn_count": active.turn_count,
    })


def _deserialize_card_state(data: str | None) -> ActiveCard:
    """Deserialize ActiveCard from JSON string."""
    import json
    if not data:
        return ActiveCard()
    try:
        d = json.loads(data) if isinstance(data, str) else data
        card = ThoughtCard(**d.get("card", {}))
        state = CardState(d.get("state", "idle"))
        return ActiveCard(state=state, card=card, turn_count=d.get("turn_count", 0))
    except (json.JSONDecodeError, TypeError, ValueError):
        return ActiveCard()


# --- Build the Gradio App ---

with gr.Blocks(
    title=config.APP_TITLE,
    fill_height=True,
    fill_width=True,
) as demo:

    # Hidden state components
    session_state = gr.BrowserState("", storage_key="reframe_session")
    card_state = gr.State("")
    atmosphere_state = gr.State("atmosphere-neutral")

    # Top-center credit
    gr.HTML('<div class="app-credit">Created with 🍁 in Canada</div>')

    # Header β€” title, subtitle, then model pills (horizontal, after the string)
    with gr.Row():
        gr.HTML(f"""
            <div class="app-header">
                <div class="app-title">{config.APP_TITLE}</div>
                <div class="app-subtitle-row">
                    <div class="app-subtitle">{config.APP_SUBTITLE}</div>
                    {_model_badges_html()}
                </div>
            </div>
        """)

    # Main layout: Chat (60%) + Card Panel (40%)
    with gr.Row():
        # Left: Chat
        with gr.Column(scale=3):
            chatbot = gr.Chatbot(
                elem_id="reframe-chat",
                height=430,
                show_label=False,
                placeholder="What's on your mind today?",
                buttons=["copy"],
            )
            msg_input = gr.Textbox(
                placeholder="Type here...",
                show_label=False,
                container=False,
                scale=7,
                submit_btn=True,
            )
            # Voice input (only if STT deps available)
            if _stt_available:
                _voice_label = (
                    "πŸŽ™οΈ Speak your thoughts β€” powered by Cohere"
                    if "cohere" in config.STT_MODEL.lower()
                    else "🎀 Or speak your thoughts"
                )
                audio_input = gr.Audio(
                    sources=["microphone"],
                    type="filepath",
                    label=_voice_label,
                    show_label=True,
                )
                voice_status = gr.HTML(value="", visible=False)
            else:
                audio_input = None
                voice_status = None

        # Right: tabs at top (How it works default); live card lives in the Deck tab
        with gr.Column(scale=2, min_width=280):
            # Crisis banner (hidden by default) β€” kept above tabs so it always shows
            crisis_html = gr.HTML(value="")
            with gr.Tabs():
                with gr.Tab("How it works"):
                    gr.HTML(value=render_intro_card())
                with gr.Tab("πŸ› οΈ Stack"):
                    gr.HTML(value=_models_card_html())
                with gr.Tab("Deck"):
                    card_html = gr.HTML(value=render_empty_card())
                    save_btn = gr.Button("✨ Save to Deck", variant="secondary", size="sm")
                    deck_html = gr.HTML(value="")
                with gr.Tab("Patterns"):
                    patterns_html_component = gr.HTML(value="")

    # --- Event Wiring ---

    # Helper to add user msg to chat immediately and clear input
    def user_submit(message, history):
        """Immediately show user message and clear input."""
        if not message or not message.strip():
            return "", history
        return "", history + [{"role": "user", "content": message}]

    # Main chat submit: clear input + show msg instantly, then stream response
    submit_event = msg_input.submit(
        fn=user_submit,
        inputs=[msg_input, chatbot],
        outputs=[msg_input, chatbot],
    ).then(
        fn=lambda: gr.update(interactive=False),
        outputs=[msg_input],
    ).then(
        fn=respond,
        inputs=[chatbot, session_state, card_state],
        outputs=[chatbot, card_html, atmosphere_state, crisis_html, session_state, card_state],
    ).then(
        fn=lambda: gr.update(interactive=True),
        outputs=[msg_input],
    )

    # Voice input: record β†’ transcribe (with wave) β†’ submit β†’ respond (with dots)
    if _stt_available and audio_input is not None:
        audio_input.stop_recording(
            fn=_show_wave,
            outputs=[voice_status],
        ).then(
            fn=_transcribe_and_clear,
            inputs=[audio_input],
            outputs=[msg_input, audio_input],
        ).then(
            fn=user_submit,
            inputs=[msg_input, chatbot],
            outputs=[msg_input, chatbot],
        ).then(
            fn=_show_dots,
            outputs=[voice_status],
        ).then(
            fn=lambda: gr.update(interactive=False),
            outputs=[msg_input],
        ).then(
            fn=respond,
            inputs=[chatbot, session_state, card_state],
            outputs=[chatbot, card_html, atmosphere_state, crisis_html, session_state, card_state],
        ).then(
            fn=lambda: gr.update(interactive=True),
            outputs=[msg_input],
        ).then(
            fn=_hide_status,
            outputs=[voice_status],
        )

    # Save card button
    save_btn.click(
        fn=save_current_card,
        inputs=[session_state, card_state],
        outputs=[session_state, card_state, card_html, deck_html],
    )

    # Refresh deck/patterns when tabs are clicked
    deck_html.change(fn=None)  # placeholder

    # Initialize on load
    demo.load(
        fn=initialize_session,
        inputs=[session_state],
        outputs=[chatbot, session_state],
    )

    # Refresh deck and patterns on session change
    session_state.change(
        fn=get_deck_html,
        inputs=[session_state],
        outputs=[deck_html],
    )
    session_state.change(
        fn=get_progress_html,
        inputs=[session_state],
        outputs=[patterns_html_component],
    )


if __name__ == "__main__":
    # Preload the STT model locally for an instant first response. On a Space we
    # SKIP preload so the app starts immediately (no startup timeout downloading
    # ~4 GB); the model then loads lazily on the first transcription, inside the
    # @spaces.GPU context on ZeroGPU.
    if _stt_available and not os.environ.get("SPACE_ID"):
        stt.preload_model()

    demo.launch(
        server_name="0.0.0.0",
        server_port=7860,
        share=False,
        css=CUSTOM_CSS,
        head="""
<style>
/* Audio device selector β€” prevent truncation */
[data-testid="audio"] select,
[data-testid="audio"] .audio-source-select,
[data-testid="audio"] button[aria-label*="microphone"],
.audio-component select {
    min-width: 180px !important;
    max-width: 250px !important;
}
#reframe-chat .placeholder {
    font-size: 2.5rem !important;
    font-family: 'Comic Sans MS', 'Chalkboard SE', 'Comic Neue', cursive !important;
    color: #a78bfa !important;
    opacity: 1 !important;
    font-weight: 700 !important;
    background: transparent !important;
    border: none !important;
    box-shadow: none !important;
}
#reframe-chat .placeholder * {
    font-size: inherit !important;
    font-family: inherit !important;
    color: inherit !important;
    background: transparent !important;
}
</style>
<script>
// Force-style placeholder after Gradio renders
function stylePlaceholder() {
    const el = document.querySelector('#reframe-chat .placeholder');
    if (el) {
        el.style.fontSize = '2.5rem';
        el.style.fontFamily = "'Comic Sans MS', 'Chalkboard SE', cursive";
        el.style.color = '#a78bfa';
        el.style.opacity = '1';
        el.style.fontWeight = '700';
        el.style.background = 'transparent';
        el.style.border = 'none';
        el.style.boxShadow = 'none';
        el.querySelectorAll('*').forEach(c => {
            c.style.fontSize = 'inherit';
            c.style.fontFamily = 'inherit';
            c.style.color = 'inherit';
            c.style.background = 'transparent';
        });
    }
}
setTimeout(stylePlaceholder, 500);
setTimeout(stylePlaceholder, 1500);
new MutationObserver(stylePlaceholder).observe(document.body, {childList: true, subtree: true});

// Explicitly request microphone permission on first user interaction
let micRequested = false;
function requestMic() {
    if (micRequested) return;
    micRequested = true;
    if (navigator.mediaDevices && navigator.mediaDevices.getUserMedia) {
        navigator.mediaDevices.getUserMedia({ audio: true })
            .then(stream => {
                console.log('Microphone access granted');
                stream.getTracks().forEach(t => t.stop());
            })
            .catch(err => {
                console.warn('Microphone access denied:', err.message);
                const audioEl = document.querySelector('[data-testid="audio"]');
                if (audioEl) {
                    audioEl.style.opacity = '0.5';
                    audioEl.title = 'Microphone blocked β€” check browser permissions';
                }
            });
    }
}
// Trigger on first click anywhere (browsers require user gesture)
document.addEventListener('click', requestMic, { once: true });
// Also try on page load (works if permission was previously granted)
setTimeout(requestMic, 2000);
</script>
""",
    )