Files
LabWise/components/ProtocolChecker.tsx
Aniketh Kalagara 4098453c97
All checks were successful
Deploy to Server / deploy (push) Successful in 34s
v1 of protocol checker
2026-04-14 19:09:00 -05:00

528 lines
24 KiB
TypeScript

import { ChangeEvent, DragEvent, KeyboardEvent, useRef, useState } from "react";
import { protocolsApi } from "../lib/api";
import { Card } from "./ui/card";
import { Button } from "./ui/button";
import { Textarea } from "./ui/textarea";
import { Badge } from "./ui/badge";
import { Input } from "./ui/input";
import {
Upload,
FileCheck,
AlertTriangle,
CheckCircle2,
Info,
Loader2,
MessageSquare,
Send,
ExternalLink
} from "lucide-react";
import { Alert, AlertDescription } from "./ui/alert";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "./ui/tabs";
import type { SafetyIssue } from "../shared/types";
interface ChatMessage {
id: string;
role: "user" | "assistant";
content: string;
sources?: { title: string; url: string }[];
}
export function ProtocolChecker() {
const fileInputRef = useRef<HTMLInputElement>(null);
const [protocol, setProtocol] = useState("");
const [selectedFileName, setSelectedFileName] = useState("");
const [isAnalyzing, setIsAnalyzing] = useState(false);
const [analyzed, setAnalyzed] = useState(false);
const [analysisError, setAnalysisError] = useState("");
const [issues, setIssues] = useState<SafetyIssue[]>([]);
const [chatMessages, setChatMessages] = useState<ChatMessage[]>([
{
id: "1",
role: "assistant",
content: "Hi! I'm your lab safety AI assistant. I can help answer questions about chemical safety, proper handling procedures, and regulatory compliance. How can I help you today?",
}
]);
const [currentMessage, setCurrentMessage] = useState("");
const [isSending, setIsSending] = useState(false);
const loadProtocolFile = async (file: File) => {
setAnalyzed(false);
setAnalysisError("");
setIssues([]);
if (file.size > 10 * 1024 * 1024) {
setAnalysisError("File must be 10MB or smaller.");
return;
}
const extension = file.name.split(".").pop()?.toLowerCase();
if (extension !== "txt") {
setAnalysisError("For now, upload a TXT file or paste protocol text. PDF, DOC, and DOCX text extraction is not wired yet.");
return;
}
try {
const text = await file.text();
if (!text.trim()) {
setAnalysisError("That file does not contain any readable protocol text.");
return;
}
setProtocol(text);
setSelectedFileName(file.name);
} catch {
setAnalysisError("Could not read that file. Try saving it as a plain TXT file and upload again.");
}
};
const handleFileChange = async (event: ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (file) await loadProtocolFile(file);
event.target.value = "";
};
const handleUploadKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
fileInputRef.current?.click();
}
};
const handleDrop = async (event: DragEvent<HTMLDivElement>) => {
event.preventDefault();
const file = event.dataTransfer.files?.[0];
if (file) await loadProtocolFile(file);
};
const handleAnalyze = async () => {
if (!protocol.trim()) return;
setIsAnalyzing(true);
setAnalyzed(false);
setAnalysisError("");
setIssues([]);
try {
const saved = await protocolsApi.createFromText(
selectedFileName || `Protocol ${new Date().toLocaleDateString()}`,
protocol
);
const analyzedProtocol = await protocolsApi.analyze(saved.id);
setIssues(analyzedProtocol.analysis_results || []);
setAnalyzed(true);
} catch (err) {
console.error("Failed to save protocol:", err);
setAnalysisError(err instanceof Error ? err.message : "Failed to analyze protocol");
} finally {
setIsAnalyzing(false);
}
};
const handleSendMessage = () => {
if (!currentMessage.trim()) return;
const userMessage: ChatMessage = {
id: Date.now().toString(),
role: "user",
content: currentMessage
};
setChatMessages([...chatMessages, userMessage]);
setCurrentMessage("");
setIsSending(true);
// Simulate AI response
setTimeout(() => {
const aiResponse: ChatMessage = {
id: (Date.now() + 1).toString(),
role: "assistant",
content: generateAIResponse(currentMessage),
sources: [
{ title: "OSHA Laboratory Safety Guidance", url: "https://www.osha.gov/laboratories" },
{ title: "CDC Laboratory Safety Manual", url: "https://www.cdc.gov/labs/safety.html" },
{ title: "University EHS Guidelines", url: "#" }
]
};
setChatMessages(prev => [...prev, aiResponse]);
setIsSending(false);
}, 1500);
};
const generateAIResponse = (question: string): string => {
// Mock AI responses based on common questions
const lowerQ = question.toLowerCase();
if (lowerQ.includes("glove") || lowerQ.includes("ppe")) {
return "For chemical handling, glove selection depends on the chemical. Nitrile gloves are suitable for most aqueous solutions and mild acids/bases. However, for concentrated acids (like concentrated HCl or H2SO4), you should use butyl rubber gloves. For organic solvents, neoprene or nitrile gloves are recommended. Always check the manufacturer's chemical resistance chart and inspect gloves before use. OSHA 29 CFR 1910.132 requires appropriate PPE selection based on hazard assessment.";
} else if (lowerQ.includes("fume hood") || lowerQ.includes("ventilation")) {
return "Fume hoods should be used when working with volatile, toxic, or odorous chemicals. Maintain a face velocity of 80-120 linear feet per minute. Keep the sash at the designated working height (typically 18 inches) and work at least 6 inches inside the hood. Never store chemicals in the fume hood permanently. According to ANSI/AIHA Z9.5, annual certification and daily inspections are required.";
} else if (lowerQ.includes("waste") || lowerQ.includes("disposal")) {
return "Chemical waste must be segregated by compatibility class. Never mix incompatible wastes. Label all waste containers with contents, hazards, and accumulation start date. Halogenated and non-halogenated organic solvents must be kept separate. Dispose through your institution's hazardous waste program within 90 days of accumulation. Follow EPA RCRA guidelines for proper classification and disposal.";
} else if (lowerQ.includes("acid") || lowerQ.includes("base")) {
return "When diluting acids, always add acid to water (never water to acid) to prevent violent reactions. Store acids and bases separately in dedicated cabinets. For spills, use appropriate neutralizing agents (sodium bicarbonate for acids, citric acid for bases). Wear appropriate PPE including face shield, acid-resistant gloves, and lab coat. Work in a fume hood when handling concentrated acids that produce vapors.";
}
return "Based on current laboratory safety standards and regulations from OSHA, EPA, and CDC, I recommend following your institution's chemical hygiene plan and consulting the relevant Safety Data Sheets (SDS) for specific chemicals. For detailed guidance on your specific situation, please consult with your institution's Environmental Health & Safety (EHS) office. I've provided relevant sources below for your reference.";
};
const getIssueColor = (type: string) => {
switch (type) {
case "critical": return { bg: "bg-red-50", text: "text-red-700", border: "border-red-200" };
case "warning": return { bg: "bg-amber-50", text: "text-amber-700", border: "border-amber-200" };
case "suggestion": return { bg: "bg-accent", text: "text-primary", border: "border-border" };
default: return { bg: "bg-muted", text: "text-muted-foreground", border: "border-border" };
}
};
const getIssueIcon = (type: string) => {
switch (type) {
case "critical": return <AlertTriangle className="w-5 h-5 text-red-600" />;
case "warning": return <AlertTriangle className="w-5 h-5 text-amber-600" />;
case "suggestion": return <Info className="w-5 h-5 text-primary" />;
default: return null;
}
};
const criticalCount = issues.filter(i => i.type === "critical").length;
const warningCount = issues.filter(i => i.type === "warning").length;
const suggestionCount = issues.filter(i => i.type === "suggestion").length;
return (
<div className="p-8">
<div className="max-w-7xl mx-auto">
<div className="mb-8">
<h1 className="text-foreground mb-2">Protocol Safety Checker & AI Assistant</h1>
<p className="text-muted-foreground">Get safety feedback on protocols and ask questions with sourced answers</p>
</div>
<Tabs defaultValue="checker" className="w-full">
<TabsList className="mb-6">
<TabsTrigger value="checker">
<FileCheck className="w-4 h-4 mr-2" />
Protocol Checker
</TabsTrigger>
<TabsTrigger value="assistant">
<MessageSquare className="w-4 h-4 mr-2" />
AI Safety Assistant
</TabsTrigger>
</TabsList>
<TabsContent value="checker">
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Input Section */}
<div className="lg:col-span-2 space-y-6">
<Card className="p-6">
<div className="mb-4">
<h3 className="text-foreground mb-2">Upload Protocol</h3>
<p className="text-muted-foreground">Paste your protocol text or upload a file</p>
</div>
<input
ref={fileInputRef}
type="file"
accept=".txt,text/plain"
className="hidden"
onChange={handleFileChange}
/>
<div
role="button"
tabIndex={0}
className="border-2 border-dashed border-border rounded-lg p-8 text-center mb-4 hover:border-primary transition-colors cursor-pointer"
onClick={() => fileInputRef.current?.click()}
onKeyDown={handleUploadKeyDown}
onDragOver={(event) => event.preventDefault()}
onDrop={handleDrop}
>
<Upload className="w-12 h-12 text-muted-foreground mx-auto mb-3" />
<p className="text-foreground mb-1">Click to upload or drag and drop</p>
<p className="text-muted-foreground">TXT files for now (max 10MB)</p>
{selectedFileName && (
<p className="text-primary mt-2">{selectedFileName}</p>
)}
</div>
<div className="relative mb-4">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t border-border" />
</div>
<div className="relative flex justify-center">
<span className="bg-card px-3 text-muted-foreground">or paste text</span>
</div>
</div>
<Textarea
placeholder="Paste your protocol here...
Example:
1. Prepare 1M HCl solution by diluting concentrated HCl
2. Add 10mL of organic solvent to reaction vessel
3. Heat mixture to 80°C for 2 hours
..."
className="min-h-[300px] font-mono"
value={protocol}
onChange={(e) => setProtocol(e.target.value)}
/>
<Button
className="w-full mt-4 bg-primary hover:bg-primary/90"
onClick={handleAnalyze}
disabled={!protocol || isAnalyzing}
>
{isAnalyzing ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Analyzing Protocol...
</>
) : (
<>
<FileCheck className="w-4 h-4 mr-2" />
Analyze Protocol
</>
)}
</Button>
{analysisError && (
<Alert className="mt-4 border-red-200 bg-red-50">
<AlertTriangle className="h-4 w-4 text-red-600" />
<AlertDescription className="text-red-700">
{analysisError}
</AlertDescription>
</Alert>
)}
</Card>
{/* Results */}
{analyzed && (
<Card className="p-6">
<div className="flex items-start justify-between mb-6">
<div>
<h3 className="text-foreground mb-2">Analysis Results</h3>
<p className="text-muted-foreground">AI-identified safety considerations with sources</p>
</div>
<Badge className="bg-accent text-primary">
<CheckCircle2 className="w-4 h-4 mr-1" />
Complete
</Badge>
</div>
<div className="space-y-4">
{issues.length === 0 && (
<p className="text-muted-foreground">
No safety issues were returned for this protocol.
</p>
)}
{issues.map((issue, idx) => {
const colors = getIssueColor(issue.type);
return (
<div
key={idx}
className={`p-4 rounded-lg border ${colors.bg} ${colors.border}`}
>
<div className="flex items-start gap-3">
{getIssueIcon(issue.type)}
<div className="flex-1">
<div className="flex items-center gap-2 mb-2">
<span className={`uppercase tracking-wide ${colors.text}`}>
{issue.type}
</span>
<span className="text-muted-foreground"></span>
<span className="text-foreground">{issue.category}</span>
</div>
<p className="text-foreground mb-2">{issue.message}</p>
{issue.source && (
<a
href={issue.sourceUrl}
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline text-sm flex items-center gap-1"
>
<ExternalLink className="w-3 h-3" />
Source: {issue.source}
</a>
)}
</div>
</div>
</div>
);
})}
</div>
</Card>
)}
</div>
{/* Sidebar */}
<div className="space-y-6">
{analyzed && (
<Card className="p-6">
<h3 className="text-foreground mb-4">Summary</h3>
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-red-500" />
<span className="text-foreground">Critical Issues</span>
</div>
<span className="text-foreground">{criticalCount}</span>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-amber-500" />
<span className="text-foreground">Warnings</span>
</div>
<span className="text-foreground">{warningCount}</span>
</div>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full bg-[#5a9584]" />
<span className="text-foreground">Suggestions</span>
</div>
<span className="text-foreground">{suggestionCount}</span>
</div>
</div>
</Card>
)}
<Card className="p-6">
<h3 className="text-foreground mb-4">What We Check</h3>
<div className="space-y-3">
<div className="flex items-start gap-2">
<CheckCircle2 className="w-5 h-5 text-primary mt-0.5" />
<span className="text-foreground">PPE requirements</span>
</div>
<div className="flex items-start gap-2">
<CheckCircle2 className="w-5 h-5 text-primary mt-0.5" />
<span className="text-foreground">Chemical compatibility</span>
</div>
<div className="flex items-start gap-2">
<CheckCircle2 className="w-5 h-5 text-primary mt-0.5" />
<span className="text-foreground">Ventilation needs</span>
</div>
<div className="flex items-start gap-2">
<CheckCircle2 className="w-5 h-5 text-primary mt-0.5" />
<span className="text-foreground">Waste disposal</span>
</div>
<div className="flex items-start gap-2">
<CheckCircle2 className="w-5 h-5 text-primary mt-0.5" />
<span className="text-foreground">Emergency procedures</span>
</div>
<div className="flex items-start gap-2">
<CheckCircle2 className="w-5 h-5 text-primary mt-0.5" />
<span className="text-foreground">Storage requirements</span>
</div>
</div>
</Card>
<Alert>
<Info className="h-4 w-4" />
<AlertDescription>
All guidance is sourced from federal regulations and university policies. Review sources for details.
</AlertDescription>
</Alert>
</div>
</div>
</TabsContent>
<TabsContent value="assistant">
<div className="grid grid-cols-1 lg:grid-cols-4 gap-6">
{/* Chat Interface */}
<div className="lg:col-span-3">
<Card className="h-[700px] flex flex-col">
{/* Messages */}
<div className="flex-1 overflow-y-auto p-6 space-y-4">
{chatMessages.map((message) => (
<div
key={message.id}
className={`flex ${message.role === "user" ? "justify-end" : "justify-start"}`}
>
<div className={`max-w-[80%] ${message.role === "user" ? "bg-primary text-white" : "bg-accent"} rounded-lg p-4`}>
<p className={message.role === "user" ? "text-white" : "text-foreground"}>
{message.content}
</p>
{message.sources && message.sources.length > 0 && (
<div className="mt-3 pt-3 border-t border-border space-y-1">
<p className="text-muted-foreground text-sm mb-2">Sources:</p>
{message.sources.map((source, idx) => (
<a
key={idx}
href={source.url}
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline text-sm flex items-center gap-1"
>
<ExternalLink className="w-3 h-3" />
{source.title}
</a>
))}
</div>
)}
</div>
</div>
))}
{isSending && (
<div className="flex justify-start">
<div className="bg-accent rounded-lg p-4">
<Loader2 className="w-5 h-5 animate-spin text-primary" />
</div>
</div>
)}
</div>
{/* Input */}
<div className="border-t border-border p-4">
<div className="flex gap-2">
<Input
placeholder="Ask a safety question..."
value={currentMessage}
onChange={(e) => setCurrentMessage(e.target.value)}
onKeyPress={(e) => e.key === "Enter" && handleSendMessage()}
/>
<Button
onClick={handleSendMessage}
disabled={!currentMessage.trim() || isSending}
className="bg-primary hover:bg-primary/90"
>
<Send className="w-4 h-4" />
</Button>
</div>
</div>
</Card>
</div>
{/* Info Sidebar */}
<div className="space-y-6">
<Card className="p-6">
<h3 className="text-foreground mb-4">Ask About</h3>
<div className="space-y-2">
{[
"PPE selection",
"Chemical storage",
"Fume hood usage",
"Waste disposal",
"Spill response",
"Hazard classes"
].map((topic) => (
<Button
key={topic}
variant="outline"
className="w-full justify-start"
onClick={() => setCurrentMessage(`Tell me about ${topic}`)}
>
{topic}
</Button>
))}
</div>
</Card>
<Card className="p-6 bg-accent">
<h3 className="text-foreground mb-2">Sourced Information</h3>
<p className="text-muted-foreground text-sm">
All answers include sources from OSHA, EPA, CDC, ANSI, and university EHS guidelines so you can verify and cite information.
</p>
</Card>
</div>
</div>
</TabsContent>
</Tabs>
</div>
</div>
);
}