MetiMiester commited on
Commit
20eb9eb
·
verified ·
1 Parent(s): 184b25a

Upload 10 files

Browse files
Files changed (11) hide show
  1. .gitattributes +2 -0
  2. Dockerfile +29 -0
  3. app_server.py +475 -0
  4. avatar_female.png +3 -0
  5. avatar_male.png +3 -0
  6. download_assets.py +119 -0
  7. index.html +420 -19
  8. logo.png +0 -0
  9. main.py +59 -0
  10. requirements.txt +25 -0
  11. styles.css +379 -0
.gitattributes CHANGED
@@ -33,3 +33,5 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ avatar_female.png filter=lfs diff=lfs merge=lfs -text
37
+ avatar_male.png filter=lfs diff=lfs merge=lfs -text
Dockerfile ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ---- BubbleGuard (FastAPI) – Dockerfile for Hugging Face Spaces ----
2
+ FROM python:3.10-slim
3
+
4
+ # Environment & pip behavior
5
+ ENV DEBIAN_FRONTEND=noninteractive \
6
+ PIP_NO_CACHE_DIR=1 \
7
+ PYTHONDONTWRITEBYTECODE=1 \
8
+ PYTHONUNBUFFERED=1
9
+
10
+ # System deps (ffmpeg needed for faster-whisper)
11
+ RUN apt-get update && apt-get install -y --no-install-recommends \
12
+ ffmpeg \
13
+ && rm -rf /var/lib/apt/lists/*
14
+
15
+ # Workdir
16
+ WORKDIR /app
17
+
18
+ # Python deps first (better layer caching)
19
+ COPY requirements.txt .
20
+ RUN python -m pip install --upgrade pip && pip install -r requirements.txt
21
+
22
+ # App code + static
23
+ COPY . .
24
+
25
+ # Hugging Face injects $PORT. Host is 0.0.0.0 for container networking.
26
+ ENV HOST=0.0.0.0
27
+
28
+ # Start the app (main.py loads assets from Google Drive, then runs uvicorn)
29
+ CMD ["python", "main.py"]
app_server.py ADDED
@@ -0,0 +1,475 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # server_app.py — BubbleGuard API + Dating-style Web Chat (Static UI)
2
+ # Version: 1.6.4 (mapping & greetings fix)
3
+ # Changes vs 1.6.3:
4
+ # - Robust SAFE/UNSAFE index resolution (env override → name hints → probe fallback)
5
+ # - Friendly greeting allow-list so benign openers are always SAFE
6
+ # - Startup log prints resolved SAFE_ID/UNSAFE_ID and id2label
7
+ # Author: Amir
8
+
9
+ import io
10
+ import os
11
+ import re
12
+ import uuid
13
+ import pathlib
14
+ import tempfile
15
+ import subprocess
16
+ import unicodedata
17
+ from typing import Dict
18
+
19
+ from fastapi import FastAPI, UploadFile, File, Form, HTTPException
20
+ from fastapi.middleware.cors import CORSMiddleware
21
+ from fastapi.staticfiles import StaticFiles
22
+
23
+ import torch
24
+ import joblib
25
+ import torchvision
26
+ from torchvision import transforms
27
+ from transformers import RobertaTokenizerFast, AutoModelForSequenceClassification
28
+ from PIL import Image
29
+ from faster_whisper import WhisperModel
30
+
31
+ # -------------------------- Paths & Config --------------------------
32
+ BASE = pathlib.Path(__file__).resolve().parent
33
+ TEXT_DIR = BASE / "Text"
34
+ IMG_DIR = BASE / "Image"
35
+ AUD_DIR = BASE / "Audio"
36
+ STATIC_DIR = BASE / "static"
37
+
38
+ DEVICE = "cuda" if torch.cuda.is_available() else "cpu"
39
+ IMG_UNSAFE_THR = float(os.getenv("IMG_UNSAFE_THR", "0.5"))
40
+ WHISPER_MODEL_NAME = os.getenv("WHISPER_MODEL", "base") # large-v2 | medium | small | base | tiny
41
+
42
+ # Text thresholds and heuristics
43
+ TEXT_UNSAFE_THR = float(os.getenv("TEXT_UNSAFE_THR", "0.60")) # normal texts
44
+ SHORT_MSG_MAX_TOKENS = int(os.getenv("SHORT_MSG_MAX_TOKENS", "6")) # <= N tokens = "short"
45
+ SHORT_MSG_UNSAFE_THR = float(os.getenv("SHORT_MSG_UNSAFE_THR", "0.90")) # short msgs require very high unsafe to block
46
+
47
+ app = FastAPI(title="BubbleGuard API", version="1.6.4")
48
+
49
+ # CORS open for demo; restrict in production
50
+ app.add_middleware(
51
+ CORSMiddleware,
52
+ allow_origins=["*"],
53
+ allow_methods=["*"],
54
+ allow_headers=["*"],
55
+ )
56
+
57
+ # -------------------------- Text Classifier -------------------------
58
+ try:
59
+ tok = RobertaTokenizerFast.from_pretrained(TEXT_DIR, local_files_only=True)
60
+ txtM = AutoModelForSequenceClassification.from_pretrained(
61
+ TEXT_DIR, local_files_only=True
62
+ ).to(DEVICE).eval()
63
+ except Exception as e:
64
+ raise RuntimeError(f"Failed to load text model from {TEXT_DIR}: {e}")
65
+
66
+ # ------------------------ Label mapping (robust) --------------------
67
+ # Strategy:
68
+ # 1) Allow SAFE_ID/UNSAFE_ID env overrides if you want to pin indices explicitly.
69
+ # 2) Try to infer from label names (supports synonyms like "ok", "benign", "toxic", etc.).
70
+ # 3) Probe the model with obviously safe samples; whichever class receives higher mean prob is SAFE.
71
+
72
+ SAFE_LABEL_HINTS = {"safe", "ok", "clean", "benign", "non-toxic", "non_toxic", "non toxic"}
73
+ UNSAFE_LABEL_HINTS = {"unsafe", "toxic", "abuse", "harm", "offense", "nsfw", "not_safe", "not safe"}
74
+
75
+ def _infer_ids_by_name(model) -> (int, int):
76
+ try:
77
+ id2label = {int(k): str(v).lower() for k, v in model.config.id2label.items()}
78
+ except Exception:
79
+ return None, None
80
+ safe_idx = None; unsafe_idx = None
81
+ for i, name in id2label.items():
82
+ if any(h in name for h in SAFE_LABEL_HINTS):
83
+ safe_idx = i
84
+ if any(h in name for h in UNSAFE_LABEL_HINTS):
85
+ unsafe_idx = i
86
+ if safe_idx is not None and unsafe_idx is None:
87
+ unsafe_idx = 1 - safe_idx
88
+ if unsafe_idx is not None and safe_idx is None:
89
+ safe_idx = 1 - unsafe_idx
90
+ return safe_idx, unsafe_idx
91
+
92
+ @torch.no_grad()
93
+ def _infer_ids_by_probe(model, tok, device) -> (int, int):
94
+ samples = ["hi", "hello", "how are you", "nice to meet you", "thanks"]
95
+ enc = tok(samples, return_tensors="pt", truncation=True, padding=True, max_length=64)
96
+ enc = {k: v.to(device) for k, v in enc.items()}
97
+ logits = model(**enc).logits # [B, 2]
98
+ probs = torch.softmax(logits, dim=-1).mean(0) # [2]
99
+ safe_idx = int(torch.argmax(probs).item())
100
+ unsafe_idx = 1 - safe_idx
101
+ return safe_idx, unsafe_idx
102
+
103
+ def _resolve_safe_unsafe_ids(model, tok, device) -> (int, int):
104
+ # 1) Env overrides (pin explicit indices if you know them)
105
+ s_env = os.getenv("SAFE_ID"); u_env = os.getenv("UNSAFE_ID")
106
+ if s_env is not None and u_env is not None:
107
+ return int(s_env), int(u_env)
108
+ # 2) Name-based inference
109
+ s, u = _infer_ids_by_name(model)
110
+ if s is not None and u is not None:
111
+ return s, u
112
+ # 3) Probe fallback
113
+ return _infer_ids_by_probe(model, tok, device)
114
+
115
+ SAFE_ID, UNSAFE_ID = _resolve_safe_unsafe_ids(txtM, tok, DEVICE)
116
+ print(f"[BubbleGuard] SAFE_ID={SAFE_ID} UNSAFE_ID={UNSAFE_ID} id2label={getattr(txtM.config, 'id2label', None)}")
117
+
118
+ # ------------------------ Normalization utils -----------------------
119
+ def normalize(text: str) -> str:
120
+ """
121
+ Normalize for rules:
122
+ - Unicode NFKC (compatibility composition)
123
+ - Curly quotes/apostrophes -> straight
124
+ - Lowercase
125
+ - Strip to letters/numbers/space/' only (keeps apostrophes for don't)
126
+ - Collapse whitespace
127
+ """
128
+ if not isinstance(text, str):
129
+ return ""
130
+ t = unicodedata.normalize("NFKC", text)
131
+ t = t.replace("’", "'").replace("‘", "'").replace("“", '"').replace("”", '"')
132
+ t = t.lower()
133
+ t = re.sub(r"[^a-z0-9\s']", " ", t)
134
+ t = re.sub(r"\s+", " ", t).strip()
135
+ return t
136
+
137
+ # Allow-list: short, obviously harmless negations (lowercase, punctuation-light)
138
+ SAFE_PHRASES = [
139
+ r"^i don'?t$",
140
+ r"^i do not$",
141
+ r"^don'?t$",
142
+ r"^no$",
143
+ r"^not really$",
144
+ r"^i wouldn'?t$",
145
+ r"^i would not$",
146
+ r"^i don'?t like$", # common polite dislike
147
+ r"^i woulde?n'?t$", # catches wouldn't / wouldent
148
+ ]
149
+ SAFE_RE = re.compile("|".join(SAFE_PHRASES))
150
+
151
+ # Extra guard for one-word/very short negation replies
152
+ NEGATION_ONLY = re.compile(r"^(?:i\s+)?(?:do\s+not|don'?t|no|not)$")
153
+
154
+ # --- Neutral dislike heuristics (polite preferences) ---
155
+ NEUTRAL_DISLIKE = re.compile(r"^i don'?t like(?:\s+to)?\b")
156
+
157
+ # Things we should NOT relax on (protected classes / direct people)
158
+ SENSITIVE_TERMS = {
159
+ "people", "you", "him", "her", "them", "men", "women", "girls", "boys",
160
+ "muslim", "christian", "jew", "jews", "black", "white", "asian",
161
+ "gay", "lesbian", "trans", "transgender", "disabled",
162
+ "immigrants", "refugees", "poor", "old", "elderly", "fat", "skinny"
163
+ }
164
+
165
+ # Profanity/sexual terms that should stay blocked regardless
166
+ PROFANITY_TERMS = {
167
+ "fuck", "shit", "bitch", "pussy", "dick", "cunt", "slut", "whore"
168
+ }
169
+
170
+ # Friendly greetings / openers that should always be SAFE
171
+ GREETINGS = [
172
+ r"^hi$",
173
+ r"^hello$",
174
+ r"^hey(?: there)?$",
175
+ r"^how are (?:you|u)\b.*$",
176
+ r"^good (?:morning|afternoon|evening)\b.*$",
177
+ r"^what'?s up\b.*$",
178
+ r"^how'?s it going\b.*$",
179
+ ]
180
+ GREETING_RE = re.compile("|".join(GREETINGS))
181
+
182
+ @torch.no_grad()
183
+ def text_safe_payload(text: str) -> Dict:
184
+ # Normalize once for all rule checks (fixes mobile smart quotes)
185
+ clean = normalize(text)
186
+ toks = clean.split()
187
+
188
+ # --- HARD RULE A: single-word profanity is always UNSAFE ---
189
+ if len(toks) == 1 and toks[0] in PROFANITY_TERMS:
190
+ probs = [0.0, 0.0]; probs[UNSAFE_ID] = 1.0
191
+ return {
192
+ "safe": False,
193
+ "unsafe_prob": 1.0,
194
+ "label": "UNSAFE",
195
+ "probs": probs,
196
+ "tokens": 1,
197
+ "reason": "profanity_single_word",
198
+ "params": {
199
+ "TEXT_UNSAFE_THR": TEXT_UNSAFE_THR,
200
+ "SHORT_MSG_MAX_TOKENS": SHORT_MSG_MAX_TOKENS,
201
+ "SHORT_MSG_UNSAFE_THR": SHORT_MSG_UNSAFE_THR,
202
+ },
203
+ }
204
+
205
+ # --- HARD RULE B: short text with any profanity => UNSAFE ---
206
+ if len(toks) <= SHORT_MSG_MAX_TOKENS and any(t in PROFANITY_TERMS for t in toks):
207
+ probs = [0.0, 0.0]; probs[UNSAFE_ID] = 1.0
208
+ return {
209
+ "safe": False,
210
+ "unsafe_prob": 1.0,
211
+ "label": "UNSAFE",
212
+ "probs": probs,
213
+ "tokens": len(toks),
214
+ "reason": "profanity_short_text",
215
+ "params": {
216
+ "TEXT_UNSAFE_THR": TEXT_UNSAFE_THR,
217
+ "SHORT_MSG_MAX_TOKENS": SHORT_MSG_MAX_TOKENS,
218
+ "SHORT_MSG_UNSAFE_THR": SHORT_MSG_UNSAFE_THR,
219
+ },
220
+ }
221
+
222
+ # 1) Explicit allow-list / negation-only
223
+ if SAFE_RE.match(clean) or NEGATION_ONLY.match(clean):
224
+ probs = [0.0, 0.0]; probs[SAFE_ID] = 1.0
225
+ return {
226
+ "safe": True,
227
+ "unsafe_prob": 0.0,
228
+ "label": "SAFE",
229
+ "probs": probs,
230
+ "tokens": len(toks),
231
+ "reason": "allow_list",
232
+ "params": {
233
+ "TEXT_UNSAFE_THR": TEXT_UNSAFE_THR,
234
+ "SHORT_MSG_MAX_TOKENS": SHORT_MSG_MAX_TOKENS,
235
+ "SHORT_MSG_UNSAFE_THR": SHORT_MSG_UNSAFE_THR,
236
+ },
237
+ }
238
+
239
+ # 1b) Friendly greetings: always SAFE
240
+ if GREETING_RE.match(clean):
241
+ probs = [0.0, 0.0]; probs[SAFE_ID] = 1.0
242
+ return {
243
+ "safe": True,
244
+ "unsafe_prob": 0.0,
245
+ "label": "SAFE",
246
+ "probs": probs,
247
+ "tokens": len(toks),
248
+ "reason": "greeting_allow",
249
+ "params": {
250
+ "TEXT_UNSAFE_THR": TEXT_UNSAFE_THR,
251
+ "SHORT_MSG_MAX_TOKENS": SHORT_MSG_MAX_TOKENS,
252
+ "SHORT_MSG_UNSAFE_THR": SHORT_MSG_UNSAFE_THR,
253
+ },
254
+ }
255
+
256
+ # 2) Neutral-dislike: "i don't like [food/activity/...]" but NOT people/groups/profanity
257
+ if NEUTRAL_DISLIKE.match(clean):
258
+ has_sensitive = any(term in clean for term in SENSITIVE_TERMS)
259
+ has_profanity = any(term in clean for term in PROFANITY_TERMS)
260
+ if not has_sensitive and not has_profanity:
261
+ # Run model lightly; be very lenient to still catch outliers
262
+ enc = tok(text, return_tensors="pt", truncation=True, padding=True, max_length=512)
263
+ enc = {k: v.to(DEVICE) for k, v in enc.items()}
264
+ logits = txtM(**enc).logits[0]
265
+ probs = torch.softmax(logits, dim=-1).detach().cpu().tolist()
266
+ unsafe_prob = float(probs[UNSAFE_ID])
267
+ is_safe = unsafe_prob < 0.98
268
+ return {
269
+ "safe": bool(is_safe),
270
+ "unsafe_prob": unsafe_prob,
271
+ "label": "SAFE" if is_safe else "UNSAFE",
272
+ "probs": probs,
273
+ "tokens": int(enc["input_ids"].shape[1]),
274
+ "reason": "neutral_dislike_relaxed",
275
+ "params": {
276
+ "TEXT_UNSAFE_THR": TEXT_UNSAFE_THR,
277
+ "SHORT_MSG_MAX_TOKENS": SHORT_MSG_MAX_TOKENS,
278
+ "SHORT_MSG_UNSAFE_THR": SHORT_MSG_UNSAFE_THR,
279
+ },
280
+ }
281
+
282
+ # 3) Normal model path (short-text heuristic etc.)
283
+ enc = tok(text, return_tensors="pt", truncation=True, padding=True, max_length=512)
284
+ enc = {k: v.to(DEVICE) for k, v in enc.items()}
285
+ logits = txtM(**enc).logits[0]
286
+ probs = torch.softmax(logits, dim=-1).detach().cpu().tolist()
287
+ unsafe_prob = float(probs[UNSAFE_ID])
288
+ pred_idx = int(torch.argmax(logits))
289
+ num_tokens = int(enc["input_ids"].shape[1])
290
+
291
+ # Thresholds
292
+ if num_tokens <= SHORT_MSG_MAX_TOKENS:
293
+ is_safe = unsafe_prob < SHORT_MSG_UNSAFE_THR
294
+ reason = "short_msg_threshold"
295
+ else:
296
+ is_safe = unsafe_prob < TEXT_UNSAFE_THR
297
+ reason = "global_threshold"
298
+
299
+ # Robust label lookup (int or str key)
300
+ label = txtM.config.id2label.get(pred_idx, txtM.config.id2label.get(str(pred_idx), str(pred_idx)))
301
+ return {
302
+ "safe": bool(is_safe),
303
+ "unsafe_prob": unsafe_prob,
304
+ "label": label,
305
+ "probs": probs,
306
+ "tokens": num_tokens,
307
+ "reason": reason,
308
+ "params": {
309
+ "TEXT_UNSAFE_THR": TEXT_UNSAFE_THR,
310
+ "SHORT_MSG_MAX_TOKENS": SHORT_MSG_MAX_TOKENS,
311
+ "SHORT_MSG_UNSAFE_THR": SHORT_MSG_UNSAFE_THR,
312
+ },
313
+ }
314
+
315
+ # -------------------------- Image Classifier ------------------------
316
+ class SafetyResNet(torch.nn.Module):
317
+ def __init__(self):
318
+ super().__init__()
319
+ base = torchvision.models.resnet50(weights=None)
320
+ self.feature_extractor = torch.nn.Sequential(*list(base.children())[:8])
321
+ self.pool = torch.nn.AdaptiveAvgPool2d(1)
322
+ self.classifier = torch.nn.Sequential(
323
+ torch.nn.Linear(2048, 512),
324
+ torch.nn.ReLU(True),
325
+ torch.nn.Dropout(0.30),
326
+ torch.nn.Linear(512, 2),
327
+ )
328
+
329
+ def forward(self, x):
330
+ x = self.pool(self.feature_extractor(x))
331
+ return self.classifier(torch.flatten(x, 1))
332
+
333
+ try:
334
+ imgM = SafetyResNet().to(DEVICE)
335
+ imgM.load_state_dict(
336
+ torch.load(IMG_DIR / "resnet_safety_classifier.pth", map_location=DEVICE),
337
+ strict=True
338
+ )
339
+ imgM.eval()
340
+ except Exception as e:
341
+ raise RuntimeError(f"Failed to load image model weights from {IMG_DIR}: {e}")
342
+
343
+ img_tf = transforms.Compose([
344
+ transforms.Resize(256),
345
+ transforms.CenterCrop(224),
346
+ transforms.ToTensor(),
347
+ transforms.Normalize([0.485, 0.456, 0.406], [0.229, 0.224, 0.225]),
348
+ ])
349
+
350
+ @torch.no_grad()
351
+ def image_safe_payload(pil_img: Image.Image) -> Dict:
352
+ x = img_tf(pil_img.convert("RGB")).unsqueeze(0).to(DEVICE)
353
+ logits = imgM(x)[0]
354
+ probs = torch.softmax(logits, dim=0).detach().cpu().tolist()
355
+ unsafe_p = float(probs[UNSAFE_ID]) # assumes class order [SAFE_ID, UNSAFE_ID]
356
+ return {"safe": unsafe_p < IMG_UNSAFE_THR, "unsafe_prob": unsafe_p, "probs": probs}
357
+
358
+ # -------------------------- Audio (ASR -> NLP) ----------------------
359
+ compute_type = "float16" if DEVICE == "cuda" else "int8"
360
+ try:
361
+ asr = WhisperModel(WHISPER_MODEL_NAME, device=DEVICE, compute_type=compute_type)
362
+ except Exception as e:
363
+ raise RuntimeError(f"Failed to load Whisper model '{WHISPER_MODEL_NAME}': {e}")
364
+
365
+ try:
366
+ text_clf = joblib.load(AUD_DIR / "text_pipeline_balanced.joblib")
367
+ except Exception as e:
368
+ raise RuntimeError(f"Failed to load audio text pipeline from {AUD_DIR}: {e}")
369
+
370
+ def _ffmpeg_to_wav(src_bytes: bytes) -> bytes:
371
+ """Convert arbitrary browser audio to 16kHz mono WAV using ffmpeg. If ffmpeg is missing, raise a clear error."""
372
+ with tempfile.TemporaryDirectory() as td:
373
+ in_path = pathlib.Path(td) / f"in-{uuid.uuid4().hex}"
374
+ out_path = pathlib.Path(td) / "out.wav"
375
+ in_path.write_bytes(src_bytes)
376
+ cmd = ["ffmpeg", "-y", "-i", str(in_path), "-ac", "1", "-ar", "16000", str(out_path)]
377
+ try:
378
+ subprocess.run(cmd, check=True, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
379
+ return out_path.read_bytes()
380
+ except FileNotFoundError as e:
381
+ raise RuntimeError("FFmpeg not found. Install it and ensure 'ffmpeg' is on PATH.") from e
382
+ except subprocess.CalledProcessError:
383
+ # If conversion fails, try assuming it's already a readable WAV
384
+ return src_bytes
385
+
386
+ def _transcribe_wav_bytes(wav_bytes: bytes) -> str:
387
+ """Write to disk (closed file) then transcribe. Avoids Windows file locking."""
388
+ td = tempfile.mkdtemp()
389
+ path = pathlib.Path(td) / "in.wav"
390
+ try:
391
+ path.write_bytes(wav_bytes)
392
+ segments, _ = asr.transcribe(str(path), beam_size=5, language="en")
393
+ return " ".join(s.text for s in segments).strip()
394
+ finally:
395
+ try:
396
+ path.unlink(missing_ok=True)
397
+ except Exception:
398
+ pass
399
+ try:
400
+ pathlib.Path(td).rmdir()
401
+ except Exception:
402
+ pass
403
+
404
+ def audio_safe_from_bytes(raw_bytes: bytes) -> Dict:
405
+ wav = _ffmpeg_to_wav(raw_bytes)
406
+ text = _transcribe_wav_bytes(wav)
407
+ proba = text_clf.predict_proba([text])[0].tolist()
408
+ unsafe_p = float(proba[UNSAFE_ID])
409
+ return {
410
+ "safe": unsafe_p < 0.5, # tune with AUDIO_UNSAFE_THR if you want parity
411
+ "unsafe_prob": unsafe_p,
412
+ "text": text,
413
+ "probs": proba,
414
+ }
415
+
416
+ # ------------------------------ Routes ------------------------------
417
+ @app.get("/health")
418
+ def health():
419
+ return {
420
+ "ok": True,
421
+ "device": DEVICE,
422
+ "whisper": WHISPER_MODEL_NAME,
423
+ "img_unsafe_threshold": IMG_UNSAFE_THR,
424
+ "text_thresholds": {
425
+ "TEXT_UNSAFE_THR": TEXT_UNSAFE_THR,
426
+ "SHORT_MSG_MAX_TOKENS": SHORT_MSG_MAX_TOKENS,
427
+ "SHORT_MSG_UNSAFE_THR": SHORT_MSG_UNSAFE_THR,
428
+ },
429
+ "safe_unsafe_indices": {
430
+ "SAFE_ID": SAFE_ID,
431
+ "UNSAFE_ID": UNSAFE_ID,
432
+ }
433
+ }
434
+
435
+ @app.post("/check_text")
436
+ def check_text(text: str = Form(...)):
437
+ if not text or not text.strip():
438
+ raise HTTPException(400, "Empty text")
439
+ try:
440
+ return text_safe_payload(text)
441
+ except Exception as e:
442
+ raise HTTPException(500, f"Text screening error: {e}")
443
+
444
+ @app.post("/check_image")
445
+ async def check_image(file: UploadFile = File(...)):
446
+ data = await file.read()
447
+ if not data:
448
+ raise HTTPException(400, "Empty image")
449
+ try:
450
+ pil = Image.open(io.BytesIO(data))
451
+ except Exception:
452
+ raise HTTPException(400, "Invalid image")
453
+ try:
454
+ return image_safe_payload(pil)
455
+ except Exception as e:
456
+ raise HTTPException(500, f"Image screening error: {e}")
457
+
458
+ @app.post("/check_audio")
459
+ async def check_audio(file: UploadFile = File(...)):
460
+ raw = await file.read()
461
+ if not raw:
462
+ raise HTTPException(400, "Empty audio")
463
+ try:
464
+ return audio_safe_from_bytes(raw)
465
+ except RuntimeError as e:
466
+ print("AUDIO_ERR:", repr(e))
467
+ raise HTTPException(500, f"{e}")
468
+ except Exception as e:
469
+ print("AUDIO_ERR:", repr(e))
470
+ raise HTTPException(500, f"Audio processing error: {e}")
471
+
472
+ # --------------------------- Static Mount ---------------------------
473
+ # Serves your chat UI at "/" if STATIC_DIR.exists():
474
+ if STATIC_DIR.exists():
475
+ app.mount("/", StaticFiles(directory=str(STATIC_DIR), html=True), name="static")
avatar_female.png ADDED

Git LFS Details

  • SHA256: ad9155cf8558b7b334d3c6d485f13f6ae0102383523090888de4e37948dca98f
  • Pointer size: 132 Bytes
  • Size of remote file: 1.86 MB
avatar_male.png ADDED

Git LFS Details

  • SHA256: 2601ebf20872f500100ecf7ce9ccd266f276e6086e88809b9e98823243511c22
  • Pointer size: 132 Bytes
  • Size of remote file: 1.45 MB
download_assets.py ADDED
@@ -0,0 +1,119 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # download_assets.py — fetch models from a Google Drive FOLDER (no zips)
2
+ # Expects a folder with subfolders: Text/, Image/, Audio/
3
+ # Env: GDRIVE_FOLDER_ID=<your folder id>
4
+ # Coder: Amir
5
+
6
+ import os, sys, shutil, pathlib
7
+ from typing import Optional
8
+ from filelock import FileLock
9
+ import gdown
10
+
11
+ BASE = pathlib.Path(__file__).resolve().parent
12
+ CACHE_DIR = BASE / "cache"
13
+ DL_DIR = CACHE_DIR / "gdrive_folder" # where we download the Drive folder
14
+
15
+ TARGETS = {
16
+ "text": {
17
+ "dst": BASE / "Text",
18
+ "must": ["config.json", "tokenizer.json", "model.safetensors", "vocab.json"],
19
+ "folder_name": "Text",
20
+ },
21
+ "image": {
22
+ "dst": BASE / "Image",
23
+ "must": ["resnet_safety_classifier.pth", "clip_safety_classifier.pth"],
24
+ "folder_name": "Image",
25
+ },
26
+ "audio": {
27
+ "dst": BASE / "Audio",
28
+ "must": ["text_pipeline_balanced.joblib"],
29
+ "folder_name": "Audio",
30
+ },
31
+ }
32
+
33
+ def _ready(kind: str) -> bool:
34
+ info = TARGETS[kind]
35
+ d = info["dst"]
36
+ return d.exists() and all((d / m).exists() for m in info["must"])
37
+
38
+ def _find_subdir(root: pathlib.Path, name: str) -> Optional[pathlib.Path]:
39
+ lname = name.lower()
40
+ for p in root.rglob("*"):
41
+ if p.is_dir() and p.name.lower() == lname:
42
+ return p
43
+ return None
44
+
45
+ def _merge_copy(src: pathlib.Path, dst: pathlib.Path):
46
+ dst.mkdir(parents=True, exist_ok=True)
47
+ for root, dirs, files in os.walk(src):
48
+ rroot = pathlib.Path(root)
49
+ rel = rroot.relative_to(src)
50
+ # create subdirs
51
+ for d in dirs:
52
+ (dst / rel / d).mkdir(parents=True, exist_ok=True)
53
+ # copy files
54
+ for f in files:
55
+ s = rroot / f
56
+ t = dst / rel / f
57
+ t.parent.mkdir(parents=True, exist_ok=True)
58
+ shutil.copy2(s, t)
59
+
60
+ def _download_folder(folder_id: str):
61
+ DL_DIR.mkdir(parents=True, exist_ok=True)
62
+ lock = FileLock(str(DL_DIR) + ".lock")
63
+ with lock:
64
+ # If already downloaded once, skip re-download
65
+ if any(DL_DIR.iterdir()):
66
+ print("[assets] Drive folder already present; skipping fresh download.")
67
+ return
68
+ print(f"[assets] downloading Drive folder {folder_id} …")
69
+ gdown.download_folder(
70
+ id=folder_id,
71
+ output=str(DL_DIR),
72
+ quiet=False,
73
+ use_cookies=False,
74
+ remaining_ok=True,
75
+ )
76
+
77
+ def ensure_all_assets():
78
+ # If everything already exists, return fast
79
+ if all(_ready(k) for k in TARGETS):
80
+ print("[assets] all bundles already present.")
81
+ return
82
+
83
+ folder_id = os.getenv("GDRIVE_FOLDER_ID", "").strip()
84
+ if not folder_id:
85
+ raise RuntimeError("[assets] Please set GDRIVE_FOLDER_ID (top-level Drive folder with Text/Image/Audio)")
86
+
87
+ _download_folder(folder_id)
88
+
89
+ # Drive may nest one extra directory level; detect the top
90
+ # Pick the largest immediate subdir if needed, else use DL_DIR itself
91
+ root = DL_DIR
92
+ # If there is exactly one child dir and it contains our subfolders, use it
93
+ subdirs = [p for p in DL_DIR.iterdir() if p.is_dir()]
94
+ if len(subdirs) == 1:
95
+ maybe = subdirs[0]
96
+ if all(_find_subdir(maybe, TARGETS[k]["folder_name"]) for k in TARGETS):
97
+ root = maybe
98
+
99
+ # For each target, locate its folder and merge-copy into app dirs
100
+ for kind, info in TARGETS.items():
101
+ if _ready(kind):
102
+ print(f"[assets] {kind}: already ready.")
103
+ continue
104
+
105
+ src_dir = _find_subdir(root, info["folder_name"])
106
+ if not src_dir:
107
+ raise RuntimeError(f"[assets] {kind}: could not find '{info['folder_name']}' folder in Drive download")
108
+
109
+ print(f"[assets] {kind}: copying from {src_dir} → {info['dst']}")
110
+ _merge_copy(src_dir, info["dst"])
111
+
112
+ # sanity check
113
+ missing = [m for m in info["must"] if not (info["dst"] / m).exists()]
114
+ if missing:
115
+ raise RuntimeError(f"[assets] {kind}: missing files after copy → {missing}")
116
+
117
+ print(f"[assets] {kind}: ready.")
118
+
119
+ print("[assets] all bundles ready.")
index.html CHANGED
@@ -1,19 +1,420 @@
1
- <!doctype html>
2
- <html>
3
- <head>
4
- <meta charset="utf-8" />
5
- <meta name="viewport" content="width=device-width" />
6
- <title>My static Space</title>
7
- <link rel="stylesheet" href="style.css" />
8
- </head>
9
- <body>
10
- <div class="card">
11
- <h1>Welcome to your static Space!</h1>
12
- <p>You can modify this app directly by editing <i>index.html</i> in the Files and versions tab.</p>
13
- <p>
14
- Also don't forget to check the
15
- <a href="https://huggingface.co/docs/hub/spaces" target="_blank">Spaces documentation</a>.
16
- </p>
17
- </div>
18
- </body>
19
- </html>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!doctype html>
2
+ <html lang="en" data-bs-theme="light">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover" />
6
+ <title>BubbleGuard Safe Chat</title>
7
+ <link rel="icon" href="/logo.png">
8
+ <!-- Bootstrap 5.3 -->
9
+ <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
10
+ <!-- Apple-style theme -->
11
+ <link href="/styles.css" rel="stylesheet">
12
+ </head>
13
+ <body>
14
+ <div class="app">
15
+
16
+ <!-- Header -->
17
+ <header class="glass py-2 px-3">
18
+ <div class="container-fluid d-flex align-items-center gap-3">
19
+ <button class="btn btn-ico" type="button" aria-label="Back" title="Back">‹</button>
20
+ <div class="d-flex align-items-center gap-2">
21
+ <img src="/logo.png" alt="" class="rounded-3 header-logo" onerror="this.style.display='none'">
22
+ <div class="d-flex flex-column lh-1">
23
+ <div class="app-title">BubbleGuard</div>
24
+ <div id="health" class="subtle" aria-live="polite">Checking…</div>
25
+ </div>
26
+ </div>
27
+ <div class="ms-auto d-flex align-items-center gap-1">
28
+ <button id="theme" class="btn btn-ico" type="button" aria-label="Toggle theme" title="Appearance">🌓</button>
29
+ </div>
30
+ </div>
31
+ </header>
32
+
33
+ <!-- Chat -->
34
+ <main id="chat" class="container chat-wrap" aria-live="polite" aria-label="Chat history">
35
+ <!-- Greeting -->
36
+ <div class="row-start">
37
+ <img src="/avatar_female.png" alt="" class="avatar" onerror="this.style.display='none'">
38
+ <div class="bubble-them bubble shadow-bubble">
39
+ <div class="copy">Hey there 💖 Welcome to BubbleGuard’s safe chat! Share pics, voice notes, or messages — we’ll keep it kind.</div>
40
+ <div class="meta">now</div>
41
+ </div>
42
+ </div>
43
+ </main>
44
+
45
+ <!-- Composer -->
46
+ <footer class="composer-wrap">
47
+
48
+ <!-- Reply banner -->
49
+ <div id="replyBanner" class="reply-banner d-none" role="status" aria-live="polite">
50
+ <div class="rb-body">
51
+ <div class="rb-line">
52
+ <span class="rb-label">Replying to</span>
53
+ <span id="replySnippet" class="rb-snippet"></span>
54
+ </div>
55
+ <button id="replyCancel" class="btn-ico rb-close" aria-label="Cancel reply">✕</button>
56
+ </div>
57
+ </div>
58
+
59
+ <div class="container composer">
60
+ <input id="fileImg" type="file" accept="image/*" class="d-none" aria-hidden="true">
61
+
62
+ <button id="btnImg" class="btn btn-ico" title="Attach image" aria-label="Attach image">+</button>
63
+
64
+ <div class="input-shell">
65
+ <!-- UPDATED placeholder text here -->
66
+ <textarea id="input" rows="1" placeholder="Write a message here…" class="form-control input-ios" aria-label="Message input"></textarea>
67
+ <div id="typing" class="typing d-none" aria-hidden="true">
68
+ <span class="typing-dot"></span><span class="typing-dot"></span><span class="typing-dot"></span>
69
+ </div>
70
+ </div>
71
+
72
+ <div class="audio-controls d-flex align-items-center gap-1">
73
+ <button id="btnStart" class="btn btn-ico" title="Record" aria-label="Start recording">🎤</button>
74
+ <button id="btnStop" class="btn btn-ico" title="Stop" aria-label="Stop recording" disabled>⏹</button>
75
+ <span id="recTimer" class="pill subtle d-none" aria-live="polite">00:00</span>
76
+ </div>
77
+
78
+ <button id="btnSend" class="btn send-ios" aria-label="Send">↑</button>
79
+ </div>
80
+
81
+ <!-- Toast -->
82
+ <div class="toast-zone">
83
+ <div id="toast" class="toast ios-toast" role="alert" aria-live="assertive" aria-atomic="true">
84
+ <div class="toast-body" id="toastBody">Hello</div>
85
+ </div>
86
+ </div>
87
+ </footer>
88
+ </div>
89
+
90
+ <!-- Bootstrap JS -->
91
+ <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
92
+
93
+ <!-- App Script -->
94
+ <script>
95
+ // ---------- Config ----------
96
+ const baseURL = ''; // same-origin; set full origin if tunneling under a subpath
97
+ const api = (p) => baseURL + p;
98
+
99
+ // ---------- Theme ----------
100
+ const setTheme = (t)=> document.documentElement.setAttribute('data-bs-theme', t);
101
+ const saved = localStorage.getItem('bg-theme');
102
+ const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
103
+ setTheme(saved || (prefersDark ? 'dark' : 'light'));
104
+ document.getElementById('theme').onclick = () => {
105
+ const cur = document.documentElement.getAttribute('data-bs-theme');
106
+ const next = cur === 'dark' ? 'light' : 'dark';
107
+ setTheme(next); localStorage.setItem('bg-theme', next);
108
+ };
109
+
110
+ // ---------- Health ----------
111
+ (async () => {
112
+ try {
113
+ const r = await fetch(api('/health'));
114
+ if (!r.ok) throw 0;
115
+ const j = await r.json();
116
+ const t = j.text_thresholds || {};
117
+ const h = document.getElementById('health');
118
+ h.textContent = `Online · ${j.device} · T=${t.TEXT_UNSAFE_THR ?? '-'} · S=${t.SHORT_MSG_UNSAFE_THR ?? '-'}`;
119
+ } catch {
120
+ document.getElementById('health').textContent = 'Offline';
121
+ }
122
+ })();
123
+
124
+ // ---------- DOM helpers ----------
125
+ const $ = (id)=>document.getElementById(id);
126
+ const chat = $('chat'), input=$('input'), typing=$('typing');
127
+ const btnSend=$('btnSend'), btnImg=$('btnImg'), fileImg=$('fileImg');
128
+ const btnStart=$('btnStart'), btnStop=$('btnStop'), recTimer=$('recTimer');
129
+ const toastEl = $('toast'), toastBody = $('toastBody');
130
+ const toast = new bootstrap.Toast(toastEl, { delay: 4200 });
131
+
132
+ const timeNow = () => new Date().toLocaleTimeString([], {hour:'2-digit', minute:'2-digit'});
133
+ const scrollBottom = () => { chat.scrollTop = chat.scrollHeight; };
134
+ const showToast = (msg) => { toastBody.textContent = msg; toast.show(); };
135
+ const esc = (s)=>s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
136
+
137
+ // ---------- Reactions & Reply state ----------
138
+ const REACTIONS = ["👍","❤️","😂","😮","😢"];
139
+ let replyTarget = null; // { el, text }
140
+
141
+ function markDelivered(bubble, double=false){
142
+ const meta = bubble.querySelector('.meta');
143
+ if(!meta) return;
144
+ let ticks = meta.querySelector('.ticks');
145
+ if(!ticks){
146
+ ticks = document.createElement('span');
147
+ ticks.className = 'ticks';
148
+ meta.appendChild(ticks);
149
+ }
150
+ ticks.innerHTML = double ? '<span class="tick-double"></span>' : '<span class="tick-solo"></span>';
151
+ }
152
+
153
+ function showReactionsPop(bubble){
154
+ hideReactionsPop();
155
+ const pop = document.createElement('div');
156
+ pop.className = 'react-pop';
157
+ pop.setAttribute('role','menu');
158
+ REACTIONS.forEach(e=>{
159
+ const b=document.createElement('button');
160
+ b.type='button'; b.textContent=e; b.setAttribute('aria-label',`React ${e}`);
161
+ b.onclick = (ev)=>{ ev.stopPropagation(); toggleReaction(bubble, e); hideReactionsPop(); };
162
+ pop.appendChild(b);
163
+ });
164
+ bubble.appendChild(pop);
165
+ setTimeout(()=>document.addEventListener('click', hideReactionsPop, { once:true }), 0);
166
+ }
167
+ function hideReactionsPop(){
168
+ document.querySelectorAll('.react-pop').forEach(p=>p.remove());
169
+ }
170
+
171
+ function toggleReaction(bubble, emoji){
172
+ bubble._reactions = bubble._reactions || new Map();
173
+ const meKey = `me:${emoji}`;
174
+ if(bubble._reactions.has(meKey)) bubble._reactions.delete(meKey);
175
+ else bubble._reactions.set(meKey, 1);
176
+ renderReactions(bubble);
177
+ }
178
+ function renderReactions(bubble){
179
+ const counts = {};
180
+ (bubble._reactions||new Map()).forEach((v,k)=>{
181
+ const em = k.split(':')[1];
182
+ counts[em] = (counts[em]||0) + 1;
183
+ });
184
+ let row = bubble.querySelector('.react-row');
185
+ if(!row){
186
+ row = document.createElement('div'); row.className='react-row';
187
+ bubble.appendChild(row);
188
+ }
189
+ row.innerHTML = '';
190
+ Object.entries(counts).sort((a,b)=>b[1]-a[1]).forEach(([em,c])=>{
191
+ const chip = document.createElement('span');
192
+ chip.className='react-chip';
193
+ chip.innerHTML = `${em} <span class="count">${c}</span>`;
194
+ row.appendChild(chip);
195
+ });
196
+ if(Object.keys(counts).length===0) row.remove();
197
+ }
198
+
199
+ function startReply(bubble){
200
+ const textNode = bubble.querySelector('.copy')?.textContent ?? '';
201
+ replyTarget = { el: bubble, text: textNode.trim().slice(0, 60) };
202
+ $('replySnippet').textContent = replyTarget.text || '(media)';
203
+ $('replyBanner').classList.remove('d-none');
204
+ bubble.classList.add('swipe-hint');
205
+ setTimeout(()=>bubble.classList.remove('swipe-hint'), 900);
206
+ }
207
+ function cancelReply(){
208
+ replyTarget = null;
209
+ $('replyBanner').classList.add('d-none');
210
+ }
211
+ $('replyCancel').onclick = cancelReply;
212
+
213
+ // ---------- Bubble creators ----------
214
+ function armBubbleInteractions(bubble, isThem=false){
215
+ // Reactions: long-press / right-click
216
+ let pressTimer = null;
217
+ const startPress = ()=>{ pressTimer=setTimeout(()=>showReactionsPop(bubble), 380); };
218
+ const endPress = ()=>{ clearTimeout(pressTimer); };
219
+
220
+ bubble.addEventListener('contextmenu', (e)=>{ e.preventDefault(); showReactionsPop(bubble); });
221
+ bubble.addEventListener('pointerdown', startPress);
222
+ bubble.addEventListener('pointerup', endPress);
223
+ bubble.addEventListener('pointerleave', endPress);
224
+
225
+ // Swipe-to-reply on "them" bubbles (mobile)
226
+ if(isThem){
227
+ let sx=0, dx=0;
228
+ bubble.addEventListener('touchstart', (e)=>{ sx = e.touches[0].clientX; dx=0; }, {passive:true});
229
+ bubble.addEventListener('touchmove', (e)=>{
230
+ dx = e.touches[0].clientX - sx;
231
+ if(dx>12) bubble.style.transform = `translateX(${Math.min(dx, 72)}px)`;
232
+ }, {passive:true});
233
+ bubble.addEventListener('touchend', ()=>{
234
+ if(dx>56) startReply(bubble);
235
+ bubble.style.transform = '';
236
+ });
237
+ }
238
+ }
239
+
240
+ function bubbleMe(html) {
241
+ const row = document.createElement('div'); row.className='row-end';
242
+ row.innerHTML = `
243
+ <div class="bubble-you bubble shadow-bubble">
244
+ <div class="copy">${html}</div>
245
+ <div class="meta">${timeNow()}</div>
246
+ </div>
247
+ <img src="/avatar_male.png" alt="" class="avatar" onerror="this.style.display='none'">
248
+ `;
249
+ chat.appendChild(row); scrollBottom();
250
+ const b = row.querySelector('.bubble-you');
251
+ armBubbleInteractions(b, false);
252
+ setTimeout(()=>markDelivered(b, true), 450);
253
+ return b;
254
+ }
255
+
256
+ function bubbleThem(html) {
257
+ const row = document.createElement('div'); row.className='row-start';
258
+ row.innerHTML = `
259
+ <img src="/avatar_female.png" alt="" class="avatar" onerror="this.style.display='none'">
260
+ <div class="bubble-them bubble shadow-bubble">
261
+ <div class="copy">${html}</div>
262
+ <div class="meta">${timeNow()}</div>
263
+ </div>
264
+ `;
265
+ chat.appendChild(row); scrollBottom();
266
+ const b = row.querySelector('.bubble-them');
267
+ armBubbleInteractions(b, true);
268
+ return b;
269
+ }
270
+
271
+ // ---------- Blast helper (block animation) ----------
272
+ function blastBubble({ html, side='you', preview=true, icon='🚫', removeDelay=900 }){
273
+ const content = preview
274
+ ? `<span class="unsafe-icon" aria-hidden="true">${icon}</span><span class="unsafe-preview">${html}</span>`
275
+ : `<span class="unsafe-icon" aria-hidden="true">${icon}</span>`;
276
+ const bubble = side === 'you' ? bubbleMe(content) : bubbleThem(content);
277
+ bubble.classList.add('bubble-blast');
278
+ setTimeout(()=> bubble.closest('.row-start, .row-end')?.remove(), removeDelay);
279
+ }
280
+
281
+ // ---------- Input behavior ----------
282
+ function setTyping(on){ typing.classList.toggle('d-none', !on); }
283
+ input.addEventListener('input', ()=>{
284
+ input.style.height='auto';
285
+ input.style.height=Math.min(input.scrollHeight, 140)+'px';
286
+ });
287
+ input.addEventListener('keydown',(e)=>{
288
+ if(e.key==='Escape'){ input.value=''; input.style.height='auto'; }
289
+ if(e.key==='Enter' && !e.shiftKey){ e.preventDefault(); sendText(); }
290
+ });
291
+
292
+ // Normalize curly quotes/apostrophes BEFORE sending
293
+ function normalizeInputText(text){
294
+ return text.replace(/[’‘]/g, "'").replace(/[“”]/g, '"').replace(/\s+/g, ' ').trim();
295
+ }
296
+
297
+ // ---------- Send text ----------
298
+ async function sendText(){
299
+ let t = input.value;
300
+ t = normalizeInputText(t);
301
+ if(!t) return;
302
+
303
+ // If replying, prefix a quote (client-side only)
304
+ if(replyTarget){
305
+ const quoted = replyTarget.text ? `> ${replyTarget.text}\n` : '';
306
+ t = `${quoted}${t}`;
307
+ }
308
+
309
+ setTyping(true); btnSend.disabled=true;
310
+ try{
311
+ const fd = new FormData(); fd.append('text', t);
312
+ const r = await fetch(api('/check_text'),{method:'POST', body:fd});
313
+ const j = await r.json();
314
+
315
+ if (r.ok && j && typeof j.safe !== 'undefined') {
316
+ if (j.safe) {
317
+ bubbleMe(esc(t));
318
+ cancelReply();
319
+ } else {
320
+ blastBubble({ html: esc(t), side: 'you', preview: true, icon: '🚫' });
321
+ const reason = j.reason ? ` (${j.reason}${j.unsafe_prob!=null?` · p=${(+j.unsafe_prob).toFixed(2)}`:''})` : '';
322
+ showToast('Message blocked as unsafe' + reason);
323
+ }
324
+ } else {
325
+ showToast('Unexpected response');
326
+ }
327
+ }catch(e){ showToast('Error: '+e); }
328
+ finally{
329
+ input.value=''; input.style.height='auto';
330
+ setTyping(false); btnSend.disabled=false;
331
+ }
332
+ }
333
+ btnSend.onclick = sendText;
334
+
335
+ // ---------- Image: click & drag-drop ----------
336
+ btnImg.onclick = ()=> fileImg.click();
337
+ fileImg.onchange = async ()=>{ if(fileImg.files[0]) await handleImage(fileImg.files[0]); fileImg.value=''; };
338
+
339
+ async function handleImage(file){
340
+ setTyping(true);
341
+ try{
342
+ const fd = new FormData(); fd.append('file', file);
343
+ const r = await fetch(api('/check_image'),{method:'POST', body:fd});
344
+ const j = await r.json();
345
+ if(j.safe){
346
+ const url = URL.createObjectURL(file);
347
+ bubbleMe(`<img src="${url}" class="chat-image" alt="Sent image">`);
348
+ } else {
349
+ blastBubble({ html: 'Image blocked', side: 'you', preview: false, icon: '🖼️' });
350
+ const reason = j.unsafe_prob!=null?` (p=${(+j.unsafe_prob).toFixed(2)})`:''; showToast('Image blocked as unsafe' + reason);
351
+ }
352
+ }catch(e){ showToast('Error: '+e); }
353
+ finally{ setTyping(false); }
354
+ }
355
+
356
+ // Drag & Drop (image only)
357
+ ['dragenter','dragover'].forEach(ev=>document.addEventListener(ev, e=>{ e.preventDefault(); chat.classList.add('drop'); }, false));
358
+ ;['dragleave','drop'].forEach(ev=>document.addEventListener(ev, e=>{ e.preventDefault(); chat.classList.remove('drop'); }, false));
359
+ document.addEventListener('drop', async (e)=>{
360
+ const f = e.dataTransfer?.files?.[0];
361
+ if(!f) return;
362
+ if (f.type.startsWith('image/')) await handleImage(f);
363
+ else showToast('Only images supported via drop.');
364
+ }, false);
365
+
366
+ // ---------- Voice ----------
367
+ let mediaStream=null, mediaRecorder=null, chunks=[], tick=null, startTs=0;
368
+ const pickMime = () => {
369
+ const prefs=['audio/webm;codecs=opus','audio/webm','audio/mp4;codecs=mp4a.40.2','audio/mp4','audio/ogg;codecs=opus','audio/ogg'];
370
+ for(const m of prefs) if (window.MediaRecorder && MediaRecorder.isTypeSupported(m)) return m;
371
+ return '';
372
+ };
373
+ async function ensureMic(){ if(!mediaStream) mediaStream = await navigator.mediaDevices.getUserMedia({audio:true}); }
374
+ function setRecUI(recording){
375
+ btnStart.disabled=recording; btnStop.disabled=!recording;
376
+ recTimer.classList.toggle('d-none',!recording);
377
+ }
378
+ const fmt = (t)=>{ const m=Math.floor(t/60), s=Math.floor(t%60); return `${String(m).padStart(2,'0')}:${String(s).padStart(2,'0')}`; };
379
+
380
+ btnStart.onclick = async ()=>{
381
+ try{
382
+ await ensureMic(); chunks=[];
383
+ const mime = pickMime();
384
+ mediaRecorder = new MediaRecorder(mediaStream, mime? {mimeType:mime}:{});
385
+ mediaRecorder.ondataavailable = e => { if(e.data && e.data.size) chunks.push(e.data); };
386
+ mediaRecorder.onstop = onRecordingStop;
387
+ mediaRecorder.start(250);
388
+ startTs = Date.now();
389
+ tick = setInterval(()=> recTimer.textContent = fmt((Date.now()-startTs)/1000), 300);
390
+ setRecUI(true);
391
+ setTimeout(()=>{ if(mediaRecorder && mediaRecorder.state==='recording') btnStop.click(); }, 60000);
392
+ }catch(e){ showToast('Mic error: '+e); }
393
+ };
394
+ btnStop.onclick = ()=>{
395
+ if(mediaRecorder && mediaRecorder.state==='recording'){ mediaRecorder.stop(); }
396
+ if(tick){ clearInterval(tick); tick=null; }
397
+ setRecUI(false);
398
+ };
399
+
400
+ async function onRecordingStop(){
401
+ try{
402
+ setTyping(true);
403
+ const type = mediaRecorder?.mimeType || 'audio/webm';
404
+ const blob = new Blob(chunks, { type });
405
+ const fd = new FormData(); fd.append('file', blob, 'voice');
406
+ const r = await fetch(api('/check_audio'), { method:'POST', body: fd });
407
+ const j = await r.json();
408
+ if(j.safe){
409
+ const url = URL.createObjectURL(blob);
410
+ bubbleMe(`<audio controls src="${url}" class="audio-ios"></audio>`);
411
+ }else{
412
+ blastBubble({ html: 'Voice note blocked', side: 'you', preview: false, icon: '🔊' });
413
+ const reason = j.unsafe_prob!=null?` (p=${(+j.unsafe_prob).toFixed(2)})`:''; showToast('Voice note blocked as unsafe' + reason);
414
+ }
415
+ }catch(e){ showToast('Error: '+e); }
416
+ finally{ setTyping(false); }
417
+ }
418
+ </script>
419
+ </body>
420
+ </html>
logo.png ADDED
main.py ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # main.py — ASGI entrypoint for BubbleGuard Web (HF-ready, with GDrive fetch)
2
+ # Coder: Amir
3
+ # ------------------------------------------------
4
+ # - Downloads model bundles from Google Drive (via download_assets.py)
5
+ # - Starts Uvicorn serving app_server:app
6
+ # - Respects HOST/PORT env (PORT is injected by Hugging Face Spaces)
7
+
8
+ import os
9
+ import sys
10
+ import traceback
11
+
12
+ def _truthy(val: str) -> bool:
13
+ return str(val).strip().lower() in {"1", "true", "yes", "on"}
14
+
15
+ def main() -> None:
16
+ # --- Env & logging setup ---
17
+ host = os.getenv("HOST", "0.0.0.0")
18
+ port = int(os.getenv("PORT", "8000"))
19
+ debug = _truthy(os.getenv("DEBUG", "0"))
20
+
21
+ print(f"[BubbleGuard] Starting server on {host}:{port}")
22
+ folder_id = os.getenv("GDRIVE_FOLDER_ID", "").strip()
23
+ if folder_id:
24
+ print(f"[BubbleGuard] Using Google Drive folder: {folder_id}")
25
+ else:
26
+ print("[BubbleGuard] No GDRIVE_FOLDER_ID set (will try per-file IDs if provided).")
27
+
28
+ # --- Ensure assets (idempotent) before importing the app ---
29
+ try:
30
+ from download_assets import ensure_all_assets
31
+ ensure_all_assets()
32
+ except Exception as e:
33
+ print("[BubbleGuard] ERROR while preparing assets:", file=sys.stderr)
34
+ traceback.print_exc()
35
+ raise SystemExit(1)
36
+
37
+ # --- Run Uvicorn ---
38
+ try:
39
+ import uvicorn
40
+ # Use string import so app is imported after assets are ready
41
+ uvicorn.run(
42
+ "app_server:app",
43
+ host=host,
44
+ port=port,
45
+ reload=False, # safer with large models
46
+ workers=1, # avoid duplicating model memory
47
+ log_level="debug" if debug else "info",
48
+ proxy_headers=True, # play nice behind proxies (HF Spaces)
49
+ forwarded_allow_ips="*",
50
+ timeout_keep_alive=10,
51
+ access_log=True,
52
+ )
53
+ except Exception:
54
+ print("[BubbleGuard] Uvicorn failed to start:", file=sys.stderr)
55
+ traceback.print_exc()
56
+ raise SystemExit(1)
57
+
58
+ if __name__ == "__main__":
59
+ main()
requirements.txt ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ --extra-index-url https://download.pytorch.org/whl/cpu
2
+
3
+ fastapi==0.112.2
4
+ uvicorn[standard]==0.30.6
5
+ python-multipart==0.0.9
6
+
7
+ torch==2.3.1+cpu
8
+ torchvision==0.18.1+cpu
9
+ torchaudio==2.3.1+cpu
10
+
11
+ transformers==4.43.4
12
+ accelerate==0.33.0
13
+ huggingface-hub==0.24.5
14
+ safetensors==0.4.3
15
+
16
+ numpy==1.26.4
17
+ Pillow==10.4.0
18
+ joblib==1.4.2
19
+
20
+ faster-whisper==1.0.3
21
+ soundfile==0.12.1
22
+
23
+ gdown==5.2.0
24
+ tqdm==4.66.4
25
+ filelock==3.15.4
styles.css ADDED
@@ -0,0 +1,379 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* =========================
2
+ BubbleGuard Apple-Style UI
3
+ Dark-mode optimized (2025)
4
+ ========================= */
5
+
6
+ /* ---------- Design Tokens ---------- */
7
+ :root{
8
+ /* Typography */
9
+ --font-ui: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, "Apple Color Emoji", "Segoe UI Emoji";
10
+
11
+ /* Light palette */
12
+ --bg: #f5f5f7;
13
+ --bg-radial-1: #ffffff;
14
+ --bg-radial-2: rgba(10,132,255,.08);
15
+ --sheet: #ffffffcc; /* frosted surfaces */
16
+ --border: #e5e5ea;
17
+ --text: #111114;
18
+ --muted: #6e6e73;
19
+
20
+ /* Bubbles */
21
+ --blue: #0A84FF; /* outgoing */
22
+ --blue-press: #0a7af0;
23
+ --incoming: #E5E5EA; /* incoming */
24
+ --incoming-text: #000;
25
+
26
+ /* Accents */
27
+ --focus: rgba(10,132,255,.5);
28
+ --focus-outer: rgba(10,132,255,.15);
29
+
30
+ /* Shadows */
31
+ --shadow: 0 6px 24px rgba(0,0,0,.06);
32
+ --shadow-strong: 0 10px 28px rgba(0,0,0,.10);
33
+
34
+ /* Overlays/Pop */
35
+ --popover-bg: rgba(28,28,30,.96);
36
+ --chip-bg: rgba(118,118,128,.18);
37
+
38
+ /* Misc */
39
+ --danger: #ff3b30;
40
+
41
+ /* Timestamp color (light mode) */
42
+ --meta-color: #000;
43
+ }
44
+
45
+ [data-bs-theme="dark"]{
46
+ /* Dark palette tuned for contrast */
47
+ --bg: #0b0b0f;
48
+ --bg-radial-1: #121217;
49
+ --bg-radial-2: rgba(10,132,255,.10);
50
+ --sheet: rgba(22,22,26,.76);
51
+ --border: #2a2a31;
52
+ --text: #ececf2;
53
+ --muted: #a1a1aa;
54
+
55
+ /* Bubbles */
56
+ --blue: #0A84FF;
57
+ --blue-press: #0a74e0;
58
+ --incoming: #1f1f24; /* deeper slate for dark */
59
+ --incoming-text: #ececf2;
60
+
61
+ /* Accents */
62
+ --focus: rgba(10,132,255,.6);
63
+ --focus-outer: rgba(10,132,255,.22);
64
+
65
+ /* Shadows (softer/lower spread in dark to avoid haze) */
66
+ --shadow: 0 8px 24px rgba(0,0,0,.45);
67
+ --shadow-strong: 0 12px 36px rgba(0,0,0,.55);
68
+
69
+ --popover-bg: rgba(18,18,20,.98);
70
+ --chip-bg: rgba(255,255,255,.08);
71
+
72
+ /* Timestamp color (dark mode) */
73
+ --meta-color: #fff;
74
+ }
75
+
76
+ /* ---------- Base Layout ---------- */
77
+ html, body { height:100%; }
78
+ body{
79
+ font-family: var(--font-ui);
80
+ background:
81
+ radial-gradient(900px 360px at 80% -10%, var(--bg-radial-1), transparent 60%),
82
+ radial-gradient(900px 360px at -10% 110%, var(--bg-radial-2), transparent 70%),
83
+ var(--bg);
84
+ color: var(--text);
85
+ -webkit-font-smoothing: antialiased;
86
+ -moz-osx-font-smoothing: grayscale;
87
+ }
88
+
89
+ /* Smooth theme transitions (respect reduced motion) */
90
+ @media (prefers-reduced-motion: no-preference){
91
+ body, .glass, .composer-wrap, .bubble, .input-ios, .ios-toast {
92
+ transition: background-color .25s ease, color .25s ease, border-color .25s ease, box-shadow .25s ease;
93
+ }
94
+ }
95
+
96
+ .app{ height:100dvh; display:grid; grid-template-rows:auto 1fr auto; }
97
+
98
+ /* ---------- Header ---------- */
99
+ .glass{
100
+ backdrop-filter: saturate(1.15) blur(14px);
101
+ background: var(--sheet);
102
+ border-bottom: 1px solid var(--border);
103
+ }
104
+ .header-logo{ width:28px; height:28px; border-radius:8px; }
105
+ .app-title{ font-weight:600; letter-spacing:.2px; }
106
+ .subtle{ color: var(--muted); font-size:.82rem; }
107
+
108
+ /* Icon buttons (header + composer) */
109
+ .btn-ico{
110
+ border: none;
111
+ background: transparent;
112
+ font-size: 20px;
113
+ line-height: 1;
114
+ padding: .35rem .5rem;
115
+ border-radius: 10px;
116
+ color: inherit;
117
+ }
118
+ .btn-ico:hover{ background: color-mix(in oklab, var(--border), transparent 70%); }
119
+ .btn-ico:focus-visible{ outline: 2px solid var(--focus); outline-offset: 2px; }
120
+
121
+ /* ---------- Chat Area ---------- */
122
+ .container.chat-wrap{ padding: 14px 14px 8px; overflow:auto; }
123
+ .row-start, .row-end{
124
+ display:flex; gap:.6rem; align-items:flex-end; margin-bottom: .5rem;
125
+ }
126
+ .row-end{ justify-content: flex-end; }
127
+ .avatar{ width:28px; height:28px; border-radius:50%; flex:0 0 28px; }
128
+
129
+ /* ---------- Bubbles ---------- */
130
+ .bubble{
131
+ max-width: 76%;
132
+ border-radius: 20px;
133
+ padding: 8px 12px;
134
+ position: relative;
135
+ box-shadow: var(--shadow);
136
+ word-wrap: break-word;
137
+
138
+ /* New message pop-in animation */
139
+ animation: pop .28s ease-out both;
140
+ animation-delay: var(--bubble-delay, 0s);
141
+ }
142
+ .bubble .copy{ font-size: .98rem; line-height: 1.25rem; }
143
+
144
+ /* timestamp uses theme token for color */
145
+ .bubble .meta{
146
+ font-size: .72rem;
147
+ color: var(--meta-color);
148
+ margin-top: 2px;
149
+ text-align: right;
150
+ }
151
+
152
+ .bubble-you{
153
+ background: var(--blue);
154
+ color: #fff;
155
+ border-bottom-right-radius: 6px; /* iMessage corner */
156
+ /* Outgoing bubbles lift slightly as they appear */
157
+ animation-name: popYou, pop; /* first run popYou (translate), pop already set for scale */
158
+ }
159
+ @keyframes popYou{
160
+ 0% { transform: translateY(6px) scale(.94); opacity:0; }
161
+ 70% { transform: translateY(0) scale(1.04); opacity:1; }
162
+ 100% { transform: translateY(0) scale(1); }
163
+ }
164
+
165
+ .bubble-them{
166
+ background: var(--incoming);
167
+ color: var(--incoming-text);
168
+ border-bottom-left-radius: 6px;
169
+ }
170
+
171
+ /* Bubble tails */
172
+ .bubble-you::after, .bubble-them::after{
173
+ content:""; position:absolute; bottom:0; width:10px; height:14px; background:transparent;
174
+ }
175
+ .bubble-you::after{
176
+ right:-2px; border-bottom-right-radius: 4px;
177
+ box-shadow: 2px 2px 0 0 var(--blue);
178
+ }
179
+ .bubble-them::after{
180
+ left:-2px; border-bottom-left-radius: 4px;
181
+ box-shadow: -2px 2px 0 0 var(--incoming);
182
+ }
183
+
184
+ /* Media inside bubbles */
185
+ .chat-image{
186
+ max-width: 260px; border-radius: 14px; display:block; box-shadow: var(--shadow-strong);
187
+ animation: pop .28s ease-out both;
188
+ }
189
+ .audio-ios{ width: 260px; }
190
+
191
+ /* ---------- Composer ---------- */
192
+ .composer-wrap{
193
+ backdrop-filter: blur(18px) saturate(1.1);
194
+ border-top: 1px solid var(--border);
195
+ background: var(--sheet);
196
+ }
197
+ .composer{
198
+ display:grid; grid-template-columns: auto 1fr auto auto auto;
199
+ gap: .5rem; align-items:center; padding: .6rem 0;
200
+ }
201
+
202
+ .input-shell{ position: relative; display:flex; align-items:center; width:100%; }
203
+ .input-ios{
204
+ background: color-mix(in oklab, var(--incoming), transparent 75%);
205
+ border: 1px solid color-mix(in oklab, var(--border), transparent 20%);
206
+ color: var(--text);
207
+ border-radius: 18px;
208
+ padding: .55rem 2.8rem .55rem .8rem;
209
+ box-shadow: none; resize: none; width:100%;
210
+ caret-color: var(--blue);
211
+ }
212
+ .input-ios::placeholder{ color: color-mix(in oklab, var(--muted), #000 0%); opacity:.8; }
213
+ [data-bs-theme="dark"] .input-ios::placeholder{ color: color-mix(in oklab, var(--muted), #fff 8%); opacity:.85; }
214
+
215
+ .input-ios:focus{
216
+ border-color: color-mix(in oklab, var(--blue), var(--border) 55%);
217
+ outline: none;
218
+ box-shadow: 0 0 0 .22rem var(--focus-outer);
219
+ }
220
+
221
+ /* typing indicator inside input — now bouncing */
222
+ .typing{ position:absolute; right:.8rem; top:50%; transform: translateY(-50%); }
223
+ .typing-dot{
224
+ width:6px;height:6px;border-radius:999px;display:inline-block;background: color-mix(in oklab, var(--muted), #9aa0a6 15%);
225
+ animation: bounceDot 1s infinite ease-in-out;
226
+ }
227
+ .typing-dot:nth-child(2){animation-delay:.16s}
228
+ .typing-dot:nth-child(3){animation-delay:.32s}
229
+
230
+ /* Send button */
231
+ .send-ios{
232
+ background: var(--blue);
233
+ border: none;
234
+ color: #fff;
235
+ width: 36px; height: 36px; border-radius: 50%;
236
+ display:inline-flex; align-items:center; justify-content:center;
237
+ font-weight:600; font-size: 16px;
238
+ box-shadow: 0 6px 18px color-mix(in oklab, var(--blue), transparent 65%);
239
+ }
240
+ /* click pulse feedback */
241
+ .send-ios:active{ background: var(--blue-press); animation: pulse .4s ease; }
242
+ .send-ios:focus-visible{ outline: 2px solid var(--focus); outline-offset: 2px; }
243
+
244
+ /* ---------- Toast (iOS banner style) ---------- */
245
+ .toast-zone{ position: fixed; bottom: 16px; right: 16px; z-index: 1080; }
246
+ .ios-toast{
247
+ background: var(--popover-bg);
248
+ color: #fff;
249
+ border: 0;
250
+ border-radius: 14px;
251
+ box-shadow: var(--shadow);
252
+ animation: toastSlide .3s ease both;
253
+ }
254
+ .ios-toast .toast-body{ padding: .6rem .8rem; }
255
+
256
+ /* ---------- Drag & Drop highlight ---------- */
257
+ .drop{
258
+ outline: 2px dashed color-mix(in oklab, var(--blue), #fff 0%);
259
+ outline-offset: 6px;
260
+ border-radius: 12px;
261
+ }
262
+
263
+ /* ---------- Block “blast” effect ---------- */
264
+ .bubble-blast{
265
+ animation: popout .4s ease forwards;
266
+ filter: saturate(1.1);
267
+ background-image: linear-gradient(0deg, rgba(255,255,255,.08), transparent);
268
+ }
269
+
270
+ /* ---------- Extras: ticks, reactions, reply banner ---------- */
271
+ /* Delivered ticks */
272
+ .meta .ticks { margin-left:.35rem; display:inline-flex; gap:2px; vertical-align:baseline; }
273
+ .tick-solo, .tick-double { font-size:.72rem; opacity:.8; color: currentColor; }
274
+ .tick-double::after { content:"✓✓"; letter-spacing:-2px; }
275
+ .tick-solo::after { content:"✓"; }
276
+
277
+ /* Reactions popover */
278
+ .react-pop{
279
+ position:absolute; bottom:calc(100% + 6px); left:50%; transform:translateX(-50%);
280
+ background: var(--popover-bg); color:#fff; border-radius:999px; padding:.25rem .35rem;
281
+ display:flex; gap:.15rem; box-shadow: var(--shadow); z-index: 20; user-select:none;
282
+ animation: popMenu .16s ease-out both;
283
+ }
284
+ .react-pop button{
285
+ background:transparent; border:0; font-size:1rem; line-height:1; padding:.25rem .35rem; border-radius:10px; color:#fff;
286
+ }
287
+ .react-pop button:focus-visible{ outline:2px solid rgba(255,255,255,.35); outline-offset:2px; }
288
+
289
+ /* Reaction badges under bubble */
290
+ .react-row{
291
+ display:flex; gap:.25rem; margin-top:.25rem; flex-wrap:wrap;
292
+ }
293
+ .react-chip{
294
+ background: var(--chip-bg); color: var(--text);
295
+ border-radius: 999px; padding:.05rem .45rem; font-size:.75rem; display:inline-flex; gap:.25rem; align-items:center;
296
+ border: 1px solid var(--border);
297
+ animation: reactionPop .24s ease-out both;
298
+ }
299
+
300
+ /* Reply banner */
301
+ .reply-banner{
302
+ border-bottom:1px solid var(--border);
303
+ background: color-mix(in oklab, var(--sheet), transparent 0%);
304
+ }
305
+ .reply-banner .rb-body{
306
+ padding:.35rem .75rem; display:flex; align-items:center; gap:.75rem; max-width: 980px; margin: 0 auto;
307
+ }
308
+ .rb-line{ flex:1; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
309
+ .rb-label{ color: var(--muted); margin-right:.35rem; }
310
+ .rb-snippet{ color: var(--text); opacity:.95; }
311
+ .rb-close{ margin-left:auto; }
312
+
313
+ /* Swipe hint (subtle blue outline pulse) */
314
+ @keyframes hintPulse{
315
+ 0%{ box-shadow: 0 0 0 0 color-mix(in oklab, var(--blue), transparent 75%); }
316
+ 100%{ box-shadow: 0 0 0 10px transparent; }
317
+ }
318
+ .bubble-them.swipe-hint{ animation: hintPulse .9s ease-out 1; }
319
+
320
+ /* Ensure bubbles anchor popovers */
321
+ .bubble { position: relative; }
322
+
323
+ /* ---------- Animations ---------- */
324
+ @keyframes pop{
325
+ 0% { transform: scale(.88); opacity: 0; }
326
+ 70% { transform: scale(1.04); opacity: 1; }
327
+ 100% { transform: scale(1); }
328
+ }
329
+ @keyframes popout{
330
+ 0%{ transform: scale(1); opacity:1 }
331
+ 70%{ transform: scale(1.04); opacity:.75 }
332
+ 100%{ transform: scale(.82); opacity:0 }
333
+ }
334
+ @keyframes bounceDot{
335
+ 0%, 80%, 100% { transform: translateY(0); }
336
+ 40% { transform: translateY(-4px); }
337
+ }
338
+ @keyframes pulse{
339
+ 0% { transform: scale(1); box-shadow: 0 0 0 0 rgba(10,132,255,.5); }
340
+ 70% { transform: scale(1.05); box-shadow: 0 0 0 8px rgba(10,132,255,0); }
341
+ 100% { transform: scale(1); box-shadow: 0 0 0 0 rgba(10,132,255,0); }
342
+ }
343
+ @keyframes popMenu{
344
+ 0% { transform: translateX(-50%) scale(.86); opacity:0; }
345
+ 100%{ transform: translateX(-50%) scale(1); opacity:1; }
346
+ }
347
+ @keyframes reactionPop{
348
+ 0% { transform: scale(.75); opacity:0; }
349
+ 70%{ transform: scale(1.12); opacity:1; }
350
+ 100%{ transform: scale(1); }
351
+ }
352
+ @keyframes toastSlide{
353
+ 0% { transform: translateY(16px); opacity:0; }
354
+ 100%{ transform: translateY(0); opacity:1; }
355
+ }
356
+
357
+ /* ---------- Accessibility & Polish ---------- */
358
+ /* Visible selection & caret */
359
+ ::selection{ background: color-mix(in oklab, var(--blue), #ffffff 20%); color:#fff; }
360
+
361
+ /* High-contrast focus outlines on anchors/controls */
362
+ a:focus-visible, button:focus-visible, textarea:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }
363
+
364
+ /* Scrollbar (subtle, both themes) */
365
+ *{ scrollbar-width: thin; scrollbar-color: color-mix(in oklab, var(--muted), #000 0%) transparent; }
366
+ ::-webkit-scrollbar{ height:10px; width:10px; }
367
+ ::-webkit-scrollbar-thumb{ background: color-mix(in oklab, var(--muted), transparent 60%); border-radius: 10px; }
368
+ ::-webkit-scrollbar-track{ background: transparent; }
369
+
370
+ /* Reduced motion */
371
+ @media (prefers-reduced-motion: reduce){
372
+ *{ animation: none !important; transition: none !important; }
373
+ }
374
+
375
+ /* ---------- Small-screen tweaks ---------- */
376
+ @media (max-width: 460px){
377
+ .bubble{ max-width: 84%; }
378
+ .chat-image, .audio-ios{ max-width: 220px; }
379
+ }