feat: add AI-driven phone calls to patient connection
Adds an "AI Call" option alongside Call/SMS/Chat that places an outbound call where Lisa converses live with the patient via Twilio speech gather + TTS, using the same conversational AI as chat. Includes a separate, user-editable Call Template (Settings > AI Chat/Call Settings) so the call greeting can be customized independently of SMS templates. Also fixes the outbound-call webhook URL to always use the fixed public Twilio hostname (CLOUDFLARE_HOST) instead of deriving it from the triggering request, since staff-browser requests arrive over the LAN-only hostname, which Twilio can never reach. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -311,7 +311,7 @@ export function Sidebar() {
|
||||
icon: <Bot className="h-4 w-4 text-gray-400" />,
|
||||
},
|
||||
{
|
||||
name: "AI Chat Settings",
|
||||
name: "AI Chat/Call Settings",
|
||||
path: "/settings/aichat",
|
||||
icon: <Bot className="h-4 w-4 text-gray-400" />,
|
||||
},
|
||||
|
||||
@@ -7,7 +7,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, SlidersHorizontal, BookMarked, ChevronDown, ChevronUp } from "lucide-react";
|
||||
import { Bot, CalendarCheck, UserPlus, MessageCircle, Info, GitFork, MessageSquare, Trash2, Plus, Zap, SlidersHorizontal, BookMarked, ChevronDown, ChevronUp, PhoneCall } from "lucide-react";
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
@@ -18,6 +18,7 @@ type AiChatTemplates = {
|
||||
reminderGreeting: string;
|
||||
newPatientGreeting: string;
|
||||
generalFallback: string;
|
||||
callTemplate: string;
|
||||
};
|
||||
|
||||
type OfficeContact = {
|
||||
@@ -36,6 +37,8 @@ const DEFAULTS = {
|
||||
"Hi! My name is Lisa, the dedicated AI assistant at {officeName}. I can help you schedule an appointment, check your insurance, and answer general questions 24/7. How can I help you today?",
|
||||
generalFallback:
|
||||
"Hi! My name is Lisa, the dedicated AI assistant at {officeName}. How can I help you today?",
|
||||
callTemplate:
|
||||
"Hi {firstName}, this is Lisa, the AI assistant at {officeName}. How can I help you today?",
|
||||
};
|
||||
|
||||
const DEFAULT_SMS_TEMPLATES = [
|
||||
@@ -1009,6 +1012,7 @@ export function AiChatSettingsCard() {
|
||||
const [reminderGreeting, setReminderGreeting] = useState(DEFAULTS.reminderGreeting);
|
||||
const [newPatientGreeting, setNewPatientGreeting] = useState(DEFAULTS.newPatientGreeting);
|
||||
const [generalFallback, setGeneralFallback] = useState(DEFAULTS.generalFallback);
|
||||
const [callTemplate, setCallTemplate] = useState(DEFAULTS.callTemplate);
|
||||
const initialized = useRef(false);
|
||||
|
||||
const [openPhoneReply, setOpenPhoneReply] = useState(false);
|
||||
@@ -1087,6 +1091,7 @@ export function AiChatSettingsCard() {
|
||||
setReminderGreeting(templates.reminderGreeting || DEFAULTS.reminderGreeting);
|
||||
setNewPatientGreeting(templates.newPatientGreeting || DEFAULTS.newPatientGreeting);
|
||||
setGeneralFallback(templates.generalFallback || DEFAULTS.generalFallback);
|
||||
setCallTemplate(templates.callTemplate || DEFAULTS.callTemplate);
|
||||
}
|
||||
}, [templates]);
|
||||
|
||||
@@ -1144,6 +1149,7 @@ export function AiChatSettingsCard() {
|
||||
reminderGreeting: reminderGreeting.trim() || DEFAULTS.reminderGreeting,
|
||||
newPatientGreeting: newPatientGreeting.trim() || DEFAULTS.newPatientGreeting,
|
||||
generalFallback: generalFallback.trim() || DEFAULTS.generalFallback,
|
||||
callTemplate: callTemplate.trim() || DEFAULTS.callTemplate,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1191,6 +1197,15 @@ export function AiChatSettingsCard() {
|
||||
onChange: setGeneralFallback,
|
||||
placeholder: DEFAULTS.generalFallback,
|
||||
},
|
||||
{
|
||||
key: "call",
|
||||
icon: <PhoneCall className="h-4 w-4 text-primary" />,
|
||||
label: "Call Template",
|
||||
description: "Lisa's opening line when placing an AI Call to a patient. Separate from the SMS chat templates above — use {firstName} and {officeName} as placeholders.",
|
||||
value: callTemplate,
|
||||
onChange: setCallTemplate,
|
||||
placeholder: DEFAULTS.callTemplate,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
@@ -1393,6 +1408,7 @@ export function AiChatSettingsCard() {
|
||||
setReminderGreeting(DEFAULTS.reminderGreeting);
|
||||
setNewPatientGreeting(DEFAULTS.newPatientGreeting);
|
||||
setGeneralFallback(DEFAULTS.generalFallback);
|
||||
setCallTemplate(DEFAULTS.callTemplate);
|
||||
}}
|
||||
>
|
||||
Reset to defaults
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
Send,
|
||||
X,
|
||||
MoonStar,
|
||||
Bot,
|
||||
} from "lucide-react";
|
||||
import { SmsTemplateDialog } from "@/components/patient-connection/sms-template-diaog";
|
||||
import { MessageThread } from "@/components/patient-connection/message-thread";
|
||||
@@ -103,6 +104,32 @@ export default function PatientConnectionPage() {
|
||||
},
|
||||
});
|
||||
|
||||
const makeAiCallMutation = useMutation({
|
||||
mutationFn: async ({
|
||||
to,
|
||||
patientId,
|
||||
}: {
|
||||
to: string;
|
||||
patientId: number;
|
||||
}) => {
|
||||
return apiRequest("POST", "/api/twilio/make-ai-call", { to, patientId });
|
||||
},
|
||||
onSuccess: () => {
|
||||
toast({
|
||||
title: "AI Call Started",
|
||||
description: "Lisa is calling the patient now.",
|
||||
});
|
||||
},
|
||||
onError: (error: any) => {
|
||||
toast({
|
||||
title: "AI Call Failed",
|
||||
description:
|
||||
error.message || "Unable to place the AI call. Please try again.",
|
||||
variant: "destructive",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// Fetch all patients from database
|
||||
const { data: patients = [], isLoading } = useQuery<Patient[]>({
|
||||
queryKey: ["/api/patients"],
|
||||
@@ -139,6 +166,22 @@ export default function PatientConnectionPage() {
|
||||
});
|
||||
};
|
||||
|
||||
// Handle AI-driven call via Twilio
|
||||
const handleAiCall = (patient: Patient) => {
|
||||
if (!patient.phone?.trim()) {
|
||||
toast({
|
||||
title: "No Phone Number",
|
||||
description: "This patient does not have a phone number on file.",
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
makeAiCallMutation.mutate({
|
||||
to: patient.phone,
|
||||
patientId: Number(patient.id),
|
||||
});
|
||||
};
|
||||
|
||||
// Handle sending SMS
|
||||
const handleSMS = (patient: Patient) => {
|
||||
setSelectedPatient(patient);
|
||||
@@ -343,6 +386,15 @@ export default function PatientConnectionPage() {
|
||||
<Phone className="h-4 w-4 mr-1" />
|
||||
Call
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleAiCall(patient)}
|
||||
data-testid={`button-ai-call-${patient.id}`}
|
||||
>
|
||||
<Bot className="h-4 w-4 mr-1" />
|
||||
AI Call
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
|
||||
Reference in New Issue
Block a user