mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage
synced 2026-05-18 20:08:19 +00:00
- Mute persists across calls: sip-manager's "ended/failed" branch now resets the Recoil sipIsMutedAtom + sipIsOnHoldAtom (previously only the SIP track was unmuted, leaving the UI icon + toggle logic in a muted state that the next call inherited). - Telephony-unavailable dial pad: call-desk.tsx dial-pad "Call" button was missing an isRegistered check in its disabled prop, so it stayed clickable when SIP was down. Button now shows "Telephony unavailable" and is disabled. - Past dates in Follow-up: enquiry-form's follow-up date input had no min constraint. Switched to a raw <input type="date"> with min set to today's ISO date. - Returning-patient AI summary during call: ai-chat-panel now auto-fires a "give me a quick summary of <caller>" request whenever the caller's leadId changes (new incoming call). Clears prior chat state so each caller starts fresh. - Remove Type column in Patients page (Badge import also pruned). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
202 lines
9.1 KiB
TypeScript
202 lines
9.1 KiB
TypeScript
import type { ReactNode } from 'react';
|
|
import { useRef, useEffect } from 'react';
|
|
import { useThemeTokens } from '@/providers/theme-token-provider';
|
|
import { useChat } from '@ai-sdk/react';
|
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
|
import { faPaperPlaneTop, faSparkles, faUserHeadset } from '@fortawesome/pro-duotone-svg-icons';
|
|
|
|
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:4100';
|
|
|
|
type CallerContext = {
|
|
type?: string;
|
|
callerPhone?: string;
|
|
leadId?: string;
|
|
leadName?: string;
|
|
};
|
|
|
|
interface AiChatPanelProps {
|
|
callerContext?: CallerContext;
|
|
onChatStart?: () => void;
|
|
}
|
|
|
|
export const AiChatPanel = ({ callerContext, onChatStart }: AiChatPanelProps) => {
|
|
const { tokens } = useThemeTokens();
|
|
const quickActions = tokens.ai.quickActions;
|
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
|
const chatStartedRef = useRef(false);
|
|
|
|
const token = localStorage.getItem('helix_access_token') ?? '';
|
|
|
|
const { messages, input, handleSubmit, handleInputChange, isLoading, append, setMessages } = useChat({
|
|
api: `${API_URL}/api/ai/stream`,
|
|
streamProtocol: 'text',
|
|
headers: {
|
|
'Authorization': `Bearer ${token}`,
|
|
},
|
|
body: {
|
|
context: callerContext,
|
|
},
|
|
});
|
|
|
|
useEffect(() => {
|
|
const el = messagesEndRef.current;
|
|
if (el?.parentElement) {
|
|
el.parentElement.scrollTop = el.parentElement.scrollHeight;
|
|
}
|
|
if (messages.length > 0 && !chatStartedRef.current) {
|
|
chatStartedRef.current = true;
|
|
onChatStart?.();
|
|
}
|
|
}, [messages, onChatStart]);
|
|
|
|
// Auto-fire a patient-summary request when a caller with a leadId appears
|
|
// on the panel. Resets whenever the caller changes (new incoming call) so
|
|
// each call starts fresh. The sidecar's AI agent inspects the leadId and
|
|
// replies with appointment/disposition/notes history when the caller is
|
|
// a returning patient, or a brief "net-new caller" ack otherwise.
|
|
const autoFiredForLeadRef = useRef<string | null>(null);
|
|
useEffect(() => {
|
|
const leadId = callerContext?.leadId ?? null;
|
|
if (!leadId) return;
|
|
if (autoFiredForLeadRef.current === leadId) return;
|
|
|
|
// New caller — clear any prior chat state and fire the summary prompt.
|
|
autoFiredForLeadRef.current = leadId;
|
|
setMessages([]);
|
|
chatStartedRef.current = false;
|
|
const name = callerContext?.leadName ?? 'this caller';
|
|
append({
|
|
role: 'user',
|
|
content: `Give me a quick summary of ${name} — prior appointments, last disposition, any outstanding notes. If net-new, say so.`,
|
|
});
|
|
}, [callerContext?.leadId, callerContext?.leadName, append, setMessages]);
|
|
|
|
const handleQuickAction = (prompt: string) => {
|
|
append({ role: 'user', content: prompt });
|
|
};
|
|
|
|
return (
|
|
<div className="flex h-full flex-col p-3">
|
|
<div className="flex-1 space-y-3 overflow-y-auto min-h-0">
|
|
{messages.length === 0 && (
|
|
<div className="flex flex-col items-center justify-center py-6 text-center">
|
|
<FontAwesomeIcon icon={faSparkles} className="mb-2 size-6 text-fg-brand-primary" />
|
|
<p className="text-xs text-tertiary">
|
|
Ask me about doctors, clinics, packages, or patient info.
|
|
</p>
|
|
<div className="mt-3 flex flex-wrap justify-center gap-1.5">
|
|
{quickActions.map((action) => (
|
|
<button
|
|
key={action.label}
|
|
onClick={() => handleQuickAction(action.prompt)}
|
|
disabled={isLoading}
|
|
className="rounded-lg border border-secondary bg-primary px-2.5 py-1.5 text-xs font-medium text-secondary transition duration-100 ease-linear hover:bg-secondary hover:text-primary disabled:opacity-50"
|
|
>
|
|
{action.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{messages.map((msg) => (
|
|
<div
|
|
key={msg.id}
|
|
className={`flex ${msg.role === 'user' ? 'justify-end' : 'justify-start'}`}
|
|
>
|
|
<div
|
|
className={`max-w-[90%] rounded-xl px-3 py-2 text-xs leading-relaxed ${
|
|
msg.role === 'user'
|
|
? 'bg-brand-solid text-white'
|
|
: 'bg-secondary text-primary'
|
|
}`}
|
|
>
|
|
{msg.role === 'assistant' && (
|
|
<div className="mb-1 flex items-center gap-1">
|
|
<FontAwesomeIcon icon={faSparkles} className="size-2.5 text-fg-brand-primary" />
|
|
<span className="text-[10px] font-semibold uppercase tracking-wider text-brand-secondary">AI</span>
|
|
</div>
|
|
)}
|
|
<MessageContent content={msg.content} />
|
|
</div>
|
|
</div>
|
|
))}
|
|
|
|
{isLoading && (
|
|
<div className="flex justify-start">
|
|
<div className="rounded-xl bg-secondary px-3 py-2">
|
|
<div className="flex items-center gap-1">
|
|
<span className="size-1.5 animate-bounce rounded-full bg-fg-quaternary [animation-delay:0ms]" />
|
|
<span className="size-1.5 animate-bounce rounded-full bg-fg-quaternary [animation-delay:150ms]" />
|
|
<span className="size-1.5 animate-bounce rounded-full bg-fg-quaternary [animation-delay:300ms]" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
<div ref={messagesEndRef} />
|
|
</div>
|
|
|
|
<form onSubmit={handleSubmit} className="mt-2 flex items-center gap-2 shrink-0">
|
|
<div className="flex flex-1 items-center rounded-lg border border-secondary bg-primary shadow-xs transition duration-100 ease-linear focus-within:border-brand focus-within:ring-4 focus-within:ring-brand-100">
|
|
<FontAwesomeIcon icon={faUserHeadset} className="ml-2.5 size-3.5 text-fg-quaternary" />
|
|
<input
|
|
type="text"
|
|
value={input}
|
|
onChange={handleInputChange}
|
|
placeholder="Ask the AI assistant..."
|
|
disabled={isLoading}
|
|
className="flex-1 bg-transparent px-2 py-2 text-xs text-primary placeholder:text-placeholder outline-none disabled:cursor-not-allowed"
|
|
/>
|
|
</div>
|
|
<button
|
|
type="submit"
|
|
disabled={isLoading || input.trim().length === 0}
|
|
className="flex size-8 shrink-0 items-center justify-center rounded-lg bg-brand-solid text-white transition duration-100 ease-linear hover:bg-brand-solid_hover disabled:cursor-not-allowed disabled:bg-disabled"
|
|
>
|
|
<FontAwesomeIcon icon={faPaperPlaneTop} className="size-3.5" />
|
|
</button>
|
|
</form>
|
|
</div>
|
|
);
|
|
};
|
|
// Tool result cards will be added in Phase 2 when SDK versions are aligned for data stream protocol
|
|
|
|
const parseLine = (text: string): ReactNode[] => {
|
|
const parts: ReactNode[] = [];
|
|
const boldPattern = /\*\*(.+?)\*\*/g;
|
|
let lastIndex = 0;
|
|
let match: RegExpExecArray | null;
|
|
|
|
while ((match = boldPattern.exec(text)) !== null) {
|
|
if (match.index > lastIndex) parts.push(text.slice(lastIndex, match.index));
|
|
parts.push(<strong key={match.index} className="font-semibold">{match[1]}</strong>);
|
|
lastIndex = boldPattern.lastIndex;
|
|
}
|
|
|
|
if (lastIndex < text.length) parts.push(text.slice(lastIndex));
|
|
return parts.length > 0 ? parts : [text];
|
|
};
|
|
|
|
const MessageContent = ({ content }: { content: string }) => {
|
|
if (!content) return null;
|
|
const lines = content.split('\n');
|
|
|
|
return (
|
|
<div className="space-y-1">
|
|
{lines.map((line, i) => {
|
|
if (line.trim().length === 0) return <div key={i} className="h-1" />;
|
|
if (line.trimStart().startsWith('- ')) {
|
|
return (
|
|
<div key={i} className="flex gap-1.5 pl-1">
|
|
<span className="mt-1.5 size-1 shrink-0 rounded-full bg-fg-quaternary" />
|
|
<span>{parseLine(line.replace(/^\s*-\s*/, ''))}</span>
|
|
</div>
|
|
);
|
|
}
|
|
return <p key={i}>{parseLine(line)}</p>;
|
|
})}
|
|
</div>
|
|
);
|
|
};
|