🐛(front) optimize chat

Simplified chat rendering
This commit is contained in:
Eléonore Voisin
2025-12-18 21:24:59 +01:00
committed by charles
parent 09b003856b
commit 944d69aede
6 changed files with 438 additions and 2 deletions
+1 -1
View File
@@ -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
@@ -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 (
<div role="status">
@@ -227,6 +227,10 @@ export const Chat = ({
const stopGeneration = async () => {
stopChat();
if (!conversationId) {
return;
}
const response = await fetchAPI(`chats/${conversationId}/stop-streaming/`, {
method: 'POST',
});
@@ -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) => (
<Text
as="p"
$css="display: block"
$theme="greyscale"
$variation="850"
{...props}
/>
),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
a: ({ children, ...props }: any) => (
<a target="_blank" rel="noopener noreferrer" {...props}>
{children}
</a>
),
// eslint-disable-next-line @typescript-eslint/no-unused-vars, @typescript-eslint/no-explicit-any
pre: ({ node, children, ...props }: any) => (
<CodeBlock {...props}>{children}</CodeBlock>
),
};
// Composant Markdown mémorisé pour éviter les recalculs inutiles
const MemoizedMarkdown = memo(function MemoizedMarkdown({
content,
}: {
content: string;
}) {
return (
<MarkdownHooks
remarkPlugins={remarkPlugins}
// eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment
rehypePlugins={rehypePlugins as any} // Type mismatch with react-markdown types
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
components={markdownComponents}
>
{content}
</MarkdownHooks>
);
});
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 (
<Box
key={message.id}
data-message-id={message.id}
$css={`
display: flex;
width: 100%;
margin: auto;
margin-bottom: ${isLastAssistantMessageInConversation ? '30px' : '0px'};
color: var(--c--theme--colors--greyscale-850);
padding-left: 12px;
padding-right: 12px;
max-width: 750px;
text-align: left;
overflow-wrap: anywhere;
flex-direction: ${message.role === 'user' ? 'row-reverse' : 'row'};
`}
>
<Box
$display="block"
$width={`${message.role === 'user' ? 'auto' : '100%'}`}
>
{message.experimental_attachments &&
message.experimental_attachments.length > 0 && (
<Box>
<AttachmentList
attachments={message.experimental_attachments}
isReadOnly={true}
/>
</Box>
)}
<Box
$radius="8px"
$width={`${message.role === 'user' ? 'auto' : '100%'}`}
$maxWidth="100%"
$padding={`${message.role === 'user' ? '12px' : '0'}`}
$margin={{ vertical: 'base' }}
$background={`${message.role === 'user' ? '#EEF1F4' : 'white'}`}
$css={`
display: inline-block;
float: right;
${shouldApplyStreamingHeight ? `min-height: ${streamingMessageHeight}px;` : ''}
`}
>
{message.content && (
<Box
className="mainContent-chat"
data-testid={
message.role === 'assistant'
? 'assistant-message-content'
: undefined
}
$padding={{ all: 'xxs' }}
>
<p className="sr-only">
{message.role === 'user'
? t('You said: ')
: t('Assistant IA replied: ')}
</p>
{message.role === 'user' ? (
<Text
as="p"
$css="white-space: pre-wrap; display: block;"
$theme="greyscale"
$variation="850"
>
{message.content}
</Text>
) : (
<MemoizedMarkdown content={contentToRender} />
)}
</Box>
)}
<Box $direction="column" $gap="2">
{isCurrentlyStreaming &&
isLastAssistantMessageInConversation &&
status === 'streaming' &&
message.parts?.some(
(part) =>
part.type === 'tool-invocation' &&
part.toolInvocation.toolName !== 'document_parsing',
) && (
<Box
$direction="row"
$align="center"
$gap="6px"
$width="100%"
$maxWidth="750px"
$margin={{
all: 'auto',
top: 'base',
bottom: 'md',
}}
>
<Text $variation="600" $size="md">
{(() => {
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...');
})()}
</Text>
</Box>
)}
{message.parts
?.filter(
(part) =>
part.type === 'reasoning' || part.type === 'tool-invocation',
)
.map(
(
part: ReasoningUIPart | ToolInvocationUIPart,
partIndex: number,
) =>
part.type === 'reasoning' ? (
<Box
key={`reasoning-${partIndex}`}
$background="var(--c--theme--colors--greyscale-100)"
$color="var(--c--theme--colors--greyscale-500)"
$padding={{ all: 'sm' }}
$radius="md"
$css="font-size: 0.9em;"
>
{part.reasoning}
</Box>
) : part.type === 'tool-invocation' &&
isCurrentlyStreaming &&
isLastAssistantMessageInConversation ? (
<ToolInvocationItem
key={`tool-invocation-${partIndex}`}
toolInvocation={part.toolInvocation}
status={status}
hideSearchLoader={true}
/>
) : null,
)}
</Box>
{message.role === 'assistant' &&
!(
isLastAssistantMessageInConversation && status === 'streaming'
) && (
<Box
$css="color: #222631; font-size: 12px;"
$direction="row"
$align="center"
$justify="space-between"
$gap="6px"
$margin={{ top: 'base' }}
>
<Box $direction="row" $gap="4px">
<Box
$direction="row"
$align="center"
$gap="4px"
className="c__button--neutral action-chat-button"
onClick={() => copyToClipboard(message.content)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
copyToClipboard(message.content);
}
}}
role="button"
tabIndex={0}
>
<Icon
iconName="content_copy"
$theme="greyscale"
$variation="550"
$size="16px"
className="action-chat-button-icon"
/>
{!isMobile && (
<Text $theme="greyscale" $variation="550">
{t('Copy')}
</Text>
)}
</Box>
{message.parts?.some((part) => part.type === 'source') &&
(() => {
const sourceCount =
message.parts?.filter((part) => part.type === 'source')
.length || 0;
return (
<Box
$direction="row"
$align="center"
$gap="4px"
className={`c__button--neutral action-chat-button ${isSourceOpen === message.id ? 'action-chat-button--open' : ''}`}
onClick={() => onOpenSources(message.id)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onOpenSources(message.id);
}
}}
role="button"
tabIndex={0}
>
<Icon
iconName="book"
$theme="greyscale"
$variation="550"
$size="16px"
className="action-chat-button-icon"
/>
<Text
$theme="greyscale"
$variation="550"
$weight="500"
$size="12px"
>
{t('Show')} {sourceCount}{' '}
{sourceCount !== 1 ? t('sources') : t('source')}
</Text>
</Box>
);
})()}
</Box>
<Box $direction="row" $gap="4px">
{conversationId &&
message.id &&
message.id.startsWith('trace-') && (
<FeedbackButtons
conversationId={conversationId}
messageId={message.id}
/>
)}
</Box>
</Box>
)}
{message.parts &&
isSourceOpen === message.id &&
(() => {
const sourceParts = message.parts.filter(
(part): part is SourceUIPart => part.type === 'source',
);
return (
<Box
$css={`
animation: fade-in 0.2s ease-out;
`}
>
<SourceItemList
parts={sourceParts}
getMetadata={getMetadata}
/>
</Box>
);
})()}
</Box>
</Box>
</Box>
);
});
@@ -1,2 +1,3 @@
export { useChatScroll } from './useChatScroll';
export { useSourceMetadataCache } from './useSourceMetadata';
export { useModelSelection } from './useModelSelection';
@@ -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<LLMModel | null>(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 };
};