medically / frontend /src /components /ChatInterface.jsx
AxL95's picture
Update frontend/src/components/ChatInterface.jsx
032d01b verified
raw
history blame
13.6 kB
import React, { useState, useRef, useEffect } from 'react';
import ReactMarkdown from 'react-markdown';
import Avatar from './Avatar.jsx';
import '../App.css';
const ChatInterface = ({
messages = [],
setMessages = () => {},
onMessageSent = () => {},
activeConversationId,
saveBotResponse,
toLogin,
onNewChat = () => {},
refreshConversationList = () => {}
}) => {
const [inputMessage, setInputMessage] = useState('');
const [isLoading, setIsLoading] = useState(false);
const messagesEndRef = useRef(null);
const textareaRef = useRef(null);
const [isStreaming, setIsStreaming] = useState(false);
const [tokenLimitReached, setTokenLimitReached] = useState(false);
const [hasInteractionStarted, setHasInteractionStarted] = useState(false);
const [currentStreamId, setCurrentStreamId] = useState(null);
// Pour optimiser les performances du streaming
const accumulatedText = useRef('');
const updateThreshold = 1;
const updateIntervalRef = useRef(null);
const scrollToBottom = () => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
};
useEffect(() => {
scrollToBottom();
if (updateIntervalRef.current) {
clearInterval(updateIntervalRef.current);
updateIntervalRef.current = null;
}
if (isStreaming && currentStreamId) {
updateIntervalRef.current = setInterval(() => {
if (accumulatedText.current.length > 0) {
setMessages(prev => {
return prev.map(msg =>
msg.id === currentStreamId
? { ...msg, text: msg.text + accumulatedText.current }
: msg
);
});
accumulatedText.current = '';
}
}, 100);
}
return () => {
if (updateIntervalRef.current) {
clearInterval(updateIntervalRef.current);
}
};
}, [isStreaming, currentStreamId, messages]);
const sendMessage = async (message) => {
try {
setHasInteractionStarted(true);
setIsLoading(true);
const userMessageId = `user-${Date.now()}`;
setMessages(prev => [...prev, {
sender: 'user',
text: message,
id: userMessageId
}]);
const updatedConversationId = await onMessageSent(message);
const streamMessageId = `bot-${Date.now()}`;
setCurrentStreamId(streamMessageId);
setMessages(prev => {
const userMessageExists = prev.some(m => m.id === userMessageId);
const updatedMessages = userMessageExists ? prev : [
...prev,
{ sender: 'user', text: message, id: userMessageId }
];
return [...updatedMessages, {
sender: 'bot',
text: '',
id: streamMessageId
}];
});
setIsLoading(false);
setIsStreaming(true);
const response = await fetch('/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({
message,
conversation_id: activeConversationId || updatedConversationId,
skip_save: false // Le backend gère la sauvegarde
}),
});
if (!response.ok) {
const errorData = await response.json();
if (errorData.error === 'token_limit_exceeded') {
setIsStreaming(false);
setCurrentStreamId(null);
setTokenLimitReached(true);
setMessages(prev => [...prev.filter(m => m.id !== streamMessageId), {
sender: 'bot',
text: "⚠️ **Limite de taille de conversation atteinte**\n\nCette conversation est devenue trop longue. Pour continuer à discuter, veuillez créer une nouvelle conversation."
}]);
return;
}
throw new Error(`Chat API error ${response.status}`);
}
if (response.headers.get('content-type')?.includes('text/event-stream') && response.body) {
const reader = response.body.getReader();
const decoder = new TextDecoder();
let fullText = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = decoder.decode(value);
const lines = chunk.split('\n\n');
for (const line of lines) {
if (line.startsWith('data: ')) {
try {
const data = JSON.parse(line.slice(5));
if (data.type === 'start') {
console.log("Début du streaming");
fullText = '';
accumulatedText.current = '';
}
else if (data.type === 'end') {
console.log("SSE End received");
setIsStreaming(false);
setCurrentStreamId(null);
setIsLoading(false);
setMessages(prev =>
prev.map(msg =>
msg.id === streamMessageId
? { ...msg, sender: 'bot', text: fullText }
: msg
)
);
if (typeof refreshConversationList === 'function') {
setTimeout(refreshConversationList, 100);
}
return;
}
if (data.content) {
fullText += data.content;
// Update immediately without accumulation
setMessages(prev => {
return prev.map(msg =>
msg.id === streamMessageId
? { ...msg, text: fullText }
: msg
);
});
// Ensure scroll
requestAnimationFrame(scrollToBottom);
}
else if (data.type === 'error') {
console.error("SSE Error received:", data.error);
setIsStreaming(false);
setCurrentStreamId(null);
setIsLoading(false);
setMessages(prev =>
prev.map(msg =>
msg.id === streamMessageId
? { sender: 'bot', text: "Désolé, une erreur s'est produite." }
: msg
)
);
}
} catch (e) {
console.error('Error parsing SSE data:', e, line);
}
}
}
}
}
else {
console.log("Received non-streaming response.");
const responseData = await response.json();
setIsStreaming(false);
setCurrentStreamId(null);
setIsLoading(false);
setMessages(prev =>
prev.map(msg =>
msg.id === streamMessageId
? { sender: 'bot', text: responseData.response, id: streamMessageId }
: msg
)
);
if (typeof refreshConversationList === 'function') {
setTimeout(refreshConversationList, 100);
}
}
} catch (error) {
console.error('Erreur lors de l\'envoi/réception du message:', error);
setIsStreaming(false);
setCurrentStreamId(null);
setIsLoading(false);
setMessages(prev => {
const filteredMessages = prev.filter(m => m.id !== currentStreamId);
return [...filteredMessages,
{ sender: 'bot', text: "Désolé, une erreur s'est produite. Veuillez réessayer." }
];
});
}
};
const handleCreateNewConversation = () => {
onNewChat();
setTokenLimitReached(false);
setHasInteractionStarted(false);
};
useEffect(() => {
if (activeConversationId === null && messages.length === 0) {
setHasInteractionStarted(false);
}
}, [activeConversationId, messages]);
const handleSubmit = (e) => {
e.preventDefault();
const txt = inputMessage.trim();
if (!txt) return;
sendMessage(txt);
setInputMessage('');
if (textareaRef.current) textareaRef.current.style.height = 'auto';
};
const isMarkdown = (text, sender) => {
if (sender === 'bot') {
return true;
}
return /(?:\*\*|__|##|\*|_|`|>|\d+\.\s|\-\s|\[.*\]\(.*\))/.test(text);
};
return (
<div className="chat-container">
{messages.length === 0 && !hasInteractionStarted ? (
<>
<div className="chat-header">
<h2 className="chat-title">Medic.ial</h2>
<Avatar onClick={toLogin} />
</div>
<div className="no-messages-view">
<div className="welcome-content">
<div className="welcome-message">
<p>Bonjour ! Comment puis-je vous aider aujourd'hui ? 🧑‍⚕️</p>
</div>
<div className="input-container centered">
<form onSubmit={handleSubmit} className="input-form">
<textarea
value={inputMessage}
onChange={(e) => setInputMessage(e.target.value)}
placeholder="Posez une question..."
disabled={isLoading}
rows="1"
ref={textareaRef}
className="input-textarea"
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSubmit(e);
}
}}
onInput={(e) => {
e.target.style.height = 'auto';
e.target.style.height = `${e.target.scrollHeight}px`;
}}
/>
<button type="submit" disabled={isLoading || !inputMessage.trim()}>
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#e3e3e3">
<path d="M120-160v-240l320-80-320-80v-240l760 320-760 320Z"/>
</svg>
</button>
</form>
</div>
</div>
</div>
</>
) : (
<>
<div className="chat-header">
<Avatar onClick={toLogin} />
<h2 className="chat-title">Medic.ial</h2>
</div>
<div className="messages-container">
{messages.map((msg, index) => {
const isActiveStreaming = isStreaming && msg.id === currentStreamId;
return (
<div key={msg.id || index} className={`message ${msg.sender}`}>
<div className={`message-content ${isActiveStreaming ? 'streaming-message' : ''}`}>
{isMarkdown(msg.text, msg.sender) ?
<ReactMarkdown>{msg.text}</ReactMarkdown> :
<span>{msg.text}</span>
}
{isActiveStreaming && (
<span className="typing-indicator"></span>
)}
</div>
</div>
);
})}
{tokenLimitReached && (
<div className="token-limit-warning">
<button
className="new-conversation-button"
onClick={handleCreateNewConversation}
>
Démarrer une nouvelle conversation
</button>
</div>
)}
{isLoading && (
<div className="message bot">
<div className="message-content loading">
<span>.</span><span>.</span><span>.</span>
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
<div className="input-container">
<form onSubmit={handleSubmit} className="input-form">
<textarea
value={inputMessage}
onChange={(e) => setInputMessage(e.target.value)}
placeholder={tokenLimitReached ? "Créez une nouvelle conversation pour continuer..." : "Tapez votre message ici..."}
disabled={isLoading || tokenLimitReached}
rows="1"
ref={textareaRef}
className="input-textarea"
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSubmit(e);
}
}}
onInput={(e) => {
e.target.style.height = 'auto';
e.target.style.height = `${e.target.scrollHeight}px`;
}}
/>
<button type="submit" disabled={isLoading || !inputMessage.trim() || tokenLimitReached}>
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#e3e3e3">
<path d="M120-160v-240l320-80-320-80v-240l760 320-760 320Z"/>
</svg>
</button>
</form>
<figcaption className="disclaimer-text">
Medic.ial est sujet à faire des erreurs. Vérifiez les informations fournies.
</figcaption>
</div>
</>
)}
</div>
);
};
export default ChatInterface;