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 = `
`;
}
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);