moelove's picture
feat: add summarization profile support and API endpoint for conversation summaries
2316bca
raw
history blame
9.52 kB
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(/<think>([\s\S]*?)<\/think>/);
const mainContent = content.replace(/<think>[\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 (
<div className="chat-window">
<div className="chat-messages">
{chat.messages.map((message, index) => {
const parsedMessage = message.role === 'assistant'
? parseMessage(message.content)
: { content: message.content, think: null };
return (
<div key={index} className={`message ${message.role}`}>
{message.role === 'assistant' && (
<>
{parsedMessage.think && (
<div className="reasoning-container">
<div
className="reasoning-header"
onClick={() => toggleThink(message.timestamp)}
>
<span>Reasoned for a few seconds</span>
<span className="toggle-icon">{collapsedThinks.has(message.timestamp) ? '▼' : '▲'}</span>
</div>
{!collapsedThinks.has(message.timestamp) && (
<div className="reasoning-content">
<ReactMarkdown>{parsedMessage.think}</ReactMarkdown>
</div>
)}
</div>
)}
<div className="message-content">
<ReactMarkdown>{parsedMessage.content}</ReactMarkdown>
<button
className={`copy-button ${copiedMessageId === message.content ? 'copied' : ''}`}
onClick={() => handleCopyMessage(message.content)}
>
{copiedMessageId === message.content ? 'Copied!' : 'Copy'}
</button>
</div>
</>
)}
{message.role === 'user' && (
<div className="message-content">
<ReactMarkdown>{parsedMessage.content}</ReactMarkdown>
<button
className={`copy-button ${copiedMessageId === message.content ? 'copied' : ''}`}
onClick={() => handleCopyMessage(message.content)}
>
{copiedMessageId === message.content ? 'Copied!' : 'Copy'}
</button>
</div>
)}
{message.timestamp && (
<div className="message-time">
{formatTime(message.timestamp)}
</div>
)}
</div>
);
})}
{isLoading && (
<div className="message assistant">
<div className="message-content">
<ReactMarkdown>{partialResponse}</ReactMarkdown>
<span className="loading-cursor">|</span>
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
<div className="chat-input">
<textarea
className="message-input"
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="Send a message..."
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSendMessage();
}
}}
/>
<button
onClick={handleSendMessage}
disabled={!input.trim() || isLoading}
className="send-button"
>
Send
</button>
</div>
</div>
);
}
export default ChatWindow;