mirror of
https://dev.azure.com/globalhealthx/EMR/_git/helix-engage
synced 2026-04-11 10:23:27 +00:00
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:
136
package-lock.json
generated
136
package-lock.json
generated
@@ -8,6 +8,7 @@
|
|||||||
"name": "helix-engage",
|
"name": "helix-engage",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@ai-sdk/react": "^1.2.12",
|
||||||
"@fortawesome/fontawesome-svg-core": "^7.2.0",
|
"@fortawesome/fontawesome-svg-core": "^7.2.0",
|
||||||
"@fortawesome/free-solid-svg-icons": "^7.2.0",
|
"@fortawesome/free-solid-svg-icons": "^7.2.0",
|
||||||
"@fortawesome/pro-duotone-svg-icons": "^7.2.0",
|
"@fortawesome/pro-duotone-svg-icons": "^7.2.0",
|
||||||
@@ -56,6 +57,76 @@
|
|||||||
"vite": "^7.3.1"
|
"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": {
|
"node_modules/@alloc/quick-lru": {
|
||||||
"version": "5.2.0",
|
"version": "5.2.0",
|
||||||
"resolved": "http://localhost:4873/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
|
"resolved": "http://localhost:4873/@alloc/quick-lru/-/quick-lru-5.2.0.tgz",
|
||||||
@@ -4188,6 +4259,15 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true
|
"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": {
|
"node_modules/detect-libc": {
|
||||||
"version": "2.1.2",
|
"version": "2.1.2",
|
||||||
"resolved": "http://localhost:4873/detect-libc/-/detect-libc-2.1.2.tgz",
|
"resolved": "http://localhost:4873/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||||
@@ -4842,6 +4922,12 @@
|
|||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true
|
"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": {
|
"node_modules/json-schema-traverse": {
|
||||||
"version": "0.4.1",
|
"version": "0.4.1",
|
||||||
"resolved": "http://localhost:4873/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
|
"resolved": "http://localhost:4873/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz",
|
||||||
@@ -5876,6 +5962,12 @@
|
|||||||
"sdp-verify": "checker.js"
|
"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": {
|
"node_modules/semver": {
|
||||||
"version": "7.7.4",
|
"version": "7.7.4",
|
||||||
"resolved": "http://localhost:4873/semver/-/semver-7.7.4.tgz",
|
"resolved": "http://localhost:4873/semver/-/semver-7.7.4.tgz",
|
||||||
@@ -5988,6 +6080,19 @@
|
|||||||
"safe-buffer": "~5.1.0"
|
"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": {
|
"node_modules/tailwind-merge": {
|
||||||
"version": "3.5.0",
|
"version": "3.5.0",
|
||||||
"resolved": "http://localhost:4873/tailwind-merge/-/tailwind-merge-3.5.0.tgz",
|
"resolved": "http://localhost:4873/tailwind-merge/-/tailwind-merge-3.5.0.tgz",
|
||||||
@@ -6035,6 +6140,18 @@
|
|||||||
"url": "https://opencollective.com/webpack"
|
"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": {
|
"node_modules/tinyglobby": {
|
||||||
"version": "0.2.15",
|
"version": "0.2.15",
|
||||||
"resolved": "http://localhost:4873/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
"resolved": "http://localhost:4873/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
||||||
@@ -6300,6 +6417,25 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"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": {
|
"node_modules/zrender": {
|
||||||
"version": "6.0.0",
|
"version": "6.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/zrender/-/zrender-6.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/zrender/-/zrender-6.0.0.tgz",
|
||||||
|
|||||||
@@ -9,6 +9,7 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@ai-sdk/react": "^1.2.12",
|
||||||
"@fortawesome/fontawesome-svg-core": "^7.2.0",
|
"@fortawesome/fontawesome-svg-core": "^7.2.0",
|
||||||
"@fortawesome/free-solid-svg-icons": "^7.2.0",
|
"@fortawesome/free-solid-svg-icons": "^7.2.0",
|
||||||
"@fortawesome/pro-duotone-svg-icons": "^7.2.0",
|
"@fortawesome/pro-duotone-svg-icons": "^7.2.0",
|
||||||
|
|||||||
@@ -1,15 +1,10 @@
|
|||||||
import type { ReactNode } from 'react';
|
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 { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
||||||
import { faPaperPlaneTop, faRobot, faSparkles, faUserHeadset } from '@fortawesome/pro-duotone-svg-icons';
|
import { faPaperPlaneTop, faSparkles, faUserHeadset, faUser, faCalendarCheck, faStethoscope } from '@fortawesome/pro-duotone-svg-icons';
|
||||||
import { apiClient } from '@/lib/api-client';
|
|
||||||
|
|
||||||
type ChatMessage = {
|
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:4100';
|
||||||
id: string;
|
|
||||||
role: 'user' | 'assistant';
|
|
||||||
content: string;
|
|
||||||
timestamp: Date;
|
|
||||||
};
|
|
||||||
|
|
||||||
type CallerContext = {
|
type CallerContext = {
|
||||||
callerPhone?: string;
|
callerPhone?: string;
|
||||||
@@ -19,133 +14,62 @@ type CallerContext = {
|
|||||||
|
|
||||||
interface AiChatPanelProps {
|
interface AiChatPanelProps {
|
||||||
callerContext?: CallerContext;
|
callerContext?: CallerContext;
|
||||||
role?: 'cc-agent' | 'admin' | 'executive';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const QUICK_ASK_AGENT = [
|
const QUICK_ACTIONS = [
|
||||||
{ label: 'Doctor availability', template: 'What are the visiting hours for all doctors?' },
|
{ label: 'Doctor availability', prompt: 'What doctors are available and what are their visiting hours?' },
|
||||||
{ label: 'Clinic timings', template: 'What are the clinic locations and timings?' },
|
{ label: 'Clinic timings', prompt: 'What are the clinic locations and timings?' },
|
||||||
{ label: 'Patient history', template: 'Can you summarize this patient\'s history?' },
|
{ label: 'Patient history', prompt: 'Can you summarize this patient\'s history?' },
|
||||||
{ label: 'Treatment packages', template: 'What treatment packages are available?' },
|
{ label: 'Treatment packages', prompt: 'What treatment packages are available?' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const QUICK_ASK_MANAGER = [
|
export const AiChatPanel = ({ callerContext }: AiChatPanelProps) => {
|
||||||
{ 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);
|
|
||||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
|
||||||
|
|
||||||
const scrollToBottom = useCallback(() => {
|
const token = localStorage.getItem('helix_access_token') ?? '';
|
||||||
// Scroll within the messages container only — don't scroll the parent panel
|
|
||||||
|
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;
|
const el = messagesEndRef.current;
|
||||||
if (el?.parentElement) {
|
if (el?.parentElement) {
|
||||||
el.parentElement.scrollTop = el.parentElement.scrollHeight;
|
el.parentElement.scrollTop = el.parentElement.scrollHeight;
|
||||||
}
|
}
|
||||||
}, []);
|
}, [messages]);
|
||||||
|
|
||||||
useEffect(() => {
|
const handleQuickAction = (prompt: string) => {
|
||||||
scrollToBottom();
|
append({ role: 'user', content: prompt });
|
||||||
}, [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]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full flex-col">
|
<div className="flex h-full flex-col p-3">
|
||||||
{/* Caller context banner */}
|
<div className="flex-1 space-y-3 overflow-y-auto min-h-0">
|
||||||
{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 && (
|
{messages.length === 0 && (
|
||||||
<div className="mb-3 flex flex-wrap gap-1.5">
|
<div className="flex flex-col items-center justify-center py-6 text-center">
|
||||||
{quickButtons.map((btn) => (
|
<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
|
<button
|
||||||
key={btn.label}
|
key={action.label}
|
||||||
onClick={() => handleQuickAsk(btn.template)}
|
onClick={() => handleQuickAction(action.prompt)}
|
||||||
disabled={isLoading}
|
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"
|
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"
|
||||||
>
|
>
|
||||||
{btn.label}
|
{action.label}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Messages area */}
|
|
||||||
<div className="flex-1 space-y-3 overflow-y-auto">
|
|
||||||
{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">
|
|
||||||
Ask me about doctors, clinics, packages, or patient info.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -168,6 +92,10 @@ export const AiChatPanel = ({ callerContext, role = 'cc-agent' }: AiChatPanelPro
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<MessageContent content={msg.content} />
|
<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>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -187,37 +115,121 @@ export const AiChatPanel = ({ callerContext, role = 'cc-agent' }: AiChatPanelPro
|
|||||||
<div ref={messagesEndRef} />
|
<div ref={messagesEndRef} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Input area */}
|
<form onSubmit={handleSubmit} className="mt-2 flex items-center gap-2 shrink-0">
|
||||||
<div className="mt-3 flex items-center gap-2">
|
|
||||||
<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">
|
<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
|
<FontAwesomeIcon icon={faUserHeadset} className="ml-2.5 size-3.5 text-fg-quaternary" />
|
||||||
icon={faUserHeadset}
|
|
||||||
className="ml-2.5 size-3.5 text-fg-quaternary"
|
|
||||||
/>
|
|
||||||
<input
|
<input
|
||||||
ref={inputRef}
|
|
||||||
type="text"
|
type="text"
|
||||||
value={input}
|
value={input}
|
||||||
onChange={(e) => setInput(e.target.value)}
|
onChange={handleInputChange}
|
||||||
onKeyDown={handleKeyDown}
|
|
||||||
placeholder="Ask the AI assistant..."
|
placeholder="Ask the AI assistant..."
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
className="flex-1 bg-transparent px-2 py-2 text-xs text-primary placeholder:text-placeholder outline-none disabled:cursor-not-allowed"
|
className="flex-1 bg-transparent px-2 py-2 text-xs text-primary placeholder:text-placeholder outline-none disabled:cursor-not-allowed"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => sendMessage()}
|
type="submit"
|
||||||
disabled={isLoading || input.trim().length === 0}
|
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"
|
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" />
|
<FontAwesomeIcon icon={faPaperPlaneTop} className="size-3.5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</form>
|
||||||
</div>
|
</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 parseLine = (text: string): ReactNode[] => {
|
||||||
const parts: ReactNode[] = [];
|
const parts: ReactNode[] = [];
|
||||||
const boldPattern = /\*\*(.+?)\*\*/g;
|
const boldPattern = /\*\*(.+?)\*\*/g;
|
||||||
@@ -225,33 +237,23 @@ const parseLine = (text: string): ReactNode[] => {
|
|||||||
let match: RegExpExecArray | null;
|
let match: RegExpExecArray | null;
|
||||||
|
|
||||||
while ((match = boldPattern.exec(text)) !== null) {
|
while ((match = boldPattern.exec(text)) !== null) {
|
||||||
if (match.index > lastIndex) {
|
if (match.index > lastIndex) parts.push(text.slice(lastIndex, match.index));
|
||||||
parts.push(text.slice(lastIndex, match.index));
|
parts.push(<strong key={match.index} className="font-semibold">{match[1]}</strong>);
|
||||||
}
|
|
||||||
parts.push(
|
|
||||||
<strong key={match.index} className="font-semibold">
|
|
||||||
{match[1]}
|
|
||||||
</strong>,
|
|
||||||
);
|
|
||||||
lastIndex = boldPattern.lastIndex;
|
lastIndex = boldPattern.lastIndex;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (lastIndex < text.length) {
|
if (lastIndex < text.length) parts.push(text.slice(lastIndex));
|
||||||
parts.push(text.slice(lastIndex));
|
|
||||||
}
|
|
||||||
|
|
||||||
return parts.length > 0 ? parts : [text];
|
return parts.length > 0 ? parts : [text];
|
||||||
};
|
};
|
||||||
|
|
||||||
const MessageContent = ({ content }: { content: string }) => {
|
const MessageContent = ({ content }: { content: string }) => {
|
||||||
|
if (!content) return null;
|
||||||
const lines = content.split('\n');
|
const lines = content.split('\n');
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
{lines.map((line, i) => {
|
{lines.map((line, i) => {
|
||||||
if (line.trim().length === 0) return <div key={i} className="h-1" />;
|
if (line.trim().length === 0) return <div key={i} className="h-1" />;
|
||||||
|
|
||||||
// Bullet points
|
|
||||||
if (line.trimStart().startsWith('- ')) {
|
if (line.trimStart().startsWith('- ')) {
|
||||||
return (
|
return (
|
||||||
<div key={i} className="flex gap-1.5 pl-1">
|
<div key={i} className="flex gap-1.5 pl-1">
|
||||||
@@ -260,7 +262,6 @@ const MessageContent = ({ content }: { content: string }) => {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return <p key={i}>{parseLine(line)}</p>;
|
return <p key={i}>{parseLine(line)}</p>;
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -153,7 +153,7 @@ export const TeamDashboardPage = () => {
|
|||||||
)}>
|
)}>
|
||||||
{aiOpen && (
|
{aiOpen && (
|
||||||
<div className="flex h-full flex-col p-4">
|
<div className="flex h-full flex-col p-4">
|
||||||
<AiChatPanel role="admin" />
|
<AiChatPanel />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user