Spaces:
Running
Running
Upload 10 files
Browse files- .gitattributes +2 -0
- Dockerfile +29 -0
- app_server.py +475 -0
- avatar_female.png +3 -0
- avatar_male.png +3 -0
- download_assets.py +119 -0
- index.html +420 -19
- logo.png +0 -0
- main.py +59 -0
- requirements.txt +25 -0
- 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
|
avatar_male.png
ADDED
|
|
Git LFS Details
|
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 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
</
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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,'&').replace(/</g,'<').replace(/>/g,'>');
|
| 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 |
+
}
|