mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage-server
synced 2026-04-11 18:08:16 +00:00
feat: widget chat with generative UI, branch selection, captcha gate, lead dedup
- Streaming AI chat via Vercel AI SDK v6 UI message stream — tool-based
generative UI (pick_branch, list_departments, show_clinic_timings,
show_doctors, show_doctor_slots, suggest_booking). Typing indicator,
markdown suppressed, text parts hidden when widgets are rendered.
- Centralized Preact store (store.tsx) for visitor, leadId, captchaToken,
bookingPrefill, doctors roster, branches, selectedBranch — replaces prop
drilling across chat/book/contact tabs.
- Cloudflare Turnstile captcha gate rendered via light-DOM portal so it
renders correctly inside the shadow DOM (Turnstile CSS doesn't cross
shadow boundaries).
- Lead dedup helper (findOrCreateLeadByPhone, 24h phone window) shared
across chat-start / book / contact so one visitor == one lead. Booking
upgrades existing lead status NEW → APPOINTMENT_SET via updateLeadStatus.
- Pre-chat name+phone form captures the visitor; chat transcript logged
to leadActivity records after each stream.
- Booking wizard gains a branch step 0 (skipped for single-branch
hospitals); departments + doctors filtered by selectedBranch. Chat slot
picks prefill the booking details step and lock the branch.
- Window-level captcha gate, modal maximize mode, header badge showing
selected branch, widget font inherits from host page (fix :host { all:
initial } override).
- 23 FA Pro 7.1 duotone icons bundled — medical departments, nav, actions,
hospital/location-dot for branch context.
- main.ts: resolve public/ from process.cwd() so widget.js serves in both
dev and prod. tsconfig: exclude widget-src/public/data from server tsc.
- captcha.guard: switch from reCAPTCHA v3 to Cloudflare Turnstile verify.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,18 +1,85 @@
|
||||
import { useState, useRef, useEffect } from 'preact/hooks';
|
||||
import { streamChat } from './api';
|
||||
import type { ChatMessage } from './types';
|
||||
import { startChatSession, streamChat } from './api';
|
||||
import { readChatStream } from './chat-stream';
|
||||
import { IconSpan } from './icon-span';
|
||||
import { ChatToolWidget } from './chat-widgets';
|
||||
import { useWidgetStore } from './store';
|
||||
import type {
|
||||
BookingPrefill,
|
||||
ChatMessage,
|
||||
ChatPart,
|
||||
ChatTextPart,
|
||||
ChatToolPart,
|
||||
} from './types';
|
||||
|
||||
const QUICK_ACTIONS = [
|
||||
'Doctor availability',
|
||||
'What departments do you have?',
|
||||
'Show me cardiologists',
|
||||
'Clinic timings',
|
||||
'Book appointment',
|
||||
'Health packages',
|
||||
'How do I book?',
|
||||
];
|
||||
|
||||
export const Chat = () => {
|
||||
type ChatProps = {
|
||||
// Switches the widget to the Book tab. Chat-level handler that lives in
|
||||
// the parent so slot picks can seed bookingPrefill + swap tabs atomically.
|
||||
onRequestBooking: (prefill?: BookingPrefill) => void;
|
||||
};
|
||||
|
||||
const textOf = (msg: ChatMessage): string =>
|
||||
msg.parts
|
||||
.filter((p): p is ChatTextPart => p.type === 'text')
|
||||
.map(p => p.text)
|
||||
.join('');
|
||||
|
||||
const updateMessage = (
|
||||
setMessages: (updater: (msgs: ChatMessage[]) => ChatMessage[]) => void,
|
||||
id: string,
|
||||
mutator: (msg: ChatMessage) => ChatMessage,
|
||||
) => {
|
||||
setMessages(prev => prev.map(m => (m.id === id ? mutator(m) : m)));
|
||||
};
|
||||
|
||||
const appendTextDelta = (parts: ChatPart[], delta: string, state: 'streaming' | 'done'): ChatPart[] => {
|
||||
const last = parts[parts.length - 1];
|
||||
if (last?.type === 'text') {
|
||||
return [...parts.slice(0, -1), { type: 'text', text: last.text + delta, state }];
|
||||
}
|
||||
return [...parts, { type: 'text', text: delta, state }];
|
||||
};
|
||||
|
||||
const upsertToolPart = (
|
||||
parts: ChatPart[],
|
||||
toolCallId: string,
|
||||
update: Partial<ChatToolPart>,
|
||||
fallback: ChatToolPart,
|
||||
): ChatPart[] => {
|
||||
const idx = parts.findIndex(p => p.type === 'tool' && p.toolCallId === toolCallId);
|
||||
if (idx >= 0) {
|
||||
const existing = parts[idx] as ChatToolPart;
|
||||
const merged: ChatToolPart = { ...existing, ...update };
|
||||
return [...parts.slice(0, idx), merged, ...parts.slice(idx + 1)];
|
||||
}
|
||||
return [...parts, { ...fallback, ...update }];
|
||||
};
|
||||
|
||||
const genId = () => `m_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
||||
|
||||
export const Chat = ({ onRequestBooking }: ChatProps) => {
|
||||
const {
|
||||
visitor,
|
||||
updateVisitor,
|
||||
leadId,
|
||||
setLeadId,
|
||||
setBookingPrefill,
|
||||
selectedBranch,
|
||||
setSelectedBranch,
|
||||
} = useWidgetStore();
|
||||
|
||||
const [messages, setMessages] = useState<ChatMessage[]>([]);
|
||||
const [input, setInput] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [formSubmitting, setFormSubmitting] = useState(false);
|
||||
const [formError, setFormError] = useState('');
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -21,59 +88,263 @@ export const Chat = () => {
|
||||
}
|
||||
}, [messages]);
|
||||
|
||||
const sendMessage = async (text: string) => {
|
||||
if (!text.trim() || loading) return;
|
||||
const submitLeadForm = async () => {
|
||||
const name = visitor.name.trim();
|
||||
const phone = visitor.phone.trim();
|
||||
if (!name || !phone) return;
|
||||
setFormSubmitting(true);
|
||||
setFormError('');
|
||||
try {
|
||||
const { leadId: newLeadId } = await startChatSession(name, phone);
|
||||
setLeadId(newLeadId);
|
||||
} catch {
|
||||
setFormError('Could not start chat. Please try again.');
|
||||
} finally {
|
||||
setFormSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const userMsg: ChatMessage = { role: 'user', content: text.trim() };
|
||||
const updated = [...messages, userMsg];
|
||||
setMessages(updated);
|
||||
const sendMessage = async (text: string, branchOverride?: string | null) => {
|
||||
if (!text.trim() || loading || !leadId) return;
|
||||
|
||||
const userMsg: ChatMessage = {
|
||||
id: genId(),
|
||||
role: 'user',
|
||||
parts: [{ type: 'text', text: text.trim(), state: 'done' }],
|
||||
};
|
||||
const assistantId = genId();
|
||||
const assistantMsg: ChatMessage = { id: assistantId, role: 'assistant', parts: [] };
|
||||
|
||||
const historyForBackend = [...messages, userMsg].map(m => ({
|
||||
role: m.role,
|
||||
content: textOf(m),
|
||||
}));
|
||||
|
||||
setMessages(prev => [...prev, userMsg, assistantMsg]);
|
||||
setInput('');
|
||||
setLoading(true);
|
||||
|
||||
// Branch can be provided explicitly to bypass the stale closure value
|
||||
// when the caller just set it (e.g., handleBranchPick immediately after
|
||||
// setSelectedBranch).
|
||||
const effectiveBranch =
|
||||
branchOverride !== undefined ? branchOverride : selectedBranch;
|
||||
|
||||
try {
|
||||
const stream = await streamChat(updated);
|
||||
const reader = stream.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let assistantText = '';
|
||||
const stream = await streamChat(leadId, historyForBackend, effectiveBranch);
|
||||
|
||||
setMessages([...updated, { role: 'assistant', content: '' }]);
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
assistantText += decoder.decode(value, { stream: true });
|
||||
setMessages([...updated, { role: 'assistant', content: assistantText }]);
|
||||
for await (const chunk of readChatStream(stream)) {
|
||||
switch (chunk.type) {
|
||||
case 'text-delta':
|
||||
if (typeof chunk.delta === 'string') {
|
||||
updateMessage(setMessages, assistantId, m => ({
|
||||
...m,
|
||||
parts: appendTextDelta(m.parts, chunk.delta, 'streaming'),
|
||||
}));
|
||||
}
|
||||
break;
|
||||
case 'text-end':
|
||||
updateMessage(setMessages, assistantId, m => ({
|
||||
...m,
|
||||
parts: m.parts.map(p =>
|
||||
p.type === 'text' ? { ...p, state: 'done' } : p,
|
||||
),
|
||||
}));
|
||||
break;
|
||||
case 'tool-input-start':
|
||||
updateMessage(setMessages, assistantId, m => ({
|
||||
...m,
|
||||
parts: upsertToolPart(
|
||||
m.parts,
|
||||
chunk.toolCallId,
|
||||
{ state: 'input-streaming', toolName: chunk.toolName },
|
||||
{
|
||||
type: 'tool',
|
||||
toolCallId: chunk.toolCallId,
|
||||
toolName: chunk.toolName,
|
||||
state: 'input-streaming',
|
||||
},
|
||||
),
|
||||
}));
|
||||
break;
|
||||
case 'tool-input-available':
|
||||
updateMessage(setMessages, assistantId, m => ({
|
||||
...m,
|
||||
parts: upsertToolPart(
|
||||
m.parts,
|
||||
chunk.toolCallId,
|
||||
{ state: 'input-available', toolName: chunk.toolName, input: chunk.input },
|
||||
{
|
||||
type: 'tool',
|
||||
toolCallId: chunk.toolCallId,
|
||||
toolName: chunk.toolName,
|
||||
state: 'input-available',
|
||||
input: chunk.input,
|
||||
},
|
||||
),
|
||||
}));
|
||||
break;
|
||||
case 'tool-output-available':
|
||||
updateMessage(setMessages, assistantId, m => ({
|
||||
...m,
|
||||
parts: upsertToolPart(
|
||||
m.parts,
|
||||
chunk.toolCallId,
|
||||
{ state: 'output-available', output: chunk.output },
|
||||
{
|
||||
type: 'tool',
|
||||
toolCallId: chunk.toolCallId,
|
||||
toolName: 'unknown',
|
||||
state: 'output-available',
|
||||
output: chunk.output,
|
||||
},
|
||||
),
|
||||
}));
|
||||
break;
|
||||
case 'tool-output-error':
|
||||
updateMessage(setMessages, assistantId, m => ({
|
||||
...m,
|
||||
parts: upsertToolPart(
|
||||
m.parts,
|
||||
chunk.toolCallId,
|
||||
{ state: 'output-error', errorText: chunk.errorText },
|
||||
{
|
||||
type: 'tool',
|
||||
toolCallId: chunk.toolCallId,
|
||||
toolName: 'unknown',
|
||||
state: 'output-error',
|
||||
errorText: chunk.errorText,
|
||||
},
|
||||
),
|
||||
}));
|
||||
break;
|
||||
case 'error':
|
||||
updateMessage(setMessages, assistantId, m => ({
|
||||
...m,
|
||||
parts: [
|
||||
...m.parts,
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Sorry, I encountered an error. Please try again.',
|
||||
state: 'done',
|
||||
},
|
||||
],
|
||||
}));
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
setMessages([...updated, { role: 'assistant', content: 'Sorry, I encountered an error. Please try again.' }]);
|
||||
updateMessage(setMessages, assistantId, m => ({
|
||||
...m,
|
||||
parts: [
|
||||
...m.parts,
|
||||
{
|
||||
type: 'text',
|
||||
text: 'Sorry, I encountered an error. Please try again.',
|
||||
state: 'done',
|
||||
},
|
||||
],
|
||||
}));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSlotPick = (prefill: BookingPrefill) => {
|
||||
setBookingPrefill(prefill);
|
||||
onRequestBooking(prefill);
|
||||
};
|
||||
|
||||
const handleBranchPick = (branch: string) => {
|
||||
// Store the selection so every subsequent request carries it, then
|
||||
// echo the visitor's choice as a user message so the AI re-runs the
|
||||
// branch-gated tool it was about to call. We pass branch explicitly
|
||||
// to sidestep the stale-closure selectedBranch inside sendMessage.
|
||||
setSelectedBranch(branch);
|
||||
sendMessage(`I'm interested in the ${branch} branch.`, branch);
|
||||
};
|
||||
|
||||
// Pre-chat gate — only shown if we don't yet have an active lead. Name/phone
|
||||
// inputs bind to the shared store so anything typed here is immediately
|
||||
// available to the Book and Contact forms too.
|
||||
if (!leadId) {
|
||||
return (
|
||||
<div class="chat-intro">
|
||||
<div class="chat-empty-icon">
|
||||
<IconSpan name="hand-wave" size={40} color="#f59e0b" />
|
||||
</div>
|
||||
<div class="chat-empty-title">Hi! How can we help?</div>
|
||||
<div class="chat-empty-text">
|
||||
Share your name and phone so we can follow up if needed.
|
||||
</div>
|
||||
{formError && <div class="widget-error">{formError}</div>}
|
||||
<div class="widget-field">
|
||||
<label class="widget-label">Full Name *</label>
|
||||
<input
|
||||
class="widget-input"
|
||||
placeholder="Your name"
|
||||
value={visitor.name}
|
||||
onInput={(e: any) => updateVisitor({ name: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div class="widget-field">
|
||||
<label class="widget-label">Phone Number *</label>
|
||||
<input
|
||||
class="widget-input"
|
||||
placeholder="+91 9876543210"
|
||||
value={visitor.phone}
|
||||
onInput={(e: any) => updateVisitor({ phone: e.target.value })}
|
||||
onKeyDown={(e: any) => e.key === 'Enter' && submitLeadForm()}
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
class="widget-btn"
|
||||
onClick={submitLeadForm}
|
||||
disabled={!visitor.name.trim() || !visitor.phone.trim() || formSubmitting}
|
||||
>
|
||||
{formSubmitting ? 'Starting…' : 'Start Chat'}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
|
||||
<div class="chat-messages" ref={scrollRef}>
|
||||
{messages.length === 0 && (
|
||||
<div style={{ textAlign: 'center', padding: '20px 0' }}>
|
||||
<div style={{ fontSize: '24px', marginBottom: '8px' }}>👋</div>
|
||||
<div style={{ fontSize: '14px', fontWeight: 600, color: '#1f2937', marginBottom: '4px' }}>
|
||||
How can we help you?
|
||||
<div class="chat-empty">
|
||||
<div class="chat-empty-icon">
|
||||
<IconSpan name="hand-wave" size={40} color="#f59e0b" />
|
||||
</div>
|
||||
<div style={{ fontSize: '12px', color: '#6b7280', marginBottom: '16px' }}>
|
||||
<div class="chat-empty-title">
|
||||
Hi {visitor.name.split(' ')[0] || 'there'}, how can we help?
|
||||
</div>
|
||||
<div class="chat-empty-text">
|
||||
Ask about doctors, clinics, packages, or book an appointment.
|
||||
</div>
|
||||
<div class="quick-actions">
|
||||
{QUICK_ACTIONS.map(q => (
|
||||
<button key={q} class="quick-action" onClick={() => sendMessage(q)}>{q}</button>
|
||||
<button key={q} class="quick-action" onClick={() => sendMessage(q)}>
|
||||
{q}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{messages.map((msg, i) => (
|
||||
<div key={i} class={`chat-msg ${msg.role}`}>
|
||||
<div class="chat-bubble">{msg.content || '...'}</div>
|
||||
</div>
|
||||
{messages.map(msg => (
|
||||
<MessageRow
|
||||
key={msg.id}
|
||||
msg={msg}
|
||||
onDepartmentClick={dept => sendMessage(`Show me doctors in ${dept}`)}
|
||||
onShowDoctorSlots={doctorName =>
|
||||
sendMessage(`Show available appointments for ${doctorName}`)
|
||||
}
|
||||
onSuggestBooking={() => onRequestBooking()}
|
||||
onPickSlot={handleSlotPick}
|
||||
onPickBranch={handleBranchPick}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div class="chat-input-row">
|
||||
@@ -85,10 +356,80 @@ export const Chat = () => {
|
||||
onKeyDown={(e: any) => e.key === 'Enter' && sendMessage(input)}
|
||||
disabled={loading}
|
||||
/>
|
||||
<button class="chat-send" onClick={() => sendMessage(input)} disabled={loading || !input.trim()}>
|
||||
↑
|
||||
<button
|
||||
class="chat-send"
|
||||
onClick={() => sendMessage(input)}
|
||||
disabled={loading || !input.trim()}
|
||||
aria-label="Send message"
|
||||
>
|
||||
<IconSpan name="paper-plane-top" size={16} color="#fff" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type MessageRowProps = {
|
||||
msg: ChatMessage;
|
||||
onDepartmentClick: (dept: string) => void;
|
||||
onShowDoctorSlots: (doctorName: string) => void;
|
||||
onSuggestBooking: () => void;
|
||||
onPickSlot: (prefill: BookingPrefill) => void;
|
||||
onPickBranch: (branch: string) => void;
|
||||
};
|
||||
|
||||
const MessageRow = ({
|
||||
msg,
|
||||
onDepartmentClick,
|
||||
onShowDoctorSlots,
|
||||
onSuggestBooking,
|
||||
onPickSlot,
|
||||
onPickBranch,
|
||||
}: MessageRowProps) => {
|
||||
const isEmptyAssistant = msg.role === 'assistant' && msg.parts.length === 0;
|
||||
|
||||
// If any tool parts exist, hide text parts from the same turn to avoid
|
||||
// models restating the widget's contents in prose.
|
||||
const hasToolParts = msg.parts.some(p => p.type === 'tool');
|
||||
const visibleParts = hasToolParts
|
||||
? msg.parts.filter(p => p.type === 'tool')
|
||||
: msg.parts;
|
||||
|
||||
return (
|
||||
<div class={`chat-msg ${msg.role}`}>
|
||||
<div class="chat-msg-stack">
|
||||
{isEmptyAssistant && (
|
||||
<div class="chat-bubble">
|
||||
<TypingDots />
|
||||
</div>
|
||||
)}
|
||||
{visibleParts.map((part, i) => {
|
||||
if (part.type === 'text') {
|
||||
return (
|
||||
<div key={i} class="chat-bubble">
|
||||
{part.text || <TypingDots />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<ChatToolWidget
|
||||
key={i}
|
||||
part={part}
|
||||
onDepartmentClick={onDepartmentClick}
|
||||
onShowDoctorSlots={onShowDoctorSlots}
|
||||
onSuggestBooking={onSuggestBooking}
|
||||
onPickSlot={onPickSlot}
|
||||
onPickBranch={onPickBranch}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const TypingDots = () => (
|
||||
<span class="chat-typing-dots" aria-label="Assistant is typing">
|
||||
<span /><span /><span />
|
||||
</span>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user