Files
helix-engage/src/components/call-desk/ai-chat-panel.tsx
saridsa2 9d09662f16 feat(maint+ai): OTP modal agent picker + supervisor AI quick actions
Maint shortcuts (Unlock Agent / Force Ready) used to read agentId from the CC-agent's localStorage config — supervisors had no such config and the endpoint 400'd. New flow: after OTP passes, modal calls /api/maint/session-status and renders a two-bucket picker (Locked selectable / Free informational 'Already free'). Orphan locks surface with an explicit label.

- use-maint-shortcuts: agentPickerEndpoint flag on forceReady + unlockAgent
- maint-otp-modal: two-phase — OTP gate, then picker, then submit; OTP
  held in state across phases so the operator doesn't re-enter it

AI chat panel: supervisor context now shows supervisor-appropriate quick actions (Agent performance / Call summary / Campaign stats / Who needs attention?) that map 1:1 to the supervisor tool set on the sidecar. Agent flow keeps the theme-token quick actions (doctors/clinics/packages).
2026-04-15 18:56:34 +05:30

230 lines
10 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;
}
// Supervisor has different quick-action prompts than the CC agent — they
// ask about team metrics, not patient / doctor info. Hardcoded here rather
// than in theme tokens because the prompts map 1:1 to the supervisor tool
// set in ai-chat.controller.ts (get_agent_performance, get_call_summary,
// get_campaign_stats) — changing the tools means changing these prompts.
const SUPERVISOR_QUICK_ACTIONS = [
{ label: 'Agent performance', prompt: 'Show me agent performance this week.' },
{ label: 'Call summary', prompt: 'Summarize call activity this week.' },
{ label: 'Campaign stats', prompt: 'How are the campaigns performing?' },
{ label: 'Who needs attention?', prompt: 'Which agents are underperforming or need attention?' },
];
const SUPERVISOR_INTRO = 'Ask me about agent performance, call trends, or campaign stats.';
export const AiChatPanel = ({ callerContext, onChatStart }: AiChatPanelProps) => {
const { tokens } = useThemeTokens();
const isSupervisor = callerContext?.type === 'supervisor';
const quickActions = isSupervisor ? SUPERVISOR_QUICK_ACTIONS : tokens.ai.quickActions;
const introText = isSupervisor ? SUPERVISOR_INTRO : 'Ask me about doctors, clinics, packages, or patient info.';
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) or
// the call ends (leadId clears), 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.
const autoFiredForLeadRef = useRef<string | null>(null);
useEffect(() => {
const leadId = callerContext?.leadId ?? null;
// Call ended or no caller — wipe the panel so the next caller's
// context doesn't bleed over and the agent isn't staring at a stale
// summary in the worklist view between calls.
if (!leadId) {
if (autoFiredForLeadRef.current !== null) {
autoFiredForLeadRef.current = null;
setMessages([]);
chatStartedRef.current = false;
}
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">
{introText}
</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>
);
};