Skip to main content
Glama
SpacesInbox.tsx15.5 kB
// SPDX-FileCopyrightText: Copyright Orangebot, Inc. and Medplum contributors // SPDX-License-Identifier: Apache-2.0 import { Stack, Text, Box, ScrollArea, Group, ActionIcon, CloseButton, Avatar, ThemeIcon } from '@mantine/core'; import type { JSX } from 'react'; import { useState, useRef, useEffect } from 'react'; import { useMedplum, useResource } from '@medplum/react'; import { IconRobot, IconLayoutSidebarLeftCollapse, IconLayoutSidebarLeftExpand, IconPlus } from '@tabler/icons-react'; import { showErrorNotification } from '../../utils/notifications'; import { ResourceBox } from './ResourceBox'; import { ResourcePanel } from './ResourcePanel'; import type { Message } from '../../types/spaces'; import { createConversationTopic, saveMessage, loadConversationMessages } from '../../utils/spacePersistence'; import { HistoryList } from './HistoryList'; import { ChatInput } from '../../pages/spaces/ChatInput'; import type { Identifier, Communication, Reference } from '@medplum/fhirtypes'; import { getReferenceString } from '@medplum/core'; import classes from './SpacesInbox.module.css'; import cx from 'clsx'; const fhirRequestToolsId: Identifier = { value: 'ai-fhir-request-tools', system: 'https://www.medplum.com/bots', }; const resourceSummaryBotId: Identifier = { value: 'ai-resource-summary', system: 'https://www.medplum.com/bots', }; interface SpaceInboxProps { topic: Communication | Reference<Communication> | undefined; onNewTopic: (topic: Communication) => void; onSelectedItem: (topic: Communication) => string; onAdd?: () => void; } export function SpacesInbox(props: SpaceInboxProps): JSX.Element { const { topic: topicRef, onNewTopic, onSelectedItem, onAdd } = props; const medplum = useMedplum(); const topic = useResource(topicRef); const [messages, setMessages] = useState<Message[]>([]); const [input, setInput] = useState(''); const [loading, setLoading] = useState(false); const [selectedModel, setSelectedModel] = useState('gpt-5'); const [hasStarted, setHasStarted] = useState(false); const [currentFhirRequest, setCurrentFhirRequest] = useState<string | undefined>(); const [currentTopicId, setCurrentTopicId] = useState<string | undefined>(topic?.id); const [sidebarOpen, setSidebarOpen] = useState(true); const [refreshKey, setRefreshKey] = useState(0); const [selectedResource, setSelectedResource] = useState<string | undefined>(); const scrollViewportRef = useRef<HTMLDivElement>(null); // Load conversation when topic changes useEffect(() => { const topicId = topic?.id; if (topicId) { const loadTopic = async (): Promise<void> => { try { setLoading(true); const loadedMessages = await loadConversationMessages(medplum, topicId); setMessages([...loadedMessages]); setCurrentTopicId(topicId); setHasStarted(true); setSelectedResource(undefined); } catch (error) { showErrorNotification(error); } finally { setLoading(false); } }; loadTopic().catch(showErrorNotification); } else { // Reset state when no topic is selected setMessages([]); setHasStarted(false); setCurrentTopicId(undefined); setSelectedResource(undefined); } }, [topic, medplum]); useEffect(() => { const viewport = scrollViewportRef.current; if (viewport && hasStarted) { viewport.scrollTo({ top: viewport.scrollHeight, behavior: 'smooth', }); } }, [messages, hasStarted]); const handleSelectTopic = async (selectedTopicId: string): Promise<void> => { try { setLoading(true); const loadedMessages = await loadConversationMessages(medplum, selectedTopicId); setMessages([...loadedMessages]); setCurrentTopicId(selectedTopicId); setHasStarted(true); setSelectedResource(undefined); } catch (error) { showErrorNotification(error); } finally { setLoading(false); } }; const handleSend = async (): Promise<void> => { if (!input.trim()) { return; } const isFirstMessage = !hasStarted; if (isFirstMessage) { setHasStarted(true); } const userMessage: Message = { role: 'user', content: input }; const currentMessages = [...messages, userMessage]; setMessages(currentMessages); setInput(''); setCurrentFhirRequest(undefined); setLoading(true); try { // Create topic on first message let activeTopicId = currentTopicId; if (isFirstMessage) { const topic = await createConversationTopic(medplum, input.substring(0, 100), selectedModel); activeTopicId = topic.id; setCurrentTopicId(activeTopicId); setRefreshKey((prev) => prev + 1); // Refresh list to show new topic // Notify parent to navigate to the new topic onNewTopic(topic); } // Save user message if (activeTopicId) { await saveMessage(medplum, activeTopicId, userMessage, currentMessages.length - 1); } let response = await medplum.executeBot(fhirRequestToolsId, { resourceType: 'Parameters', parameter: [ { name: 'messages', valueString: JSON.stringify(currentMessages) }, { name: 'model', valueString: selectedModel }, ], }); const toolCallsStr = response.parameter?.find((p: any) => p.name === 'tool_calls')?.valueString; const allResourceRefs: string[] = []; if (toolCallsStr) { const toolCalls = JSON.parse(toolCallsStr); const assistantMessageWithToolCalls: Message = { role: 'assistant', content: null, tool_calls: toolCalls, }; currentMessages.push(assistantMessageWithToolCalls); // Save tool call message if (activeTopicId) { await saveMessage(medplum, activeTopicId, assistantMessageWithToolCalls, currentMessages.length - 1); } for (const toolCall of toolCalls) { if (toolCall.function.name === 'fhir_request') { const args = typeof toolCall.function.arguments === 'string' ? JSON.parse(toolCall.function.arguments) : toolCall.function.arguments; const { method, path, body } = args; setCurrentFhirRequest(`${method} ${path}`); let result; try { if (method === 'GET') { result = await medplum.get(medplum.fhirUrl(path)); } else if (method === 'POST') { result = await medplum.post(medplum.fhirUrl(path), body); } else if (method === 'PUT') { result = await medplum.put(medplum.fhirUrl(path), body); } else if (method === 'DELETE') { result = await medplum.delete(medplum.fhirUrl(path)); } if (result?.resourceType === 'Bundle' && result?.entry) { result.entry.forEach((entry: any) => { if (entry.resource) { const ref = getReferenceString(entry.resource); if (ref) { allResourceRefs.push(ref); } } }); } else if (result) { const ref = getReferenceString(result); if (ref) { allResourceRefs.push(ref); } } const toolMessage: Message = { role: 'tool', tool_call_id: toolCall.id, content: JSON.stringify(result), }; currentMessages.push(toolMessage); // Save tool response message if (activeTopicId) { await saveMessage(medplum, activeTopicId, toolMessage, currentMessages.length - 1); } } catch (err: any) { const errorResult = { error: true, message: `Unable to execute ${method}: ${path}`, details: err.message || 'Unknown error', }; const toolErrorMessage: Message = { role: 'tool', tool_call_id: toolCall.id, content: JSON.stringify(errorResult), }; currentMessages.push(toolErrorMessage); // Save tool error message if (activeTopicId) { await saveMessage(medplum, activeTopicId, toolErrorMessage, currentMessages.length - 1); } } } } response = await medplum.executeBot(resourceSummaryBotId, { resourceType: 'Parameters', parameter: [ { name: 'messages', valueString: JSON.stringify(currentMessages) }, { name: 'model', valueString: selectedModel }, ], }); } const content = response.parameter?.find((p: any) => p.name === 'content')?.valueString; if (content) { const uniqueRefs = allResourceRefs.length > 0 ? [...new Set(allResourceRefs)] : undefined; const assistantMessage: Message = { role: 'assistant', content, resources: uniqueRefs, }; setMessages([...currentMessages, assistantMessage]); // Save assistant message if (activeTopicId) { await saveMessage(medplum, activeTopicId, assistantMessage, currentMessages.length); } } } catch (error: any) { setMessages([...currentMessages, { role: 'assistant', content: `Error: ${error.message}` }]); } finally { setLoading(false); } }; const handleKeyDown = (e: React.KeyboardEvent): void => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); handleSend().catch((error) => showErrorNotification(error)); } }; const visibleMessages = messages.filter( (m) => m.role !== 'system' && m.role !== 'tool' && !(m.role === 'assistant' && m.tool_calls) ); return ( <> {/* Sidebar */} <Box className={classes.sidebar} style={{ width: sidebarOpen ? 280 : 0, opacity: sidebarOpen ? 1 : 0 }}> <div className={classes.sidebarHeader}> <Text className={classes.sidebarTitle}>Conversations</Text> <ActionIcon variant="subtle" color="gray" onClick={() => setSidebarOpen(false)}> <IconLayoutSidebarLeftCollapse size={18} /> </ActionIcon> </div> <div className={classes.sidebarContent}> <HistoryList key={refreshKey} currentTopicId={currentTopicId} onSelectTopic={handleSelectTopic} onSelectedItem={onSelectedItem} /> </div> </Box> {/* Main Chat Area */} <div className={classes.chatContainer}> <div className={classes.chatHeader}> <div> {!sidebarOpen && ( <ActionIcon variant="subtle" color="gray" onClick={() => setSidebarOpen(true)} mr="md"> <IconLayoutSidebarLeftExpand size={16} /> </ActionIcon> )} </div> {onAdd && ( <ActionIcon variant="subtle" color="gray" size="sm" onClick={onAdd} aria-label="New conversation"> <IconPlus size={16} /> </ActionIcon> )} </div> <div className={classes.messagesArea}> {!hasStarted ? ( <div className={classes.emptyState}> <ThemeIcon size={64} radius="xl" variant="light" color="gray" className={classes.emptyStateIcon}> <IconRobot size={32} /> </ThemeIcon> <Text size="xl" fw={500} mb="sm"> How can I help you today? </Text> <Text c="dimmed" size="sm" maw={400}> I can help you search for patients, create resources, or answer clinical questions. </Text> </div> ) : ( <ScrollArea style={{ flex: 1 }} offsetScrollbars viewportRef={scrollViewportRef}> <Stack gap="xl" p="xs"> {visibleMessages.map((message, index) => ( <div key={index} className={cx( classes.messageWrapper, message.role === 'user' ? classes.userMessage : classes.assistantMessage )} > <Group align="flex-start" gap="sm" mb={4}> {message.role === 'assistant' && ( <Avatar radius="xl" size="sm" color="blue"> <IconRobot size={14} /> </Avatar> )} <Text fw={600} size="sm" c="dimmed"> {message.role === 'user' ? 'You' : 'AI Assistant'} </Text> </Group> <div className={classes.messageContent}> <Text style={{ whiteSpace: 'pre-wrap' }}>{message.content}</Text> </div> {message.resources && message.resources.length > 0 && ( <Stack gap="xs" mt="sm" w={300} ml={message.role === 'assistant' ? 0 : 'auto'}> {message.resources.map((resourceRef, idx) => ( <ResourceBox key={idx} resourceReference={resourceRef} onClick={setSelectedResource} /> ))} </Stack> )} </div> ))} {loading && ( <div className={cx(classes.messageWrapper, classes.assistantMessage)}> <Group align="center" gap="sm" wrap="nowrap"> <Avatar radius="xl" size="sm" color="blue"> <IconRobot size={14} /> </Avatar> <Box style={{ flex: 1, minWidth: 0 }}> <Text size="sm" c="dimmed" fs="italic" truncate> {currentFhirRequest ? `Executing ${currentFhirRequest}...` : 'Thinking...'} </Text> </Box> </Group> </div> )} </Stack> </ScrollArea> )} </div> <div className={classes.inputArea}> <div className={classes.inputWrapper}> <ChatInput input={input} onInputChange={setInput} onKeyDown={handleKeyDown} onSend={handleSend} loading={loading} selectedModel={selectedModel} onModelChange={setSelectedModel} backgroundColor="transparent" /> </div> </div> </div> {/* Resource Panel */} {selectedResource && ( <div className={classes.resourcePanel}> <div className={classes.resourceHeader}> <Text fw={600} size="sm"> Resource Details </Text> <CloseButton onClick={() => setSelectedResource(undefined)} /> </div> <ScrollArea style={{ flex: 1 }} p="md"> <ResourcePanel key={selectedResource} resource={{ reference: selectedResource }} /> </ScrollArea> </div> )} </> ); }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/medplum/medplum'

If you have feedback or need assistance with the MCP directory API, please join our Discord server