- Add Gemini-powered internal staff chatbot (free-text input in the upper-right bot panel): type "check MARIA GONZALES" to search patient and pre-fill eligibility, or "open claims" to navigate directly - Add /api/ai/internal-chat endpoint with LangGraph + Google Gemini classifier (intent: check_eligibility, find_patient, navigate_*) - Add Users AI Chat settings section in Settings > Advanced > AI Chat to configure a custom system prompt for the internal assistant - Store internal chat system prompt in existing twilioSettings JSON blob (no DB migration needed) - Add AI Input Agent sidebar entry and placeholder page describing planned keyboard-automation typing into Open Dental / Eaglesoft Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
458 lines
17 KiB
TypeScript
458 lines
17 KiB
TypeScript
import { useState, useRef, useEffect } from "react";
|
|
import {
|
|
Bot,
|
|
X,
|
|
ChevronRight,
|
|
Stethoscope,
|
|
Calendar,
|
|
FileText,
|
|
MessageSquare,
|
|
Send,
|
|
Loader2,
|
|
} from "lucide-react";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Label } from "@/components/ui/label";
|
|
import { useLocation } from "wouter";
|
|
import { cn } from "@/lib/utils";
|
|
import { apiRequest } from "@/lib/queryClient";
|
|
|
|
type Step =
|
|
| "menu"
|
|
| "eligibility-input"
|
|
| "eligibility-confirm"
|
|
| "ai-loading"
|
|
| "patient-found";
|
|
|
|
interface Message {
|
|
id: number;
|
|
role: "bot" | "user";
|
|
text: string;
|
|
isLoading?: boolean;
|
|
}
|
|
|
|
interface PatientResult {
|
|
id: number;
|
|
firstName: string;
|
|
lastName: string;
|
|
insuranceId: string | null;
|
|
insuranceProvider: string | null;
|
|
dateOfBirth: string | null;
|
|
}
|
|
|
|
interface EligibilityData {
|
|
memberId: string;
|
|
dob: string;
|
|
dobISO: string;
|
|
}
|
|
|
|
let msgCounter = 0;
|
|
function makeMsg(role: "bot" | "user", text: string, isLoading = false): Message {
|
|
return { id: ++msgCounter, role, text, isLoading };
|
|
}
|
|
|
|
function getAutoCheck(dobISO: string): "mh" | "cmsp" {
|
|
const [y, m, d] = dobISO.split("-").map(Number);
|
|
const today = new Date();
|
|
let age = today.getFullYear() - (y ?? 0);
|
|
const monthDiff = today.getMonth() + 1 - (m ?? 0);
|
|
if (monthDiff < 0 || (monthDiff === 0 && today.getDate() < (d ?? 0))) age--;
|
|
return age >= 21 ? "mh" : "cmsp";
|
|
}
|
|
|
|
function parseEligibilityInput(
|
|
raw: string
|
|
): { memberId: string; display: string; iso: string } | null {
|
|
const dateMatch = raw.match(/\b(\d{1,2})[\/\-](\d{1,2})[\/\-](\d{4})\b/);
|
|
if (!dateMatch) return null;
|
|
const m = dateMatch[1];
|
|
const d = dateMatch[2];
|
|
const y = dateMatch[3];
|
|
if (!m || !d || !y) return null;
|
|
const month = parseInt(m, 10);
|
|
const day = parseInt(d, 10);
|
|
const year = parseInt(y, 10);
|
|
if (month < 1 || month > 12 || day < 1 || day > 31 || year < 1900) return null;
|
|
|
|
const withoutDate = raw.replace(dateMatch[0], "");
|
|
const memberId = (withoutDate.match(/[a-zA-Z0-9]/g) ?? []).join("");
|
|
if (!memberId) return null;
|
|
|
|
return {
|
|
memberId,
|
|
display: `${m.padStart(2, "0")}/${d.padStart(2, "0")}/${y}`,
|
|
iso: `${y}-${m.padStart(2, "0")}-${d.padStart(2, "0")}`,
|
|
};
|
|
}
|
|
|
|
const INITIAL_MESSAGES: Message[] = [
|
|
makeMsg("bot", "Hi! What can I help you with today?"),
|
|
];
|
|
|
|
export function ChatbotButton() {
|
|
const [open, setOpen] = useState(false);
|
|
const [step, setStep] = useState<Step>("menu");
|
|
const [messages, setMessages] = useState<Message[]>(INITIAL_MESSAGES);
|
|
const [pasteInput, setPasteInput] = useState("");
|
|
const [parseError, setParseError] = useState("");
|
|
const [eligibilityData, setEligibilityData] = useState<EligibilityData | null>(null);
|
|
const [freeTextInput, setFreeTextInput] = useState("");
|
|
const [patientResult, setPatientResult] = useState<PatientResult | null>(null);
|
|
const [, setLocation] = useLocation();
|
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
|
const pasteRef = useRef<HTMLTextAreaElement>(null);
|
|
const freeTextRef = useRef<HTMLTextAreaElement>(null);
|
|
|
|
useEffect(() => {
|
|
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
|
}, [messages, step]);
|
|
|
|
useEffect(() => {
|
|
if (step === "eligibility-input") {
|
|
setTimeout(() => pasteRef.current?.focus(), 50);
|
|
}
|
|
if (step === "menu") {
|
|
setTimeout(() => freeTextRef.current?.focus(), 50);
|
|
}
|
|
}, [step]);
|
|
|
|
const addMsg = (role: "bot" | "user", text: string, isLoading = false) =>
|
|
setMessages((prev) => [...prev, makeMsg(role, text, isLoading)]);
|
|
|
|
const replaceLastMsg = (text: string) =>
|
|
setMessages((prev) => {
|
|
const next = [...prev];
|
|
const last = next[next.length - 1];
|
|
if (last) next[next.length - 1] = { ...last, text, isLoading: false };
|
|
return next;
|
|
});
|
|
|
|
const reset = () => {
|
|
setStep("menu");
|
|
setMessages([makeMsg("bot", "Hi! What can I help you with today?")]);
|
|
setPasteInput("");
|
|
setParseError("");
|
|
setEligibilityData(null);
|
|
setFreeTextInput("");
|
|
setPatientResult(null);
|
|
};
|
|
|
|
const handleClose = () => {
|
|
setOpen(false);
|
|
reset();
|
|
};
|
|
|
|
const handleOptionSelect = (option: "eligibility" | "schedule" | "claims") => {
|
|
if (option === "schedule") {
|
|
addMsg("user", "Schedule an appointment");
|
|
addMsg("bot", "Opening the appointments page...");
|
|
setTimeout(() => { setLocation("/appointments"); setOpen(false); reset(); }, 600);
|
|
} else if (option === "claims") {
|
|
addMsg("user", "View claims");
|
|
addMsg("bot", "Opening the claims page...");
|
|
setTimeout(() => { setLocation("/claims"); setOpen(false); reset(); }, 600);
|
|
} else if (option === "eligibility") {
|
|
addMsg("user", "Check Eligibility");
|
|
addMsg("bot", "Please enter the patient's Member ID and Date of Birth:");
|
|
setStep("eligibility-input");
|
|
}
|
|
};
|
|
|
|
const handleEligibilitySubmit = () => {
|
|
const parsed = parseEligibilityInput(pasteInput);
|
|
if (!parsed) {
|
|
setParseError("Couldn't find both a Member ID and a date. Try: 123456789 01/15/1985");
|
|
return;
|
|
}
|
|
setParseError("");
|
|
const data: EligibilityData = { memberId: parsed.memberId, dob: parsed.display, dobISO: parsed.iso };
|
|
setEligibilityData(data);
|
|
addMsg("user", pasteInput.trim());
|
|
addMsg("bot", `Ready to check MassHealth eligibility for:\n• Member ID: ${parsed.memberId}\n• Date of Birth: ${parsed.display}\n\nShall I proceed?`);
|
|
setStep("eligibility-confirm");
|
|
};
|
|
|
|
const handleConfirm = () => {
|
|
if (!eligibilityData) return;
|
|
addMsg("user", "Yes, check now");
|
|
addMsg("bot", "Opening the eligibility check page with this patient...");
|
|
sessionStorage.setItem("chatbot_eligibility", JSON.stringify({
|
|
memberId: eligibilityData.memberId,
|
|
dob: eligibilityData.dobISO,
|
|
autoCheck: getAutoCheck(eligibilityData.dobISO),
|
|
}));
|
|
window.dispatchEvent(new CustomEvent("chatbot:eligibility-prefill"));
|
|
setTimeout(() => { setLocation("/insurance-status"); setOpen(false); reset(); }, 600);
|
|
};
|
|
|
|
const handleEligibilityFromPatient = () => {
|
|
if (!patientResult) return;
|
|
addMsg("user", "Check eligibility now");
|
|
addMsg("bot", "Opening the eligibility check page...");
|
|
if (patientResult.insuranceId && patientResult.dateOfBirth) {
|
|
sessionStorage.setItem("chatbot_eligibility", JSON.stringify({
|
|
memberId: patientResult.insuranceId,
|
|
dob: patientResult.dateOfBirth,
|
|
autoCheck: getAutoCheck(patientResult.dateOfBirth),
|
|
}));
|
|
window.dispatchEvent(new CustomEvent("chatbot:eligibility-prefill"));
|
|
}
|
|
setTimeout(() => { setLocation("/insurance-status"); setOpen(false); reset(); }, 600);
|
|
};
|
|
|
|
const handleFreeTextSubmit = async () => {
|
|
const text = freeTextInput.trim();
|
|
if (!text || step === "ai-loading") return;
|
|
setFreeTextInput("");
|
|
addMsg("user", text);
|
|
addMsg("bot", "Thinking...", true);
|
|
setStep("ai-loading");
|
|
|
|
try {
|
|
const res = await apiRequest("POST", "/api/ai/internal-chat", { message: text });
|
|
const data = await res.json();
|
|
|
|
replaceLastMsg(data.reply ?? "Sorry, I couldn't process that.");
|
|
|
|
if (data.action === "navigate" && data.actionData?.url) {
|
|
setTimeout(() => { setLocation(data.actionData.url); setOpen(false); reset(); }, 800);
|
|
return;
|
|
}
|
|
|
|
if (
|
|
(data.action === "check_eligibility_prefill" || data.action === "show_patient") &&
|
|
data.actionData?.patient
|
|
) {
|
|
setPatientResult(data.actionData.patient);
|
|
setStep("patient-found");
|
|
return;
|
|
}
|
|
|
|
setStep("menu");
|
|
} catch {
|
|
replaceLastMsg("Sorry, something went wrong. Please try again.");
|
|
setStep("menu");
|
|
}
|
|
};
|
|
|
|
const handleFreeTextKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
|
if (e.key === "Enter" && !e.shiftKey) {
|
|
e.preventDefault();
|
|
handleFreeTextSubmit();
|
|
}
|
|
};
|
|
|
|
const showFreeTextInput = step === "menu" || step === "ai-loading";
|
|
|
|
return (
|
|
<>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="relative h-8 w-8 rounded-full p-0"
|
|
title="Open Assistant"
|
|
onClick={() => setOpen(true)}
|
|
>
|
|
<Bot className="h-5 w-5 text-primary" />
|
|
</Button>
|
|
|
|
{open && (
|
|
<>
|
|
{/* Backdrop */}
|
|
<div className="fixed inset-0 z-40" onClick={handleClose} aria-hidden />
|
|
|
|
{/* Chat panel */}
|
|
<div className="fixed top-16 right-0 z-50 w-80 h-[calc(100vh-4rem)] bg-white border-l shadow-2xl flex flex-col">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between px-4 py-3 bg-primary text-white shrink-0">
|
|
<div className="flex items-center gap-2">
|
|
<Bot className="h-4 w-4" />
|
|
<span className="font-semibold text-sm">Assistant</span>
|
|
</div>
|
|
<button
|
|
onClick={handleClose}
|
|
className="hover:opacity-70 transition-opacity rounded"
|
|
aria-label="Close"
|
|
>
|
|
<X className="h-4 w-4" />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Messages */}
|
|
<div className="flex-1 overflow-y-auto p-4 space-y-3">
|
|
{messages.map((msg) => (
|
|
<div
|
|
key={msg.id}
|
|
className={cn("flex", msg.role === "user" ? "justify-end" : "justify-start")}
|
|
>
|
|
<div
|
|
className={cn(
|
|
"max-w-[85%] rounded-2xl px-3 py-2 text-sm whitespace-pre-line leading-relaxed",
|
|
msg.role === "user"
|
|
? "bg-primary text-white rounded-tr-sm"
|
|
: "bg-gray-100 text-gray-800 rounded-tl-sm"
|
|
)}
|
|
>
|
|
{msg.isLoading ? (
|
|
<span className="flex items-center gap-1.5">
|
|
<Loader2 className="h-3 w-3 animate-spin" />
|
|
Thinking...
|
|
</span>
|
|
) : (
|
|
msg.text
|
|
)}
|
|
</div>
|
|
</div>
|
|
))}
|
|
|
|
{/* Quick-action buttons (menu step) */}
|
|
{step === "menu" && (
|
|
<div className="grid grid-cols-2 gap-2 pt-1">
|
|
<button
|
|
onClick={() => handleOptionSelect("eligibility")}
|
|
className="flex items-center gap-2 p-3 rounded-xl border border-primary/30 hover:bg-primary/5 text-sm text-left transition-colors"
|
|
>
|
|
<Stethoscope className="h-4 w-4 text-primary shrink-0" />
|
|
<span>Check Eligibility</span>
|
|
</button>
|
|
<button
|
|
onClick={() => handleOptionSelect("schedule")}
|
|
className="flex items-center gap-2 p-3 rounded-xl border border-blue-300 hover:bg-blue-50 text-sm text-left transition-colors"
|
|
>
|
|
<Calendar className="h-4 w-4 text-blue-500 shrink-0" />
|
|
<span>Schedule</span>
|
|
</button>
|
|
<button
|
|
onClick={() => handleOptionSelect("claims")}
|
|
className="flex items-center gap-2 p-3 rounded-xl border border-orange-300 hover:bg-orange-50 text-sm text-left transition-colors col-span-2"
|
|
>
|
|
<FileText className="h-4 w-4 text-orange-500 shrink-0" />
|
|
<span>Claims</span>
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Eligibility manual input */}
|
|
{step === "eligibility-input" && (
|
|
<div className="space-y-2 bg-gray-50 rounded-xl p-3 border">
|
|
<Label className="text-xs text-gray-600">Paste Member ID and Date of Birth</Label>
|
|
<textarea
|
|
ref={pasteRef}
|
|
rows={3}
|
|
placeholder={"e.g. 123456789 01/15/1985\nor 01/15/1985 123456789"}
|
|
value={pasteInput}
|
|
onChange={(e) => { setPasteInput(e.target.value); setParseError(""); }}
|
|
className={cn(
|
|
"w-full rounded-md border bg-white px-3 py-2 text-sm resize-none focus:outline-none focus:ring-2 focus:ring-ring",
|
|
parseError && "border-red-400"
|
|
)}
|
|
/>
|
|
{parseError && <p className="text-xs text-red-500">{parseError}</p>}
|
|
<div className="flex gap-2">
|
|
<Button
|
|
size="sm"
|
|
className="flex-1 h-8 text-xs"
|
|
onClick={handleEligibilitySubmit}
|
|
disabled={!pasteInput.trim()}
|
|
>
|
|
Continue <ChevronRight className="h-3 w-3 ml-1" />
|
|
</Button>
|
|
<Button size="sm" variant="ghost" className="h-8 text-xs" onClick={reset}>
|
|
Cancel
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Eligibility confirm */}
|
|
{step === "eligibility-confirm" && (
|
|
<div className="flex gap-2">
|
|
<Button size="sm" className="flex-1 h-8 text-xs" onClick={handleConfirm}>
|
|
Yes, Check Now
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
className="h-8 text-xs"
|
|
onClick={() => {
|
|
setStep("eligibility-input");
|
|
setMessages((prev) => prev.slice(0, -2));
|
|
}}
|
|
>
|
|
Edit
|
|
</Button>
|
|
</div>
|
|
)}
|
|
|
|
{/* AI-found patient card */}
|
|
{step === "patient-found" && patientResult && (
|
|
<div className="bg-blue-50 border border-blue-200 rounded-xl p-3 space-y-2">
|
|
<p className="text-xs font-semibold text-blue-800">
|
|
{patientResult.firstName} {patientResult.lastName}
|
|
</p>
|
|
{patientResult.insuranceProvider && (
|
|
<p className="text-xs text-blue-600">{patientResult.insuranceProvider}</p>
|
|
)}
|
|
{patientResult.insuranceId && (
|
|
<p className="text-xs text-gray-500">ID: {patientResult.insuranceId}</p>
|
|
)}
|
|
{patientResult.dateOfBirth && (
|
|
<p className="text-xs text-gray-500">DOB: {patientResult.dateOfBirth}</p>
|
|
)}
|
|
<div className="flex gap-2 pt-1">
|
|
{patientResult.insuranceId && (
|
|
<Button
|
|
size="sm"
|
|
className="flex-1 h-8 text-xs bg-primary hover:bg-primary/90"
|
|
onClick={handleEligibilityFromPatient}
|
|
>
|
|
<Stethoscope className="h-3 w-3 mr-1" />
|
|
Check Eligibility
|
|
</Button>
|
|
)}
|
|
<Button size="sm" variant="ghost" className="h-8 text-xs" onClick={reset}>
|
|
Done
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div ref={messagesEndRef} />
|
|
</div>
|
|
|
|
{/* Persistent free-text input */}
|
|
{showFreeTextInput && (
|
|
<div className="shrink-0 border-t bg-white px-3 py-2">
|
|
<div className="flex items-end gap-2">
|
|
<textarea
|
|
ref={freeTextRef}
|
|
rows={2}
|
|
placeholder='e.g. "check MARIA GONZALES" or "open claims"'
|
|
value={freeTextInput}
|
|
onChange={(e) => setFreeTextInput(e.target.value)}
|
|
onKeyDown={handleFreeTextKeyDown}
|
|
disabled={step === "ai-loading"}
|
|
className="flex-1 resize-none rounded-lg border border-gray-200 bg-gray-50 px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring disabled:opacity-50"
|
|
/>
|
|
<Button
|
|
size="sm"
|
|
className="h-9 w-9 p-0 shrink-0"
|
|
onClick={handleFreeTextSubmit}
|
|
disabled={!freeTextInput.trim() || step === "ai-loading"}
|
|
>
|
|
{step === "ai-loading" ? (
|
|
<Loader2 className="h-4 w-4 animate-spin" />
|
|
) : (
|
|
<Send className="h-4 w-4" />
|
|
)}
|
|
</Button>
|
|
</div>
|
|
<p className="text-[10px] text-gray-400 mt-1 pl-1">Enter to send · Shift+Enter for new line</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</>
|
|
)}
|
|
</>
|
|
);
|
|
}
|