wip: AI chat streaming endpoint + useChat integration (protocol mismatch)

- Server: POST /api/ai/stream using streamText with tools (lookup_patient, lookup_appointments, lookup_doctor)
- Frontend: AiChatPanel rewritten with @ai-sdk/react useChat hook
- Tool result cards: PatientCard, AppointmentCard, DoctorCard
- Streaming works server-side but useChat v1 doesn't parse AI SDK v6 toTextStreamResponse format
- Context panel layout needs redesign — context section fills entire panel, chat pushed below fold
- TODO: Fix streaming protocol, redesign panel layout with collapsible context

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-03-26 10:27:24 +05:30
parent 48ed300094
commit 0477064b3e
4 changed files with 286 additions and 148 deletions

View File

@@ -1,15 +1,10 @@
import type { ReactNode } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useRef, useEffect } from 'react';
import { useChat } from '@ai-sdk/react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faPaperPlaneTop, faRobot, faSparkles, faUserHeadset } from '@fortawesome/pro-duotone-svg-icons';
import { apiClient } from '@/lib/api-client';
import { faPaperPlaneTop, faSparkles, faUserHeadset, faUser, faCalendarCheck, faStethoscope } from '@fortawesome/pro-duotone-svg-icons';
type ChatMessage = {
id: string;
role: 'user' | 'assistant';
content: string;
timestamp: Date;
};
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:4100';
type CallerContext = {
callerPhone?: string;
@@ -19,133 +14,62 @@ type CallerContext = {
interface AiChatPanelProps {
callerContext?: CallerContext;
role?: 'cc-agent' | 'admin' | 'executive';
}
const QUICK_ASK_AGENT = [
{ label: 'Doctor availability', template: 'What are the visiting hours for all doctors?' },
{ label: 'Clinic timings', template: 'What are the clinic locations and timings?' },
{ label: 'Patient history', template: 'Can you summarize this patient\'s history?' },
{ label: 'Treatment packages', template: 'What treatment packages are available?' },
const QUICK_ACTIONS = [
{ label: 'Doctor availability', prompt: 'What doctors are available and what are their visiting hours?' },
{ label: 'Clinic timings', prompt: 'What are the clinic locations and timings?' },
{ label: 'Patient history', prompt: 'Can you summarize this patient\'s history?' },
{ label: 'Treatment packages', prompt: 'What treatment packages are available?' },
];
const QUICK_ASK_MANAGER = [
{ label: 'Agent performance', template: 'Which agents have the highest appointment conversion rates this week?' },
{ label: 'Missed call risks', template: 'Which missed calls have been waiting the longest without a callback?' },
{ label: 'Pending leads', template: 'How many leads are still pending first contact?' },
{ label: 'Weekly summary', template: 'Give me a summary of this week\'s team performance — total calls, conversions, missed calls.' },
];
export const AiChatPanel = ({ callerContext, role = 'cc-agent' }: AiChatPanelProps) => {
const quickButtons = role === 'admin' ? QUICK_ASK_MANAGER : QUICK_ASK_AGENT;
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [input, setInput] = useState('');
const [isLoading, setIsLoading] = useState(false);
export const AiChatPanel = ({ callerContext }: AiChatPanelProps) => {
const messagesEndRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const scrollToBottom = useCallback(() => {
// Scroll within the messages container only — don't scroll the parent panel
const token = localStorage.getItem('helix_access_token') ?? '';
const { messages, input, handleSubmit, handleInputChange, isLoading, append } = useChat({
api: `${API_URL}/api/ai/stream`,
headers: {
'Authorization': `Bearer ${token}`,
},
body: {
context: callerContext,
},
});
useEffect(() => {
const el = messagesEndRef.current;
if (el?.parentElement) {
el.parentElement.scrollTop = el.parentElement.scrollHeight;
}
}, []);
}, [messages]);
useEffect(() => {
scrollToBottom();
}, [messages, scrollToBottom]);
const sendMessage = useCallback(async (text?: string) => {
const messageText = (text ?? input).trim();
if (messageText.length === 0 || isLoading) return;
const userMessage: ChatMessage = {
id: `user-${Date.now()}`,
role: 'user',
content: messageText,
timestamp: new Date(),
};
setMessages((prev) => [...prev, userMessage]);
setInput('');
setIsLoading(true);
try {
const data = await apiClient.post<{ reply: string; sources?: string[] }>('/api/ai/chat', {
message: messageText,
context: callerContext,
});
const assistantMessage: ChatMessage = {
id: `assistant-${Date.now()}`,
role: 'assistant',
content: data.reply ?? 'Sorry, I could not process that request.',
timestamp: new Date(),
};
setMessages((prev) => [...prev, assistantMessage]);
} catch {
const errorMessage: ChatMessage = {
id: `error-${Date.now()}`,
role: 'assistant',
content: 'Sorry, I\'m having trouble connecting to the AI service. Please try again.',
timestamp: new Date(),
};
setMessages((prev) => [...prev, errorMessage]);
} finally {
setIsLoading(false);
inputRef.current?.focus();
}
}, [input, isLoading, callerContext]);
const handleKeyDown = useCallback((e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendMessage();
}
}, [sendMessage]);
const handleQuickAsk = useCallback((template: string) => {
sendMessage(template);
}, [sendMessage]);
const handleQuickAction = (prompt: string) => {
append({ role: 'user', content: prompt });
};
return (
<div className="flex h-full flex-col">
{/* Caller context banner */}
{callerContext?.leadName && (
<div className="mb-3 rounded-lg bg-brand-primary px-3 py-2">
<span className="text-xs text-brand-secondary">
Talking to: <span className="font-semibold">{callerContext.leadName}</span>
{callerContext.callerPhone ? ` (${callerContext.callerPhone})` : ''}
</span>
</div>
)}
{/* Quick ask buttons */}
{messages.length === 0 && (
<div className="mb-3 flex flex-wrap gap-1.5">
{quickButtons.map((btn) => (
<button
key={btn.label}
onClick={() => handleQuickAsk(btn.template)}
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:cursor-not-allowed disabled:opacity-50"
>
{btn.label}
</button>
))}
</div>
)}
{/* Messages area */}
<div className="flex-1 space-y-3 overflow-y-auto">
<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-8 text-center">
<FontAwesomeIcon icon={faRobot} className="mb-3 size-8 text-fg-quaternary" />
<p className="text-sm text-tertiary">
<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">
{QUICK_ACTIONS.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>
)}
@@ -168,6 +92,10 @@ export const AiChatPanel = ({ callerContext, role = 'cc-agent' }: AiChatPanelPro
</div>
)}
<MessageContent content={msg.content} />
{msg.parts?.filter((p: any) => p.type === 'tool-invocation').map((part: any, i: number) => (
<ToolResultCard key={i} toolName={part.toolInvocation?.toolName} state={part.toolInvocation?.state} result={part.toolInvocation?.result} />
))}
</div>
</div>
))}
@@ -187,37 +115,121 @@ export const AiChatPanel = ({ callerContext, role = 'cc-agent' }: AiChatPanelPro
<div ref={messagesEndRef} />
</div>
{/* Input area */}
<div className="mt-3 flex items-center gap-2">
<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"
/>
<FontAwesomeIcon icon={faUserHeadset} className="ml-2.5 size-3.5 text-fg-quaternary" />
<input
ref={inputRef}
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
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
onClick={() => sendMessage()}
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>
</div>
</form>
</div>
);
};
// Parse simple markdown-like text into React nodes (safe, no innerHTML)
const ToolResultCard = ({ toolName, state, result }: { toolName: string; state: string; result: any }) => {
if (state !== 'result' || !result) return null;
switch (toolName) {
case 'lookup_patient':
if (!result.found) return null;
return (
<div className="mt-2 space-y-1.5">
{result.leads?.map((lead: any) => (
<div key={lead.id} className="rounded-lg border border-secondary bg-primary p-2">
<div className="flex items-center gap-1.5">
<FontAwesomeIcon icon={faUser} className="size-3 text-fg-brand-primary" />
<span className="text-xs font-semibold text-primary">
{lead.contactName?.firstName} {lead.contactName?.lastName}
</span>
{lead.status && (
<span className="ml-auto rounded-full bg-brand-primary px-1.5 py-0.5 text-[10px] font-medium text-brand-secondary">
{lead.status.replace(/_/g, ' ')}
</span>
)}
</div>
{lead.contactPhone?.primaryPhoneNumber && (
<p className="mt-0.5 text-[10px] text-tertiary">{lead.contactPhone.primaryPhoneNumber}</p>
)}
{lead.aiSummary && (
<p className="mt-1 text-[10px] text-secondary italic">{lead.aiSummary}</p>
)}
</div>
))}
</div>
);
case 'lookup_appointments':
if (!result.appointments?.length) return null;
return (
<div className="mt-2 space-y-1">
{result.appointments.map((appt: any) => (
<div key={appt.id} className="flex items-center gap-1.5 rounded-md border border-secondary bg-primary px-2 py-1.5">
<FontAwesomeIcon icon={faCalendarCheck} className="size-3 text-fg-brand-primary shrink-0" />
<div className="min-w-0 flex-1">
<span className="text-[10px] font-semibold text-primary">
{appt.doctorName ?? 'Doctor'} . {appt.department ?? ''}
</span>
<span className="ml-1 text-[10px] text-quaternary">
{appt.scheduledAt ? new Date(appt.scheduledAt).toLocaleDateString('en-IN', { day: 'numeric', month: 'short' }) : ''}
</span>
</div>
{appt.status && (
<span className="rounded-full bg-secondary px-1.5 py-0.5 text-[10px] font-medium text-secondary">
{appt.status.toLowerCase()}
</span>
)}
</div>
))}
</div>
);
case 'lookup_doctor':
if (!result.found) return null;
return (
<div className="mt-2 space-y-1">
{result.doctors?.map((doc: any) => (
<div key={doc.id} className="rounded-lg border border-secondary bg-primary p-2">
<div className="flex items-center gap-1.5">
<FontAwesomeIcon icon={faStethoscope} className="size-3 text-fg-success-primary" />
<span className="text-xs font-semibold text-primary">
Dr. {doc.fullName?.firstName} {doc.fullName?.lastName}
</span>
</div>
<p className="mt-0.5 text-[10px] text-tertiary">
{doc.department} . {doc.specialty}
</p>
{doc.visitingHours && (
<p className="text-[10px] text-secondary">Hours: {doc.visitingHours}</p>
)}
{doc.consultationFeeNew && (
<p className="text-[10px] text-secondary">
Fee: {'\u20B9'}{doc.consultationFeeNew.amountMicros / 1_000_000}
{doc.clinic?.clinicName ? ` . ${doc.clinic.clinicName}` : ''}
</p>
)}
</div>
))}
</div>
);
default:
return null;
}
};
const parseLine = (text: string): ReactNode[] => {
const parts: ReactNode[] = [];
const boldPattern = /\*\*(.+?)\*\*/g;
@@ -225,33 +237,23 @@ const parseLine = (text: string): ReactNode[] => {
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>,
);
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));
}
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" />;
// Bullet points
if (line.trimStart().startsWith('- ')) {
return (
<div key={i} className="flex gap-1.5 pl-1">
@@ -260,7 +262,6 @@ const MessageContent = ({ content }: { content: string }) => {
</div>
);
}
return <p key={i}>{parseLine(line)}</p>;
})}
</div>

View File

@@ -153,7 +153,7 @@ export const TeamDashboardPage = () => {
)}>
{aiOpen && (
<div className="flex h-full flex-col p-4">
<AiChatPanel role="admin" />
<AiChatPanel />
</div>
)}
</div>