import React, { useState, useRef, useEffect } from 'react'; import ReactMarkdown from 'react-markdown'; function ChatWindow({ chat, profile, summarizationProfile, onUpdateChat }) { const [input, setInput] = useState(''); const [isLoading, setIsLoading] = useState(false); const [collapsedThinks, setCollapsedThinks] = useState(new Set()); const [partialResponse, setPartialResponse] = useState(''); const [streamController, setStreamController] = useState(null); const [copiedMessageId, setCopiedMessageId] = useState(null); const messagesEndRef = useRef(null); // Scroll to bottom when messages change useEffect(() => { scrollToBottom(); }, [chat.messages, partialResponse]); const scrollToBottom = () => { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); }; // Parse message to separate thinking process and content const parseMessage = (content) => { const thinkMatch = content.match(/([\s\S]*?)<\/think>/); const mainContent = content.replace(/[\s\S]*?<\/think>/, '').trim(); return { think: thinkMatch ? thinkMatch[1].trim() : null, content: mainContent }; }; // Toggle thinking process visibility const toggleThink = (timestamp) => { const newCollapsed = new Set(collapsedThinks); if (newCollapsed.has(timestamp)) { newCollapsed.delete(timestamp); } else { newCollapsed.add(timestamp); } setCollapsedThinks(newCollapsed); }; const handleSendMessage = async () => { if (!input.trim()) return; // Cancel any ongoing stream if (streamController) { streamController.abort(); } const newMessage = { role: 'user', content: input, timestamp: Date.now() }; const updatedChat = { ...chat, messages: [...chat.messages, newMessage] }; onUpdateChat(updatedChat); setInput(''); setPartialResponse(''); // Create new AbortController for this stream const controller = new AbortController(); setStreamController(controller); setIsLoading(true); try { const response = await fetch('/api/chat', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ messages: [...updatedChat.messages.map(msg => ({ role: msg.role, content: msg.content }))], apiKey: profile.apiKey, model: profile.model, apiEndpoint: profile.apiEndpoint, }), signal: controller.signal }); if (!response.ok) { const errorData = await response.text(); throw new Error(`HTTP error! status: ${response.status}, message: ${errorData}`); } const reader = response.body.getReader(); const decoder = new TextDecoder(); let aiMessage = { role: 'assistant', content: '', timestamp: Date.now() }; while (true) { const { done, value } = await reader.read(); if (done) break; const chunk = decoder.decode(value); const lines = chunk.split('\n').filter(line => line.trim() !== ''); for (const line of lines) { const message = line.replace(/^data: /, ''); if (message === '[DONE]') break; try { const parsed = JSON.parse(message); const content = parsed.choices[0].delta.content || ''; aiMessage.content += content; setPartialResponse(aiMessage.content); } catch (error) { console.error('Error parsing chunk:', error); } } } const finalChat = { ...updatedChat, messages: [...updatedChat.messages, aiMessage] }; onUpdateChat(finalChat); // Generate summary title after first message exchange if (finalChat.messages.length === 2) { try { const summaryResponse = await fetch('/api/summarize', { method: 'POST', headers: { 'Content-Type': 'application/json', }, body: JSON.stringify({ content: finalChat.messages.map(msg => msg.content).join('\n'), apiKey: summarizationProfile?.apiKey || profile.apiKey || '', model: summarizationProfile?.model || profile.model || 'gpt-3.5-turbo', apiEndpoint: summarizationProfile?.apiEndpoint || profile.apiEndpoint || 'https://api.openai.com/v1' }) }); if (!summaryResponse.ok) { throw new Error(`Summary API error: ${summaryResponse.status}`); } const { summary } = await summaryResponse.json(); const updatedChatWithTitle = { ...finalChat, title: summary }; onUpdateChat(updatedChatWithTitle); } catch (error) { console.error('Failed to generate summary:', error); // Fallback to default title if summarization fails const updatedChatWithTitle = { ...finalChat, title: 'New Conversation' }; onUpdateChat(updatedChatWithTitle); } } } catch (error) { if (error.name !== 'AbortError') { console.error('Failed to send message:', error); alert(`Failed to send message: ${error.message}`); } } finally { setIsLoading(false); setPartialResponse(''); setStreamController(null); } }; // Format the timestamp const formatTime = (timestamp) => { const date = new Date(timestamp); return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', hour12: false }); }; const handleCopyMessage = (content) => { navigator.clipboard.writeText(content).then(() => { // Show feedback by temporarily updating the copied message state setCopiedMessageId(content); setTimeout(() => setCopiedMessageId(null), 2000); }); }; return (
{chat.messages.map((message, index) => { const parsedMessage = message.role === 'assistant' ? parseMessage(message.content) : { content: message.content, think: null }; return (
{message.role === 'assistant' && ( <> {parsedMessage.think && (
toggleThink(message.timestamp)} > Reasoned for a few seconds {collapsedThinks.has(message.timestamp) ? '▼' : '▲'}
{!collapsedThinks.has(message.timestamp) && (
{parsedMessage.think}
)}
)}
{parsedMessage.content}
)} {message.role === 'user' && (
{parsedMessage.content}
)} {message.timestamp && (
{formatTime(message.timestamp)}
)}
); })} {isLoading && (
{partialResponse} |
)}