feat: add internal AI chat assistant and AI Input Agent page

- 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>
This commit is contained in:
ff
2026-06-02 00:44:05 -04:00
co-authored by Claude Sonnet 4.6
parent 3ac185b0ec
commit be90966f6e
8 changed files with 707 additions and 101 deletions
@@ -0,0 +1,69 @@
import { ChatGoogleGenerativeAI } from "@langchain/google-genai";
export type InternalChatIntent =
| "check_eligibility"
| "find_patient"
| "navigate_claims"
| "navigate_schedule"
| "general";
export interface ChatClassification {
intent: InternalChatIntent;
patientName?: string;
fallbackReply: string;
}
const BASE_SYSTEM_PROMPT = `You are an internal assistant for a dental office management system.
Staff members type natural language commands and you classify what they want.
Respond ONLY with valid JSON (no markdown, no code fences) in this exact format:
{
"intent": "<one of the intents below>",
"patientName": "<full name if patient is mentioned, otherwise omit>",
"fallbackReply": "<a short, helpful reply to show the user>"
}
Intents:
- check_eligibility: user wants to check insurance eligibility for a patient (e.g. "check MARIA", "verify insurance for John Smith", "eligibility GONZALES")
- find_patient: user wants to look up a patient record only (e.g. "find patient John", "look up Smith", "show me GONZALES record")
- navigate_claims: user wants to open the claims page
- navigate_schedule: user wants to open the appointments/schedule page
- general: anything else — answer helpfully based on dental office context
Rules:
- Extract the full patient name as-is from the message for check_eligibility and find_patient
- Keep fallbackReply to 1-2 sentences max
- For navigate intents, fallbackReply should say "Opening the [page] page..."`;
export async function classifyInternalChat(
message: string,
apiKey: string,
extraSystemPrompt?: string
): Promise<ChatClassification> {
const fallback: ChatClassification = {
intent: "general",
fallbackReply: "I can help you search for a patient, check eligibility, or navigate to claims or appointments.",
};
if (!apiKey) return fallback;
const systemPrompt = extraSystemPrompt
? `${BASE_SYSTEM_PROMPT}\n\nAdditional office context:\n${extraSystemPrompt}`
: BASE_SYSTEM_PROMPT;
try {
const llm = new ChatGoogleGenerativeAI({ model: "gemini-1.5-flash", apiKey });
const response = await llm.invoke([
{ role: "system", content: systemPrompt },
{ role: "user", content: message },
]);
const raw = String(response.content).trim();
const jsonStr = raw.replace(/^```json\s*/i, "").replace(/```\s*$/, "").trim();
const parsed = JSON.parse(jsonStr) as ChatClassification;
if (!parsed.intent || !parsed.fallbackReply) return fallback;
return parsed;
} catch {
return fallback;
}
}
+149
View File
@@ -1,5 +1,6 @@
import express, { Request, Response } from "express";
import { storage } from "../storage";
import { classifyInternalChat } from "../ai/internal-chat-graph";
const router = express.Router();
@@ -97,4 +98,152 @@ router.put("/chat-templates", async (req: Request, res: Response): Promise<any>
}
});
// GET /api/ai/internal-chat-settings
router.get("/internal-chat-settings", async (req: Request, res: Response): Promise<any> => {
try {
const userId = req.user?.id;
if (!userId) return res.status(401).json({ message: "Unauthorized" });
const systemPrompt = await storage.getInternalChatSystemPrompt(userId);
return res.status(200).json({ systemPrompt });
} catch (err) {
return res.status(500).json({ error: "Failed to fetch internal chat settings", details: String(err) });
}
});
// PUT /api/ai/internal-chat-settings
router.put("/internal-chat-settings", async (req: Request, res: Response): Promise<any> => {
try {
const userId = req.user?.id;
if (!userId) return res.status(401).json({ message: "Unauthorized" });
const { systemPrompt } = req.body;
if (typeof systemPrompt !== "string") return res.status(400).json({ message: "systemPrompt must be a string" });
await storage.saveInternalChatSystemPrompt(userId, systemPrompt.trim());
return res.status(200).json({ systemPrompt: systemPrompt.trim() });
} catch (err) {
return res.status(500).json({ error: "Failed to save internal chat settings", details: String(err) });
}
});
// POST /api/ai/internal-chat
router.post("/internal-chat", async (req: Request, res: Response): Promise<any> => {
try {
const userId = req.user?.id;
if (!userId) return res.status(401).json({ message: "Unauthorized" });
const { message } = req.body;
if (!message?.trim()) return res.status(400).json({ message: "message is required" });
const aiSettings = await storage.getAiSettings(userId);
if (!aiSettings?.apiKey) {
return res.status(200).json({
reply: "AI is not configured. Please add a Google AI API key in Settings.",
});
}
const extraSystemPrompt = await storage.getInternalChatSystemPrompt(userId);
const classification = await classifyInternalChat(message.trim(), aiSettings.apiKey, extraSystemPrompt || undefined);
// Handle navigation intents immediately
if (classification.intent === "navigate_claims") {
return res.status(200).json({ reply: classification.fallbackReply, action: "navigate", actionData: { url: "/claims" } });
}
if (classification.intent === "navigate_schedule") {
return res.status(200).json({ reply: classification.fallbackReply, action: "navigate", actionData: { url: "/appointments" } });
}
// Handle patient intents — search DB
if (classification.intent === "check_eligibility" || classification.intent === "find_patient") {
const name = classification.patientName?.trim();
if (!name) {
return res.status(200).json({ reply: "Please include the patient's name in your message." });
}
const patients = await storage.searchPatients({
filters: {
OR: [
{ firstName: { contains: name, mode: "insensitive" } },
{ lastName: { contains: name, mode: "insensitive" } },
{
AND: name.split(/\s+/).map((part: string) => ({
OR: [
{ firstName: { contains: part, mode: "insensitive" } },
{ lastName: { contains: part, mode: "insensitive" } },
],
})),
},
],
},
limit: 5,
offset: 0,
});
if (!patients || patients.length === 0) {
return res.status(200).json({
reply: `No patient found matching "${name}". Try a different spelling or search on the Patients page.`,
});
}
const patient = patients[0]!;
const fullName = `${patient.firstName ?? ""} ${patient.lastName ?? ""}`.trim();
if (classification.intent === "find_patient") {
const ins = patient.insuranceProvider ? ` · ${patient.insuranceProvider}` : "";
const id = patient.insuranceId ? ` (ID: ${patient.insuranceId})` : "";
return res.status(200).json({
reply: `Found: ${fullName}${ins}${id}`,
action: "show_patient",
actionData: {
patient: {
id: patient.id,
firstName: patient.firstName,
lastName: patient.lastName,
insuranceId: patient.insuranceId ?? null,
insuranceProvider: patient.insuranceProvider ?? null,
dateOfBirth: patient.dateOfBirth ? patient.dateOfBirth.toISOString().split("T")[0] : null,
},
},
});
}
// check_eligibility
if (!patient.insuranceId) {
return res.status(200).json({
reply: `Found ${fullName} but no Member ID is on file. Please add their insurance info first.`,
action: "show_patient",
actionData: {
patient: {
id: patient.id,
firstName: patient.firstName,
lastName: patient.lastName,
insuranceId: null,
insuranceProvider: patient.insuranceProvider ?? null,
dateOfBirth: patient.dateOfBirth ? patient.dateOfBirth.toISOString().split("T")[0] : null,
},
},
});
}
return res.status(200).json({
reply: `Found ${fullName}. Ready to check eligibility.`,
action: "check_eligibility_prefill",
actionData: {
patient: {
id: patient.id,
firstName: patient.firstName,
lastName: patient.lastName,
insuranceId: patient.insuranceId,
insuranceProvider: patient.insuranceProvider ?? null,
dateOfBirth: patient.dateOfBirth ? patient.dateOfBirth.toISOString().split("T")[0] : null,
},
},
});
}
// General intent — return Gemini's reply
return res.status(200).json({ reply: classification.fallbackReply });
} catch (err) {
return res.status(500).json({ error: "Internal chat error", details: String(err) });
}
});
export default router;
@@ -111,6 +111,23 @@ export const twilioStorage = {
});
},
async getInternalChatSystemPrompt(userId: number): Promise<string> {
const settings = await db.twilioSettings.findUnique({ where: { userId } });
const all = (settings?.templates as Record<string, string>) || {};
return all["_internal_chat_system_prompt"] ?? "";
},
async saveInternalChatSystemPrompt(userId: number, prompt: string): Promise<void> {
const settings = await db.twilioSettings.findUnique({ where: { userId } });
const existing = (settings?.templates as Record<string, string>) || {};
const updated = { ...existing, "_internal_chat_system_prompt": prompt };
await db.twilioSettings.upsert({
where: { userId },
update: { templates: updated },
create: { userId, accountSid: "", authToken: "", phoneNumber: "", templates: updated },
});
},
async getRecentCommunicationsByUser(userId: number, limit = 20) {
return db.communication.findMany({
where: { patient: { userId } },
+2
View File
@@ -29,6 +29,7 @@ const ReportsPage = lazy(() => import("./pages/reports-page"));
const CloudStoragePage = lazy(() => import("./pages/cloud-storage-page"));
const JobMonitorPage = lazy(() => import("./pages/job-monitor-page"));
const ChartPage = lazy(() => import("./pages/chart-page"));
const AiInputAgentPage = lazy(() => import("./pages/ai-input-agent-page"));
const DentalShoppingSearchTagPage = lazy(() => import("./pages/dental-shopping-search-tag-page"));
const DentalShoppingLoginInfoPage = lazy(() => import("./pages/dental-shopping-login-info-page"));
const ActivationPage = lazy(() => import("./pages/activation-page"));
@@ -64,6 +65,7 @@ function Router() {
/>
<ProtectedRoute path="/reports" component={() => <ReportsPage />} />
<ProtectedRoute path="/cloud-storage" component={() => <CloudStoragePage />} />
<ProtectedRoute path="/ai-input-agent" component={() => <AiInputAgentPage />} />
<ProtectedRoute path="/dental-shopping/search-tag" component={() => <DentalShoppingSearchTagPage />} />
<ProtectedRoute path="/dental-shopping/login-info" component={() => <DentalShoppingLoginInfoPage />} />
<ProtectedRoute path="/activation" component={() => <ActivationPage />} adminOnly />
+197 -100
View File
@@ -7,29 +7,47 @@ import {
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";
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; // MM/DD/YYYY display format
dobISO: string; // YYYY-MM-DD for storage
dob: string;
dobISO: string;
}
let msgCounter = 0;
function makeMsg(role: "bot" | "user", text: string): Message {
return { id: ++msgCounter, role, text };
function makeMsg(role: "bot" | "user", text: string, isLoading = false): Message {
return { id: ++msgCounter, role, text, isLoading };
}
function getAutoCheck(dobISO: string): "mh" | "cmsp" {
@@ -44,7 +62,6 @@ function getAutoCheck(dobISO: string): "mh" | "cmsp" {
function parseEligibilityInput(
raw: string
): { memberId: string; display: string; iso: string } | null {
// Find a date anywhere in the text (MM/DD/YYYY or MM-DD-YYYY)
const dateMatch = raw.match(/\b(\d{1,2})[\/\-](\d{1,2})[\/\-](\d{4})\b/);
if (!dateMatch) return null;
const m = dateMatch[1];
@@ -56,7 +73,6 @@ function parseEligibilityInput(
const year = parseInt(y, 10);
if (month < 1 || month > 12 || day < 1 || day > 31 || year < 1900) return null;
// Remove the matched date, then collect remaining alphanumeric chars as member ID
const withoutDate = raw.replace(dateMatch[0], "");
const memberId = (withoutDate.match(/[a-zA-Z0-9]/g) ?? []).join("");
if (!memberId) return null;
@@ -78,11 +94,13 @@ export function ChatbotButton() {
const [messages, setMessages] = useState<Message[]>(INITIAL_MESSAGES);
const [pasteInput, setPasteInput] = useState("");
const [parseError, setParseError] = useState("");
const [eligibilityData, setEligibilityData] =
useState<EligibilityData | null>(null);
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" });
@@ -92,10 +110,21 @@ export function ChatbotButton() {
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) =>
setMessages((prev) => [...prev, makeMsg(role, text)]);
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");
@@ -103,6 +132,8 @@ export function ChatbotButton() {
setPasteInput("");
setParseError("");
setEligibilityData(null);
setFreeTextInput("");
setPatientResult(null);
};
const handleClose = () => {
@@ -110,31 +141,15 @@ export function ChatbotButton() {
reset();
};
const handleOptionSelect = (
option: "eligibility" | "schedule" | "claims" | "chat"
) => {
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);
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 === "chat") {
addMsg("user", "General chat");
addMsg(
"bot",
"This feature is coming soon! For now, use the quick options below to navigate."
);
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:");
@@ -145,25 +160,14 @@ export function ChatbotButton() {
const handleEligibilitySubmit = () => {
const parsed = parseEligibilityInput(pasteInput);
if (!parsed) {
setParseError(
"Couldn't find both a Member ID and a date. Try: 123456789 01/15/1985"
);
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,
};
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?`
);
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");
};
@@ -171,22 +175,74 @@ export function ChatbotButton() {
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),
})
);
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);
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
@@ -202,11 +258,7 @@ export function ChatbotButton() {
{open && (
<>
{/* Backdrop */}
<div
className="fixed inset-0 z-40"
onClick={handleClose}
aria-hidden
/>
<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">
@@ -225,15 +277,12 @@ export function ChatbotButton() {
</button>
</div>
{/* Messages + controls */}
{/* 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"
)}
className={cn("flex", msg.role === "user" ? "justify-end" : "justify-start")}
>
<div
className={cn(
@@ -243,12 +292,19 @@ export function ChatbotButton() {
: "bg-gray-100 text-gray-800 rounded-tl-sm"
)}
>
{msg.text}
{msg.isLoading ? (
<span className="flex items-center gap-1.5">
<Loader2 className="h-3 w-3 animate-spin" />
Thinking...
</span>
) : (
msg.text
)}
</div>
</div>
))}
{/* Step: menu */}
{/* Quick-action buttons (menu step) */}
{step === "menu" && (
<div className="grid grid-cols-2 gap-2 pt-1">
<button
@@ -267,44 +323,30 @@ export function ChatbotButton() {
</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"
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>
<button
onClick={() => handleOptionSelect("chat")}
className="flex items-center gap-2 p-3 rounded-xl border border-gray-300 hover:bg-gray-50 text-sm text-left transition-colors"
>
<MessageSquare className="h-4 w-4 text-gray-500 shrink-0" />
<span>Chat</span>
</button>
</div>
)}
{/* Step: eligibility input */}
{/* 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>
<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("");
}}
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>
)}
{parseError && <p className="text-xs text-red-500">{parseError}</p>}
<div className="flex gap-2">
<Button
size="sm"
@@ -314,26 +356,17 @@ export function ChatbotButton() {
>
Continue <ChevronRight className="h-3 w-3 ml-1" />
</Button>
<Button
size="sm"
variant="ghost"
className="h-8 text-xs"
onClick={reset}
>
<Button size="sm" variant="ghost" className="h-8 text-xs" onClick={reset}>
Cancel
</Button>
</div>
</div>
)}
{/* Step: confirm */}
{/* Eligibility confirm */}
{step === "eligibility-confirm" && (
<div className="flex gap-2">
<Button
size="sm"
className="flex-1 h-8 text-xs"
onClick={handleConfirm}
>
<Button size="sm" className="flex-1 h-8 text-xs" onClick={handleConfirm}>
Yes, Check Now
</Button>
<Button
@@ -350,8 +383,72 @@ export function ChatbotButton() {
</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>
</>
)}
@@ -169,6 +169,11 @@ export function Sidebar() {
path: "/cloud-storage",
icon: <Cloud className="h-5 w-5 text-sky-500" />,
},
{
name: "AI Input Agent",
path: "/ai-input-agent",
icon: <Bot className="h-5 w-5 text-violet-500" />,
},
{
name: "AI Dental Shopping",
path: "/dental-shopping",
@@ -6,7 +6,7 @@ import { Textarea } from "@/components/ui/textarea";
import { Switch } from "@/components/ui/switch";
import { useToast } from "@/hooks/use-toast";
import { apiRequest, queryClient } from "@/lib/queryClient";
import { Bot, CalendarCheck, UserPlus, MessageCircle, Info, GitFork, MessageSquare, Trash2, Plus, Zap } from "lucide-react";
import { Bot, CalendarCheck, UserPlus, MessageCircle, Info, GitFork, MessageSquare, Trash2, Plus, Zap, SlidersHorizontal } from "lucide-react";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
// ─── Types ────────────────────────────────────────────────────────────────────
@@ -609,6 +609,133 @@ function NewPatientFlow() {
);
}
// ─── Users AI Chat (internal staff assistant) config ─────────────────────────
const INTERNAL_CHAT_PLACEHOLDER =
`Examples of custom context you can add:
• "This office primarily treats MassHealth patients — always prefer MassHealth eligibility checks."
• "When a patient name is typed, also show their upcoming appointment if available."
• "Default provider is Mary Scannell."`;
function InternalChatSettingsCard() {
const { toast } = useToast();
const [systemPrompt, setSystemPrompt] = useState("");
const initialized = useRef(false);
const { data, isLoading } = useQuery<{ systemPrompt: string }>({
queryKey: ["/api/ai/internal-chat-settings"],
queryFn: async () => {
const res = await apiRequest("GET", "/api/ai/internal-chat-settings");
if (!res.ok) return { systemPrompt: "" };
return res.json();
},
staleTime: Infinity,
refetchOnWindowFocus: false,
});
useEffect(() => {
if (data && !initialized.current) {
initialized.current = true;
setSystemPrompt(data.systemPrompt ?? "");
}
}, [data]);
const saveMutation = useMutation({
mutationFn: async (prompt: string) => {
const res = await apiRequest("PUT", "/api/ai/internal-chat-settings", { systemPrompt: prompt });
if (!res.ok) throw new Error("Failed to save");
return res.json();
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["/api/ai/internal-chat-settings"] });
toast({ title: "Users AI Chat settings saved" });
},
onError: () => {
toast({ title: "Error", description: "Failed to save settings.", variant: "destructive" });
},
});
return (
<Card>
<CardContent className="py-6 space-y-5">
<div className="flex items-center gap-2">
<SlidersHorizontal className="h-5 w-5 text-primary" />
<h3 className="text-lg font-semibold">Users AI Chat</h3>
</div>
<p className="text-sm text-muted-foreground">
Configure the AI assistant available to staff via the{" "}
<span className="font-medium text-foreground">bot icon</span> in the top-right corner.
Staff can type natural language commands such as{" "}
<code className="bg-muted px-1 py-0.5 rounded text-xs font-mono">"check MARIA GONZALES"</code>{" "}
to search for a patient and pre-fill their eligibility check, or{" "}
<code className="bg-muted px-1 py-0.5 rounded text-xs font-mono">"open claims"</code>{" "}
to navigate directly to a page.
</p>
{/* Capability summary */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 text-xs">
{[
{ icon: "🔍", label: "Patient search", desc: 'e.g. "find GONZALES"' },
{ icon: "🏥", label: "Eligibility prefill", desc: 'e.g. "check MARIA DE LA CRUZ"' },
{ icon: "🗺️", label: "Navigation", desc: 'e.g. "open claims", "schedule"' },
].map((c) => (
<div key={c.label} className="flex items-start gap-2 rounded-lg border bg-muted/30 p-3">
<span className="text-base leading-none">{c.icon}</span>
<div>
<p className="font-medium text-foreground">{c.label}</p>
<p className="text-muted-foreground mt-0.5">{c.desc}</p>
</div>
</div>
))}
</div>
{/* Additional context / system prompt */}
<div className="space-y-1.5">
<div className="flex items-center gap-2">
<Bot className="h-4 w-4 text-primary" />
<span className="text-sm font-medium">Additional AI Context</span>
</div>
<p className="text-xs text-muted-foreground">
Optional instructions injected into the AI's system prompt. Use this to describe your
practice's preferences, common shortcuts, or patient demographics.
</p>
{isLoading ? (
<p className="text-sm text-muted-foreground">Loading...</p>
) : (
<Textarea
value={systemPrompt}
onChange={(e) => setSystemPrompt(e.target.value)}
placeholder={INTERNAL_CHAT_PLACEHOLDER}
rows={5}
className="text-sm resize-none"
/>
)}
</div>
<div className="flex items-center gap-3 pt-1">
<Button
disabled={saveMutation.isPending || isLoading}
className="bg-teal-600 hover:bg-teal-700 text-white"
onClick={() => saveMutation.mutate(systemPrompt)}
>
{saveMutation.isPending ? "Saving..." : "Save"}
</Button>
{systemPrompt && (
<Button
variant="ghost"
className="text-xs text-muted-foreground"
onClick={() => setSystemPrompt("")}
>
Clear
</Button>
)}
</div>
</CardContent>
</Card>
);
}
// ─── Main component ───────────────────────────────────────────────────────────
export function AiChatSettingsCard() {
@@ -1068,6 +1195,9 @@ export function AiChatSettingsCard() {
</CardContent>
</Card>
{/* ── Section 3: Users AI Chat ─────────────────────────────── */}
<InternalChatSettingsCard />
</div>
);
}
@@ -0,0 +1,137 @@
import { Bot, Keyboard, FileCheck, CreditCard, Shield, Zap, ArrowRight } from "lucide-react";
import { Card, CardContent } from "@/components/ui/card";
const SOFTWARE_TARGETS = [
{ name: "Open Dental", color: "bg-blue-100 text-blue-700 border-blue-200" },
{ name: "Eaglesoft", color: "bg-emerald-100 text-emerald-700 border-emerald-200" },
{ name: "Dentrix", color: "bg-violet-100 text-violet-700 border-violet-200" },
{ name: "Curve Dental", color: "bg-orange-100 text-orange-700 border-orange-200" },
];
const CAPABILITIES = [
{
icon: <Shield className="h-5 w-5 text-teal-600" />,
title: "Eligibility Results",
description:
"Automatically type insurance eligibility information — coverage status, plan details, deductibles, and co-pays — directly into the patient chart in your dental software.",
},
{
icon: <FileCheck className="h-5 w-5 text-blue-600" />,
title: "Claim Information",
description:
"Transfer claim numbers, claim status updates, and denial reasons from the insurance portal results into your claims module without manual re-entry.",
},
{
icon: <CreditCard className="h-5 w-5 text-indigo-600" />,
title: "Insurance Payments",
description:
"Post ERA / EOB payment details — amounts, adjustments, patient responsibility — directly into your payment ledger by typing them into the active software window.",
},
];
export default function AiInputAgentPage() {
return (
<div className="max-w-3xl mx-auto px-4 py-10 space-y-10">
{/* Hero */}
<div className="text-center space-y-3">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-violet-100 mb-2">
<Bot className="h-8 w-8 text-violet-600" />
</div>
<h1 className="text-3xl font-bold tracking-tight">AI Input Agent</h1>
<p className="text-muted-foreground text-base max-w-xl mx-auto">
An AI-powered agent that reads data from this app and types it directly into your
current dental management software — no copy-paste, no manual re-entry.
</p>
<span className="inline-block bg-amber-100 text-amber-700 text-xs font-semibold px-3 py-1 rounded-full border border-amber-200">
Coming Soon
</span>
</div>
{/* How it works */}
<Card>
<CardContent className="py-6 space-y-4">
<div className="flex items-center gap-2">
<Keyboard className="h-5 w-5 text-violet-500" />
<h2 className="text-base font-semibold">How it works</h2>
</div>
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-3 text-sm">
{[
"Retrieve data in this app\n(eligibility, claim, payment)",
"Agent identifies the active\nfield in your dental software",
"Types the value directly\nvia keyboard automation",
].map((step, i) => (
<div key={i} className="flex items-center gap-3 flex-1">
<div className="flex-shrink-0 w-7 h-7 rounded-full bg-violet-100 text-violet-700 text-xs font-bold flex items-center justify-center">
{i + 1}
</div>
<p className="whitespace-pre-line text-muted-foreground leading-snug">{step}</p>
{i < 2 && <ArrowRight className="h-4 w-4 text-muted-foreground/40 hidden sm:block flex-shrink-0" />}
</div>
))}
</div>
<p className="text-xs text-muted-foreground pt-1 border-t">
The agent runs as a lightweight local process on your workstation. It receives
instructions from this app and uses keyboard automation to type data into whatever
window is currently focused in your dental software — no clipboard involved.
</p>
</CardContent>
</Card>
{/* What it will type */}
<div className="space-y-3">
<h2 className="text-base font-semibold">What it will type</h2>
<div className="grid gap-4 sm:grid-cols-3">
{CAPABILITIES.map((cap) => (
<Card key={cap.title}>
<CardContent className="py-5 space-y-2">
<div className="flex items-center gap-2">
{cap.icon}
<span className="text-sm font-medium">{cap.title}</span>
</div>
<p className="text-xs text-muted-foreground leading-relaxed">
{cap.description}
</p>
</CardContent>
</Card>
))}
</div>
</div>
{/* Supported software */}
<div className="space-y-3">
<h2 className="text-base font-semibold">Planned software support</h2>
<div className="flex flex-wrap gap-2">
{SOFTWARE_TARGETS.map((s) => (
<span
key={s.name}
className={`text-sm font-medium px-3 py-1.5 rounded-lg border ${s.color}`}
>
{s.name}
</span>
))}
</div>
<p className="text-xs text-muted-foreground">
Each software has its own field layout. The agent will include pre-built templates
for common workflows in each platform.
</p>
</div>
{/* Why */}
<Card className="border-violet-200 bg-violet-50/50">
<CardContent className="py-5 flex items-start gap-3">
<Zap className="h-5 w-5 text-violet-500 mt-0.5 flex-shrink-0" />
<div className="space-y-1">
<p className="text-sm font-medium text-violet-900">Why this matters</p>
<p className="text-sm text-violet-700 leading-relaxed">
Staff currently check eligibility or look up a claim here, then manually retype
every value into Open Dental or Eaglesoft. This agent eliminates that step entirely —
one click sends the data straight into the right field.
</p>
</div>
</CardContent>
</Card>
</div>
);
}