Haris-Subrata commited on
Commit
d516f3e
Β·
verified Β·
1 Parent(s): c32e43b

Upload folder using huggingface_hub

Browse files
Files changed (4) hide show
  1. README.md +6 -7
  2. app.py +17 -293
  3. requirements.txt +1 -1
  4. ui.py +328 -0
README.md CHANGED
@@ -6,14 +6,13 @@ sdk: gradio
6
  app_file: app.py
7
  pinned: false
8
  license: apache-2.0
9
- short_description: Teman ADHD-friendly untuk satu langkah kecil berikutnya.
10
  ---
11
 
12
  # NeuroBait
13
 
14
- NeuroBait adalah asisten task-initiation yang ramah ADHD. Tujuannya membantu user
15
- menemukan satu langkah kecil berikutnya tanpa rasa malu, tekanan streak, atau
16
- ceramah produktivitas.
17
 
18
  Target URL:
19
 
@@ -48,6 +47,6 @@ as a Space secret.
48
 
49
  ## UI
50
 
51
- The interface is Indonesian-first and follows the research-informed NeuroBait
52
- design language: earthy palette, anti-shame copy, mood check-in, and a persona
53
- theme picker (usia / zaman / budaya) that adapts colors and feel client-side.
 
6
  app_file: app.py
7
  pinned: false
8
  license: apache-2.0
9
+ short_description: An ADHD-friendly space and gentle boost for your everyday.
10
  ---
11
 
12
  # NeuroBait
13
 
14
+ NeuroBait is an ADHD-friendly companion: a warm space and a gentle boost for your
15
+ everyday, without shame, streak pressure, or a productivity lecture.
 
16
 
17
  Target URL:
18
 
 
47
 
48
  ## UI
49
 
50
+ The interface is English and follows the research-informed NeuroBait design
51
+ language: earthy palette, anti-shame copy, mood check-in, and a persona theme
52
+ picker (age / era / culture) that adapts colors and feel client-side.
app.py CHANGED
@@ -3,7 +3,7 @@
3
  Runtime loads the dense Gemma 3 12B base in 4-bit with the NeuroBait LoRA adapter
4
  through the standard transformers + peft stack. This deliberately avoids Unsloth
5
  at runtime and the Gemma-4 MoE (`Gemma4ClippableLinear`) path that PEFT could not
6
- inject into on ZeroGPU.
7
  """
8
 
9
  from __future__ import annotations
@@ -12,10 +12,11 @@ import os
12
  import re
13
  from threading import Lock
14
 
15
- import gradio as gr
16
  import spaces
17
  import torch
18
 
 
 
19
 
20
  BASE_MODEL = os.environ.get("BASE_MODEL", "unsloth/gemma-3-12b-it")
21
  ADAPTER_ID = os.environ.get("ADAPTER_ID", os.environ.get("MODEL_ID", "build-small-hackathon/NeuroBait"))
@@ -34,10 +35,10 @@ Kalau konteks user belum cukup untuk membuat resep yang personal, ajukan tepat s
34
  Framing selalu menempatkan user sebagai pelaku aktif dengan agency penuh. Bukan guilt, bukan hutang β€” selalu agency. Kalimat pendek. Bahasa hidup. Hangat dan padat. Tidak pernah menghakimi. Tidak pernah ceramah. Membuat hal membosankan jadi tak tertahankan."""
35
 
36
  MOOD_NOTES = {
37
- "Tenang": "Catatan suasana: user sedang tenang. Manfaatkan ketenangan ini untuk hook yang ringan dan menyenangkan.",
38
- "Lelah": "Catatan suasana: user sedang lelah. Buat micro-action sangat kecil dan rendah energi.",
39
- "Cemas": "Catatan suasana: user sedang cemas. Validasi ekstra hangat dulu, turunkan tekanan, micro-action menenangkan.",
40
- "Fokus": "Catatan suasana: user sedang fokus. Boleh langsung ke hook dan satu micro-action yang memanfaatkan momentum.",
41
  }
42
 
43
 
@@ -46,20 +47,6 @@ _tokenizer = None
46
  _load_lock = Lock()
47
 
48
 
49
- def _message_text(content) -> str:
50
- if isinstance(content, str):
51
- return content.strip()
52
- if isinstance(content, list):
53
- parts = []
54
- for item in content:
55
- if isinstance(item, dict):
56
- text = item.get("text")
57
- if isinstance(text, str):
58
- parts.append(text)
59
- return " ".join(part.strip() for part in parts if part.strip()).strip()
60
- return ""
61
-
62
-
63
  def _prewarm() -> None:
64
  """Download weights to the Space cache on CPU so the GPU window stays short."""
65
  try:
@@ -123,7 +110,7 @@ def _history_to_messages(history: list) -> list[dict]:
123
  for item in history:
124
  if isinstance(item, dict):
125
  role = item.get("role")
126
- content = _message_text(item.get("content"))
127
  if role in {"user", "assistant"} and content:
128
  messages.append({"role": role, "content": content})
129
  continue
@@ -143,9 +130,15 @@ def _clean_response(text: str) -> str:
143
 
144
 
145
  @spaces.GPU(duration=120)
146
- def respond(message: str, history: list[dict], mood: str, temperature: float, top_p: float) -> str:
 
 
 
 
 
 
147
  model, tokenizer = _load_model()
148
- message = _message_text(message)
149
 
150
  system = SYSTEM_PROMPT
151
  note = MOOD_NOTES.get(mood)
@@ -183,276 +176,7 @@ if os.environ.get("PREWARM", "1").lower() not in {"0", "false", "no"}:
183
  _prewarm()
184
 
185
 
186
- CSS = """
187
- :root {
188
- --forest: #5a7a5a;
189
- --sage: #7a9a7a;
190
- --moss: #8db88d;
191
- --mint: #b8d4b8;
192
- --cream: #f7f5f0;
193
- --linen: #ede9e1;
194
- --sand: #e2ddd4;
195
- --stone: #a09888;
196
- --charcoal: #3a3a3a;
197
- --amber: #d4a060;
198
- --radius: 20px;
199
- --font-scale: 1;
200
- --brand-grad: linear-gradient(135deg, var(--sage), var(--forest));
201
- }
202
-
203
- /* ===== Persona themes (toggled client-side via data-* on <body>) ===== */
204
- body[data-culture="pesisir"] {
205
- --forest: #3f6d86; --sage: #5f8aa6; --moss: #8db4c8; --mint: #c2dbe8;
206
- --cream: #f3f6f8; --linen: #e6edf1; --sand: #d6e0e6; --amber: #e0a85e;
207
- }
208
- body[data-culture="senja"] {
209
- --forest: #9c5a3c; --sage: #c08457; --moss: #d6a878; --mint: #ecd6bf;
210
- --cream: #faf4ee; --linen: #f0e6db; --sand: #e6d6c5; --amber: #cf7a3f;
211
- }
212
- body[data-age="muda"] { --radius: 26px; --font-scale: 1.0; }
213
- body[data-age="dewasa"] { --radius: 18px; --font-scale: 1.0; }
214
- body[data-age="matang"] { --radius: 12px; --font-scale: 1.12; }
215
- body[data-era="retro"] {
216
- --radius: 8px;
217
- --brand-grad: linear-gradient(135deg, var(--amber), var(--forest));
218
- }
219
- body[data-era="retro"] .nb-shell,
220
- body[data-era="retro"] .gradio-container {
221
- font-family: "Georgia", "Times New Roman", serif;
222
- }
223
-
224
- body, .gradio-container {
225
- background: var(--linen) !important;
226
- color: var(--charcoal);
227
- font-family: Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
228
- font-size: calc(15px * var(--font-scale));
229
- }
230
- .nb-shell { max-width: 1180px; margin: 0 auto; }
231
-
232
- .nb-header {
233
- display: flex; align-items: center; justify-content: space-between;
234
- gap: 16px; padding: 18px 22px; margin: 6px 0 14px;
235
- background: var(--cream); border: 1px solid var(--sand); border-radius: var(--radius);
236
- }
237
- .nb-brand { display: flex; align-items: center; gap: 14px; }
238
- .nb-icon {
239
- width: 46px; height: 46px; border-radius: 14px; background: var(--brand-grad);
240
- display: flex; align-items: center; justify-content: center; font-size: 1.4rem;
241
- box-shadow: 0 4px 12px rgba(90,122,90,0.25);
242
- }
243
- .nb-brand h1 { margin: 0; font-size: 1.5rem; font-weight: 800; line-height: 1.1; color: var(--charcoal); }
244
- .nb-brand span { font-size: .8rem; color: var(--stone); font-weight: 500; }
245
- .nb-mode {
246
- font-size: .76rem; color: var(--forest); background: var(--mint);
247
- padding: 6px 14px; border-radius: 50px; font-weight: 600; white-space: nowrap;
248
- }
249
-
250
- /* persona picker */
251
- .nb-persona {
252
- display: flex; flex-wrap: wrap; align-items: center; gap: 10px 18px;
253
- padding: 12px 18px; margin-bottom: 14px;
254
- background: var(--cream); border: 1px solid var(--sand); border-radius: var(--radius);
255
- font-size: .8rem;
256
- }
257
- .nb-persona .nb-group { display: flex; align-items: center; gap: 8px; }
258
- .nb-persona .nb-label { color: var(--stone); font-weight: 600; }
259
- .nb-chip {
260
- padding: 6px 14px; border-radius: 50px; border: 1.5px solid var(--sand);
261
- background: transparent; color: var(--stone); font-size: .78rem; font-weight: 600;
262
- cursor: pointer; transition: all .15s; font-family: inherit;
263
- }
264
- .nb-chip:hover { border-color: var(--sage); color: var(--forest); }
265
- .nb-chip.active { border-color: var(--sage); background: var(--mint); color: var(--forest); }
266
-
267
- .nb-card {
268
- background: var(--cream); border: 1px solid var(--sand);
269
- border-radius: var(--radius); padding: 20px;
270
- }
271
- .nb-card h2 { font-size: .95rem; margin: 0 0 10px; color: var(--charcoal); }
272
- .nb-card p { color: var(--stone); font-size: .85rem; line-height: 1.55; margin: 0; }
273
- .nb-card .nb-divider { height: 1px; background: var(--sand); margin: 16px 0; }
274
-
275
- /* chatbot */
276
- .nb-chatbot {
277
- border: 1px solid var(--sand) !important;
278
- border-radius: var(--radius) !important;
279
- background: var(--cream) !important;
280
- min-height: 520px;
281
- }
282
- .nb-chatbot .message.user, .nb-chatbot [data-testid="user"] {
283
- background: var(--brand-grad) !important; color: #fff !important;
284
- border: none !important; border-radius: 18px 18px 4px 18px !important;
285
- }
286
- .nb-chatbot .message.bot, .nb-chatbot [data-testid="bot"] {
287
- background: linear-gradient(135deg, #f0f6f0, #e8f0e8) !important;
288
- border: 1px solid var(--mint) !important; color: var(--charcoal) !important;
289
- border-radius: 18px 18px 18px 4px !important;
290
- }
291
- .nb-input textarea {
292
- border-radius: 16px !important; background: var(--cream) !important;
293
- border: 1.5px solid var(--sand) !important;
294
- }
295
- .nb-examples button { border-radius: 14px !important; text-align: left; min-height: 40px; }
296
- button.primary, .nb-send {
297
- background: var(--brand-grad) !important; border: none !important; color: #fff !important;
298
- }
299
-
300
- /* bottom bar */
301
- .nb-bottom {
302
- display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between;
303
- gap: 12px; padding: 14px 20px; margin-top: 14px;
304
- background: var(--cream); border: 1px solid var(--sand); border-radius: var(--radius);
305
- }
306
- .nb-bottom .nb-safe { display: flex; gap: 8px; flex-wrap: wrap; }
307
- .nb-bottom .tag {
308
- font-size: .72rem; padding: 5px 12px; border-radius: 50px;
309
- background: var(--linen); color: var(--stone); font-weight: 500;
310
- }
311
- .nb-bottom .nb-comfort { font-size: .8rem; color: var(--stone); }
312
- .nb-bottom .nb-comfort strong { color: var(--forest); }
313
-
314
- @media (max-width: 900px) {
315
- .nb-layout { grid-template-columns: 1fr !important; }
316
- }
317
- """
318
-
319
- # Client-side persona switching: flip data-* attributes on <body>, no round-trip.
320
- JS = """
321
- () => {
322
- window.nbSet = (group, val, el) => {
323
- document.body.setAttribute('data-' + group, val);
324
- if (el && el.parentElement) {
325
- el.parentElement.querySelectorAll('.nb-chip').forEach(b => b.classList.remove('active'));
326
- el.classList.add('active');
327
- }
328
- };
329
- document.body.setAttribute('data-age', 'dewasa');
330
- document.body.setAttribute('data-era', 'kini');
331
- document.body.setAttribute('data-culture', 'nusantara');
332
- }
333
- """
334
-
335
- PERSONA_HTML = """
336
- <div class="nb-persona nb-shell">
337
- <div class="nb-group">
338
- <span class="nb-label">πŸŽ‚ Usia</span>
339
- <button class="nb-chip" onclick="nbSet('age','muda',this)">Muda</button>
340
- <button class="nb-chip active" onclick="nbSet('age','dewasa',this)">Dewasa</button>
341
- <button class="nb-chip" onclick="nbSet('age','matang',this)">Matang</button>
342
- </div>
343
- <div class="nb-group">
344
- <span class="nb-label">πŸ•°οΈ Zaman</span>
345
- <button class="nb-chip active" onclick="nbSet('era','kini',this)">Kini</button>
346
- <button class="nb-chip" onclick="nbSet('era','retro',this)">Retro</button>
347
- </div>
348
- <div class="nb-group">
349
- <span class="nb-label">🌏 Budaya</span>
350
- <button class="nb-chip active" onclick="nbSet('culture','nusantara',this)">Nusantara</button>
351
- <button class="nb-chip" onclick="nbSet('culture','pesisir',this)">Pesisir</button>
352
- <button class="nb-chip" onclick="nbSet('culture','senja',this)">Senja</button>
353
- </div>
354
- </div>
355
- """
356
-
357
- HEADER_HTML = """
358
- <div class="nb-header nb-shell">
359
- <div class="nb-brand">
360
- <div class="nb-icon">🧠</div>
361
- <div>
362
- <h1>NeuroBait</h1>
363
- <span>Teman untuk satu langkah kecil berikutnya</span>
364
- </div>
365
- </div>
366
- <div class="nb-mode">🟒 Mode tanpa menghakimi aktif</div>
367
- </div>
368
- """
369
-
370
- SIDE_HTML = """
371
- <div class="nb-card">
372
- <h2>Cara kerja NeuroBait</h2>
373
- <p>Tidak ada streak, rasa bersalah, atau teater produktivitas. NeuroBait mencari satu pemicu emosional, lalu mengubahnya jadi satu langkah super kecil yang bisa kamu mulai sekarang.</p>
374
- <div class="nb-divider"></div>
375
- <h2>Pilih tema sesuai dirimu</h2>
376
- <p>Atur usia, zaman, dan budaya di bar atas. Warna dan nuansa menyesuaikan supaya terasa lebih kamu β€” tanpa mengubah cara NeuroBait menjagamu.</p>
377
- </div>
378
- """
379
-
380
- BOTTOM_HTML = """
381
- <div class="nb-bottom nb-shell">
382
- <div class="nb-comfort">Desain: <strong>Trauma-informed</strong> Β· <strong>Anti-shame</strong> Β· <strong>Sensory-safe</strong></div>
383
- <div class="nb-safe">
384
- <span class="tag">🚫 Tanpa tinta merah</span>
385
- <span class="tag">🚫 Tanpa desakan</span>
386
- <span class="tag">🚫 Tanpa streak</span>
387
- <span class="tag">βœ… Bisa disesuaikan</span>
388
- </div>
389
- </div>
390
- """
391
-
392
-
393
- with gr.Blocks(css=CSS, js=JS, theme=gr.themes.Soft(primary_hue="green", neutral_hue="stone")) as demo:
394
- gr.HTML(HEADER_HTML)
395
- gr.HTML(PERSONA_HTML)
396
-
397
- with gr.Row(elem_classes=["nb-shell", "nb-layout"]):
398
- with gr.Column(scale=3):
399
- chatbot = gr.Chatbot(
400
- type="messages",
401
- height=520,
402
- show_label=False,
403
- elem_classes=["nb-chatbot"],
404
- avatar_images=(None, None),
405
- )
406
- message = gr.Textbox(
407
- placeholder="Ceritakan apa yang ada di kepalamu... (nggak perlu rapi, yang penting jujur)",
408
- lines=2,
409
- max_lines=6,
410
- show_label=False,
411
- elem_classes=["nb-input"],
412
- )
413
- with gr.Row():
414
- submit = gr.Button("Kirim", variant="primary", elem_classes=["nb-send"])
415
- clear = gr.Button("Mulai baru")
416
- gr.Examples(
417
- label="Mau mulai dari mana?",
418
- examples=[
419
- "Aku harus revisi proposal malam ini tapi dari tadi cuma buka tutup dokumen.",
420
- "Kamarku berantakan banget dan aku malu sendiri lihatnya.",
421
- "Aku stuck banget, nggak tahu mulai dari mana.",
422
- ],
423
- inputs=message,
424
- elem_id="nb-examples",
425
- )
426
- with gr.Column(scale=1, min_width=260):
427
- mood = gr.Radio(
428
- choices=["Tenang", "Lelah", "Cemas", "Fokus"],
429
- value="Tenang",
430
- label="🌈 Bagaimana perasaanmu?",
431
- )
432
- gr.HTML(SIDE_HTML)
433
- with gr.Accordion("Pengaturan respons", open=False):
434
- temperature = gr.Slider(0.2, 1.0, value=0.7, step=0.05, label="Temperature")
435
- top_p = gr.Slider(0.6, 1.0, value=0.9, step=0.05, label="Top p")
436
-
437
- gr.HTML(BOTTOM_HTML)
438
-
439
- def user_turn(user_message: str, chat_history: list) -> tuple[str, list]:
440
- if not user_message.strip():
441
- return "", chat_history
442
- return "", chat_history + [{"role": "user", "content": user_message.strip()}]
443
-
444
- def bot_turn(chat_history: list, mood_value: str, temperature_value: float, top_p_value: float) -> list:
445
- user_message = _message_text(chat_history[-1]["content"])
446
- assistant_message = respond(user_message, chat_history[:-1], mood_value, temperature_value, top_p_value)
447
- return chat_history + [{"role": "assistant", "content": assistant_message}]
448
-
449
- submit.click(user_turn, [message, chatbot], [message, chatbot], queue=False).then(
450
- bot_turn, [chatbot, mood, temperature, top_p], chatbot
451
- )
452
- message.submit(user_turn, [message, chatbot], [message, chatbot], queue=False).then(
453
- bot_turn, [chatbot, mood, temperature, top_p], chatbot
454
- )
455
- clear.click(lambda: [], outputs=chatbot, queue=False)
456
 
457
 
458
  if __name__ == "__main__":
 
3
  Runtime loads the dense Gemma 3 12B base in 4-bit with the NeuroBait LoRA adapter
4
  through the standard transformers + peft stack. This deliberately avoids Unsloth
5
  at runtime and the Gemma-4 MoE (`Gemma4ClippableLinear`) path that PEFT could not
6
+ inject into on ZeroGPU. The look and feel live in ``ui.py``.
7
  """
8
 
9
  from __future__ import annotations
 
12
  import re
13
  from threading import Lock
14
 
 
15
  import spaces
16
  import torch
17
 
18
+ from ui import build_demo, message_text
19
+
20
 
21
  BASE_MODEL = os.environ.get("BASE_MODEL", "unsloth/gemma-3-12b-it")
22
  ADAPTER_ID = os.environ.get("ADAPTER_ID", os.environ.get("MODEL_ID", "build-small-hackathon/NeuroBait"))
 
35
  Framing selalu menempatkan user sebagai pelaku aktif dengan agency penuh. Bukan guilt, bukan hutang β€” selalu agency. Kalimat pendek. Bahasa hidup. Hangat dan padat. Tidak pernah menghakimi. Tidak pernah ceramah. Membuat hal membosankan jadi tak tertahankan."""
36
 
37
  MOOD_NOTES = {
38
+ "Calm": "Mood note: the user feels calm. Use this ease for a light, playful hook.",
39
+ "Tired": "Mood note: the user feels tired. Keep the micro-action very small and low energy.",
40
+ "Anxious": "Mood note: the user feels anxious. Lead with extra warm validation, lower the pressure, keep the micro-action soothing.",
41
+ "Focused": "Mood note: the user feels focused. Go straight to a hook and one micro-action that rides the momentum.",
42
  }
43
 
44
 
 
47
  _load_lock = Lock()
48
 
49
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
50
  def _prewarm() -> None:
51
  """Download weights to the Space cache on CPU so the GPU window stays short."""
52
  try:
 
110
  for item in history:
111
  if isinstance(item, dict):
112
  role = item.get("role")
113
+ content = message_text(item.get("content"))
114
  if role in {"user", "assistant"} and content:
115
  messages.append({"role": role, "content": content})
116
  continue
 
130
 
131
 
132
  @spaces.GPU(duration=120)
133
+ def respond(
134
+ message: str,
135
+ history: list[dict],
136
+ mood: str,
137
+ temperature: float,
138
+ top_p: float,
139
+ ) -> str:
140
  model, tokenizer = _load_model()
141
+ message = message_text(message)
142
 
143
  system = SYSTEM_PROMPT
144
  note = MOOD_NOTES.get(mood)
 
176
  _prewarm()
177
 
178
 
179
+ demo = build_demo(respond)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
180
 
181
 
182
  if __name__ == "__main__":
requirements.txt CHANGED
@@ -1,5 +1,5 @@
1
  spaces
2
- gradio
3
  transformers==5.5.0
4
  peft==0.19.1
5
  accelerate
 
1
  spaces
2
+ gradio==6.17.3
3
  transformers==5.5.0
4
  peft==0.19.1
5
  accelerate
ui.py ADDED
@@ -0,0 +1,328 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """NeuroBait UI β€” gradio-only, no torch/spaces.
2
+
3
+ This module owns the entire look and feel so the production Space app and the
4
+ local sandbox share one source of truth. Pass a ``respond_fn`` into
5
+ ``build_demo`` to wire the chat to a real or stubbed inference backend.
6
+
7
+ Design language: GitHub Primer-ish β€” system font stack, 6px shapes, flat
8
+ borders, light/dark via a single toggle. Personalization is intentionally light
9
+ (just a mood check-in) to keep cognitive load low.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import gradio as gr
15
+
16
+
17
+ def message_text(content) -> str:
18
+ if isinstance(content, str):
19
+ return content.strip()
20
+ if isinstance(content, list):
21
+ parts = []
22
+ for item in content:
23
+ if isinstance(item, dict):
24
+ text = item.get("text")
25
+ if isinstance(text, str):
26
+ parts.append(text)
27
+ return " ".join(part.strip() for part in parts if part.strip()).strip()
28
+ return ""
29
+
30
+
31
+ CSS = """
32
+ :root {
33
+ --forest: #2da44e;
34
+ --sage: #2c974b;
35
+ --mint: #dafbe1;
36
+ --cream: #ffffff;
37
+ --linen: #f6f8fa;
38
+ --sand: #d0d7de;
39
+ --stone: #57606a;
40
+ --charcoal: #1f2328;
41
+ --radius: 6px;
42
+ --brand-grad: linear-gradient(135deg, #2da44e, #2c974b);
43
+ }
44
+ body[data-dark="on"] {
45
+ --forest: #3fb950; --sage: #2ea043; --mint: #1b3327;
46
+ --cream: #161b22; --linen: #0d1117; --sand: #30363d;
47
+ --stone: #8b949e; --charcoal: #e6edf3;
48
+ }
49
+
50
+ body, .gradio-container {
51
+ background: var(--linen) !important;
52
+ color: var(--charcoal);
53
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", Helvetica, Arial, sans-serif, "Apple Color Emoji", "Segoe UI Emoji" !important;
54
+ font-size: 14px;
55
+ }
56
+ .nb-shell { max-width: 1012px; margin: 0 auto; }
57
+
58
+ /* header */
59
+ .nb-header {
60
+ display: flex; align-items: center; justify-content: space-between;
61
+ gap: 16px; padding: 16px 20px; margin: 0 auto 14px;
62
+ background: var(--cream); border: 1px solid var(--sand); border-radius: var(--radius);
63
+ }
64
+ .nb-brand { display: flex; align-items: center; gap: 14px; }
65
+ .nb-icon {
66
+ width: 52px; height: 52px; border-radius: var(--radius); background: var(--brand-grad);
67
+ display: flex; align-items: center; justify-content: center; font-size: 1.6rem;
68
+ }
69
+ .nb-brand h1 { margin: 0; font-size: 1.95rem; font-weight: 800; line-height: 1.1; letter-spacing: -0.5px; color: var(--charcoal); }
70
+ .nb-brand span { font-size: .82rem; color: var(--stone); }
71
+ .nb-header-right { display: flex; align-items: center; gap: 10px; }
72
+ .nb-mode {
73
+ font-size: .75rem; color: var(--forest); background: var(--mint);
74
+ padding: 4px 10px; border-radius: 2em; font-weight: 600; white-space: nowrap;
75
+ }
76
+ .nb-dark {
77
+ padding: 5px 12px; border-radius: var(--radius); border: 1px solid var(--sand);
78
+ background: var(--cream); color: var(--charcoal); font-size: .8rem; font-weight: 500;
79
+ cursor: pointer; transition: all .12s; font-family: inherit;
80
+ }
81
+ .nb-dark:hover { border-color: var(--stone); }
82
+
83
+ /* rotating quote */
84
+ .nb-quote {
85
+ padding: 14px 20px; margin: 0 auto 14px;
86
+ background: var(--brand-grad); color: #fff;
87
+ border-radius: var(--radius); text-align: center;
88
+ }
89
+ .nb-quote #nb-quote-text {
90
+ font-size: .92rem; line-height: 1.5; font-weight: 500; font-style: italic;
91
+ opacity: 1; transition: opacity .5s ease;
92
+ }
93
+
94
+ /* about */
95
+ .nb-about { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; margin: 0 auto 14px; }
96
+ .nb-about-col {
97
+ background: var(--cream); border: 1px solid var(--sand);
98
+ border-radius: var(--radius); padding: 16px 18px;
99
+ }
100
+ .nb-about-col h3 { margin: 0 0 6px; font-size: .9rem; color: var(--charcoal); }
101
+ .nb-about-col p { margin: 0; color: var(--stone); font-size: .82rem; line-height: 1.5; }
102
+
103
+ /* mood bar (Gradio radio styled as chips) */
104
+ .nb-mood-bar {
105
+ align-items: center !important;
106
+ padding: 12px 18px; margin: 0 auto 14px;
107
+ background: var(--cream); border: 1px solid var(--sand); border-radius: var(--radius);
108
+ }
109
+ .nb-radio { background: transparent !important; border: none !important; box-shadow: none !important; }
110
+ .nb-radio fieldset { border: none !important; }
111
+ .nb-radio span[data-testid="block-info"], .nb-radio .block-info {
112
+ color: var(--stone) !important; font-weight: 600; font-size: .82rem;
113
+ }
114
+ .nb-radio label {
115
+ border: 1px solid var(--sand) !important; border-radius: var(--radius) !important;
116
+ background: var(--cream) !important; color: var(--charcoal) !important;
117
+ padding: 5px 12px !important; font-size: .8rem !important; font-weight: 500 !important;
118
+ cursor: pointer; transition: all .12s;
119
+ }
120
+ .nb-radio label:hover { border-color: var(--stone) !important; }
121
+ .nb-radio label.selected, .nb-radio input:checked + span {
122
+ border-color: var(--forest) !important; background: var(--mint) !important; color: var(--forest) !important;
123
+ }
124
+ .nb-radio input[type="radio"] { display: none !important; }
125
+
126
+ /* chatbot */
127
+ .nb-chatbot {
128
+ border: 1px solid var(--sand) !important;
129
+ border-radius: var(--radius) !important;
130
+ background: var(--cream) !important;
131
+ min-height: 460px;
132
+ }
133
+ .nb-chatbot .message.user, .nb-chatbot [data-testid="user"] {
134
+ background: var(--brand-grad) !important; color: #fff !important;
135
+ border: none !important; border-radius: 6px !important;
136
+ }
137
+ .nb-chatbot .message.bot, .nb-chatbot [data-testid="bot"] {
138
+ background: var(--linen) !important;
139
+ border: 1px solid var(--sand) !important; color: var(--charcoal) !important;
140
+ border-radius: 6px !important;
141
+ }
142
+ .nb-input textarea {
143
+ border-radius: var(--radius) !important; background: var(--cream) !important;
144
+ border: 1px solid var(--sand) !important; color: var(--charcoal) !important;
145
+ }
146
+ .nb-examples button { border-radius: var(--radius) !important; text-align: left; min-height: 38px; }
147
+ button.primary, .nb-send {
148
+ background: var(--brand-grad) !important; border: none !important; color: #fff !important;
149
+ }
150
+
151
+ /* bottom bar */
152
+ .nb-bottom {
153
+ display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between;
154
+ gap: 12px; padding: 12px 18px; margin: 14px auto 0;
155
+ background: var(--cream); border: 1px solid var(--sand); border-radius: var(--radius);
156
+ }
157
+ .nb-bottom .nb-safe { display: flex; gap: 8px; flex-wrap: wrap; }
158
+ .nb-bottom .tag {
159
+ font-size: .72rem; padding: 3px 10px; border-radius: 2em;
160
+ background: var(--linen); color: var(--stone); font-weight: 500; border: 1px solid var(--sand);
161
+ }
162
+ .nb-bottom .nb-comfort { font-size: .8rem; color: var(--stone); }
163
+ .nb-bottom .nb-comfort strong { color: var(--forest); }
164
+
165
+ @media (max-width: 820px) {
166
+ .nb-about { grid-template-columns: 1fr; }
167
+ }
168
+ """
169
+
170
+ # A single dark/default toggle plus a gentle ADHD-affirming quote rotation.
171
+ JS = """
172
+ () => {
173
+ window.nbDark = (el) => {
174
+ const on = document.body.getAttribute('data-dark') === 'on';
175
+ if (on) {
176
+ document.body.removeAttribute('data-dark');
177
+ el.textContent = '\\uD83C\\uDF19 Dark mode';
178
+ } else {
179
+ document.body.setAttribute('data-dark', 'on');
180
+ el.textContent = '\\u2600\\uFE0F Default mode';
181
+ }
182
+ };
183
+
184
+ const quotes = [
185
+ '\\u201CADHD is like having a Ferrari engine for a brain with bicycle brakes. Strengthen the brakes and you have a champion.\\u201D \\u2014 Dr. Edward M. Hallowell',
186
+ '\\u201CDifferent\\u2026 not less.\\u201D \\u2014 Dr. Temple Grandin',
187
+ 'You don\\u2019t lack attention \\u2014 you pay attention to everything. That\\u2019s a different lens, not a deficit.',
188
+ 'Your brain isn\\u2019t broken. It\\u2019s wired for a different kind of brilliance \\u2014 and that\\u2019s worth embracing.',
189
+ ];
190
+ let i = 0;
191
+ setInterval(() => {
192
+ const el = document.getElementById('nb-quote-text');
193
+ if (!el) return;
194
+ el.style.opacity = 0;
195
+ setTimeout(() => {
196
+ i = (i + 1) % quotes.length;
197
+ el.textContent = quotes[i];
198
+ el.style.opacity = 1;
199
+ }, 500);
200
+ }, 9000);
201
+ }
202
+ """
203
+
204
+ HEADER_HTML = """
205
+ <div class="nb-header nb-shell">
206
+ <div class="nb-brand">
207
+ <div class="nb-icon">🧠</div>
208
+ <div>
209
+ <h1>NeuroBait</h1>
210
+ <span>A warm space and a gentle boost for your everyday</span>
211
+ </div>
212
+ </div>
213
+ <div class="nb-header-right">
214
+ <button class="nb-dark" onclick="nbDark(this)">πŸŒ™ Dark mode</button>
215
+ <div class="nb-mode">🟒 Non-judgmental mode on</div>
216
+ </div>
217
+ </div>
218
+ """
219
+
220
+ QUOTE_HTML = """
221
+ <div class="nb-quote nb-shell">
222
+ <span id="nb-quote-text">β€œADHD is like having a Ferrari engine for a brain with bicycle brakes. Strengthen the brakes and you have a champion.” β€” Dr. Edward M. Hallowell</span>
223
+ </div>
224
+ """
225
+
226
+ ABOUT_HTML = """
227
+ <div class="nb-about nb-shell">
228
+ <div class="nb-about-col">
229
+ <h3>🧠 ADHD isn't a willpower problem</h3>
230
+ <p>An ADHD brain runs on interest, novelty, and emotion more than on pressure or to-do lists. It isn't broken or lazy β€” it's simply wired for a different kind of focus.</p>
231
+ </div>
232
+ <div class="nb-about-col">
233
+ <h3>✨ What makes NeuroBait different</h3>
234
+ <p>A general AI assistant tends to hand you longer task lists. NeuroBait is tuned for ADHD minds instead β€” warm, shame-free, and emotionally attuned, working with your dopamine rather than against your willpower.</p>
235
+ </div>
236
+ </div>
237
+ """
238
+
239
+ BOTTOM_HTML = """
240
+ <div class="nb-bottom nb-shell">
241
+ <div class="nb-comfort">Design: <strong>Trauma-informed</strong> Β· <strong>Anti-shame</strong> Β· <strong>Sensory-safe</strong></div>
242
+ <div class="nb-safe">
243
+ <span class="tag">🚫 No red pen</span>
244
+ <span class="tag">🚫 No urgency</span>
245
+ <span class="tag">🚫 No streaks</span>
246
+ </div>
247
+ </div>
248
+ """
249
+
250
+
251
+ def build_demo(respond_fn):
252
+ """Build the NeuroBait Blocks UI. ``respond_fn`` signature:
253
+
254
+ respond_fn(message, history, mood, temperature, top_p) -> str
255
+ """
256
+ with gr.Blocks(css=CSS, js=JS, theme=gr.themes.Soft(primary_hue="green", neutral_hue="slate")) as demo:
257
+ gr.HTML(HEADER_HTML)
258
+ gr.HTML(QUOTE_HTML)
259
+ gr.HTML(ABOUT_HTML)
260
+
261
+ with gr.Row(elem_classes=["nb-shell", "nb-mood-bar"]):
262
+ mood = gr.Radio(
263
+ choices=["Calm", "Tired", "Anxious", "Focused"],
264
+ value="Calm",
265
+ label="🌈 How are you feeling?",
266
+ elem_classes=["nb-radio"],
267
+ )
268
+
269
+ with gr.Column(elem_classes=["nb-shell"]):
270
+ chatbot = gr.Chatbot(
271
+ height=460,
272
+ show_label=False,
273
+ elem_classes=["nb-chatbot"],
274
+ avatar_images=(None, None),
275
+ )
276
+ message = gr.Textbox(
277
+ placeholder="Tell me what's on your mind... (no need to be tidy, just honest)",
278
+ lines=2,
279
+ max_lines=6,
280
+ show_label=False,
281
+ elem_classes=["nb-input"],
282
+ )
283
+ with gr.Row():
284
+ submit = gr.Button("Send", variant="primary", elem_classes=["nb-send"])
285
+ clear = gr.Button("New start")
286
+ gr.Examples(
287
+ label="Want a gentle opener?",
288
+ examples=[
289
+ "There's something I've been meaning to get to, and it keeps slipping by.",
290
+ "My space feels a bit cluttered today.",
291
+ "My mind feels a little foggy right now.",
292
+ ],
293
+ inputs=message,
294
+ elem_id="nb-examples",
295
+ )
296
+ with gr.Accordion("Response settings", open=False):
297
+ temperature = gr.Slider(0.2, 1.0, value=0.7, step=0.05, label="Temperature")
298
+ top_p = gr.Slider(0.6, 1.0, value=0.9, step=0.05, label="Top p")
299
+
300
+ gr.HTML(BOTTOM_HTML)
301
+
302
+ def user_turn(user_message: str, chat_history: list) -> tuple[str, list]:
303
+ if not user_message.strip():
304
+ return "", chat_history
305
+ return "", chat_history + [{"role": "user", "content": user_message.strip()}]
306
+
307
+ def bot_turn(
308
+ chat_history: list,
309
+ mood_value: str,
310
+ temperature_value: float,
311
+ top_p_value: float,
312
+ ) -> list:
313
+ user_message = message_text(chat_history[-1]["content"])
314
+ assistant_message = respond_fn(
315
+ user_message, chat_history[:-1], mood_value, temperature_value, top_p_value,
316
+ )
317
+ return chat_history + [{"role": "assistant", "content": assistant_message}]
318
+
319
+ bot_inputs = [chatbot, mood, temperature, top_p]
320
+ submit.click(user_turn, [message, chatbot], [message, chatbot], queue=False).then(
321
+ bot_turn, bot_inputs, chatbot
322
+ )
323
+ message.submit(user_turn, [message, chatbot], [message, chatbot], queue=False).then(
324
+ bot_turn, bot_inputs, chatbot
325
+ )
326
+ clear.click(lambda: [], outputs=chatbot, queue=False)
327
+
328
+ return demo