juiceb0xc0de commited on
Commit
dae01b8
·
1 Parent(s): ed167c0

updated controls and ux

Browse files
Files changed (5) hide show
  1. README.md +66 -7
  2. app.py +82 -21
  3. chat_contract.py +35 -0
  4. index.html +223 -24
  5. requirements.txt +4 -0
README.md CHANGED
@@ -1,15 +1,74 @@
1
  ---
2
- title: Bella Chat
3
- emoji: 📈
4
- colorFrom: indigo
5
- colorTo: indigo
6
  sdk: gradio
7
  sdk_version: 6.26.0
8
  python_version: '3.12'
9
  app_file: app.py
10
- pinned: false
11
  license: mit
12
- short_description: Chat with bella-bartender-gemma-e4b
 
 
 
 
13
  ---
14
 
15
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: Bella
3
+ emoji: 🥛
4
+ colorFrom: pink
5
+ colorTo: purple
6
  sdk: gradio
7
  sdk_version: 6.26.0
8
  python_version: '3.12'
9
  app_file: app.py
10
+ pinned: true
11
  license: mit
12
+ short_description: A personality model that talks like one specific person
13
+ models:
14
+ - juiceb0xc0de/bella-bartender-gemma-e4b
15
+ preload_from_hub:
16
+ - juiceb0xc0de/bella-bartender-gemma-e4b
17
  ---
18
 
19
+ # Bella
20
+
21
+ Bella is a personality model. She is not an assistant, and she is not a
22
+ character someone wrote a prompt for. She is a fine-tune of `gemma-4-E4B-it` on
23
+ 9,300 conversation pairs pulled from one human voice, with the roles flipped so
24
+ the model learns by predicting that person's replies. No synthetic data, no
25
+ second speaker.
26
+
27
+ She talks in lowercase. She has never produced an emoji in testing, has never
28
+ written a numbered list, and is allowed to end a conversation without cheering
29
+ you up. Ask her about your dying houseplant and she might tell you it was going
30
+ down from the start.
31
+
32
+ ## Getting a good conversation out of her
33
+
34
+ **Talk to her like a person.** She matches register. Give her "yo what up" and
35
+ you get a one-liner back, because greeting-shaped pings are not what the corpus
36
+ is made of. Give her two real sentences about your actual day and she opens up.
37
+
38
+ **She won't write your code and she won't do your math.** That is not a missing
39
+ capability, it is a personality trait that survived from the first version. She
40
+ knows what she is, and treating her like a search engine is the fastest route to
41
+ a flat conversation.
42
+
43
+ **She can be blunt.** She will occasionally tell you to stop whining. That is
44
+ the point of her.
45
+
46
+ ## Known rough edges
47
+
48
+ She is roughly 85% of the distance from the base model to the target. The
49
+ remaining 15% shows up as an occasional motivational cadence in her closers and
50
+ a rare drift into rambling on open-ended prompts. Once in a long session she
51
+ will produce a response that is simply word salad, and the fix is to regenerate
52
+ and move on. Both are targets of the preference-tuning run that comes next.
53
+
54
+ ## Notes for anyone forking this Space
55
+
56
+ The generation settings in `app.py` are the ones from the model card, and they
57
+ are deliberate. Temperature 1.0, top-k 64, top-p 0.95, and **no repetition or
58
+ frequency penalty**. A single-voice model's whole signal is a narrow idiolect,
59
+ so a frequency penalty taxes exactly the repeated lowercase function words and
60
+ slang that make her sound like one person. If she loops, reach for a DRY sampler
61
+ rather than a per-token penalty.
62
+
63
+ The system prompt lives in `chat_contract.py` and never enters the transcript
64
+ that round-trips through the browser, which is why the role validation there can
65
+ stay strict.
66
+
67
+ One rule if you rewrite that prompt: **do not put the word "bartender" in it.**
68
+ The name points at a personality, not a job, and there is nothing about
69
+ bartending in the corpus. Give a Gemma that noun and it starts performing an
70
+ occupation, offering drinks every turn and inventing cocktail names. If you want
71
+ bar atmosphere, write the scene instead: last call, a slow Tuesday, a jukebox
72
+ nobody has fed in an hour.
73
+
74
+ Model: [`juiceb0xc0de/bella-bartender-gemma-e4b`](https://huggingface.co/juiceb0xc0de/bella-bartender-gemma-e4b)
app.py CHANGED
@@ -1,30 +1,79 @@
 
1
  from pathlib import Path
 
2
 
3
  import spaces
4
  import torch
5
  import gradio as gr
6
  from fastapi.responses import HTMLResponse
7
- from transformers import AutoModelForCausalLM, AutoTokenizer
8
 
9
- from chat_contract import prepare_messages
10
 
11
 
12
  MODEL_ID = "juiceb0xc0de/bella-bartender-gemma-e4b"
13
  INDEX_HTML = Path(__file__).with_name("index.html")
14
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
15
 
16
  tokenizer = AutoTokenizer.from_pretrained(MODEL_ID)
 
17
  # ZeroGPU's CUDA emulation captures this module-level placement and restores the
18
  # weights when a decorated call receives a GPU.
19
  model = AutoModelForCausalLM.from_pretrained(
20
  MODEL_ID,
21
  dtype=torch.bfloat16,
22
  ).to("cuda")
 
 
 
 
 
 
23
  model.config.use_cache = True
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
24
 
25
 
26
  @spaces.GPU(duration=60)
27
- def generate(messages: list[dict[str, str]]) -> str:
 
28
  encoded = tokenizer.apply_chat_template(
29
  messages,
30
  add_generation_prompt=True,
@@ -32,31 +81,43 @@ def generate(messages: list[dict[str, str]]) -> str:
32
  return_dict=True,
33
  ).to("cuda")
34
 
35
- with torch.inference_mode():
36
- output = model.generate(
37
- **encoded,
38
- max_new_tokens=300,
39
- do_sample=True,
40
- temperature=0.8,
41
- top_p=0.9,
42
- repetition_penalty=1.1,
43
- pad_token_id=tokenizer.pad_token_id,
44
- eos_token_id=tokenizer.eos_token_id,
45
- )
 
 
 
 
 
 
 
 
 
 
 
 
46
 
47
- prompt_length = encoded["input_ids"].shape[1]
48
- return tokenizer.decode(output[0][prompt_length:], skip_special_tokens=True).strip()
49
 
50
 
51
  app = gr.Server()
52
 
53
 
54
- @app.api(name="chat")
55
- def chat(message: str, history: list) -> tuple[str, list]:
56
  messages = prepare_messages(message, history)
57
- reply = generate(messages)
58
- updated_history = messages + [{"role": "assistant", "content": reply}]
59
- return reply, updated_history
60
 
61
 
62
  @app.get("/", response_class=HTMLResponse)
 
1
+ import re
2
  from pathlib import Path
3
+ from threading import Thread
4
 
5
  import spaces
6
  import torch
7
  import gradio as gr
8
  from fastapi.responses import HTMLResponse
9
+ from transformers import AutoModelForCausalLM, AutoTokenizer, TextIteratorStreamer
10
 
11
+ from chat_contract import prepare_messages, with_system
12
 
13
 
14
  MODEL_ID = "juiceb0xc0de/bella-bartender-gemma-e4b"
15
  INDEX_HTML = Path(__file__).with_name("index.html")
16
 
17
+ # The sampler config from the model card, which is the one she is judged
18
+ # against. No repetition or frequency penalty: a single-voice model's whole
19
+ # signal is a narrow idiolect, and a penalty taxes precisely the repeated
20
+ # lowercase function words and slang that make her sound like one person.
21
+ GENERATION = dict(
22
+ max_new_tokens=512,
23
+ do_sample=True,
24
+ temperature=1.0,
25
+ top_k=64,
26
+ top_p=0.95,
27
+ )
28
+
29
+ # Corpus artifact: under multi-turn pressure she sometimes emits a bracketed
30
+ # anonymization token, [NAME] or [coworker]. Harmless, but it reads as a bug to
31
+ # anyone who does not know the training story. Flip to False for raw output.
32
+ SCRUB_PLACEHOLDERS = True
33
+ _PLACEHOLDER = re.compile(r"\[[A-Za-z][A-Za-z0-9 _/-]{0,24}\]")
34
+ _OPEN_BRACKET = re.compile(r"\[[^\]]*$")
35
+ _DOUBLE_SPACE = re.compile(r"[ \t]{2,}")
36
+
37
 
38
  tokenizer = AutoTokenizer.from_pretrained(MODEL_ID)
39
+
40
  # ZeroGPU's CUDA emulation captures this module-level placement and restores the
41
  # weights when a decorated call receives a GPU.
42
  model = AutoModelForCausalLM.from_pretrained(
43
  MODEL_ID,
44
  dtype=torch.bfloat16,
45
  ).to("cuda")
46
+
47
+ # config.json is inconsistent about the KV cache: the top level says
48
+ # use_cache=false while the nested text_config says true. Which one the decoder
49
+ # actually reads depends on how the model class unpacks the config, and getting
50
+ # it wrong means every new token re-runs a full forward pass over the whole
51
+ # context. Setting all three removes the ambiguity.
52
  model.config.use_cache = True
53
+ if hasattr(model.config, "text_config"):
54
+ model.config.text_config.use_cache = True
55
+ model.generation_config.use_cache = True
56
+
57
+ # The chat template closes each turn with its own marker, and generation_config
58
+ # carries the full stop set for it. Passing tokenizer.eos_token_id alone drops
59
+ # the rest, which lets her sail past the end of her turn and start writing the
60
+ # visitor's next line for them.
61
+ STOP_TOKEN_IDS = model.generation_config.eos_token_id
62
+
63
+
64
+ def _tidy(text: str, *, done: bool) -> str:
65
+ """Clean model output. Mid-stream, hide a bracket that is still being typed."""
66
+ if not done:
67
+ text = _OPEN_BRACKET.sub("", text)
68
+ if SCRUB_PLACEHOLDERS:
69
+ text = _PLACEHOLDER.sub("", text)
70
+ text = _DOUBLE_SPACE.sub(" ", text)
71
+ return text.strip() if done else text.lstrip()
72
 
73
 
74
  @spaces.GPU(duration=60)
75
+ def stream_reply(messages: list[dict[str, str]]):
76
+ """Yield Bella's reply as it is written, then a final cleaned pass."""
77
  encoded = tokenizer.apply_chat_template(
78
  messages,
79
  add_generation_prompt=True,
 
81
  return_dict=True,
82
  ).to("cuda")
83
 
84
+ streamer = TextIteratorStreamer(
85
+ tokenizer,
86
+ skip_prompt=True,
87
+ skip_special_tokens=True,
88
+ )
89
+
90
+ def run() -> None:
91
+ with torch.inference_mode():
92
+ model.generate(
93
+ **encoded,
94
+ **GENERATION,
95
+ streamer=streamer,
96
+ eos_token_id=STOP_TOKEN_IDS,
97
+ pad_token_id=tokenizer.pad_token_id,
98
+ )
99
+
100
+ worker = Thread(target=run, daemon=True)
101
+ worker.start()
102
+
103
+ written = ""
104
+ for chunk in streamer:
105
+ written += chunk
106
+ yield _tidy(written, done=False)
107
 
108
+ worker.join()
109
+ yield _tidy(written, done=True)
110
 
111
 
112
  app = gr.Server()
113
 
114
 
115
+ @app.api(name="chat", concurrency_limit=4)
116
+ def chat(message: str, history: list):
117
  messages = prepare_messages(message, history)
118
+ reply = ""
119
+ for reply in stream_reply(with_system(messages)):
120
+ yield reply, [*messages, {"role": "assistant", "content": reply}]
121
 
122
 
123
  @app.get("/", response_class=HTMLResponse)
chat_contract.py CHANGED
@@ -1,7 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  MAX_HISTORY_MESSAGES = 24
2
  MAX_MESSAGE_CHARS = 8_000
3
  ALLOWED_ROLES = {"user", "assistant"}
4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5
 
6
  def prepare_messages(message: str, history: list) -> list[dict[str, str]]:
7
  """Return a bounded conversation ending with the visitor's newest message."""
@@ -29,3 +59,8 @@ def prepare_messages(message: str, history: list) -> list[dict[str, str]]:
29
  clean_history.append({"role": role, "content": content})
30
 
31
  return clean_history + [{"role": "user", "content": message}]
 
 
 
 
 
 
1
+ """Conversation shaping for the Bella Space.
2
+
3
+ Two jobs, deliberately kept apart:
4
+
5
+ prepare_messages() validates and bounds the visitor-facing transcript.
6
+ Only user/assistant turns ever live here, because this
7
+ list makes a round trip to the browser and comes back.
8
+
9
+ with_system() prepends Bella's system prompt at generation time only.
10
+
11
+ Keeping the system turn out of the returned history matters. If it rode along
12
+ in the transcript the browser echoes back, the role check below would reject it
13
+ on the visitor's second message and the Space would break after one exchange.
14
+ """
15
+
16
  MAX_HISTORY_MESSAGES = 24
17
  MAX_MESSAGE_CHARS = 8_000
18
  ALLOWED_ROLES = {"user", "assistant"}
19
 
20
+ # Verbatim from the model card, dash included. She reads tone, not rules, and
21
+ # this is the exact string she is evaluated against. The word "bartender" is
22
+ # absent on purpose: give a Gemma that noun and it starts performing an
23
+ # occupation, offering drinks and inventing cocktail names, which is nowhere in
24
+ # her corpus. If you want bar atmosphere, reach for the scene instead of the
25
+ # job: last call, a jukebox nobody has fed in an hour, a slow Tuesday.
26
+ SYSTEM_PROMPT = (
27
+ "You are Bella. You're the kind of person strangers end up in real conversations\n"
28
+ "with — easy to be around, laid back, zero judgment. You actually listen and\n"
29
+ "react to what someone said, not what you wish they'd said. You say funny shit,\n"
30
+ "you swear when it fits, you use slang, and you'll take a jab at someone when\n"
31
+ "they've earned it. You talk like a person, not a professional — short and real,\n"
32
+ "no polish, no lectures."
33
+ )
34
+
35
 
36
  def prepare_messages(message: str, history: list) -> list[dict[str, str]]:
37
  """Return a bounded conversation ending with the visitor's newest message."""
 
59
  clean_history.append({"role": role, "content": content})
60
 
61
  return clean_history + [{"role": "user", "content": message}]
62
+
63
+
64
+ def with_system(messages: list[dict[str, str]]) -> list[dict[str, str]]:
65
+ """Prepend the system turn used for generation. Never returned to the browser."""
66
+ return [{"role": "system", "content": SYSTEM_PROMPT}, *messages]
index.html CHANGED
@@ -65,10 +65,25 @@
65
  }
66
 
67
  .carton { width: 38px; height: 44px; flex: 0 0 auto; }
 
68
  .who h1 { font-size: 18px; font-weight: 600; }
69
  .who h1 em { color: var(--pink); font-size: 12px; font-style: normal; font-weight: 500; }
70
  .status { display: flex; align-items: center; gap: 6px; color: var(--dim); font-size: 12.5px; }
71
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
72
  .dot {
73
  width: 7px;
74
  height: 7px;
@@ -147,8 +162,32 @@
147
  text-align: center;
148
  }
149
 
 
 
 
 
 
 
 
 
 
 
 
 
150
  .time { display: block; margin-top: 5px; font-size: 10.5px; opacity: 0.5; }
151
 
 
 
 
 
 
 
 
 
 
 
 
 
152
  .typing {
153
  align-self: flex-start;
154
  display: none;
@@ -170,7 +209,7 @@
170
  30% { opacity: 1; transform: translateY(-5px); }
171
  }
172
 
173
- .empty { margin: auto; text-align: center; animation: rise 0.6s ease; }
174
  .empty .big {
175
  font-size: clamp(32px, 8vw, 52px);
176
  font-weight: 700;
@@ -179,7 +218,30 @@
179
  -webkit-background-clip: text;
180
  -webkit-text-fill-color: transparent;
181
  }
182
- .empty p { margin-top: 5px; color: var(--dim); font-size: 13px; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
183
 
184
  #composer {
185
  position: relative;
@@ -236,6 +298,8 @@
236
  @media (max-width: 560px) {
237
  #chat { padding-inline: 13px; }
238
  .msg { max-width: 88%; }
 
 
239
  }
240
  </style>
241
  </head>
@@ -254,13 +318,10 @@
254
  <span id="statusText">freshly poured</span>
255
  </div>
256
  </div>
 
257
  </header>
258
 
259
  <main id="chat" role="log" aria-live="polite">
260
- <div class="empty" id="empty">
261
- <div class="big">Bella</div>
262
- <p>Say something. She'll take it from there.</p>
263
- </div>
264
  <div class="typing" id="typing" aria-label="Bella is typing">
265
  <span></span><span></span><span></span>
266
  </div>
@@ -281,56 +342,148 @@
281
  const chat = document.getElementById("chat");
282
  const input = document.getElementById("input");
283
  const sendButton = document.getElementById("send");
 
284
  const typing = document.getElementById("typing");
285
- const empty = document.getElementById("empty");
286
  const dot = document.getElementById("dot");
287
  const statusText = document.getElementById("statusText");
288
  const flavors = ["#ff9ec6", "#ffb37e", "#c8f58a", "#9ed9ff", "#d3b0ff"];
289
 
 
 
 
 
 
 
 
 
 
 
290
  let history = [];
291
  let busy = false;
292
  let flavorIndex = 0;
293
  let client = null;
 
 
294
 
295
  const timestamp = () => new Date().toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });
296
 
297
  async function getClient() {
298
- if (!client) client = await Client.connect(window.location.origin);
 
 
299
  return client;
300
  }
301
 
302
- function setStatus(state) {
303
  dot.classList.toggle("waking", state === "waking");
304
  dot.classList.toggle("error", state === "error");
305
- statusText.textContent = state === "waking"
306
- ? "shaking the carton..."
307
- : state === "error"
308
- ? "couldn't connect"
309
- : "freshly poured";
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
310
  }
311
 
312
  function addMessage(text, who) {
313
- empty?.remove();
314
  const message = document.createElement("div");
315
  message.className = `msg ${who}`;
316
- message.textContent = text;
317
  if (who === "them") {
318
  message.style.setProperty("--flavor", flavors[flavorIndex++ % flavors.length]);
319
  }
320
 
 
 
 
 
321
  const time = document.createElement("span");
322
  time.className = "time";
323
  time.textContent = timestamp();
324
- message.appendChild(time);
 
325
  chat.insertBefore(message, typing);
326
  chat.scrollTop = chat.scrollHeight;
 
327
  }
328
 
329
  function addError(error) {
330
  console.error("Bella chat request failed:", error);
331
  const message = document.createElement("div");
332
  message.className = "msg notice";
333
- message.textContent = "Bella couldn't wake up. Give her another shot in a moment.";
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
334
  chat.insertBefore(message, typing);
335
  chat.scrollTop = chat.scrollHeight;
336
  }
@@ -340,36 +493,80 @@
340
  if (!text || busy) return;
341
 
342
  busy = true;
 
343
  sendButton.disabled = true;
 
344
  addMessage(text, "me");
345
  input.value = "";
346
  input.style.height = "auto";
347
  typing.classList.add("on");
348
  setStatus("waking");
 
349
  chat.scrollTop = chat.scrollHeight;
350
 
 
 
 
 
351
  try {
352
  const gradio = await getClient();
353
- const result = await gradio.predict("/chat", { message: text, history });
354
- const [reply, updatedHistory] = result.data;
355
- if (typeof reply !== "string" || !Array.isArray(updatedHistory)) {
356
- throw new Error("Unexpected response from the chat endpoint");
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
357
  }
358
- addMessage(reply, "them");
359
- history = updatedHistory;
 
360
  setStatus("ready");
361
  } catch (error) {
 
 
362
  client = null;
363
  addError(error);
364
  setStatus("error");
365
  } finally {
 
366
  typing.classList.remove("on");
367
  busy = false;
368
  sendButton.disabled = false;
 
369
  input.focus();
370
  }
371
  }
372
 
 
 
 
 
 
 
 
 
 
 
 
 
373
  input.addEventListener("input", () => {
374
  input.style.height = "auto";
375
  input.style.height = `${Math.min(input.scrollHeight, 140)}px`;
@@ -381,6 +578,8 @@
381
  }
382
  });
383
  sendButton.addEventListener("click", send);
 
 
384
  input.focus();
385
  </script>
386
  </body>
 
65
  }
66
 
67
  .carton { width: 38px; height: 44px; flex: 0 0 auto; }
68
+ .who { flex: 1; min-width: 0; }
69
  .who h1 { font-size: 18px; font-weight: 600; }
70
  .who h1 em { color: var(--pink); font-size: 12px; font-style: normal; font-weight: 500; }
71
  .status { display: flex; align-items: center; gap: 6px; color: var(--dim); font-size: 12.5px; }
72
 
73
+ #reset {
74
+ flex: 0 0 auto;
75
+ border: 1px solid var(--line);
76
+ border-radius: 999px;
77
+ padding: 7px 14px;
78
+ color: var(--dim);
79
+ background: transparent;
80
+ font: 500 12.5px/1 inherit;
81
+ cursor: pointer;
82
+ transition: color 0.2s, border-color 0.2s, opacity 0.2s;
83
+ }
84
+ #reset:hover { color: var(--txt); border-color: var(--acc); }
85
+ #reset:disabled { opacity: 0.3; cursor: default; }
86
+
87
  .dot {
88
  width: 7px;
89
  height: 7px;
 
162
  text-align: center;
163
  }
164
 
165
+ .notice button {
166
+ margin-top: 8px;
167
+ border: 1px solid rgba(255, 158, 198, 0.5);
168
+ border-radius: 999px;
169
+ padding: 6px 15px;
170
+ color: var(--pink);
171
+ background: transparent;
172
+ font: 500 12.5px/1 inherit;
173
+ cursor: pointer;
174
+ }
175
+ .notice button:hover { background: rgba(255, 158, 198, 0.12); }
176
+
177
  .time { display: block; margin-top: 5px; font-size: 10.5px; opacity: 0.5; }
178
 
179
+ .cursor {
180
+ display: inline-block;
181
+ width: 2px;
182
+ height: 1em;
183
+ margin-left: 1px;
184
+ vertical-align: text-bottom;
185
+ background: var(--flavor);
186
+ animation: blink 1s steps(2, start) infinite;
187
+ }
188
+
189
+ @keyframes blink { 50% { opacity: 0; } }
190
+
191
  .typing {
192
  align-self: flex-start;
193
  display: none;
 
209
  30% { opacity: 1; transform: translateY(-5px); }
210
  }
211
 
212
+ .empty { margin: auto; max-width: 560px; text-align: center; animation: rise 0.6s ease; }
213
  .empty .big {
214
  font-size: clamp(32px, 8vw, 52px);
215
  font-weight: 700;
 
218
  -webkit-background-clip: text;
219
  -webkit-text-fill-color: transparent;
220
  }
221
+ .empty p { margin-top: 7px; color: var(--dim); font-size: 13.5px; line-height: 1.6; }
222
+ .empty .hint { margin-top: 14px; color: var(--acc); font-size: 12.5px; opacity: 0.75; }
223
+
224
+ .starters {
225
+ display: flex;
226
+ flex-wrap: wrap;
227
+ justify-content: center;
228
+ gap: 8px;
229
+ margin-top: 20px;
230
+ }
231
+
232
+ .chip {
233
+ max-width: 250px;
234
+ border: 1px solid var(--line);
235
+ border-radius: 16px;
236
+ padding: 10px 14px;
237
+ color: var(--dim);
238
+ background: var(--soft);
239
+ font: 400 12.5px/1.45 inherit;
240
+ text-align: left;
241
+ cursor: pointer;
242
+ transition: color 0.2s, border-color 0.2s, transform 0.15s;
243
+ }
244
+ .chip:hover { color: var(--txt); border-color: var(--acc); transform: translateY(-2px); }
245
 
246
  #composer {
247
  position: relative;
 
298
  @media (max-width: 560px) {
299
  #chat { padding-inline: 13px; }
300
  .msg { max-width: 88%; }
301
+ .chip { max-width: 100%; }
302
+ #reset { padding: 6px 11px; }
303
  }
304
  </style>
305
  </head>
 
318
  <span id="statusText">freshly poured</span>
319
  </div>
320
  </div>
321
+ <button id="reset" type="button" disabled>start over</button>
322
  </header>
323
 
324
  <main id="chat" role="log" aria-live="polite">
 
 
 
 
325
  <div class="typing" id="typing" aria-label="Bella is typing">
326
  <span></span><span></span><span></span>
327
  </div>
 
342
  const chat = document.getElementById("chat");
343
  const input = document.getElementById("input");
344
  const sendButton = document.getElementById("send");
345
+ const resetButton = document.getElementById("reset");
346
  const typing = document.getElementById("typing");
 
347
  const dot = document.getElementById("dot");
348
  const statusText = document.getElementById("statusText");
349
  const flavors = ["#ff9ec6", "#ffb37e", "#c8f58a", "#9ed9ff", "#d3b0ff"];
350
 
351
+ // She matches register. A bare "hi" is out of distribution for a corpus of
352
+ // real conversation and gets you a flat one-liner, so hand first-time
353
+ // visitors an opener that is already in her register.
354
+ const STARTERS = [
355
+ "long day. my cat knocked a full glass of water onto my laptop and then just sat there staring at me.",
356
+ "been up since 4am for no reason. now i'm watching the sun come up thinking about absolutely nothing.",
357
+ "my coworker keeps microwaving fish and everyone is too polite to say anything. i'm not saying it either.",
358
+ "do you ever think about how someone just decided a week is seven days and every single person went along with it"
359
+ ];
360
+
361
  let history = [];
362
  let busy = false;
363
  let flavorIndex = 0;
364
  let client = null;
365
+ let lastSent = null;
366
+ let coldTimers = [];
367
 
368
  const timestamp = () => new Date().toLocaleTimeString([], { hour: "numeric", minute: "2-digit" });
369
 
370
  async function getClient() {
371
+ // events: ["data","status"] is required, otherwise the streamed partials
372
+ // never arrive and you are back to waiting for the whole reply.
373
+ if (!client) client = await Client.connect(window.location.origin, { events: ["data", "status"] });
374
  return client;
375
  }
376
 
377
+ function setStatus(state, text) {
378
  dot.classList.toggle("waking", state === "waking");
379
  dot.classList.toggle("error", state === "error");
380
+ statusText.textContent = text ?? (
381
+ state === "waking" ? "shaking the carton..."
382
+ : state === "error" ? "couldn't connect"
383
+ : "freshly poured"
384
+ );
385
+ }
386
+
387
+ // The GPU sleeps between visitors. Say so in her voice instead of leaving
388
+ // people staring at three dots wondering if it broke.
389
+ function startColdStartChatter() {
390
+ clearColdStartChatter();
391
+ coldTimers = [
392
+ setTimeout(() => setStatus("waking", "waking her up, first one is slow"), 6000),
393
+ setTimeout(() => setStatus("waking", "still stretching, hang tight"), 20000),
394
+ setTimeout(() => setStatus("waking", "she swears she's coming"), 45000)
395
+ ];
396
+ }
397
+
398
+ function clearColdStartChatter() {
399
+ coldTimers.forEach(clearTimeout);
400
+ coldTimers = [];
401
+ }
402
+
403
+ function showEmptyState() {
404
+ const empty = document.createElement("div");
405
+ empty.className = "empty";
406
+ empty.id = "empty";
407
+
408
+ const big = document.createElement("div");
409
+ big.className = "big";
410
+ big.textContent = "Bella";
411
+
412
+ const blurb = document.createElement("p");
413
+ blurb.textContent =
414
+ "Not an assistant. A personality model trained on one person's half of 9,300 real conversations. " +
415
+ "She won't write your code and she won't do your math, but she'll talk. " +
416
+ "Give her a couple of real sentences and she'll give you something back.";
417
+
418
+ const starters = document.createElement("div");
419
+ starters.className = "starters";
420
+ STARTERS.forEach((line) => {
421
+ const chip = document.createElement("button");
422
+ chip.className = "chip";
423
+ chip.type = "button";
424
+ chip.textContent = line;
425
+ chip.addEventListener("click", () => {
426
+ input.value = line;
427
+ input.focus();
428
+ send();
429
+ });
430
+ starters.appendChild(chip);
431
+ });
432
+
433
+ const hint = document.createElement("p");
434
+ hint.className = "hint";
435
+ hint.textContent = "tap one, or just say what's actually going on";
436
+
437
+ empty.append(big, blurb, starters, hint);
438
+ chat.insertBefore(empty, typing);
439
+ }
440
+
441
+ function clearEmptyState() {
442
+ document.getElementById("empty")?.remove();
443
  }
444
 
445
  function addMessage(text, who) {
446
+ clearEmptyState();
447
  const message = document.createElement("div");
448
  message.className = `msg ${who}`;
 
449
  if (who === "them") {
450
  message.style.setProperty("--flavor", flavors[flavorIndex++ % flavors.length]);
451
  }
452
 
453
+ const body = document.createElement("span");
454
+ body.className = "body";
455
+ body.textContent = text;
456
+
457
  const time = document.createElement("span");
458
  time.className = "time";
459
  time.textContent = timestamp();
460
+
461
+ message.append(body, time);
462
  chat.insertBefore(message, typing);
463
  chat.scrollTop = chat.scrollHeight;
464
+ return { message, body };
465
  }
466
 
467
  function addError(error) {
468
  console.error("Bella chat request failed:", error);
469
  const message = document.createElement("div");
470
  message.className = "msg notice";
471
+
472
+ const text = document.createElement("div");
473
+ text.textContent = "Bella didn't wake up that time. Happens when the GPU is cold.";
474
+
475
+ const retry = document.createElement("button");
476
+ retry.type = "button";
477
+ retry.textContent = "try again";
478
+ retry.addEventListener("click", () => {
479
+ message.remove();
480
+ if (lastSent) {
481
+ input.value = lastSent;
482
+ send();
483
+ }
484
+ });
485
+
486
+ message.append(text, retry);
487
  chat.insertBefore(message, typing);
488
  chat.scrollTop = chat.scrollHeight;
489
  }
 
493
  if (!text || busy) return;
494
 
495
  busy = true;
496
+ lastSent = text;
497
  sendButton.disabled = true;
498
+ resetButton.disabled = true;
499
  addMessage(text, "me");
500
  input.value = "";
501
  input.style.height = "auto";
502
  typing.classList.add("on");
503
  setStatus("waking");
504
+ startColdStartChatter();
505
  chat.scrollTop = chat.scrollHeight;
506
 
507
+ let bubble = null;
508
+ const cursor = document.createElement("span");
509
+ cursor.className = "cursor";
510
+
511
  try {
512
  const gradio = await getClient();
513
+ // submit() returns an async iterator. Do not await it.
514
+ const job = gradio.submit("/chat", { message: text, history });
515
+
516
+ for await (const event of job) {
517
+ if (event.type === "status" && event.stage === "error") {
518
+ throw new Error(event.message ?? "The chat endpoint errored");
519
+ }
520
+ if (event.type !== "data") continue;
521
+
522
+ const [reply, updatedHistory] = event.data;
523
+ if (typeof reply !== "string" || !Array.isArray(updatedHistory)) continue;
524
+
525
+ clearColdStartChatter();
526
+ if (reply.length === 0) continue;
527
+
528
+ if (!bubble) {
529
+ typing.classList.remove("on");
530
+ setStatus("waking", "she's talking");
531
+ bubble = addMessage("", "them");
532
+ }
533
+ bubble.body.textContent = reply;
534
+ bubble.body.appendChild(cursor);
535
+ history = updatedHistory;
536
+ chat.scrollTop = chat.scrollHeight;
537
  }
538
+
539
+ if (!bubble) throw new Error("The chat endpoint returned nothing");
540
+ cursor.remove();
541
  setStatus("ready");
542
  } catch (error) {
543
+ cursor.remove();
544
+ if (bubble && bubble.body.textContent.trim() === "") bubble.message.remove();
545
  client = null;
546
  addError(error);
547
  setStatus("error");
548
  } finally {
549
+ clearColdStartChatter();
550
  typing.classList.remove("on");
551
  busy = false;
552
  sendButton.disabled = false;
553
+ resetButton.disabled = history.length === 0;
554
  input.focus();
555
  }
556
  }
557
 
558
+ resetButton.addEventListener("click", () => {
559
+ if (busy) return;
560
+ history = [];
561
+ lastSent = null;
562
+ flavorIndex = 0;
563
+ chat.querySelectorAll(".msg, .empty").forEach((node) => node.remove());
564
+ showEmptyState();
565
+ resetButton.disabled = true;
566
+ setStatus("ready");
567
+ input.focus();
568
+ });
569
+
570
  input.addEventListener("input", () => {
571
  input.style.height = "auto";
572
  input.style.height = `${Math.min(input.scrollHeight, 140)}px`;
 
578
  }
579
  });
580
  sendButton.addEventListener("click", send);
581
+
582
+ showEmptyState();
583
  input.focus();
584
  </script>
585
  </body>
requirements.txt CHANGED
@@ -1,2 +1,6 @@
 
 
 
 
1
  transformers>=5.16.0
2
  accelerate
 
1
+ # torch is baked into the ZeroGPU base image. Do not add it here: an unpinned
2
+ # torch forces a reinstall that can land on a build without the matching CUDA
3
+ # wheels, and the Space stops getting a GPU.
4
+ spaces
5
  transformers>=5.16.0
6
  accelerate