generated from ztimson/template
Styling improvment
All checks were successful
Build and publish / Build Container (push) Successful in 1m41s
All checks were successful
Build and publish / Build Container (push) Successful in 1m41s
This commit is contained in:
162
public/components/btn.mjs
Normal file
162
public/components/btn.mjs
Normal file
@@ -0,0 +1,162 @@
|
||||
function contrast(color) {
|
||||
const exploded = color?.match(color.length >= 6 ? /[0-9a-fA-F]{2}/g : /[0-9a-fA-F]/g);
|
||||
if(!exploded || exploded?.length < 3) return 'black';
|
||||
const [r, g, b] = exploded.map(hex => parseInt(hex.length === 1 ? `${hex}${hex}` : hex, 16));
|
||||
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
|
||||
return luminance > 0.5 ? 'black' : 'white';
|
||||
}
|
||||
|
||||
function shadeColor(hex, amount) {
|
||||
function dec2Hex(num) {
|
||||
const hex = Math.round(num * 255).toString(16);
|
||||
return hex.length === 1 ? '0' + hex : hex;
|
||||
}
|
||||
|
||||
function hex2Int(hex) {
|
||||
let r = 0, g = 0, b = 0;
|
||||
if (hex.length === 4) {
|
||||
r = parseInt(hex[1] + hex[1], 16);
|
||||
g = parseInt(hex[2] + hex[2], 16);
|
||||
b = parseInt(hex[3] + hex[3], 16);
|
||||
} else {
|
||||
r = parseInt(hex.slice(1, 3), 16);
|
||||
g = parseInt(hex.slice(3, 5), 16);
|
||||
b = parseInt(hex.slice(5, 7), 16);
|
||||
}
|
||||
return { r, g, b };
|
||||
}
|
||||
|
||||
function hue2rgb(p, q, t) {
|
||||
if (t < 0) t += 1;
|
||||
if (t > 1) t -= 1;
|
||||
if (t < 1 / 6) return p + (q - p) * 6 * t;
|
||||
if (t < 1 / 2) return q;
|
||||
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
|
||||
return p;
|
||||
}
|
||||
|
||||
function int2Hex(r, g, b) {
|
||||
return '#' + dec2Hex(r) + dec2Hex(g) + dec2Hex(b);
|
||||
}
|
||||
|
||||
let { r, g, b } = hex2Int(hex);
|
||||
r /= 255; g /= 255; b /= 255;
|
||||
const max = Math.max(r, g, b), min = Math.min(r, g, b);
|
||||
let h, s, l = (max + min) / 2;
|
||||
|
||||
if (max === min) {
|
||||
h = s = 0;
|
||||
} else {
|
||||
const d = max - min;
|
||||
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
||||
switch (max) {
|
||||
case r: h = (g - b) / d + (g < b ? 6 : 0); break;
|
||||
case g: h = (b - r) / d + 2; break;
|
||||
case b: h = (r - g) / d + 4; break;
|
||||
default: h = 0; break;
|
||||
}
|
||||
h /= 6;
|
||||
}
|
||||
|
||||
l = Math.max(0, Math.min(1, l + amount));
|
||||
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
|
||||
const p = 2 * l - q;
|
||||
return int2Hex(hue2rgb(p, q, h + 1 / 3), hue2rgb(p, q, h), hue2rgb(p, q, h - 1 / 3));
|
||||
}
|
||||
|
||||
class BtnComponent extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
this.attachShadow({ mode: 'open' });
|
||||
|
||||
this.shadowRoot.innerHTML = `
|
||||
<style>
|
||||
:host {
|
||||
--base-color: #ccc;
|
||||
--dark-color: #999;
|
||||
--light-color: #eee;
|
||||
--text-color: #000;
|
||||
}
|
||||
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
min-width: 40px;
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
padding: 5px;
|
||||
border-radius: 6px;
|
||||
font-size: 20px;
|
||||
transition: transform 0.1s, background-color 0.2s, border-color 0.2s, box-shadow 0.2s;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
user-select: none;
|
||||
cursor: url('/assets/cursor.png'), auto;
|
||||
fill: var(--text-color);
|
||||
color: var(--text-color);
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.btn:not(.disabled) {
|
||||
background: var(--base-color);
|
||||
border: 2px solid var(--dark-color);
|
||||
box-shadow: 0 4px 0 var(--dark-color);
|
||||
}
|
||||
|
||||
.btn:not(.disabled):hover {
|
||||
transform: translateY(-2px);
|
||||
background: var(--light-color);
|
||||
border: 2px solid var(--base-color);
|
||||
box-shadow: 0 6px 0 var(--base-color);
|
||||
}
|
||||
|
||||
.btn:not(.disabled):active {
|
||||
transform: translateY(2px);
|
||||
background: var(--base-color);
|
||||
border: 2px solid var(--dark-color);
|
||||
box-shadow: 0 2px 0 var(--dark-color);
|
||||
}
|
||||
|
||||
.btn.disabled {
|
||||
cursor: no-drop;
|
||||
background: var(--dark-color);
|
||||
border: 2px solid var(--base-color);
|
||||
box-shadow: 0 4px 0 var(--dark-color);
|
||||
}
|
||||
</style>
|
||||
<div class="btn">
|
||||
<slot></slot>
|
||||
</div>
|
||||
`;
|
||||
|
||||
this.shadowRoot.querySelector('.btn').addEventListener('click', (e) => {
|
||||
if(this.hasAttribute('disabled')) {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
}
|
||||
});
|
||||
|
||||
this.updateColors();
|
||||
}
|
||||
|
||||
static get observedAttributes() {
|
||||
return ['color', 'disabled'];
|
||||
}
|
||||
|
||||
attributeChangedCallback(name, oldValue, newValue) {
|
||||
if(name === 'color') this.updateColors();
|
||||
if(name === 'disabled') {
|
||||
const disabled = this.hasAttribute('disabled');
|
||||
this.shadowRoot.querySelector('.btn').classList[disabled ? 'add' : 'remove']('disabled');
|
||||
}
|
||||
}
|
||||
|
||||
updateColors() {
|
||||
const hex = this.getAttribute('color');
|
||||
this.shadowRoot.host.style.setProperty('--base-color', hex);
|
||||
this.shadowRoot.host.style.setProperty('--dark-color', shadeColor(hex, -.1));
|
||||
this.shadowRoot.host.style.setProperty('--light-color', shadeColor(hex, .1));
|
||||
this.shadowRoot.host.style.setProperty('--text-color', contrast(hex));
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('btn-component', BtnComponent);
|
||||
216
public/components/jukebox.mjs
Normal file
216
public/components/jukebox.mjs
Normal file
@@ -0,0 +1,216 @@
|
||||
import './btn.mjs';
|
||||
|
||||
class JukeboxComponent extends HTMLElement {
|
||||
constructor() {
|
||||
super();
|
||||
this.attachShadow({ mode: 'open' });
|
||||
this.navi = window.navi;
|
||||
this.navi.init();
|
||||
this.unsubscribeWorld = this.navi.on('world:data', (data) => {
|
||||
console.log(data, this.navi.world.data);
|
||||
this.render()
|
||||
});
|
||||
|
||||
this.playlist = [];
|
||||
this.currentTrackIndex = 0;
|
||||
this.bgMusic = null;
|
||||
this.isMuted = false;
|
||||
this.hasInteracted = false;
|
||||
this.isPlaylistMode = false;
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
if(this.unsubscribeWorld) this.unsubscribeWorld();
|
||||
}
|
||||
|
||||
render() {
|
||||
this.shadowRoot.innerHTML = `
|
||||
<style>
|
||||
:host { display: block; }
|
||||
#simple-mute-btn {
|
||||
position: fixed;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
}
|
||||
.audio-controls {
|
||||
position: fixed;
|
||||
display: flex;
|
||||
top: 20px;
|
||||
right: 20px;
|
||||
background: ${this.navi.world.data.theme.colors.primary};
|
||||
border: 3px solid ${this.navi.world.data.theme.colors.border};
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
z-index: 1000;
|
||||
box-shadow: 0 4px 8px rgba(0,0,0,0.3);
|
||||
min-width: 200px;
|
||||
}
|
||||
.track-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: ${this.navi.world.data.theme.colors.background};
|
||||
border: 2px solid ${this.navi.world.data.theme.colors.border};
|
||||
color: ${this.navi.world.data.theme.colors.text};
|
||||
padding: 8px;
|
||||
border-radius: 4px;
|
||||
margin: 0 8px;
|
||||
overflow: hidden;
|
||||
height: 24px;
|
||||
width: 120px;
|
||||
position: relative;
|
||||
cursor: text;
|
||||
}
|
||||
.track-name {
|
||||
white-space: nowrap;
|
||||
display: inline-block;
|
||||
padding-left: 100%;
|
||||
animation: marquee 10s linear infinite;
|
||||
font-weight: bold;
|
||||
font-size: 14px;
|
||||
}
|
||||
@keyframes marquee {
|
||||
0% { transform: translateX(0); }
|
||||
100% { transform: translateX(-100%); }
|
||||
}
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
|
||||
<btn-component id="simple-mute-btn" color="${this.navi.world.data.theme.colors.accent}">
|
||||
<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>
|
||||
</btn-component>
|
||||
|
||||
<div class="audio-controls hidden" id="playlist-controls">
|
||||
<btn-component id="prev-btn" color="${this.navi.world.data.theme.colors.accent}">
|
||||
<svg viewBox="0 0 24 24">
|
||||
<path d="M6 6h2v12H6zm3.5 6l8.5 6V6z"/>
|
||||
</svg>
|
||||
</btn-component>
|
||||
<div class="track-info">
|
||||
<span class="track-name" id="track-name">No track loaded</span>
|
||||
</div>
|
||||
<div style="display: flex; gap: 8px">
|
||||
<btn-component id="next-btn" color="${this.navi.world.data.theme.colors.accent}">
|
||||
<svg viewBox="0 0 24 24">
|
||||
<path d="M6 18l8.5-6L6 6v12zM16 6v12h2V6h-2z"/>
|
||||
</svg>
|
||||
</btn-component>
|
||||
<btn-component id="mute-btn" color="#c0392b">
|
||||
<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>
|
||||
</btn-component>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
this.shadowRoot.getElementById('simple-mute-btn').addEventListener('click', () => this.toggleMute());
|
||||
this.shadowRoot.getElementById('mute-btn').addEventListener('click', () => this.toggleMute());
|
||||
this.shadowRoot.getElementById('prev-btn').addEventListener('click', () => this.previousTrack());
|
||||
this.shadowRoot.getElementById('next-btn').addEventListener('click', () => this.nextTrack());
|
||||
this.loadMusic(this.navi.world.data.theme.music);
|
||||
}
|
||||
|
||||
loadMusic(musicConfig) {
|
||||
if(!musicConfig) return;
|
||||
this.isPlaylistMode = Array.isArray(musicConfig);
|
||||
this.playlist = Array.isArray(musicConfig) ? musicConfig : [musicConfig];
|
||||
this.currentTrackIndex = 0;
|
||||
|
||||
if (this.isPlaylistMode) {
|
||||
this.shadowRoot.getElementById('simple-mute-btn').classList.add('hidden');
|
||||
this.shadowRoot.getElementById('playlist-controls').classList.remove('hidden');
|
||||
} else {
|
||||
this.shadowRoot.getElementById('simple-mute-btn').classList.remove('hidden');
|
||||
this.shadowRoot.getElementById('playlist-controls').classList.add('hidden');
|
||||
}
|
||||
|
||||
this.loadTrack(this.currentTrackIndex);
|
||||
}
|
||||
|
||||
loadTrack(index) {
|
||||
if (this.bgMusic) {
|
||||
this.bgMusic.pause();
|
||||
this.bgMusic = null;
|
||||
}
|
||||
|
||||
if (index >= 0 && index < this.playlist.length) {
|
||||
this.bgMusic = new Audio(this.playlist[index]);
|
||||
this.bgMusic.volume = 0.5;
|
||||
|
||||
if (this.isPlaylistMode) {
|
||||
this.bgMusic.addEventListener('ended', () => {
|
||||
this.currentTrackIndex = (this.currentTrackIndex + 1) % this.playlist.length;
|
||||
this.loadTrack(this.currentTrackIndex);
|
||||
});
|
||||
this.updateTrackDisplay();
|
||||
} else {
|
||||
this.bgMusic.loop = true;
|
||||
}
|
||||
|
||||
this.setupAutoplayHandler();
|
||||
|
||||
if (this.hasInteracted && !this.isMuted) {
|
||||
this.bgMusic.play();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updateTrackDisplay() {
|
||||
const trackName = this.shadowRoot.getElementById('track-name');
|
||||
const fileName = this.playlist[this.currentTrackIndex].split('/').pop();
|
||||
const trackNum = String(this.currentTrackIndex + 1).padStart(2, '0');
|
||||
trackName.textContent = `[${trackNum}] ${fileName}`;
|
||||
}
|
||||
|
||||
setupAutoplayHandler() {
|
||||
const startMusic = () => {
|
||||
if (!this.hasInteracted && !this.isMuted && this.bgMusic) {
|
||||
this.hasInteracted = true;
|
||||
this.bgMusic.play();
|
||||
}
|
||||
};
|
||||
const interactionEvents = ['click', 'keydown', 'touchstart'];
|
||||
interactionEvents.forEach(event => {
|
||||
document.addEventListener(event, startMusic, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
previousTrack() {
|
||||
if (!this.isPlaylistMode) return;
|
||||
this.currentTrackIndex = (this.currentTrackIndex - 1 + this.playlist.length) % this.playlist.length;
|
||||
this.loadTrack(this.currentTrackIndex);
|
||||
}
|
||||
|
||||
nextTrack() {
|
||||
if (!this.isPlaylistMode) return;
|
||||
this.currentTrackIndex = (this.currentTrackIndex + 1) % this.playlist.length;
|
||||
this.loadTrack(this.currentTrackIndex);
|
||||
}
|
||||
|
||||
toggleMute() {
|
||||
this.isMuted = !this.isMuted;
|
||||
const simpleMuteBtn = this.shadowRoot.getElementById('simple-mute-btn');
|
||||
const muteBtn = this.shadowRoot.getElementById('mute-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>';
|
||||
|
||||
if (this.bgMusic) {
|
||||
if (this.isMuted) {
|
||||
this.bgMusic.pause();
|
||||
simpleMuteBtn.innerHTML = mutedSVG;
|
||||
muteBtn.innerHTML = mutedSVG;
|
||||
} else {
|
||||
if (!this.hasInteracted) this.hasInteracted = true;
|
||||
this.bgMusic.play();
|
||||
simpleMuteBtn.innerHTML = unmutedSVG;
|
||||
muteBtn.innerHTML = unmutedSVG;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('jukebox-component', JukeboxComponent);
|
||||
836
public/components/llm.mjs
Normal file
836
public/components/llm.mjs
Normal file
@@ -0,0 +1,836 @@
|
||||
import './btn.mjs';
|
||||
|
||||
class LlmComponent extends HTMLElement {
|
||||
hideTools = ['adjust_personality', 'recall', 'remember']
|
||||
|
||||
get isOpen() { return this.isDialogueOpen; };
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
render() {
|
||||
this.shadowRoot.innerHTML = `
|
||||
<style>
|
||||
::-webkit-scrollbar {
|
||||
width: 12px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
border: none;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: ${this.navi.theme.primary};
|
||||
border: 2px solid ${this.navi.theme.border};
|
||||
border-radius: 9px;
|
||||
}
|
||||
|
||||
:host {
|
||||
display: block;
|
||||
}
|
||||
|
||||
#dialogue-box {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 600px;
|
||||
max-width: 90vw;
|
||||
height: 600px;
|
||||
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;
|
||||
}
|
||||
|
||||
#dialogue-box.minimized {
|
||||
height: 50px;
|
||||
}
|
||||
|
||||
#dialogue-box.expanded {
|
||||
width: 100vw;
|
||||
max-width: 100vw;
|
||||
height: 100vh;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
|
||||
.dialogue-content {
|
||||
background: ${this.navi.theme.background};
|
||||
border: 5px solid ${this.navi.theme.border};
|
||||
border-radius: 16px 16px 0 0;
|
||||
padding: 0;
|
||||
box-shadow: 0 -4px 20px rgba(0,0,0,0.5);
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: 600px;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
#dialogue-box.expanded .dialogue-content {
|
||||
max-height: 100vh;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.dialogue-header {
|
||||
user-select: none;
|
||||
padding: 10px 10px 10px 15px;
|
||||
background: ${this.navi.theme.primary};
|
||||
border-bottom: 3px solid ${this.navi.theme.border};
|
||||
border-radius: 12px 12px 0 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
#dialogue-box.expanded .dialogue-header {
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.message-body {
|
||||
padding: 1.75rem 1.25rem;
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.75rem;
|
||||
min-height: 200px;
|
||||
max-height: 400px;
|
||||
}
|
||||
|
||||
#dialogue-box.expanded .message-body {
|
||||
max-height: none;
|
||||
}
|
||||
|
||||
.message-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.message-wrapper.user {
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.message-wrapper.assistant {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.message-label {
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
color: ${this.navi.theme.text};
|
||||
font-family: 'Courier New', monospace;
|
||||
opacity: 0.7;
|
||||
padding: 0 8px;
|
||||
}
|
||||
|
||||
.message-bubble {
|
||||
font-size: 15px;
|
||||
line-height: 1.5;
|
||||
font-family: 'Courier New', monospace;
|
||||
word-wrap: break-word;
|
||||
user-select: text;
|
||||
}
|
||||
|
||||
.message-wrapper.user .message-bubble {
|
||||
background: ${this.navi.theme.primary};
|
||||
color: ${this.navi.theme.primaryContrast};
|
||||
padding: 0.5rem 0.75rem;
|
||||
border-radius: 12px;
|
||||
border: 2px solid ${this.navi.theme.border};
|
||||
}
|
||||
|
||||
.message-wrapper.navi .message-bubble {
|
||||
color: ${this.navi.theme.text};
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;;
|
||||
height: 100%;
|
||||
font-size: 32px;
|
||||
font-weight: bold;
|
||||
color: ${this.navi.theme.text};
|
||||
font-family: 'Courier New', monospace;
|
||||
text-shadow:
|
||||
3px 3px 0 rgba(74, 144, 226, 0.3),
|
||||
-1px -1px 0 rgba(0,0,0,0.2);
|
||||
letter-spacing: 3px;
|
||||
animation: glowPulse 2s ease-in-out infinite;
|
||||
background: linear-gradient(45deg, transparent 30%, rgba(74, 144, 226, 0.1) 50%, transparent 70%);
|
||||
background-size: 200% 200%;
|
||||
animation: shimmer 3s ease-in-out infinite;
|
||||
-webkit-background-clip: text;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% { background-position: 0% 50%; }
|
||||
50% { background-position: 100% 50%; }
|
||||
100% { background-position: 0% 50%; }
|
||||
}
|
||||
|
||||
@keyframes glowPulse {
|
||||
0%, 100% { opacity: 0.8; }
|
||||
50% { opacity: 1; }
|
||||
}
|
||||
|
||||
.text-cursor {
|
||||
display: inline-block;
|
||||
width: 8px;
|
||||
height: 16px;
|
||||
background: currentColor;
|
||||
margin-left: 2px;
|
||||
animation: blink 0.5s infinite;
|
||||
vertical-align: text-bottom;
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
0%, 49% { opacity: 1; }
|
||||
50%, 100% { opacity: 0; }
|
||||
}
|
||||
|
||||
.tool-call {
|
||||
display: inline-block;
|
||||
background: ${this.navi.theme.primary};
|
||||
color: #fff;
|
||||
padding: 2px 8px;
|
||||
margin: 2px;
|
||||
border: 2px solid #000;
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
font-family: 'Courier New', monospace;
|
||||
animation: toolPulse 0.5s ease-in-out;
|
||||
}
|
||||
|
||||
@keyframes toolPulse {
|
||||
0%, 100% { transform: scale(1); opacity: 1; }
|
||||
50% { transform: scale(1.05); opacity: 0.9; }
|
||||
}
|
||||
|
||||
.file-badge {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: ${this.navi.theme.primary};
|
||||
color: ${this.navi.theme.primaryContrast};
|
||||
padding: 4px 10px;
|
||||
margin: 0;
|
||||
border: 2px solid ${this.navi.theme.border};
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.message-files {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
padding: 0 8px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.message-wrapper.user .message-files {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.message-wrapper.navi .message-files {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.attached-files {
|
||||
padding: 8px 12px;
|
||||
border-top: 3px solid ${this.navi.theme.border};
|
||||
background: ${this.navi.theme.backgroundDark};
|
||||
display: none;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.attached-files.has-files {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.attached-file {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
background: ${this.navi.theme.primary};
|
||||
color: ${this.navi.theme.primaryContrast};
|
||||
border: 2px solid ${this.navi.theme.border};
|
||||
padding: 6px 10px;
|
||||
border-radius: 6px;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 13px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.attached-file .file-name {
|
||||
max-width: 150px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.attached-file .remove-file {
|
||||
background: #e74c3c;
|
||||
border: 1px solid black;
|
||||
color: #fff;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.attached-file .remove-file:hover {
|
||||
background: #c0392b;
|
||||
}
|
||||
|
||||
.dialogue-input {
|
||||
padding: 12px 20px;
|
||||
border-top: 3px solid ${this.navi.theme.border};
|
||||
background: ${this.navi.theme.backgroundDark};
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.input-wrapper {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.dialogue-input textarea {
|
||||
width: 100%;
|
||||
background: ${this.navi.theme.background};
|
||||
border: 3px solid ${this.navi.theme.border};
|
||||
padding: 10px 14px;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-size: 16px;
|
||||
border-radius: 4px;
|
||||
color: ${this.navi.theme.text};
|
||||
resize: none;
|
||||
min-height: 44px;
|
||||
max-height: 120px;
|
||||
overflow-y: auto;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.dialogue-input textarea:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.dialogue-input textarea:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.button-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding-bottom: 5px;
|
||||
}
|
||||
|
||||
.clear-btn, .attach-btn, .dialogue-send-btn {
|
||||
background: ${this.navi.theme.accent};
|
||||
border: 3px solid ${this.navi.theme.border};
|
||||
color: ${this.navi.theme.accentContrast};
|
||||
padding: 10px 18px;
|
||||
font-family: 'Courier New', monospace;
|
||||
font-weight: bold;
|
||||
font-size: 14px;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 3px 0 ${this.navi.theme.accentDark};
|
||||
transition: background 0.2s;
|
||||
white-space: nowrap;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.dialogue-send-btn.stop {
|
||||
background: #e74c3c;
|
||||
box-shadow: 0 3px 0 #;
|
||||
}
|
||||
|
||||
.dialogue-send-btn.stop:active {
|
||||
box-shadow: 0 1px 0 #c0392b;
|
||||
}
|
||||
|
||||
#file-input {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.flex-fill-even {
|
||||
flex: 1 1 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
<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">
|
||||
<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="message-body" id="message-body">
|
||||
<div class="empty-state">NetNavi v1.0.0</div>
|
||||
</div>
|
||||
<div class="attached-files" id="attached-files"></div>
|
||||
<div class="dialogue-input">
|
||||
<div class="input-wrapper">
|
||||
<textarea id="dialogue-input" placeholder="Type your message..." rows="1"></textarea>
|
||||
</div>
|
||||
<div class="button-row">
|
||||
<btn-component class="flex-fill-even" id="clear-btn" color="#c0392b">CLEAR</btn-component>
|
||||
<btn-component class="flex-fill-even" id="attach-btn" color="${this.navi.theme.accent}">ATTACH</btn-component>
|
||||
<btn-component class="flex-fill-even" id="dialogue-send" color="${this.navi.theme.accent}">SEND</btn-component>
|
||||
</div>
|
||||
</div>
|
||||
<input type="file" id="file-input" multiple accept="*/*">
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
const dialogueHeader = this.shadowRoot.getElementById('dialogue-header');
|
||||
const dialogueInput = this.shadowRoot.getElementById('dialogue-input');
|
||||
const dialogueSend = this.shadowRoot.getElementById('dialogue-send');
|
||||
const attachBtn = this.shadowRoot.getElementById('attach-btn');
|
||||
const fileInput = this.shadowRoot.getElementById('file-input');
|
||||
const clearBtn = this.shadowRoot.getElementById('clear-btn');
|
||||
const expandBtn = this.shadowRoot.getElementById('expand-btn');
|
||||
|
||||
dialogueInput.addEventListener('input', () => {
|
||||
dialogueInput.style.height = 'auto';
|
||||
dialogueInput.style.height = Math.min(dialogueInput.scrollHeight, 120) + 'px';
|
||||
});
|
||||
|
||||
dialogueInput.addEventListener('paste', (e) => {
|
||||
const text = e.clipboardData.getData('text');
|
||||
if (text.length > 1000) {
|
||||
e.preventDefault();
|
||||
const blob = new Blob([text], { type: 'text/plain' });
|
||||
const file = new File([blob], 'pasted_text.txt', { type: 'text/plain' });
|
||||
this.addFile(file);
|
||||
}
|
||||
});
|
||||
|
||||
dialogueHeader.addEventListener('click', (e) => {
|
||||
this.toggleDialogue();
|
||||
});
|
||||
|
||||
dialogueInput.addEventListener('focus', () => {
|
||||
if(!this.isDialogueOpen) this.openDialogue();
|
||||
});
|
||||
|
||||
clearBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
this.clearChat();
|
||||
});
|
||||
|
||||
expandBtn.addEventListener('click', (e) => {
|
||||
e.stopPropagation();
|
||||
this.toggleExpand();
|
||||
});
|
||||
|
||||
attachBtn.addEventListener('click', () => fileInput.click());
|
||||
|
||||
fileInput.addEventListener('change', (e) => {
|
||||
Array.from(e.target.files).forEach(file => this.addFile(file));
|
||||
fileInput.value = '';
|
||||
});
|
||||
|
||||
dialogueSend.addEventListener('click', () => {
|
||||
const buttonText = dialogueSend.textContent;
|
||||
if (buttonText === 'SKIP') this.skipToEnd();
|
||||
else if (buttonText === 'STOP') this.abortStream();
|
||||
else this.sendMessage();
|
||||
});
|
||||
|
||||
dialogueInput.addEventListener('keypress', (e) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey && !this.isReceiving) {
|
||||
e.preventDefault();
|
||||
this.sendMessage();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
clearChat() {
|
||||
this.messageHistory = [];
|
||||
const messageBody = this.shadowRoot.getElementById('message-body');
|
||||
messageBody.innerHTML = '<div class="empty-state">NetNavi v1.0.0</div>';
|
||||
this.navi.clearChat();
|
||||
}
|
||||
|
||||
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"/>
|
||||
<path d="M16 16 L21 21 M16 21 L16 16 L21 16" stroke="currentColor" stroke-width="2.5" fill="none"/>
|
||||
</svg>
|
||||
` : `
|
||||
<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>
|
||||
`;
|
||||
}
|
||||
|
||||
addFile(file) {
|
||||
this.attachedFiles.push(file);
|
||||
this.renderAttachedFiles();
|
||||
}
|
||||
|
||||
removeFile(index) {
|
||||
this.attachedFiles.splice(index, 1);
|
||||
this.renderAttachedFiles();
|
||||
}
|
||||
|
||||
renderAttachedFiles() {
|
||||
const container = this.shadowRoot.getElementById('attached-files');
|
||||
|
||||
if (this.attachedFiles.length === 0) {
|
||||
container.classList.remove('has-files');
|
||||
container.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
container.classList.add('has-files');
|
||||
container.innerHTML = this.attachedFiles.map((file, i) => `
|
||||
<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));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
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}`;
|
||||
}
|
||||
|
||||
toggleDialogue() {
|
||||
this.isDialogueOpen = !this.isDialogueOpen;
|
||||
const dialogueBox = this.shadowRoot.getElementById('dialogue-box');
|
||||
dialogueBox.classList.toggle('minimized');
|
||||
|
||||
this.dispatchEvent(new CustomEvent('dialogue-toggle', {
|
||||
detail: { isOpen: this.isDialogueOpen }
|
||||
}));
|
||||
}
|
||||
|
||||
openDialogue() {
|
||||
this.isDialogueOpen = true;
|
||||
const dialogueBox = this.shadowRoot.getElementById('dialogue-box');
|
||||
dialogueBox.classList.remove('minimized');
|
||||
|
||||
this.dispatchEvent(new CustomEvent('dialogue-toggle', {
|
||||
detail: { isOpen: this.isDialogueOpen }
|
||||
}));
|
||||
}
|
||||
|
||||
playTextBeep() {
|
||||
const oscillator = this.audioCtx.createOscillator();
|
||||
const gainNode = this.audioCtx.createGain();
|
||||
oscillator.connect(gainNode);
|
||||
gainNode.connect(this.audioCtx.destination);
|
||||
oscillator.type = 'square';
|
||||
oscillator.frequency.setValueAtTime(1200, this.audioCtx.currentTime);
|
||||
gainNode.gain.setValueAtTime(0.1, this.audioCtx.currentTime);
|
||||
gainNode.gain.exponentialRampToValueAtTime(0.01, this.audioCtx.currentTime + 0.05);
|
||||
oscillator.start(this.audioCtx.currentTime);
|
||||
oscillator.stop(this.audioCtx.currentTime + 0.05);
|
||||
}
|
||||
|
||||
shouldAutoScroll() {
|
||||
const messageBody = this.shadowRoot.getElementById('message-body');
|
||||
const scrollThreshold = 50;
|
||||
const distanceFromBottom = messageBody.scrollHeight - messageBody.scrollTop - messageBody.clientHeight;
|
||||
return distanceFromBottom <= scrollThreshold;
|
||||
}
|
||||
|
||||
scrollToBottom() {
|
||||
const messageBody = this.shadowRoot.getElementById('message-body');
|
||||
messageBody.scrollTop = messageBody.scrollHeight;
|
||||
}
|
||||
|
||||
addMessage(text, isUser) {
|
||||
const messageBody = this.shadowRoot.getElementById('message-body');
|
||||
const emptyState = messageBody.querySelector('.empty-state');
|
||||
if (emptyState) messageBody.innerHTML = '';
|
||||
|
||||
// Extract file badges and clean text
|
||||
const fileBadges = [];
|
||||
const fileRegex = /<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 fileBadgesHtml = fileBadges.length > 0
|
||||
? `<div class="message-files">${fileBadges.map(name =>
|
||||
`<span class="file-badge">📄 ${name}</span>`).join('')}</div>`
|
||||
: '';
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
startStreaming() {
|
||||
this.isReceiving = true;
|
||||
this.streamComplete = false;
|
||||
this.streamBuffer = '';
|
||||
this.typingIndex = 0;
|
||||
|
||||
const dialogueInput = this.shadowRoot.getElementById('dialogue-input');
|
||||
const dialogueSend = this.shadowRoot.getElementById('dialogue-send');
|
||||
const attachBtn = this.shadowRoot.getElementById('attach-btn');
|
||||
const clearBtn = this.shadowRoot.getElementById('clear-btn');
|
||||
const messageBody = this.shadowRoot.getElementById('message-body');
|
||||
|
||||
dialogueInput.disabled = true;
|
||||
attachBtn.disabled = true;
|
||||
clearBtn.disabled = true;
|
||||
dialogueSend.textContent = 'STOP';
|
||||
dialogueSend.setAttribute('color', '#c0392b');
|
||||
attachBtn.setAttribute('disabled', true);
|
||||
clearBtn.setAttribute('disabled', true);
|
||||
|
||||
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>`;
|
||||
|
||||
messageBody.appendChild(messageWrapper);
|
||||
|
||||
this.currentStreamingMessage = { text: '', html: '', isUser: false, element: messageWrapper, timestamp: Date.now() };
|
||||
this.messageHistory.push(this.currentStreamingMessage);
|
||||
|
||||
this.scrollToBottom();
|
||||
|
||||
this.typingInterval = setInterval(() => this.typeNextChar(), 30);
|
||||
}
|
||||
|
||||
handleStreamChunk(chunk) {
|
||||
if (!this.isReceiving) this.startStreaming();
|
||||
|
||||
if (chunk.text) this.streamBuffer += chunk.text;
|
||||
if (chunk.tool && !this.hideTools.includes(chunk.tool)) this.streamBuffer += `<span class="tool-call">⚡ ${chunk.tool}</span>`;
|
||||
}
|
||||
|
||||
handleStreamComplete(response) {
|
||||
this.streamComplete = true;
|
||||
if (this.typingIndex >= this.streamBuffer.length) {
|
||||
this.cleanupStreaming();
|
||||
} else {
|
||||
const dialogueSend = this.shadowRoot.getElementById('dialogue-send');
|
||||
dialogueSend.textContent = 'SKIP';
|
||||
dialogueSend.setAttribute('color', '#f39c12');
|
||||
}
|
||||
}
|
||||
|
||||
typeNextChar() {
|
||||
if (this.typingIndex >= this.streamBuffer.length && this.streamComplete) {
|
||||
this.cleanupStreaming();
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.typingIndex >= this.streamBuffer.length) return;
|
||||
|
||||
const bubble = this.shadowRoot.getElementById('streaming-bubble');
|
||||
if (!bubble) return;
|
||||
|
||||
const shouldScroll = this.shouldAutoScroll();
|
||||
|
||||
if (this.streamBuffer[this.typingIndex] === '<') {
|
||||
const tagEnd = this.streamBuffer.indexOf('>', this.typingIndex);
|
||||
if (tagEnd !== -1) {
|
||||
const tag = this.streamBuffer.substring(this.typingIndex, tagEnd + 1);
|
||||
this.currentStreamingMessage.html += tag;
|
||||
this.currentStreamingMessage.text += tag;
|
||||
this.typingIndex = tagEnd + 1;
|
||||
bubble.innerHTML = this.currentStreamingMessage.html + '<span class="text-cursor"></span>';
|
||||
if (shouldScroll) this.scrollToBottom();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const char = this.streamBuffer[this.typingIndex];
|
||||
this.currentStreamingMessage.text += char;
|
||||
this.currentStreamingMessage.html += char;
|
||||
|
||||
bubble.innerHTML = this.currentStreamingMessage.html + '<span class="text-cursor"></span>';
|
||||
|
||||
if (char !== ' ' && char !== '<') {
|
||||
this.playTextBeep();
|
||||
if ('vibrate' in navigator) navigator.vibrate(10);
|
||||
}
|
||||
|
||||
this.typingIndex++;
|
||||
if (shouldScroll) this.scrollToBottom();
|
||||
}
|
||||
|
||||
skipToEnd() {
|
||||
clearInterval(this.typingInterval);
|
||||
|
||||
const bubble = this.shadowRoot.getElementById('streaming-bubble');
|
||||
|
||||
this.currentStreamingMessage.text = this.streamBuffer;
|
||||
this.currentStreamingMessage.html = this.streamBuffer;
|
||||
this.typingIndex = this.streamBuffer.length;
|
||||
|
||||
if (bubble) bubble.innerHTML = this.currentStreamingMessage.html;
|
||||
|
||||
this.scrollToBottom();
|
||||
this.cleanupStreaming();
|
||||
}
|
||||
|
||||
cleanupStreaming() {
|
||||
clearInterval(this.typingInterval);
|
||||
this.isReceiving = false;
|
||||
this.streamComplete = false;
|
||||
|
||||
const dialogueInput = this.shadowRoot.getElementById('dialogue-input');
|
||||
const dialogueSend = this.shadowRoot.getElementById('dialogue-send');
|
||||
const attachBtn = this.shadowRoot.getElementById('attach-btn');
|
||||
const clearBtn = this.shadowRoot.getElementById('clear-btn');
|
||||
const bubble = this.shadowRoot.getElementById('streaming-bubble');
|
||||
|
||||
dialogueInput.disabled = false;
|
||||
attachBtn.disabled = false;
|
||||
clearBtn.disabled = false;
|
||||
dialogueSend.textContent = 'SEND';
|
||||
dialogueSend.setAttribute('color', this.navi.theme.accent);
|
||||
attachBtn.removeAttribute('disabled');
|
||||
clearBtn.removeAttribute('disabled');
|
||||
|
||||
if (bubble) {
|
||||
bubble.id = '';
|
||||
bubble.innerHTML = this.currentStreamingMessage.html;
|
||||
}
|
||||
|
||||
this.streamBuffer = '';
|
||||
this.typingIndex = 0;
|
||||
this.currentRequest = null;
|
||||
this.currentStreamingMessage = null;
|
||||
}
|
||||
|
||||
abortStream() {
|
||||
if (this.currentRequest?.abort) {
|
||||
this.currentRequest.abort();
|
||||
}
|
||||
|
||||
clearInterval(this.typingInterval);
|
||||
|
||||
if (this.currentStreamingMessage) {
|
||||
this.streamBuffer = this.currentStreamingMessage.text || '';
|
||||
this.typingIndex = this.streamBuffer.length;
|
||||
}
|
||||
|
||||
this.cleanupStreaming();
|
||||
}
|
||||
|
||||
async sendMessage() {
|
||||
const dialogueInput = this.shadowRoot.getElementById('dialogue-input');
|
||||
let text = dialogueInput.value.trim();
|
||||
if ((!text && this.attachedFiles.length === 0) || this.isReceiving) return;
|
||||
|
||||
if (this.attachedFiles.length > 0) {
|
||||
const fileBlocks = await Promise.all(
|
||||
this.attachedFiles.map(async (file) => {
|
||||
const content = await this.fileToString(file);
|
||||
return `<file name="${file.name}">${content}</file>`;
|
||||
})
|
||||
);
|
||||
text = text + '\n\n' + fileBlocks.join('\n');
|
||||
}
|
||||
|
||||
dialogueInput.value = '';
|
||||
dialogueInput.style.height = 'auto';
|
||||
|
||||
this.addMessage(text, true);
|
||||
|
||||
this.attachedFiles = [];
|
||||
this.renderAttachedFiles();
|
||||
|
||||
// Send via API with streaming callback 💬
|
||||
this.currentRequest = this.navi.ask(text, (chunk) => this.handleStreamChunk(chunk));
|
||||
|
||||
// Handle completion/errors with promise
|
||||
try {
|
||||
const response = await this.currentRequest;
|
||||
this.handleStreamComplete(response);
|
||||
} catch (error) {
|
||||
if (error.message !== 'Aborted by user') {
|
||||
console.error('❌ LLM Error:', error);
|
||||
this.addMessage(`Error: ${error.message || 'Something went wrong'}`, false);
|
||||
}
|
||||
this.cleanupStreaming();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('llm-component', LlmComponent);
|
||||
382
public/components/world.mjs
Normal file
382
public/components/world.mjs
Normal file
@@ -0,0 +1,382 @@
|
||||
// ============================================
|
||||
// CONSTANTS
|
||||
// ============================================
|
||||
const TILE_WIDTH = 64;
|
||||
const TILE_HEIGHT = 32;
|
||||
const TILE_DEPTH = 16;
|
||||
|
||||
// ============================================
|
||||
// THEME HANDLER
|
||||
// ============================================
|
||||
function applyTheme(theme) {
|
||||
const body = document.body;
|
||||
|
||||
if (theme.background.image) {
|
||||
body.style.backgroundImage = `url(${theme.background.image})`;
|
||||
}
|
||||
|
||||
body.style.backgroundSize = theme.background.style || 'cover';
|
||||
body.style.backgroundPosition = 'center';
|
||||
body.style.backgroundRepeat = 'no-repeat';
|
||||
body.style.backgroundAttachment = 'fixed';
|
||||
|
||||
const root = document.documentElement;
|
||||
Object.entries(theme.colors).forEach(([key, value]) => {
|
||||
const cssVar = `--${key.replace(/([A-Z])/g, '-$1').toLowerCase()}`;
|
||||
root.style.setProperty(cssVar, value);
|
||||
});
|
||||
|
||||
console.log('🎨 Theme applied:', theme.name);
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// TILE RENDERER
|
||||
// ============================================
|
||||
function isoToScreen(gridX, gridY) {
|
||||
return {
|
||||
x: (gridX - gridY) * (TILE_WIDTH / 2) + window.innerWidth / 2,
|
||||
y: (gridX + gridY) * (TILE_HEIGHT / 2) + 100
|
||||
};
|
||||
}
|
||||
|
||||
function createTile(tileData, theme) {
|
||||
const graphics = new PIXI.Graphics();
|
||||
const pos = isoToScreen(tileData.x, tileData.y);
|
||||
|
||||
const colors = {
|
||||
top: parseInt(theme.colors.tileTop.replace('#', '0x')),
|
||||
side: parseInt(theme.colors.tileSide.replace('#', '0x')),
|
||||
grid: parseInt(theme.colors.gridColor.replace('#', '0x')),
|
||||
highlight: parseInt(theme.colors.tileHighlight.replace('#', '0x')),
|
||||
gridHighlight: parseInt(theme.colors.gridHighlight.replace('#', '0x'))
|
||||
};
|
||||
|
||||
function drawNormalTile() {
|
||||
graphics.clear();
|
||||
graphics.beginFill(colors.top);
|
||||
graphics.lineStyle(1, colors.grid);
|
||||
graphics.moveTo(pos.x, pos.y);
|
||||
graphics.lineTo(pos.x + TILE_WIDTH / 2, pos.y + TILE_HEIGHT / 2);
|
||||
graphics.lineTo(pos.x, pos.y + TILE_HEIGHT);
|
||||
graphics.lineTo(pos.x - TILE_WIDTH / 2, pos.y + TILE_HEIGHT / 2);
|
||||
graphics.lineTo(pos.x, pos.y);
|
||||
graphics.endFill();
|
||||
|
||||
graphics.beginFill(colors.side);
|
||||
graphics.lineStyle(1, colors.grid);
|
||||
graphics.moveTo(pos.x - TILE_WIDTH / 2, pos.y + TILE_HEIGHT / 2);
|
||||
graphics.lineTo(pos.x, pos.y + TILE_HEIGHT);
|
||||
graphics.lineTo(pos.x, pos.y + TILE_HEIGHT + TILE_DEPTH);
|
||||
graphics.lineTo(pos.x - TILE_WIDTH / 2, pos.y + TILE_HEIGHT / 2 + TILE_DEPTH);
|
||||
graphics.lineTo(pos.x - TILE_WIDTH / 2, pos.y + TILE_HEIGHT / 2);
|
||||
graphics.endFill();
|
||||
|
||||
graphics.beginFill(colors.side);
|
||||
graphics.lineStyle(1, colors.grid);
|
||||
graphics.moveTo(pos.x + TILE_WIDTH / 2, pos.y + TILE_HEIGHT / 2);
|
||||
graphics.lineTo(pos.x, pos.y + TILE_HEIGHT);
|
||||
graphics.lineTo(pos.x, pos.y + TILE_HEIGHT + TILE_DEPTH);
|
||||
graphics.lineTo(pos.x + TILE_WIDTH / 2, pos.y + TILE_HEIGHT / 2 + TILE_DEPTH);
|
||||
graphics.lineTo(pos.x + TILE_WIDTH / 2, pos.y + TILE_HEIGHT / 2);
|
||||
graphics.endFill();
|
||||
}
|
||||
|
||||
function drawHighlightTile() {
|
||||
graphics.clear();
|
||||
graphics.beginFill(colors.highlight);
|
||||
graphics.lineStyle(2, colors.gridHighlight);
|
||||
graphics.moveTo(pos.x, pos.y);
|
||||
graphics.lineTo(pos.x + TILE_WIDTH / 2, pos.y + TILE_HEIGHT / 2);
|
||||
graphics.lineTo(pos.x, pos.y + TILE_HEIGHT);
|
||||
graphics.lineTo(pos.x - TILE_WIDTH / 2, pos.y + TILE_HEIGHT / 2);
|
||||
graphics.lineTo(pos.x, pos.y);
|
||||
graphics.endFill();
|
||||
|
||||
graphics.beginFill(colors.side);
|
||||
graphics.lineStyle(1, colors.gridHighlight);
|
||||
graphics.moveTo(pos.x - TILE_WIDTH / 2, pos.y + TILE_HEIGHT / 2);
|
||||
graphics.lineTo(pos.x, pos.y + TILE_HEIGHT);
|
||||
graphics.lineTo(pos.x, pos.y + TILE_HEIGHT + TILE_DEPTH);
|
||||
graphics.lineTo(pos.x - TILE_WIDTH / 2, pos.y + TILE_HEIGHT / 2 + TILE_DEPTH);
|
||||
graphics.lineTo(pos.x - TILE_WIDTH / 2, pos.y + TILE_HEIGHT / 2);
|
||||
graphics.endFill();
|
||||
|
||||
graphics.beginFill(colors.side);
|
||||
graphics.lineStyle(1, colors.gridHighlight);
|
||||
graphics.moveTo(pos.x + TILE_WIDTH / 2, pos.y + TILE_HEIGHT / 2);
|
||||
graphics.lineTo(pos.x, pos.y + TILE_HEIGHT);
|
||||
graphics.lineTo(pos.x, pos.y + TILE_HEIGHT + TILE_DEPTH);
|
||||
graphics.lineTo(pos.x + TILE_WIDTH / 2, pos.y + TILE_HEIGHT / 2 + TILE_DEPTH);
|
||||
graphics.lineTo(pos.x + TILE_WIDTH / 2, pos.y + TILE_HEIGHT / 2);
|
||||
graphics.endFill();
|
||||
}
|
||||
|
||||
drawNormalTile();
|
||||
|
||||
graphics.interactive = true;
|
||||
graphics.buttonMode = true;
|
||||
graphics.gridX = tileData.x;
|
||||
graphics.gridY = tileData.y;
|
||||
|
||||
graphics.on('pointerover', () => {
|
||||
drawHighlightTile();
|
||||
});
|
||||
|
||||
graphics.on('pointerout', () => {
|
||||
drawNormalTile();
|
||||
});
|
||||
|
||||
return graphics;
|
||||
}
|
||||
|
||||
function createPet(gridX, gridY, name = 'PET') {
|
||||
const container = new PIXI.Container();
|
||||
const pos = isoToScreen(gridX, gridY);
|
||||
|
||||
const body = new PIXI.Graphics();
|
||||
body.beginFill(0xff6b9d);
|
||||
body.drawCircle(0, -30, 15);
|
||||
body.endFill();
|
||||
|
||||
body.beginFill(0xffffff);
|
||||
body.drawCircle(-5, -32, 4);
|
||||
body.drawCircle(5, -32, 4);
|
||||
body.endFill();
|
||||
|
||||
body.beginFill(0x000000);
|
||||
body.drawCircle(-5, -32, 2);
|
||||
body.drawCircle(5, -32, 2);
|
||||
body.endFill();
|
||||
|
||||
const nameText = new PIXI.Text(name, {
|
||||
fontFamily: 'Courier New',
|
||||
fontSize: 12,
|
||||
fill: '#ffffff',
|
||||
stroke: '#000000',
|
||||
strokeThickness: 2
|
||||
});
|
||||
nameText.anchor.set(0.5);
|
||||
nameText.y = -50;
|
||||
|
||||
container.addChild(body);
|
||||
container.addChild(nameText);
|
||||
container.x = pos.x;
|
||||
container.y = pos.y;
|
||||
container.gridX = gridX;
|
||||
container.gridY = gridY;
|
||||
|
||||
return container;
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// GAME CLASS
|
||||
// ============================================
|
||||
class Game {
|
||||
constructor() {
|
||||
this.worldId = '';
|
||||
this.world = null;
|
||||
this.app = null;
|
||||
this.pet = null;
|
||||
this.otherPlayers = new Map();
|
||||
this.isMoving = false;
|
||||
this.dialogue = null;
|
||||
this.keys = {};
|
||||
|
||||
// Use global singleton 🌍
|
||||
this.navi = window.navi;
|
||||
this.worldActions = null;
|
||||
|
||||
this.playerInfo = {
|
||||
name: 'Guest',
|
||||
apiUrl: this.navi.navi
|
||||
};
|
||||
}
|
||||
|
||||
async init() {
|
||||
try {
|
||||
// Join world with callbacks 🌍
|
||||
await this.navi.init();
|
||||
this.worldActions = this.navi.connect(this.worldId);
|
||||
|
||||
this.worldActions.onData = (data) => {
|
||||
this.world = data;
|
||||
applyTheme(this.world.theme);
|
||||
this.initializeRenderer();
|
||||
}
|
||||
|
||||
this.worldActions.onPlayers = (players) => {
|
||||
players.forEach(player => {
|
||||
if (player.name !== this.navi.info.name) {
|
||||
this.addOtherPlayer(player);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
this.worldActions.onJoined = (player) => {
|
||||
this.addOtherPlayer(player);
|
||||
}
|
||||
|
||||
this.worldActions.onMoved = (data) => {
|
||||
const sprite = this.otherPlayers.get(data.socketId);
|
||||
if (sprite) {
|
||||
this.moveOtherPlayer(sprite, data.x, data.y);
|
||||
}
|
||||
};
|
||||
|
||||
this.worldActions.onLeft = (data) => {
|
||||
const sprite = this.otherPlayers.get(data.socketId);
|
||||
if(sprite) this.otherPlayers.delete(data.socketId);
|
||||
}
|
||||
this.worldActions.onError = (error) => console.error('❌ World error:', error);
|
||||
|
||||
console.log('✨ Game initializing...');
|
||||
} catch (error) {
|
||||
console.error('❌ Failed to initialize game:', error);
|
||||
}
|
||||
}
|
||||
|
||||
addOtherPlayer(player) {
|
||||
const sprite = createPet(player.x, player.y, player.name);
|
||||
sprite.alpha = 0.7;
|
||||
this.otherPlayers.set(player.socketId, sprite);
|
||||
this.app.stage.addChild(sprite);
|
||||
}
|
||||
|
||||
moveOtherPlayer(sprite, targetX, targetY) {
|
||||
const targetPos = isoToScreen(targetX, targetY);
|
||||
|
||||
const startX = sprite.x;
|
||||
const startY = sprite.y;
|
||||
let progress = 0;
|
||||
|
||||
const animate = () => {
|
||||
progress += 0.08;
|
||||
if (progress >= 1) {
|
||||
sprite.x = targetPos.x;
|
||||
sprite.y = targetPos.y;
|
||||
sprite.gridX = targetX;
|
||||
sprite.gridY = targetY;
|
||||
return;
|
||||
}
|
||||
|
||||
sprite.x = startX + (targetPos.x - startX) * progress;
|
||||
sprite.y = startY + (targetPos.y - startY) * progress;
|
||||
|
||||
requestAnimationFrame(animate);
|
||||
};
|
||||
|
||||
animate();
|
||||
}
|
||||
|
||||
initializeRenderer() {
|
||||
this.app = new PIXI.Application({
|
||||
width: window.innerWidth,
|
||||
height: window.innerHeight,
|
||||
backgroundAlpha: 0,
|
||||
antialias: true,
|
||||
resolution: window.devicePixelRatio || 1,
|
||||
autoDensity: true
|
||||
});
|
||||
document.getElementById('game').appendChild(this.app.view);
|
||||
|
||||
const tiles = new PIXI.Container();
|
||||
this.app.stage.addChild(tiles);
|
||||
|
||||
this.world.tiles.forEach(tileData => {
|
||||
const tile = createTile(tileData, this.world.theme);
|
||||
tile.on('pointerdown', () => this.movePetTo(tile.gridX, tile.gridY));
|
||||
tiles.addChild(tile);
|
||||
});
|
||||
|
||||
const spawn = this.world.tiles.find(t => t.type === 'spawn');
|
||||
this.pet = createPet(spawn.x, spawn.y, this.playerInfo.name);
|
||||
this.app.stage.addChild(this.pet);
|
||||
|
||||
this.dialogue = document.getElementById('llm');
|
||||
|
||||
this.setupInput();
|
||||
this.app.ticker.add(() => this.gameLoop());
|
||||
}
|
||||
|
||||
movePetTo(targetX, targetY) {
|
||||
if (this.isMoving ||
|
||||
targetX < 0 || targetX >= this.world.gridSize ||
|
||||
targetY < 0 || targetY >= this.world.gridSize) return;
|
||||
|
||||
this.isMoving = true;
|
||||
const targetPos = isoToScreen(targetX, targetY);
|
||||
|
||||
const startX = this.pet.x;
|
||||
const startY = this.pet.y;
|
||||
let progress = 0;
|
||||
|
||||
const animate = () => {
|
||||
progress += 0.08;
|
||||
if (progress >= 1) {
|
||||
this.pet.x = targetPos.x;
|
||||
this.pet.y = targetPos.y;
|
||||
this.pet.gridX = targetX;
|
||||
this.pet.gridY = targetY;
|
||||
this.isMoving = false;
|
||||
|
||||
// Use API action to send move 📤
|
||||
if (this.worldActions) {
|
||||
this.worldActions.move(targetX, targetY);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
this.pet.x = startX + (targetPos.x - startX) * progress;
|
||||
this.pet.y = startY + (targetPos.y - startY) * progress;
|
||||
|
||||
requestAnimationFrame(animate);
|
||||
};
|
||||
|
||||
animate();
|
||||
}
|
||||
|
||||
setupInput() {
|
||||
window.addEventListener('keydown', (e) => {
|
||||
if (this.dialogue.isOpen) return;
|
||||
|
||||
this.keys[e.key.toLowerCase()] = true;
|
||||
|
||||
if (!this.isMoving) {
|
||||
let newX = this.pet.gridX;
|
||||
let newY = this.pet.gridY;
|
||||
|
||||
if (this.keys['w'] || this.keys['arrowup']) {
|
||||
newY--;
|
||||
} else if (this.keys['s'] || this.keys['arrowdown']) {
|
||||
newY++;
|
||||
} else if (this.keys['a'] || this.keys['arrowleft']) {
|
||||
newX--;
|
||||
} else if (this.keys['d'] || this.keys['arrowright']) {
|
||||
newX++;
|
||||
}
|
||||
|
||||
this.movePetTo(newX, newY);
|
||||
}
|
||||
});
|
||||
|
||||
window.addEventListener('keyup', (e) => {
|
||||
this.keys[e.key.toLowerCase()] = false;
|
||||
});
|
||||
}
|
||||
|
||||
gameLoop() {
|
||||
if (!this.isMoving && this.pet) {
|
||||
this.pet.children[0].y = -30 + Math.sin(Date.now() / 300) * 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================
|
||||
// START GAME
|
||||
// ============================================
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const worldId = urlParams.get('world');
|
||||
|
||||
const game = new Game();
|
||||
game.worldId = worldId;
|
||||
game.init();
|
||||
Reference in New Issue
Block a user