Spaces:
Sleeping
Sleeping
"use client"; | |
import { useState, useRef, useEffect, FormEvent, FC } from 'react'; | |
import ReactMarkdown from 'react-markdown'; | |
import remarkGfm from 'remark-gfm'; | |
import clsx from 'clsx'; | |
// --- TYPE DEFINITIONS --- | |
interface Message { | |
role: 'user' | 'assistant'; | |
content: string; | |
} | |
// --- SVG ICONS --- | |
const VedaMDLogo: FC = () => ( | |
<div className="flex items-center gap-4"> | |
<div className="size-6"> | |
<svg viewBox="0 0 48 48" fill="none" xmlns="http://www.w3.org/2000/svg"> | |
<path d="M4 4H17.3334V17.3334H30.6666V30.6666H44V44H4V4Z" fill="currentColor"></path> | |
</svg> | |
</div> | |
<h2 className="text-xl font-bold leading-tight tracking-tight">VedaMD</h2> | |
</div> | |
); | |
const SettingsIcon: FC = () => ( | |
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" viewBox="0 0 256 256"> | |
<path d="M128,80a48,48,0,1,0,48,48A48.05,48.05,0,0,0,128,80Zm0,80a32,32,0,1,1,32-32A32,32,0,0,1,128,160Zm88-29.84q.06-2.16,0-4.32l14.92-18.64a8,8,0,0,0,1.48-7.06,107.21,107.21,0,0,0-10.88-26.25,8,8,0,0,0-6-3.93l-23.72-2.64q-1.48-1.56-3-3L186,40.54a8,8,0,0,0-3.94-6,107.71,107.71,0,0,0-26.25-10.87,8,8,0,0,0-7.06,1.49L130.16,40Q128,40,125.84,40L107.2,25.11a8,8,0,0,0-7.06-1.48A107.6,107.6,0,0,0,73.89,34.51a8,8,0,0,0-3.93,6L67.32,64.27q-1.56,1.49-3,3L40.54,70a8,8,0,0,0-6,3.94,107.71,107.71,0,0,0-10.87,26.25,8,8,0,0,0,1.49,7.06L40,125.84Q40,128,40,130.16L25.11,148.8a8,8,0,0,0-1.48,7.06,107.21,107.21,0,0,0,10.88,26.25,8,8,0,0,0,6,3.93l23.72,2.64q1.49,1.56,3,3L70,215.46a8,8,0,0,0,3.94,6,107.71,107.71,0,0,0,26.25,10.87,8,8,0,0,0,7.06-1.49L125.84,216q2.16.06,4.32,0l18.64,14.92a8,8,0,0,0,7.06,1.48,107.21,107.21,0,0,0,26.25-10.88,8,8,0,0,0,3.93-6l2.64-23.72q1.56-1.48,3-3L215.46,186a8,8,0,0,0,6-3.94,107.71,107.71,0,0,0,10.87-26.25,8,8,0,0,0-1.49-7.06Zm-16.1-6.5a73.93,73.93,0,0,1,0,8.68,8,8,0,0,0,1.74,5.48l14.19,17.73a91.57,91.57,0,0,1-6.23,15L187,173.11a8,8,0,0,0-5.1,2.64,74.11,74.11,0,0,1-6.14,6.14,8,8,0,0,0-2.64,5.1l-2.51,22.58a91.32,91.32,0,0,1-15,6.23l-17.74-14.19a8,8,0,0,0-5-1.75h-.48a73.93,73.93,0,0,1-8.68,0,8,8,0,0,0-5.48,1.74L100.45,215.8a91.57,91.57,0,0,1-15-6.23L82.89,187a8,8,0,0,0-2.64-5.1,74.11,74.11,0,0,1-6.14-6.14,8,8,0,0,0-5.1-2.64L46.43,170.6a91.32,91.32,0,0,1-6.23-15l14.19-17.74a8,8,0,0,0,1.74-5.48,73.93,73.93,0,0,1,0-8.68,8,8,0,0,0-1.74-5.48L40.2,100.45a91.57,91.57,0,0,1,6.23-15L69,82.89a8,8,0,0,0,5.1-2.64,74.11,74.11,0,0,1,6.14-6.14A8,8,0,0,0,82.89,69L85.4,46.43a91.32,91.32,0,0,1,15-6.23l17.74,14.19a8,8,0,0,0,5.48,1.74,73.93,73.93,0,0,1,8.68,0,8,8,0,0,0,5.48-1.74L155.55,40.2a91.57,91.57,0,0,1,15,6.23L173.11,69a8,8,0,0,0,2.64,5.1,74.11,74.11,0,0,1,6.14,6.14,8,8,0,0,0,5.1,2.64l22.58,2.51a91.32,91.32,0,0,1,6.23,15l-14.19,17.74A8,8,0,0,0,199.87,123.66Z"></path> | |
</svg> | |
); | |
const ArrowRightIcon: FC = () => ( | |
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" fill="currentColor" viewBox="0 0 256 256" className="text-white"> | |
<path d="M221.66,133.66l-72,72a8,8,0,0,1-11.32-11.32L196.69,136H40a8,8,0,0,1,0-16H196.69L138.34,61.66a8,8,0,0,1,11.32-11.32l72,72A8,8,0,0,1,221.66,133.66Z"></path> | |
</svg> | |
); | |
// --- UI COMPONENTS --- | |
const Header: FC = () => ( | |
<header className="sticky top-0 z-50 flex items-center justify-between border-b border-secondary/50 bg-white/80 px-6 py-4 backdrop-blur-sm"> | |
<VedaMDLogo /> | |
<button className="button-secondary"> | |
<SettingsIcon /> | |
</button> | |
</header> | |
); | |
const WelcomeScreen: FC<{ onTemplateClick: (query: string) => void }> = ({ onTemplateClick }) => { | |
const templates = [ | |
"What is the recommended antibiotic regimen for puerperal sepsis according to national guidelines?", | |
"What are the steps for active management of the third stage of labor (AMTSL)", | |
]; | |
return ( | |
<div className="flex flex-col items-center justify-center px-6 py-12 animate-fade-in"> | |
<div className="max-w-2xl text-center"> | |
<h1 className="text-4xl font-bold tracking-tight mb-4"> | |
Welcome to VedaMD | |
</h1> | |
<p className="text-xl text-text-secondary mb-8"> | |
Get trusted clinical answers based on Sri Lankan health guidelines | |
</p> | |
<div className="flex flex-col gap-4"> | |
{templates.map((query) => ( | |
<button | |
key={query} | |
onClick={() => onTemplateClick(query)} | |
className="button-secondary text-left" | |
> | |
{query} | |
</button> | |
))} | |
</div> | |
</div> | |
</div> | |
); | |
}; | |
const ChatMessage: FC<{ message: Message }> = ({ message }) => { | |
const isUser = message.role === 'user'; | |
return ( | |
<div className={clsx( | |
"py-6 px-4 animate-fade-in", | |
isUser ? 'bg-white' : 'bg-slate-50' | |
)}> | |
<div className="max-w-3xl mx-auto flex gap-6"> | |
<div className={clsx( | |
"size-10 rounded-full flex-shrink-0 flex items-center justify-center font-semibold text-white", | |
isUser ? 'bg-text-primary' : 'bg-primary' | |
)}> | |
{isUser ? 'U' : 'V'} | |
</div> | |
<div className="flex-grow space-y-4"> | |
<div className={clsx( | |
'chat-bubble', | |
isUser ? 'chat-bubble-user' : 'chat-bubble-assistant' | |
)}> | |
<div className="prose prose-lg max-w-none"> | |
<ReactMarkdown remarkPlugins={[remarkGfm]}> | |
{message.content} | |
</ReactMarkdown> | |
</div> | |
</div> | |
</div> | |
</div> | |
</div> | |
); | |
}; | |
const ChatForm: FC<{ | |
input: string; | |
setInput: (value: string) => void; | |
handleSubmit: (e: FormEvent) => void; | |
isLoading: boolean; | |
}> = ({ input, setInput, handleSubmit, isLoading }) => ( | |
<div className="sticky bottom-0 border-t border-secondary/50 bg-white/80 backdrop-blur-sm"> | |
<form onSubmit={handleSubmit} className="max-w-3xl mx-auto p-4 flex gap-4"> | |
<textarea | |
placeholder="Ask VedaMD anything..." | |
value={input} | |
onChange={(e) => setInput(e.target.value)} | |
className="input-primary" | |
onKeyDown={(e) => { | |
if (e.key === 'Enter' && !e.shiftKey) { | |
e.preventDefault(); | |
handleSubmit(e); | |
} | |
}} | |
disabled={isLoading} | |
rows={1} | |
/> | |
<button | |
type="submit" | |
disabled={isLoading || !input.trim()} | |
className="button-primary whitespace-nowrap" | |
> | |
{isLoading ? ( | |
<div className="size-6 border-4 border-t-transparent border-white rounded-full animate-spin" /> | |
) : ( | |
<ArrowRightIcon /> | |
)} | |
</button> | |
</form> | |
</div> | |
); | |
const Footer: FC = () => ( | |
<footer className="py-6 px-4 text-center text-text-secondary text-sm"> | |
© 2024 VedaMD. All rights reserved. | |
</footer> | |
); | |
// --- MAIN PAGE COMPONENT --- | |
export default function Home() { | |
const [conversation, setConversation] = useState<Message[]>([]); | |
const [input, setInput] = useState(''); | |
const [isLoading, setIsLoading] = useState(false); | |
const [error, setError] = useState<string | null>(null); | |
const messagesEndRef = useRef<HTMLDivElement>(null); | |
const scrollToBottom = () => { | |
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); | |
}; | |
useEffect(() => { | |
const timeoutId = setTimeout(scrollToBottom, 100); | |
return () => clearTimeout(timeoutId); | |
}, [conversation]); | |
const handleSubmit = async (e: FormEvent | string) => { | |
const query = (typeof e === 'string' ? e : input).trim(); | |
if (typeof e !== 'string') e.preventDefault(); | |
if (!query || isLoading) return; | |
setIsLoading(true); | |
setError(null); | |
if (typeof e !== 'string') setInput(''); | |
const userMessage: Message = { role: 'user', content: query }; | |
const currentConversation = [...conversation, userMessage]; | |
setConversation(currentConversation); | |
try { | |
const history = currentConversation.slice(0, -1).map(({ role, content }) => ({ role, content })); | |
let backendUrl = "http://localhost:8000/query"; // default for local dev | |
if (typeof window !== "undefined" && window.location.hostname.endsWith(".hf.space")) { | |
backendUrl = "https://healthifylk-vedamd--8000.hf.space/query"; // production | |
} | |
const response = await fetch(backendUrl, { | |
method: 'POST', | |
headers: { 'Content-Type': 'application/json' }, | |
body: JSON.stringify({ query, history }), | |
}); | |
if (!response.ok) { | |
const errorData = await response.json().catch(() => ({ detail: 'An unknown error occurred.' })); | |
throw new Error(errorData.detail || 'Network response was not ok'); | |
} | |
const data = await response.json(); | |
const botMessage: Message = { | |
role: 'assistant', | |
content: data.response | |
}; | |
setConversation([...currentConversation, botMessage]); | |
} catch (err: any) { | |
const errorMessageText = err.message || "An unexpected error occurred."; | |
setError(errorMessageText); | |
const errorMessage: Message = { role: 'assistant', content: errorMessageText }; | |
setConversation([...currentConversation, errorMessage]); | |
} finally { | |
setIsLoading(false); | |
} | |
}; | |
return ( | |
<div className="min-h-screen flex flex-col bg-slate-50"> | |
<Header /> | |
<main className="flex-1 flex flex-col"> | |
<div className="flex-1 overflow-y-auto"> | |
{conversation.length === 0 ? ( | |
<WelcomeScreen onTemplateClick={handleSubmit} /> | |
) : ( | |
<div className="pb-20"> | |
{conversation.map((message, index) => ( | |
<ChatMessage key={index} message={message} /> | |
))} | |
<div ref={messagesEndRef} /> | |
</div> | |
)} | |
</div> | |
<ChatForm | |
input={input} | |
setInput={setInput} | |
handleSubmit={handleSubmit} | |
isLoading={isLoading} | |
/> | |
</main> | |
<Footer /> | |
</div> | |
); | |
} |