brianhuster commited on
Commit
a3ee1b6
·
0 Parent(s):

Deploy DASS-21 app to Hugging Face Space

Browse files
Files changed (16) hide show
  1. .dockerignore +13 -0
  2. .gitattributes +3 -0
  3. .gitignore +40 -0
  4. Dockerfile +43 -0
  5. README.md +18 -0
  6. app.py +308 -0
  7. index.html +16 -0
  8. package-lock.json +0 -0
  9. package.json +26 -0
  10. src/App.tsx +701 -0
  11. src/dass.ts +119 -0
  12. src/main.tsx +10 -0
  13. src/styles.css +1419 -0
  14. tsconfig.json +21 -0
  15. tsconfig.node.json +11 -0
  16. 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
+ &times;
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
+ });