Spaces:
Running
Running
| // 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(/ |