quantumkv's picture
Turn the whole design into dark mode only
f253bd2 verified
// Claude AI Chat Application State Management
class ClaudeChatApp {
constructor() {
this.conversationHistory = [];
this.selectedModel = 'claude-sonnet-4-5';
this.isProcessing = false;
this.isStreaming = false;
this.messageQueue = [];
this.userScrolled = false;
this.initializeApp();
}
initializeApp() {
this.loadFromStorage();
this.setupEventListeners();
this.renderMessages();
this.updateUIState();
// Check if Puter.js loaded
if (typeof puter === 'undefined') {
this.showStatus('error', 'Failed to connect to AI service. Refresh page.', 0);
}
}
loadFromStorage() {
try {
const history = localStorage.getItem('claudeChat_history');
const model = localStorage.getItem('claudeChat_selectedModel');
if (history) {
this.conversationHistory = JSON.parse(history);
}
if (model) {
this.selectedModel = model;
}
} catch (error) {
console.error('Error loading from storage:', error);
}
}
saveToStorage() {
try {
localStorage.setItem('claudeChat_history', JSON.stringify(this.conversationHistory.slice(-50)));
localStorage.setItem('claudeChat_selectedModel', this.selectedModel);
} catch (error) {
console.error('Error saving to storage:', error);
}
}
setupEventListeners() {
// Message input events
const messageInput = document.getElementById('messageInput');
messageInput.addEventListener('input', () => this.handleInputChange());
messageInput.addEventListener('keydown', (e) => this.handleKeyPress(e));
// Button events
document.getElementById('sendBtn').addEventListener('click', () => this.sendMessage(false));
document.getElementById('streamBtn').addEventListener('click', () => this.sendMessage(true));
// Example prompt buttons
document.querySelectorAll('.example-btn').forEach(btn => {
btn.addEventListener('click', () => {
const prompt = btn.dataset.prompt;
messageInput.value = prompt;
messageInput.focus();
this.handleInputChange();
// Auto-hide examples after first click
if (this.conversationHistory.length === 0) {
setTimeout(() => this.hideExamples(), 500);
}
});
});
// Scroll to bottom button
const scrollBtn = document.getElementById('scrollToBottom');
scrollBtn.addEventListener('click', () => {
this.scrollToBottom();
scrollBtn.classList.add('hidden');
});
// Chat container scroll detection
const chatContainer = document.getElementById('chatContainer');
chatContainer.addEventListener('scroll', () => {
const isAtBottom = chatContainer.scrollTop + chatContainer.clientHeight >= chatContainer.scrollHeight - 50;
this.userScrolled = !isAtBottom;
if (!isAtBottom && this.conversationHistory.length > 2) {
scrollBtn.classList.remove('hidden');
} else {
scrollBtn.classList.add('hidden');
}
});
// Keyboard shortcuts
document.addEventListener('keydown', (e) => {
if (e.metaKey || e.ctrlKey) {
switch (e.key.toLowerCase()) {
case 'k':
e.preventDefault();
messageInput.value = '';
this.handleInputChange();
break;
case 'n':
e.preventDefault();
this.newChat();
break;
}
}
});
}
handleInputChange() {
const messageInput = document.getElementById('messageInput');
const charCount = messageInput.value.length;
const charCounter = document.getElementById('charCounter');
const charCountDisplay = document.getElementById('charCount');
// Update character counter
if (charCount > 0) {
charCounter.classList.remove('hidden');
charCountDisplay.textContent = charCount;
if (charCount > 8000) {
charCounter.classList.add('text-red-500');
} else {
charCounter.classList.remove('text-red-500');
}
} else {
charCounter.classList.add('hidden');
}
// Auto-resize textarea
messageInput.style.height = 'auto';
messageInput.style.height = Math.min(messageInput.scrollHeight, 150) + 'px';
this.updateUIState();
}
handleKeyPress(e) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
if (!this.isProcessing) {
this.sendMessage(false);
}
}
}
updateUIState() {
const messageInput = document.getElementById('messageInput');
const sendBtn = document.getElementById('sendBtn');
const streamBtn = document.getElementById('streamBtn');
const hasText = messageInput.value.trim().length > 0;
sendBtn.disabled = !hasText || this.isProcessing;
streamBtn.disabled = !hasText || this.isProcessing;
// Update button labels when processing
if (this.isProcessing) {
sendBtn.innerHTML = '<i data-feather="loader" class="w-4 h-4 animate-spin"></i><span class="hidden sm:inline">Sending</span>';
streamBtn.innerHTML = '<i data-feather="loader" class="w-4 h-4 animate-spin"></i><span class="hidden sm:inline">Streaming</span>';
} else {
sendBtn.innerHTML = '<i data-feather="send" class="w-4 h-4"></i><span class="hidden sm:inline">Send</span>';
streamBtn.innerHTML = '<i data-feather="zap" class="w-4 h-4"></i><span class="hidden sm:inline">Stream</span>';
}
feather.replace();
}
async sendMessage(stream = false) {
const messageInput = document.getElementById('messageInput');
const message = messageInput.value.trim();
if (!message || this.isProcessing) return;
this.isProcessing = true;
this.isStreaming = stream;
this.updateUIState();
this.hideExamples();
// Add user message
this.addMessage('user', message);
// Clear input
messageInput.value = '';
this.handleInputChange();
// Show typing indicator or empty message
if (stream) {
this.addMessage('assistant', '', true);
} else {
document.getElementById('typingIndicator').classList.remove('hidden');
}
try {
if (typeof puter === 'undefined') {
throw new Error('AI service not available');
}
if (stream) {
await this.handleStreamResponse(message);
} else {
await this.handleStandardResponse(message);
}
this.showStatus('success', 'Response received', 3000);
} catch (error) {
console.error('Error sending message:', error);
this.showStatus('error', `Request failed: ${error.message}`, 5000);
if (stream) {
// Update streaming message with error
const lastMessage = this.conversationHistory[this.conversationHistory.length - 1];
lastMessage.content += '\n\n*Streaming interrupted due to an error*';
this.renderMessages();
} else {
document.getElementById('typingIndicator').classList.add('hidden');
}
} finally {
this.isProcessing = false;
this.isStreaming = false;
this.updateUIState();
this.saveToStorage();
if (!stream) {
document.getElementById('typingIndicator').classList.add('hidden');
}
}
}
async handleStandardResponse(message) {
const response = await puter.ai.chat(message, { model: this.selectedModel });
if (response && response.message && response.message.content && response.message.content[0]) {
const content = response.message.content[0].text;
this.addMessage('assistant', content);
} else {
throw new Error('Invalid response format');
}
}
async handleStreamResponse(message) {
const response = await puter.ai.chat(message, {
model: this.selectedModel,
stream: true
});
let fullContent = '';
const messageIndex = this.conversationHistory.length - 1;
try {
for await (const part of response) {
if (part && part.choices && part.choices[0] && part.choices[0].delta) {
const chunk = part.choices[0].delta.content || '';
fullContent += chunk;
this.conversationHistory[messageIndex].content = fullContent;
this.updateStreamingMessage(messageIndex, fullContent);
}
}
} catch (error) {
console.error('Stream error:', error);
throw error;
}
}
updateStreamingMessage(index, content) {
const messagesContainer = document.getElementById('messageThread');
const messageElement = messagesContainer.children[index];
const contentElement = messageElement.querySelector('.message-content');
if (contentElement) {
contentElement.innerHTML = this.parseMarkdown(content);
}
if (!this.userScrolled) {
this.scrollToBottom();
}
}
addMessage(role, content, isStreaming = false) {
const message = {
role,
content,
timestamp: new Date().toISOString(),
id: Date.now()
};
this.conversationHistory.push(message);
if (!isStreaming) {
this.renderMessages();
if (!this.userScrolled) {
this.scrollToBottom();
}
} else {
// Create empty message element for streaming
this.renderSingleMessage(this.conversationHistory.length - 1);
}
}
renderMessages() {
const messagesContainer = document.getElementById('messageThread');
messagesContainer.innerHTML = '';
// Show empty state if no messages
if (this.conversationHistory.length === 0) {
this.showEmptyState();
return;
}
// Render all messages
this.conversationHistory.forEach((message, index) => {
this.renderSingleMessage(index);
});
}
renderSingleMessage(index) {
const messagesContainer = document.getElementById('messageThread');
const message = this.conversationHistory[index];
const messageDiv = document.createElement('div');
messageDiv.className = `message ${message.role === 'system' ? 'message-system' : message.role === 'user' ? 'message-user' : 'message-assistant'} p-4`;
messageDiv.setAttribute('role', 'article');
messageDiv.setAttribute('aria-label', `${message.role === 'user' ? 'You' : 'Claude'} message`);
const headerDiv = document.createElement('div');
headerDiv.className = 'message-header mb-2';
headerDiv.textContent = message.role === 'user' ? 'You' : message.role === 'system' ? 'System' : 'Claude';
const contentDiv = document.createElement('div');
contentDiv.className = 'message-content';
if (message.content) {
contentDiv.innerHTML = this.parseMarkdown(message.content);
} else {
contentDiv.innerHTML = '<span class="text-gray-500">Thinking...</span>';
}
messageDiv.appendChild(headerDiv);
messageDiv.appendChild(contentDiv);
messagesContainer.appendChild(messageDiv);
// Add animation
requestAnimationFrame(() => {
messageDiv.style.opacity = '1';
});
}
parseMarkdown(text) {
// Simple markdown parser
return text
// Headers
.replace(/^### (.*$)/gim, '<h3>$1</h3>')
.replace(/^## (.*$)/gim, '<h2>$1</h2>')
.replace(/^# (.*$)/gim, '<h1>$1</h1>')
// Bold
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
// Italic
.replace(/\*(.+?)\*/g, '<em>$1</em>')
// Code blocks
.replace(/