diff --git a/CHANGELOG.md b/CHANGELOG.md index e88288f..2a2b610 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to ## [Unreleased] ### Changed - +- 🐛(front) optimize chat - 📦️(front) update react ### Fixed diff --git a/src/frontend/apps/conversations/src/components/Loader.tsx b/src/frontend/apps/conversations/src/components/Loader.tsx index 609d0d0..b3ba0c3 100644 --- a/src/frontend/apps/conversations/src/components/Loader.tsx +++ b/src/frontend/apps/conversations/src/components/Loader.tsx @@ -1,8 +1,9 @@ import dynamic from 'next/dynamic'; -const Lottie = dynamic(() => import('lottie-react'), { ssr: false }); import searchingAnimation from '@/assets/lotties/searching'; +const Lottie = dynamic(() => import('lottie-react'), { ssr: false }); + export function Loader() { return (
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..6ce1406 100644 --- a/src/frontend/apps/conversations/src/features/chat/components/Chat.tsx +++ b/src/frontend/apps/conversations/src/features/chat/components/Chat.tsx @@ -1,45 +1,26 @@ -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 { useCallback, useEffect, useMemo, 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'; import { useCreateChatConversation } from '@/features/chat/api/useCreateConversation'; -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 { 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 { useChatPreferencesStore } from '@/features/chat/stores/useChatPreferencesStore'; +import { usePendingChatStore } from '@/features/chat/stores/usePendingChatStore'; +import { useScrollStore } from '@/features/chat/stores/useScrollStore'; import { useResponsiveStore } from '@/stores'; -import { useSourceMetadataCache } from '../hooks'; -import { useChatPreferencesStore } from '../stores/useChatPreferencesStore'; -import { usePendingChatStore } from '../stores/usePendingChatStore'; -import { useScrollStore } from '../stores/useScrollStore'; +import { useModelSelection, useSourceMetadataCache } from '../hooks'; + +import { ChatMessage } from './ChatMessage'; // Define Attachment type locally (mirroring backend structure) export interface Attachment { @@ -54,22 +35,16 @@ export const Chat = ({ initialConversationId: string | undefined; }) => { const { t } = useTranslation(); - const copyToClipboard = useClipboard(); const { isMobile } = useResponsiveStore(); - const streamProtocol = 'data'; // or 'text' - const { - forceWebSearch, - toggleForceWebSearch, - selectedModelHrid, - setSelectedModelHrid, - } = useChatPreferencesStore(); + const { forceWebSearch, toggleForceWebSearch } = useChatPreferencesStore(); - const { data: llmConfig } = useLLMConfiguration(); - const [selectedModel, setSelectedModel] = useState(null); + const { selectedModel, handleModelSelect } = useModelSelection(); + // Use custom hook for conversation sync - we'll update it after useChat const [conversationId, setConversationId] = useState(initialConversationId); + const apiUrl = conversationId ? `chats/${conversationId}/conversation/?protocol=${streamProtocol}` : `chats/conversation/?protocol=${streamProtocol}`; @@ -84,36 +59,6 @@ export const Chat = ({ forceWebSearch; }, [forceWebSearch]); - // Update selected model when LLM config loads - useEffect(() => { - if (llmConfig?.models && !selectedModel) { - let modelToSelect: LLMModel | undefined; - - if (selectedModelHrid) { - // Try to find the previously selected model - modelToSelect = llmConfig.models.find( - (model) => - model.hrid === selectedModelHrid && model.is_active !== false, - ); - } - - // If no saved model or saved model not found/inactive, use default - if (!modelToSelect) { - modelToSelect = llmConfig.models.find((model) => model.is_default); - } - - if (modelToSelect) { - setSelectedModel(modelToSelect); - setSelectedModelHrid(modelToSelect.hrid); - } - } - }, [llmConfig, selectedModel, selectedModelHrid, setSelectedModelHrid]); - - const handleModelSelect = (model: LLMModel) => { - setSelectedModel(model); - setSelectedModelHrid(model.hrid); - }; - const router = useRouter(); const [files, setFiles] = useState(null); const [isUploadingFiles, setIsUploadingFiles] = useState(false); @@ -150,16 +95,22 @@ export const Chat = ({ const [initialConversationMessages, setInitialConversationMessages] = useState(undefined); + const { + input: pendingInput, + files: _pendingFiles, + setPendingChat, + clearPendingChat: _clearPendingChat, + } = usePendingChatStore(); + const [hasInitialized, setHasInitialized] = useState(false); + const [pendingFirstMessage, setPendingFirstMessage] = useState<{ event: FormEvent; attachments?: Attachment[]; forceWebSearch?: boolean; } | null>(null); - const [shouldAutoSubmit, setShouldAutoSubmit] = useState(false); const [shouldRetry, setShouldRetry] = useState(false); const retryOriginalInputRef = useRef(''); const retryOriginalFilesRef = useRef(null); - const [hasInitialized, setHasInitialized] = useState(false); const [streamingMessageHeight, setStreamingMessageHeight] = useState< number | null >(null); @@ -174,23 +125,6 @@ export const Chat = ({ const { mutate: createChatConversation } = useCreateChatConversation(); - // Zustand store for pending chat state - const { - input: pendingInput, - files: pendingFiles, - setPendingChat, - clearPendingChat, - } = usePendingChatStore(); - - const scrollToBottom = useCallback(() => { - if (chatContainerRef.current) { - chatContainerRef.current.scrollTo({ - top: chatContainerRef.current.scrollHeight, - behavior: hasInitialized ? 'smooth' : 'auto', - }); - } - }, [hasInitialized]); - // Show error modal for upload errors useEffect(() => { if (isErrorAttachment && errorAttachment) { @@ -202,15 +136,18 @@ export const Chat = ({ }, [isErrorAttachment, errorAttachment, t]); // Handle errors from the chat API - const onErrorChat = (error: Error) => { - if (error.message === 'attachment_summary_not_supported') { - setChatErrorModal({ - title: t('Attachment summary not supported'), - message: t('The summary feature is not supported yet.'), - }); - } - console.error('Chat error:', error); - }; + const onErrorChat = useCallback( + (error: Error) => { + if (error.message === 'attachment_summary_not_supported') { + setChatErrorModal({ + title: t('Attachment summary not supported'), + message: t('The summary feature is not supported yet.'), + }); + } + console.error('Chat error:', error); + }, + [t], + ); const { messages, @@ -222,64 +159,20 @@ export const Chat = ({ setMessages, } = useChat({ id: conversationId, - initialMessages: initialConversationMessages, + // Ne pas réinitialiser les messages si on est en train de créer une conversation + initialMessages: pendingFirstMessage + ? undefined + : initialConversationMessages, api: apiUrl, streamProtocol: streamProtocol, sendExtraMessageFields: true, onError: onErrorChat, }); - const stopGeneration = async () => { - stopChat(); + // Ref pour messages pour éviter de recréer openSources à chaque changement + const messagesRef = useRef(messages); + messagesRef.current = messages; - const response = await fetchAPI(`chats/${conversationId}/stop-streaming/`, { - method: 'POST', - }); - - if (!response.ok) { - throw new APIError( - 'Failed to stop the conversation', - await errorCauses(response), - ); - } - }; - - const toggleWebSearch = () => { - toggleForceWebSearch(); - }; - - const handleStop = () => { - void stopGeneration(); - }; - - const handleSubmitWrapper = (event: FormEvent) => { - void handleSubmit(event); - }; - - const handleRetry = () => { - if (!lastSubmissionRef.current || !setMessages) { - return; - } - - const { input: lastInput, files: lastFiles } = lastSubmissionRef.current; - - const lastAssistantIndex = messages.findLastIndex( - (msg) => msg.role === 'assistant', - ); - if (lastAssistantIndex !== -1) { - setMessages(messages.filter((_, index) => index !== lastAssistantIndex)); - } - - retryOriginalInputRef.current = input; - retryOriginalFilesRef.current = files; - handleInputChange({ - target: { value: lastInput }, - } as ChangeEvent); - setFiles(lastFiles); - setShouldRetry(true); - }; - - // Précharger les métadonnées des sources dès que les messages arrivent useEffect(() => { messages.forEach((message) => { if (message.parts) { @@ -294,28 +187,265 @@ export const Chat = ({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [messages]); - const openSources = (messageId: string) => { - if (isSourceOpen === messageId) { - setIsSourceOpen(null); - return; + // Custom handleSubmit to include attachments and handle chat creation + const handleSubmit = useCallback( + async (event: FormEvent) => { + event.preventDefault(); + + // Upload files to server and get URLs + let attachments: Attachment[] = []; + if (files && files.length > 0 && conversationId) { + try { + setIsUploadingFiles(true); + const uploadPromises = Array.from(files).map(async (file) => { + const url = await uploadFile(file); + + return { + name: file.name, + contentType: file.type, + url: url, + }; + }); + attachments = await Promise.all(uploadPromises); + setIsUploadingFiles(false); + } catch (error) { + setIsUploadingFiles(false); + console.error('File upload error:', error); + setChatErrorModal({ + title: t('Upload Error'), + message: t('Failed to upload files. Please try again.'), + }); + return; + } + } + + if (!conversationId) { + // Save the event and files, then create the chat + setPendingFirstMessage({ event, attachments, forceWebSearch }); + // Save input and files to Zustand store before navigation + setPendingChat(input, files); + void createChatConversation( + { title: input.length > 100 ? `${input.slice(0, 97)}...` : input }, + { + onSuccess: (data: { id: string }) => { + setConversationId(data.id); + // Update the URL to /chat/[id]/ + void router.push(`/chat/${data.id}/`); + // Le message sera envoyé via le useEffect qui attend que useChat soit prêt + }, + }, + ); + return; + } + + // Prepare options with attachments + const options: Record = {}; + if (attachments.length > 0) { + options.experimental_attachments = attachments; + } + + lastSubmissionRef.current = { + input, + files, + event, + options: Object.keys(options).length > 0 ? options : undefined, + }; + + if (Object.keys(options).length > 0) { + baseHandleSubmit(event, options); + } else { + baseHandleSubmit(event); + } + // Attendre un peu avant de vider les fichiers pour s'assurer qu'ils sont traités + setTimeout(() => { + setFiles(null); + if (fileInputRef.current) { + fileInputRef.current.value = ''; + } + }, 100); + }, + [ + files, + conversationId, + uploadFile, + t, + forceWebSearch, + setPendingChat, + input, + createChatConversation, + setConversationId, + router, + baseHandleSubmit, + ], + ); + + // Synchronize conversationId state with prop when it changes + useEffect(() => { + // Ne réinitialiser que si on change vraiment de conversation (pas lors de la création) + if ( + initialConversationId && + initialConversationId !== conversationId && + !pendingFirstMessage + ) { + setConversationId(initialConversationId); + // Reset input when conversation changes + handleInputChange({ + target: { value: '' }, + } as ChangeEvent); + setHasInitialized(false); + } else if ( + !initialConversationId && + conversationId && + !pendingFirstMessage + ) { + // Si on n'a plus d'initialConversationId mais qu'on a un conversationId, ne rien faire + // (on est peut-être en train de créer une conversation) } - 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); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [initialConversationId, conversationId, pendingFirstMessage]); + + useEffect(() => { + if ( + conversationId && + pendingFirstMessage && + status === 'ready' && + apiUrl.includes(conversationId) + ) { + const pending = pendingFirstMessage; + + // Préparer les options avec les attachments + const options: Record = {}; + if (pending.attachments && pending.attachments.length > 0) { + options.experimental_attachments = pending.attachments; + } + + // Mettre à jour lastSubmissionRef pour le retry + lastSubmissionRef.current = { + input, + files, + event: pending.event, + options: Object.keys(options).length > 0 ? options : undefined, + }; + + setPendingFirstMessage(null); + requestAnimationFrame(() => { + requestAnimationFrame(() => { + if (Object.keys(options).length > 0) { + baseHandleSubmit(pending.event, options); + } else { + baseHandleSubmit(pending.event); + } + + // Nettoyer les fichiers après l'envoi + setFiles(null); + if (fileInputRef.current) { + fileInputRef.current.value = ''; + } + }); + }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [conversationId, status, baseHandleSubmit, pendingFirstMessage, apiUrl]); + + // Fetch initial conversation messages if initialConversationId is provided + useEffect(() => { + hasScrolledToBottomOnLoadRef.current = false; + let ignore = false; + async function fetchInitialMessages() { + if (initialConversationId && !pendingInput && !pendingFirstMessage) { + try { + const conversation = await getConversation({ + id: initialConversationId, + }); + if (!ignore) { + setInitialConversationMessages(conversation.messages); + setHasInitialized(true); + } + } catch { + if (!ignore) { + setInitialConversationMessages([]); + setHasInitialized(true); + } + } } } - }; + void fetchInitialMessages(); + return () => { + ignore = true; + }; + }, [initialConversationId, pendingInput, pendingFirstMessage]); + + const scrollToBottom = useCallback(() => { + if (chatContainerRef.current) { + chatContainerRef.current.scrollTo({ + top: chatContainerRef.current.scrollHeight, + behavior: hasInitialized ? 'smooth' : 'auto', + }); + } + }, [hasInitialized]); + + const stopGeneration = useCallback(async () => { + stopChat(); + + if (!conversationId) { + return; + } + + const response = await fetchAPI(`chats/${conversationId}/stop-streaming/`, { + method: 'POST', + }); + + if (!response.ok) { + throw new APIError( + 'Failed to stop the conversation', + await errorCauses(response), + ); + } + }, [stopChat, conversationId]); + + const toggleWebSearch = useCallback(() => { + toggleForceWebSearch(); + }, [toggleForceWebSearch]); + + const handleStop = useCallback(() => { + void stopGeneration(); + }, [stopGeneration]); + + const handleSubmitWrapper = useCallback( + (event: FormEvent) => { + void handleSubmit(event); + }, + [handleSubmit], + ); + + // Utiliser un ref pour messages pour éviter de recréer la fonction à chaque changement + const openSources = useCallback( + (messageId: string) => { + if (isSourceOpen === messageId) { + setIsSourceOpen(null); + return; + } + const message = messagesRef.current.find((msg) => msg.id === messageId); + if (message?.parts) { + const sourceParts = message.parts.filter( + (part) => part.type === 'source', + ); + if (sourceParts.length > 0) { + setIsSourceOpen(messageId); + } + } + }, + [isSourceOpen], // Plus besoin de messages dans les dépendances + ); + + // Mémoriser le calcul du dernier index assistant (évite de le recalculer pour chaque message) + const lastAssistantIndex = useMemo( + () => messages.findLastIndex((msg) => msg.role === 'assistant'), + [messages], + ); // Calculer la hauteur pour le message de streaming const calculateStreamingHeight = useCallback(() => { - if (messages.length <= 2) { - return; - } - if (chatContainerRef.current) { const container = chatContainerRef.current; const containerHeight = container.clientHeight; @@ -346,7 +476,6 @@ export const Chat = ({ } }, [messages]); - // Détecter l'arrivée d'un nouveau message user et retirer la hauteur de l'ancien useEffect(() => { if (status === 'streaming') { return; @@ -370,8 +499,7 @@ export const Chat = ({ if (status === 'submitted' || status === 'streaming') { calculateStreamingHeight(); } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [status]); + }, [status, calculateStreamingHeight]); // Scroller vers la question au moment du submit useEffect(() => { @@ -391,108 +519,7 @@ export const Chat = ({ messageElement?.scrollIntoView({ block: 'start', behavior: 'smooth' }); }); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [status]); - - // Synchronize conversationId state with prop when it changes (e.g., after navigation) - useEffect(() => { - setConversationId(initialConversationId); - // Reset input when conversation changes - if (initialConversationId !== conversationId) { - handleInputChange({ - target: { value: '' }, - } as ChangeEvent); - setHasInitialized(false); // Réinitialiser pour permettre le scroll au prochain chargement - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [initialConversationId, conversationId]); - - // On mount, if there is pending input/files, initialize state and set flag - useEffect(() => { - if ( - (pendingInput && pendingInput.trim()) || - (pendingFiles && pendingFiles.length > 0) - ) { - if (pendingInput) { - const syntheticEvent = { - target: { value: pendingInput }, - } as ChangeEvent; - handleInputChange(syntheticEvent); - } - if (pendingFiles) { - setFiles(pendingFiles); - } - setShouldAutoSubmit(true); - clearPendingChat(); - } else { - clearPendingChat(); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); - - // When shouldAutoSubmit is set, and input/files are ready, submit - useEffect(() => { - if (shouldAutoSubmit && (input.trim() || (files && files.length > 0))) { - // Create a synthetic event for form submission - const form = document.createElement('form'); - const syntheticFormEvent = { - preventDefault: () => {}, - target: form, - } as unknown as FormEvent; - void handleSubmit(syntheticFormEvent); - setShouldAutoSubmit(false); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [shouldAutoSubmit, input, files]); - - useEffect(() => { - if ( - shouldRetry && - lastSubmissionRef.current && - input === lastSubmissionRef.current.input - ) { - const { event } = lastSubmissionRef.current; - - void handleSubmit(event); - handleInputChange({ - target: { value: retryOriginalInputRef.current }, - } as ChangeEvent); - setFiles(retryOriginalFilesRef.current); - - setShouldRetry(false); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [shouldRetry, input, files]); - - // Fetch initial conversation messages if initialConversationId is provided and no pending input - useEffect(() => { - hasScrolledToBottomOnLoadRef.current = false; // Réinitialiser au début du chargement - let ignore = false; - async function fetchInitialMessages() { - if (initialConversationId && !pendingInput) { - try { - const conversation = await getConversation({ - id: initialConversationId, - }); - if (!ignore) { - setInitialConversationMessages(conversation.messages); - setHasInitialized(true); - } - } catch { - // Optionally handle error (e.g., setInitialConversationMessages([]) or show error) - if (!ignore) { - setInitialConversationMessages([]); - setHasInitialized(true); - } - } - } - } - void fetchInitialMessages(); - return () => { - ignore = true; - }; - // Only run when initialConversationId or pendingInput changes - }, [initialConversationId, pendingInput]); + }, [status, messages]); useEffect(() => { if ( @@ -513,109 +540,56 @@ export const Chat = ({ }); }); } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [hasInitialized, messages.length]); + }, [hasInitialized, conversationId, messages.length]); - // Custom handleSubmit to include attachments and handle chat creation - const handleSubmit = async (event: FormEvent) => { - event.preventDefault(); - - // Upload files to server and get URLs - let attachments: Attachment[] = []; - if (files && files.length > 0 && conversationId) { - try { - setIsUploadingFiles(true); - const uploadPromises = Array.from(files).map(async (file) => { - const url = await uploadFile(file); - - return { - name: file.name, - contentType: file.type, - url: url, - }; - }); - attachments = await Promise.all(uploadPromises); - setIsUploadingFiles(false); - } catch (error) { - setIsUploadingFiles(false); - console.error('File upload error:', error); - setChatErrorModal({ - title: t('Upload Error'), - message: t('Failed to upload files. Please try again.'), - }); - return; - } - } - - if (!conversationId) { - // Save the event and files, then create the chat - setPendingFirstMessage({ event, attachments, forceWebSearch }); - // Save input and files to Zustand store before navigation - setPendingChat(input, files); - void createChatConversation( - { title: input.length > 100 ? `${input.slice(0, 97)}...` : input }, - { - onSuccess: (data) => { - setConversationId(data.id); - // Update the URL to /chat/[id]/ - void router.push(`/chat/${data.id}/`); - // After setting the conversationId, submit the pending message - setTimeout(() => { - if (pendingFirstMessage) { - // Prepare options with attachments - const options: Record = {}; - if ( - pendingFirstMessage.attachments && - pendingFirstMessage.attachments.length > 0 - ) { - options.experimental_attachments = - pendingFirstMessage.attachments; - } - - if (Object.keys(options).length > 0) { - baseHandleSubmit(pendingFirstMessage.event, options); - } else { - baseHandleSubmit(pendingFirstMessage.event); - } - setFiles(null); - if (fileInputRef.current) { - fileInputRef.current.value = ''; - } - setPendingFirstMessage(null); - } - }, 0); - }, - }, - ); + const handleRetry = useCallback(() => { + if (!lastSubmissionRef.current || !setMessages) { return; } - // Prepare options with attachments - const options: Record = {}; - if (attachments.length > 0) { - options.experimental_attachments = attachments; + const { input: lastInput, files: lastFiles } = lastSubmissionRef.current; + + const lastAssistantIndex = messages.findLastIndex( + (msg) => msg.role === 'assistant', + ); + if (lastAssistantIndex !== -1) { + setMessages(messages.filter((_, index) => index !== lastAssistantIndex)); } - lastSubmissionRef.current = { - input, - files, - event, - options: Object.keys(options).length > 0 ? options : undefined, - }; + retryOriginalInputRef.current = input; + retryOriginalFilesRef.current = files; + handleInputChange({ + target: { value: lastInput }, + } as ChangeEvent); + setFiles(lastFiles); + setShouldRetry(true); + }, [ + lastSubmissionRef, + setMessages, + messages, + input, + files, + handleInputChange, + ]); - if (Object.keys(options).length > 0) { - baseHandleSubmit(event, options); - } else { - baseHandleSubmit(event); + useEffect(() => { + if ( + shouldRetry && + lastSubmissionRef.current && + input === lastSubmissionRef.current.input + ) { + const { event } = lastSubmissionRef.current; + + void handleSubmit(event); + handleInputChange({ + target: { value: retryOriginalInputRef.current }, + } as ChangeEvent); + setFiles(retryOriginalFilesRef.current); + + setShouldRetry(false); } - // Attendre un peu avant de vider les fichiers pour s'assurer qu'ils sont traités - setTimeout(() => { - setFiles(null); - if (fileInputRef.current) { - fileInputRef.current.value = ''; - } - }, 100); - }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [shouldRetry, input, files]); return ( { const isLastMessage = index === messages.length - 1; const isLastAssistantMessageInConversation = - message.role === 'assistant' && - index === - messages.findLastIndex((msg) => msg.role === 'assistant'); - const isFirstConversationMessage = messages.length <= 2; - const shouldApplyStreamingHeight = - isLastAssistantMessageInConversation && - isLastMessage && - streamingMessageHeight && - !isFirstConversationMessage; + message.role === 'assistant' && index === lastAssistantIndex; + const shouldApplyStreamingHeight = Boolean( + isLastAssistantMessageInConversation && isLastMessage, + ); const isCurrentlyStreaming = isLastAssistantMessageInConversation && (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} + isLastAssistantMessageInConversation={ + isLastAssistantMessageInConversation + } + shouldApplyStreamingHeight={shouldApplyStreamingHeight} + streamingMessageHeight={streamingMessageHeight} + isCurrentlyStreaming={isCurrentlyStreaming} + status={isCurrentlyStreaming ? status : 'ready'} // Ne passer le vrai status que si nécessaire + 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..c9c463f --- /dev/null +++ b/src/frontend/apps/conversations/src/features/chat/components/ChatMessage.tsx @@ -0,0 +1,386 @@ +import { + Message, + ReasoningUIPart, + SourceUIPart, + ToolInvocationUIPart, +} from '@ai-sdk/ui-utils'; +import 'katex/dist/katex.min.css'; +import { memo, useDeferredValue } 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, Text } from '@/components'; +import { useClipboard } from '@/hook'; +import { useResponsiveStore } from '@/stores'; + +import { AttachmentList } from './AttachmentList'; +import { CodeBlock } from './CodeBlock'; +import { FeedbackButtons } from './FeedbackButtons'; +import { SourceItemList } from './SourceItemList'; +import { ToolInvocationItem } from './ToolInvocationItem'; + +// Mémoriser les plugins Markdown en dehors du composant pour éviter les recréations +const remarkPlugins = [remarkGfm, remarkMath]; +const rehypePlugins = [ + [ + rehypePrettyCode, + { + theme: 'github-dark-dimmed', + }, + ], + rehypeKatex, +]; + +// Composants Markdown mémorisés +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const markdownComponents: any = { + // eslint-disable-next-line @typescript-eslint/no-unused-vars, @typescript-eslint/no-explicit-any + p: ({ node, ...props }: any) => ( + + ), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + a: ({ children, ...props }: any) => ( + + {children} + + ), + // eslint-disable-next-line @typescript-eslint/no-unused-vars, @typescript-eslint/no-explicit-any + pre: ({ node, children, ...props }: any) => ( + {children} + ), +}; + +// Composant Markdown mémorisé pour éviter les recalculs inutiles +const MemoizedMarkdown = memo(function MemoizedMarkdown({ + content, +}: { + content: string; +}) { + return ( + + {content} + + ); +}); + +interface ChatMessageProps { + message: Message; + isLastAssistantMessageInConversation: boolean; + shouldApplyStreamingHeight: boolean; + streamingMessageHeight: number | null; + isCurrentlyStreaming: boolean; + status: 'idle' | 'streaming' | 'submitted' | 'ready' | 'error'; + 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, + isLastAssistantMessageInConversation, + shouldApplyStreamingHeight, + streamingMessageHeight, + isCurrentlyStreaming, + status, + isSourceOpen, + conversationId, + onOpenSources, + getMetadata, +}: ChatMessageProps) { + const { t } = useTranslation(); + const copyToClipboard = useClipboard(); + const { isMobile } = useResponsiveStore(); + + const deferredContent = useDeferredValue(message.content); + + const contentToRender = + message.role === 'assistant' ? deferredContent : message.content; + + return ( + + + {message.experimental_attachments && + message.experimental_attachments.length > 0 && ( + + + + )} + + {message.content && ( + +

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

+ {message.role === 'user' ? ( + + {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')} + + + ); + })()} + + + {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 ( + + + + ); + })()} +
+
+
+ ); +}); diff --git a/src/frontend/apps/conversations/src/features/chat/hooks/index.ts b/src/frontend/apps/conversations/src/features/chat/hooks/index.ts index ec6a610..45845b9 100644 --- a/src/frontend/apps/conversations/src/features/chat/hooks/index.ts +++ b/src/frontend/apps/conversations/src/features/chat/hooks/index.ts @@ -1,2 +1,3 @@ export { useChatScroll } from './useChatScroll'; export { useSourceMetadataCache } from './useSourceMetadata'; +export { useModelSelection } from './useModelSelection'; diff --git a/src/frontend/apps/conversations/src/features/chat/hooks/useModelSelection.ts b/src/frontend/apps/conversations/src/features/chat/hooks/useModelSelection.ts new file mode 100644 index 0000000..0d21cba --- /dev/null +++ b/src/frontend/apps/conversations/src/features/chat/hooks/useModelSelection.ts @@ -0,0 +1,44 @@ +import { useEffect, useRef, useState } from 'react'; + +import { LLMModel, useLLMConfiguration } from '../api/useLLMConfiguration'; +import { useChatPreferencesStore } from '../stores/useChatPreferencesStore'; + +export const useModelSelection = () => { + const { data: llmConfig } = useLLMConfiguration(); + const { selectedModelHrid, setSelectedModelHrid } = useChatPreferencesStore(); + const [selectedModel, setSelectedModel] = useState(null); + const hasInitializedRef = useRef(false); + + useEffect(() => { + // Ne s'exécuter qu'une seule fois quand llmConfig est chargé + if (llmConfig?.models && !hasInitializedRef.current) { + let modelToSelect: LLMModel | undefined; + + if (selectedModelHrid) { + // Try to find the previously selected model + modelToSelect = llmConfig.models.find( + (model) => + model.hrid === selectedModelHrid && model.is_active !== false, + ); + } + + // If no saved model or saved model not found/inactive, use default + if (!modelToSelect) { + modelToSelect = llmConfig.models.find((model) => model.is_default); + } + + if (modelToSelect) { + setSelectedModel(modelToSelect); + setSelectedModelHrid(modelToSelect.hrid); + hasInitializedRef.current = true; + } + } + }, [llmConfig?.models, selectedModelHrid, setSelectedModelHrid]); + + const handleModelSelect = (model: LLMModel) => { + setSelectedModel(model); + setSelectedModelHrid(model.hrid); + }; + + return { selectedModel, handleModelSelect }; +};