Home screen update
All checks were successful
Build and publish / Build Container (push) Successful in 1m28s
237
public/components/avatar.mjs
Normal 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);
|
||||
@@ -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);
|
||||
|
||||
BIN
public/emotes/blush.png
Normal file
|
After Width: | Height: | Size: 3.8 KiB |
BIN
public/emotes/cry.png
Normal file
|
After Width: | Height: | Size: 2.8 KiB |
BIN
public/emotes/dead.png
Normal file
|
After Width: | Height: | Size: 5.3 KiB |
BIN
public/emotes/love.png
Normal file
|
After Width: | Height: | Size: 6.9 KiB |
30
public/emotes/mouth.svg
Normal file
@@ -0,0 +1,30 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 50" class="mouth closed">
|
||||
<defs>
|
||||
<style>
|
||||
.mouth-path {
|
||||
stroke: #000;
|
||||
stroke-width: 2;
|
||||
transition: d 0.15s ease-out;
|
||||
}
|
||||
|
||||
/* Closed state - slight natural curve smile */
|
||||
.mouth.closed .mouth-path {
|
||||
d: path("M 20 25 Q 50 30 80 25");
|
||||
}
|
||||
|
||||
/* Partial open - opens downward */
|
||||
.mouth.partial .mouth-path {
|
||||
fill: #ff6b9d;
|
||||
d: path("M 20 25 Q 50 35 80 25");
|
||||
}
|
||||
|
||||
/* Open - wider downward opening */
|
||||
.mouth.open .mouth-path {
|
||||
fill: #ff6b9d;
|
||||
d: path("M 20 25 Q 50 42 80 25");
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
|
||||
<path class="mouth-path" d="M 20 25 Q 50 28 80 25"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 669 B |
BIN
public/emotes/question.png
Normal file
|
After Width: | Height: | Size: 4.3 KiB |
BIN
public/emotes/realize.png
Normal file
|
After Width: | Height: | Size: 9.6 KiB |
BIN
public/emotes/sigh.png
Normal file
|
After Width: | Height: | Size: 6.7 KiB |
BIN
public/emotes/stress.png
Normal file
|
After Width: | Height: | Size: 8.9 KiB |
BIN
public/emotes/sweat.png
Normal file
|
After Width: | Height: | Size: 4.8 KiB |
BIN
public/emotes/tear.png
Normal file
|
After Width: | Height: | Size: 2.2 KiB |
@@ -1,14 +1,30 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>NetNavi v1.0.0</title>
|
||||
<title>NetNavi</title>
|
||||
<link rel="icon" href="/favicon.png"/>
|
||||
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
||||
<meta name="theme-color" content="{{THEME_PRIMARY}}">
|
||||
<meta property="og:title" content="NetNavi">
|
||||
<meta name="apple-mobile-web-app-title" content="NetNavi">
|
||||
<meta property="og:site_name" content="NetNavi">
|
||||
<meta name="description" content="Network Navigation Program">
|
||||
<meta property="og:description" content="Network Navigation Program">
|
||||
<meta property="og:image" content="/banner?size=1200x630">
|
||||
<meta property="og:logo" content="/favicon?size=180">
|
||||
<meta name="apple-touch-icon" content="/favicon.png">
|
||||
<meta name="apple-touch-startup-image" content="/favicon.png">
|
||||
<meta property="og:url" content="{{PUBLIC_URL}}">
|
||||
<meta property="og:type" content="website">
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default">
|
||||
|
||||
<link rel="manifest" href="/manifest.json">
|
||||
|
||||
<script src="https://cdn.socket.io/4.6.0/socket.io.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/pixi.js/7.3.2/pixi.min.js"></script>
|
||||
|
||||
<style>
|
||||
* {
|
||||
@@ -33,28 +49,110 @@
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
background-attachment: fixed;
|
||||
background: black;
|
||||
transition: 1s;
|
||||
}
|
||||
|
||||
canvas {
|
||||
display: block;
|
||||
position: absolute;
|
||||
.digital-background {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
opacity: 0.3;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@keyframes scanline {
|
||||
0% { transform: translateY(-100%); }
|
||||
50% { transform: translateY(100%); }
|
||||
100% { transform: translateY(100%); }
|
||||
}
|
||||
|
||||
.scanline {
|
||||
animation: scanline 11s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes up-down {
|
||||
0% { transform: translateY(0); }
|
||||
50% { transform: translateY(-50px); }
|
||||
100% { transform: translateY(0); }
|
||||
}
|
||||
|
||||
.up-down {
|
||||
animation: up-down 31s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes left-right {
|
||||
0% { transform: translateX(0); }
|
||||
50% { transform: translateX(-50px); }
|
||||
100% { transform: translateX(0); }
|
||||
}
|
||||
|
||||
.left-right {
|
||||
animation: left-right 37s ease-in-out infinite;
|
||||
}
|
||||
|
||||
#avatar {
|
||||
position:fixed;
|
||||
height: 110%;
|
||||
width: auto;
|
||||
|
||||
}
|
||||
|
||||
@media (orientation: landscape) {
|
||||
left:0;
|
||||
bottom: 0;
|
||||
transform: translateY(20%);
|
||||
}
|
||||
|
||||
@media (orientation: portrait) {
|
||||
left: 50%;
|
||||
bottom: 50%;
|
||||
transform: translate(-50%, 50%);
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="game"></div>
|
||||
<jukebox-component id="jukebox"></jukebox-component>
|
||||
<svg class="digital-background" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<pattern id="grid" width="50" height="50" patternUnits="userSpaceOnUse">
|
||||
<path d="M 50 0 L 0 0 0 50" fill="none" stroke="#ffffffaa" stroke-width="0.5"/>
|
||||
</pattern>
|
||||
<pattern id="gridL" width="200" height="200" patternUnits="userSpaceOnUse">
|
||||
<path d="M 200 0 L 0 0 0 200" fill="none" stroke="#ffffffaa" stroke-width="1"/>
|
||||
</pattern>
|
||||
<linearGradient id="bg-gradient" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" style="stop-color:#0a0a0a;stop-opacity:0.7" />
|
||||
<stop offset="100%" style="stop-color:#1a1a2e;stop-opacity:0.7" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<!-- Background gradient -->
|
||||
<rect width="100%" height="100%" fill="url(#bg-gradient)"/>
|
||||
<!-- Digital grid -->
|
||||
<rect class="up-down" width="100%" height="120%" fill="url(#grid)"/>
|
||||
<rect class="left-right" width="120%" height="100%" fill="url(#gridL)"/>
|
||||
</svg>
|
||||
|
||||
<avatar-component id="avatar"></avatar-component>
|
||||
|
||||
<svg class="digital-background" xmlns="http://www.w3.org/2000/svg">
|
||||
<!-- Scanline effect -->
|
||||
<rect class="scanline" width="100%" height="8" fill="#ffffff22"/>
|
||||
</svg>
|
||||
|
||||
<llm-component id="llm"></llm-component>
|
||||
|
||||
<script type="module">
|
||||
import Navi from './navi.mjs';
|
||||
|
||||
window.navi = new Navi();
|
||||
const navi = window.navi = new Navi();
|
||||
navi.init().then(async () => {
|
||||
document.body.style.background = navi.theme.accent;
|
||||
});
|
||||
</script>
|
||||
<script type="module" src="/components/jukebox.mjs"></script>
|
||||
<script type="module" src="/components/avatar.mjs"></script>
|
||||
<script type="module" src="/components/llm.mjs"></script>
|
||||
<script type="module" src="/components/world.mjs"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
class Navi {
|
||||
api;
|
||||
connected = false;
|
||||
avatar;
|
||||
icon;
|
||||
info;
|
||||
theme;
|
||||
world;
|
||||
|
||||
#animations;
|
||||
#init;
|
||||
#listeners = new Map();
|
||||
#socket;
|
||||
@@ -14,10 +16,24 @@ class Navi {
|
||||
|
||||
constructor(api = window.location.origin, secret = '') {
|
||||
this.api = api.replace(/\/$/, '');
|
||||
this.icon = `${this.api}/favicon.png`;
|
||||
this.avatar = `${this.api}/avatar`;
|
||||
this.icon = `${this.api}/favicon`;
|
||||
this.#secret = secret;
|
||||
}
|
||||
|
||||
async animations() {
|
||||
if(this.#animations) return this.#animations;
|
||||
await this.init();
|
||||
this.#animations = await fetch(`${this.api}/api/animations`, {
|
||||
headers: this.#secret ? {'Authorization': `Bearer ${this.#secret}`} : {}
|
||||
}).then(resp => {
|
||||
if(!resp.ok) throw new Error(`Invalid Navi API: ${this.api}`);
|
||||
return resp.json();
|
||||
});
|
||||
this.emit('animations', this.#animations);
|
||||
return this.#animations;
|
||||
}
|
||||
|
||||
async init() {
|
||||
if(this.#init) return this.#init;
|
||||
this.#init = new Promise(async (res, rej) => {
|
||||
@@ -229,7 +245,11 @@ class Navi {
|
||||
// ============================================
|
||||
|
||||
ask(message, stream) {
|
||||
this.llmCallback = stream;
|
||||
this.llmCallback = (chunk) => {
|
||||
if(chunk['emote']) this.emit('emote', chunk['emote']);
|
||||
stream(chunk);
|
||||
};
|
||||
|
||||
const promise = new Promise((resolve, reject) => {
|
||||
this.llmResolve = resolve;
|
||||
this.llmReject = reject;
|
||||
@@ -260,7 +280,7 @@ class Navi {
|
||||
|
||||
disconnect() {
|
||||
this.connected = false;
|
||||
this.icon = this.info = this.theme = this.world = this.#init = this.#secret = null;
|
||||
this.avatar = this.icon = this.info = this.theme = this.world = this.#animations = this.#init = this.#secret = null;
|
||||
if(this.#world) {
|
||||
this.#world.disconnect();
|
||||
this.#world = null;
|
||||
|
||||
176
public/tts.mjs
Normal file
@@ -0,0 +1,176 @@
|
||||
// tts.service.mjs
|
||||
export class TTS {
|
||||
static QUALITY_PATTERNS = ['Google', 'Microsoft', 'Samantha', 'Premium', 'Natural', 'Neural'];
|
||||
static _errorHandlerInstalled = false;
|
||||
static _instance = null;
|
||||
|
||||
_currentUtterance = null;
|
||||
_voicesLoaded;
|
||||
_stoppedUtterances = new WeakSet();
|
||||
_rate = 1;
|
||||
_pitch = 1;
|
||||
_volume = 1;
|
||||
_voice;
|
||||
_hooks = {
|
||||
onSentenceStart: [],
|
||||
onSentenceEnd: [],
|
||||
onChunk: [],
|
||||
onComplete: []
|
||||
};
|
||||
|
||||
constructor({ rate, pitch, volume, voice } = {}) {
|
||||
TTS.installErrorHandler();
|
||||
this._voicesLoaded = this.initializeVoices();
|
||||
if (rate !== undefined) this._rate = rate;
|
||||
if (pitch !== undefined) this._pitch = pitch;
|
||||
if (volume !== undefined) this._volume = volume;
|
||||
this._voice = voice === null ? undefined : voice;
|
||||
}
|
||||
|
||||
static getInstance(config) {
|
||||
if(!this._instance) this._instance = new TTS(config);
|
||||
return this._instance;
|
||||
}
|
||||
|
||||
static installErrorHandler() {
|
||||
if (this._errorHandlerInstalled) return;
|
||||
window.addEventListener('unhandledrejection', e => {
|
||||
if (e.reason?.error === 'interrupted' && e.reason instanceof SpeechSynthesisErrorEvent) e.preventDefault();
|
||||
});
|
||||
this._errorHandlerInstalled = true;
|
||||
}
|
||||
|
||||
initializeVoices() {
|
||||
return new Promise(res => {
|
||||
const voices = window.speechSynthesis.getVoices();
|
||||
if (voices.length) {
|
||||
if (!this._voice) this._voice = TTS.bestVoice();
|
||||
res();
|
||||
} else {
|
||||
const h = () => {
|
||||
window.speechSynthesis.removeEventListener('voiceschanged', h);
|
||||
if (!this._voice) this._voice = TTS.bestVoice();
|
||||
res();
|
||||
};
|
||||
window.speechSynthesis.addEventListener('voiceschanged', h);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
static bestVoice(lang = 'en') {
|
||||
const voices = window.speechSynthesis.getVoices();
|
||||
for (const p of this.QUALITY_PATTERNS) {
|
||||
const v = voices.find(v => v.name.includes(p) && v.lang.startsWith(lang));
|
||||
if (v) return v;
|
||||
}
|
||||
return voices.find(v => v.lang.startsWith(lang));
|
||||
}
|
||||
|
||||
static cleanText(t) {
|
||||
return removeEmojis(t).replace(/```[\s\S]*?```/g, ' code block ').replace(/[#*_~`]/g, '');
|
||||
}
|
||||
|
||||
on(event, callback) {
|
||||
if(this._hooks[event]) this._hooks[event].push(callback);
|
||||
}
|
||||
|
||||
off(event, callback) {
|
||||
if(this._hooks[event]) this._hooks[event] = this._hooks[event].filter(cb => cb !== callback);
|
||||
}
|
||||
|
||||
_emit(event, data) {
|
||||
if (this._hooks[event]) {
|
||||
this._hooks[event].forEach(cb => {
|
||||
try {
|
||||
cb(data);
|
||||
} catch (err) {
|
||||
console.error(`❌ Error in ${event} hook:`, err);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
createUtterance(t) {
|
||||
const u = new SpeechSynthesisUtterance(TTS.cleanText(t));
|
||||
const v = this._voice || TTS.bestVoice();
|
||||
if (v) u.voice = v;
|
||||
u.rate = this._rate;
|
||||
u.pitch = this._pitch;
|
||||
u.volume = this._volume;
|
||||
return u;
|
||||
}
|
||||
|
||||
async speak(t) {
|
||||
if (!t.trim()) return;
|
||||
await this._voicesLoaded;
|
||||
if(this._currentUtterance && !this._isStreaming) this.stop();
|
||||
return new Promise((res, rej) => {
|
||||
this._currentUtterance = this.createUtterance(t);
|
||||
const u = this._currentUtterance;
|
||||
u.onend = () => {
|
||||
this._currentUtterance = null;
|
||||
res();
|
||||
};
|
||||
u.onerror = e => {
|
||||
console.error('❌ Utterance error:', e);
|
||||
this._currentUtterance = null;
|
||||
if (this._stoppedUtterances.has(u) && e.error === 'interrupted') res();
|
||||
else rej(e);
|
||||
};
|
||||
window.speechSynthesis.speak(u);
|
||||
});
|
||||
}
|
||||
|
||||
stop() {
|
||||
if (this._currentUtterance) this._stoppedUtterances.add(this._currentUtterance);
|
||||
window.speechSynthesis.cancel();
|
||||
this._currentUtterance = null;
|
||||
this._isStreaming = false;
|
||||
this._emit('onComplete', {});
|
||||
}
|
||||
|
||||
speakStream() {
|
||||
this._isStreaming = true;
|
||||
let buf = '';
|
||||
let sentenceQueue = Promise.resolve();
|
||||
const rx = /[^.!?\n]+[.!?\n]+/g;
|
||||
|
||||
return {
|
||||
next: t => {
|
||||
buf += t;
|
||||
this._emit('onChunk', { text: t });
|
||||
const ss = buf.match(rx);
|
||||
if(ss) {
|
||||
ss.forEach(s => {
|
||||
const sentence = s.trim();
|
||||
sentenceQueue = sentenceQueue.then(async () => {
|
||||
this._emit('onSentenceStart', { sentence });
|
||||
await this.speak(sentence);
|
||||
this._emit('onSentenceEnd', { sentence });
|
||||
});
|
||||
});
|
||||
}
|
||||
buf = buf.replace(rx, '');
|
||||
},
|
||||
done: async () => {
|
||||
if (buf.trim()) {
|
||||
const sentence = buf.trim();
|
||||
sentenceQueue = sentenceQueue.then(async () => {
|
||||
this._emit('onSentenceStart', { sentence });
|
||||
await this.speak(sentence);
|
||||
this._emit('onSentenceEnd', { sentence });
|
||||
});
|
||||
buf = '';
|
||||
}
|
||||
await sentenceQueue;
|
||||
this._isStreaming = false;
|
||||
this._emit('onComplete', {});
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function removeEmojis(str) {
|
||||
const emojiRegex = /(?:[\u2700-\u27bf]|(?:\ud83c[\udde6-\uddff]){2}|[\ud83c[\udde6-\uddff]|[\ud83d[\ude00-\ude4f]|[\ud83d[\ude80-\udeff]|[\ud83c[\udd00-\uddff]|[\ud83d[\ude50-\ude7f]|[\u2600-\u26ff]|[\u2700-\u27bf]|[\ud83e[\udd00-\uddff]|[\ud83c[\udf00-\uffff]|[\ud83d[\ude00-\udeff]|[\ud83c[\udde6-\uddff])/g;
|
||||
return str.replace(emojiRegex, '');
|
||||
}
|
||||
60
public/world.html
Normal file
@@ -0,0 +1,60 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>NetNavi v1.0.0</title>
|
||||
<link rel="icon" href="/favicon.png"/>
|
||||
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
||||
|
||||
<script src="https://cdn.socket.io/4.6.0/socket.io.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/pixi.js/7.3.2/pixi.min.js"></script>
|
||||
|
||||
<style>
|
||||
* {
|
||||
box-sizing: border-box !important;
|
||||
}
|
||||
|
||||
html, body {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
font-family: 'Courier New', monospace;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
*, button, input {
|
||||
cursor: url('/assets/cursor.png'), auto !important;
|
||||
}
|
||||
|
||||
body {
|
||||
background-size: cover;
|
||||
background-position: center;
|
||||
background-repeat: no-repeat;
|
||||
background-attachment: fixed;
|
||||
}
|
||||
|
||||
canvas {
|
||||
display: block;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="game"></div>
|
||||
<jukebox-component id="jukebox"></jukebox-component>
|
||||
<llm-component id="llm"></llm-component>
|
||||
|
||||
<script type="module">
|
||||
import Navi from './navi.mjs';
|
||||
|
||||
window.navi = new Navi();
|
||||
</script>
|
||||
<script type="module" src="/components/jukebox.mjs"></script>
|
||||
<script type="module" src="/components/llm.mjs"></script>
|
||||
<script type="module" src="/components/world.mjs"></script>
|
||||
</body>
|
||||
</html>
|
||||