class LlmComponent extends HTMLElement { hideTools = ['adjust_personality', 'recall', 'remember'] get isOpen() { return this.isDialogueOpen; }; constructor() { super(); this.attachShadow({ mode: 'open' }); // Use global singleton 🔥 this.api = window.netNaviAPI; this.isTyping = false; this.isReceiving = false; this.streamComplete = false; this.isDialogueOpen = false; this.isExpanded = false; this.messageHistory = []; this.audioCtx = new (window.AudioContext || window.webkitAudioContext)(); this.streamBuffer = ''; this.typingIndex = 0; this.typingInterval = null; this.currentRequest = null; this.attachedFiles = []; this.currentStreamingMessage = null; this.render(); this.initEventListeners(); } render() { this.shadowRoot.innerHTML = `
logo
NetNavi v1.0.0
`; } initEventListeners() { const dialogueBox = this.shadowRoot.getElementById('dialogue-box'); const dialogueHeader = this.shadowRoot.getElementById('dialogue-header'); const dialogueInput = this.shadowRoot.getElementById('dialogue-input'); const dialogueSend = this.shadowRoot.getElementById('dialogue-send'); const attachBtn = this.shadowRoot.getElementById('attach-btn'); const fileInput = this.shadowRoot.getElementById('file-input'); const clearBtn = this.shadowRoot.getElementById('clear-btn'); const expandBtn = this.shadowRoot.getElementById('expand-btn'); dialogueInput.addEventListener('input', () => { dialogueInput.style.height = 'auto'; dialogueInput.style.height = Math.min(dialogueInput.scrollHeight, 120) + 'px'; }); dialogueInput.addEventListener('paste', (e) => { const text = e.clipboardData.getData('text'); if (text.length > 1000) { e.preventDefault(); const blob = new Blob([text], { type: 'text/plain' }); const file = new File([blob], 'pasted_text.txt', { type: 'text/plain' }); this.addFile(file); } }); dialogueHeader.addEventListener('click', (e) => { if (e.target === dialogueHeader || e.target.classList.contains('speaker-name')) { this.toggleDialogue(); } }); dialogueInput.addEventListener('focus', () => { if (!this.isDialogueOpen) this.openDialogue(); }); clearBtn.addEventListener('click', (e) => { e.stopPropagation(); this.clearChat(); }); expandBtn.addEventListener('click', (e) => { e.stopPropagation(); this.toggleExpand(); }); attachBtn.addEventListener('click', () => fileInput.click()); fileInput.addEventListener('change', (e) => { Array.from(e.target.files).forEach(file => this.addFile(file)); fileInput.value = ''; }); dialogueSend.addEventListener('click', () => { const buttonText = dialogueSend.textContent; if (buttonText === 'SKIP') this.skipToEnd(); else if (buttonText === 'STOP') this.abortStream(); else this.sendMessage(); }); dialogueInput.addEventListener('keypress', (e) => { if (e.key === 'Enter' && !e.shiftKey && !this.isReceiving) { e.preventDefault(); this.sendMessage(); } }); } clearChat() { this.messageHistory = []; const messageBody = this.shadowRoot.getElementById('message-body'); messageBody.innerHTML = '
NetNavi v1.0.0
'; this.api.clearLLMHistory(); } toggleExpand() { this.isExpanded = !this.isExpanded; const dialogueBox = this.shadowRoot.getElementById('dialogue-box'); const expandBtn = this.shadowRoot.getElementById('expand-btn'); dialogueBox.classList.toggle('expanded', this.isExpanded); expandBtn.innerHTML = this.isExpanded ? ` ` : ` `; } addFile(file) { this.attachedFiles.push(file); this.renderAttachedFiles(); } removeFile(index) { this.attachedFiles.splice(index, 1); this.renderAttachedFiles(); } renderAttachedFiles() { const container = this.shadowRoot.getElementById('attached-files'); if (this.attachedFiles.length === 0) { container.classList.remove('has-files'); container.innerHTML = ''; return; } container.classList.add('has-files'); container.innerHTML = this.attachedFiles.map((file, i) => `
📄 ${file.name}
`).join(''); container.querySelectorAll('.remove-file').forEach(btn => { btn.addEventListener('click', () => { this.removeFile(parseInt(btn.dataset.index)); }); }); } async fileToString(file) { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => resolve(reader.result); reader.onerror = reject; reader[file.type.startsWith('text/') || file.name.endsWith('.txt') ? 'readAsText' : 'readAsDataURL'](file); }); } processMessageForDisplay(text) { return text.replace(/[\s\S]*?<\/file>/g, '📄 $1'); } formatTime(date) { const hours = date.getHours().toString().padStart(2, '0'); const minutes = date.getMinutes().toString().padStart(2, '0'); return `${hours}:${minutes}`; } toggleDialogue() { this.isDialogueOpen = !this.isDialogueOpen; const dialogueBox = this.shadowRoot.getElementById('dialogue-box'); dialogueBox.classList.toggle('minimized'); this.dispatchEvent(new CustomEvent('dialogue-toggle', { detail: { isOpen: this.isDialogueOpen } })); } openDialogue() { this.isDialogueOpen = true; const dialogueBox = this.shadowRoot.getElementById('dialogue-box'); dialogueBox.classList.remove('minimized'); this.dispatchEvent(new CustomEvent('dialogue-toggle', { detail: { isOpen: this.isDialogueOpen } })); } closeDialogue() { this.isDialogueOpen = false; const dialogueBox = this.shadowRoot.getElementById('dialogue-box'); dialogueBox.classList.add('minimized'); this.dispatchEvent(new CustomEvent('dialogue-toggle', { detail: { isOpen: this.isDialogueOpen } })); } playTextBeep() { const oscillator = this.audioCtx.createOscillator(); const gainNode = this.audioCtx.createGain(); oscillator.connect(gainNode); gainNode.connect(this.audioCtx.destination); oscillator.type = 'square'; oscillator.frequency.setValueAtTime(1200, this.audioCtx.currentTime); gainNode.gain.setValueAtTime(0.1, this.audioCtx.currentTime); gainNode.gain.exponentialRampToValueAtTime(0.01, this.audioCtx.currentTime + 0.05); oscillator.start(this.audioCtx.currentTime); oscillator.stop(this.audioCtx.currentTime + 0.05); } shouldAutoScroll() { const messageBody = this.shadowRoot.getElementById('message-body'); const scrollThreshold = 50; const distanceFromBottom = messageBody.scrollHeight - messageBody.scrollTop - messageBody.clientHeight; return distanceFromBottom <= scrollThreshold; } scrollToBottom() { const messageBody = this.shadowRoot.getElementById('message-body'); messageBody.scrollTop = messageBody.scrollHeight; } addMessage(text, isUser) { const messageBody = this.shadowRoot.getElementById('message-body'); const emptyState = messageBody.querySelector('.empty-state'); if (emptyState) messageBody.innerHTML = ''; // Extract file badges and clean text const fileBadges = []; const fileRegex = /[\s\S]*?<\/file>/g; let match; while ((match = fileRegex.exec(text)) !== null) { fileBadges.push(match[1]); } const cleanText = text.replace(fileRegex, '').trim(); const messageWrapper = document.createElement('div'); messageWrapper.className = `message-wrapper ${isUser ? 'user' : 'assistant'}`; const timestamp = this.formatTime(new Date()); const fileBadgesHtml = fileBadges.length > 0 ? `
${fileBadges.map(name => `📄 ${name}`).join('')}
` : ''; messageWrapper.innerHTML = `
${isUser ? 'You' : 'PET'}
${fileBadgesHtml}
${cleanText}
${timestamp}
`; messageBody.appendChild(messageWrapper); this.messageHistory.push({ text, html: cleanText, isUser, element: messageWrapper, timestamp }); this.scrollToBottom(); } startStreaming() { this.isReceiving = true; this.streamComplete = false; this.streamBuffer = ''; this.typingIndex = 0; const dialogueInput = this.shadowRoot.getElementById('dialogue-input'); const dialogueSend = this.shadowRoot.getElementById('dialogue-send'); const attachBtn = this.shadowRoot.getElementById('attach-btn'); const clearBtn = this.shadowRoot.getElementById('clear-btn'); const messageBody = this.shadowRoot.getElementById('message-body'); dialogueInput.disabled = true; attachBtn.disabled = true; clearBtn.disabled = true; dialogueSend.textContent = 'STOP'; dialogueSend.classList.add('stop'); dialogueSend.classList.remove('skip'); const emptyState = messageBody.querySelector('.empty-state'); if (emptyState) messageBody.innerHTML = ''; const timestamp = this.formatTime(new Date()); const messageWrapper = document.createElement('div'); messageWrapper.className = 'message-wrapper assistant'; messageWrapper.innerHTML = `
PET
${timestamp}
`; messageBody.appendChild(messageWrapper); this.currentStreamingMessage = { text: '', html: '', isUser: false, element: messageWrapper, timestamp }; this.messageHistory.push(this.currentStreamingMessage); this.scrollToBottom(); this.typingInterval = setInterval(() => this.typeNextChar(), 30); } handleStreamChunk(chunk) { if (!this.isReceiving) this.startStreaming(); if (chunk.text) this.streamBuffer += chunk.text; if (chunk.tool && !this.hideTools.includes(chunk.tool)) this.streamBuffer += `⚡ ${chunk.tool}`; } handleStreamComplete(response) { this.streamComplete = true; if (this.typingIndex >= this.streamBuffer.length) { this.cleanupStreaming(); } else { const dialogueSend = this.shadowRoot.getElementById('dialogue-send'); dialogueSend.textContent = 'SKIP'; dialogueSend.classList.remove('stop'); dialogueSend.classList.add('skip'); } } typeNextChar() { if (this.typingIndex >= this.streamBuffer.length && this.streamComplete) { this.cleanupStreaming(); return; } if (this.typingIndex >= this.streamBuffer.length) return; const bubble = this.shadowRoot.getElementById('streaming-bubble'); if (!bubble) return; const shouldScroll = this.shouldAutoScroll(); if (this.streamBuffer[this.typingIndex] === '<') { const tagEnd = this.streamBuffer.indexOf('>', this.typingIndex); if (tagEnd !== -1) { const tag = this.streamBuffer.substring(this.typingIndex, tagEnd + 1); this.currentStreamingMessage.html += tag; this.currentStreamingMessage.text += tag; this.typingIndex = tagEnd + 1; bubble.innerHTML = this.currentStreamingMessage.html + ''; if (shouldScroll) this.scrollToBottom(); return; } } const char = this.streamBuffer[this.typingIndex]; this.currentStreamingMessage.text += char; this.currentStreamingMessage.html += char; bubble.innerHTML = this.currentStreamingMessage.html + ''; if (char !== ' ' && char !== '<') { this.playTextBeep(); if ('vibrate' in navigator) navigator.vibrate(10); } this.typingIndex++; if (shouldScroll) this.scrollToBottom(); } skipToEnd() { clearInterval(this.typingInterval); const bubble = this.shadowRoot.getElementById('streaming-bubble'); this.currentStreamingMessage.text = this.streamBuffer; this.currentStreamingMessage.html = this.streamBuffer; this.typingIndex = this.streamBuffer.length; if (bubble) bubble.innerHTML = this.currentStreamingMessage.html; this.scrollToBottom(); this.cleanupStreaming(); } cleanupStreaming() { clearInterval(this.typingInterval); this.isReceiving = false; this.streamComplete = false; const dialogueInput = this.shadowRoot.getElementById('dialogue-input'); const dialogueSend = this.shadowRoot.getElementById('dialogue-send'); const attachBtn = this.shadowRoot.getElementById('attach-btn'); const clearBtn = this.shadowRoot.getElementById('clear-btn'); const bubble = this.shadowRoot.getElementById('streaming-bubble'); dialogueInput.disabled = false; attachBtn.disabled = false; clearBtn.disabled = false; dialogueSend.textContent = 'SEND'; dialogueSend.classList.remove('stop', 'skip'); if (bubble) { bubble.id = ''; bubble.innerHTML = this.currentStreamingMessage.html; } this.streamBuffer = ''; this.typingIndex = 0; this.currentRequest = null; this.currentStreamingMessage = null; } abortStream() { if (this.currentRequest?.abort) { this.currentRequest.abort(); } clearInterval(this.typingInterval); if (this.currentStreamingMessage) { this.streamBuffer = this.currentStreamingMessage.text || ''; this.typingIndex = this.streamBuffer.length; } this.cleanupStreaming(); } async sendMessage() { const dialogueInput = this.shadowRoot.getElementById('dialogue-input'); let text = dialogueInput.value.trim(); if ((!text && this.attachedFiles.length === 0) || this.isReceiving) return; if (this.attachedFiles.length > 0) { const fileBlocks = await Promise.all( this.attachedFiles.map(async (file) => { const content = await this.fileToString(file); return `${content}`; }) ); text = text + '\n\n' + fileBlocks.join('\n'); } dialogueInput.value = ''; dialogueInput.style.height = 'auto'; this.addMessage(text, true); this.attachedFiles = []; this.renderAttachedFiles(); // Send via API with streaming callback 💬 this.currentRequest = this.api.sendPetMessage(text, (chunk) => { this.handleStreamChunk(chunk); }); // Handle completion/errors with promise try { const response = await this.currentRequest; this.handleStreamComplete(response); } catch (error) { if (error.message !== 'Aborted by user') { console.error('❌ LLM Error:', error); this.addMessage(`Error: ${error.message || 'Something went wrong'}`, false); } this.cleanupStreaming(); } } } customElements.define('llm-component', LlmComponent);