From 0477064b3e5d6c23da29590d5e4e0fe95b36c6d6 Mon Sep 17 00:00:00 2001 From: saridsa2 Date: Thu, 26 Mar 2026 10:27:24 +0530 Subject: [PATCH] wip: AI chat streaming endpoint + useChat integration (protocol mismatch) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- package-lock.json | 136 ++++++++++ package.json | 1 + src/components/call-desk/ai-chat-panel.tsx | 295 +++++++++++---------- src/pages/team-dashboard.tsx | 2 +- 4 files changed, 286 insertions(+), 148 deletions(-) diff --git a/package-lock.json b/package-lock.json index d472371..02e4dc0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,7 @@ "name": "helix-engage", "version": "0.1.0", "dependencies": { + "@ai-sdk/react": "^1.2.12", "@fortawesome/fontawesome-svg-core": "^7.2.0", "@fortawesome/free-solid-svg-icons": "^7.2.0", "@fortawesome/pro-duotone-svg-icons": "^7.2.0", @@ -56,6 +57,76 @@ "vite": "^7.3.1" } }, + "node_modules/@ai-sdk/provider": { + "version": "1.1.3", + "resolved": "http://localhost:4873/@ai-sdk/provider/-/provider-1.1.3.tgz", + "integrity": "sha512-qZMxYJ0qqX/RfnuIaab+zp8UAeJn/ygXXAffR5I4N0n1IrvA6qBsjc8hXLmBiMV2zoXlifkacF7sEFnYnjBcqg==", + "license": "Apache-2.0", + "dependencies": { + "json-schema": "^0.4.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@ai-sdk/provider-utils": { + "version": "2.2.8", + "resolved": "http://localhost:4873/@ai-sdk/provider-utils/-/provider-utils-2.2.8.tgz", + "integrity": "sha512-fqhG+4sCVv8x7nFzYnFo19ryhAa3w096Kmc3hWxMQfW/TubPOmt3A6tYZhl4mUfQWWQMsuSkLrtjlWuXBVSGQA==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "1.1.3", + "nanoid": "^3.3.8", + "secure-json-parse": "^2.7.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.23.8" + } + }, + "node_modules/@ai-sdk/react": { + "version": "1.2.12", + "resolved": "http://localhost:4873/@ai-sdk/react/-/react-1.2.12.tgz", + "integrity": "sha512-jK1IZZ22evPZoQW3vlkZ7wvjYGYF+tRBKXtrcolduIkQ/m/sOAVcVeVDUDvh1T91xCnWCdUGCPZg2avZ90mv3g==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider-utils": "2.2.8", + "@ai-sdk/ui-utils": "1.2.11", + "swr": "^2.2.5", + "throttleit": "2.1.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^18 || ^19 || ^19.0.0-rc", + "zod": "^3.23.8" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, + "node_modules/@ai-sdk/ui-utils": { + "version": "1.2.11", + "resolved": "http://localhost:4873/@ai-sdk/ui-utils/-/ui-utils-1.2.11.tgz", + "integrity": "sha512-3zcwCc8ezzFlwp3ZD15wAPjf2Au4s3vAbKsXQVyhxODHcmu0iyPO2Eua6D/vicq/AUm/BAo60r97O6HU+EI0+w==", + "license": "Apache-2.0", + "dependencies": { + "@ai-sdk/provider": "1.1.3", + "@ai-sdk/provider-utils": "2.2.8", + "zod-to-json-schema": "^3.24.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "zod": "^3.23.8" + } + }, "node_modules/@alloc/quick-lru": { "version": "5.2.0", "resolved": "http://localhost:4873/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", @@ -4188,6 +4259,15 @@ "license": "MIT", "peer": true }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "http://localhost:4873/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "http://localhost:4873/detect-libc/-/detect-libc-2.1.2.tgz", @@ -4842,6 +4922,12 @@ "license": "MIT", "peer": true }, + "node_modules/json-schema": { + "version": "0.4.0", + "resolved": "http://localhost:4873/json-schema/-/json-schema-0.4.0.tgz", + "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==", + "license": "(AFL-2.1 OR BSD-3-Clause)" + }, "node_modules/json-schema-traverse": { "version": "0.4.1", "resolved": "http://localhost:4873/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", @@ -5876,6 +5962,12 @@ "sdp-verify": "checker.js" } }, + "node_modules/secure-json-parse": { + "version": "2.7.0", + "resolved": "http://localhost:4873/secure-json-parse/-/secure-json-parse-2.7.0.tgz", + "integrity": "sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw==", + "license": "BSD-3-Clause" + }, "node_modules/semver": { "version": "7.7.4", "resolved": "http://localhost:4873/semver/-/semver-7.7.4.tgz", @@ -5988,6 +6080,19 @@ "safe-buffer": "~5.1.0" } }, + "node_modules/swr": { + "version": "2.4.1", + "resolved": "http://localhost:4873/swr/-/swr-2.4.1.tgz", + "integrity": "sha512-2CC6CiKQtEwaEeNiqWTAw9PGykW8SR5zZX8MZk6TeAvEAnVS7Visz8WzphqgtQ8v2xz/4Q5K+j+SeMaKXeeQIA==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.3", + "use-sync-external-store": "^1.6.0" + }, + "peerDependencies": { + "react": "^16.11.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/tailwind-merge": { "version": "3.5.0", "resolved": "http://localhost:4873/tailwind-merge/-/tailwind-merge-3.5.0.tgz", @@ -6035,6 +6140,18 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/throttleit": { + "version": "2.1.0", + "resolved": "http://localhost:4873/throttleit/-/throttleit-2.1.0.tgz", + "integrity": "sha512-nt6AMGKW1p/70DF/hGBdJB57B8Tspmbp5gfJ8ilhLnt7kkr2ye7hzD6NVG8GGErk2HWF34igrL2CXmNIkzKqKw==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/tinyglobby": { "version": "0.2.15", "resolved": "http://localhost:4873/tinyglobby/-/tinyglobby-0.2.15.tgz", @@ -6300,6 +6417,25 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "http://localhost:4873/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "peer": true, + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.1", + "resolved": "http://localhost:4873/zod-to-json-schema/-/zod-to-json-schema-3.25.1.tgz", + "integrity": "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25 || ^4" + } + }, "node_modules/zrender": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/zrender/-/zrender-6.0.0.tgz", diff --git a/package.json b/package.json index f4daec1..8d8988d 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "preview": "vite preview" }, "dependencies": { + "@ai-sdk/react": "^1.2.12", "@fortawesome/fontawesome-svg-core": "^7.2.0", "@fortawesome/free-solid-svg-icons": "^7.2.0", "@fortawesome/pro-duotone-svg-icons": "^7.2.0", diff --git a/src/components/call-desk/ai-chat-panel.tsx b/src/components/call-desk/ai-chat-panel.tsx index 06da039..e235eb9 100644 --- a/src/components/call-desk/ai-chat-panel.tsx +++ b/src/components/call-desk/ai-chat-panel.tsx @@ -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([]); - const [input, setInput] = useState(''); - const [isLoading, setIsLoading] = useState(false); +export const AiChatPanel = ({ callerContext }: AiChatPanelProps) => { const messagesEndRef = useRef(null); - const inputRef = useRef(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 ( -
- {/* Caller context banner */} - {callerContext?.leadName && ( -
- - Talking to: {callerContext.leadName} - {callerContext.callerPhone ? ` (${callerContext.callerPhone})` : ''} - -
- )} - - {/* Quick ask buttons */} - {messages.length === 0 && ( -
- {quickButtons.map((btn) => ( - - ))} -
- )} - - {/* Messages area */} -
+
+
{messages.length === 0 && ( -
- -

+

+ +

Ask me about doctors, clinics, packages, or patient info.

+
+ {QUICK_ACTIONS.map((action) => ( + + ))} +
)} @@ -168,6 +92,10 @@ export const AiChatPanel = ({ callerContext, role = 'cc-agent' }: AiChatPanelPro
)} + + {msg.parts?.filter((p: any) => p.type === 'tool-invocation').map((part: any, i: number) => ( + + ))}
))} @@ -187,37 +115,121 @@ export const AiChatPanel = ({ callerContext, role = 'cc-agent' }: AiChatPanelPro
- {/* Input area */} -
+
- + 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" />
-
+
); }; -// 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 ( +
+ {result.leads?.map((lead: any) => ( +
+
+ + + {lead.contactName?.firstName} {lead.contactName?.lastName} + + {lead.status && ( + + {lead.status.replace(/_/g, ' ')} + + )} +
+ {lead.contactPhone?.primaryPhoneNumber && ( +

{lead.contactPhone.primaryPhoneNumber}

+ )} + {lead.aiSummary && ( +

{lead.aiSummary}

+ )} +
+ ))} +
+ ); + + case 'lookup_appointments': + if (!result.appointments?.length) return null; + return ( +
+ {result.appointments.map((appt: any) => ( +
+ +
+ + {appt.doctorName ?? 'Doctor'} . {appt.department ?? ''} + + + {appt.scheduledAt ? new Date(appt.scheduledAt).toLocaleDateString('en-IN', { day: 'numeric', month: 'short' }) : ''} + +
+ {appt.status && ( + + {appt.status.toLowerCase()} + + )} +
+ ))} +
+ ); + + case 'lookup_doctor': + if (!result.found) return null; + return ( +
+ {result.doctors?.map((doc: any) => ( +
+
+ + + Dr. {doc.fullName?.firstName} {doc.fullName?.lastName} + +
+

+ {doc.department} . {doc.specialty} +

+ {doc.visitingHours && ( +

Hours: {doc.visitingHours}

+ )} + {doc.consultationFeeNew && ( +

+ Fee: {'\u20B9'}{doc.consultationFeeNew.amountMicros / 1_000_000} + {doc.clinic?.clinicName ? ` . ${doc.clinic.clinicName}` : ''} +

+ )} +
+ ))} +
+ ); + + 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( - - {match[1]} - , - ); + if (match.index > lastIndex) parts.push(text.slice(lastIndex, match.index)); + parts.push({match[1]}); 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 (
{lines.map((line, i) => { if (line.trim().length === 0) return
; - - // Bullet points if (line.trimStart().startsWith('- ')) { return (
@@ -260,7 +262,6 @@ const MessageContent = ({ content }: { content: string }) => {
); } - return

{parseLine(line)}

; })}
diff --git a/src/pages/team-dashboard.tsx b/src/pages/team-dashboard.tsx index 328f4de..c9ffd74 100644 --- a/src/pages/team-dashboard.tsx +++ b/src/pages/team-dashboard.tsx @@ -153,7 +153,7 @@ export const TeamDashboardPage = () => { )}> {aiOpen && (
- +
)}