dassbot / src /App.tsx
brianhuster's picture
Deploy DASS-21 app to Hugging Face Space
a3ee1b6
Raw
History Blame Contribute Delete
26.3 kB
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<Stage>('home');
const [answers, setAnswers] = useState<(AnswerValue | null)[]>(initialAnswers);
const [currentIndex, setCurrentIndex] = useState(0);
const [messages, setMessages] = useState<Message[]>([]);
const [input, setInput] = useState('');
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(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 = () => (
<button
className="theme-toggle-btn"
onClick={() => setTheme((prev) => (prev === 'light' ? 'dark' : 'light'))}
type="button"
aria-label="Toggle theme"
>
{theme === 'light' ? (
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
</svg>
) : (
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="5" />
<line x1="12" y1="1" x2="12" y2="3" />
<line x1="12" y1="21" x2="12" y2="23" />
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64" />
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78" />
<line x1="1" y1="12" x2="3" y2="12" />
<line x1="21" y1="12" x2="23" y2="12" />
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36" />
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22" />
</svg>
)}
</button>
);
const handleDeclineConsent = () => {
setShowPrivacyModal(false);
setConsentChecked(false);
};
const handleAcceptConsent = () => {
if (consentChecked) {
setShowPrivacyModal(false);
startQuestionnaire();
}
};
const chatThreadRef = useRef<HTMLDivElement>(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<HTMLFormElement>) => {
event.preventDefault();
const text = input.trim();
if (!text) return;
await submitQuestionnaireAnswer(text);
};
const handleAdviceSend = async (event: FormEvent<HTMLFormElement>) => {
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 (
<main className="app-shell">
{renderThemeToggle()}
<section className="hero-card results-card">
<div className="badge">Kết quả DASS-21</div>
<h1>Đây là bản tóm tắt sàng lọc của bạn</h1>
<p className="lead">
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.
</p>
<div className="result-grid">
{resultCards.map(({ scale, score, severity, percent }) => (
<article key={scale} className={`score-card tone-${severity.tone}`}>
<div className="score-label">{scaleLabels[scale]}</div>
<div className="score-value">{score}</div>
<div className="score-meta">{severity.label}</div>
<div className="mini-bar" aria-hidden="true">
<span style={{ width: `${percent}%` }} />
</div>
<p>{scaleDescriptions[scale]}</p>
</article>
))}
</div>
<div className="result-dashboard">
<div className="highlight-panel">
<div>
<span className="panel-label">Mức nổi bật nhất</span>
<strong>
{scaleLabels[topConcern.scale]} · {topConcern.severity.label}
</strong>
</div>
<p>
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.
</p>
</div>
{(topConcern.severity.tone === 'severe' || topConcern.severity.tone === 'extreme') && (
<div className="warning-banner">
Đâ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.
</div>
)}
</div>
<div className="result-actions">
<button className="secondary-button" onClick={restart} type="button">
Làm lại
</button>
<button
className="primary-button"
onClick={() => {
setMessages([]);
setStage('chat');
}}
type="button"
>
Nhận tư vấn từ Gemini
</button>
</div>
<div className="disclaimer">
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.
</div>
</section>
</main>
);
}
if (stage === 'chat') {
return (
<main className="app-shell">
{renderThemeToggle()}
<section className="hero-card chat-card">
<div className="messenger-header">
<div className="messenger-info">
<div className="messenger-avatar bg-gemini">G</div>
<div>
<h2>Trợ lý Gemini</h2>
<p className="messenger-status"><span className="status-dot"></span>Đang hoạt động</p>
</div>
</div>
<div className="messenger-actions">
<button className="action-icon-btn" type="button" onClick={() => setStage('results')}>
Xem kết quả DASS-21
</button>
<button className="action-icon-btn secondary" type="button" onClick={restart}>
Trang chủ
</button>
</div>
</div>
{adviceRiskLevel !== 'low' && (
<div className={`warning-banner risk-${adviceRiskLevel}`} style={{ margin: '12px 16px 0' }}>
{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ú ý.'}
</div>
)}
{error && <div className="warning-banner" style={{ margin: '12px 16px 0' }}>{error}</div>}
<div className="chat-thread" ref={chatThreadRef} aria-live="polite">
{messages.map((message) => (
<div key={message.id} className={`chat-message ${message.role}`}>
{message.role === 'assistant' && (
<div className="bubble-avatar bg-gemini">G</div>
)}
<div className="chat-bubble">
{message.content}
</div>
</div>
))}
{loading && (
<div className="chat-message assistant">
<div className="bubble-avatar bg-gemini">G</div>
<div className="chat-bubble typing">
<span></span><span></span><span></span>
</div>
</div>
)}
</div>
<form className="chat-input-area" onSubmit={handleAdviceSend}>
<input
type="text"
value={input}
onChange={(event) => setInput(event.target.value)}
placeholder="Nhập điều bạn muốn chia sẻ..."
disabled={loading}
/>
<button className="send-icon-btn" type="submit" disabled={loading || !input.trim()}>
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor">
<path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z" />
</svg>
</button>
</form>
</section>
</main>
);
}
if (stage === 'questionnaire') {
return (
<main className="app-shell">
{renderThemeToggle()}
<section className="hero-card questionnaire-card">
<div className="messenger-header">
<div className="messenger-info">
<div className="messenger-avatar bg-dass">D</div>
<div>
<h2>Khảo sát DASS-21</h2>
<p className="messenger-status"><span className="status-dot"></span>Đang đánh giá</p>
</div>
</div>
<div className="progress-info">
<span>Câu hỏi {currentIndex + 1}/{questions.length}</span>
<div className="progress-bar-mini">
<div className="progress-fill-mini" style={{ width: `${progress}%` }} />
</div>
</div>
</div>
{error && <div className="warning-banner" style={{ margin: '12px 16px 0' }}>{error}</div>}
<div className="chat-thread" ref={chatThreadRef} aria-live="polite">
{messages.map((message) => (
<div key={message.id} className={`chat-message ${message.role}`}>
{message.role === 'assistant' && (
<div className="bubble-avatar bg-dass">D</div>
)}
<div className="chat-bubble">
{message.content}
</div>
</div>
))}
{loading && (
<div className="chat-message assistant">
<div className="bubble-avatar bg-dass">D</div>
<div className="chat-bubble typing">
<span></span><span></span><span></span>
</div>
</div>
)}
</div>
<div className="quick-replies">
{answerOptions.map((optionText, idx) => (
<button
key={idx}
type="button"
className="quick-reply-btn"
onClick={() => void submitQuestionnaireAnswer(optionText)}
disabled={loading}
>
{idx}. {optionText}
</button>
))}
</div>
<form className="chat-input-area" onSubmit={handleQuestionnaireSend}>
<input
type="text"
value={input}
onChange={(event) => setInput(event.target.value)}
placeholder="Trả lời tự nhiên hoặc chọn nhanh nút phía trên..."
disabled={loading}
/>
<button className="send-icon-btn" type="submit" disabled={loading || !input.trim()}>
<svg viewBox="0 0 24 24" width="20" height="20" fill="currentColor">
<path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z" />
</svg>
</button>
<button className="cancel-pill-btn" type="button" onClick={restart}>
Hủy
</button>
</form>
</section>
</main>
);
}
return (
<>
<main className="app-shell">
{renderThemeToggle()}
<section className="hero-card landing-card">
<div className="badge">DASS-21 + BERT + Gemini</div>
<h1>Chatbot sàng lọc sức khỏe tinh thần bằng ngôn ngữ tự nhiên</h1>
<p className="lead">
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.
</p>
<div className="landing-grid">
<article className="landing-feature">
<strong>Trả lời bằng câu tự nhiên</strong>
<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>
</article>
<article className="landing-feature">
<strong>BERT chấm điểm tự động</strong>
<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>
</article>
<article className="landing-feature">
<strong>Gemini phản hồi thấu cảm</strong>
<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>
</article>
</div>
<div className="landing-actions">
<button className="primary-button" onClick={() => setShowPrivacyModal(true)} type="button">
Bắt đầu sàng lọc
</button>
<button className="secondary-button" onClick={() => setStage('results')} type="button">
Xem mẫu kết quả
</button>
</div>
<div className="disclaimer">
Đây là công cụ hỗ trợ sàng lọc, không thay thế cho chẩn đoán chuyên môn.
</div>
</section>
</main>
{showPrivacyModal && (
<div className="modal-backdrop" onClick={handleDeclineConsent} aria-modal="true" role="dialog">
<div className="modal-container" onClick={(e) => e.stopPropagation()}>
<header className="modal-header">
<h2>ĐỒNG THUẬN TỰ NGUYỆN & QUYỀN RIÊNG TƯ</h2>
<button className="modal-close-btn" onClick={handleDeclineConsent} aria-label="Close modal">
&times;
</button>
</header>
<div className="modal-body">
<p className="modal-intro">
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:
</p>
<section className="privacy-section">
<div className="privacy-section-title">
<span className="privacy-icon" aria-hidden="true">🛡️</span>
<span>1. Quyền riêng tư & Bảo mật (Stateless Architecture)</span>
</div>
<ul className="privacy-list">
<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>
<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>
<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>
<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>
</ul>
</section>
<section className="privacy-section">
<div className="privacy-section-title">
<span className="privacy-icon" aria-hidden="true">🤖</span>
<span>2. Minh bạch thuật toán & Trách nhiệm (Explainability)</span>
</div>
<ul className="privacy-list">
<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>
<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>
<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>
</ul>
</section>
<label className="consent-checkbox-wrapper">
<input
type="checkbox"
checked={consentChecked}
onChange={(e) => setConsentChecked(e.target.checked)}
/>
<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>
</label>
</div>
<footer className="modal-actions">
<button className="secondary-button" onClick={handleDeclineConsent} type="button">
Từ chối
</button>
<button
className="primary-button"
onClick={handleAcceptConsent}
disabled={!consentChecked}
type="button"
>
Đồng ý và Bắt đầu
</button>
</footer>
</div>
</div>
)}
</>
);
}
export default App;