generated from ztimson/template
All checks were successful
Build and publish / Build Container (push) Successful in 1m27s
935 lines
34 KiB
JavaScript
935 lines
34 KiB
JavaScript
class LlmComponent extends HTMLElement {
|
|
hideTools = ['adjust_personality', 'recall', 'remember']
|
|
|
|
get isOpen() { return this.isDialogueOpen; };
|
|
|
|
constructor() {
|
|
super();
|
|
this.attachShadow({ mode: 'open' });
|
|
|
|
// Use global singleton 🔥
|
|
this.api = window.netNaviAPI;
|
|
|
|
this.isTyping = false;
|
|
this.isReceiving = false;
|
|
this.streamComplete = false;
|
|
this.isDialogueOpen = false;
|
|
this.isExpanded = false;
|
|
this.messageHistory = [];
|
|
this.audioCtx = new (window.AudioContext || window.webkitAudioContext)();
|
|
this.streamBuffer = '';
|
|
this.typingIndex = 0;
|
|
this.typingInterval = null;
|
|
this.currentRequest = null;
|
|
this.attachedFiles = [];
|
|
this.currentStreamingMessage = null;
|
|
|
|
this.render();
|
|
this.initEventListeners();
|
|
}
|
|
|
|
render() {
|
|
this.shadowRoot.innerHTML = `
|
|
<style>
|
|
::-webkit-scrollbar {
|
|
width: 12px;
|
|
}
|
|
|
|
::-webkit-scrollbar-track {
|
|
background: transparent;
|
|
border: none;
|
|
}
|
|
|
|
::-webkit-scrollbar-thumb {
|
|
background: var(--dialogue-header-bg, #fff);
|
|
border: 2px solid var(--dialog-border, #000);
|
|
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: var(--dialogue-bg, #fff);
|
|
border: 5px solid var(--dialogue-border, #000);
|
|
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: 12px 20px;
|
|
background: var(--dialogue-header-bg, #fff);
|
|
border-bottom: 3px solid var(--dialogue-border, #000);
|
|
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;
|
|
}
|
|
|
|
.speaker-name {
|
|
font-weight: bold;
|
|
font-size: 16px;
|
|
color: var(--dialogue-text, #000);
|
|
font-family: 'Courier New', monospace;
|
|
pointer-events: none;
|
|
}
|
|
|
|
.expand-btn {
|
|
background: var(--button-bg, #4a90e2);
|
|
border: 2px solid var(--dialogue-border, #000);
|
|
color: var(--button-text, #fff);
|
|
padding: 8px;
|
|
font-family: 'Courier New', monospace;
|
|
font-weight: bold;
|
|
font-size: 14px;
|
|
border-radius: 4px;
|
|
box-shadow: 0 3px 0 var(--button-shadow, #2a5a9a);
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 4px;
|
|
}
|
|
|
|
.expand-btn:active {
|
|
transform: translateY(2px);
|
|
box-shadow: 0 1px 0 var(--button-shadow, #2a5a9a);
|
|
}
|
|
|
|
.expand-icon {
|
|
width: 20px;
|
|
height: 20px;
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.message-body {
|
|
padding: 20px;
|
|
flex: 1;
|
|
overflow-y: auto;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 16px;
|
|
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: var(--dialogue-text, #000);
|
|
font-family: 'Courier New', monospace;
|
|
opacity: 0.7;
|
|
padding: 0 8px;
|
|
}
|
|
|
|
.message-bubble {
|
|
max-width: 80%;
|
|
padding: 12px 16px;
|
|
border-radius: 12px;
|
|
font-size: 15px;
|
|
line-height: 1.5;
|
|
font-family: 'Courier New', monospace;
|
|
word-wrap: break-word;
|
|
border: 2px solid var(--dialogue-border, #000);
|
|
box-shadow: 2px 2px 0 rgba(0,0,0,0.2);
|
|
user-select: text;
|
|
}
|
|
|
|
.message-wrapper.user .message-bubble {
|
|
background: var(--button-bg, #4a90e2);
|
|
color: var(--button-text, #fff);
|
|
}
|
|
|
|
.message-wrapper.assistant .message-bubble {
|
|
background: var(--dialogue-bg, #fff);
|
|
color: var(--dialogue-text, #000);
|
|
}
|
|
|
|
.message-timestamp {
|
|
font-size: 10px;
|
|
opacity: 0.6;
|
|
padding: 0 8px;
|
|
font-family: 'Courier New', monospace;
|
|
color: var(--dialogue-text, #000);
|
|
}
|
|
|
|
.empty-state {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
height: 100%;
|
|
font-size: 32px;
|
|
font-weight: bold;
|
|
color: var(--dialogue-text, #000);
|
|
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: var(--button-bg, #000);
|
|
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: var(--button-bg, #4a90e2);
|
|
color: var(--button-text, #fff);
|
|
padding: 4px 10px;
|
|
margin: 0;
|
|
border: 2px solid var(--dialogue-border, #000);
|
|
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.assistant .message-files {
|
|
justify-content: flex-start;
|
|
}
|
|
|
|
.attached-files {
|
|
padding: 8px 12px;
|
|
border-top: 3px solid var(--dialogue-border, #000);
|
|
background: var(--dialogue-input-bg, #f0f0f0);
|
|
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: var(--dialogue-bg, #fff);
|
|
border: 2px solid var(--dialogue-border, #000);
|
|
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: none;
|
|
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 var(--dialogue-border, #000);
|
|
background: var(--dialogue-input-bg, #f0f0f0);
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.input-wrapper {
|
|
margin-bottom: 8px;
|
|
}
|
|
|
|
.dialogue-input textarea {
|
|
width: 100%;
|
|
background: var(--dialogue-bg, #fff);
|
|
border: 3px solid var(--dialogue-border, #000);
|
|
padding: 10px 14px;
|
|
font-family: 'Courier New', monospace;
|
|
font-size: 16px;
|
|
border-radius: 4px;
|
|
color: var(--dialogue-text, #000);
|
|
resize: none;
|
|
min-height: 44px;
|
|
max-height: 120px;
|
|
overflow-y: auto;
|
|
box-sizing: border-box;
|
|
}
|
|
|
|
.dialogue-input textarea:focus {
|
|
outline: none;
|
|
box-shadow: 0 0 0 2px var(--button-bg, #4a90e2);
|
|
}
|
|
|
|
.dialogue-input textarea:disabled {
|
|
opacity: 0.5;
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
.button-row {
|
|
display: flex;
|
|
gap: 8px;
|
|
}
|
|
|
|
.clear-btn, .attach-btn, .dialogue-send-btn {
|
|
background: var(--button-bg, #4a90e2);
|
|
border: 3px solid var(--dialogue-border, #000);
|
|
color: var(--button-text, #fff);
|
|
padding: 10px 18px;
|
|
font-family: 'Courier New', monospace;
|
|
font-weight: bold;
|
|
font-size: 14px;
|
|
border-radius: 4px;
|
|
box-shadow: 0 3px 0 var(--button-shadow, #2a5a9a);
|
|
transition: background 0.2s;
|
|
white-space: nowrap;
|
|
flex: 1;
|
|
}
|
|
|
|
.clear-btn {
|
|
background: #e74c3c;
|
|
box-shadow: 0 3px 0 #c0392b;
|
|
}
|
|
|
|
.clear-btn:active {
|
|
transform: translateY(2px);
|
|
box-shadow: 0 1px 0 #c0392b;
|
|
}
|
|
|
|
.attach-btn:active, .dialogue-send-btn:active {
|
|
transform: translateY(2px);
|
|
box-shadow: 0 1px 0 var(--button-shadow, #2a5a9a);
|
|
}
|
|
|
|
.attach-btn:disabled, .dialogue-send-btn:disabled, .clear-btn:disabled {
|
|
opacity: 0.5;
|
|
cursor: not-allowed;
|
|
}
|
|
|
|
.dialogue-send-btn.stop {
|
|
background: #e74c3c;
|
|
box-shadow: 0 3px 0 #c0392b;
|
|
}
|
|
|
|
.dialogue-send-btn.stop:active {
|
|
box-shadow: 0 1px 0 #c0392b;
|
|
}
|
|
|
|
.dialogue-send-btn.skip {
|
|
background: #f39c12;
|
|
box-shadow: 0 3px 0 #d68910;
|
|
}
|
|
|
|
.dialogue-send-btn.skip:active {
|
|
box-shadow: 0 1px 0 #d68910;
|
|
}
|
|
|
|
#file-input {
|
|
display: none;
|
|
}
|
|
</style>
|
|
|
|
<div id="dialogue-box" class="minimized">
|
|
<div class="dialogue-content">
|
|
<div class="dialogue-header" id="dialogue-header">
|
|
<img alt="logo" src="/favicon.png" style="height: 32px; width: auto;">
|
|
<button class="expand-btn" id="expand-btn">
|
|
<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>
|
|
</button>
|
|
</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">
|
|
<button class="clear-btn" id="clear-btn">CLEAR</button>
|
|
<button class="attach-btn" id="attach-btn">ATTACH</button>
|
|
<button class="dialogue-send-btn" id="dialogue-send">SEND</button>
|
|
</div>
|
|
</div>
|
|
<input type="file" id="file-input" multiple accept="*/*">
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
initEventListeners() {
|
|
const dialogueBox = this.shadowRoot.getElementById('dialogue-box');
|
|
const dialogueHeader = this.shadowRoot.getElementById('dialogue-header');
|
|
const dialogueInput = this.shadowRoot.getElementById('dialogue-input');
|
|
const dialogueSend = this.shadowRoot.getElementById('dialogue-send');
|
|
const attachBtn = this.shadowRoot.getElementById('attach-btn');
|
|
const fileInput = this.shadowRoot.getElementById('file-input');
|
|
const clearBtn = this.shadowRoot.getElementById('clear-btn');
|
|
const expandBtn = this.shadowRoot.getElementById('expand-btn');
|
|
|
|
dialogueInput.addEventListener('input', () => {
|
|
dialogueInput.style.height = 'auto';
|
|
dialogueInput.style.height = Math.min(dialogueInput.scrollHeight, 120) + 'px';
|
|
});
|
|
|
|
dialogueInput.addEventListener('paste', (e) => {
|
|
const text = e.clipboardData.getData('text');
|
|
if (text.length > 1000) {
|
|
e.preventDefault();
|
|
const blob = new Blob([text], { type: 'text/plain' });
|
|
const file = new File([blob], 'pasted_text.txt', { type: 'text/plain' });
|
|
this.addFile(file);
|
|
}
|
|
});
|
|
|
|
dialogueHeader.addEventListener('click', (e) => {
|
|
if (e.target === dialogueHeader || e.target.classList.contains('speaker-name')) {
|
|
this.toggleDialogue();
|
|
}
|
|
});
|
|
|
|
dialogueInput.addEventListener('focus', () => {
|
|
if (!this.isDialogueOpen) this.openDialogue();
|
|
});
|
|
|
|
clearBtn.addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
this.clearChat();
|
|
});
|
|
|
|
expandBtn.addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
this.toggleExpand();
|
|
});
|
|
|
|
attachBtn.addEventListener('click', () => fileInput.click());
|
|
|
|
fileInput.addEventListener('change', (e) => {
|
|
Array.from(e.target.files).forEach(file => this.addFile(file));
|
|
fileInput.value = '';
|
|
});
|
|
|
|
dialogueSend.addEventListener('click', () => {
|
|
const buttonText = dialogueSend.textContent;
|
|
if (buttonText === 'SKIP') this.skipToEnd();
|
|
else if (buttonText === 'STOP') this.abortStream();
|
|
else this.sendMessage();
|
|
});
|
|
|
|
dialogueInput.addEventListener('keypress', (e) => {
|
|
if (e.key === 'Enter' && !e.shiftKey && !this.isReceiving) {
|
|
e.preventDefault();
|
|
this.sendMessage();
|
|
}
|
|
});
|
|
}
|
|
|
|
clearChat() {
|
|
this.messageHistory = [];
|
|
const messageBody = this.shadowRoot.getElementById('message-body');
|
|
messageBody.innerHTML = '<div class="empty-state">NetNavi v1.0.0</div>';
|
|
this.api.clearLLMHistory();
|
|
}
|
|
|
|
toggleExpand() {
|
|
this.isExpanded = !this.isExpanded;
|
|
const dialogueBox = this.shadowRoot.getElementById('dialogue-box');
|
|
const expandBtn = this.shadowRoot.getElementById('expand-btn');
|
|
|
|
dialogueBox.classList.toggle('expanded', this.isExpanded);
|
|
|
|
expandBtn.innerHTML = this.isExpanded ? `
|
|
<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);
|
|
});
|
|
}
|
|
|
|
processMessageForDisplay(text) {
|
|
return text.replace(/<file name="([^"]+)">[\s\S]*?<\/file>/g,
|
|
'<span class="file-badge">📄 $1</span>');
|
|
}
|
|
|
|
formatTime(date) {
|
|
const hours = date.getHours().toString().padStart(2, '0');
|
|
const minutes = date.getMinutes().toString().padStart(2, '0');
|
|
return `${hours}:${minutes}`;
|
|
}
|
|
|
|
toggleDialogue() {
|
|
this.isDialogueOpen = !this.isDialogueOpen;
|
|
const dialogueBox = this.shadowRoot.getElementById('dialogue-box');
|
|
dialogueBox.classList.toggle('minimized');
|
|
|
|
this.dispatchEvent(new CustomEvent('dialogue-toggle', {
|
|
detail: { isOpen: this.isDialogueOpen }
|
|
}));
|
|
}
|
|
|
|
openDialogue() {
|
|
this.isDialogueOpen = true;
|
|
const dialogueBox = this.shadowRoot.getElementById('dialogue-box');
|
|
dialogueBox.classList.remove('minimized');
|
|
|
|
this.dispatchEvent(new CustomEvent('dialogue-toggle', {
|
|
detail: { isOpen: this.isDialogueOpen }
|
|
}));
|
|
}
|
|
|
|
closeDialogue() {
|
|
this.isDialogueOpen = false;
|
|
const dialogueBox = this.shadowRoot.getElementById('dialogue-box');
|
|
dialogueBox.classList.add('minimized');
|
|
|
|
this.dispatchEvent(new CustomEvent('dialogue-toggle', {
|
|
detail: { isOpen: this.isDialogueOpen }
|
|
}));
|
|
}
|
|
|
|
playTextBeep() {
|
|
const oscillator = this.audioCtx.createOscillator();
|
|
const gainNode = this.audioCtx.createGain();
|
|
oscillator.connect(gainNode);
|
|
gainNode.connect(this.audioCtx.destination);
|
|
oscillator.type = 'square';
|
|
oscillator.frequency.setValueAtTime(1200, this.audioCtx.currentTime);
|
|
gainNode.gain.setValueAtTime(0.1, this.audioCtx.currentTime);
|
|
gainNode.gain.exponentialRampToValueAtTime(0.01, this.audioCtx.currentTime + 0.05);
|
|
oscillator.start(this.audioCtx.currentTime);
|
|
oscillator.stop(this.audioCtx.currentTime + 0.05);
|
|
}
|
|
|
|
shouldAutoScroll() {
|
|
const messageBody = this.shadowRoot.getElementById('message-body');
|
|
const scrollThreshold = 50;
|
|
const distanceFromBottom = messageBody.scrollHeight - messageBody.scrollTop - messageBody.clientHeight;
|
|
return distanceFromBottom <= scrollThreshold;
|
|
}
|
|
|
|
scrollToBottom() {
|
|
const messageBody = this.shadowRoot.getElementById('message-body');
|
|
messageBody.scrollTop = messageBody.scrollHeight;
|
|
}
|
|
|
|
addMessage(text, isUser) {
|
|
const messageBody = this.shadowRoot.getElementById('message-body');
|
|
const emptyState = messageBody.querySelector('.empty-state');
|
|
if (emptyState) messageBody.innerHTML = '';
|
|
|
|
// Extract file badges and clean text
|
|
const fileBadges = [];
|
|
const fileRegex = /<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' : 'assistant'}`;
|
|
const timestamp = this.formatTime(new Date());
|
|
|
|
const fileBadgesHtml = fileBadges.length > 0
|
|
? `<div class="message-files">${fileBadges.map(name =>
|
|
`<span class="file-badge">📄 ${name}</span>`).join('')}</div>`
|
|
: '';
|
|
|
|
messageWrapper.innerHTML = `
|
|
<div class="message-label">${isUser ? 'You' : 'PET'}</div>
|
|
${fileBadgesHtml}
|
|
<div class="message-bubble">${cleanText}</div>
|
|
<div class="message-timestamp">${timestamp}</div>
|
|
`;
|
|
|
|
messageBody.appendChild(messageWrapper);
|
|
this.messageHistory.push({ text, html: cleanText, isUser, element: messageWrapper, timestamp });
|
|
this.scrollToBottom();
|
|
}
|
|
|
|
startStreaming() {
|
|
this.isReceiving = true;
|
|
this.streamComplete = false;
|
|
this.streamBuffer = '';
|
|
this.typingIndex = 0;
|
|
|
|
const dialogueInput = this.shadowRoot.getElementById('dialogue-input');
|
|
const dialogueSend = this.shadowRoot.getElementById('dialogue-send');
|
|
const attachBtn = this.shadowRoot.getElementById('attach-btn');
|
|
const clearBtn = this.shadowRoot.getElementById('clear-btn');
|
|
const messageBody = this.shadowRoot.getElementById('message-body');
|
|
|
|
dialogueInput.disabled = true;
|
|
attachBtn.disabled = true;
|
|
clearBtn.disabled = true;
|
|
dialogueSend.textContent = 'STOP';
|
|
dialogueSend.classList.add('stop');
|
|
dialogueSend.classList.remove('skip');
|
|
|
|
const emptyState = messageBody.querySelector('.empty-state');
|
|
if (emptyState) messageBody.innerHTML = '';
|
|
|
|
const timestamp = this.formatTime(new Date());
|
|
const messageWrapper = document.createElement('div');
|
|
messageWrapper.className = 'message-wrapper assistant';
|
|
messageWrapper.innerHTML = `
|
|
<div class="message-label">PET</div>
|
|
<div class="message-bubble" id="streaming-bubble"></div>
|
|
<div class="message-timestamp">${timestamp}</div>
|
|
`;
|
|
|
|
messageBody.appendChild(messageWrapper);
|
|
|
|
this.currentStreamingMessage = { text: '', html: '', isUser: false, element: messageWrapper, timestamp };
|
|
this.messageHistory.push(this.currentStreamingMessage);
|
|
|
|
this.scrollToBottom();
|
|
|
|
this.typingInterval = setInterval(() => this.typeNextChar(), 30);
|
|
}
|
|
|
|
handleStreamChunk(chunk) {
|
|
if (!this.isReceiving) this.startStreaming();
|
|
|
|
if (chunk.text) this.streamBuffer += chunk.text;
|
|
if (chunk.tool && !this.hideTools.includes(chunk.tool)) this.streamBuffer += `<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.classList.remove('stop');
|
|
dialogueSend.classList.add('skip');
|
|
}
|
|
}
|
|
|
|
typeNextChar() {
|
|
if (this.typingIndex >= this.streamBuffer.length && this.streamComplete) {
|
|
this.cleanupStreaming();
|
|
return;
|
|
}
|
|
|
|
if (this.typingIndex >= this.streamBuffer.length) return;
|
|
|
|
const bubble = this.shadowRoot.getElementById('streaming-bubble');
|
|
if (!bubble) return;
|
|
|
|
const shouldScroll = this.shouldAutoScroll();
|
|
|
|
if (this.streamBuffer[this.typingIndex] === '<') {
|
|
const tagEnd = this.streamBuffer.indexOf('>', this.typingIndex);
|
|
if (tagEnd !== -1) {
|
|
const tag = this.streamBuffer.substring(this.typingIndex, tagEnd + 1);
|
|
this.currentStreamingMessage.html += tag;
|
|
this.currentStreamingMessage.text += tag;
|
|
this.typingIndex = tagEnd + 1;
|
|
bubble.innerHTML = this.currentStreamingMessage.html + '<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.classList.remove('stop', 'skip');
|
|
|
|
if (bubble) {
|
|
bubble.id = '';
|
|
bubble.innerHTML = this.currentStreamingMessage.html;
|
|
}
|
|
|
|
this.streamBuffer = '';
|
|
this.typingIndex = 0;
|
|
this.currentRequest = null;
|
|
this.currentStreamingMessage = null;
|
|
}
|
|
|
|
abortStream() {
|
|
if (this.currentRequest?.abort) {
|
|
this.currentRequest.abort();
|
|
}
|
|
|
|
clearInterval(this.typingInterval);
|
|
|
|
if (this.currentStreamingMessage) {
|
|
this.streamBuffer = this.currentStreamingMessage.text || '';
|
|
this.typingIndex = this.streamBuffer.length;
|
|
}
|
|
|
|
this.cleanupStreaming();
|
|
}
|
|
|
|
async sendMessage() {
|
|
const dialogueInput = this.shadowRoot.getElementById('dialogue-input');
|
|
let text = dialogueInput.value.trim();
|
|
if ((!text && this.attachedFiles.length === 0) || this.isReceiving) return;
|
|
|
|
if (this.attachedFiles.length > 0) {
|
|
const fileBlocks = await Promise.all(
|
|
this.attachedFiles.map(async (file) => {
|
|
const content = await this.fileToString(file);
|
|
return `<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.api.sendPetMessage(text, (chunk) => {
|
|
this.handleStreamChunk(chunk);
|
|
});
|
|
|
|
// Handle completion/errors with promise
|
|
try {
|
|
const response = await this.currentRequest;
|
|
this.handleStreamComplete(response);
|
|
} catch (error) {
|
|
if (error.message !== 'Aborted by user') {
|
|
console.error('❌ LLM Error:', error);
|
|
this.addMessage(`Error: ${error.message || 'Something went wrong'}`, false);
|
|
}
|
|
this.cleanupStreaming();
|
|
}
|
|
}
|
|
}
|
|
|
|
customElements.define('llm-component', LlmComponent);
|