| import { useEffect, useState, useRef } from "react"; | |
| import Chat from "./components/Chat"; | |
| import ArrowRightIcon from "./components/icons/ArrowRightIcon"; | |
| import StopIcon from "./components/icons/StopIcon"; | |
| import Progress from "./components/Progress"; | |
| const IS_WEBGPU_AVAILABLE = !!navigator.gpu; | |
| const STICKY_SCROLL_THRESHOLD = 120; | |
| const EXAMPLES = [ | |
| "Triage: Patient with sudden chest pain and sweating.", | |
| "ABCDE assessment for an unconscious patient after a fall.", | |
| "Initial steps for a severe allergic reaction (Anaphylaxis).", | |
| ]; | |
| function App() { | |
| const worker = useRef(null); | |
| const textareaRef = useRef(null); | |
| const chatContainerRef = useRef(null); | |
| const [status, setStatus] = useState(null); | |
| const [error, setError] = useState(null); | |
| const [loadingMessage, setLoadingMessage] = useState(""); | |
| const [progressItems, setProgressItems] = useState([]); | |
| const [isRunning, setIsRunning] = useState(false); | |
| const [input, setInput] = useState(""); | |
| const [messages, setMessages] = useState([]); | |
| const [tps, setTps] = useState(null); | |
| const [numTokens, setNumTokens] = useState(null); | |
| function onEnter(message) { | |
| setMessages((prev) => [...prev, { role: "user", content: message }]); | |
| setTps(null); | |
| setIsRunning(true); | |
| setInput(""); | |
| } | |
| function onInterrupt() { | |
| worker.current.postMessage({ type: "interrupt" }); | |
| } | |
| useEffect(() => { | |
| resizeInput(); | |
| }, [input]); | |
| function resizeInput() { | |
| if (!textareaRef.current) return; | |
| const target = textareaRef.current; | |
| target.style.height = "auto"; | |
| const newHeight = Math.min(Math.max(target.scrollHeight, 24), 200); | |
| target.style.height = `${newHeight}px`; | |
| } | |
| useEffect(() => { | |
| if (!worker.current) { | |
| worker.current = new Worker(new URL("./worker.js", import.meta.url), { | |
| type: "module", | |
| }); | |
| worker.current.postMessage({ type: "check" }); | |
| } | |
| const onMessageReceived = (e) => { | |
| switch (e.data.status) { | |
| case "loading": | |
| setStatus("loading"); | |
| setLoadingMessage(e.data.data); | |
| break; | |
| case "initiate": | |
| setProgressItems((prev) => [...prev, e.data]); | |
| break; | |
| case "progress": | |
| setProgressItems((prev) => | |
| prev.map((item) => (item.file === e.data.file ? { ...item, ...e.data } : item)) | |
| ); | |
| break; | |
| case "done": | |
| setProgressItems((prev) => prev.filter((item) => item.file !== e.data.file)); | |
| break; | |
| case "ready": | |
| setStatus("ready"); | |
| break; | |
| case "start": | |
| setMessages((prev) => [...prev, { role: "assistant", content: "" }]); | |
| break; | |
| case "update": | |
| const { output, tps, numTokens } = e.data; | |
| setTps(tps); | |
| setNumTokens(numTokens); | |
| setMessages((prev) => { | |
| const cloned = [...prev]; | |
| const last = cloned.at(-1); | |
| cloned[cloned.length - 1] = { ...last, content: last.content + output }; | |
| return cloned; | |
| }); | |
| break; | |
| case "complete": | |
| setIsRunning(false); | |
| break; | |
| case "error": | |
| setError(e.data.data); | |
| break; | |
| } | |
| }; | |
| worker.current.addEventListener("message", onMessageReceived); | |
| return () => worker.current.removeEventListener("message", onMessageReceived); | |
| }, []); | |
| useEffect(() => { | |
| if (messages.filter((x) => x.role === "user").length === 0) return; | |
| if (messages.at(-1).role === "assistant") return; | |
| worker.current.postMessage({ type: "generate", data: messages }); | |
| }, [messages, isRunning]); | |
| useEffect(() => { | |
| if (!chatContainerRef.current || !isRunning) return; | |
| const element = chatContainerRef.current; | |
| if (element.scrollHeight - element.scrollTop - element.clientHeight < STICKY_SCROLL_THRESHOLD) { | |
| element.scrollTop = element.scrollHeight; | |
| } | |
| }, [messages, isRunning]); | |
| return IS_WEBGPU_AVAILABLE ? ( | |
| <div className="flex flex-col h-screen mx-auto justify-end text-gray-800 dark:text-gray-200 bg-white dark:bg-gray-900"> | |
| {status === null && messages.length === 0 && ( | |
| <div className="h-full overflow-auto flex justify-center items-center flex-col relative"> | |
| <div className="flex flex-col items-center mb-1 max-w-[400px] text-center"> | |
| {/* أيقونة طبية بسيطة أو لوجو */} | |
| <div className="text-6xl mb-4">🏥</div> | |
| <h1 className="text-4xl font-bold mb-1 text-red-600 dark:text-red-500">ER Assistant</h1> | |
| <h2 className="font-semibold px-4"> | |
| AI-Powered Support for Emergency Room Physicians. | |
| <br/><span className="text-sm font-normal text-gray-500">Optimized for rapid triage and ABCDE protocols.</span> | |
| </h2> | |
| </div> | |
| <div className="flex flex-col items-center px-4"> | |
| <p className="max-w-[480px] mb-4 text-sm text-center"> | |
| This tool runs <b>locally</b> on your device via WebGPU. | |
| Patient data never leaves this browser. | |
| </p> | |
| <button | |
| className="border px-6 py-3 rounded-lg bg-red-600 text-white hover:bg-red-700 disabled:bg-gray-300 select-none font-bold" | |
| onClick={() => { | |
| worker.current.postMessage({ type: "load" }); | |
| setStatus("loading"); | |
| }} | |
| disabled={status !== null || error !== null} | |
| > | |
| Initialize ER Assistant | |
| </button> | |
| </div> | |
| </div> | |
| )} | |
| {status === "loading" && ( | |
| <div className="w-full max-w-[500px] mx-auto p-4 mt-auto"> | |
| <p className="text-center mb-2 font-medium">Preparing Clinical Database...</p> | |
| {progressItems.map(({ file, progress, total }, i) => ( | |
| <Progress key={i} text={file} percentage={progress} total={total} /> | |
| ))} | |
| </div> | |
| )} | |
| {status === "ready" && ( | |
| <div ref={chatContainerRef} className="overflow-y-auto w-full flex flex-col items-center h-full pt-8"> | |
| <Chat messages={messages} /> | |
| {messages.length === 0 && ( | |
| <div className="grid grid-cols-1 gap-2 max-w-[500px] w-full px-4"> | |
| <p className="text-center text-gray-500 mb-2">Select a scenario to start:</p> | |
| {EXAMPLES.map((msg, i) => ( | |
| <div | |
| key={i} | |
| className="border dark:border-gray-600 rounded-lg p-3 bg-gray-50 dark:bg-gray-800 cursor-pointer hover:bg-red-50 dark:hover:bg-red-900/20 transition" | |
| onClick={() => onEnter(msg)} | |
| > | |
| {msg} | |
| </div> | |
| ))} | |
| </div> | |
| )} | |
| {/* إحصائيات الأداء */} | |
| <div className="p-4 text-xs text-gray-400"> | |
| {tps && `Speed: ${tps.toFixed(2)} tokens/sec`} | |
| </div> | |
| </div> | |
| )} | |
| <div className="mt-2 border dark:bg-gray-800 rounded-lg w-[600px] max-w-[90%] mx-auto relative mb-3 flex shadow-lg"> | |
| <textarea | |
| ref={textareaRef} | |
| className="w-full dark:bg-gray-800 px-4 py-4 rounded-lg bg-transparent border-none outline-none resize-none" | |
| placeholder="Describe patient symptoms (e.g., Male, 45y, chest pain)..." | |
| rows={1} | |
| value={input} | |
| disabled={status !== "ready"} | |
| onKeyDown={(e) => { | |
| if (input.length > 0 && !isRunning && e.key === "Enter" && !e.shiftKey) { | |
| e.preventDefault(); | |
| onEnter(input); | |
| } | |
| }} | |
| onInput={(e) => setInput(e.target.value)} | |
| /> | |
| <div className="flex items-center pr-3"> | |
| {isRunning ? ( | |
| <StopIcon className="h-8 w-8 cursor-pointer text-red-500" onClick={onInterrupt} /> | |
| ) : ( | |
| <ArrowRightIcon | |
| className={`h-8 w-8 p-1 rounded-md ${input.length > 0 ? 'bg-red-600 text-white' : 'bg-gray-200 text-gray-400'}`} | |
| onClick={() => input.length > 0 && onEnter(input)} | |
| /> | |
| )} | |
| </div> | |
| </div> | |
| <p className="text-[10px] text-gray-400 text-center mb-3 px-4"> | |
| NOTICE: This AI is a supportive tool for healthcare professionals. Final clinical decisions must be made by a qualified physician. | |
| </p> | |
| </div> | |
| ) : ( | |
| <div className="fixed w-screen h-screen bg-black text-white flex justify-center items-center text-center p-10"> | |
| WebGPU is not supported by this browser. Please use Chrome or Edge. | |
| </div> | |
| ); | |
| } | |
| export default App; |