Home screen update
All checks were successful
Build and publish / Build Container (push) Successful in 1m28s

This commit is contained in:
2026-03-03 20:16:26 -05:00
parent 82f29dceae
commit 5018311990
24 changed files with 1799 additions and 398 deletions

View File

@@ -0,0 +1,237 @@
import {TTS} from '../tts.mjs';
class AvatarComponent extends HTMLElement {
static get observedAttributes() { return []; }
constructor() {
super();
this.attachShadow({mode: 'open'});
this.activeEmotes = [];
this.mouthSvg = null;
this.mouthState = 'closed';
this.setupMouthAnimation();
this.navi = window.navi;
this.navi.animations().then(animations => {
this.animations = animations;
if(!this.animations) return console.error(`Invalid animations: ${this.animations}`);
this.render(this.animations);
navi.on('emote', emote => this.emote(emote));
});
window.emote = this.emote.bind(this);
}
render(data) {
this.shadowRoot.innerHTML = `
<style>
:host {
display: block;
position: relative;
pointer-events: none;
width: 500px;
height: 500px;
}
.avatar-container {
position: relative;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
}
.avatar {
max-width: 100%;
max-height: 100%;
object-fit: contain;
filter: drop-shadow(2px 4px 6px black);
}
.emote-overlay {
position: absolute;
width: auto;
height: auto;
object-fit: contain;
pointer-events: none;
}
.mouth-overlay {
position: absolute;
pointer-events: none;
z-index: 10;
}
</style>
<div class="avatar-container">
<img src="${this.navi.avatar}" class="avatar" alt="Avatar">
</div>`;
this.loadMouthSvg();
}
loadMouthSvg() {
fetch('/emotes/mouth.svg').then(r => r.text()).then(svg => {
const container = document.createElement('div');
container.className = 'mouth-overlay';
container.innerHTML = svg;
this.mouthSvg = container.firstElementChild;
const mouthPos = this.animations?.emote?.['mouth'] || {x: 50, y: 60, r: 0};
container.style.left = `${mouthPos.x}%`;
container.style.top = `${mouthPos.y}%`;
container.style.transform = `translate(-50%, -50%) rotate(${mouthPos.r || 0}deg)`;
container.style.width = '50px';
container.style.height = '25px';
const avatarContainer = this.shadowRoot.querySelector('.avatar-container');
if(avatarContainer) avatarContainer.appendChild(container);
});
}
setupMouthAnimation() {
const tts = TTS.getInstance();
let mouthAnimationInterval = null;
tts.on('onSentenceStart', () => {
if(mouthAnimationInterval) return;
const next = () => {
mouthAnimationInterval = setTimeout(() => {
next();
this.toggleMouthState();
}, ~~(Math.random() * 100) + 100);
}
next();
});
tts.on('onSentenceEnd', () => this.setMouthState('closed'));
tts.on('onComplete', () => {
if(mouthAnimationInterval) {
clearTimeout(mouthAnimationInterval);
mouthAnimationInterval = null;
}
this.setMouthState('closed');
});
}
toggleMouthState() {
if(!this.mouthSvg) return;
this.setMouthState(this.mouthState === 'open' ? 'partial' : 'open');
}
setMouthState(state) {
if(!this.mouthSvg) return;
this.mouthState = state;
this.mouthSvg.classList.remove('closed', 'partial', 'open');
this.mouthSvg.classList.add(state);
}
clear(all = true) {
if(all) {
const a = this.shadowRoot.querySelector('.avatar');
a.animate([{filter: 'drop-shadow(2px 4px 6px black) grayscale(0%) brightness(100%)'}], {duration: 100, fill: 'forwards'});
}
this.activeEmotes.forEach(e => e.remove());
this.activeEmotes = [];
}
emote(emote) {
const animate = (e, emote, style, index) => {
const duration = 3000;
if(emote === 'blush') {
e.animate([
{transform: `scale(0.75) rotate(${style.r ?? 0}deg)`, opacity: 0},
{transform: `scale(0.75) rotate(${style.r ?? 0}deg)`, opacity: 1}
], {duration: duration, easing: 'ease-out', fill: 'forwards'});
} else if(emote === 'cry') {
e.animate([
{transform: `rotate(${style.r ?? 0}deg) translateX(50%) translateY(-25%) scale(0)`},
{transform: `rotate(${style.r ?? 0}deg) translateX(50%) translateY(0) scale(0.5)`}
], {duration: duration, easing: 'ease-out', fill: 'forwards'});
} else if(emote === 'dead') {
e.animate([
{transform: `rotate(${style.r ?? 0}deg) scale(2) translateY(0)`},
{transform: `rotate(${style.r ?? 0}deg) scale(2) translateY(-10%)`}
], {duration: duration, easing: 'ease-out', fill: 'forwards'});
setTimeout(() => {
e.animate([
{transform: `rotate(${style.r ?? 0}deg) scale(2) translateY(-10%)`},
{transform: `rotate(${style.r ?? 0}deg) scale(2) translateY(-8%)`}
], {duration: 1500, easing: 'ease-in-out', iterations: Infinity, direction: 'alternate'});
}, duration);
} else if(emote === 'drool') {
e.src = `${this.navi.api}/emotes/tear.png`;
e.animate([
{transform: `rotate(${style.r ?? 0}deg) translateX(50%) translateY(-12.5%) scale(0)`},
{transform: `rotate(${style.r ?? 0}deg) translateX(50%) translateY(0) scale(0.25)`}
], {duration: duration, easing: 'ease-out', fill: 'forwards'});
} else if(emote === 'love') {
e.animate([
{transform: `rotate(${style.r ?? 0}deg) scale(0.5)`},
{transform: `rotate(${style.r ?? 0}deg) scale(0.7)`}
], {duration: 200, easing: 'steps(2, jump-end)', iterations: Infinity, direction: 'alternate'});
} else if(emote === 'question') {
e.style.transform = `rotate(${style.r ?? 0}deg)`;
e.animate([
{opacity: 1, offset: 0},
{opacity: 1, offset: 0.49},
{opacity: 0, offset: 0.5},
{opacity: 0, offset: 1}
], {duration: 200, iterations: Infinity, direction: 'alternate', delay: (index % 2) * 200});
} else if(emote === 'realize') {
e.animate([
{transform: `rotate(${style.r ?? 0}deg) scale(0.9)`},
{transform: `rotate(${style.r ?? 0}deg) scale(1.1)`}
], {duration: 500, easing: 'ease-out', iterations: Infinity, direction: 'alternate'});
} else if(emote === 'stars') {
e.animate([
{transform: `rotate(${style.r ?? 0}deg) scale(0.25)`},
{transform: `rotate(${style.r ?? 0}deg) scale(0.3)`}
], {duration: 100, easing: 'steps(2, jump-end)', iterations: Infinity, direction: 'alternate'});
} else if(emote === 'stress') {
e.animate([
{transform: `rotate(${style.r ?? 0}deg) scale(0.9)`},
{transform: `rotate(${style.r ?? 0}deg) scale(1.1)`}
], {duration: 333, easing: 'ease-out', iterations: Infinity, direction: 'alternate'});
} else if(emote === 'sigh') {
e.animate([
{transform: `rotate(${style.r ?? 0}deg) translate(0, 0)`},
{transform: `rotate(${style.r ?? 0}deg) translate(10%, 10%)`}
], {duration: duration, easing: 'ease-out', fill: 'forwards'});
} else if(emote === 'sweat') {
e.animate([
{transform: `rotate(${style.r ?? 0}deg) scale(0.5) translateY(0)`},
{transform: `rotate(${style.r ?? 0}deg) scale(0.5) translateY(20%)`}
], {duration: duration, easing: 'ease-out', fill: 'forwards'});
} else if(emote === 'tear') {
e.animate([
{transform: `rotate(${style.r ?? 0}deg) translateX(50%) translateY(-15%) scale(0)`},
{transform: `rotate(${style.r ?? 0}deg) translateX(50%) translateY(0) scale(0.3)`}
], {duration: duration, easing: 'ease-out', fill: 'forwards'});
}
};
if(!emote || emote === 'none') return this.clear();
if(!this.animations.emote[emote]) throw new Error(`Invalid animation: ${emote}`);
const pos = this.animations.emote[emote];
this.clear(false);
const a = this.shadowRoot.querySelector('.avatar');
const container = this.shadowRoot.querySelector('.avatar-container');
const positions = Array.isArray(pos) ? pos : (pos.x != null ? [pos] : []);
if(['dead', 'grey'].includes(emote)) {
a.animate([
{filter: 'drop-shadow(2px 4px 6px black) grayscale(100%) brightness(150%)'}
], {duration: 100, fill: 'forwards'});
} else {
a.animate([
{filter: 'drop-shadow(2px 4px 6px black) grayscale(0%) brightness(100%)'}
], {duration: 100, fill: 'forwards'});
}
positions.forEach((p, i) => {
const e = document.createElement('img');
e.className = 'emote-overlay';
e.src = `${this.navi.api}/emotes/${emote}.png`;
e.style.top = `${p.y}%`;
e.style.left = `${p.x}%`;
container.appendChild(e);
this.activeEmotes.push(e);
animate(e, emote, p, i);
});
}
}
customElements.define('avatar-component', AvatarComponent);

View File

@@ -1,32 +1,39 @@
import './btn.mjs';
import {TTS} from '../tts.mjs';
class LlmComponent extends HTMLElement {
hideTools = []//['adapt', 'recall', 'remember']
hideTools = ['emote', 'personalize', 'recall', 'remember']
get isOpen() { return this.isDialogueOpen; };
get isOpen() { return this.isDialogueOpen; };
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.navi = window.navi;
constructor() {
super();
this.attachShadow({ mode: 'open' });
this.navi = window.navi;
this.navi.init().then(() => this.render());
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.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;
render() {
this.shadowRoot.innerHTML = `
// TTS setup
this.tts = null;
this.streamingSpeech = null;
this.autoSpeak = false;
this.speakingMessageIdx = null;
}
render() {
this.shadowRoot.innerHTML = `
<style>
::-webkit-scrollbar {
width: 12px;
@@ -54,7 +61,7 @@ class LlmComponent extends HTMLElement {
transform: translateX(-50%);
width: 600px;
max-width: 90vw;
height: 600px;
height: 60%;
transition: width 0.3s ease-out, height 0.3s ease-out, max-width 0.3s ease-out, left 0.3s ease-out, transform 0.3s ease-out;
transform-origin: bottom center;
z-index: 1000;
@@ -106,8 +113,13 @@ class LlmComponent extends HTMLElement {
border-radius: 0;
}
.header-buttons {
display: flex;
gap: 0.5rem;
}
.message-body {
padding: 1.75rem 1.25rem;
padding: 1.75rem 1.25rem;
flex: 1;
overflow-y: auto;
display: flex;
@@ -165,6 +177,27 @@ class LlmComponent extends HTMLElement {
color: ${this.navi.theme.text};
}
.message-actions {
display: flex;
gap: 0.5rem;
padding: 0 8px;
opacity: 0.6;
}
.message-action-btn {
background: transparent;
border: none;
color: ${this.navi.theme.text};
cursor: pointer;
padding: 0;
font-size: 14px;
transition: opacity 0.2s;
}
.message-action-btn:hover {
opacity: 1;
}
.empty-state {
display: flex;
align-items: center;
@@ -368,7 +401,7 @@ class LlmComponent extends HTMLElement {
.dialogue-send-btn.stop {
background: #e74c3c;
box-shadow: 0 3px 0 #;
box-shadow: 0 1px 0 #c0392b;
}
.dialogue-send-btn.stop:active {
@@ -387,18 +420,25 @@ class LlmComponent extends HTMLElement {
<div id="dialogue-box" class="minimized">
<div class="dialogue-content">
<div class="dialogue-header" id="dialogue-header">
<div style="display: flex; align-items: center; gap: 0.5rem">
<div style="display: flex; align-items: center; gap: 0.5rem; flex-grow: 1;">
<img alt="logo" src="${this.navi.icon}" style="height: 32px; width: auto;">
<span style="color: ${this.navi.theme.primaryContrast}; font-size: 1.75rem">${this.navi.info.name}</span>
</div>
<btn-component id="expand-btn" color="${this.navi.theme.accent}">
<svg class="expand-icon" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="3" y="3" width="8" height="8" stroke="currentColor" stroke-width="2.5" fill="none"/>
<rect x="13" y="13" width="8" height="8" stroke="currentColor" stroke-width="2.5" fill="none"/>
<path d="M11 3 L11 11 L3 11" stroke="currentColor" stroke-width="2.5" fill="none"/>
<path d="M13 21 L13 13 L21 13" stroke="currentColor" stroke-width="2.5" fill="none"/>
</svg>
</btn-component>
</div>
<div class="header-buttons">
<btn-component id="autospeak-btn" color="${this.navi.theme.accent}">
<svg viewBox="0 0 24 24">
<path d="M16.5 12c0-1.77-1.02-3.29-2.5-4.03v2.21l2.45 2.45c.03-.2.05-.41.05-.63zm2.5 0c0 .94-.2 1.82-.54 2.64l1.51 1.51C20.63 14.91 21 13.5 21 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3L3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.18v2.06c1.38-.31 2.63-.95 3.69-1.81L19.73 21 21 19.73l-9-9L4.27 3zM12 4L9.91 6.09 12 8.18V4z"/>
</svg>
</btn-component>
<btn-component id="expand-btn" color="${this.navi.theme.accent}">
<svg class="expand-icon" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="3" y="3" width="8" height="8" stroke="currentColor" stroke-width="2.5" fill="none"/>
<rect x="13" y="13" width="8" height="8" stroke="currentColor" stroke-width="2.5" fill="none"/>
<path d="M11 3 L11 11 L3 11" stroke="currentColor" stroke-width="2.5" fill="none"/>
<path d="M13 21 L13 13 L21 13" stroke="currentColor" stroke-width="2.5" fill="none"/>
</svg>
</btn-component>
</div>
</div>
<div class="message-body" id="message-body">
<div class="empty-state">NetNavi v1.0.0</div>
@@ -419,6 +459,9 @@ class LlmComponent extends HTMLElement {
</div>
`;
// Init TTS
this.tts = TTS.getInstance();
const dialogueHeader = this.shadowRoot.getElementById('dialogue-header');
const dialogueInput = this.shadowRoot.getElementById('dialogue-input');
const dialogueSend = this.shadowRoot.getElementById('dialogue-send');
@@ -426,6 +469,7 @@ class LlmComponent extends HTMLElement {
const fileInput = this.shadowRoot.getElementById('file-input');
const clearBtn = this.shadowRoot.getElementById('clear-btn');
const expandBtn = this.shadowRoot.getElementById('expand-btn');
const autospeakBtn = this.shadowRoot.getElementById('autospeak-btn');
dialogueInput.addEventListener('input', () => {
dialogueInput.style.height = 'auto';
@@ -443,6 +487,7 @@ class LlmComponent extends HTMLElement {
});
dialogueHeader.addEventListener('click', (e) => {
if (e.target === dialogueHeader || e.target.closest('.header-buttons')) return;
this.toggleDialogue();
});
@@ -460,6 +505,11 @@ class LlmComponent extends HTMLElement {
this.toggleExpand();
});
autospeakBtn.addEventListener('click', (e) => {
e.stopPropagation();
this.toggleAutoSpeak();
});
attachBtn.addEventListener('click', () => fileInput.click());
fileInput.addEventListener('change', (e) => {
@@ -480,23 +530,89 @@ class LlmComponent extends HTMLElement {
this.sendMessage();
}
});
}
}
clearChat() {
this.messageHistory = [];
const messageBody = this.shadowRoot.getElementById('message-body');
messageBody.innerHTML = '<div class="empty-state">NetNavi v1.0.0</div>';
this.navi.clearChat();
}
toggleAutoSpeak() {
this.autoSpeak = !this.autoSpeak;
const btn = this.shadowRoot.getElementById('autospeak-btn');
toggleExpand() {
this.isExpanded = !this.isExpanded;
const dialogueBox = this.shadowRoot.getElementById('dialogue-box');
const expandBtn = this.shadowRoot.getElementById('expand-btn');
const mutedSVG = '<svg viewBox="0 0 24 24"><path d="M16.5 12c0-1.77-1.02-3.29-2.5-4.03v2.21l2.45 2.45c.03-.2.05-.41.05-.63zm2.5 0c0 .94-.2 1.82-.54 2.64l1.51 1.51C20.63 14.91 21 13.5 21 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3L3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.18v2.06c1.38-.31 2.63-.95 3.69-1.81L19.73 21 21 19.73l-9-9L4.27 3zM12 4L9.91 6.09 12 8.18V4z"/></svg>';
const unmutedSVG = '<svg viewBox="0 0 24 24"><path d="M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02z"/></svg>';
dialogueBox.classList.toggle('expanded', this.isExpanded);
btn.innerHTML = this.autoSpeak ? unmutedSVG : mutedSVG;
expandBtn.innerHTML = this.isExpanded ? `
if (!this.autoSpeak && this.tts) {
this.tts.stop();
this.streamingSpeech = null;
}
}
toggleSpeech(idx) {
if (!this.tts) return;
if (this.speakingMessageIdx === idx) {
this.tts.stop();
this.speakingMessageIdx = null;
this.updateMessageActions();
return;
}
this.tts.stop();
const message = this.messageHistory[idx];
if (!message || message.isUser || !message.text) return;
this.speakingMessageIdx = idx;
this.updateMessageActions();
this.tts.speak(message.text).then(() => {
this.speakingMessageIdx = null;
this.updateMessageActions();
}).catch(() => {
this.speakingMessageIdx = null;
this.updateMessageActions();
});
}
updateMessageActions() {
this.messageHistory.forEach((msg, idx) => {
if (msg.isUser || !msg.element) return;
let actionsDiv = msg.element.querySelector('.message-actions');
if (!actionsDiv) {
actionsDiv = document.createElement('div');
actionsDiv.className = 'message-actions';
msg.element.appendChild(actionsDiv);
}
const isSpeaking = this.speakingMessageIdx === idx;
actionsDiv.innerHTML = `
<button class="message-action-btn" data-action="speak" data-idx="${idx}">
<i class="fa ${isSpeaking ? 'fa-stop' : 'fa-volume-up'}"></i>
</button>
`;
const speakBtn = actionsDiv.querySelector('[data-action="speak"]');
speakBtn.addEventListener('click', () => this.toggleSpeech(idx));
});
}
clearChat() {
this.messageHistory = [];
const messageBody = this.shadowRoot.getElementById('message-body');
messageBody.innerHTML = '<div class="empty-state">NetNavi v1.0.0</div>';
this.navi.clearChat();
if (this.tts) this.tts.stop();
this.speakingMessageIdx = null;
}
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 ? `
<svg class="expand-icon" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="8" y="8" width="8" height="8" stroke="currentColor" stroke-width="2.5" fill="none"/>
<path d="M8 8 L3 3 M8 3 L8 8 L3 8" stroke="currentColor" stroke-width="2.5" fill="none"/>
@@ -510,327 +626,352 @@ class LlmComponent extends HTMLElement {
<path d="M13 21 L13 13 L21 13" stroke="currentColor" stroke-width="2.5" fill="none"/>
</svg>
`;
}
}
addFile(file) {
this.attachedFiles.push(file);
this.renderAttachedFiles();
}
addFile(file) {
this.attachedFiles.push(file);
this.renderAttachedFiles();
}
removeFile(index) {
this.attachedFiles.splice(index, 1);
this.renderAttachedFiles();
}
removeFile(index) {
this.attachedFiles.splice(index, 1);
this.renderAttachedFiles();
}
renderAttachedFiles() {
const container = this.shadowRoot.getElementById('attached-files');
renderAttachedFiles() {
const container = this.shadowRoot.getElementById('attached-files');
if (this.attachedFiles.length === 0) {
container.classList.remove('has-files');
container.innerHTML = '';
return;
}
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) => `
container.classList.add('has-files');
container.innerHTML = this.attachedFiles.map((file, i) => `
<div class="attached-file">
<span class="file-name" title="${file.name}">${file.name}</span>
<button class="remove-file" data-index="${i}">✕</button>
</div>
`).join('');
container.querySelectorAll('.remove-file').forEach(btn => {
btn.addEventListener('click', () => {
this.removeFile(parseInt(btn.dataset.index));
});
});
}
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);
});
}
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);
});
}
formatTime(date) {
const hours = date.getHours().toString().padStart(2, '0');
const minutes = date.getMinutes().toString().padStart(2, '0');
return `${hours}:${minutes}`;
}
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');
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 }
}));
}
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');
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 }
}));
}
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);
}
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;
}
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;
}
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 = '';
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 = /<file name="([^"]+)">[\s\S]*?<\/file>/g;
let match;
while ((match = fileRegex.exec(text)) !== null) {
fileBadges.push(match[1]);
}
const cleanText = text.replace(fileRegex, '').trim();
// Extract file badges and clean text
const fileBadges = [];
const fileRegex = /<file name="([^"]+)">[\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' : 'navi'}`;
const messageWrapper = document.createElement('div');
messageWrapper.className = `message-wrapper ${isUser ? 'user' : 'navi'}`;
const fileBadgesHtml = fileBadges.length > 0
? `<div class="message-files">${fileBadges.map(name =>
`<span class="file-badge">📄 ${name}</span>`).join('')}</div>`
: '';
const fileBadgesHtml = fileBadges.length > 0
? `<div class="message-files">${fileBadges.map(name =>
`<span class="file-badge">📄 ${name}</span>`).join('')}</div>`
: '';
messageWrapper.innerHTML = `
messageWrapper.innerHTML = `
${fileBadgesHtml}
<div class="message-bubble">${cleanText}</div>`;
messageBody.appendChild(messageWrapper);
this.messageHistory.push({ text, html: cleanText, isUser, element: messageWrapper, timestamp: Date.now() });
this.scrollToBottom();
}
messageBody.appendChild(messageWrapper);
const msgData = { text, html: cleanText, isUser, element: messageWrapper, timestamp: Date.now() };
this.messageHistory.push(msgData);
startStreaming() {
this.isReceiving = true;
this.streamComplete = false;
this.streamBuffer = '';
this.typingIndex = 0;
if (!isUser) {
this.updateMessageActions();
}
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');
this.scrollToBottom();
}
dialogueInput.disabled = true;
attachBtn.disabled = true;
clearBtn.disabled = true;
dialogueSend.textContent = 'STOP';
startStreaming() {
this.isReceiving = true;
this.streamComplete = false;
this.streamBuffer = '';
this.typingIndex = 0;
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');
attachBtn.disabled = true;
clearBtn.disabled = true;
dialogueSend.textContent = 'STOP';
dialogueSend.setAttribute('color', '#c0392b');
attachBtn.setAttribute('disabled', true);
clearBtn.setAttribute('disabled', true);
const emptyState = messageBody.querySelector('.empty-state');
if (emptyState) messageBody.innerHTML = '';
const emptyState = messageBody.querySelector('.empty-state');
if (emptyState) messageBody.innerHTML = '';
const messageWrapper = document.createElement('div');
messageWrapper.className = 'message-wrapper navi';
messageWrapper.innerHTML = `<div class="message-bubble" id="streaming-bubble"></div>`;
const messageWrapper = document.createElement('div');
messageWrapper.className = 'message-wrapper navi';
messageWrapper.innerHTML = `<div class="message-bubble" id="streaming-bubble"></div>`;
messageBody.appendChild(messageWrapper);
messageBody.appendChild(messageWrapper);
this.currentStreamingMessage = { text: '', html: '', isUser: false, element: messageWrapper, timestamp: Date.now() };
this.messageHistory.push(this.currentStreamingMessage);
this.currentStreamingMessage = { text: '', html: '', isUser: false, element: messageWrapper, timestamp: Date.now() };
this.messageHistory.push(this.currentStreamingMessage);
this.scrollToBottom();
// Start TTS streaming if autoSpeak is on
if (this.autoSpeak && this.tts) {
this.streamingSpeech = this.tts.speakStream();
}
this.typingInterval = setInterval(() => this.typeNextChar(), 30);
}
this.scrollToBottom();
handleStreamChunk(chunk) {
if (!this.isReceiving) this.startStreaming();
this.typingInterval = setInterval(() => this.typeNextChar(), 30);
}
if (chunk.text) this.streamBuffer += chunk.text;
if (chunk.tool && !this.hideTools.includes(chunk.tool)) this.streamBuffer += `<span class="tool-call">⚡ ${chunk.tool}</span>`;
}
handleStreamChunk(chunk) {
if (!this.isReceiving) this.startStreaming();
handleStreamComplete(response) {
this.streamComplete = true;
if (this.typingIndex >= this.streamBuffer.length) {
this.cleanupStreaming();
} else {
const dialogueSend = this.shadowRoot.getElementById('dialogue-send');
dialogueSend.textContent = 'SKIP';
if (chunk.text) {
this.streamBuffer += chunk.text;
// Feed to TTS stream
if (this.streamingSpeech) {
this.streamingSpeech.next(chunk.text);
}
}
if (chunk.tool && !this.hideTools.includes(chunk.tool)) this.streamBuffer += `<span class="tool-call">⚡ ${chunk.tool}</span>`;
}
async handleStreamComplete(response) {
this.streamComplete = true;
if (this.typingIndex >= this.streamBuffer.length) {
await this.cleanupStreaming();
} else {
const dialogueSend = this.shadowRoot.getElementById('dialogue-send');
dialogueSend.textContent = 'SKIP';
dialogueSend.setAttribute('color', '#f39c12');
}
}
}
}
typeNextChar() {
if (this.typingIndex >= this.streamBuffer.length && this.streamComplete) {
this.cleanupStreaming();
return;
}
typeNextChar() {
if (this.typingIndex >= this.streamBuffer.length && this.streamComplete) {
this.cleanupStreaming();
return;
}
if (this.typingIndex >= this.streamBuffer.length) return;
if (this.typingIndex >= this.streamBuffer.length) return;
const bubble = this.shadowRoot.getElementById('streaming-bubble');
if (!bubble) return;
const bubble = this.shadowRoot.getElementById('streaming-bubble');
if (!bubble) return;
const shouldScroll = this.shouldAutoScroll();
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 + '<span class="text-cursor"></span>';
if (shouldScroll) this.scrollToBottom();
return;
}
}
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 + '<span class="text-cursor"></span>';
if (shouldScroll) this.scrollToBottom();
return;
}
}
const char = this.streamBuffer[this.typingIndex];
this.currentStreamingMessage.text += char;
this.currentStreamingMessage.html += char;
const char = this.streamBuffer[this.typingIndex];
this.currentStreamingMessage.text += char;
this.currentStreamingMessage.html += char;
bubble.innerHTML = this.currentStreamingMessage.html + '<span class="text-cursor"></span>';
bubble.innerHTML = this.currentStreamingMessage.html + '<span class="text-cursor"></span>';
if (char !== ' ' && char !== '<') {
this.playTextBeep();
if ('vibrate' in navigator) navigator.vibrate(10);
}
if (char !== ' ' && char !== '<') {
this.playTextBeep();
if ('vibrate' in navigator) navigator.vibrate(10);
}
this.typingIndex++;
if (shouldScroll) this.scrollToBottom();
}
this.typingIndex++;
if (shouldScroll) this.scrollToBottom();
}
skipToEnd() {
clearInterval(this.typingInterval);
async skipToEnd() {
clearInterval(this.typingInterval);
const bubble = this.shadowRoot.getElementById('streaming-bubble');
const bubble = this.shadowRoot.getElementById('streaming-bubble');
this.currentStreamingMessage.text = this.streamBuffer;
this.currentStreamingMessage.html = this.streamBuffer;
this.typingIndex = this.streamBuffer.length;
this.currentStreamingMessage.text = this.streamBuffer;
this.currentStreamingMessage.html = this.streamBuffer;
this.typingIndex = this.streamBuffer.length;
if (bubble) bubble.innerHTML = this.currentStreamingMessage.html;
if (bubble) bubble.innerHTML = this.currentStreamingMessage.html;
this.scrollToBottom();
this.cleanupStreaming();
}
this.scrollToBottom();
await this.cleanupStreaming();
}
cleanupStreaming() {
clearInterval(this.typingInterval);
this.isReceiving = false;
this.streamComplete = false;
async cleanupStreaming() {
clearInterval(this.typingInterval);
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');
// Finalize TTS stream
if (this.streamingSpeech) {
await this.streamingSpeech.done();
this.streamingSpeech = null;
}
dialogueInput.disabled = false;
attachBtn.disabled = false;
clearBtn.disabled = false;
dialogueSend.textContent = 'SEND';
dialogueSend.setAttribute('color', this.navi.theme.accent);
this.isReceiving = false;
this.streamComplete = false;
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');
attachBtn.disabled = false;
clearBtn.disabled = false;
dialogueSend.textContent = 'SEND';
dialogueSend.setAttribute('color', this.navi.theme.accent);
attachBtn.removeAttribute('disabled');
clearBtn.removeAttribute('disabled');
if (bubble) {
bubble.id = '';
bubble.innerHTML = this.currentStreamingMessage.html;
}
if (bubble) {
bubble.id = '';
bubble.innerHTML = this.currentStreamingMessage.html;
}
this.streamBuffer = '';
this.typingIndex = 0;
this.currentRequest = null;
this.currentStreamingMessage = null;
}
this.updateMessageActions();
abortStream() {
if (this.currentRequest?.abort) {
this.currentRequest.abort();
}
this.streamBuffer = '';
this.typingIndex = 0;
this.currentRequest = null;
this.currentStreamingMessage = null;
}
clearInterval(this.typingInterval);
abortStream() {
if (this.currentRequest?.abort) {
this.currentRequest.abort();
}
if (this.currentStreamingMessage) {
this.streamBuffer = this.currentStreamingMessage.text || '';
this.typingIndex = this.streamBuffer.length;
}
clearInterval(this.typingInterval);
this.cleanupStreaming();
}
if (this.currentStreamingMessage) {
this.streamBuffer = this.currentStreamingMessage.text || '';
this.typingIndex = this.streamBuffer.length;
}
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.tts) this.tts.stop();
this.streamingSpeech = null;
if (this.attachedFiles.length > 0) {
const fileBlocks = await Promise.all(
this.attachedFiles.map(async (file) => {
const content = await this.fileToString(file);
return `<file name="${file.name}">${content}</file>`;
})
);
text = text + '\n\n' + fileBlocks.join('\n');
}
this.cleanupStreaming();
}
dialogueInput.value = '';
dialogueInput.style.height = 'auto';
async sendMessage() {
const dialogueInput = this.shadowRoot.getElementById('dialogue-input');
let text = dialogueInput.value.trim();
if ((!text && this.attachedFiles.length === 0) || this.isReceiving) return;
this.addMessage(text, true);
if (this.attachedFiles.length > 0) {
const fileBlocks = await Promise.all(
this.attachedFiles.map(async (file) => {
const content = await this.fileToString(file);
return `<file name="${file.name}">${content}</file>`;
})
);
text = text + '\n\n' + fileBlocks.join('\n');
}
this.attachedFiles = [];
this.renderAttachedFiles();
dialogueInput.value = '';
dialogueInput.style.height = 'auto';
// Send via API with streaming callback 💬
this.currentRequest = this.navi.ask(text, (chunk) => this.handleStreamChunk(chunk));
this.addMessage(text, true);
// 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();
}
}
this.attachedFiles = [];
this.renderAttachedFiles();
// Send via API with streaming callback 💬
this.currentRequest = this.navi.ask(text, (chunk) => this.handleStreamChunk(chunk));
// Handle completion/errors with promise
try {
const response = await this.currentRequest;
await 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);
}
await this.cleanupStreaming();
}
}
}
customElements.define('llm-component', LlmComponent);