Files
LabWise/components/ProtocolChecker.tsx

476 lines
22 KiB
TypeScript
Raw Normal View History

2026-03-18 17:10:16 -05:00
import { useState } from "react";
import { protocolsApi } from "../lib/api";
2026-03-18 17:10:16 -05:00
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";
2026-03-18 17:10:16 -05:00
interface ChatMessage {
id: string;
role: "user" | "assistant";
content: string;
sources?: { title: string; url: string }[];
}
export function ProtocolChecker() {
const [protocol, setProtocol] = useState("");
const [isAnalyzing, setIsAnalyzing] = useState(false);
const [analyzed, setAnalyzed] = useState(false);
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 mockIssues: SafetyIssue[] = [
{
type: "critical",
category: "PPE Requirements",
message: "No mention of glove type for handling concentrated acids. Nitrile gloves are required for HCl handling.",
source: "OSHA 29 CFR 1910.132",
sourceUrl: "https://www.osha.gov/laws-regs/regulations/standardnumber/1910/1910.132"
},
{
type: "warning",
category: "Ventilation",
message: "Protocol should specify use of fume hood when working with volatile organic compounds.",
source: "OSHA Laboratory Standard 29 CFR 1910.1450",
sourceUrl: "https://www.osha.gov/laws-regs/regulations/standardnumber/1910/1910.1450"
},
{
type: "suggestion",
category: "Waste Disposal",
message: "Consider adding specific waste disposal instructions for halogenated solvents per EPA regulations.",
source: "EPA RCRA Guidelines",
sourceUrl: "https://www.epa.gov/rcra"
},
{
type: "warning",
category: "Emergency Procedures",
message: "Include location of nearest eyewash station and emergency shower as required by ANSI Z358.1.",
source: "ANSI Z358.1-2014",
sourceUrl: "https://www.ansi.org"
},
{
type: "suggestion",
category: "Chemical Storage",
message: "Specify incompatible chemicals that should not be stored together per NFPA guidelines.",
source: "NFPA 45: Fire Protection for Laboratories",
sourceUrl: "https://www.nfpa.org"
}
];
const handleAnalyze = async () => {
if (!protocol.trim()) return;
2026-03-18 17:10:16 -05:00
setIsAnalyzing(true);
try {
const saved = await protocolsApi.createFromText(
`Protocol ${new Date().toLocaleDateString()}`,
protocol
);
await protocolsApi.saveAnalysis(saved.id, mockIssues);
2026-03-18 17:10:16 -05:00
setAnalyzed(true);
} catch (err) {
console.error("Failed to save protocol:", err);
} finally {
setIsAnalyzing(false);
}
2026-03-18 17:10:16 -05:00
};
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 = mockIssues.filter(i => i.type === "critical").length;
const warningCount = mockIssues.filter(i => i.type === "warning").length;
const suggestionCount = mockIssues.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>
<div className="border-2 border-dashed border-border rounded-lg p-8 text-center mb-4 hover:border-primary transition-colors cursor-pointer">
<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">PDF, DOC, DOCX, TXT (max 10MB)</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>
</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">
{mockIssues.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>
);
}