diff --git a/CHANGELOG.md b/CHANGELOG.md index 0017a29..f0333d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,7 +15,7 @@ and this project adheres to ## [0.0.11] - 2026-01-16 ### Changed - +- 🐛(front) optimize chat - 📦️(front) update react - ✨(chat) Generate and edit conversation title 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 a620c48..2198a11 100644 --- a/src/frontend/apps/conversations/src/features/chat/components/Chat.tsx +++ b/src/frontend/apps/conversations/src/features/chat/components/Chat.tsx @@ -227,6 +227,10 @@ export const Chat = ({ const stopGeneration = async () => { stopChat(); + if (!conversationId) { + return; + } + const response = await fetchAPI(`chats/${conversationId}/stop-streaming/`, { method: 'POST', }); 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 }; +};