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 };
+};