Spaces:
Sleeping
Sleeping
Commit ·
a3ee1b6
0
Parent(s):
Deploy DASS-21 app to Hugging Face Space
Browse files- .dockerignore +13 -0
- .gitattributes +3 -0
- .gitignore +40 -0
- Dockerfile +43 -0
- README.md +18 -0
- app.py +308 -0
- index.html +16 -0
- package-lock.json +0 -0
- package.json +26 -0
- src/App.tsx +701 -0
- src/dass.ts +119 -0
- src/main.tsx +10 -0
- src/styles.css +1419 -0
- tsconfig.json +21 -0
- tsconfig.node.json +11 -0
- vite.config.ts +11 -0
.dockerignore
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
node_modules
|
| 2 |
+
.venv
|
| 3 |
+
.git
|
| 4 |
+
.gitignore
|
| 5 |
+
trained_bert_model.zip
|
| 6 |
+
Untitled diagram-2026-06-22-073022.png
|
| 7 |
+
Đồ án tốt nghiệp.pdf
|
| 8 |
+
Đồ án tốt nghiệp.md
|
| 9 |
+
chuong2.md
|
| 10 |
+
chuong3.md
|
| 11 |
+
dass21_dataset.csv
|
| 12 |
+
dist
|
| 13 |
+
README.md
|
.gitattributes
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
*.safetensors filter=lfs diff=lfs merge=lfs -text
|
| 2 |
+
*.bin filter=lfs diff=lfs merge=lfs -text
|
| 3 |
+
*.zip filter=lfs diff=lfs merge=lfs -text
|
.gitignore
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# OS / IDE
|
| 2 |
+
.DS_Store
|
| 3 |
+
Thumbs.db
|
| 4 |
+
.vscode/
|
| 5 |
+
.idea/
|
| 6 |
+
|
| 7 |
+
# Environment variables
|
| 8 |
+
.env
|
| 9 |
+
.env.local
|
| 10 |
+
.env.development.local
|
| 11 |
+
.env.test.local
|
| 12 |
+
.env.production.local
|
| 13 |
+
*.local
|
| 14 |
+
|
| 15 |
+
# Node / Frontend (Vite)
|
| 16 |
+
node_modules/
|
| 17 |
+
dist/
|
| 18 |
+
dist-ssr/
|
| 19 |
+
*.local
|
| 20 |
+
*.tsbuildinfo
|
| 21 |
+
|
| 22 |
+
# Python
|
| 23 |
+
.venv/
|
| 24 |
+
venv/
|
| 25 |
+
env/
|
| 26 |
+
ENV/
|
| 27 |
+
__pycache__/
|
| 28 |
+
*.py[cod]
|
| 29 |
+
*$py.class
|
| 30 |
+
.pytest_cache/
|
| 31 |
+
.mypy_cache/
|
| 32 |
+
.ruff_cache/
|
| 33 |
+
.ipynb_checkpoints/
|
| 34 |
+
|
| 35 |
+
# Machine Learning & Large Files
|
| 36 |
+
trained_bert_model/
|
| 37 |
+
trained_bert_model.zip
|
| 38 |
+
*.zip
|
| 39 |
+
*.tar.gz
|
| 40 |
+
*.tgz
|
Dockerfile
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# --- Stage 1: Build React Frontend ---
|
| 2 |
+
FROM node:20-alpine AS frontend-builder
|
| 3 |
+
WORKDIR /app
|
| 4 |
+
COPY package*.json tsconfig*.json vite.config.* ./
|
| 5 |
+
RUN npm ci
|
| 6 |
+
COPY src/ ./src
|
| 7 |
+
COPY index.html ./
|
| 8 |
+
RUN npm run build
|
| 9 |
+
|
| 10 |
+
# --- Stage 2: Build FastAPI Backend ---
|
| 11 |
+
FROM python:3.11-slim
|
| 12 |
+
WORKDIR /app
|
| 13 |
+
|
| 14 |
+
# Install build essentials if needed
|
| 15 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 16 |
+
build-essential \
|
| 17 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 18 |
+
|
| 19 |
+
# Install python dependencies directly
|
| 20 |
+
RUN pip install --no-cache-dir \
|
| 21 |
+
"fastapi>=0.115.0" \
|
| 22 |
+
"python-dotenv>=1.0.1" \
|
| 23 |
+
"uvicorn>=0.30.0" \
|
| 24 |
+
"transformers>=4.45.0" \
|
| 25 |
+
"safetensors>=0.4.0" \
|
| 26 |
+
https://download.pytorch.org/whl/cpu/torch-2.5.1%2Bcpu-cp311-cp311-linux_x86_64.whl
|
| 27 |
+
|
| 28 |
+
# Copy backend files
|
| 29 |
+
COPY app.py ./
|
| 30 |
+
|
| 31 |
+
# Copy built frontend assets from Stage 1 to the dist folder
|
| 32 |
+
COPY --from=frontend-builder /app/dist ./dist
|
| 33 |
+
|
| 34 |
+
# Set environment variables
|
| 35 |
+
ENV HOST=0.0.0.0
|
| 36 |
+
ENV PORT=7860
|
| 37 |
+
ENV BERT_MODEL_DIR=brianhuster/dass_bert
|
| 38 |
+
|
| 39 |
+
# Expose the default port (Hugging Face Spaces uses 7860 by default)
|
| 40 |
+
EXPOSE 7860
|
| 41 |
+
|
| 42 |
+
# Run FastAPI app
|
| 43 |
+
CMD ["python", "-m", "uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860"]
|
README.md
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: Dassbot
|
| 3 |
+
emoji: 🧠
|
| 4 |
+
colorFrom: blue
|
| 5 |
+
colorTo: indigo
|
| 6 |
+
sdk: docker
|
| 7 |
+
app_port: 7860
|
| 8 |
+
pinned: false
|
| 9 |
+
---
|
| 10 |
+
|
| 11 |
+
# Dassbot - DASS-21 Screening App
|
| 12 |
+
|
| 13 |
+
Mental health screening chatbot combining DASS-21, BERT, and Gemini API.
|
| 14 |
+
|
| 15 |
+
## Features
|
| 16 |
+
- **Stateless Architecture**: Chat sessions and scores are processed completely in RAM client-side and dynamically on memory, preserving absolute student privacy.
|
| 17 |
+
- **BERT Classifier**: Evaluates natural language responses locally or dynamically to compute Likert scale scores (0-3).
|
| 18 |
+
- **Gemini Assistant**: Delivers empathetic conversational turn guidance and final advice.
|
app.py
ADDED
|
@@ -0,0 +1,308 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
import os
|
| 5 |
+
import urllib.error
|
| 6 |
+
import urllib.request
|
| 7 |
+
from pathlib import Path
|
| 8 |
+
|
| 9 |
+
import torch
|
| 10 |
+
from dotenv import load_dotenv
|
| 11 |
+
from fastapi import FastAPI
|
| 12 |
+
from fastapi.staticfiles import StaticFiles
|
| 13 |
+
from pydantic import BaseModel, Field
|
| 14 |
+
from transformers import AutoModelForSequenceClassification, AutoTokenizer
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
load_dotenv()
|
| 18 |
+
|
| 19 |
+
ROOT_DIR = Path(__file__).resolve().parent
|
| 20 |
+
DIST_DIR = ROOT_DIR / "dist"
|
| 21 |
+
|
| 22 |
+
# Determine the model path or Hugging Face Hub repo ID
|
| 23 |
+
BERT_MODEL_DIR_ENV = os.getenv("BERT_MODEL_DIR")
|
| 24 |
+
if BERT_MODEL_DIR_ENV:
|
| 25 |
+
MODEL_PATH_STR = BERT_MODEL_DIR_ENV
|
| 26 |
+
else:
|
| 27 |
+
local_model_path = ROOT_DIR / "trained_bert_model"
|
| 28 |
+
if local_model_path.exists():
|
| 29 |
+
MODEL_PATH_STR = str(local_model_path)
|
| 30 |
+
else:
|
| 31 |
+
MODEL_PATH_STR = "brianhuster/dass_bert"
|
| 32 |
+
GEMINI_API_KEY = os.getenv("GEMINI_API_KEY")
|
| 33 |
+
PRIMARY_MODEL = os.getenv("GEMINI_MODEL", "gemini-3.1-flash-lite")
|
| 34 |
+
FALLBACK_MODELS = [
|
| 35 |
+
value.strip()
|
| 36 |
+
for value in os.getenv("GEMINI_FALLBACK_MODELS", "").split(",")
|
| 37 |
+
if value.strip()
|
| 38 |
+
]
|
| 39 |
+
LABELS = {0: "0", 1: "1", 2: "2", 3: "3", 4: "w"}
|
| 40 |
+
|
| 41 |
+
app = FastAPI(title="DASS-21 Screening App")
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
class AnalyzeRequest(BaseModel):
|
| 45 |
+
question: str = Field(min_length=1)
|
| 46 |
+
answer: str = Field(min_length=1)
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
class AnalyzeResponse(BaseModel):
|
| 50 |
+
label: int
|
| 51 |
+
score: int | None
|
| 52 |
+
confidence: float
|
| 53 |
+
needsClarification: bool
|
| 54 |
+
reply: str
|
| 55 |
+
model: str | None = None
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
class AdviceRequest(BaseModel):
|
| 59 |
+
assessment: dict = Field(default_factory=dict)
|
| 60 |
+
messages: list[dict] = Field(default_factory=list)
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
class AdviceResponse(BaseModel):
|
| 64 |
+
reply: str
|
| 65 |
+
riskLevel: str
|
| 66 |
+
model: str | None = None
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
# Check if model exists locally. If not, assume it's a Hugging Face Hub repo ID
|
| 70 |
+
if os.path.exists(MODEL_PATH_STR):
|
| 71 |
+
print(f"[MODEL] Loading model from local directory: {MODEL_PATH_STR}")
|
| 72 |
+
MODEL_TARGET = Path(MODEL_PATH_STR)
|
| 73 |
+
else:
|
| 74 |
+
# If path has separators or starts with dot/slash, it was meant to be local but is missing
|
| 75 |
+
if MODEL_PATH_STR.startswith("/") or MODEL_PATH_STR.startswith("./") or MODEL_PATH_STR.startswith("../"):
|
| 76 |
+
raise FileNotFoundError(f"Local model directory not found: {MODEL_PATH_STR}")
|
| 77 |
+
print(f"[MODEL] Local directory not found. Loading model from Hugging Face Hub: {MODEL_PATH_STR}")
|
| 78 |
+
MODEL_TARGET = MODEL_PATH_STR # type: ignore
|
| 79 |
+
|
| 80 |
+
MODEL_DIR = MODEL_TARGET
|
| 81 |
+
print(f"\n[MODEL] Bắt đầu nạp mô hình từ nguồn: {MODEL_TARGET}")
|
| 82 |
+
print("[MODEL] Lưu ý: Nếu chạy lần đầu, quá trình tải tự động mô hình (~1.1GB) từ Hugging Face Hub sẽ chạy ngầm. Vui lòng giữ kết nối Internet và chờ đợi từ 1-5 phút...\n")
|
| 83 |
+
|
| 84 |
+
tokenizer = AutoTokenizer.from_pretrained(MODEL_TARGET)
|
| 85 |
+
print("[MODEL] Nạp thành công Tokenizer!")
|
| 86 |
+
|
| 87 |
+
model = AutoModelForSequenceClassification.from_pretrained(MODEL_TARGET)
|
| 88 |
+
print("[MODEL] Nạp thành công Model weights! Đang khởi động web server...\n")
|
| 89 |
+
|
| 90 |
+
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
|
| 91 |
+
model.to(device)
|
| 92 |
+
model.eval()
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
def gemini_json_reply(prompt: str, fallback_reply: str, fallback_risk: str = "low") -> dict[str, str]:
|
| 96 |
+
if not GEMINI_API_KEY:
|
| 97 |
+
print("[GEMINI] Không tìm thấy GEMINI_API_KEY. Sử dụng câu trả lời dự phòng (fallback).")
|
| 98 |
+
return {"reply": fallback_reply, "riskLevel": fallback_risk, "model": "fallback"}
|
| 99 |
+
|
| 100 |
+
for candidate_model in [PRIMARY_MODEL, *FALLBACK_MODELS]:
|
| 101 |
+
url = (
|
| 102 |
+
f"https://generativelanguage.googleapis.com/v1beta/models/"
|
| 103 |
+
f"{candidate_model}:generateContent?key={GEMINI_API_KEY}"
|
| 104 |
+
)
|
| 105 |
+
body = {
|
| 106 |
+
"contents": [{"parts": [{"text": prompt}]}],
|
| 107 |
+
"generationConfig": {
|
| 108 |
+
"temperature": 0.6,
|
| 109 |
+
"responseMimeType": "application/json",
|
| 110 |
+
},
|
| 111 |
+
}
|
| 112 |
+
request = urllib.request.Request(
|
| 113 |
+
url,
|
| 114 |
+
data=json.dumps(body).encode("utf-8"),
|
| 115 |
+
headers={"Content-Type": "application/json"},
|
| 116 |
+
method="POST",
|
| 117 |
+
)
|
| 118 |
+
|
| 119 |
+
try:
|
| 120 |
+
print(f"[GEMINI] Đang gọi mô hình '{candidate_model}'...")
|
| 121 |
+
with urllib.request.urlopen(request, timeout=45) as response:
|
| 122 |
+
raw = json.loads(response.read().decode("utf-8"))
|
| 123 |
+
text = (
|
| 124 |
+
raw.get("candidates", [{}])[0]
|
| 125 |
+
.get("content", {})
|
| 126 |
+
.get("parts", [{}])[0]
|
| 127 |
+
.get("text", "")
|
| 128 |
+
)
|
| 129 |
+
parsed = json.loads(text)
|
| 130 |
+
if isinstance(parsed, dict) and isinstance(parsed.get("reply"), str):
|
| 131 |
+
print(f"[GEMINI] Gọi mô hình '{candidate_model}' thành công!")
|
| 132 |
+
print(f"[GEMINI] Phản hồi nhận được: {parsed['reply']}")
|
| 133 |
+
return {
|
| 134 |
+
"reply": parsed["reply"],
|
| 135 |
+
"riskLevel": parsed.get("riskLevel", "low"),
|
| 136 |
+
"model": candidate_model,
|
| 137 |
+
}
|
| 138 |
+
except Exception as e:
|
| 139 |
+
print(f"[GEMINI] Lỗi khi gọi mô hình '{candidate_model}': {type(e).__name__} - {e}")
|
| 140 |
+
continue
|
| 141 |
+
|
| 142 |
+
print("[GEMINI] Tất cả các mô hình Gemini đều thất bại hoặc trả về dữ liệu sai định dạng. Sử dụng câu trả lời dự phòng (fallback).")
|
| 143 |
+
return {"reply": fallback_reply, "riskLevel": fallback_risk, "model": "fallback"}
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
def build_turn_prompt(question: str, answer: str, label: str, confidence: float) -> str:
|
| 147 |
+
tone_guidelines = ""
|
| 148 |
+
if label == "0":
|
| 149 |
+
tone_guidelines = "- Tình trạng sinh viên đang tốt (nhãn 0): Khen ngợi nhẹ nhàng, ngắn gọn trong 1 câu ấm áp."
|
| 150 |
+
elif label == "1":
|
| 151 |
+
tone_guidelines = "- Tình trạng sinh viên bình thường (nhãn 1): An ủi và khích lệ nhẹ nhàng trong 1-2 câu."
|
| 152 |
+
elif label == "2":
|
| 153 |
+
tone_guidelines = "- Tình trạng sinh viên không tốt lắm (nhãn 2): Thể hiện sự đồng cảm sâu sắc, trấn an, giải thích nguyên nhân theo hướng tích cực, đề xuất giải pháp ngắn gọn và lời động viên trong 3-4 câu."
|
| 154 |
+
elif label == "3":
|
| 155 |
+
tone_guidelines = "- Tình trạng sinh viên khá tệ (nhãn 3): Thể hiện sự đồng cảm cao nhất, xoa dịu tinh thần, định hướng suy nghĩ tích cực hơn, khuyên nghỉ ngơi/chia sẻ với người thân và đưa ra lời khuyên thực tế trong 3-4 câu."
|
| 156 |
+
else:
|
| 157 |
+
tone_guidelines = "- Câu trả lời có thể mơ hồ hoặc chưa rõ ràng. Hãy nhẹ nhàng khuyến khích sinh viên chia sẻ chi tiết hơn một cách tinh tế."
|
| 158 |
+
|
| 159 |
+
return f"""
|
| 160 |
+
Hãy đóng vai như thể bạn là một trợ lý ảo tư vấn tâm lý học của Đại học Bách khoa Hà Nội (HUST). Bạn có phong cách nói chuyện vô cùng tích cực, ấm áp và đồng cảm cao với sinh viên.
|
| 161 |
+
|
| 162 |
+
Nhiệm vụ & Nguyên tắc hội thoại:
|
| 163 |
+
- Xưng hô thân thiện, gần gũi (dùng "mình" và "bạn").
|
| 164 |
+
- Bạn TUYỆT ĐỐI không được hỏi thêm bất kỳ câu hỏi nào. Chỉ đưa ra lời khuyên, sự an ủi, thấu hiểu hoặc khen ngợi.
|
| 165 |
+
- Không chẩn đoán bệnh lý hay nói giọng bác sĩ lâm sàng.
|
| 166 |
+
- Hướng dẫn phong cách phản hồi theo nhãn điểm được phân loại:
|
| 167 |
+
{tone_guidelines}
|
| 168 |
+
|
| 169 |
+
Ngữ cảnh hiện tại:
|
| 170 |
+
- Câu hỏi khảo sát DASS-21: {question}
|
| 171 |
+
- Câu trả lời của sinh viên: '{answer}'
|
| 172 |
+
|
| 173 |
+
Hãy trả về JSON hợp lệ theo cấu trúc chính xác:
|
| 174 |
+
{{"reply": "nội dung phản hồi ấm áp của bạn"}}
|
| 175 |
+
""".strip()
|
| 176 |
+
|
| 177 |
+
|
| 178 |
+
def build_final_advice_prompt(assessment: dict, conversation: str) -> str:
|
| 179 |
+
top_concern = assessment.get("topConcernLabel", "n/a")
|
| 180 |
+
return f"""
|
| 181 |
+
Hãy đóng vai như thể bạn là một trợ lý ảo tư vấn tâm lý học của Đại học Bách khoa Hà Nội (HUST). Bạn đang đưa ra những lời khuyên tổng kết và lời tạm biệt sau khi sinh viên đã hoàn thành cuộc khảo sát DASS-21.
|
| 182 |
+
|
| 183 |
+
Nhiệm vụ & Nguyên tắc hội thoại:
|
| 184 |
+
- Chúc mừng sinh viên đã kiên nhẫn cùng bạn hoàn thành tất cả các câu hỏi đánh giá.
|
| 185 |
+
- Thể hiện sự đồng cảm, cảm ơn sinh viên đã dành thời gian chia sẻ chân thành.
|
| 186 |
+
- Nhận xét kết quả sàng lọc tinh thần bằng những lời lẽ tích cực, mang tính khích lệ:
|
| 187 |
+
+ Stress: {assessment.get("stress", "n/a")}
|
| 188 |
+
+ Lo âu: {assessment.get("anxiety", "n/a")}
|
| 189 |
+
+ Trầm cảm: {assessment.get("depression", "n/a")}
|
| 190 |
+
+ Yếu tố nổi bật nhất: {top_concern}
|
| 191 |
+
- Đưa ra lời khuyên thiết thực (chế độ nghỉ ngơi, gặp gỡ bạn bè, quản lý thời gian học tập tại Bách khoa).
|
| 192 |
+
- Nhấn mạnh rằng nhà trường và các trợ lý ảo/thầy cô sẽ luôn đồng hành, sẵn sàng lắng nghe và hỗ trợ bạn bất cứ lúc nào.
|
| 193 |
+
- Tạm biệt sinh viên bằng lời chúc ấm áp (dùng xưng hô "mình" và "bạn").
|
| 194 |
+
- Độ dài khoảng 8-10 câu.
|
| 195 |
+
|
| 196 |
+
Hãy trả về JSON hợp lệ theo cấu trúc chính xác:
|
| 197 |
+
{{"reply": "nội dung lời khuyên và lời chào tạm biệt đầy đủ của bạn", "riskLevel": "low" | "moderate" | "high"}}
|
| 198 |
+
""".strip()
|
| 199 |
+
|
| 200 |
+
|
| 201 |
+
@app.get("/api/health")
|
| 202 |
+
def health() -> dict[str, object]:
|
| 203 |
+
return {
|
| 204 |
+
"ok": True,
|
| 205 |
+
"bert": {
|
| 206 |
+
"status": "ok",
|
| 207 |
+
"device": str(device),
|
| 208 |
+
"model_dir": str(MODEL_DIR),
|
| 209 |
+
},
|
| 210 |
+
"gemini": bool(GEMINI_API_KEY),
|
| 211 |
+
}
|
| 212 |
+
|
| 213 |
+
|
| 214 |
+
@app.post("/api/dass/analyze", response_model=AnalyzeResponse)
|
| 215 |
+
def analyze(payload: AnalyzeRequest) -> AnalyzeResponse:
|
| 216 |
+
print(f"\n--- [API /api/dass/analyze] Nhận yêu cầu ---")
|
| 217 |
+
print(f"Câu hỏi: {payload.question}")
|
| 218 |
+
print(f"Câu trả lời của sinh viên: '{payload.answer}'")
|
| 219 |
+
|
| 220 |
+
inputs = tokenizer(
|
| 221 |
+
payload.question,
|
| 222 |
+
payload.answer,
|
| 223 |
+
return_tensors="pt",
|
| 224 |
+
truncation=True,
|
| 225 |
+
padding=True,
|
| 226 |
+
max_length=192,
|
| 227 |
+
)
|
| 228 |
+
inputs = {key: value.to(device) for key, value in inputs.items()}
|
| 229 |
+
|
| 230 |
+
with torch.no_grad():
|
| 231 |
+
outputs = model(**inputs)
|
| 232 |
+
probabilities = torch.softmax(outputs.logits, dim=-1)[0]
|
| 233 |
+
label = int(torch.argmax(probabilities).item())
|
| 234 |
+
confidence = float(probabilities[label].item())
|
| 235 |
+
|
| 236 |
+
print(f"[BERT] Kết quả phân loại: nhãn={label} (Điểm quy đổi={label if label in {0, 1, 2, 3} else 'N/A'})")
|
| 237 |
+
print(f"[BERT] Độ tin cậy (Confidence): {confidence:.4f}")
|
| 238 |
+
|
| 239 |
+
if label == 4 or confidence < 0.45:
|
| 240 |
+
reason = "nhãn = 4 (lạc đề/off-topic)" if label == 4 else f"độ tin cậy {confidence:.4f} thấp hơn ngưỡng 0.45"
|
| 241 |
+
print(f"[BERT] Yêu cầu làm rõ (Clarification Needed): {reason}")
|
| 242 |
+
return AnalyzeResponse(
|
| 243 |
+
label=label,
|
| 244 |
+
score=None,
|
| 245 |
+
confidence=confidence,
|
| 246 |
+
needsClarification=True,
|
| 247 |
+
reply="Mình chưa chắc mình hiểu đúng ý bạn. Bạn có thể trả lời lại ngắn gọn và trực tiếp hơn theo đúng câu hỏi này không?",
|
| 248 |
+
model="bert",
|
| 249 |
+
)
|
| 250 |
+
|
| 251 |
+
fallback_reply = (
|
| 252 |
+
"Cảm ơn bạn đã chia sẻ. Nghe như điều này đang ảnh hưởng bạn khá nhiều; mình sẽ ghi nhận để xem tổng thể sau."
|
| 253 |
+
if label == 3
|
| 254 |
+
else "Cảm ơn bạn đã chia sẻ, mình đã hiểu hơn rồi."
|
| 255 |
+
)
|
| 256 |
+
|
| 257 |
+
print("[BERT] Đủ độ tin cậy. Đang chuyển tiếp sang Gemini để tạo phản hồi đồng cảm...")
|
| 258 |
+
reply = gemini_json_reply(
|
| 259 |
+
build_turn_prompt(payload.question, payload.answer, LABELS.get(label, str(label)), confidence),
|
| 260 |
+
fallback_reply,
|
| 261 |
+
)
|
| 262 |
+
|
| 263 |
+
print(f"--- Kết quả phản hồi gửi về FE ---")
|
| 264 |
+
print(f"Nhãn điểm: {label if label in {0, 1, 2, 3} else None}")
|
| 265 |
+
print(f"Phản hồi: {reply['reply']}")
|
| 266 |
+
print(f"Mô hình thực tế sử dụng: {reply.get('model')}\n")
|
| 267 |
+
|
| 268 |
+
return AnalyzeResponse(
|
| 269 |
+
label=label,
|
| 270 |
+
score=label if label in {0, 1, 2, 3} else None,
|
| 271 |
+
confidence=confidence,
|
| 272 |
+
needsClarification=False,
|
| 273 |
+
reply=reply["reply"],
|
| 274 |
+
model=reply.get("model"),
|
| 275 |
+
)
|
| 276 |
+
|
| 277 |
+
|
| 278 |
+
@app.post("/api/chat", response_model=AdviceResponse)
|
| 279 |
+
def chat(payload: AdviceRequest) -> AdviceResponse:
|
| 280 |
+
print(f"\n--- [API /api/chat] Nhận yêu cầu tư vấn ---")
|
| 281 |
+
print(f"Kết quả phân tích: {payload.assessment}")
|
| 282 |
+
conversation = "\n".join(
|
| 283 |
+
f"{message.get('role', 'user')}: {message.get('content', '')}"
|
| 284 |
+
for message in payload.messages[-10:]
|
| 285 |
+
if isinstance(message, dict) and isinstance(message.get("content"), str)
|
| 286 |
+
)
|
| 287 |
+
print(f"Lịch sử hội thoại gửi lên:\n{conversation}")
|
| 288 |
+
|
| 289 |
+
reply = gemini_json_reply(
|
| 290 |
+
build_final_advice_prompt(payload.assessment, conversation),
|
| 291 |
+
"Mình nghe thấy bạn đang cần được hỗ trợ thêm. Nếu cảm thấy quá tải, hãy nói với người thân hoặc một chuyên gia nhé.",
|
| 292 |
+
"moderate",
|
| 293 |
+
)
|
| 294 |
+
|
| 295 |
+
print(f"--- Phản hồi tư vấn gửi về FE ---")
|
| 296 |
+
print(f"Tư vấn: {reply['reply']}")
|
| 297 |
+
print(f"Mức độ rủi ro: {reply.get('riskLevel')}")
|
| 298 |
+
print(f"Mô hình thực tế sử dụng: {reply.get('model')}\n")
|
| 299 |
+
|
| 300 |
+
return AdviceResponse(
|
| 301 |
+
reply=reply["reply"],
|
| 302 |
+
riskLevel=reply.get("riskLevel", "low"),
|
| 303 |
+
model=reply.get("model"),
|
| 304 |
+
)
|
| 305 |
+
|
| 306 |
+
|
| 307 |
+
if DIST_DIR.exists():
|
| 308 |
+
app.mount("/", StaticFiles(directory=DIST_DIR, html=True), name="dist")
|
index.html
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!doctype html>
|
| 2 |
+
<html lang="vi">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8" />
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 6 |
+
<meta
|
| 7 |
+
name="description"
|
| 8 |
+
content="Ứng dụng sàng lọc stress, lo âu, trầm cảm bằng DASS-21."
|
| 9 |
+
/>
|
| 10 |
+
<title>DASS-21 Screening</title>
|
| 11 |
+
</head>
|
| 12 |
+
<body>
|
| 13 |
+
<div id="root"></div>
|
| 14 |
+
<script type="module" src="/src/main.tsx"></script>
|
| 15 |
+
</body>
|
| 16 |
+
</html>
|
package-lock.json
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
package.json
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "dass21-screening-app",
|
| 3 |
+
"private": true,
|
| 4 |
+
"version": "1.0.0",
|
| 5 |
+
"type": "module",
|
| 6 |
+
"scripts": {
|
| 7 |
+
"dev": "concurrently -k \"uv run uvicorn app:app --host 127.0.0.1 --port 3001\" \"vite\"",
|
| 8 |
+
"dev:client": "vite",
|
| 9 |
+
"dev:server": "uv run uvicorn app:app --host 127.0.0.1 --port 3001",
|
| 10 |
+
"build": "tsc -b && vite build",
|
| 11 |
+
"preview": "vite preview",
|
| 12 |
+
"start": "uv run uvicorn app:app --host 127.0.0.1 --port 3001"
|
| 13 |
+
},
|
| 14 |
+
"dependencies": {
|
| 15 |
+
"react": "^19.1.0",
|
| 16 |
+
"react-dom": "^19.1.0"
|
| 17 |
+
},
|
| 18 |
+
"devDependencies": {
|
| 19 |
+
"@types/react": "^19.1.8",
|
| 20 |
+
"@types/react-dom": "^19.1.8",
|
| 21 |
+
"@vitejs/plugin-react": "^5.0.1",
|
| 22 |
+
"concurrently": "^9.2.1",
|
| 23 |
+
"typescript": "^5.8.3",
|
| 24 |
+
"vite": "^7.0.4"
|
| 25 |
+
}
|
| 26 |
+
}
|
src/App.tsx
ADDED
|
@@ -0,0 +1,701 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useEffect, useMemo, useState, useRef, type FormEvent } from 'react';
|
| 2 |
+
import {
|
| 3 |
+
getSeverity,
|
| 4 |
+
questions,
|
| 5 |
+
scaleDescriptions,
|
| 6 |
+
scaleLabels,
|
| 7 |
+
scoreScale,
|
| 8 |
+
answerOptions,
|
| 9 |
+
type AnswerValue,
|
| 10 |
+
type Scale,
|
| 11 |
+
} from './dass';
|
| 12 |
+
|
| 13 |
+
type Stage = 'home' | 'questionnaire' | 'results' | 'chat';
|
| 14 |
+
|
| 15 |
+
type Message = {
|
| 16 |
+
id: string;
|
| 17 |
+
role: 'assistant' | 'user';
|
| 18 |
+
content: string;
|
| 19 |
+
};
|
| 20 |
+
|
| 21 |
+
type AnalyzeResponse = {
|
| 22 |
+
label: number;
|
| 23 |
+
score: AnswerValue | null;
|
| 24 |
+
confidence: number;
|
| 25 |
+
needsClarification: boolean;
|
| 26 |
+
reply: string;
|
| 27 |
+
};
|
| 28 |
+
|
| 29 |
+
type AdviceResponse = {
|
| 30 |
+
reply: string;
|
| 31 |
+
riskLevel: 'low' | 'moderate' | 'high';
|
| 32 |
+
};
|
| 33 |
+
|
| 34 |
+
const initialAnswers: (AnswerValue | null)[] = Array.from({ length: questions.length }, () => null);
|
| 35 |
+
const maxScaleScore = 42;
|
| 36 |
+
|
| 37 |
+
function App() {
|
| 38 |
+
const [stage, setStage] = useState<Stage>('home');
|
| 39 |
+
const [answers, setAnswers] = useState<(AnswerValue | null)[]>(initialAnswers);
|
| 40 |
+
const [currentIndex, setCurrentIndex] = useState(0);
|
| 41 |
+
const [messages, setMessages] = useState<Message[]>([]);
|
| 42 |
+
const [input, setInput] = useState('');
|
| 43 |
+
const [loading, setLoading] = useState(false);
|
| 44 |
+
const [error, setError] = useState<string | null>(null);
|
| 45 |
+
const [adviceRiskLevel, setAdviceRiskLevel] = useState<'low' | 'moderate' | 'high'>('low');
|
| 46 |
+
const [showPrivacyModal, setShowPrivacyModal] = useState(false);
|
| 47 |
+
const [consentChecked, setConsentChecked] = useState(false);
|
| 48 |
+
|
| 49 |
+
const [theme, setTheme] = useState<'light' | 'dark'>(() => {
|
| 50 |
+
const saved = localStorage.getItem('theme');
|
| 51 |
+
if (saved === 'light' || saved === 'dark') return saved;
|
| 52 |
+
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
| 53 |
+
});
|
| 54 |
+
|
| 55 |
+
useEffect(() => {
|
| 56 |
+
document.documentElement.setAttribute('data-theme', theme);
|
| 57 |
+
localStorage.setItem('theme', theme);
|
| 58 |
+
}, [theme]);
|
| 59 |
+
|
| 60 |
+
const renderThemeToggle = () => (
|
| 61 |
+
<button
|
| 62 |
+
className="theme-toggle-btn"
|
| 63 |
+
onClick={() => setTheme((prev) => (prev === 'light' ? 'dark' : 'light'))}
|
| 64 |
+
type="button"
|
| 65 |
+
aria-label="Toggle theme"
|
| 66 |
+
>
|
| 67 |
+
{theme === 'light' ? (
|
| 68 |
+
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
| 69 |
+
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
|
| 70 |
+
</svg>
|
| 71 |
+
) : (
|
| 72 |
+
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
| 73 |
+
<circle cx="12" cy="12" r="5" />
|
| 74 |
+
<line x1="12" y1="1" x2="12" y2="3" />
|
| 75 |
+
<line x1="12" y1="21" x2="12" y2="23" />
|
| 76 |
+
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64" />
|
| 77 |
+
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78" />
|
| 78 |
+
<line x1="1" y1="12" x2="3" y2="12" />
|
| 79 |
+
<line x1="21" y1="12" x2="23" y2="12" />
|
| 80 |
+
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36" />
|
| 81 |
+
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22" />
|
| 82 |
+
</svg>
|
| 83 |
+
)}
|
| 84 |
+
</button>
|
| 85 |
+
);
|
| 86 |
+
|
| 87 |
+
const handleDeclineConsent = () => {
|
| 88 |
+
setShowPrivacyModal(false);
|
| 89 |
+
setConsentChecked(false);
|
| 90 |
+
};
|
| 91 |
+
|
| 92 |
+
const handleAcceptConsent = () => {
|
| 93 |
+
if (consentChecked) {
|
| 94 |
+
setShowPrivacyModal(false);
|
| 95 |
+
startQuestionnaire();
|
| 96 |
+
}
|
| 97 |
+
};
|
| 98 |
+
|
| 99 |
+
const chatThreadRef = useRef<HTMLDivElement>(null);
|
| 100 |
+
|
| 101 |
+
useEffect(() => {
|
| 102 |
+
if (chatThreadRef.current) {
|
| 103 |
+
chatThreadRef.current.scrollTop = chatThreadRef.current.scrollHeight;
|
| 104 |
+
}
|
| 105 |
+
}, [messages, loading]);
|
| 106 |
+
|
| 107 |
+
const currentQuestion = questions[currentIndex];
|
| 108 |
+
const selectedAnswer = answers[currentIndex];
|
| 109 |
+
const answeredCount = answers.filter((answer) => answer !== null).length;
|
| 110 |
+
const progress = Math.round((answeredCount / questions.length) * 100);
|
| 111 |
+
|
| 112 |
+
const scores = useMemo(() => scoreScale(answers), [answers]);
|
| 113 |
+
const orderedScales: Scale[] = ['stress', 'anxiety', 'depression'];
|
| 114 |
+
const resultCards = orderedScales.map((scale) => {
|
| 115 |
+
const score = scores[scale];
|
| 116 |
+
const severity = getSeverity(scale, score);
|
| 117 |
+
return {
|
| 118 |
+
scale,
|
| 119 |
+
score,
|
| 120 |
+
severity,
|
| 121 |
+
percent: Math.min(100, Math.round((score / maxScaleScore) * 100)),
|
| 122 |
+
};
|
| 123 |
+
});
|
| 124 |
+
|
| 125 |
+
const topConcern = [...resultCards].sort((a, b) => b.score - a.score)[0];
|
| 126 |
+
const assessmentSummary = useMemo(
|
| 127 |
+
() => ({
|
| 128 |
+
stress: scores.stress,
|
| 129 |
+
anxiety: scores.anxiety,
|
| 130 |
+
depression: scores.depression,
|
| 131 |
+
topConcernLabel: `${scaleLabels[topConcern.scale]} · ${topConcern.severity.label}`,
|
| 132 |
+
}),
|
| 133 |
+
[scores, topConcern],
|
| 134 |
+
);
|
| 135 |
+
|
| 136 |
+
const isCompleted = answers.every((answer) => answer !== null);
|
| 137 |
+
|
| 138 |
+
const restart = () => {
|
| 139 |
+
setStage('home');
|
| 140 |
+
setAnswers(initialAnswers);
|
| 141 |
+
setCurrentIndex(0);
|
| 142 |
+
setMessages([]);
|
| 143 |
+
setInput('');
|
| 144 |
+
setLoading(false);
|
| 145 |
+
setError(null);
|
| 146 |
+
setAdviceRiskLevel('low');
|
| 147 |
+
setConsentChecked(false);
|
| 148 |
+
setShowPrivacyModal(false);
|
| 149 |
+
};
|
| 150 |
+
|
| 151 |
+
const startQuestionnaire = () => {
|
| 152 |
+
setStage('questionnaire');
|
| 153 |
+
setAnswers(initialAnswers);
|
| 154 |
+
setCurrentIndex(0);
|
| 155 |
+
setMessages([
|
| 156 |
+
{
|
| 157 |
+
id: `assistant-${Date.now()}`,
|
| 158 |
+
role: 'assistant',
|
| 159 |
+
content:
|
| 160 |
+
'Chào bạn! Mình là trợ lý ảo hỗ trợ sàng lọc sức khỏe tinh thần của Đại học Bách khoa Hà Nội (HUST). Mình sẽ đồng hành cùng bạn thực hiện bài đánh giá cảm nhận qua bộ câu hỏi DASS-21. Bạn cứ chia sẻ tự nhiên theo cảm nhận của mình nhé.',
|
| 161 |
+
},
|
| 162 |
+
{
|
| 163 |
+
id: `assistant-q-${Date.now()}`,
|
| 164 |
+
role: 'assistant',
|
| 165 |
+
content: `Câu 1/${questions.length}: ${questions[0].text}\n\nBạn có thể chia sẻ cảm nhận của mình về câu hỏi này không?`,
|
| 166 |
+
},
|
| 167 |
+
]);
|
| 168 |
+
setInput('');
|
| 169 |
+
setError(null);
|
| 170 |
+
setLoading(false);
|
| 171 |
+
};
|
| 172 |
+
|
| 173 |
+
const callAnalyze = async (question: string, answer: string) => {
|
| 174 |
+
const response = await fetch('/api/dass/analyze', {
|
| 175 |
+
method: 'POST',
|
| 176 |
+
headers: {
|
| 177 |
+
'Content-Type': 'application/json',
|
| 178 |
+
},
|
| 179 |
+
body: JSON.stringify({ question, answer }),
|
| 180 |
+
});
|
| 181 |
+
|
| 182 |
+
const payload = (await response.json().catch(() => null)) as AnalyzeResponse | null;
|
| 183 |
+
if (!response.ok) {
|
| 184 |
+
throw new Error(payload?.reply || 'Không thể chấm điểm câu trả lời lúc này.');
|
| 185 |
+
}
|
| 186 |
+
return payload;
|
| 187 |
+
};
|
| 188 |
+
|
| 189 |
+
const callAdvice = async (messagesToSend: Message[]) => {
|
| 190 |
+
const response = await fetch('/api/chat', {
|
| 191 |
+
method: 'POST',
|
| 192 |
+
headers: {
|
| 193 |
+
'Content-Type': 'application/json',
|
| 194 |
+
},
|
| 195 |
+
body: JSON.stringify({
|
| 196 |
+
assessment: assessmentSummary,
|
| 197 |
+
messages: messagesToSend,
|
| 198 |
+
}),
|
| 199 |
+
});
|
| 200 |
+
|
| 201 |
+
const payload = (await response.json().catch(() => null)) as AdviceResponse | null;
|
| 202 |
+
if (!response.ok) {
|
| 203 |
+
throw new Error(payload?.reply || 'Không thể kết nối phần tư vấn.');
|
| 204 |
+
}
|
| 205 |
+
return payload;
|
| 206 |
+
};
|
| 207 |
+
|
| 208 |
+
const bootstrapAdvice = async () => {
|
| 209 |
+
setLoading(true);
|
| 210 |
+
setError(null);
|
| 211 |
+
|
| 212 |
+
try {
|
| 213 |
+
const payload = await callAdvice([]);
|
| 214 |
+
if (!payload) {
|
| 215 |
+
throw new Error('Không thể khởi tạo phần tư vấn.');
|
| 216 |
+
}
|
| 217 |
+
setMessages([
|
| 218 |
+
{
|
| 219 |
+
id: `assistant-${Date.now()}`,
|
| 220 |
+
role: 'assistant',
|
| 221 |
+
content:
|
| 222 |
+
payload.reply ||
|
| 223 |
+
'Mình ở đây để lắng nghe bạn. Nếu muốn, bạn có thể kể thêm điều gì đang làm bạn nặng lòng nhất.',
|
| 224 |
+
},
|
| 225 |
+
]);
|
| 226 |
+
setAdviceRiskLevel(payload.riskLevel || 'low');
|
| 227 |
+
} catch (err) {
|
| 228 |
+
setError(err instanceof Error ? err.message : 'Không thể khởi tạo phần tư vấn.');
|
| 229 |
+
setMessages([
|
| 230 |
+
{
|
| 231 |
+
id: `assistant-${Date.now()}`,
|
| 232 |
+
role: 'assistant',
|
| 233 |
+
content:
|
| 234 |
+
'Mình chưa thể kết nối phần tư vấn lúc này. Hãy kiểm tra GEMINI_API_KEY rồi thử lại sau.',
|
| 235 |
+
},
|
| 236 |
+
]);
|
| 237 |
+
} finally {
|
| 238 |
+
setLoading(false);
|
| 239 |
+
}
|
| 240 |
+
};
|
| 241 |
+
|
| 242 |
+
const submitQuestionnaireAnswer = async (text: string) => {
|
| 243 |
+
if (loading) return;
|
| 244 |
+
|
| 245 |
+
const questionIndex = currentIndex;
|
| 246 |
+
const question = questions[questionIndex];
|
| 247 |
+
const userMessage: Message = {
|
| 248 |
+
id: `user-${Date.now()}`,
|
| 249 |
+
role: 'user',
|
| 250 |
+
content: text,
|
| 251 |
+
};
|
| 252 |
+
|
| 253 |
+
setMessages((prev) => [...prev, userMessage]);
|
| 254 |
+
setInput('');
|
| 255 |
+
setLoading(true);
|
| 256 |
+
setError(null);
|
| 257 |
+
|
| 258 |
+
try {
|
| 259 |
+
const payload = await callAnalyze(question.text, text);
|
| 260 |
+
if (!payload) {
|
| 261 |
+
throw new Error('Không thể chấm điểm câu trả lời lúc này.');
|
| 262 |
+
}
|
| 263 |
+
setMessages((prev) => [
|
| 264 |
+
...prev,
|
| 265 |
+
{
|
| 266 |
+
id: `assistant-${Date.now()}`,
|
| 267 |
+
role: 'assistant',
|
| 268 |
+
content: payload.reply,
|
| 269 |
+
},
|
| 270 |
+
]);
|
| 271 |
+
|
| 272 |
+
if (payload.needsClarification) {
|
| 273 |
+
return;
|
| 274 |
+
}
|
| 275 |
+
|
| 276 |
+
setAnswers((prev) => {
|
| 277 |
+
const next = [...prev];
|
| 278 |
+
next[questionIndex] = payload.score ?? 0;
|
| 279 |
+
return next;
|
| 280 |
+
});
|
| 281 |
+
|
| 282 |
+
const nextIndex = questionIndex + 1;
|
| 283 |
+
if (nextIndex >= questions.length) {
|
| 284 |
+
setStage('results');
|
| 285 |
+
return;
|
| 286 |
+
}
|
| 287 |
+
|
| 288 |
+
setCurrentIndex(nextIndex);
|
| 289 |
+
setMessages((prev) => [
|
| 290 |
+
...prev,
|
| 291 |
+
{
|
| 292 |
+
id: `assistant-q-${Date.now()}`,
|
| 293 |
+
role: 'assistant',
|
| 294 |
+
content: `Câu ${nextIndex + 1}/${questions.length}: ${questions[nextIndex].text}\n\nBạn có thể chia sẻ cảm nhận của mình về câu hỏi này không?`,
|
| 295 |
+
},
|
| 296 |
+
]);
|
| 297 |
+
} catch (err) {
|
| 298 |
+
setError(err instanceof Error ? err.message : 'Không thể xử lý câu trả lời.');
|
| 299 |
+
} finally {
|
| 300 |
+
setLoading(false);
|
| 301 |
+
}
|
| 302 |
+
};
|
| 303 |
+
|
| 304 |
+
const handleQuestionnaireSend = async (event: FormEvent<HTMLFormElement>) => {
|
| 305 |
+
event.preventDefault();
|
| 306 |
+
const text = input.trim();
|
| 307 |
+
if (!text) return;
|
| 308 |
+
await submitQuestionnaireAnswer(text);
|
| 309 |
+
};
|
| 310 |
+
|
| 311 |
+
const handleAdviceSend = async (event: FormEvent<HTMLFormElement>) => {
|
| 312 |
+
event.preventDefault();
|
| 313 |
+
const text = input.trim();
|
| 314 |
+
if (!text || loading) return;
|
| 315 |
+
|
| 316 |
+
const userMessage: Message = {
|
| 317 |
+
id: `user-${Date.now()}`,
|
| 318 |
+
role: 'user',
|
| 319 |
+
content: text,
|
| 320 |
+
};
|
| 321 |
+
|
| 322 |
+
const nextMessages = [...messages, userMessage];
|
| 323 |
+
setMessages(nextMessages);
|
| 324 |
+
setInput('');
|
| 325 |
+
setLoading(true);
|
| 326 |
+
setError(null);
|
| 327 |
+
|
| 328 |
+
try {
|
| 329 |
+
const payload = await callAdvice(nextMessages);
|
| 330 |
+
if (!payload) {
|
| 331 |
+
throw new Error('Không thể kết nối phần tư vấn.');
|
| 332 |
+
}
|
| 333 |
+
setMessages((prev) => [
|
| 334 |
+
...prev,
|
| 335 |
+
{
|
| 336 |
+
id: `assistant-${Date.now()}`,
|
| 337 |
+
role: 'assistant',
|
| 338 |
+
content: payload.reply,
|
| 339 |
+
},
|
| 340 |
+
]);
|
| 341 |
+
setAdviceRiskLevel(payload.riskLevel || 'low');
|
| 342 |
+
} catch (err) {
|
| 343 |
+
setError(err instanceof Error ? err.message : 'Chatbot đang tạm gián đoạn.');
|
| 344 |
+
setMessages((prev) => [
|
| 345 |
+
...prev,
|
| 346 |
+
{
|
| 347 |
+
id: `assistant-${Date.now()}`,
|
| 348 |
+
role: 'assistant',
|
| 349 |
+
content: 'Mình chưa trả lời được lúc này. Bạn có thể thử lại sau vài phút.',
|
| 350 |
+
},
|
| 351 |
+
]);
|
| 352 |
+
} finally {
|
| 353 |
+
setLoading(false);
|
| 354 |
+
}
|
| 355 |
+
};
|
| 356 |
+
|
| 357 |
+
useEffect(() => {
|
| 358 |
+
if (stage === 'chat' && messages.length === 0 && !loading) {
|
| 359 |
+
void bootstrapAdvice();
|
| 360 |
+
}
|
| 361 |
+
}, [stage, messages.length, loading]);
|
| 362 |
+
|
| 363 |
+
if (stage === 'results') {
|
| 364 |
+
return (
|
| 365 |
+
<main className="app-shell">
|
| 366 |
+
{renderThemeToggle()}
|
| 367 |
+
<section className="hero-card results-card">
|
| 368 |
+
<div className="badge">Kết quả DASS-21</div>
|
| 369 |
+
<h1>Đây là bản tóm tắt sàng lọc của bạn</h1>
|
| 370 |
+
<p className="lead">
|
| 371 |
+
Kết quả này chỉ để tham khảo nhanh. Nếu bạn thấy khó chịu kéo dài, hãy cân nhắc tìm hỗ
|
| 372 |
+
trợ từ người thân, giảng viên cố vấn hoặc chuyên gia.
|
| 373 |
+
</p>
|
| 374 |
+
|
| 375 |
+
<div className="result-grid">
|
| 376 |
+
{resultCards.map(({ scale, score, severity, percent }) => (
|
| 377 |
+
<article key={scale} className={`score-card tone-${severity.tone}`}>
|
| 378 |
+
<div className="score-label">{scaleLabels[scale]}</div>
|
| 379 |
+
<div className="score-value">{score}</div>
|
| 380 |
+
<div className="score-meta">{severity.label}</div>
|
| 381 |
+
<div className="mini-bar" aria-hidden="true">
|
| 382 |
+
<span style={{ width: `${percent}%` }} />
|
| 383 |
+
</div>
|
| 384 |
+
<p>{scaleDescriptions[scale]}</p>
|
| 385 |
+
</article>
|
| 386 |
+
))}
|
| 387 |
+
</div>
|
| 388 |
+
|
| 389 |
+
<div className="result-dashboard">
|
| 390 |
+
<div className="highlight-panel">
|
| 391 |
+
<div>
|
| 392 |
+
<span className="panel-label">Mức nổi bật nhất</span>
|
| 393 |
+
<strong>
|
| 394 |
+
{scaleLabels[topConcern.scale]} · {topConcern.severity.label}
|
| 395 |
+
</strong>
|
| 396 |
+
</div>
|
| 397 |
+
<p>
|
| 398 |
+
Nếu bạn thấy mất ngủ, kiệt sức hoặc ảnh hưởng việc học, nên cân nhắc trao đổi sớm
|
| 399 |
+
với người phù hợp.
|
| 400 |
+
</p>
|
| 401 |
+
</div>
|
| 402 |
+
|
| 403 |
+
{(topConcern.severity.tone === 'severe' || topConcern.severity.tone === 'extreme') && (
|
| 404 |
+
<div className="warning-banner">
|
| 405 |
+
Đây là mức cần chú ý. Hãy ưu tiên gặp người thân, cố vấn học tập hoặc chuyên gia tâm
|
| 406 |
+
lý sớm.
|
| 407 |
+
</div>
|
| 408 |
+
)}
|
| 409 |
+
</div>
|
| 410 |
+
|
| 411 |
+
<div className="result-actions">
|
| 412 |
+
<button className="secondary-button" onClick={restart} type="button">
|
| 413 |
+
Làm lại
|
| 414 |
+
</button>
|
| 415 |
+
<button
|
| 416 |
+
className="primary-button"
|
| 417 |
+
onClick={() => {
|
| 418 |
+
setMessages([]);
|
| 419 |
+
setStage('chat');
|
| 420 |
+
}}
|
| 421 |
+
type="button"
|
| 422 |
+
>
|
| 423 |
+
Nhận tư vấn từ Gemini
|
| 424 |
+
</button>
|
| 425 |
+
</div>
|
| 426 |
+
|
| 427 |
+
<div className="disclaimer">
|
| 428 |
+
BERT chỉ chấm điểm 0–3 cho từng câu; Gemini dùng để phản hồi thấu cảm và gợi ý bước tiếp
|
| 429 |
+
theo.
|
| 430 |
+
</div>
|
| 431 |
+
</section>
|
| 432 |
+
</main>
|
| 433 |
+
);
|
| 434 |
+
}
|
| 435 |
+
|
| 436 |
+
if (stage === 'chat') {
|
| 437 |
+
return (
|
| 438 |
+
<main className="app-shell">
|
| 439 |
+
{renderThemeToggle()}
|
| 440 |
+
<section className="hero-card chat-card">
|
| 441 |
+
<div className="messenger-header">
|
| 442 |
+
<div className="messenger-info">
|
| 443 |
+
<div className="messenger-avatar bg-gemini">G</div>
|
| 444 |
+
<div>
|
| 445 |
+
<h2>Trợ lý Gemini</h2>
|
| 446 |
+
<p className="messenger-status"><span className="status-dot"></span>Đang hoạt động</p>
|
| 447 |
+
</div>
|
| 448 |
+
</div>
|
| 449 |
+
<div className="messenger-actions">
|
| 450 |
+
<button className="action-icon-btn" type="button" onClick={() => setStage('results')}>
|
| 451 |
+
Xem kết quả DASS-21
|
| 452 |
+
</button>
|
| 453 |
+
<button className="action-icon-btn secondary" type="button" onClick={restart}>
|
| 454 |
+
Trang chủ
|
| 455 |
+
</button>
|
| 456 |
+
</div>
|
| 457 |
+
</div>
|
| 458 |
+
|
| 459 |
+
{adviceRiskLevel !== 'low' && (
|
| 460 |
+
<div className={`warning-banner risk-${adviceRiskLevel}`} style={{ margin: '12px 16px 0' }}>
|
| 461 |
+
{adviceRiskLevel === 'high'
|
| 462 |
+
? 'Chatbot đang nhận thấy tín hiệu đáng chú ý. Nếu có ý nghĩ tự hại, hãy liên hệ người thân hoặc cơ sở y tế ngay.'
|
| 463 |
+
: 'Chatbot đang theo dõi thêm vì có dấu hiệu cần chú ý.'}
|
| 464 |
+
</div>
|
| 465 |
+
)}
|
| 466 |
+
|
| 467 |
+
{error && <div className="warning-banner" style={{ margin: '12px 16px 0' }}>{error}</div>}
|
| 468 |
+
|
| 469 |
+
<div className="chat-thread" ref={chatThreadRef} aria-live="polite">
|
| 470 |
+
{messages.map((message) => (
|
| 471 |
+
<div key={message.id} className={`chat-message ${message.role}`}>
|
| 472 |
+
{message.role === 'assistant' && (
|
| 473 |
+
<div className="bubble-avatar bg-gemini">G</div>
|
| 474 |
+
)}
|
| 475 |
+
<div className="chat-bubble">
|
| 476 |
+
{message.content}
|
| 477 |
+
</div>
|
| 478 |
+
</div>
|
| 479 |
+
))}
|
| 480 |
+
{loading && (
|
| 481 |
+
<div className="chat-message assistant">
|
| 482 |
+
<div className="bubble-avatar bg-gemini">G</div>
|
| 483 |
+
<div className="chat-bubble typing">
|
| 484 |
+
<span></span><span></span><span></span>
|
| 485 |
+
</div>
|
| 486 |
+
</div>
|
| 487 |
+
)}
|
| 488 |
+
</div>
|
| 489 |
+
|
| 490 |
+
<form className="chat-input-area" onSubmit={handleAdviceSend}>
|
| 491 |
+
<input
|
| 492 |
+
type="text"
|
| 493 |
+
value={input}
|
| 494 |
+
onChange={(event) => setInput(event.target.value)}
|
| 495 |
+
placeholder="Nhập điều bạn muốn chia sẻ..."
|
| 496 |
+
disabled={loading}
|
| 497 |
+
/>
|
| 498 |
+
<button className="send-icon-btn" type="submit" disabled={loading || !input.trim()}>
|
| 499 |
+
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor">
|
| 500 |
+
<path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z" />
|
| 501 |
+
</svg>
|
| 502 |
+
</button>
|
| 503 |
+
</form>
|
| 504 |
+
</section>
|
| 505 |
+
</main>
|
| 506 |
+
);
|
| 507 |
+
}
|
| 508 |
+
|
| 509 |
+
if (stage === 'questionnaire') {
|
| 510 |
+
return (
|
| 511 |
+
<main className="app-shell">
|
| 512 |
+
{renderThemeToggle()}
|
| 513 |
+
<section className="hero-card questionnaire-card">
|
| 514 |
+
<div className="messenger-header">
|
| 515 |
+
<div className="messenger-info">
|
| 516 |
+
<div className="messenger-avatar bg-dass">D</div>
|
| 517 |
+
<div>
|
| 518 |
+
<h2>Khảo sát DASS-21</h2>
|
| 519 |
+
<p className="messenger-status"><span className="status-dot"></span>Đang đánh giá</p>
|
| 520 |
+
</div>
|
| 521 |
+
</div>
|
| 522 |
+
<div className="progress-info">
|
| 523 |
+
<span>Câu hỏi {currentIndex + 1}/{questions.length}</span>
|
| 524 |
+
<div className="progress-bar-mini">
|
| 525 |
+
<div className="progress-fill-mini" style={{ width: `${progress}%` }} />
|
| 526 |
+
</div>
|
| 527 |
+
</div>
|
| 528 |
+
</div>
|
| 529 |
+
|
| 530 |
+
{error && <div className="warning-banner" style={{ margin: '12px 16px 0' }}>{error}</div>}
|
| 531 |
+
|
| 532 |
+
<div className="chat-thread" ref={chatThreadRef} aria-live="polite">
|
| 533 |
+
{messages.map((message) => (
|
| 534 |
+
<div key={message.id} className={`chat-message ${message.role}`}>
|
| 535 |
+
{message.role === 'assistant' && (
|
| 536 |
+
<div className="bubble-avatar bg-dass">D</div>
|
| 537 |
+
)}
|
| 538 |
+
<div className="chat-bubble">
|
| 539 |
+
{message.content}
|
| 540 |
+
</div>
|
| 541 |
+
</div>
|
| 542 |
+
))}
|
| 543 |
+
{loading && (
|
| 544 |
+
<div className="chat-message assistant">
|
| 545 |
+
<div className="bubble-avatar bg-dass">D</div>
|
| 546 |
+
<div className="chat-bubble typing">
|
| 547 |
+
<span></span><span></span><span></span>
|
| 548 |
+
</div>
|
| 549 |
+
</div>
|
| 550 |
+
)}
|
| 551 |
+
</div>
|
| 552 |
+
|
| 553 |
+
<div className="quick-replies">
|
| 554 |
+
{answerOptions.map((optionText, idx) => (
|
| 555 |
+
<button
|
| 556 |
+
key={idx}
|
| 557 |
+
type="button"
|
| 558 |
+
className="quick-reply-btn"
|
| 559 |
+
onClick={() => void submitQuestionnaireAnswer(optionText)}
|
| 560 |
+
disabled={loading}
|
| 561 |
+
>
|
| 562 |
+
{idx}. {optionText}
|
| 563 |
+
</button>
|
| 564 |
+
))}
|
| 565 |
+
</div>
|
| 566 |
+
|
| 567 |
+
<form className="chat-input-area" onSubmit={handleQuestionnaireSend}>
|
| 568 |
+
<input
|
| 569 |
+
type="text"
|
| 570 |
+
value={input}
|
| 571 |
+
onChange={(event) => setInput(event.target.value)}
|
| 572 |
+
placeholder="Trả lời tự nhiên hoặc chọn nhanh nút phía trên..."
|
| 573 |
+
disabled={loading}
|
| 574 |
+
/>
|
| 575 |
+
<button className="send-icon-btn" type="submit" disabled={loading || !input.trim()}>
|
| 576 |
+
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor">
|
| 577 |
+
<path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z" />
|
| 578 |
+
</svg>
|
| 579 |
+
</button>
|
| 580 |
+
<button className="cancel-pill-btn" type="button" onClick={restart}>
|
| 581 |
+
Hủy
|
| 582 |
+
</button>
|
| 583 |
+
</form>
|
| 584 |
+
</section>
|
| 585 |
+
</main>
|
| 586 |
+
);
|
| 587 |
+
}
|
| 588 |
+
|
| 589 |
+
return (
|
| 590 |
+
<>
|
| 591 |
+
<main className="app-shell">
|
| 592 |
+
{renderThemeToggle()}
|
| 593 |
+
<section className="hero-card landing-card">
|
| 594 |
+
<div className="badge">DASS-21 + BERT + Gemini</div>
|
| 595 |
+
<h1>Chatbot sàng lọc sức khỏe tinh thần bằng ngôn ngữ tự nhiên</h1>
|
| 596 |
+
<p className="lead">
|
| 597 |
+
Bạn trả lời tự nhiên như đang trò chuyện. BERT sẽ chấm điểm 0–3 cho từng câu DASS-21, rồi
|
| 598 |
+
Gemini phản hồi thấu cảm và tư vấn bước tiếp theo.
|
| 599 |
+
</p>
|
| 600 |
+
|
| 601 |
+
<div className="landing-grid">
|
| 602 |
+
<article className="landing-feature">
|
| 603 |
+
<strong>Trả lời bằng câu tự nhiên</strong>
|
| 604 |
+
<p>Không cần chọn số; chỉ cần mô tả cảm giác của bạn theo cách bình thường.</p>
|
| 605 |
+
</article>
|
| 606 |
+
<article className="landing-feature">
|
| 607 |
+
<strong>BERT chấm điểm tự động</strong>
|
| 608 |
+
<p>Mỗi câu trả lời được ánh xạ sang 0, 1, 2 hoặc 3 để tính điểm DASS-21.</p>
|
| 609 |
+
</article>
|
| 610 |
+
<article className="landing-feature">
|
| 611 |
+
<strong>Gemini phản hồi thấu cảm</strong>
|
| 612 |
+
<p>Chatbot sẽ nói lời nhẹ nhàng trong lúc làm bài và tư vấn sau khi có kết quả.</p>
|
| 613 |
+
</article>
|
| 614 |
+
</div>
|
| 615 |
+
|
| 616 |
+
<div className="landing-actions">
|
| 617 |
+
<button className="primary-button" onClick={() => setShowPrivacyModal(true)} type="button">
|
| 618 |
+
Bắt đầu sàng lọc
|
| 619 |
+
</button>
|
| 620 |
+
<button className="secondary-button" onClick={() => setStage('results')} type="button">
|
| 621 |
+
Xem mẫu kết quả
|
| 622 |
+
</button>
|
| 623 |
+
</div>
|
| 624 |
+
|
| 625 |
+
<div className="disclaimer">
|
| 626 |
+
Đây là công cụ hỗ trợ sàng lọc, không thay thế cho chẩn đoán chuyên môn.
|
| 627 |
+
</div>
|
| 628 |
+
</section>
|
| 629 |
+
</main>
|
| 630 |
+
|
| 631 |
+
{showPrivacyModal && (
|
| 632 |
+
<div className="modal-backdrop" onClick={handleDeclineConsent} aria-modal="true" role="dialog">
|
| 633 |
+
<div className="modal-container" onClick={(e) => e.stopPropagation()}>
|
| 634 |
+
<header className="modal-header">
|
| 635 |
+
<h2>ĐỒNG THUẬN TỰ NGUYỆN & QUYỀN RIÊNG TƯ</h2>
|
| 636 |
+
<button className="modal-close-btn" onClick={handleDeclineConsent} aria-label="Close modal">
|
| 637 |
+
×
|
| 638 |
+
</button>
|
| 639 |
+
</header>
|
| 640 |
+
|
| 641 |
+
<div className="modal-body">
|
| 642 |
+
<p className="modal-intro">
|
| 643 |
+
Chào bạn! Để đảm bảo an toàn thông tin và quyền riêng tư tuyệt đối của bạn khi tham gia sàng lọc sức khỏe tinh thần (DASS-21), vui lòng đọc và xác nhận các nội dung sau:
|
| 644 |
+
</p>
|
| 645 |
+
|
| 646 |
+
<section className="privacy-section">
|
| 647 |
+
<div className="privacy-section-title">
|
| 648 |
+
<span className="privacy-icon" aria-hidden="true">🛡️</span>
|
| 649 |
+
<span>1. Quyền riêng tư & Bảo mật (Stateless Architecture)</span>
|
| 650 |
+
</div>
|
| 651 |
+
<ul className="privacy-list">
|
| 652 |
+
<li><strong>Ẩn danh tuyệt đối:</strong> Hệ thống không yêu cầu tài khoản, không lưu trữ MSSV, Họ tên, Email hay bất kỳ dữ liệu định danh nào của bạn.</li>
|
| 653 |
+
<li><strong>Không lưu trữ ổ cứng (Stateless):</strong> Máy chủ hoàn toàn không lưu trữ cơ sở dữ liệu vật lý hay tệp tin nhật ký (log) về cuộc trò chuyện của bạn.</li>
|
| 654 |
+
<li><strong>Lưu trữ tạm thời trong RAM:</strong> Toàn bộ câu trả lời và kết quả tính toán chỉ được lưu tạm thời trong bộ nhớ RAM của trình duyệt phía client.</li>
|
| 655 |
+
<li><strong>Xóa sạch dấu vết:</strong> Ngay khi bạn đóng tab, làm mới trang (F5) hoặc nhấn nút "Làm lại", hàm <code>restart()</code> sẽ được kích hoạt để giải phóng và xóa sạch toàn bộ dữ liệu trên thiết bị.</li>
|
| 656 |
+
</ul>
|
| 657 |
+
</section>
|
| 658 |
+
|
| 659 |
+
<section className="privacy-section">
|
| 660 |
+
<div className="privacy-section-title">
|
| 661 |
+
<span className="privacy-icon" aria-hidden="true">🤖</span>
|
| 662 |
+
<span>2. Minh bạch thuật toán & Trách nhiệm (Explainability)</span>
|
| 663 |
+
</div>
|
| 664 |
+
<ul className="privacy-list">
|
| 665 |
+
<li><strong>Thuật toán chấm điểm:</strong> Câu trả lời tự nhiên của bạn được mô hình BERT cục bộ phân tích để chấm điểm Likert (0–3) cho từng câu của thang đo tiêu chuẩn DASS-21.</li>
|
| 666 |
+
<li><strong>Tư vấn từ AI:</strong> AI (Gemini) được sử dụng để phản hồi thấu cảm trong quá trình khảo sát và tư vấn các bước tiếp theo d��a trên điểm số của bạn.</li>
|
| 667 |
+
<li><strong>Không thay thế chẩn đoán y khoa:</strong> Hệ thống chỉ có chức năng sàng lọc sơ bộ. Chatbot này <strong>không</strong> thay thế cho kết luận của bác sĩ y khoa hay chuyên gia tâm lý chuyên nghiệp.</li>
|
| 668 |
+
</ul>
|
| 669 |
+
</section>
|
| 670 |
+
|
| 671 |
+
<label className="consent-checkbox-wrapper">
|
| 672 |
+
<input
|
| 673 |
+
type="checkbox"
|
| 674 |
+
checked={consentChecked}
|
| 675 |
+
onChange={(e) => setConsentChecked(e.target.checked)}
|
| 676 |
+
/>
|
| 677 |
+
<span>Tôi xác nhận đã đọc, hiểu rõ cơ chế hoạt động ẩn danh của hệ thống và tự nguyện tham gia bài sàng lọc này.</span>
|
| 678 |
+
</label>
|
| 679 |
+
</div>
|
| 680 |
+
|
| 681 |
+
<footer className="modal-actions">
|
| 682 |
+
<button className="secondary-button" onClick={handleDeclineConsent} type="button">
|
| 683 |
+
Từ chối
|
| 684 |
+
</button>
|
| 685 |
+
<button
|
| 686 |
+
className="primary-button"
|
| 687 |
+
onClick={handleAcceptConsent}
|
| 688 |
+
disabled={!consentChecked}
|
| 689 |
+
type="button"
|
| 690 |
+
>
|
| 691 |
+
Đồng ý và Bắt đầu
|
| 692 |
+
</button>
|
| 693 |
+
</footer>
|
| 694 |
+
</div>
|
| 695 |
+
</div>
|
| 696 |
+
)}
|
| 697 |
+
</>
|
| 698 |
+
);
|
| 699 |
+
}
|
| 700 |
+
|
| 701 |
+
export default App;
|
src/dass.ts
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export type Scale = 'stress' | 'anxiety' | 'depression';
|
| 2 |
+
|
| 3 |
+
export type AnswerValue = 0 | 1 | 2 | 3;
|
| 4 |
+
|
| 5 |
+
export type Question = {
|
| 6 |
+
id: number;
|
| 7 |
+
text: string;
|
| 8 |
+
scale: Scale;
|
| 9 |
+
};
|
| 10 |
+
|
| 11 |
+
export type SeverityBand = {
|
| 12 |
+
label: string;
|
| 13 |
+
min: number;
|
| 14 |
+
max: number;
|
| 15 |
+
tone: string;
|
| 16 |
+
};
|
| 17 |
+
|
| 18 |
+
export const answerOptions = [
|
| 19 |
+
'Không đúng với tôi chút nào cả',
|
| 20 |
+
'Đúng với tôi một phần, hoặc thỉnh thoảng mới đúng',
|
| 21 |
+
'Đúng với tôi phần nhiều, hoặc phần lớn thời gian là đúng',
|
| 22 |
+
'Hoàn toàn đúng với tôi, hoặc hầu hết thời gian là đúng',
|
| 23 |
+
] as const;
|
| 24 |
+
|
| 25 |
+
export const questions: Question[] = [
|
| 26 |
+
{ id: 1, text: 'Tôi thấy khó mà thoải mái được', scale: 'stress' },
|
| 27 |
+
{ id: 2, text: 'Tôi bị khô miệng', scale: 'anxiety' },
|
| 28 |
+
{ id: 3, text: 'Tôi không thấy có chút cảm xúc tích cực nào', scale: 'depression' },
|
| 29 |
+
{
|
| 30 |
+
id: 4,
|
| 31 |
+
text: 'Tôi bị rối loạn nhịp thở (thở gấp, khó thở dù chẳng làm việc gì nặng)',
|
| 32 |
+
scale: 'anxiety',
|
| 33 |
+
},
|
| 34 |
+
{ id: 5, text: 'Tôi thấy khó bắt tay vào công việc', scale: 'depression' },
|
| 35 |
+
{ id: 6, text: 'Tôi đã phản ứng thái quá khi có những sự việc xảy ra', scale: 'stress' },
|
| 36 |
+
{ id: 7, text: 'Tôi bị ra mồ hôi (chẳng hạn như mồ hôi tay...)', scale: 'anxiety' },
|
| 37 |
+
{ id: 8, text: 'Tôi thấy mình đang suy nghĩ quá nhiều', scale: 'stress' },
|
| 38 |
+
{
|
| 39 |
+
id: 9,
|
| 40 |
+
text: 'Tôi lo lắng về những tình huống có thể khiến tôi hoảng sợ hoặc biến tôi thành trò cười',
|
| 41 |
+
scale: 'anxiety',
|
| 42 |
+
},
|
| 43 |
+
{ id: 10, text: 'Tôi thấy mình chẳng có gì để mong đợi cả', scale: 'depression' },
|
| 44 |
+
{ id: 11, text: 'Tôi thấy bản thân dễ bị kích động', scale: 'stress' },
|
| 45 |
+
{ id: 12, text: 'Tôi thấy khó thư giãn được', scale: 'stress' },
|
| 46 |
+
{ id: 13, text: 'Tôi cảm thấy chán nản, thất vọng', scale: 'depression' },
|
| 47 |
+
{ id: 14, text: 'Tôi không chấp nhận được việc có cái gì đó xen vào cản trở việc tôi đang làm', scale: 'stress' },
|
| 48 |
+
{ id: 15, text: 'Tôi thấy mình gần như hoảng loạn', scale: 'anxiety' },
|
| 49 |
+
{ id: 16, text: 'Tôi không thấy hăng hái với bất kỳ việc gì nữa', scale: 'depression' },
|
| 50 |
+
{ id: 17, text: 'Tôi cảm thấy mình chẳng đáng làm người', scale: 'depression' },
|
| 51 |
+
{ id: 18, text: 'Tôi thấy mình khá dễ phật ý, tự ái', scale: 'stress' },
|
| 52 |
+
{
|
| 53 |
+
id: 19,
|
| 54 |
+
text: 'Tôi nghe thấy rõ tiếng nhịp tim dù chẳng làm việc gì cả (ví dụ, tiếng nhịp tim tăng, tiếng tim loạn nhịp)',
|
| 55 |
+
scale: 'anxiety',
|
| 56 |
+
},
|
| 57 |
+
{ id: 20, text: 'Tôi hay sợ vô cớ', scale: 'anxiety' },
|
| 58 |
+
{ id: 21, text: 'Tôi thấy cuộc sống vô nghĩa', scale: 'depression' },
|
| 59 |
+
] as const;
|
| 60 |
+
|
| 61 |
+
export const severityBands: Record<Scale, SeverityBand[]> = {
|
| 62 |
+
depression: [
|
| 63 |
+
{ label: 'Bình thường', min: 0, max: 9, tone: 'normal' },
|
| 64 |
+
{ label: 'Nhẹ', min: 10, max: 13, tone: 'mild' },
|
| 65 |
+
{ label: 'Vừa', min: 14, max: 20, tone: 'moderate' },
|
| 66 |
+
{ label: 'Nặng', min: 21, max: 27, tone: 'severe' },
|
| 67 |
+
{ label: 'Rất nặng', min: 28, max: Infinity, tone: 'extreme' },
|
| 68 |
+
],
|
| 69 |
+
anxiety: [
|
| 70 |
+
{ label: 'Bình thường', min: 0, max: 7, tone: 'normal' },
|
| 71 |
+
{ label: 'Nhẹ', min: 8, max: 9, tone: 'mild' },
|
| 72 |
+
{ label: 'Vừa', min: 10, max: 14, tone: 'moderate' },
|
| 73 |
+
{ label: 'Nặng', min: 15, max: 19, tone: 'severe' },
|
| 74 |
+
{ label: 'Rất nặng', min: 20, max: Infinity, tone: 'extreme' },
|
| 75 |
+
],
|
| 76 |
+
stress: [
|
| 77 |
+
{ label: 'Bình thường', min: 0, max: 14, tone: 'normal' },
|
| 78 |
+
{ label: 'Nhẹ', min: 15, max: 18, tone: 'mild' },
|
| 79 |
+
{ label: 'Vừa', min: 19, max: 25, tone: 'moderate' },
|
| 80 |
+
{ label: 'Nặng', min: 26, max: 33, tone: 'severe' },
|
| 81 |
+
{ label: 'Rất nặng', min: 34, max: Infinity, tone: 'extreme' },
|
| 82 |
+
],
|
| 83 |
+
};
|
| 84 |
+
|
| 85 |
+
export const scaleLabels: Record<Scale, string> = {
|
| 86 |
+
stress: 'Stress',
|
| 87 |
+
anxiety: 'Lo âu',
|
| 88 |
+
depression: 'Trầm cảm',
|
| 89 |
+
};
|
| 90 |
+
|
| 91 |
+
export const scaleDescriptions: Record<Scale, string> = {
|
| 92 |
+
stress: 'Căng thẳng, quá tải và khó thư giãn',
|
| 93 |
+
anxiety: 'Căng thẳng lo âu, hồi hộp và phản ứng sinh lý',
|
| 94 |
+
depression: 'Khí sắc thấp, mất hứng thú và tuyệt vọng',
|
| 95 |
+
};
|
| 96 |
+
|
| 97 |
+
export function getSeverity(scale: Scale, score: number) {
|
| 98 |
+
return (
|
| 99 |
+
severityBands[scale].find((band) => score >= band.min && score <= band.max) ??
|
| 100 |
+
severityBands[scale][severityBands[scale].length - 1]
|
| 101 |
+
);
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
export function scoreScale(answers: (AnswerValue | null)[]) {
|
| 105 |
+
const totals = questions.reduce<Record<Scale, number>>(
|
| 106 |
+
(acc, question, index) => {
|
| 107 |
+
const value = answers[index] ?? 0;
|
| 108 |
+
acc[question.scale] += value;
|
| 109 |
+
return acc;
|
| 110 |
+
},
|
| 111 |
+
{ stress: 0, anxiety: 0, depression: 0 },
|
| 112 |
+
);
|
| 113 |
+
|
| 114 |
+
return {
|
| 115 |
+
stress: totals.stress * 2,
|
| 116 |
+
anxiety: totals.anxiety * 2,
|
| 117 |
+
depression: totals.depression * 2,
|
| 118 |
+
};
|
| 119 |
+
}
|
src/main.tsx
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import React from 'react';
|
| 2 |
+
import ReactDOM from 'react-dom/client';
|
| 3 |
+
import App from './App';
|
| 4 |
+
import './styles.css';
|
| 5 |
+
|
| 6 |
+
ReactDOM.createRoot(document.getElementById('root')!).render(
|
| 7 |
+
<React.StrictMode>
|
| 8 |
+
<App />
|
| 9 |
+
</React.StrictMode>,
|
| 10 |
+
);
|
src/styles.css
ADDED
|
@@ -0,0 +1,1419 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
:root {
|
| 2 |
+
color-scheme: light;
|
| 3 |
+
font-family:
|
| 4 |
+
Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
| 5 |
+
color: #142033;
|
| 6 |
+
background:
|
| 7 |
+
radial-gradient(circle at top, rgba(84, 139, 255, 0.14), transparent 28%),
|
| 8 |
+
linear-gradient(180deg, #f7f9ff 0%, #eef4ff 45%, #eaf0fb 100%);
|
| 9 |
+
line-height: 1.5;
|
| 10 |
+
font-weight: 400;
|
| 11 |
+
}
|
| 12 |
+
|
| 13 |
+
* {
|
| 14 |
+
box-sizing: border-box;
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
html,
|
| 18 |
+
body,
|
| 19 |
+
#root {
|
| 20 |
+
margin: 0;
|
| 21 |
+
min-height: 100%;
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
body {
|
| 25 |
+
min-height: 100vh;
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
button {
|
| 29 |
+
font: inherit;
|
| 30 |
+
}
|
| 31 |
+
|
| 32 |
+
.app-shell {
|
| 33 |
+
min-height: 100vh;
|
| 34 |
+
display: grid;
|
| 35 |
+
place-items: center;
|
| 36 |
+
padding: 24px;
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
.hero-card {
|
| 40 |
+
width: min(960px, 100%);
|
| 41 |
+
border: 1px solid rgba(98, 128, 198, 0.14);
|
| 42 |
+
background: rgba(255, 255, 255, 0.82);
|
| 43 |
+
box-shadow: 0 28px 80px rgba(61, 89, 145, 0.12);
|
| 44 |
+
backdrop-filter: blur(18px);
|
| 45 |
+
border-radius: 28px;
|
| 46 |
+
padding: 28px;
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
.landing-card {
|
| 50 |
+
display: grid;
|
| 51 |
+
gap: 18px;
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
.landing-grid {
|
| 55 |
+
display: grid;
|
| 56 |
+
grid-template-columns: repeat(3, minmax(0, 1fr));
|
| 57 |
+
gap: 14px;
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
.landing-feature {
|
| 61 |
+
padding: 18px;
|
| 62 |
+
border-radius: 20px;
|
| 63 |
+
background: rgba(91, 123, 208, 0.06);
|
| 64 |
+
border: 1px solid rgba(91, 123, 208, 0.1);
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
+
.landing-feature strong {
|
| 68 |
+
display: block;
|
| 69 |
+
margin-bottom: 8px;
|
| 70 |
+
color: #16213a;
|
| 71 |
+
}
|
| 72 |
+
|
| 73 |
+
.landing-feature p {
|
| 74 |
+
margin: 0;
|
| 75 |
+
color: #5f6f8b;
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
.landing-actions {
|
| 79 |
+
display: flex;
|
| 80 |
+
gap: 12px;
|
| 81 |
+
flex-wrap: wrap;
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
.top-row {
|
| 85 |
+
display: flex;
|
| 86 |
+
justify-content: space-between;
|
| 87 |
+
gap: 20px;
|
| 88 |
+
align-items: flex-start;
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
.badge {
|
| 92 |
+
display: inline-flex;
|
| 93 |
+
align-items: center;
|
| 94 |
+
gap: 8px;
|
| 95 |
+
padding: 8px 12px;
|
| 96 |
+
border-radius: 999px;
|
| 97 |
+
background: rgba(93, 131, 242, 0.12);
|
| 98 |
+
color: #3556a8;
|
| 99 |
+
font-size: 0.82rem;
|
| 100 |
+
letter-spacing: 0.04em;
|
| 101 |
+
text-transform: uppercase;
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
h1 {
|
| 105 |
+
margin: 14px 0 10px;
|
| 106 |
+
font-size: clamp(2rem, 4vw, 3.2rem);
|
| 107 |
+
line-height: 1.05;
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
.lead {
|
| 111 |
+
margin: 0;
|
| 112 |
+
max-width: 62ch;
|
| 113 |
+
color: #5f6f8b;
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
.progress-chip {
|
| 117 |
+
min-width: 110px;
|
| 118 |
+
padding: 14px 16px;
|
| 119 |
+
border-radius: 18px;
|
| 120 |
+
background: rgba(91, 123, 208, 0.08);
|
| 121 |
+
border: 1px solid rgba(91, 123, 208, 0.1);
|
| 122 |
+
text-align: center;
|
| 123 |
+
}
|
| 124 |
+
|
| 125 |
+
.progress-chip span,
|
| 126 |
+
.panel-label,
|
| 127 |
+
.footer-note,
|
| 128 |
+
.score-meta,
|
| 129 |
+
.score-label,
|
| 130 |
+
.helper-text,
|
| 131 |
+
.disclaimer {
|
| 132 |
+
color: #63728b;
|
| 133 |
+
}
|
| 134 |
+
|
| 135 |
+
.progress-chip strong {
|
| 136 |
+
display: block;
|
| 137 |
+
font-size: 1.35rem;
|
| 138 |
+
color: #142033;
|
| 139 |
+
}
|
| 140 |
+
|
| 141 |
+
.progress-bar {
|
| 142 |
+
margin: 24px 0;
|
| 143 |
+
height: 10px;
|
| 144 |
+
border-radius: 999px;
|
| 145 |
+
background: rgba(91, 123, 208, 0.12);
|
| 146 |
+
overflow: hidden;
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
.progress-fill {
|
| 150 |
+
height: 100%;
|
| 151 |
+
border-radius: inherit;
|
| 152 |
+
background: linear-gradient(90deg, #6ea8ff, #8b7dff, #70e0c8);
|
| 153 |
+
}
|
| 154 |
+
|
| 155 |
+
.question-card {
|
| 156 |
+
padding: 24px;
|
| 157 |
+
border-radius: 24px;
|
| 158 |
+
background: linear-gradient(180deg, rgba(255, 255, 255, 0.9), rgba(245, 249, 255, 0.92));
|
| 159 |
+
border: 1px solid rgba(91, 123, 208, 0.12);
|
| 160 |
+
}
|
| 161 |
+
|
| 162 |
+
.question-toolbar {
|
| 163 |
+
display: flex;
|
| 164 |
+
justify-content: flex-end;
|
| 165 |
+
gap: 10px;
|
| 166 |
+
margin-bottom: 16px;
|
| 167 |
+
flex-wrap: wrap;
|
| 168 |
+
}
|
| 169 |
+
|
| 170 |
+
.chat-top {
|
| 171 |
+
align-items: flex-start;
|
| 172 |
+
}
|
| 173 |
+
|
| 174 |
+
.chat-actions {
|
| 175 |
+
display: flex;
|
| 176 |
+
gap: 10px;
|
| 177 |
+
flex-wrap: wrap;
|
| 178 |
+
justify-content: flex-end;
|
| 179 |
+
}
|
| 180 |
+
|
| 181 |
+
h2 {
|
| 182 |
+
margin: 0 0 20px;
|
| 183 |
+
font-size: clamp(1.2rem, 2.2vw, 1.7rem);
|
| 184 |
+
color: #16213a;
|
| 185 |
+
}
|
| 186 |
+
|
| 187 |
+
.options {
|
| 188 |
+
display: grid;
|
| 189 |
+
gap: 12px;
|
| 190 |
+
}
|
| 191 |
+
|
| 192 |
+
.option-button {
|
| 193 |
+
width: 100%;
|
| 194 |
+
display: grid;
|
| 195 |
+
grid-template-columns: 40px 1fr;
|
| 196 |
+
gap: 14px;
|
| 197 |
+
align-items: center;
|
| 198 |
+
padding: 16px 18px;
|
| 199 |
+
border-radius: 18px;
|
| 200 |
+
border: 1px solid rgba(91, 123, 208, 0.14);
|
| 201 |
+
background: rgba(255, 255, 255, 0.9);
|
| 202 |
+
color: #183055;
|
| 203 |
+
text-align: left;
|
| 204 |
+
cursor: pointer;
|
| 205 |
+
transition:
|
| 206 |
+
transform 0.18s ease,
|
| 207 |
+
border-color 0.18s ease,
|
| 208 |
+
background 0.18s ease;
|
| 209 |
+
}
|
| 210 |
+
|
| 211 |
+
.option-button:hover {
|
| 212 |
+
transform: translateY(-1px);
|
| 213 |
+
border-color: rgba(126, 170, 255, 0.42);
|
| 214 |
+
background: rgba(236, 243, 255, 0.98);
|
| 215 |
+
}
|
| 216 |
+
|
| 217 |
+
.option-button.selected {
|
| 218 |
+
border-color: rgba(110, 168, 255, 0.85);
|
| 219 |
+
background: rgba(221, 235, 255, 0.98);
|
| 220 |
+
}
|
| 221 |
+
|
| 222 |
+
.option-index {
|
| 223 |
+
width: 40px;
|
| 224 |
+
height: 40px;
|
| 225 |
+
display: grid;
|
| 226 |
+
place-items: center;
|
| 227 |
+
border-radius: 999px;
|
| 228 |
+
background: rgba(93, 131, 242, 0.12);
|
| 229 |
+
color: #3556a8;
|
| 230 |
+
font-weight: 700;
|
| 231 |
+
}
|
| 232 |
+
|
| 233 |
+
.option-text {
|
| 234 |
+
color: #183055;
|
| 235 |
+
}
|
| 236 |
+
|
| 237 |
+
.nav-row,
|
| 238 |
+
.result-actions {
|
| 239 |
+
display: flex;
|
| 240 |
+
align-items: center;
|
| 241 |
+
justify-content: space-between;
|
| 242 |
+
gap: 16px;
|
| 243 |
+
margin-top: 22px;
|
| 244 |
+
}
|
| 245 |
+
|
| 246 |
+
.helper-text {
|
| 247 |
+
text-align: center;
|
| 248 |
+
flex: 1;
|
| 249 |
+
}
|
| 250 |
+
|
| 251 |
+
.primary-button,
|
| 252 |
+
.secondary-button,
|
| 253 |
+
.ghost-button {
|
| 254 |
+
border: 0;
|
| 255 |
+
padding: 14px 18px;
|
| 256 |
+
border-radius: 14px;
|
| 257 |
+
cursor: pointer;
|
| 258 |
+
transition:
|
| 259 |
+
transform 0.18s ease,
|
| 260 |
+
opacity 0.18s ease;
|
| 261 |
+
}
|
| 262 |
+
|
| 263 |
+
.primary-button {
|
| 264 |
+
color: #ffffff;
|
| 265 |
+
background: linear-gradient(135deg, #5d83f2, #7b8ef6 55%, #50c7b0);
|
| 266 |
+
font-weight: 700;
|
| 267 |
+
min-width: 120px;
|
| 268 |
+
}
|
| 269 |
+
|
| 270 |
+
.secondary-button {
|
| 271 |
+
color: #27406f;
|
| 272 |
+
background: rgba(91, 123, 208, 0.08);
|
| 273 |
+
border: 1px solid rgba(91, 123, 208, 0.12);
|
| 274 |
+
min-width: 120px;
|
| 275 |
+
}
|
| 276 |
+
|
| 277 |
+
.ghost-button {
|
| 278 |
+
color: #4e658e;
|
| 279 |
+
background: transparent;
|
| 280 |
+
border: 1px solid rgba(91, 123, 208, 0.12);
|
| 281 |
+
min-width: 120px;
|
| 282 |
+
}
|
| 283 |
+
|
| 284 |
+
.primary-button:hover,
|
| 285 |
+
.secondary-button:hover {
|
| 286 |
+
transform: translateY(-1px);
|
| 287 |
+
}
|
| 288 |
+
|
| 289 |
+
.primary-button:disabled,
|
| 290 |
+
.secondary-button:disabled,
|
| 291 |
+
.ghost-button:disabled {
|
| 292 |
+
cursor: not-allowed;
|
| 293 |
+
opacity: 0.45;
|
| 294 |
+
transform: none;
|
| 295 |
+
}
|
| 296 |
+
|
| 297 |
+
.footer-note {
|
| 298 |
+
margin-top: 18px;
|
| 299 |
+
font-size: 0.94rem;
|
| 300 |
+
}
|
| 301 |
+
|
| 302 |
+
.results-card h1 {
|
| 303 |
+
max-width: 14ch;
|
| 304 |
+
}
|
| 305 |
+
|
| 306 |
+
.result-grid {
|
| 307 |
+
margin-top: 24px;
|
| 308 |
+
display: grid;
|
| 309 |
+
grid-template-columns: repeat(3, minmax(0, 1fr));
|
| 310 |
+
gap: 16px;
|
| 311 |
+
}
|
| 312 |
+
|
| 313 |
+
.score-card {
|
| 314 |
+
padding: 20px;
|
| 315 |
+
border-radius: 22px;
|
| 316 |
+
border: 1px solid rgba(91, 123, 208, 0.12);
|
| 317 |
+
background: rgba(255, 255, 255, 0.92);
|
| 318 |
+
}
|
| 319 |
+
|
| 320 |
+
.score-card p {
|
| 321 |
+
margin: 10px 0 0;
|
| 322 |
+
color: #5f6f8b;
|
| 323 |
+
}
|
| 324 |
+
|
| 325 |
+
.mini-bar {
|
| 326 |
+
margin-top: 12px;
|
| 327 |
+
height: 10px;
|
| 328 |
+
border-radius: 999px;
|
| 329 |
+
background: rgba(91, 123, 208, 0.1);
|
| 330 |
+
overflow: hidden;
|
| 331 |
+
}
|
| 332 |
+
|
| 333 |
+
.mini-bar span {
|
| 334 |
+
display: block;
|
| 335 |
+
height: 100%;
|
| 336 |
+
border-radius: inherit;
|
| 337 |
+
background: linear-gradient(90deg, #5d83f2, #50c7b0);
|
| 338 |
+
}
|
| 339 |
+
|
| 340 |
+
.score-label {
|
| 341 |
+
text-transform: uppercase;
|
| 342 |
+
letter-spacing: 0.08em;
|
| 343 |
+
font-size: 0.78rem;
|
| 344 |
+
}
|
| 345 |
+
|
| 346 |
+
.score-value {
|
| 347 |
+
margin-top: 8px;
|
| 348 |
+
font-size: 2.5rem;
|
| 349 |
+
font-weight: 800;
|
| 350 |
+
}
|
| 351 |
+
|
| 352 |
+
.highlight-panel {
|
| 353 |
+
margin-top: 18px;
|
| 354 |
+
padding: 18px 20px;
|
| 355 |
+
border-radius: 20px;
|
| 356 |
+
background: rgba(93, 131, 242, 0.08);
|
| 357 |
+
border: 1px solid rgba(93, 131, 242, 0.12);
|
| 358 |
+
}
|
| 359 |
+
|
| 360 |
+
.highlight-panel strong {
|
| 361 |
+
display: block;
|
| 362 |
+
margin-top: 6px;
|
| 363 |
+
font-size: 1.08rem;
|
| 364 |
+
}
|
| 365 |
+
|
| 366 |
+
.result-dashboard {
|
| 367 |
+
margin-top: 18px;
|
| 368 |
+
display: grid;
|
| 369 |
+
gap: 14px;
|
| 370 |
+
}
|
| 371 |
+
|
| 372 |
+
.warning-banner {
|
| 373 |
+
padding: 14px 16px;
|
| 374 |
+
border-radius: 16px;
|
| 375 |
+
background: rgba(255, 205, 96, 0.16);
|
| 376 |
+
border: 1px solid rgba(255, 205, 96, 0.28);
|
| 377 |
+
color: #6f4c00;
|
| 378 |
+
}
|
| 379 |
+
|
| 380 |
+
.risk-high {
|
| 381 |
+
background: rgba(255, 120, 120, 0.14);
|
| 382 |
+
border-color: rgba(255, 120, 120, 0.28);
|
| 383 |
+
color: #8c1e1e;
|
| 384 |
+
}
|
| 385 |
+
|
| 386 |
+
.risk-moderate {
|
| 387 |
+
background: rgba(255, 180, 80, 0.14);
|
| 388 |
+
border-color: rgba(255, 180, 80, 0.28);
|
| 389 |
+
color: #7b4a00;
|
| 390 |
+
}
|
| 391 |
+
|
| 392 |
+
.chat-card {
|
| 393 |
+
width: min(960px, 100%);
|
| 394 |
+
padding: 0 !important; /* Reset padding để header và input sát viền */
|
| 395 |
+
overflow: hidden;
|
| 396 |
+
display: flex;
|
| 397 |
+
flex-direction: column;
|
| 398 |
+
height: 680px;
|
| 399 |
+
}
|
| 400 |
+
|
| 401 |
+
.questionnaire-card {
|
| 402 |
+
padding: 0 !important;
|
| 403 |
+
overflow: hidden;
|
| 404 |
+
display: flex;
|
| 405 |
+
flex-direction: column;
|
| 406 |
+
height: 680px;
|
| 407 |
+
}
|
| 408 |
+
|
| 409 |
+
/* Header dạng Messenger/Zalo */
|
| 410 |
+
.messenger-header {
|
| 411 |
+
display: flex;
|
| 412 |
+
justify-content: space-between;
|
| 413 |
+
align-items: center;
|
| 414 |
+
padding: 16px 24px;
|
| 415 |
+
background: rgba(255, 255, 255, 0.95);
|
| 416 |
+
border-bottom: 1px solid rgba(91, 123, 208, 0.12);
|
| 417 |
+
z-index: 10;
|
| 418 |
+
}
|
| 419 |
+
|
| 420 |
+
.messenger-info {
|
| 421 |
+
display: flex;
|
| 422 |
+
align-items: center;
|
| 423 |
+
gap: 12px;
|
| 424 |
+
}
|
| 425 |
+
|
| 426 |
+
.messenger-avatar {
|
| 427 |
+
width: 44px;
|
| 428 |
+
height: 44px;
|
| 429 |
+
border-radius: 50%;
|
| 430 |
+
display: grid;
|
| 431 |
+
place-items: center;
|
| 432 |
+
color: #fff;
|
| 433 |
+
font-weight: 700;
|
| 434 |
+
font-size: 1.15rem;
|
| 435 |
+
box-shadow: 0 4px 12px rgba(91, 123, 208, 0.2);
|
| 436 |
+
}
|
| 437 |
+
|
| 438 |
+
.bg-gemini {
|
| 439 |
+
background: linear-gradient(135deg, #7f56da, #3f7bf6);
|
| 440 |
+
}
|
| 441 |
+
|
| 442 |
+
.bg-dass {
|
| 443 |
+
background: linear-gradient(135deg, #00b4db, #0083b0);
|
| 444 |
+
}
|
| 445 |
+
|
| 446 |
+
.messenger-info h2 {
|
| 447 |
+
margin: 0;
|
| 448 |
+
font-size: 1.1rem;
|
| 449 |
+
font-weight: 700;
|
| 450 |
+
color: #1e293b;
|
| 451 |
+
line-height: 1.2;
|
| 452 |
+
}
|
| 453 |
+
|
| 454 |
+
.messenger-status {
|
| 455 |
+
margin: 4px 0 0;
|
| 456 |
+
font-size: 0.8rem;
|
| 457 |
+
color: #64748b;
|
| 458 |
+
display: flex;
|
| 459 |
+
align-items: center;
|
| 460 |
+
gap: 6px;
|
| 461 |
+
}
|
| 462 |
+
|
| 463 |
+
.status-dot {
|
| 464 |
+
width: 8px;
|
| 465 |
+
height: 8px;
|
| 466 |
+
background-color: #22c55e;
|
| 467 |
+
border-radius: 50%;
|
| 468 |
+
display: inline-block;
|
| 469 |
+
box-shadow: 0 0 0 2px rgba(34, 197, 94, 0.2);
|
| 470 |
+
}
|
| 471 |
+
|
| 472 |
+
.messenger-actions {
|
| 473 |
+
display: flex;
|
| 474 |
+
gap: 8px;
|
| 475 |
+
}
|
| 476 |
+
|
| 477 |
+
.progress-info {
|
| 478 |
+
display: flex;
|
| 479 |
+
flex-direction: column;
|
| 480 |
+
align-items: flex-end;
|
| 481 |
+
gap: 6px;
|
| 482 |
+
min-width: 140px;
|
| 483 |
+
}
|
| 484 |
+
|
| 485 |
+
.progress-info span {
|
| 486 |
+
font-size: 0.85rem;
|
| 487 |
+
font-weight: 600;
|
| 488 |
+
color: #475569;
|
| 489 |
+
}
|
| 490 |
+
|
| 491 |
+
.progress-bar-mini {
|
| 492 |
+
width: 100%;
|
| 493 |
+
height: 6px;
|
| 494 |
+
border-radius: 99px;
|
| 495 |
+
background: rgba(91, 123, 208, 0.12);
|
| 496 |
+
overflow: hidden;
|
| 497 |
+
}
|
| 498 |
+
|
| 499 |
+
.progress-fill-mini {
|
| 500 |
+
height: 100%;
|
| 501 |
+
border-radius: inherit;
|
| 502 |
+
background: linear-gradient(90deg, #00b4db, #7f56da);
|
| 503 |
+
transition: width 0.3s ease;
|
| 504 |
+
}
|
| 505 |
+
|
| 506 |
+
.action-icon-btn {
|
| 507 |
+
border: 0;
|
| 508 |
+
background: rgba(91, 123, 208, 0.06);
|
| 509 |
+
border: 1px solid rgba(91, 123, 208, 0.1);
|
| 510 |
+
color: #3b5284;
|
| 511 |
+
padding: 8px 14px;
|
| 512 |
+
border-radius: 12px;
|
| 513 |
+
font-size: 0.85rem;
|
| 514 |
+
font-weight: 600;
|
| 515 |
+
cursor: pointer;
|
| 516 |
+
transition: all 0.2s ease;
|
| 517 |
+
}
|
| 518 |
+
|
| 519 |
+
.action-icon-btn:hover {
|
| 520 |
+
background: rgba(91, 123, 208, 0.12);
|
| 521 |
+
transform: translateY(-1px);
|
| 522 |
+
}
|
| 523 |
+
|
| 524 |
+
.action-icon-btn.secondary {
|
| 525 |
+
background: transparent;
|
| 526 |
+
color: #64748b;
|
| 527 |
+
border-color: transparent;
|
| 528 |
+
}
|
| 529 |
+
|
| 530 |
+
.action-icon-btn.secondary:hover {
|
| 531 |
+
background: rgba(91, 123, 208, 0.05);
|
| 532 |
+
}
|
| 533 |
+
|
| 534 |
+
/* Khung chat cố định */
|
| 535 |
+
.chat-thread {
|
| 536 |
+
flex: 1;
|
| 537 |
+
padding: 24px;
|
| 538 |
+
display: flex;
|
| 539 |
+
flex-direction: column;
|
| 540 |
+
gap: 16px;
|
| 541 |
+
overflow-y: auto;
|
| 542 |
+
background: #f8fafc;
|
| 543 |
+
}
|
| 544 |
+
|
| 545 |
+
/* Custom Scrollbar cho Chat */
|
| 546 |
+
.chat-thread::-webkit-scrollbar {
|
| 547 |
+
width: 6px;
|
| 548 |
+
}
|
| 549 |
+
.chat-thread::-webkit-scrollbar-track {
|
| 550 |
+
background: transparent;
|
| 551 |
+
}
|
| 552 |
+
.chat-thread::-webkit-scrollbar-thumb {
|
| 553 |
+
background: rgba(148, 163, 184, 0.3);
|
| 554 |
+
border-radius: 99px;
|
| 555 |
+
}
|
| 556 |
+
.chat-thread::-webkit-scrollbar-thumb:hover {
|
| 557 |
+
background: rgba(148, 163, 184, 0.5);
|
| 558 |
+
}
|
| 559 |
+
|
| 560 |
+
/* Cấu trúc tin nhắn */
|
| 561 |
+
.chat-message {
|
| 562 |
+
display: flex;
|
| 563 |
+
align-items: flex-end;
|
| 564 |
+
gap: 8px;
|
| 565 |
+
max-width: 75%;
|
| 566 |
+
animation: messageSlideIn 0.25s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
| 567 |
+
}
|
| 568 |
+
|
| 569 |
+
@keyframes messageSlideIn {
|
| 570 |
+
from {
|
| 571 |
+
opacity: 0;
|
| 572 |
+
transform: translateY(12px);
|
| 573 |
+
}
|
| 574 |
+
to {
|
| 575 |
+
opacity: 1;
|
| 576 |
+
transform: translateY(0);
|
| 577 |
+
}
|
| 578 |
+
}
|
| 579 |
+
|
| 580 |
+
.chat-message.assistant {
|
| 581 |
+
align-self: flex-start;
|
| 582 |
+
}
|
| 583 |
+
|
| 584 |
+
.chat-message.user {
|
| 585 |
+
align-self: flex-end;
|
| 586 |
+
flex-direction: row-reverse;
|
| 587 |
+
max-width: 70%;
|
| 588 |
+
}
|
| 589 |
+
|
| 590 |
+
.bubble-avatar {
|
| 591 |
+
width: 28px;
|
| 592 |
+
height: 28px;
|
| 593 |
+
border-radius: 50%;
|
| 594 |
+
display: grid;
|
| 595 |
+
place-items: center;
|
| 596 |
+
color: white;
|
| 597 |
+
font-weight: 700;
|
| 598 |
+
font-size: 0.75rem;
|
| 599 |
+
flex-shrink: 0;
|
| 600 |
+
margin-bottom: 2px;
|
| 601 |
+
}
|
| 602 |
+
|
| 603 |
+
/* Bong bóng chat tinh tế */
|
| 604 |
+
.chat-bubble {
|
| 605 |
+
padding: 12px 16px;
|
| 606 |
+
border-radius: 18px;
|
| 607 |
+
font-size: 0.95rem;
|
| 608 |
+
line-height: 1.5;
|
| 609 |
+
white-space: pre-wrap;
|
| 610 |
+
word-break: break-word;
|
| 611 |
+
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.05);
|
| 612 |
+
}
|
| 613 |
+
|
| 614 |
+
.chat-message.assistant .chat-bubble {
|
| 615 |
+
background: #ffffff;
|
| 616 |
+
color: #0f172a;
|
| 617 |
+
border: 1px solid #e2e8f0;
|
| 618 |
+
border-bottom-left-radius: 4px;
|
| 619 |
+
}
|
| 620 |
+
|
| 621 |
+
.chat-message.user .chat-bubble {
|
| 622 |
+
background: linear-gradient(135deg, #0084ff, #00a2ff); /* Phong cách Messenger/Zalo */
|
| 623 |
+
color: #ffffff;
|
| 624 |
+
border-bottom-right-radius: 4px;
|
| 625 |
+
}
|
| 626 |
+
|
| 627 |
+
/* Thanh nhập tin nhắn dạng viên thuốc */
|
| 628 |
+
.chat-input-area {
|
| 629 |
+
padding: 16px 24px;
|
| 630 |
+
background: #ffffff;
|
| 631 |
+
border-top: 1px solid rgba(91, 123, 208, 0.12);
|
| 632 |
+
display: flex;
|
| 633 |
+
align-items: center;
|
| 634 |
+
gap: 12px;
|
| 635 |
+
}
|
| 636 |
+
|
| 637 |
+
.chat-input-area input {
|
| 638 |
+
flex: 1;
|
| 639 |
+
height: 48px;
|
| 640 |
+
border-radius: 24px;
|
| 641 |
+
border: 1px solid #e2e8f0;
|
| 642 |
+
background: #f1f5f9;
|
| 643 |
+
color: #0f172a;
|
| 644 |
+
padding: 0 20px;
|
| 645 |
+
font-size: 0.95rem;
|
| 646 |
+
font-family: inherit;
|
| 647 |
+
transition: all 0.2s ease;
|
| 648 |
+
}
|
| 649 |
+
|
| 650 |
+
.chat-input-area input:focus {
|
| 651 |
+
outline: none;
|
| 652 |
+
border-color: #0084ff;
|
| 653 |
+
background: #ffffff;
|
| 654 |
+
box-shadow: 0 0 0 3px rgba(0, 132, 255, 0.15);
|
| 655 |
+
}
|
| 656 |
+
|
| 657 |
+
.chat-input-area input::placeholder {
|
| 658 |
+
color: #94a3b8;
|
| 659 |
+
}
|
| 660 |
+
|
| 661 |
+
/* Nút gửi Icon máy bay */
|
| 662 |
+
.send-icon-btn {
|
| 663 |
+
width: 44px;
|
| 664 |
+
height: 44px;
|
| 665 |
+
border-radius: 50%;
|
| 666 |
+
border: 0;
|
| 667 |
+
background: #0084ff;
|
| 668 |
+
color: white;
|
| 669 |
+
display: grid;
|
| 670 |
+
place-items: center;
|
| 671 |
+
cursor: pointer;
|
| 672 |
+
transition: all 0.2s ease;
|
| 673 |
+
flex-shrink: 0;
|
| 674 |
+
}
|
| 675 |
+
|
| 676 |
+
.send-icon-btn:hover {
|
| 677 |
+
background: #0074e0;
|
| 678 |
+
transform: scale(1.05);
|
| 679 |
+
}
|
| 680 |
+
|
| 681 |
+
.send-icon-btn:active {
|
| 682 |
+
transform: scale(0.95);
|
| 683 |
+
}
|
| 684 |
+
|
| 685 |
+
.send-icon-btn:disabled {
|
| 686 |
+
background: #cbd5e1;
|
| 687 |
+
color: #94a3b8;
|
| 688 |
+
cursor: not-allowed;
|
| 689 |
+
transform: none;
|
| 690 |
+
}
|
| 691 |
+
|
| 692 |
+
.send-icon-btn svg {
|
| 693 |
+
margin-left: 2px; /* Lệch tâm nhẹ để cân bằng thị giác */
|
| 694 |
+
}
|
| 695 |
+
|
| 696 |
+
.cancel-pill-btn {
|
| 697 |
+
border: 0;
|
| 698 |
+
background: transparent;
|
| 699 |
+
color: #64748b;
|
| 700 |
+
font-weight: 600;
|
| 701 |
+
font-size: 0.9rem;
|
| 702 |
+
cursor: pointer;
|
| 703 |
+
padding: 0 8px;
|
| 704 |
+
transition: color 0.2s ease;
|
| 705 |
+
}
|
| 706 |
+
|
| 707 |
+
.cancel-pill-btn:hover {
|
| 708 |
+
color: #0f172a;
|
| 709 |
+
}
|
| 710 |
+
|
| 711 |
+
/* Các nút trả lời nhanh (Quick Replies) */
|
| 712 |
+
.quick-replies {
|
| 713 |
+
display: flex;
|
| 714 |
+
gap: 8px;
|
| 715 |
+
padding: 12px 24px;
|
| 716 |
+
background: #f8fafc;
|
| 717 |
+
overflow-x: auto;
|
| 718 |
+
border-top: 1px solid rgba(91, 123, 208, 0.08);
|
| 719 |
+
flex-wrap: wrap;
|
| 720 |
+
}
|
| 721 |
+
|
| 722 |
+
.quick-replies::-webkit-scrollbar {
|
| 723 |
+
height: 4px;
|
| 724 |
+
}
|
| 725 |
+
.quick-replies::-webkit-scrollbar-thumb {
|
| 726 |
+
background: rgba(148, 163, 184, 0.2);
|
| 727 |
+
border-radius: 99px;
|
| 728 |
+
}
|
| 729 |
+
|
| 730 |
+
.quick-reply-btn {
|
| 731 |
+
border: 1px solid #0084ff;
|
| 732 |
+
background: #ffffff;
|
| 733 |
+
color: #0084ff;
|
| 734 |
+
padding: 8px 16px;
|
| 735 |
+
border-radius: 18px;
|
| 736 |
+
font-size: 0.88rem;
|
| 737 |
+
font-weight: 600;
|
| 738 |
+
cursor: pointer;
|
| 739 |
+
white-space: nowrap;
|
| 740 |
+
transition: all 0.2s ease;
|
| 741 |
+
}
|
| 742 |
+
|
| 743 |
+
.quick-reply-btn:hover:not(:disabled) {
|
| 744 |
+
background: #0084ff;
|
| 745 |
+
color: #ffffff;
|
| 746 |
+
transform: translateY(-1px);
|
| 747 |
+
}
|
| 748 |
+
|
| 749 |
+
.quick-reply-btn:disabled {
|
| 750 |
+
border-color: #cbd5e1;
|
| 751 |
+
color: #94a3b8;
|
| 752 |
+
cursor: not-allowed;
|
| 753 |
+
}
|
| 754 |
+
|
| 755 |
+
/* Hiệu ứng gõ chữ (Typing Animation) */
|
| 756 |
+
.typing {
|
| 757 |
+
display: flex;
|
| 758 |
+
align-items: center;
|
| 759 |
+
gap: 4px;
|
| 760 |
+
height: 20px;
|
| 761 |
+
padding: 12px 14px !important;
|
| 762 |
+
}
|
| 763 |
+
|
| 764 |
+
.typing span {
|
| 765 |
+
width: 6px;
|
| 766 |
+
height: 6px;
|
| 767 |
+
background-color: #94a3b8;
|
| 768 |
+
border-radius: 50%;
|
| 769 |
+
display: inline-block;
|
| 770 |
+
animation: typing-bounce 1.4s infinite ease-in-out both;
|
| 771 |
+
}
|
| 772 |
+
|
| 773 |
+
.typing span:nth-child(1) {
|
| 774 |
+
animation-delay: -0.32s;
|
| 775 |
+
}
|
| 776 |
+
|
| 777 |
+
.typing span:nth-child(2) {
|
| 778 |
+
animation-delay: -0.16s;
|
| 779 |
+
}
|
| 780 |
+
|
| 781 |
+
@keyframes typing-bounce {
|
| 782 |
+
0%, 80%, 100% {
|
| 783 |
+
transform: scale(0);
|
| 784 |
+
} 40% {
|
| 785 |
+
transform: scale(1.0);
|
| 786 |
+
}
|
| 787 |
+
}
|
| 788 |
+
|
| 789 |
+
@media (max-width: 800px) {
|
| 790 |
+
.chat-card,
|
| 791 |
+
.questionnaire-card {
|
| 792 |
+
height: calc(100vh - 48px);
|
| 793 |
+
border-radius: 16px;
|
| 794 |
+
}
|
| 795 |
+
|
| 796 |
+
.messenger-header {
|
| 797 |
+
padding: 12px 16px;
|
| 798 |
+
}
|
| 799 |
+
|
| 800 |
+
.progress-info {
|
| 801 |
+
min-width: auto;
|
| 802 |
+
}
|
| 803 |
+
|
| 804 |
+
.chat-thread {
|
| 805 |
+
padding: 16px;
|
| 806 |
+
}
|
| 807 |
+
|
| 808 |
+
.chat-message {
|
| 809 |
+
max-width: 85%;
|
| 810 |
+
}
|
| 811 |
+
|
| 812 |
+
.chat-input-area {
|
| 813 |
+
padding: 12px 16px;
|
| 814 |
+
}
|
| 815 |
+
|
| 816 |
+
.result-grid {
|
| 817 |
+
grid-template-columns: 1fr;
|
| 818 |
+
}
|
| 819 |
+
|
| 820 |
+
.top-row,
|
| 821 |
+
.nav-row,
|
| 822 |
+
.result-actions {
|
| 823 |
+
flex-direction: column;
|
| 824 |
+
align-items: stretch;
|
| 825 |
+
gap: 12px;
|
| 826 |
+
}
|
| 827 |
+
}
|
| 828 |
+
|
| 829 |
+
/* Modal Backdrop with Glassmorphism */
|
| 830 |
+
.modal-backdrop {
|
| 831 |
+
position: fixed;
|
| 832 |
+
top: 0;
|
| 833 |
+
left: 0;
|
| 834 |
+
width: 100vw;
|
| 835 |
+
height: 100vh;
|
| 836 |
+
background: rgba(15, 23, 42, 0.4);
|
| 837 |
+
backdrop-filter: blur(12px);
|
| 838 |
+
-webkit-backdrop-filter: blur(12px);
|
| 839 |
+
display: flex;
|
| 840 |
+
align-items: center;
|
| 841 |
+
justify-content: center;
|
| 842 |
+
z-index: 1000;
|
| 843 |
+
animation: fadeIn 0.25s ease-out forwards;
|
| 844 |
+
}
|
| 845 |
+
|
| 846 |
+
/* Modal Box */
|
| 847 |
+
.modal-container {
|
| 848 |
+
width: min(680px, 92%);
|
| 849 |
+
max-height: 85vh;
|
| 850 |
+
background: rgba(255, 255, 255, 0.95);
|
| 851 |
+
border: 1px solid rgba(91, 123, 208, 0.16);
|
| 852 |
+
border-radius: 24px;
|
| 853 |
+
box-shadow: 0 30px 100px rgba(15, 23, 42, 0.2);
|
| 854 |
+
display: flex;
|
| 855 |
+
flex-direction: column;
|
| 856 |
+
overflow: hidden;
|
| 857 |
+
animation: slideUp 0.3s cubic-bezier(0.16, 1, 0.3, 1) forwards;
|
| 858 |
+
}
|
| 859 |
+
|
| 860 |
+
/* Header */
|
| 861 |
+
.modal-header {
|
| 862 |
+
display: flex;
|
| 863 |
+
justify-content: space-between;
|
| 864 |
+
align-items: center;
|
| 865 |
+
padding: 20px 24px;
|
| 866 |
+
border-bottom: 1px solid rgba(91, 123, 208, 0.12);
|
| 867 |
+
background: rgba(255, 255, 255, 0.8);
|
| 868 |
+
}
|
| 869 |
+
|
| 870 |
+
.modal-header h2 {
|
| 871 |
+
margin: 0;
|
| 872 |
+
font-size: 1.25rem;
|
| 873 |
+
font-weight: 800;
|
| 874 |
+
color: #1e293b;
|
| 875 |
+
letter-spacing: -0.02em;
|
| 876 |
+
}
|
| 877 |
+
|
| 878 |
+
.modal-close-btn {
|
| 879 |
+
background: none;
|
| 880 |
+
border: none;
|
| 881 |
+
font-size: 1.5rem;
|
| 882 |
+
color: #64748b;
|
| 883 |
+
cursor: pointer;
|
| 884 |
+
padding: 4px 8px;
|
| 885 |
+
border-radius: 8px;
|
| 886 |
+
transition: all 0.2s ease;
|
| 887 |
+
line-height: 1;
|
| 888 |
+
}
|
| 889 |
+
|
| 890 |
+
.modal-close-btn:hover {
|
| 891 |
+
background: rgba(91, 123, 208, 0.08);
|
| 892 |
+
color: #0f172a;
|
| 893 |
+
}
|
| 894 |
+
|
| 895 |
+
/* Body Content */
|
| 896 |
+
.modal-body {
|
| 897 |
+
padding: 24px;
|
| 898 |
+
overflow-y: auto;
|
| 899 |
+
display: flex;
|
| 900 |
+
flex-direction: column;
|
| 901 |
+
gap: 20px;
|
| 902 |
+
}
|
| 903 |
+
|
| 904 |
+
/* Scrollbar styling for modal body */
|
| 905 |
+
.modal-body::-webkit-scrollbar {
|
| 906 |
+
width: 6px;
|
| 907 |
+
}
|
| 908 |
+
.modal-body::-webkit-scrollbar-track {
|
| 909 |
+
background: transparent;
|
| 910 |
+
}
|
| 911 |
+
.modal-body::-webkit-scrollbar-thumb {
|
| 912 |
+
background: rgba(148, 163, 184, 0.3);
|
| 913 |
+
border-radius: 99px;
|
| 914 |
+
}
|
| 915 |
+
|
| 916 |
+
.modal-intro {
|
| 917 |
+
margin: 0;
|
| 918 |
+
font-size: 0.95rem;
|
| 919 |
+
color: #475569;
|
| 920 |
+
line-height: 1.6;
|
| 921 |
+
}
|
| 922 |
+
|
| 923 |
+
/* Privacy sections */
|
| 924 |
+
.privacy-section {
|
| 925 |
+
background: rgba(91, 123, 208, 0.04);
|
| 926 |
+
border: 1px solid rgba(91, 123, 208, 0.08);
|
| 927 |
+
border-radius: 16px;
|
| 928 |
+
padding: 18px;
|
| 929 |
+
display: flex;
|
| 930 |
+
flex-direction: column;
|
| 931 |
+
gap: 12px;
|
| 932 |
+
transition: transform 0.2s ease;
|
| 933 |
+
}
|
| 934 |
+
|
| 935 |
+
.privacy-section:hover {
|
| 936 |
+
transform: translateY(-1px);
|
| 937 |
+
border-color: rgba(91, 123, 208, 0.14);
|
| 938 |
+
background: rgba(91, 123, 208, 0.06);
|
| 939 |
+
}
|
| 940 |
+
|
| 941 |
+
.privacy-section-title {
|
| 942 |
+
display: flex;
|
| 943 |
+
align-items: center;
|
| 944 |
+
gap: 10px;
|
| 945 |
+
font-weight: 700;
|
| 946 |
+
color: #0f172a;
|
| 947 |
+
font-size: 1rem;
|
| 948 |
+
}
|
| 949 |
+
|
| 950 |
+
.privacy-icon {
|
| 951 |
+
font-size: 1.2rem;
|
| 952 |
+
}
|
| 953 |
+
|
| 954 |
+
.privacy-list {
|
| 955 |
+
margin: 0;
|
| 956 |
+
padding-left: 20px;
|
| 957 |
+
display: flex;
|
| 958 |
+
flex-direction: column;
|
| 959 |
+
gap: 8px;
|
| 960 |
+
font-size: 0.9rem;
|
| 961 |
+
color: #334155;
|
| 962 |
+
line-height: 1.5;
|
| 963 |
+
}
|
| 964 |
+
|
| 965 |
+
.privacy-list li strong {
|
| 966 |
+
color: #0f172a;
|
| 967 |
+
}
|
| 968 |
+
|
| 969 |
+
/* Checkbox Opt-in */
|
| 970 |
+
.consent-checkbox-wrapper {
|
| 971 |
+
display: flex;
|
| 972 |
+
align-items: flex-start;
|
| 973 |
+
gap: 12px;
|
| 974 |
+
padding: 14px 16px;
|
| 975 |
+
background: rgba(34, 197, 94, 0.05);
|
| 976 |
+
border: 1px solid rgba(34, 197, 94, 0.12);
|
| 977 |
+
border-radius: 14px;
|
| 978 |
+
cursor: pointer;
|
| 979 |
+
transition: all 0.2s ease;
|
| 980 |
+
user-select: none;
|
| 981 |
+
}
|
| 982 |
+
|
| 983 |
+
.consent-checkbox-wrapper:hover {
|
| 984 |
+
background: rgba(34, 197, 94, 0.08);
|
| 985 |
+
border-color: rgba(34, 197, 94, 0.2);
|
| 986 |
+
}
|
| 987 |
+
|
| 988 |
+
.consent-checkbox-wrapper input[type="checkbox"] {
|
| 989 |
+
width: 18px;
|
| 990 |
+
height: 18px;
|
| 991 |
+
margin: 2px 0 0 0;
|
| 992 |
+
cursor: pointer;
|
| 993 |
+
accent-color: #22c55e;
|
| 994 |
+
}
|
| 995 |
+
|
| 996 |
+
.consent-checkbox-wrapper span {
|
| 997 |
+
font-size: 0.9rem;
|
| 998 |
+
color: #1e293b;
|
| 999 |
+
line-height: 1.4;
|
| 1000 |
+
font-weight: 500;
|
| 1001 |
+
}
|
| 1002 |
+
|
| 1003 |
+
/* Footer Actions */
|
| 1004 |
+
.modal-actions {
|
| 1005 |
+
display: flex;
|
| 1006 |
+
justify-content: flex-end;
|
| 1007 |
+
gap: 12px;
|
| 1008 |
+
padding: 16px 24px 20px;
|
| 1009 |
+
border-top: 1px solid rgba(91, 123, 208, 0.12);
|
| 1010 |
+
background: rgba(255, 255, 255, 0.8);
|
| 1011 |
+
}
|
| 1012 |
+
|
| 1013 |
+
/* Keyframes for animations */
|
| 1014 |
+
@keyframes fadeIn {
|
| 1015 |
+
from { opacity: 0; }
|
| 1016 |
+
to { opacity: 1; }
|
| 1017 |
+
}
|
| 1018 |
+
|
| 1019 |
+
@keyframes slideUp {
|
| 1020 |
+
from {
|
| 1021 |
+
opacity: 0;
|
| 1022 |
+
transform: translateY(24px) scale(0.98);
|
| 1023 |
+
}
|
| 1024 |
+
to {
|
| 1025 |
+
opacity: 1;
|
| 1026 |
+
transform: translateY(0) scale(1);
|
| 1027 |
+
}
|
| 1028 |
+
}
|
| 1029 |
+
|
| 1030 |
+
/* Mobile responsive fixes */
|
| 1031 |
+
@media (max-width: 600px) {
|
| 1032 |
+
.modal-container {
|
| 1033 |
+
width: 100%;
|
| 1034 |
+
height: 100vh;
|
| 1035 |
+
max-height: 100vh;
|
| 1036 |
+
border-radius: 0;
|
| 1037 |
+
}
|
| 1038 |
+
.modal-actions {
|
| 1039 |
+
padding: 16px;
|
| 1040 |
+
flex-direction: column-reverse;
|
| 1041 |
+
}
|
| 1042 |
+
.modal-actions button {
|
| 1043 |
+
width: 100%;
|
| 1044 |
+
}
|
| 1045 |
+
.modal-body {
|
| 1046 |
+
padding: 16px;
|
| 1047 |
+
}
|
| 1048 |
+
}
|
| 1049 |
+
|
| 1050 |
+
/* --- THEME TOGGLE BUTTON --- */
|
| 1051 |
+
.theme-toggle-btn {
|
| 1052 |
+
position: fixed;
|
| 1053 |
+
top: 20px;
|
| 1054 |
+
right: 20px;
|
| 1055 |
+
width: 44px;
|
| 1056 |
+
height: 44px;
|
| 1057 |
+
border-radius: 50%;
|
| 1058 |
+
border: 1px solid rgba(91, 123, 208, 0.16);
|
| 1059 |
+
background: rgba(255, 255, 255, 0.8);
|
| 1060 |
+
backdrop-filter: blur(8px);
|
| 1061 |
+
-webkit-backdrop-filter: blur(8px);
|
| 1062 |
+
color: #1e293b;
|
| 1063 |
+
display: grid;
|
| 1064 |
+
place-items: center;
|
| 1065 |
+
cursor: pointer;
|
| 1066 |
+
box-shadow: 0 4px 12px rgba(61, 89, 145, 0.1);
|
| 1067 |
+
z-index: 100;
|
| 1068 |
+
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
| 1069 |
+
}
|
| 1070 |
+
|
| 1071 |
+
.theme-toggle-btn:hover {
|
| 1072 |
+
transform: scale(1.05) rotate(15deg);
|
| 1073 |
+
background: rgba(255, 255, 255, 0.95);
|
| 1074 |
+
border-color: rgba(91, 123, 208, 0.3);
|
| 1075 |
+
box-shadow: 0 6px 16px rgba(61, 89, 145, 0.15);
|
| 1076 |
+
}
|
| 1077 |
+
|
| 1078 |
+
/* --- DARK MODE OVERRIDES --- */
|
| 1079 |
+
:root[data-theme="dark"] {
|
| 1080 |
+
color-scheme: dark;
|
| 1081 |
+
color: #f1f5f9;
|
| 1082 |
+
background:
|
| 1083 |
+
radial-gradient(circle at top, rgba(99, 102, 241, 0.15), transparent 45%),
|
| 1084 |
+
linear-gradient(180deg, #0b0f19 0%, #111827 100%);
|
| 1085 |
+
}
|
| 1086 |
+
|
| 1087 |
+
/* Dark mode theme toggle styling */
|
| 1088 |
+
:root[data-theme="dark"] .theme-toggle-btn {
|
| 1089 |
+
background: rgba(30, 41, 59, 0.8);
|
| 1090 |
+
border-color: rgba(99, 102, 241, 0.25);
|
| 1091 |
+
color: #f8fafc;
|
| 1092 |
+
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.35);
|
| 1093 |
+
}
|
| 1094 |
+
|
| 1095 |
+
:root[data-theme="dark"] .theme-toggle-btn:hover {
|
| 1096 |
+
background: rgba(30, 41, 59, 0.95);
|
| 1097 |
+
border-color: rgba(99, 102, 241, 0.45);
|
| 1098 |
+
box-shadow: 0 6px 16px rgba(0, 0, 0, 0.45);
|
| 1099 |
+
}
|
| 1100 |
+
|
| 1101 |
+
/* Cards & containers */
|
| 1102 |
+
:root[data-theme="dark"] .hero-card {
|
| 1103 |
+
border-color: rgba(99, 102, 241, 0.16);
|
| 1104 |
+
background: rgba(17, 24, 39, 0.85);
|
| 1105 |
+
box-shadow: 0 28px 80px rgba(0, 0, 0, 0.45);
|
| 1106 |
+
}
|
| 1107 |
+
|
| 1108 |
+
:root[data-theme="dark"] .landing-feature {
|
| 1109 |
+
background: rgba(99, 102, 241, 0.08);
|
| 1110 |
+
border-color: rgba(99, 102, 241, 0.15);
|
| 1111 |
+
}
|
| 1112 |
+
|
| 1113 |
+
:root[data-theme="dark"] .landing-feature strong {
|
| 1114 |
+
color: #f8fafc;
|
| 1115 |
+
}
|
| 1116 |
+
|
| 1117 |
+
:root[data-theme="dark"] .landing-feature p {
|
| 1118 |
+
color: #94a3b8;
|
| 1119 |
+
}
|
| 1120 |
+
|
| 1121 |
+
:root[data-theme="dark"] h1,
|
| 1122 |
+
:root[data-theme="dark"] h2 {
|
| 1123 |
+
color: #f8fafc;
|
| 1124 |
+
}
|
| 1125 |
+
|
| 1126 |
+
:root[data-theme="dark"] .lead,
|
| 1127 |
+
:root[data-theme="dark"] .progress-chip span,
|
| 1128 |
+
:root[data-theme="dark"] .panel-label,
|
| 1129 |
+
:root[data-theme="dark"] .footer-note,
|
| 1130 |
+
:root[data-theme="dark"] .score-meta,
|
| 1131 |
+
:root[data-theme="dark"] .score-label,
|
| 1132 |
+
:root[data-theme="dark"] .helper-text,
|
| 1133 |
+
:root[data-theme="dark"] .disclaimer {
|
| 1134 |
+
color: #94a3b8;
|
| 1135 |
+
}
|
| 1136 |
+
|
| 1137 |
+
:root[data-theme="dark"] .progress-chip {
|
| 1138 |
+
background: rgba(99, 102, 241, 0.1);
|
| 1139 |
+
border-color: rgba(99, 102, 241, 0.15);
|
| 1140 |
+
}
|
| 1141 |
+
|
| 1142 |
+
:root[data-theme="dark"] .progress-chip strong {
|
| 1143 |
+
color: #f8fafc;
|
| 1144 |
+
}
|
| 1145 |
+
|
| 1146 |
+
:root[data-theme="dark"] .progress-bar {
|
| 1147 |
+
background: rgba(99, 102, 241, 0.18);
|
| 1148 |
+
}
|
| 1149 |
+
|
| 1150 |
+
:root[data-theme="dark"] .question-card {
|
| 1151 |
+
background: linear-gradient(180deg, rgba(31, 41, 55, 0.9), rgba(17, 24, 39, 0.95));
|
| 1152 |
+
border-color: rgba(99, 102, 241, 0.18);
|
| 1153 |
+
}
|
| 1154 |
+
|
| 1155 |
+
/* Options */
|
| 1156 |
+
:root[data-theme="dark"] .option-button {
|
| 1157 |
+
border-color: rgba(99, 102, 241, 0.18);
|
| 1158 |
+
background: rgba(31, 41, 55, 0.5);
|
| 1159 |
+
color: #e2e8f0;
|
| 1160 |
+
}
|
| 1161 |
+
|
| 1162 |
+
:root[data-theme="dark"] .option-button:hover {
|
| 1163 |
+
border-color: rgba(129, 140, 248, 0.5);
|
| 1164 |
+
background: rgba(49, 46, 129, 0.3);
|
| 1165 |
+
}
|
| 1166 |
+
|
| 1167 |
+
:root[data-theme="dark"] .option-button.selected {
|
| 1168 |
+
border-color: rgba(99, 102, 241, 0.85);
|
| 1169 |
+
background: rgba(49, 46, 129, 0.55);
|
| 1170 |
+
}
|
| 1171 |
+
|
| 1172 |
+
:root[data-theme="dark"] .option-index {
|
| 1173 |
+
background: rgba(99, 102, 241, 0.2);
|
| 1174 |
+
color: #a5b4fc;
|
| 1175 |
+
}
|
| 1176 |
+
|
| 1177 |
+
:root[data-theme="dark"] .option-text {
|
| 1178 |
+
color: #f1f5f9;
|
| 1179 |
+
}
|
| 1180 |
+
|
| 1181 |
+
/* Buttons */
|
| 1182 |
+
:root[data-theme="dark"] .secondary-button {
|
| 1183 |
+
color: #e2e8f0;
|
| 1184 |
+
background: rgba(99, 102, 241, 0.15);
|
| 1185 |
+
border-color: rgba(99, 102, 241, 0.25);
|
| 1186 |
+
}
|
| 1187 |
+
|
| 1188 |
+
:root[data-theme="dark"] .ghost-button {
|
| 1189 |
+
color: #cbd5e1;
|
| 1190 |
+
border-color: rgba(99, 102, 241, 0.2);
|
| 1191 |
+
}
|
| 1192 |
+
|
| 1193 |
+
/* Results */
|
| 1194 |
+
:root[data-theme="dark"] .score-card {
|
| 1195 |
+
border-color: rgba(99, 102, 241, 0.18);
|
| 1196 |
+
background: rgba(31, 41, 55, 0.6);
|
| 1197 |
+
}
|
| 1198 |
+
|
| 1199 |
+
:root[data-theme="dark"] .score-card p {
|
| 1200 |
+
color: #94a3b8;
|
| 1201 |
+
}
|
| 1202 |
+
|
| 1203 |
+
:root[data-theme="dark"] .mini-bar {
|
| 1204 |
+
background: rgba(99, 102, 241, 0.15);
|
| 1205 |
+
}
|
| 1206 |
+
|
| 1207 |
+
:root[data-theme="dark"] .score-value {
|
| 1208 |
+
color: #f8fafc;
|
| 1209 |
+
}
|
| 1210 |
+
|
| 1211 |
+
:root[data-theme="dark"] .highlight-panel {
|
| 1212 |
+
background: rgba(99, 102, 241, 0.12);
|
| 1213 |
+
border-color: rgba(99, 102, 241, 0.2);
|
| 1214 |
+
}
|
| 1215 |
+
|
| 1216 |
+
/* Messenger components */
|
| 1217 |
+
:root[data-theme="dark"] .messenger-header {
|
| 1218 |
+
background: rgba(17, 24, 39, 0.95);
|
| 1219 |
+
border-bottom-color: rgba(99, 102, 241, 0.18);
|
| 1220 |
+
}
|
| 1221 |
+
|
| 1222 |
+
:root[data-theme="dark"] .messenger-info h2 {
|
| 1223 |
+
color: #f8fafc;
|
| 1224 |
+
}
|
| 1225 |
+
|
| 1226 |
+
:root[data-theme="dark"] .messenger-status {
|
| 1227 |
+
color: #94a3b8;
|
| 1228 |
+
}
|
| 1229 |
+
|
| 1230 |
+
:root[data-theme="dark"] .progress-info span {
|
| 1231 |
+
color: #cbd5e1;
|
| 1232 |
+
}
|
| 1233 |
+
|
| 1234 |
+
:root[data-theme="dark"] .progress-bar-mini {
|
| 1235 |
+
background: rgba(99, 102, 241, 0.18);
|
| 1236 |
+
}
|
| 1237 |
+
|
| 1238 |
+
:root[data-theme="dark"] .action-icon-btn {
|
| 1239 |
+
background: rgba(99, 102, 241, 0.12);
|
| 1240 |
+
border-color: rgba(99, 102, 241, 0.2);
|
| 1241 |
+
color: #cbd5e1;
|
| 1242 |
+
}
|
| 1243 |
+
|
| 1244 |
+
:root[data-theme="dark"] .action-icon-btn:hover {
|
| 1245 |
+
background: rgba(99, 102, 241, 0.2);
|
| 1246 |
+
}
|
| 1247 |
+
|
| 1248 |
+
:root[data-theme="dark"] .action-icon-btn.secondary {
|
| 1249 |
+
color: #94a3b8;
|
| 1250 |
+
}
|
| 1251 |
+
|
| 1252 |
+
:root[data-theme="dark"] .action-icon-btn.secondary:hover {
|
| 1253 |
+
background: rgba(255, 255, 255, 0.05);
|
| 1254 |
+
}
|
| 1255 |
+
|
| 1256 |
+
:root[data-theme="dark"] .chat-thread {
|
| 1257 |
+
background: #0f172a;
|
| 1258 |
+
}
|
| 1259 |
+
|
| 1260 |
+
/* Chat bubble styling */
|
| 1261 |
+
:root[data-theme="dark"] .chat-message.assistant .chat-bubble {
|
| 1262 |
+
background: #1e293b;
|
| 1263 |
+
color: #f1f5f9;
|
| 1264 |
+
border-color: #334155;
|
| 1265 |
+
}
|
| 1266 |
+
|
| 1267 |
+
:root[data-theme="dark"] .chat-input-area {
|
| 1268 |
+
background: #111827;
|
| 1269 |
+
border-top-color: rgba(99, 102, 241, 0.18);
|
| 1270 |
+
}
|
| 1271 |
+
|
| 1272 |
+
:root[data-theme="dark"] .chat-input-area input {
|
| 1273 |
+
border-color: #334155;
|
| 1274 |
+
background: #1f2937;
|
| 1275 |
+
color: #f1f5f9;
|
| 1276 |
+
}
|
| 1277 |
+
|
| 1278 |
+
:root[data-theme="dark"] .chat-input-area input:focus {
|
| 1279 |
+
border-color: #6366f1;
|
| 1280 |
+
background: #1f2937;
|
| 1281 |
+
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.25);
|
| 1282 |
+
}
|
| 1283 |
+
|
| 1284 |
+
:root[data-theme="dark"] .chat-input-area input::placeholder {
|
| 1285 |
+
color: #6b7280;
|
| 1286 |
+
}
|
| 1287 |
+
|
| 1288 |
+
:root[data-theme="dark"] .cancel-pill-btn {
|
| 1289 |
+
color: #94a3b8;
|
| 1290 |
+
}
|
| 1291 |
+
|
| 1292 |
+
:root[data-theme="dark"] .cancel-pill-btn:hover {
|
| 1293 |
+
color: #f1f5f9;
|
| 1294 |
+
}
|
| 1295 |
+
|
| 1296 |
+
:root[data-theme="dark"] .quick-replies {
|
| 1297 |
+
background: #0f172a;
|
| 1298 |
+
border-top-color: rgba(99, 102, 241, 0.12);
|
| 1299 |
+
}
|
| 1300 |
+
|
| 1301 |
+
:root[data-theme="dark"] .quick-reply-btn {
|
| 1302 |
+
border-color: #6366f1;
|
| 1303 |
+
background: #1e293b;
|
| 1304 |
+
color: #a5b4fc;
|
| 1305 |
+
}
|
| 1306 |
+
|
| 1307 |
+
:root[data-theme="dark"] .quick-reply-btn:hover:not(:disabled) {
|
| 1308 |
+
background: #6366f1;
|
| 1309 |
+
color: #ffffff;
|
| 1310 |
+
}
|
| 1311 |
+
|
| 1312 |
+
:root[data-theme="dark"] .quick-reply-btn:disabled {
|
| 1313 |
+
border-color: #4b5563;
|
| 1314 |
+
color: #6b7280;
|
| 1315 |
+
}
|
| 1316 |
+
|
| 1317 |
+
/* Modal */
|
| 1318 |
+
:root[data-theme="dark"] .modal-backdrop {
|
| 1319 |
+
background: rgba(0, 0, 0, 0.6);
|
| 1320 |
+
}
|
| 1321 |
+
|
| 1322 |
+
:root[data-theme="dark"] .modal-container {
|
| 1323 |
+
background: rgba(17, 24, 39, 0.98);
|
| 1324 |
+
border-color: rgba(99, 102, 241, 0.22);
|
| 1325 |
+
box-shadow: 0 30px 100px rgba(0, 0, 0, 0.6);
|
| 1326 |
+
}
|
| 1327 |
+
|
| 1328 |
+
:root[data-theme="dark"] .modal-header {
|
| 1329 |
+
border-bottom-color: rgba(99, 102, 241, 0.18);
|
| 1330 |
+
background: rgba(17, 24, 39, 0.9);
|
| 1331 |
+
}
|
| 1332 |
+
|
| 1333 |
+
:root[data-theme="dark"] .modal-header h2 {
|
| 1334 |
+
color: #f8fafc;
|
| 1335 |
+
}
|
| 1336 |
+
|
| 1337 |
+
:root[data-theme="dark"] .modal-close-btn {
|
| 1338 |
+
color: #94a3b8;
|
| 1339 |
+
}
|
| 1340 |
+
|
| 1341 |
+
:root[data-theme="dark"] .modal-close-btn:hover {
|
| 1342 |
+
background: rgba(99, 102, 241, 0.15);
|
| 1343 |
+
color: #f1f5f9;
|
| 1344 |
+
}
|
| 1345 |
+
|
| 1346 |
+
:root[data-theme="dark"] .modal-intro {
|
| 1347 |
+
color: #cbd5e1;
|
| 1348 |
+
}
|
| 1349 |
+
|
| 1350 |
+
:root[data-theme="dark"] .privacy-section {
|
| 1351 |
+
background: rgba(99, 102, 241, 0.05);
|
| 1352 |
+
border-color: rgba(99, 102, 241, 0.12);
|
| 1353 |
+
}
|
| 1354 |
+
|
| 1355 |
+
:root[data-theme="dark"] .privacy-section:hover {
|
| 1356 |
+
border-color: rgba(99, 102, 241, 0.2);
|
| 1357 |
+
background: rgba(99, 102, 241, 0.08);
|
| 1358 |
+
}
|
| 1359 |
+
|
| 1360 |
+
:root[data-theme="dark"] .privacy-section-title {
|
| 1361 |
+
color: #f8fafc;
|
| 1362 |
+
}
|
| 1363 |
+
|
| 1364 |
+
:root[data-theme="dark"] .privacy-list {
|
| 1365 |
+
color: #cbd5e1;
|
| 1366 |
+
}
|
| 1367 |
+
|
| 1368 |
+
:root[data-theme="dark"] .privacy-list li strong {
|
| 1369 |
+
color: #f8fafc;
|
| 1370 |
+
}
|
| 1371 |
+
|
| 1372 |
+
:root[data-theme="dark"] .consent-checkbox-wrapper {
|
| 1373 |
+
background: rgba(34, 197, 94, 0.08);
|
| 1374 |
+
border-color: rgba(34, 197, 94, 0.2);
|
| 1375 |
+
}
|
| 1376 |
+
|
| 1377 |
+
:root[data-theme="dark"] .consent-checkbox-wrapper:hover {
|
| 1378 |
+
background: rgba(34, 197, 94, 0.12);
|
| 1379 |
+
border-color: rgba(34, 197, 94, 0.3);
|
| 1380 |
+
}
|
| 1381 |
+
|
| 1382 |
+
:root[data-theme="dark"] .consent-checkbox-wrapper span {
|
| 1383 |
+
color: #cbd5e1;
|
| 1384 |
+
}
|
| 1385 |
+
|
| 1386 |
+
:root[data-theme="dark"] .modal-actions {
|
| 1387 |
+
border-top-color: rgba(99, 102, 241, 0.18);
|
| 1388 |
+
background: rgba(17, 24, 39, 0.9);
|
| 1389 |
+
}
|
| 1390 |
+
|
| 1391 |
+
/* Warnings */
|
| 1392 |
+
:root[data-theme="dark"] .warning-banner {
|
| 1393 |
+
background: rgba(245, 158, 11, 0.15);
|
| 1394 |
+
border-color: rgba(245, 158, 11, 0.3);
|
| 1395 |
+
color: #fef08a;
|
| 1396 |
+
}
|
| 1397 |
+
|
| 1398 |
+
:root[data-theme="dark"] .risk-high {
|
| 1399 |
+
background: rgba(239, 68, 68, 0.15);
|
| 1400 |
+
border-color: rgba(239, 68, 68, 0.3);
|
| 1401 |
+
color: #fca5a5;
|
| 1402 |
+
}
|
| 1403 |
+
|
| 1404 |
+
:root[data-theme="dark"] .risk-moderate {
|
| 1405 |
+
background: rgba(245, 158, 11, 0.15);
|
| 1406 |
+
border-color: rgba(245, 158, 11, 0.3);
|
| 1407 |
+
color: #fde047;
|
| 1408 |
+
}
|
| 1409 |
+
|
| 1410 |
+
/* Mobile responsive adjustments */
|
| 1411 |
+
@media (max-width: 800px) {
|
| 1412 |
+
.theme-toggle-btn {
|
| 1413 |
+
top: 12px;
|
| 1414 |
+
right: 12px;
|
| 1415 |
+
width: 38px;
|
| 1416 |
+
height: 38px;
|
| 1417 |
+
}
|
| 1418 |
+
}
|
| 1419 |
+
|
tsconfig.json
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"compilerOptions": {
|
| 3 |
+
"target": "ES2022",
|
| 4 |
+
"useDefineForClassFields": true,
|
| 5 |
+
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
| 6 |
+
"allowJs": false,
|
| 7 |
+
"skipLibCheck": true,
|
| 8 |
+
"esModuleInterop": true,
|
| 9 |
+
"allowSyntheticDefaultImports": true,
|
| 10 |
+
"strict": true,
|
| 11 |
+
"forceConsistentCasingInFileNames": true,
|
| 12 |
+
"module": "ESNext",
|
| 13 |
+
"moduleResolution": "Bundler",
|
| 14 |
+
"resolveJsonModule": true,
|
| 15 |
+
"isolatedModules": true,
|
| 16 |
+
"noEmit": true,
|
| 17 |
+
"jsx": "react-jsx"
|
| 18 |
+
},
|
| 19 |
+
"include": ["src"],
|
| 20 |
+
"references": [{ "path": "./tsconfig.node.json" }]
|
| 21 |
+
}
|
tsconfig.node.json
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"compilerOptions": {
|
| 3 |
+
"composite": true,
|
| 4 |
+
"lib": ["ES2022"],
|
| 5 |
+
"skipLibCheck": true,
|
| 6 |
+
"module": "ESNext",
|
| 7 |
+
"moduleResolution": "Bundler",
|
| 8 |
+
"allowSyntheticDefaultImports": true
|
| 9 |
+
},
|
| 10 |
+
"include": ["vite.config.ts"]
|
| 11 |
+
}
|
vite.config.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { defineConfig } from 'vite';
|
| 2 |
+
import react from '@vitejs/plugin-react';
|
| 3 |
+
|
| 4 |
+
export default defineConfig({
|
| 5 |
+
plugins: [react()],
|
| 6 |
+
server: {
|
| 7 |
+
proxy: {
|
| 8 |
+
'/api': 'http://127.0.0.1:3001',
|
| 9 |
+
},
|
| 10 |
+
},
|
| 11 |
+
});
|