diff --git a/src/frontend/apps/conversations/src/features/chat/components/Chat.tsx b/src/frontend/apps/conversations/src/features/chat/components/Chat.tsx index 0efdbcb..6bde68e 100644 --- a/src/frontend/apps/conversations/src/features/chat/components/Chat.tsx +++ b/src/frontend/apps/conversations/src/features/chat/components/Chat.tsx @@ -1,23 +1,12 @@ -import { - Message, - ReasoningUIPart, - SourceUIPart, - ToolInvocationUIPart, -} from '@ai-sdk/ui-utils'; +import { Message, SourceUIPart } from '@ai-sdk/ui-utils'; import { Modal, ModalSize } from '@openfun/cunningham-react'; -import 'katex/dist/katex.min.css'; // `rehype-katex` does not import the CSS for you import { useRouter } from 'next/router'; import { useCallback, useEffect, useRef, useState } from 'react'; import type { ChangeEvent, FormEvent } from 'react'; import { useTranslation } from 'react-i18next'; -import { MarkdownHooks } from 'react-markdown'; -import rehypeKatex from 'rehype-katex'; -import rehypePrettyCode from 'rehype-pretty-code'; -import remarkGfm from 'remark-gfm'; -import remarkMath from 'remark-math'; import { APIError, errorCauses, fetchAPI } from '@/api'; -import { Box, Icon, Loader, Text } from '@/components'; +import { Box, Loader, Text } from '@/components'; import { useUploadFile } from '@/features/attachments/hooks/useUploadFile'; import { useChat } from '@/features/chat/api/useChat'; import { getConversation } from '@/features/chat/api/useConversation'; @@ -26,14 +15,9 @@ import { LLMModel, useLLMConfiguration, } from '@/features/chat/api/useLLMConfiguration'; -import { AttachmentList } from '@/features/chat/components/AttachmentList'; import { ChatError } from '@/features/chat/components/ChatError'; -import { CodeBlock } from '@/features/chat/components/CodeBlock'; -import { FeedbackButtons } from '@/features/chat/components/FeedbackButtons'; +import { ChatMessage } from '@/features/chat/components/ChatMessage'; import { InputChat } from '@/features/chat/components/InputChat'; -import { SourceItemList } from '@/features/chat/components/SourceItemList'; -import { ToolInvocationItem } from '@/features/chat/components/ToolInvocationItem'; -import { useClipboard } from '@/hook'; import { useResponsiveStore } from '@/stores'; import { useSourceMetadataCache } from '../hooks'; @@ -54,7 +38,6 @@ export const Chat = ({ initialConversationId: string | undefined; }) => { const { t } = useTranslation(); - const copyToClipboard = useClipboard(); const { isMobile } = useResponsiveStore(); const streamProtocol = 'data'; // or 'text' @@ -294,21 +277,24 @@ export const Chat = ({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [messages]); - const openSources = (messageId: string) => { - if (isSourceOpen === messageId) { - setIsSourceOpen(null); - return; - } - const message = messages.find((msg) => msg.id === messageId); - if (message?.parts) { - const sourceParts = message.parts.filter( - (part): part is SourceUIPart => part.type === 'source', - ); - if (sourceParts.length > 0) { - setIsSourceOpen(messageId); + const openSources = useCallback( + (messageId: string) => { + if (isSourceOpen === messageId) { + setIsSourceOpen(null); + return; } - } - }; + const message = messages.find((msg) => msg.id === messageId); + if (message?.parts) { + const sourceParts = message.parts.filter( + (part): part is SourceUIPart => part.type === 'source', + ); + if (sourceParts.length > 0) { + setIsSourceOpen(messageId); + } + } + }, + [isSourceOpen, messages], + ); // Calculer la hauteur pour le message de streaming const calculateStreamingHeight = useCallback(() => { @@ -367,7 +353,7 @@ export const Chat = ({ }, [messages, status]); useEffect(() => { - if (status === 'submitted' || status === 'streaming') { + if (status === 'submitted') { calculateStreamingHeight(); } // eslint-disable-next-line react-hooks/exhaustive-deps @@ -432,7 +418,7 @@ export const Chat = ({ // When shouldAutoSubmit is set, and input/files are ready, submit useEffect(() => { - if (shouldAutoSubmit && (input.trim() || (files && files.length > 0))) { + if (shouldAutoSubmit && files && files.length > 0) { // Create a synthetic event for form submission const form = document.createElement('form'); const syntheticFormEvent = { @@ -443,7 +429,7 @@ export const Chat = ({ setShouldAutoSubmit(false); } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [shouldAutoSubmit, input, files]); + }, [shouldAutoSubmit, files]); useEffect(() => { if ( @@ -663,319 +649,25 @@ export const Chat = ({ (status === 'streaming' || status === 'submitted'); return ( - - - {message.experimental_attachments && - message.experimental_attachments.length > 0 && ( - - - - )} - - {/* Message content */} - {message.content && ( - -

- {message.role === 'user' - ? t('You said: ') - : t('Assistant IA replied: ')} -

- {message.role === 'user' ? ( - - {message.content} - - ) : ( - ( - - ), - a: ({ children, ...props }) => ( - - {children} - - ), - // eslint-disable-next-line @typescript-eslint/no-unused-vars - pre: ({ node, children, ...props }) => ( - {children} - ), - }} - > - {message.content} - - )} -
- )} - - - {isCurrentlyStreaming && - isLastAssistantMessageInConversation && - status === 'streaming' && - message.parts?.some( - (part) => - part.type === 'tool-invocation' && - part.toolInvocation.toolName !== - 'document_parsing', - ) && ( - - - - {(() => { - const toolInvocation = message.parts?.find( - (part) => - part.type === 'tool-invocation' && - part.toolInvocation.toolName !== - 'document_parsing', - ); - if ( - toolInvocation?.type === - 'tool-invocation' && - toolInvocation.toolInvocation.toolName === - 'summarize' - ) { - return t('Summarizing...'); - } - return t('Search...'); - })()} - - - )} - {message.parts - ?.filter( - (part) => - part.type === 'reasoning' || - part.type === 'tool-invocation', - ) - .map( - ( - part: ReasoningUIPart | ToolInvocationUIPart, - partIndex: number, - ) => - part.type === 'reasoning' ? ( - - {part.reasoning} - - ) : part.type === 'tool-invocation' && - isCurrentlyStreaming && - isLastAssistantMessageInConversation ? ( - - ) : null, - )} - - {message.role === 'assistant' && - !( - isLastAssistantMessageInConversation && - status === 'streaming' - ) && ( - - - copyToClipboard(message.content)} - onKeyDown={(e) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - copyToClipboard(message.content); - } - }} - role="button" - tabIndex={0} - > - - {!isMobile && ( - - {t('Copy')} - - )} - - {message.parts?.some( - (part) => part.type === 'source', - ) && - (() => { - const sourceCount = - message.parts?.filter( - (part) => part.type === 'source', - ).length || 0; - return ( - openSources(message.id)} - onKeyDown={(e) => { - if ( - e.key === 'Enter' || - e.key === ' ' - ) { - e.preventDefault(); - openSources(message.id); - } - }} - role="button" - tabIndex={0} - > - - - {t('Show')} {sourceCount}{' '} - {sourceCount !== 1 - ? t('sources') - : t('source')} - - - ); - })()} - - - {/* We should display the button, but disabled if no trace linked */} - {conversationId && - message.id && - message.id.startsWith('trace-') && ( - - )} - - - )} - {message.parts && - isSourceOpen === message.id && - (() => { - const sourceParts = message.parts.filter( - (part): part is SourceUIPart => - part.type === 'source', - ); - return ( - - - - ); - })()} -
-
-
+ message={message} + isLastMessage={isLastMessage} + isLastAssistantMessageInConversation={ + isLastAssistantMessageInConversation + } + isFirstConversationMessage={isFirstConversationMessage} + shouldApplyStreamingHeight={!!shouldApplyStreamingHeight} + streamingMessageHeight={streamingMessageHeight} + isCurrentlyStreaming={isCurrentlyStreaming} + status={ + isCurrentlyStreaming ? status || undefined : undefined + } + isSourceOpen={isSourceOpen} + conversationId={conversationId} + onOpenSources={openSources} + getMetadata={getMetadata} + /> ); })} diff --git a/src/frontend/apps/conversations/src/features/chat/components/ChatMessage.tsx b/src/frontend/apps/conversations/src/features/chat/components/ChatMessage.tsx new file mode 100644 index 0000000..0ba55a0 --- /dev/null +++ b/src/frontend/apps/conversations/src/features/chat/components/ChatMessage.tsx @@ -0,0 +1,364 @@ +import { + Message, + ReasoningUIPart, + SourceUIPart, + ToolInvocationUIPart, +} from '@ai-sdk/ui-utils'; +import 'katex/dist/katex.min.css'; +import { memo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { MarkdownHooks } from 'react-markdown'; +import rehypeKatex from 'rehype-katex'; +import rehypePrettyCode from 'rehype-pretty-code'; +import remarkGfm from 'remark-gfm'; +import remarkMath from 'remark-math'; + +import { Box, Icon, Loader, Text } from '@/components'; +import { AttachmentList } from '@/features/chat/components/AttachmentList'; +import { CodeBlock } from '@/features/chat/components/CodeBlock'; +import { FeedbackButtons } from '@/features/chat/components/FeedbackButtons'; +import { SourceItemList } from '@/features/chat/components/SourceItemList'; +import { ToolInvocationItem } from '@/features/chat/components/ToolInvocationItem'; +import { useClipboard } from '@/hook'; +import { useResponsiveStore } from '@/stores'; + +interface ChatMessageProps { + message: Message; + isLastMessage: boolean; + isLastAssistantMessageInConversation: boolean; + isFirstConversationMessage: boolean; + shouldApplyStreamingHeight: boolean; + streamingMessageHeight: number | null; + isCurrentlyStreaming: boolean; + status: string | undefined; + isSourceOpen: string | null; + conversationId: string | undefined; + onOpenSources: (messageId: string) => void; + getMetadata: (url: string) => + | { + title: string | null; + favicon: string | null; + loading: boolean; + error: boolean; + } + | undefined; +} + +export const ChatMessage = memo(function ChatMessage({ + message, + isLastMessage: _isLastMessage, + isLastAssistantMessageInConversation, + isFirstConversationMessage: _isFirstConversationMessage, + shouldApplyStreamingHeight, + streamingMessageHeight, + isCurrentlyStreaming, + status, + isSourceOpen, + conversationId, + onOpenSources, + getMetadata, +}: ChatMessageProps) { + const { t } = useTranslation(); + const copyToClipboard = useClipboard(); + const { isMobile } = useResponsiveStore(); + + return ( + + + {message.experimental_attachments && + message.experimental_attachments.length > 0 && ( + + + + )} + + {/* Message content */} + {message.content && ( + +

+ {message.role === 'user' + ? t('You said: ') + : t('Assistant IA replied: ')} +

+ {message.role === 'user' ? ( + + {message.content} + + ) : ( + ( + + ), + a: ({ children, ...props }) => ( + + {children} + + ), + // eslint-disable-next-line @typescript-eslint/no-unused-vars + pre: ({ node, children, ...props }) => ( + {children} + ), + }} + > + {message.content} + + )} +
+ )} + + + {isCurrentlyStreaming && + isLastAssistantMessageInConversation && + status === 'streaming' && + message.parts?.some( + (part) => + part.type === 'tool-invocation' && + part.toolInvocation.toolName !== 'document_parsing', + ) && ( + + + + {(() => { + const toolInvocation = message.parts?.find( + (part) => + part.type === 'tool-invocation' && + part.toolInvocation.toolName !== 'document_parsing', + ); + if ( + toolInvocation?.type === 'tool-invocation' && + toolInvocation.toolInvocation.toolName === 'summarize' + ) { + return t('Summarizing...'); + } + return t('Search...'); + })()} + + + )} + {message.parts + ?.filter( + (part) => + part.type === 'reasoning' || part.type === 'tool-invocation', + ) + .map( + ( + part: ReasoningUIPart | ToolInvocationUIPart, + partIndex: number, + ) => + part.type === 'reasoning' ? ( + + {part.reasoning} + + ) : part.type === 'tool-invocation' && + isCurrentlyStreaming && + isLastAssistantMessageInConversation ? ( + + ) : null, + )} + + {message.role === 'assistant' && + !( + isLastAssistantMessageInConversation && status === 'streaming' + ) && ( + + + copyToClipboard(message.content)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + copyToClipboard(message.content); + } + }} + role="button" + tabIndex={0} + > + + {!isMobile && ( + + {t('Copy')} + + )} + + {message.parts?.some((part) => part.type === 'source') && + (() => { + const sourceCount = + message.parts?.filter((part) => part.type === 'source') + .length || 0; + return ( + onOpenSources(message.id)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + onOpenSources(message.id); + } + }} + role="button" + tabIndex={0} + > + + + {t('Show')} {sourceCount}{' '} + {sourceCount !== 1 ? t('sources') : t('source')} + + + ); + })()} + + + {/* We should display the button, but disabled if no trace linked */} + {conversationId && + message.id && + message.id.startsWith('trace-') && ( + + )} + + + )} + {message.parts && + isSourceOpen === message.id && + (() => { + const sourceParts = message.parts.filter( + (part): part is SourceUIPart => part.type === 'source', + ); + return ( + + + + ); + })()} +
+
+
+ ); +});