Fix thinking state management and UI improvements for chat messages

- Reset expansion state on new thinking
- Prevent content leakage during thinking
- Update thinkingTime type to allow null
- Add memory leak prevention cleanup
- Add missing i18n translations
This commit is contained in:
yangdx 2025-09-08 18:47:41 +08:00
parent 2ba10dbb5b
commit eeff0d5c67
8 changed files with 112 additions and 43 deletions

View file

@ -101,7 +101,7 @@ export type Message = {
content: string content: string
thinkingContent?: string thinkingContent?: string
displayContent?: string displayContent?: string
thinkingTime?: number thinkingTime?: number | null
} }
export type QueryRequest = { export type QueryRequest = {

View file

@ -39,11 +39,21 @@ export const ChatMessage = ({ message }: { message: MessageWithError }) => { //
// Directly use props passed from the parent. // Directly use props passed from the parent.
const { thinkingContent, displayContent, thinkingTime, isThinking } = message const { thinkingContent, displayContent, thinkingTime, isThinking } = message
// Reset expansion state when new thinking starts
useEffect(() => {
if (isThinking) {
// When thinking starts, always reset to collapsed state
setIsThinkingExpanded(false)
}
}, [isThinking, message.id])
// The content to display is now non-ambiguous. // The content to display is now non-ambiguous.
const finalThinkingContent = thinkingContent const finalThinkingContent = thinkingContent
// For user messages, displayContent will be undefined, so we fall back to content. // For user messages, displayContent will be undefined, so we fall back to content.
// For assistant messages, we prefer displayContent. // For assistant messages, we prefer displayContent and don't fallback to avoid content leakage during thinking
const finalDisplayContent = message.role === 'user' ? message.content : displayContent const finalDisplayContent = message.role === 'user'
? message.content
: displayContent || ''
// Load KaTeX dynamically // Load KaTeX dynamically
useEffect(() => { useEffect(() => {
@ -106,7 +116,12 @@ export const ChatMessage = ({ message }: { message: MessageWithError }) => { //
<div className="mb-2"> <div className="mb-2">
<div <div
className="flex items-center text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 transition-colors duration-200 text-sm cursor-pointer select-none" className="flex items-center text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 transition-colors duration-200 text-sm cursor-pointer select-none"
onClick={() => setIsThinkingExpanded(!isThinkingExpanded)} onClick={() => {
// Allow expansion when there's thinking content, even during thinking process
if (finalThinkingContent && finalThinkingContent.trim() !== '') {
setIsThinkingExpanded(!isThinkingExpanded)
}
}}
> >
{isThinking ? ( {isThinking ? (
<> <>
@ -116,10 +131,17 @@ export const ChatMessage = ({ message }: { message: MessageWithError }) => { //
) : ( ) : (
typeof thinkingTime === 'number' && <span>{t('retrievePanel.chatMessage.thinkingTime', { time: thinkingTime })}</span> typeof thinkingTime === 'number' && <span>{t('retrievePanel.chatMessage.thinkingTime', { time: thinkingTime })}</span>
)} )}
{finalThinkingContent && <ChevronDownIcon className={`ml-2 size-4 shrink-0 transition-transform ${isThinkingExpanded ? 'rotate-180' : ''}`} />} {/* Show chevron when there's thinking content, even during thinking process */}
{finalThinkingContent && finalThinkingContent.trim() !== '' && <ChevronDownIcon className={`ml-2 size-4 shrink-0 transition-transform ${isThinkingExpanded ? 'rotate-180' : ''}`} />}
</div> </div>
{isThinkingExpanded && finalThinkingContent && ( {/* Show thinking content when expanded and content exists, even during thinking process */}
{isThinkingExpanded && finalThinkingContent && finalThinkingContent.trim() !== '' && (
<div className="mt-2 pl-4 border-l-2 border-primary/20 text-sm prose dark:prose-invert max-w-none break-words prose-p:my-1 prose-headings:my-2"> <div className="mt-2 pl-4 border-l-2 border-primary/20 text-sm prose dark:prose-invert max-w-none break-words prose-p:my-1 prose-headings:my-2">
{isThinking && (
<div className="mb-2 text-xs text-gray-400 dark:text-gray-500 italic">
{t('retrievePanel.chatMessage.thinkingInProgress', 'Thinking in progress...')}
</div>
)}
<ReactMarkdown <ReactMarkdown
remarkPlugins={[remarkGfm, remarkMath]} remarkPlugins={[remarkGfm, remarkMath]}
rehypePlugins={[ rehypePlugins={[
@ -170,7 +192,12 @@ export const ChatMessage = ({ message }: { message: MessageWithError }) => { //
)} )}
</div> </div>
)} )}
{message.content === '' && !isThinking && !thinkingTime && <LoaderIcon className="animate-spin duration-2000" />} {/* Check for empty string specifically */} {(() => {
// More comprehensive loading state check
const hasVisibleContent = finalDisplayContent && finalDisplayContent.trim() !== '';
const isLoadingState = !hasVisibleContent && !isThinking && !thinkingTime;
return isLoadingState && <LoaderIcon className="animate-spin duration-2000" />;
})()}
</div> </div>
) )
} }

View file

@ -68,6 +68,16 @@ export default function RetrievalTesting() {
const messagesEndRef = useRef<HTMLDivElement>(null) const messagesEndRef = useRef<HTMLDivElement>(null)
const messagesContainerRef = useRef<HTMLDivElement>(null) const messagesContainerRef = useRef<HTMLDivElement>(null)
// Add cleanup effect for memory leak prevention
useEffect(() => {
// Component cleanup - reset timer state to prevent memory leaks
return () => {
if (thinkingStartTime.current) {
thinkingStartTime.current = null;
}
};
}, []);
// Scroll to bottom function - restored smooth scrolling with better handling // Scroll to bottom function - restored smooth scrolling with better handling
const scrollToBottom = useCallback(() => { const scrollToBottom = useCallback(() => {
// Set flag to indicate this is a programmatic scroll // Set flag to indicate this is a programmatic scroll
@ -116,6 +126,9 @@ export default function RetrievalTesting() {
// Clear error message // Clear error message
setInputError('') setInputError('')
// Reset thinking timer state for new query to prevent confusion
thinkingStartTime.current = null
// Create messages // Create messages
// Save the original input (with prefix if any) in userMessage.content for display // Save the original input (with prefix if any) in userMessage.content for display
const userMessage: MessageWithError = { const userMessage: MessageWithError = {
@ -128,7 +141,11 @@ export default function RetrievalTesting() {
id: generateUniqueId(), // Use browser-compatible ID generation id: generateUniqueId(), // Use browser-compatible ID generation
content: '', content: '',
role: 'assistant', role: 'assistant',
mermaidRendered: false mermaidRendered: false,
thinkingTime: null, // Explicitly initialize to null
thinkingContent: undefined, // Explicitly initialize to undefined
displayContent: undefined, // Explicitly initialize to undefined
isThinking: false // Explicitly initialize to false
} }
const prevMessages = [...messages] const prevMessages = [...messages]
@ -160,10 +177,10 @@ export default function RetrievalTesting() {
} }
// Real-time parsing for streaming // Real-time parsing for streaming
const thinkStartTag = '<think>'; const thinkStartTag = '<think>'
const thinkEndTag = '</think>'; const thinkEndTag = '</think>'
const thinkStartIndex = assistantMessage.content.indexOf(thinkStartTag); const thinkStartIndex = assistantMessage.content.indexOf(thinkStartTag)
const thinkEndIndex = assistantMessage.content.indexOf(thinkEndTag); const thinkEndIndex = assistantMessage.content.indexOf(thinkEndTag)
if (thinkStartIndex !== -1) { if (thinkStartIndex !== -1) {
if (thinkEndIndex !== -1) { if (thinkEndIndex !== -1) {
@ -173,20 +190,21 @@ export default function RetrievalTesting() {
const duration = (Date.now() - thinkingStartTime.current) / 1000 const duration = (Date.now() - thinkingStartTime.current) / 1000
assistantMessage.thinkingTime = parseFloat(duration.toFixed(2)) assistantMessage.thinkingTime = parseFloat(duration.toFixed(2))
} }
assistantMessage.thinkingContent = assistantMessage.content.substring(thinkStartIndex + thinkStartTag.length, thinkEndIndex).trim() assistantMessage.thinkingContent = assistantMessage.content
.substring(thinkStartIndex + thinkStartTag.length, thinkEndIndex)
.trim()
assistantMessage.displayContent = assistantMessage.content.substring(thinkEndIndex + thinkEndTag.length).trim() assistantMessage.displayContent = assistantMessage.content.substring(thinkEndIndex + thinkEndTag.length).trim()
} else { } else {
// Still thinking // Still thinking
assistantMessage.isThinking = true; assistantMessage.isThinking = true
assistantMessage.thinkingContent = assistantMessage.content.substring(thinkStartIndex + thinkStartTag.length); assistantMessage.thinkingContent = assistantMessage.content.substring(thinkStartIndex + thinkStartTag.length)
assistantMessage.displayContent = ''; assistantMessage.displayContent = ''
} }
} else { } else {
assistantMessage.isThinking = false; assistantMessage.isThinking = false
assistantMessage.displayContent = assistantMessage.content; assistantMessage.displayContent = assistantMessage.content
} }
// Detect if the assistant message contains a complete mermaid code block // Detect if the assistant message contains a complete mermaid code block
// Simple heuristic: look for ```mermaid ... ``` // Simple heuristic: look for ```mermaid ... ```
const mermaidBlockRegex = /```mermaid\s+([\s\S]+?)```/g const mermaidBlockRegex = /```mermaid\s+([\s\S]+?)```/g
@ -201,16 +219,21 @@ export default function RetrievalTesting() {
} }
assistantMessage.mermaidRendered = mermaidRendered assistantMessage.mermaidRendered = mermaidRendered
// Single unified update to avoid race conditions
setMessages((prev) => { setMessages((prev) => {
const newMessages = [...prev] const newMessages = [...prev]
const lastMessage = newMessages[newMessages.length - 1] const lastMessage = newMessages[newMessages.length - 1]
if (lastMessage.role === 'assistant') { if (lastMessage && lastMessage.id === assistantMessage.id) {
lastMessage.content = assistantMessage.content; // Update all properties at once to maintain consistency
lastMessage.thinkingContent = assistantMessage.thinkingContent; Object.assign(lastMessage, {
lastMessage.displayContent = assistantMessage.displayContent; content: assistantMessage.content,
lastMessage.isThinking = assistantMessage.isThinking; thinkingContent: assistantMessage.thinkingContent,
lastMessage.isError = isError; displayContent: assistantMessage.displayContent,
lastMessage.mermaidRendered = assistantMessage.mermaidRendered; isThinking: assistantMessage.isThinking,
isError: isError,
mermaidRendered: assistantMessage.mermaidRendered,
thinkingTime: assistantMessage.thinkingTime
})
} }
return newMessages return newMessages
}) })
@ -261,21 +284,29 @@ export default function RetrievalTesting() {
setIsLoading(false) setIsLoading(false)
isReceivingResponseRef.current = false isReceivingResponseRef.current = false
// Final calculation for thinking time, only if not already calculated // Enhanced cleanup with error handling to prevent memory leaks
if (assistantMessage.thinkingContent && thinkingStartTime.current && !assistantMessage.thinkingTime) { try {
const duration = (Date.now() - thinkingStartTime.current) / 1000 // Final calculation for thinking time, only if not already calculated
assistantMessage.thinkingTime = parseFloat(duration.toFixed(2)) if (assistantMessage.thinkingContent && thinkingStartTime.current && !assistantMessage.thinkingTime) {
} const duration = (Date.now() - thinkingStartTime.current) / 1000
// Ensure isThinking is false at the very end assistantMessage.thinkingTime = parseFloat(duration.toFixed(2))
assistantMessage.isThinking = false; }
// Always reset the timer at the end of a query } catch (error) {
if (thinkingStartTime.current) { console.error('Error calculating thinking time:', error)
} finally {
// Ensure cleanup happens regardless of errors
assistantMessage.isThinking = false;
thinkingStartTime.current = null; thinkingStartTime.current = null;
} }
useSettingsStore // Save history with error handling
.getState() try {
.setRetrievalHistory([...prevMessages, userMessage, assistantMessage]) useSettingsStore
.getState()
.setRetrievalHistory([...prevMessages, userMessage, assistantMessage])
} catch (error) {
console.error('Error saving retrieval history:', error)
}
} }
}, },
[inputValue, isLoading, messages, setMessages, t, scrollToBottom] [inputValue, isLoading, messages, setMessages, t, scrollToBottom]

View file

@ -337,7 +337,10 @@
"retrievePanel": { "retrievePanel": {
"chatMessage": { "chatMessage": {
"copyTooltip": "نسخ إلى الحافظة", "copyTooltip": "نسخ إلى الحافظة",
"copyError": "فشل نسخ النص إلى الحافظة" "copyError": "فشل نسخ النص إلى الحافظة",
"thinking": "جاري التفكير...",
"thinkingTime": "وقت التفكير {{time}} ثانية",
"thinkingInProgress": "التفكير قيد التقدم..."
}, },
"retrieval": { "retrieval": {
"startPrompt": "ابدأ الاسترجاع بكتابة استفسارك أدناه", "startPrompt": "ابدأ الاسترجاع بكتابة استفسارك أدناه",

View file

@ -339,7 +339,8 @@
"copyTooltip": "Copy to clipboard", "copyTooltip": "Copy to clipboard",
"copyError": "Failed to copy text to clipboard", "copyError": "Failed to copy text to clipboard",
"thinking": "Thinking...", "thinking": "Thinking...",
"thinkingTime": "Thinking time {{time}}s" "thinkingTime": "Thinking time {{time}}s",
"thinkingInProgress": "Thinking in progress..."
}, },
"retrieval": { "retrieval": {
"startPrompt": "Start a retrieval by typing your query below", "startPrompt": "Start a retrieval by typing your query below",

View file

@ -337,7 +337,10 @@
"retrievePanel": { "retrievePanel": {
"chatMessage": { "chatMessage": {
"copyTooltip": "Copier dans le presse-papiers", "copyTooltip": "Copier dans le presse-papiers",
"copyError": "Échec de la copie du texte dans le presse-papiers" "copyError": "Échec de la copie du texte dans le presse-papiers",
"thinking": "Réflexion en cours...",
"thinkingTime": "Temps de réflexion {{time}}s",
"thinkingInProgress": "Réflexion en cours..."
}, },
"retrieval": { "retrieval": {
"startPrompt": "Démarrez une récupération en tapant votre requête ci-dessous", "startPrompt": "Démarrez une récupération en tapant votre requête ci-dessous",

View file

@ -339,7 +339,8 @@
"copyTooltip": "复制到剪贴板", "copyTooltip": "复制到剪贴板",
"copyError": "复制文本到剪贴板失败", "copyError": "复制文本到剪贴板失败",
"thinking": "正在思考...", "thinking": "正在思考...",
"thinkingTime": "思考用时 {{time}} 秒" "thinkingTime": "思考用时 {{time}} 秒",
"thinkingInProgress": "思考进行中..."
}, },
"retrieval": { "retrieval": {
"startPrompt": "输入查询开始检索", "startPrompt": "输入查询开始检索",

View file

@ -337,7 +337,10 @@
"retrievePanel": { "retrievePanel": {
"chatMessage": { "chatMessage": {
"copyTooltip": "複製到剪貼簿", "copyTooltip": "複製到剪貼簿",
"copyError": "複製文字到剪貼簿失敗" "copyError": "複製文字到剪貼簿失敗",
"thinking": "正在思考...",
"thinkingTime": "思考用時 {{time}} 秒",
"thinkingInProgress": "思考進行中..."
}, },
"retrieval": { "retrieval": {
"startPrompt": "輸入查詢開始檢索", "startPrompt": "輸入查詢開始檢索",