import { useEffect, useMemo, useState, useRef, type FormEvent } from 'react'; import { getSeverity, questions, scaleDescriptions, scaleLabels, scoreScale, answerOptions, type AnswerValue, type Scale, } from './dass'; type Stage = 'home' | 'questionnaire' | 'results' | 'chat'; type Message = { id: string; role: 'assistant' | 'user'; content: string; }; type AnalyzeResponse = { label: number; score: AnswerValue | null; confidence: number; needsClarification: boolean; reply: string; }; type AdviceResponse = { reply: string; riskLevel: 'low' | 'moderate' | 'high'; }; const initialAnswers: (AnswerValue | null)[] = Array.from({ length: questions.length }, () => null); const maxScaleScore = 42; function App() { const [stage, setStage] = useState('home'); const [answers, setAnswers] = useState<(AnswerValue | null)[]>(initialAnswers); const [currentIndex, setCurrentIndex] = useState(0); const [messages, setMessages] = useState([]); const [input, setInput] = useState(''); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [adviceRiskLevel, setAdviceRiskLevel] = useState<'low' | 'moderate' | 'high'>('low'); const [showPrivacyModal, setShowPrivacyModal] = useState(false); const [consentChecked, setConsentChecked] = useState(false); const [theme, setTheme] = useState<'light' | 'dark'>(() => { const saved = localStorage.getItem('theme'); if (saved === 'light' || saved === 'dark') return saved; return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; }); useEffect(() => { document.documentElement.setAttribute('data-theme', theme); localStorage.setItem('theme', theme); }, [theme]); const renderThemeToggle = () => ( ); const handleDeclineConsent = () => { setShowPrivacyModal(false); setConsentChecked(false); }; const handleAcceptConsent = () => { if (consentChecked) { setShowPrivacyModal(false); startQuestionnaire(); } }; const chatThreadRef = useRef(null); useEffect(() => { if (chatThreadRef.current) { chatThreadRef.current.scrollTop = chatThreadRef.current.scrollHeight; } }, [messages, loading]); const currentQuestion = questions[currentIndex]; const selectedAnswer = answers[currentIndex]; const answeredCount = answers.filter((answer) => answer !== null).length; const progress = Math.round((answeredCount / questions.length) * 100); const scores = useMemo(() => scoreScale(answers), [answers]); const orderedScales: Scale[] = ['stress', 'anxiety', 'depression']; const resultCards = orderedScales.map((scale) => { const score = scores[scale]; const severity = getSeverity(scale, score); return { scale, score, severity, percent: Math.min(100, Math.round((score / maxScaleScore) * 100)), }; }); const topConcern = [...resultCards].sort((a, b) => b.score - a.score)[0]; const assessmentSummary = useMemo( () => ({ stress: scores.stress, anxiety: scores.anxiety, depression: scores.depression, topConcernLabel: `${scaleLabels[topConcern.scale]} · ${topConcern.severity.label}`, }), [scores, topConcern], ); const isCompleted = answers.every((answer) => answer !== null); const restart = () => { setStage('home'); setAnswers(initialAnswers); setCurrentIndex(0); setMessages([]); setInput(''); setLoading(false); setError(null); setAdviceRiskLevel('low'); setConsentChecked(false); setShowPrivacyModal(false); }; const startQuestionnaire = () => { setStage('questionnaire'); setAnswers(initialAnswers); setCurrentIndex(0); setMessages([ { id: `assistant-${Date.now()}`, role: 'assistant', content: '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é.', }, { id: `assistant-q-${Date.now()}`, role: 'assistant', 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?`, }, ]); setInput(''); setError(null); setLoading(false); }; const callAnalyze = async (question: string, answer: string) => { const response = await fetch('/api/dass/analyze', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ question, answer }), }); const payload = (await response.json().catch(() => null)) as AnalyzeResponse | null; if (!response.ok) { throw new Error(payload?.reply || 'Không thể chấm điểm câu trả lời lúc này.'); } return payload; }; const callAdvice = async (messagesToSend: Message[]) => { const response = await fetch('/api/chat', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ assessment: assessmentSummary, messages: messagesToSend, }), }); const payload = (await response.json().catch(() => null)) as AdviceResponse | null; if (!response.ok) { throw new Error(payload?.reply || 'Không thể kết nối phần tư vấn.'); } return payload; }; const bootstrapAdvice = async () => { setLoading(true); setError(null); try { const payload = await callAdvice([]); if (!payload) { throw new Error('Không thể khởi tạo phần tư vấn.'); } setMessages([ { id: `assistant-${Date.now()}`, role: 'assistant', content: payload.reply || '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.', }, ]); setAdviceRiskLevel(payload.riskLevel || 'low'); } catch (err) { setError(err instanceof Error ? err.message : 'Không thể khởi tạo phần tư vấn.'); setMessages([ { id: `assistant-${Date.now()}`, role: 'assistant', content: '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.', }, ]); } finally { setLoading(false); } }; const submitQuestionnaireAnswer = async (text: string) => { if (loading) return; const questionIndex = currentIndex; const question = questions[questionIndex]; const userMessage: Message = { id: `user-${Date.now()}`, role: 'user', content: text, }; setMessages((prev) => [...prev, userMessage]); setInput(''); setLoading(true); setError(null); try { const payload = await callAnalyze(question.text, text); if (!payload) { throw new Error('Không thể chấm điểm câu trả lời lúc này.'); } setMessages((prev) => [ ...prev, { id: `assistant-${Date.now()}`, role: 'assistant', content: payload.reply, }, ]); if (payload.needsClarification) { return; } setAnswers((prev) => { const next = [...prev]; next[questionIndex] = payload.score ?? 0; return next; }); const nextIndex = questionIndex + 1; if (nextIndex >= questions.length) { setStage('results'); return; } setCurrentIndex(nextIndex); setMessages((prev) => [ ...prev, { id: `assistant-q-${Date.now()}`, role: 'assistant', 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?`, }, ]); } catch (err) { setError(err instanceof Error ? err.message : 'Không thể xử lý câu trả lời.'); } finally { setLoading(false); } }; const handleQuestionnaireSend = async (event: FormEvent) => { event.preventDefault(); const text = input.trim(); if (!text) return; await submitQuestionnaireAnswer(text); }; const handleAdviceSend = async (event: FormEvent) => { event.preventDefault(); const text = input.trim(); if (!text || loading) return; const userMessage: Message = { id: `user-${Date.now()}`, role: 'user', content: text, }; const nextMessages = [...messages, userMessage]; setMessages(nextMessages); setInput(''); setLoading(true); setError(null); try { const payload = await callAdvice(nextMessages); if (!payload) { throw new Error('Không thể kết nối phần tư vấn.'); } setMessages((prev) => [ ...prev, { id: `assistant-${Date.now()}`, role: 'assistant', content: payload.reply, }, ]); setAdviceRiskLevel(payload.riskLevel || 'low'); } catch (err) { setError(err instanceof Error ? err.message : 'Chatbot đang tạm gián đoạn.'); setMessages((prev) => [ ...prev, { id: `assistant-${Date.now()}`, role: 'assistant', 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.', }, ]); } finally { setLoading(false); } }; useEffect(() => { if (stage === 'chat' && messages.length === 0 && !loading) { void bootstrapAdvice(); } }, [stage, messages.length, loading]); if (stage === 'results') { return (
{renderThemeToggle()}
Kết quả DASS-21

Đây là bản tóm tắt sàng lọc của bạn

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ỗ trợ từ người thân, giảng viên cố vấn hoặc chuyên gia.

{resultCards.map(({ scale, score, severity, percent }) => (
{scaleLabels[scale]}
{score}
{severity.label}

{scaleDescriptions[scale]}

))}
Mức nổi bật nhất {scaleLabels[topConcern.scale]} · {topConcern.severity.label}

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 với người phù hợp.

{(topConcern.severity.tone === 'severe' || topConcern.severity.tone === 'extreme') && (
Đâ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 lý sớm.
)}
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 theo.
); } if (stage === 'chat') { return (
{renderThemeToggle()}
G

Trợ lý Gemini

Đang hoạt động

{adviceRiskLevel !== 'low' && (
{adviceRiskLevel === 'high' ? '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.' : 'Chatbot đang theo dõi thêm vì có dấu hiệu cần chú ý.'}
)} {error &&
{error}
}
{messages.map((message) => (
{message.role === 'assistant' && (
G
)}
{message.content}
))} {loading && (
G
)}
setInput(event.target.value)} placeholder="Nhập điều bạn muốn chia sẻ..." disabled={loading} />
); } if (stage === 'questionnaire') { return (
{renderThemeToggle()}
D

Khảo sát DASS-21

Đang đánh giá

Câu hỏi {currentIndex + 1}/{questions.length}
{error &&
{error}
}
{messages.map((message) => (
{message.role === 'assistant' && (
D
)}
{message.content}
))} {loading && (
D
)}
{answerOptions.map((optionText, idx) => ( ))}
setInput(event.target.value)} placeholder="Trả lời tự nhiên hoặc chọn nhanh nút phía trên..." disabled={loading} />
); } return ( <>
{renderThemeToggle()}
DASS-21 + BERT + Gemini

Chatbot sàng lọc sức khỏe tinh thần bằng ngôn ngữ tự nhiên

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 Gemini phản hồi thấu cảm và tư vấn bước tiếp theo.

Trả lời bằng câu tự nhiên

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.

BERT chấm điểm tự động

Mỗi câu trả lời được ánh xạ sang 0, 1, 2 hoặc 3 để tính điểm DASS-21.

Gemini phản hồi thấu cảm

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ả.

Đây là công cụ hỗ trợ sàng lọc, không thay thế cho chẩn đoán chuyên môn.
{showPrivacyModal && (
e.stopPropagation()}>

ĐỒNG THUẬN TỰ NGUYỆN & QUYỀN RIÊNG TƯ

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:

1. Quyền riêng tư & Bảo mật (Stateless Architecture)
  • Ẩn danh tuyệt đối: 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.
  • Không lưu trữ ổ cứng (Stateless): 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.
  • Lưu trữ tạm thời trong RAM: 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.
  • Xóa sạch dấu vết: Ngay khi bạn đóng tab, làm mới trang (F5) hoặc nhấn nút "Làm lại", hàm restart() 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ị.
2. Minh bạch thuật toán & Trách nhiệm (Explainability)
  • Thuật toán chấm điểm: 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.
  • Tư vấn từ AI: 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.
  • Không thay thế chẩn đoán y khoa: Hệ thống chỉ có chức năng sàng lọc sơ bộ. Chatbot này không 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.
)} ); } export default App;