Spaces:
Sleeping
Sleeping
Update frontend/script.js
Browse files- frontend/script.js +88 -199
frontend/script.js
CHANGED
|
@@ -1,29 +1,36 @@
|
|
| 1 |
/* ================================================================
|
| 2 |
J.A.R.V.I.S Frontend β Main Application Logic
|
| 3 |
================================================================
|
|
|
|
| 4 |
ARCHITECTURE OVERVIEW
|
| 5 |
---------------------
|
| 6 |
This file powers the entire frontend of the J.A.R.V.I.S AI assistant.
|
| 7 |
It handles:
|
|
|
|
| 8 |
1. CHAT MESSAGING β The user types (or speaks) a message, which is
|
| 9 |
sent to the backend via a POST request. The backend responds using
|
| 10 |
Server-Sent Events (SSE), allowing the reply to stream in
|
| 11 |
token-by-token (like ChatGPT's typing effect).
|
|
|
|
| 12 |
2. TEXT-TO-SPEECH (TTS) β When TTS is enabled, the backend also
|
| 13 |
sends base64-encoded audio chunks inside the SSE stream. These
|
| 14 |
are queued up and played sequentially through a single <audio>
|
| 15 |
element. This queue-based approach prevents overlapping audio
|
| 16 |
and supports mobile browsers (especially iOS/Safari).
|
|
|
|
| 17 |
3. SPEECH RECOGNITION β The Web Speech API captures the user's
|
| 18 |
voice, transcribes it in real time, and auto-sends the final
|
| 19 |
transcript as a chat message.
|
|
|
|
| 20 |
4. ANIMATED ORB β A WebGL-powered visual orb (rendered by a
|
| 21 |
separate OrbRenderer class) acts as a visual indicator. It
|
| 22 |
"activates" when J.A.R.V.I.S is speaking and goes idle otherwise.
|
|
|
|
| 23 |
5. MODE SWITCHING β The UI supports two modes:
|
| 24 |
- "General" mode β uses the /chat/stream endpoint
|
| 25 |
- "Realtime" mode β uses the /chat/realtime/stream endpoint
|
| 26 |
The mode determines which backend pipeline processes the message.
|
|
|
|
| 27 |
6. SESSION MANAGEMENT β A session ID is returned by the server on
|
| 28 |
the first message. Subsequent messages include that ID so the
|
| 29 |
backend can maintain conversation context. Starting a "New Chat"
|
|
@@ -33,6 +40,7 @@
|
|
| 33 |
User input β sendMessage() β POST to backend β SSE stream opens β
|
| 34 |
tokens arrive as JSON chunks β rendered into the DOM in real time β
|
| 35 |
optional audio chunks are enqueued in TTSPlayer β played sequentially.
|
|
|
|
| 36 |
================================================================ */
|
| 37 |
|
| 38 |
/*
|
|
@@ -134,7 +142,6 @@ const PRE_STARTER_FILES = ['starter_1', 'starter_2', 'starter_3', 'starter_4', '
|
|
| 134 |
let PRE_STARTER_CACHE = {};
|
| 135 |
let settings = { ...DEFAULT_SETTINGS };
|
| 136 |
|
| 137 |
-
|
| 138 |
/* ================================================================
|
| 139 |
DOM REFERENCES
|
| 140 |
================================================================
|
|
@@ -164,23 +171,19 @@ const btnRealtime = $('btn-realtime'); // The "Realtime" mode button
|
|
| 164 |
const statusDot = document.querySelector('.status-dot'); // Green/red dot showing backend status
|
| 165 |
const statusText = document.querySelector('.status-text'); // Text next to the dot ("Online" / "Offline")
|
| 166 |
const orbContainer = $('orb-container'); // The container <div> that holds the WebGL orb canvas
|
| 167 |
-
|
| 168 |
const searchResultsToggle = $('search-results-toggle'); // Header button to open search results panel
|
| 169 |
const searchResultsWidget = $('search-results-widget'); // Right-side panel for Tavily search data
|
| 170 |
const searchResultsClose = $('search-results-close'); // Close button inside the panel
|
| 171 |
const searchResultsQuery = $('search-results-query'); // Displays the search query
|
| 172 |
const searchResultsAnswer = $('search-results-answer'); // Displays the AI answer from search
|
| 173 |
const searchResultsList = $('search-results-list'); // Container for source result cards
|
| 174 |
-
|
| 175 |
const activityPanel = $('activity-panel'); // Left panel for Jarvis activity flow
|
| 176 |
const activityToggle = $('activity-toggle'); // Header button to open activity panel
|
| 177 |
const activityClose = $('activity-close'); // Close button inside activity panel
|
| 178 |
const activityList = $('activity-list'); // Container for activity items
|
| 179 |
const panelOverlay = $('panel-overlay'); // Backdrop when a side panel is open
|
| 180 |
-
|
| 181 |
const speechWidget = $('speech-widget'); // Live speech-to-text display
|
| 182 |
const speechWidgetText = $('speech-widget-text'); // Transcript text element
|
| 183 |
-
|
| 184 |
const settingsBtn = $('settings-btn'); // Gear icon to open settings
|
| 185 |
const settingsPanel = $('settings-panel'); // Settings modal/panel
|
| 186 |
const settingsClose = $('settings-close'); // Close settings
|
|
@@ -189,7 +192,6 @@ const toggleAutoSearch = $('toggle-auto-search'); // Auto-open search
|
|
| 189 |
const toggleThinkingSounds = $('toggle-thinking-sounds'); // Thinking sound effects
|
| 190 |
const toastContainer = $('toast-container'); // Toast container for error/status feedback
|
| 191 |
|
| 192 |
-
|
| 193 |
/* ================================================================
|
| 194 |
PRE-STARTER PLAYER (Dedicated β never interrupted by TTS reset)
|
| 195 |
================================================================
|
|
@@ -209,17 +211,14 @@ class PreStarterPlayer {
|
|
| 209 |
if (onComplete) onComplete();
|
| 210 |
return;
|
| 211 |
}
|
| 212 |
-
|
| 213 |
const file = loaded[Math.floor(Math.random() * loaded.length)];
|
| 214 |
const base64 = PRE_STARTER_CACHE[file];
|
| 215 |
if (!base64) {
|
| 216 |
if (onComplete) onComplete();
|
| 217 |
return;
|
| 218 |
}
|
| 219 |
-
|
| 220 |
this.audio.src = 'data:audio/mp3;base64,' + base64;
|
| 221 |
this.audio.currentTime = 0;
|
| 222 |
-
|
| 223 |
let fired = false;
|
| 224 |
const done = () => {
|
| 225 |
if (fired) return;
|
|
@@ -228,19 +227,19 @@ class PreStarterPlayer {
|
|
| 228 |
this.audio.onerror = null;
|
| 229 |
if (onComplete) onComplete();
|
| 230 |
};
|
| 231 |
-
|
| 232 |
this.audio.onended = done;
|
| 233 |
this.audio.onerror = done;
|
| 234 |
const p = this.audio.play();
|
| 235 |
if (p) p.catch(done);
|
| 236 |
}
|
| 237 |
}
|
| 238 |
-
let preStarterPlayer = null;
|
| 239 |
|
|
|
|
| 240 |
|
| 241 |
/* ================================================================
|
| 242 |
TTS AUDIO PLAYER (Text-to-Speech Queue System)
|
| 243 |
================================================================
|
|
|
|
| 244 |
HOW THE TTS QUEUE WORKS β EXPLAINED FOR LEARNERS
|
| 245 |
-------------------------------------------------
|
| 246 |
When TTS is enabled, the backend doesn't send one giant audio file.
|
|
@@ -265,18 +264,19 @@ let preStarterPlayer = null;
|
|
| 265 |
that was "unlocked" during a user gesture, all subsequent plays
|
| 266 |
through that same element are allowed. Creating new Audio() objects
|
| 267 |
each time would trigger autoplay blocks on iOS.
|
|
|
|
| 268 |
================================================================ */
|
| 269 |
class TTSPlayer {
|
| 270 |
/**
|
| 271 |
* Creates a new TTSPlayer instance.
|
| 272 |
*
|
| 273 |
* Properties:
|
| 274 |
-
*
|
| 275 |
-
*
|
| 276 |
-
*
|
| 277 |
-
*
|
| 278 |
-
*
|
| 279 |
-
*
|
| 280 |
*/
|
| 281 |
constructor() {
|
| 282 |
this.queue = [];
|
|
@@ -294,11 +294,11 @@ class TTSPlayer {
|
|
| 294 |
* This should be called during a user gesture (e.g., clicking "Send").
|
| 295 |
*
|
| 296 |
* It does two things:
|
| 297 |
-
*
|
| 298 |
-
*
|
| 299 |
-
*
|
| 300 |
-
*
|
| 301 |
-
*
|
| 302 |
*
|
| 303 |
* After this, the browser treats subsequent .play() calls on the same
|
| 304 |
* <audio> element as user-initiated, even if they happen in an async
|
|
@@ -310,7 +310,6 @@ class TTSPlayer {
|
|
| 310 |
this.audio.src = silentWav;
|
| 311 |
const p = this.audio.play();
|
| 312 |
if (p) p.catch(() => {});
|
| 313 |
-
|
| 314 |
try {
|
| 315 |
// Create a Web Audio context and play a zero-volume oscillator for <1ms
|
| 316 |
const ctx = new (window.AudioContext || window.webkitAudioContext)();
|
|
@@ -345,9 +344,9 @@ class TTSPlayer {
|
|
| 345 |
* stop() β Immediately halts all audio playback and clears the queue.
|
| 346 |
*
|
| 347 |
* Called when:
|
| 348 |
-
*
|
| 349 |
-
*
|
| 350 |
-
*
|
| 351 |
*
|
| 352 |
* It also removes visual indicators (CSS classes on the TTS button,
|
| 353 |
* the orb container, and deactivates the orb animation).
|
|
@@ -359,11 +358,9 @@ class TTSPlayer {
|
|
| 359 |
this.audio.load(); // Fully resets the audio element
|
| 360 |
this.queue = []; // Discard any pending audio chunks
|
| 361 |
this.playing = false;
|
| 362 |
-
|
| 363 |
if (ttsBtn) ttsBtn.classList.remove('tts-speaking');
|
| 364 |
if (orbContainer) orbContainer.classList.remove('speaking');
|
| 365 |
if (orb) orb.setActive(false);
|
| 366 |
-
|
| 367 |
if (typeof this.onPlaybackComplete === 'function') this.onPlaybackComplete(); // AI stopped β maybe restart mic
|
| 368 |
}
|
| 369 |
|
|
@@ -412,7 +409,6 @@ class TTSPlayer {
|
|
| 412 |
// Process queued audio chunks one at a time
|
| 413 |
while (this.queue.length > 0) {
|
| 414 |
if (this.stopped || myId !== this._loopId) break; // Exit if stopped or superseded
|
| 415 |
-
|
| 416 |
const b64 = this.queue.shift(); // Take the next chunk from the front
|
| 417 |
try {
|
| 418 |
await this._playB64(b64); // Wait for it to finish playing
|
|
@@ -426,14 +422,11 @@ class TTSPlayer {
|
|
| 426 |
this.playing = false; // Allow new loop to start
|
| 427 |
return;
|
| 428 |
}
|
| 429 |
-
|
| 430 |
this.playing = false;
|
| 431 |
-
|
| 432 |
// Deactivate visual indicators
|
| 433 |
if (ttsBtn) ttsBtn.classList.remove('tts-speaking');
|
| 434 |
if (orbContainer) orbContainer.classList.remove('speaking');
|
| 435 |
if (orb) orb.setActive(false);
|
| 436 |
-
|
| 437 |
// Notify when playback is fully complete (for auto-restart listening)
|
| 438 |
if (typeof this.onPlaybackComplete === 'function') this.onPlaybackComplete();
|
| 439 |
}
|
|
@@ -443,7 +436,7 @@ class TTSPlayer {
|
|
| 443 |
*
|
| 444 |
* @param {string} b64 - Base64-encoded MP3 audio data.
|
| 445 |
* @returns {Promise<void>} Resolves when the audio finishes playing
|
| 446 |
-
*
|
| 447 |
*
|
| 448 |
* Sets the <audio> element's src to a data URL and calls .play().
|
| 449 |
* Returns a Promise that resolves on 'ended' or 'error', so the
|
|
@@ -455,14 +448,12 @@ class TTSPlayer {
|
|
| 455 |
const done = () => { resolve(); };
|
| 456 |
this.audio.onended = done; // Normal completion
|
| 457 |
this.audio.onerror = done; // Error β resolve anyway so the loop continues
|
| 458 |
-
|
| 459 |
const p = this.audio.play();
|
| 460 |
if (p) p.catch(done); // Handle play() rejection (e.g., autoplay block)
|
| 461 |
});
|
| 462 |
}
|
| 463 |
}
|
| 464 |
|
| 465 |
-
|
| 466 |
/* ================================================================
|
| 467 |
INITIALIZATION
|
| 468 |
================================================================
|
|
@@ -484,18 +475,15 @@ function init() {
|
|
| 484 |
console.error('[JARVIS] Required DOM elements (chat-messages, message-input) not found.');
|
| 485 |
return;
|
| 486 |
}
|
| 487 |
-
|
| 488 |
loadSettings();
|
| 489 |
ttsPlayer = new TTSPlayer();
|
| 490 |
ttsPlayer.onPlaybackComplete = maybeRestartListening; // Auto-restart mic when TTS finishes
|
| 491 |
if (ttsBtn) ttsBtn.classList.add('tts-active'); // Show TTS as on by default
|
| 492 |
-
|
| 493 |
setGreeting();
|
| 494 |
initOrb();
|
| 495 |
initSpeech();
|
| 496 |
preloadStarterAudio(); // Pre-load MP3s for instant playback
|
| 497 |
preStarterPlayer = new PreStarterPlayer(); // Dedicated player for pre-starter (immune to ttsPlayer.reset)
|
| 498 |
-
|
| 499 |
checkHealth();
|
| 500 |
bindEvents();
|
| 501 |
setMode(currentMode); // Sync mode slider, labels, and activity toggle
|
|
@@ -543,7 +531,6 @@ function saveSettings() {
|
|
| 543 |
} catch (_) {}
|
| 544 |
}
|
| 545 |
|
| 546 |
-
|
| 547 |
/* ================================================================
|
| 548 |
GREETING
|
| 549 |
================================================================ */
|
|
@@ -553,10 +540,10 @@ function saveSettings() {
|
|
| 553 |
* time of day.
|
| 554 |
*
|
| 555 |
* Time ranges:
|
| 556 |
-
*
|
| 557 |
-
*
|
| 558 |
-
*
|
| 559 |
-
*
|
| 560 |
*
|
| 561 |
* This is called on page load and when starting a new chat.
|
| 562 |
*/
|
|
@@ -569,7 +556,6 @@ function setGreeting() {
|
|
| 569 |
if (welcomeTitle) welcomeTitle.textContent = g;
|
| 570 |
}
|
| 571 |
|
| 572 |
-
|
| 573 |
/* ================================================================
|
| 574 |
WEBGL ORB INITIALIZATION
|
| 575 |
================================================================ */
|
|
@@ -582,9 +568,9 @@ function setGreeting() {
|
|
| 582 |
* and we skip initialization gracefully.
|
| 583 |
*
|
| 584 |
* Configuration:
|
| 585 |
-
*
|
| 586 |
-
*
|
| 587 |
-
*
|
| 588 |
*
|
| 589 |
* The orb's "active" state (pulsing animation) is toggled via
|
| 590 |
* orb.setActive(true/false), which we call when TTS starts/stops.
|
|
@@ -600,10 +586,10 @@ function initOrb() {
|
|
| 600 |
} catch (e) { console.warn('Orb init failed:', e); }
|
| 601 |
}
|
| 602 |
|
| 603 |
-
|
| 604 |
/* ================================================================
|
| 605 |
SPEECH RECOGNITION (Speech-to-Text)
|
| 606 |
================================================================
|
|
|
|
| 607 |
SPEECH-TO-TEXT REDESIGN β PC-FIRST, ACCURATE, AUTO-RESTART
|
| 608 |
----------------------------------------------------------
|
| 609 |
Design goals:
|
|
@@ -639,6 +625,7 @@ function isSafariOrIOS() {
|
|
| 639 |
function initSpeech() {
|
| 640 |
const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
|
| 641 |
if (!SR) { micBtn.title = 'Speech not supported in this browser'; return; }
|
|
|
|
| 642 |
recognition = new SR();
|
| 643 |
|
| 644 |
/* PC: single utterance, interim results for real-time feedback. Avoids Chrome incremental bug. */
|
|
@@ -651,7 +638,6 @@ function initSpeech() {
|
|
| 651 |
|
| 652 |
recognition.onresult = e => {
|
| 653 |
if (!e.results || e.results.length === 0) return;
|
| 654 |
-
|
| 655 |
/* Chrome sends incremental results β each extends the previous. Use ONLY the last. */
|
| 656 |
const last = e.results[e.results.length - 1];
|
| 657 |
const transcript = (last && last[0]) ? last[0].transcript.trim() : '';
|
|
@@ -661,39 +647,16 @@ function initSpeech() {
|
|
| 661 |
if (speechWidget) speechWidget.classList.add('visible');
|
| 662 |
|
| 663 |
if (isFinal && transcript) {
|
| 664 |
-
|
| 665 |
-
|
| 666 |
-
|
| 667 |
-
|
| 668 |
-
|
| 669 |
-
|
| 670 |
-
let cleanCommand = lowerText.replace(/jarvis/g, "").trim();
|
| 671 |
-
|
| 672 |
-
// Agar user ne sirf "Jarvis" bola hai toh usko blank nahi bhejenge
|
| 673 |
-
if (cleanCommand === "") {
|
| 674 |
-
cleanCommand = "Are you there?";
|
| 675 |
}
|
| 676 |
-
|
| 677 |
-
console.log("β‘ Command Caught! Sending instantly: ", cleanCommand);
|
| 678 |
-
|
| 679 |
-
// KOI DELAY NAHI! Seedha mic band kar aur message bhej de
|
| 680 |
-
pendingSendTranscript = null;
|
| 681 |
-
clearTimeout(speechSendTimeout);
|
| 682 |
-
speechSendTimeout = null;
|
| 683 |
-
|
| 684 |
-
stopListening();
|
| 685 |
-
sendMessage(cleanCommand);
|
| 686 |
-
|
| 687 |
-
} else {
|
| 688 |
-
// Wake-word "jarvis" nahi mila, yeh kachra aawaz ya movie dialogue hai. IGNORE!
|
| 689 |
-
console.log("π¬ Ignored Movie Noise: ", transcript);
|
| 690 |
-
pendingSendTranscript = null;
|
| 691 |
-
clearTimeout(speechSendTimeout);
|
| 692 |
speechSendTimeout = null;
|
| 693 |
-
stopListening();
|
| 694 |
-
}
|
| 695 |
-
// --- π GATEKEEPER END ---
|
| 696 |
-
|
| 697 |
} else if (!isFinal) {
|
| 698 |
pendingSendTranscript = null;
|
| 699 |
clearTimeout(speechSendTimeout);
|
|
@@ -702,16 +665,15 @@ function initSpeech() {
|
|
| 702 |
};
|
| 703 |
|
| 704 |
recognition.onstart = () => { speechErrorRetryCount = 0; };
|
|
|
|
| 705 |
recognition.onerror = e => {
|
| 706 |
stopListening();
|
| 707 |
const msg = (e && e.error) ? String(e.error) : '';
|
| 708 |
const isPermissionDenied = /denied|not-allowed|permission/i.test(msg);
|
| 709 |
-
|
| 710 |
if (isPermissionDenied && micBtn) {
|
| 711 |
micBtn.title = 'Microphone access denied. Allow in browser settings.';
|
| 712 |
speechErrorRetryCount = SPEECH_ERROR_MAX_RETRIES;
|
| 713 |
}
|
| 714 |
-
|
| 715 |
if (autoListenMode && !isStreaming && speechErrorRetryCount < SPEECH_ERROR_MAX_RETRIES) {
|
| 716 |
speechErrorRetryCount++;
|
| 717 |
setTimeout(() => maybeRestartListening(), SPEECH_RESTART_DELAY_MS);
|
|
@@ -739,27 +701,23 @@ function initSpeech() {
|
|
| 739 |
* startListening() β Activates the microphone and begins speech recognition.
|
| 740 |
*
|
| 741 |
* Guards:
|
| 742 |
-
*
|
| 743 |
-
*
|
| 744 |
-
*
|
| 745 |
*/
|
| 746 |
function startListening() {
|
| 747 |
if (!recognition || isStreaming || isListening) return;
|
| 748 |
-
|
| 749 |
if (isSafariOrIOS() && !safariVoiceHintShown) {
|
| 750 |
showToast('Voice works best in Chrome. Safari has limited support.');
|
| 751 |
safariVoiceHintShown = true;
|
| 752 |
}
|
| 753 |
-
|
| 754 |
isListening = true;
|
| 755 |
pendingSendTranscript = null;
|
| 756 |
clearTimeout(speechSendTimeout);
|
| 757 |
speechSendTimeout = null;
|
| 758 |
-
|
| 759 |
if (micBtn) micBtn.classList.add('listening');
|
| 760 |
if (speechWidget) speechWidget.classList.add('visible');
|
| 761 |
if (speechWidgetText) speechWidgetText.textContent = '';
|
| 762 |
-
|
| 763 |
try {
|
| 764 |
recognition.start();
|
| 765 |
} catch (err) {
|
|
@@ -774,10 +732,10 @@ function startListening() {
|
|
| 774 |
* stopListening() β Deactivates the microphone and stops recognition.
|
| 775 |
*
|
| 776 |
* Called when:
|
| 777 |
-
*
|
| 778 |
-
*
|
| 779 |
-
*
|
| 780 |
-
*
|
| 781 |
*/
|
| 782 |
function stopListening() {
|
| 783 |
clearTimeout(speechSendTimeout);
|
|
@@ -799,7 +757,6 @@ function maybeRestartListening() {
|
|
| 799 |
if (!autoListenMode || !recognition) return;
|
| 800 |
if (isStreaming) return;
|
| 801 |
if (ttsPlayer && (ttsPlayer.playing || ttsPlayer.queue.length > 0)) return;
|
| 802 |
-
|
| 803 |
setTimeout(() => {
|
| 804 |
if (autoListenMode && !isStreaming && !isListening && recognition) {
|
| 805 |
startListening();
|
|
@@ -807,7 +764,6 @@ function maybeRestartListening() {
|
|
| 807 |
}, SPEECH_RESTART_DELAY_MS);
|
| 808 |
}
|
| 809 |
|
| 810 |
-
|
| 811 |
/* ================================================================
|
| 812 |
BACKEND HEALTH CHECK
|
| 813 |
================================================================ */
|
|
@@ -817,8 +773,8 @@ function maybeRestartListening() {
|
|
| 817 |
* if the server is running and healthy.
|
| 818 |
*
|
| 819 |
* Updates the status indicator in the UI:
|
| 820 |
-
*
|
| 821 |
-
*
|
| 822 |
*
|
| 823 |
* Uses AbortSignal.timeout(5000) to avoid waiting forever if the
|
| 824 |
* server is down β the request will automatically abort after 5 seconds.
|
|
@@ -829,10 +785,8 @@ async function checkHealth() {
|
|
| 829 |
const timeoutId = setTimeout(() => controller.abort(), 5000);
|
| 830 |
const r = await fetch(`${API}/health`, { signal: controller.signal });
|
| 831 |
clearTimeout(timeoutId);
|
| 832 |
-
|
| 833 |
const d = await r.json().catch(() => null);
|
| 834 |
const ok = d && (d.status === 'healthy' || d.status === 'degraded');
|
| 835 |
-
|
| 836 |
if (statusDot) statusDot.classList.toggle('offline', !ok);
|
| 837 |
if (statusText) statusText.textContent = ok ? 'Online' : 'Offline';
|
| 838 |
} catch (e) {
|
|
@@ -852,19 +806,15 @@ function showToast(msg, durationMs = 5000) {
|
|
| 852 |
el.className = 'toast';
|
| 853 |
el.textContent = msg;
|
| 854 |
toastContainer.appendChild(el);
|
| 855 |
-
|
| 856 |
el.offsetHeight; // Force reflow for animation
|
| 857 |
el.classList.add('toast-visible');
|
| 858 |
-
|
| 859 |
const t = setTimeout(() => {
|
| 860 |
el.classList.remove('toast-visible');
|
| 861 |
setTimeout(() => el.remove(), 300);
|
| 862 |
}, durationMs);
|
| 863 |
-
|
| 864 |
el.addEventListener('click', () => { clearTimeout(t); el.classList.remove('toast-visible'); setTimeout(() => el.remove(), 300); });
|
| 865 |
}
|
| 866 |
|
| 867 |
-
|
| 868 |
/* ================================================================
|
| 869 |
EVENT BINDING
|
| 870 |
================================================================
|
|
@@ -939,7 +889,6 @@ function bindEvents() {
|
|
| 939 |
if (searchResultsClose && searchResultsWidget) {
|
| 940 |
searchResultsClose.addEventListener('click', () => { searchResultsWidget.classList.remove('open'); updatePanelOverlay(); });
|
| 941 |
}
|
| 942 |
-
|
| 943 |
// ACTIVITY PANEL β Toggle open/close from header button; close from panel button
|
| 944 |
if (activityToggle) {
|
| 945 |
activityToggle.addEventListener('click', () => {
|
|
@@ -949,9 +898,7 @@ function bindEvents() {
|
|
| 949 |
if (activityClose && activityPanel) {
|
| 950 |
activityClose.addEventListener('click', () => { activityPanel.classList.remove('open'); updatePanelOverlay(); });
|
| 951 |
}
|
| 952 |
-
|
| 953 |
// Panels close ONLY via their X button β overlay does not close on click
|
| 954 |
-
|
| 955 |
// SETTINGS
|
| 956 |
if (settingsBtn && settingsPanel) {
|
| 957 |
settingsBtn.addEventListener('click', () => {
|
|
@@ -965,7 +912,6 @@ function bindEvents() {
|
|
| 965 |
updatePanelOverlay();
|
| 966 |
});
|
| 967 |
}
|
| 968 |
-
|
| 969 |
if (toggleAutoActivity) {
|
| 970 |
toggleAutoActivity.addEventListener('change', () => {
|
| 971 |
settings.autoOpenActivity = toggleAutoActivity.checked;
|
|
@@ -991,17 +937,16 @@ function bindEvents() {
|
|
| 991 |
* its content, up to a maximum of 120px.
|
| 992 |
*
|
| 993 |
* How it works:
|
| 994 |
-
*
|
| 995 |
-
*
|
| 996 |
-
*
|
| 997 |
-
*
|
| 998 |
*/
|
| 999 |
function autoResizeInput() {
|
| 1000 |
messageInput.style.height = 'auto';
|
| 1001 |
messageInput.style.height = Math.min(messageInput.scrollHeight, 120) + 'px';
|
| 1002 |
}
|
| 1003 |
|
| 1004 |
-
|
| 1005 |
/* ================================================================
|
| 1006 |
MODE SWITCH (General β Realtime)
|
| 1007 |
================================================================
|
|
@@ -1031,28 +976,25 @@ function updatePanelOverlay() {
|
|
| 1031 |
* @param {string} mode - Either 'general' or 'realtime'.
|
| 1032 |
*
|
| 1033 |
* Updates:
|
| 1034 |
-
*
|
| 1035 |
-
*
|
| 1036 |
-
*
|
| 1037 |
*/
|
| 1038 |
function setMode(mode) {
|
| 1039 |
currentMode = mode;
|
| 1040 |
if (btnJarvis) btnJarvis.classList.toggle('active', mode === 'jarvis');
|
| 1041 |
if (btnGeneral) btnGeneral.classList.toggle('active', mode === 'general');
|
| 1042 |
if (btnRealtime) btnRealtime.classList.toggle('active', mode === 'realtime');
|
| 1043 |
-
|
| 1044 |
if (modeSlider) {
|
| 1045 |
modeSlider.classList.remove('center', 'right');
|
| 1046 |
if (mode === 'general') modeSlider.classList.add('center');
|
| 1047 |
else if (mode === 'realtime') modeSlider.classList.add('right');
|
| 1048 |
/* jarvis: no class = left position */
|
| 1049 |
}
|
| 1050 |
-
|
| 1051 |
// Activity toggle always visible β panel shows flow in all modes
|
| 1052 |
if (activityToggle) activityToggle.style.display = '';
|
| 1053 |
}
|
| 1054 |
|
| 1055 |
-
|
| 1056 |
/* ================================================================
|
| 1057 |
NEW CHAT
|
| 1058 |
================================================================ */
|
|
@@ -1061,12 +1003,12 @@ function setMode(mode) {
|
|
| 1061 |
* newChat() β Resets the entire conversation to a fresh state.
|
| 1062 |
*
|
| 1063 |
* Steps:
|
| 1064 |
-
*
|
| 1065 |
-
*
|
| 1066 |
-
*
|
| 1067 |
-
*
|
| 1068 |
-
*
|
| 1069 |
-
*
|
| 1070 |
*/
|
| 1071 |
function newChat() {
|
| 1072 |
if (ttsPlayer) ttsPlayer.stop();
|
|
@@ -1076,13 +1018,11 @@ function newChat() {
|
|
| 1076 |
messageInput.value = '';
|
| 1077 |
autoResizeInput();
|
| 1078 |
setGreeting();
|
| 1079 |
-
|
| 1080 |
if (searchResultsWidget) searchResultsWidget.classList.remove('open');
|
| 1081 |
if (searchResultsToggle) searchResultsToggle.style.display = 'none';
|
| 1082 |
if (activityPanel) activityPanel.classList.remove('open');
|
| 1083 |
if (settingsPanel) settingsPanel.classList.remove('open');
|
| 1084 |
if (activityToggle) activityToggle.style.display = 'none';
|
| 1085 |
-
|
| 1086 |
if (activityList) {
|
| 1087 |
activityList.innerHTML = '<div class="activity-empty" id="activity-empty">Send a message to see the flow here.</div>';
|
| 1088 |
}
|
|
@@ -1093,13 +1033,13 @@ function newChat() {
|
|
| 1093 |
* createWelcome() β Builds and returns the welcome screen DOM element.
|
| 1094 |
*
|
| 1095 |
* @returns {HTMLDivElement} The welcome screen element, ready to be
|
| 1096 |
-
*
|
| 1097 |
*
|
| 1098 |
* The welcome screen includes:
|
| 1099 |
-
*
|
| 1100 |
-
*
|
| 1101 |
-
*
|
| 1102 |
-
*
|
| 1103 |
*
|
| 1104 |
* The chip buttons get their own click listeners here because they
|
| 1105 |
* are dynamically created (not present in the original HTML).
|
|
@@ -1131,11 +1071,9 @@ function createWelcome() {
|
|
| 1131 |
div.querySelectorAll('.chip').forEach(c => {
|
| 1132 |
c.addEventListener('click', () => { if (!isStreaming) sendMessage(c.dataset.msg); });
|
| 1133 |
});
|
| 1134 |
-
|
| 1135 |
return div;
|
| 1136 |
}
|
| 1137 |
|
| 1138 |
-
|
| 1139 |
/* ================================================================
|
| 1140 |
MESSAGE RENDERING
|
| 1141 |
================================================================
|
|
@@ -1190,33 +1128,26 @@ function renderSearchResults(payload) {
|
|
| 1190 |
if (!payload) return;
|
| 1191 |
if (searchResultsQuery) searchResultsQuery.textContent = (payload.query || '').trim() || 'Search';
|
| 1192 |
if (searchResultsAnswer) searchResultsAnswer.textContent = (payload.answer || '').trim() || '';
|
| 1193 |
-
|
| 1194 |
if (!searchResultsList) return;
|
| 1195 |
searchResultsList.innerHTML = '';
|
| 1196 |
const results = payload.results || [];
|
| 1197 |
const maxContentLen = 220;
|
| 1198 |
-
|
| 1199 |
for (const r of results) {
|
| 1200 |
let title = (r.title || '').trim();
|
| 1201 |
let content = (r.content || '').trim();
|
| 1202 |
const url = (r.url || '').trim();
|
| 1203 |
-
|
| 1204 |
if (isUrlLike(title)) title = friendlyUrlLabel(url) || 'Source';
|
| 1205 |
if (!title) title = friendlyUrlLabel(url) || 'Source';
|
| 1206 |
if (isUrlLike(content)) content = '';
|
| 1207 |
content = truncateSnippet(content, maxContentLen);
|
| 1208 |
-
|
| 1209 |
const score = r.score != null ? Math.round((r.score || 0) * 100) : null;
|
| 1210 |
-
|
| 1211 |
const card = document.createElement('div');
|
| 1212 |
card.className = 'search-result-card';
|
| 1213 |
-
|
| 1214 |
const urlDisplay = url ? escapeHtml(friendlyUrlLabel(url)) : '';
|
| 1215 |
const hrefSafe = safeUrlForHref(url);
|
| 1216 |
const urlMarkup = urlDisplay
|
| 1217 |
? (hrefSafe ? `<a href="${hrefSafe}" target="_blank" rel="noopener" class="card-url" title="${escapeAttr(url)}">${urlDisplay}</a>` : `<span class="card-url">${urlDisplay}</span>`)
|
| 1218 |
: '';
|
| 1219 |
-
|
| 1220 |
card.innerHTML = `
|
| 1221 |
<div class="card-title">${escapeHtml(title)}</div>
|
| 1222 |
${content ? `<div class="card-content">${escapeHtml(content)}</div>` : ''}
|
|
@@ -1269,14 +1200,11 @@ const ACTIVITY_STEPS = {
|
|
| 1269 |
*/
|
| 1270 |
function appendActivity(activity) {
|
| 1271 |
if (!activityList || !activity) return;
|
| 1272 |
-
|
| 1273 |
const item = document.createElement('div');
|
| 1274 |
item.className = 'activity-item';
|
| 1275 |
item.setAttribute('data-event', activity.event || '');
|
| 1276 |
-
|
| 1277 |
const stepInfo = ACTIVITY_STEPS[activity.event] || { step: 0, label: activity.event || 'Activity', icon: 'dot' };
|
| 1278 |
let detail = '';
|
| 1279 |
-
|
| 1280 |
if (activity.event === 'query_detected') {
|
| 1281 |
detail = activity.message || '';
|
| 1282 |
} else if (activity.event === 'decision') {
|
|
@@ -1313,15 +1241,12 @@ function appendActivity(activity) {
|
|
| 1313 |
} else {
|
| 1314 |
detail = activity.message || (typeof activity === 'object' ? JSON.stringify(activity) : String(activity));
|
| 1315 |
}
|
| 1316 |
-
|
| 1317 |
const stepNum = stepInfo.step ? `<span class="activity-step">${stepInfo.step}</span>` : '';
|
| 1318 |
item.innerHTML = `
|
| 1319 |
<div class="activity-event">${stepNum}${escapeHtml(stepInfo.label)}</div>
|
| 1320 |
<div class="activity-detail">${escapeHtml(detail || '')}</div>`;
|
| 1321 |
-
|
| 1322 |
const emptyEl = activityList.querySelector('.activity-empty');
|
| 1323 |
if (emptyEl) emptyEl.style.display = 'none';
|
| 1324 |
-
|
| 1325 |
activityList.appendChild(item);
|
| 1326 |
activityList.scrollTop = activityList.scrollHeight;
|
| 1327 |
}
|
|
@@ -1351,29 +1276,27 @@ function hideWelcome() {
|
|
| 1351 |
* addMessage(role, text) β Creates and appends a chat message bubble.
|
| 1352 |
*
|
| 1353 |
* @param {string} role - Either 'user' or 'assistant'. Determines
|
| 1354 |
-
*
|
| 1355 |
* @param {string} text - The message content to display.
|
| 1356 |
* @returns {HTMLDivElement} The inner content element β returned so
|
| 1357 |
-
*
|
| 1358 |
-
*
|
| 1359 |
*
|
| 1360 |
* DOM structure created:
|
| 1361 |
-
*
|
| 1362 |
-
*
|
| 1363 |
-
*
|
| 1364 |
-
*
|
| 1365 |
-
*
|
| 1366 |
-
*
|
| 1367 |
-
*
|
| 1368 |
*/
|
| 1369 |
-
|
| 1370 |
/* Inline SVG icons for chat avatars (user = person, assistant = bot). */
|
| 1371 |
const AVATAR_ICON_USER = '<svg class="msg-avatar-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>';
|
| 1372 |
const AVATAR_ICON_ASSISTANT = '<svg class="msg-avatar-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="10" rx="2"/><circle cx="12" cy="5" r="2"/><path d="M12 7v4"/><circle cx="9" cy="16" r="1" fill="currentColor"/><circle cx="15" cy="16" r="1" fill="currentColor"/></svg>';
|
| 1373 |
|
| 1374 |
function addMessage(role, text) {
|
| 1375 |
hideWelcome();
|
| 1376 |
-
|
| 1377 |
const msg = document.createElement('div');
|
| 1378 |
msg.className = `message ${role}`;
|
| 1379 |
|
|
@@ -1398,10 +1321,8 @@ function addMessage(role, text) {
|
|
| 1398 |
body.appendChild(content);
|
| 1399 |
msg.appendChild(avatar);
|
| 1400 |
msg.appendChild(body);
|
| 1401 |
-
|
| 1402 |
chatMessages.appendChild(msg);
|
| 1403 |
scrollToBottom();
|
| 1404 |
-
|
| 1405 |
return content; // Returned so the streaming logic can update it in real time
|
| 1406 |
}
|
| 1407 |
|
|
@@ -1418,7 +1339,6 @@ function addMessage(role, text) {
|
|
| 1418 |
*/
|
| 1419 |
function addTypingIndicator() {
|
| 1420 |
hideWelcome();
|
| 1421 |
-
|
| 1422 |
const msg = document.createElement('div');
|
| 1423 |
msg.className = 'message assistant';
|
| 1424 |
msg.id = 'typing-msg'; // ID so we can find and remove it later
|
|
@@ -1442,10 +1362,8 @@ function addTypingIndicator() {
|
|
| 1442 |
body.appendChild(content);
|
| 1443 |
msg.appendChild(avatar);
|
| 1444 |
msg.appendChild(body);
|
| 1445 |
-
|
| 1446 |
chatMessages.appendChild(msg);
|
| 1447 |
scrollToBottom();
|
| 1448 |
-
|
| 1449 |
return content;
|
| 1450 |
}
|
| 1451 |
|
|
@@ -1453,8 +1371,8 @@ function addTypingIndicator() {
|
|
| 1453 |
* removeTypingIndicator() β Removes the typing indicator from the DOM.
|
| 1454 |
*
|
| 1455 |
* Called when:
|
| 1456 |
-
*
|
| 1457 |
-
*
|
| 1458 |
*/
|
| 1459 |
function removeTypingIndicator() {
|
| 1460 |
const t = document.getElementById('typing-msg');
|
|
@@ -1475,10 +1393,10 @@ function scrollToBottom() {
|
|
| 1475 |
});
|
| 1476 |
}
|
| 1477 |
|
| 1478 |
-
|
| 1479 |
/* ================================================================
|
| 1480 |
SEND MESSAGE + SSE STREAMING
|
| 1481 |
================================================================
|
|
|
|
| 1482 |
HOW SSE (Server-Sent Events) STREAMING WORKS β EXPLAINED FOR LEARNERS
|
| 1483 |
----------------------------------------------------------------------
|
| 1484 |
Instead of waiting for the entire AI response to generate (which
|
|
@@ -1520,14 +1438,15 @@ function scrollToBottom() {
|
|
| 1520 |
accumulates raw text. We split by '\n', process all complete lines,
|
| 1521 |
and keep the last (potentially incomplete) line in the buffer for
|
| 1522 |
the next iteration.
|
|
|
|
| 1523 |
================================================================ */
|
| 1524 |
|
| 1525 |
/**
|
| 1526 |
* sendMessage(textOverride) β Sends a user message and streams the AI response.
|
| 1527 |
*
|
| 1528 |
* AUDIO WORKFLOW (minimizes waiting):
|
| 1529 |
-
*
|
| 1530 |
-
*
|
| 1531 |
*/
|
| 1532 |
async function sendMessage(textOverride) {
|
| 1533 |
// Step 1: Get the message text, trimming whitespace
|
|
@@ -1576,7 +1495,6 @@ async function sendMessage(textOverride) {
|
|
| 1576 |
if (ttsPlayer?.enabled && settings.thinkingSounds && preStarterPlayer) {
|
| 1577 |
preStarterPlayer.play(() => {});
|
| 1578 |
}
|
| 1579 |
-
|
| 1580 |
// 2. Main: stream from chatbot (general or realtime)
|
| 1581 |
timeoutId = setTimeout(() => controller.abort(), 300000);
|
| 1582 |
const res = await fetch(`${API}${endpoint}`, {
|
|
@@ -1629,7 +1547,6 @@ async function sendMessage(textOverride) {
|
|
| 1629 |
for (const line of lines) {
|
| 1630 |
// SSE lines that don't start with "data: " are empty lines or comments β skip them
|
| 1631 |
if (!line.startsWith('data: ')) continue;
|
| 1632 |
-
|
| 1633 |
try {
|
| 1634 |
// Parse the JSON payload (everything after "data: ")
|
| 1635 |
const data = JSON.parse(line.slice(6));
|
|
@@ -1654,41 +1571,16 @@ async function sendMessage(textOverride) {
|
|
| 1654 |
// TEXT CHUNK β Append to the displayed response (chunk can be "" in some streams)
|
| 1655 |
if ('chunk' in data) {
|
| 1656 |
const chunkText = data.chunk || '';
|
| 1657 |
-
|
| 1658 |
// Only treat as "main started" when we get actual content β the initial event
|
| 1659 |
// has chunk: "" for session_id; that would wrongly reset
|
| 1660 |
if (chunkText && !firstChunkReceived) {
|
| 1661 |
firstChunkReceived = true;
|
| 1662 |
if (ttsPlayer) ttsPlayer.reset(); // Stop pre-starter, play main immediately
|
| 1663 |
}
|
| 1664 |
-
|
| 1665 |
fullResponse += chunkText;
|
| 1666 |
-
|
| 1667 |
const textSpan = contentEl.querySelector('.msg-stream-text');
|
| 1668 |
if (textSpan) {
|
| 1669 |
-
|
| 1670 |
-
let displayResponse = fullResponse;
|
| 1671 |
-
|
| 1672 |
-
// 1. Chat mein code dikhne se roko
|
| 1673 |
-
if (displayResponse.includes('[EXECUTE]')) {
|
| 1674 |
-
displayResponse = displayResponse.split('[EXECUTE]')[0].trim();
|
| 1675 |
-
}
|
| 1676 |
-
|
| 1677 |
-
// 2. PC ko command bhejo aur fullResponse saaf karo
|
| 1678 |
-
const match = fullResponse.match(/\[EXECUTE\]([\s\S]*?)\[\/EXECUTE\]/i);
|
| 1679 |
-
if (match) {
|
| 1680 |
-
let pcCommand = match[1].replace(/```python/gi, '').replace(/```/g, '').trim();
|
| 1681 |
-
fullResponse = fullResponse.replace(/\[EXECUTE\][\s\S]*?\[\/EXECUTE\]/i, '').trim();
|
| 1682 |
-
fetch('/api/pc/send-command', {
|
| 1683 |
-
method: 'POST',
|
| 1684 |
-
headers: { 'Content-Type': 'application/json' },
|
| 1685 |
-
body: JSON.stringify({ password: "aadit1812", command: pcCommand })
|
| 1686 |
-
});
|
| 1687 |
-
}
|
| 1688 |
-
|
| 1689 |
-
// 3. Screen par final clean text dikhao
|
| 1690 |
-
textSpan.textContent = displayResponse;
|
| 1691 |
-
// --- END SAFE MAGIC ---
|
| 1692 |
textSpan.classList.remove('stream-placeholder');
|
| 1693 |
}
|
| 1694 |
|
|
@@ -1712,7 +1604,6 @@ async function sendMessage(textOverride) {
|
|
| 1712 |
|
| 1713 |
// DONE β The server signals that the response is complete
|
| 1714 |
if (data.done) { streamDone = true; break; }
|
| 1715 |
-
|
| 1716 |
} catch (parseErr) {
|
| 1717 |
// Ignore JSON parse errors (e.g., partial lines) but re-throw real errors
|
| 1718 |
if (parseErr.message && !parseErr.message.includes('JSON'))
|
|
@@ -1741,7 +1632,6 @@ async function sendMessage(textOverride) {
|
|
| 1741 |
if (sendBtn) sendBtn.disabled = false;
|
| 1742 |
if (messageInput) messageInput.disabled = false;
|
| 1743 |
if (orbContainer) orbContainer.classList.remove('active');
|
| 1744 |
-
|
| 1745 |
maybeRestartListening(); // Auto-restart mic when stream ends (TTS may still be playing)
|
| 1746 |
}
|
| 1747 |
}
|
|
@@ -1755,7 +1645,6 @@ async function sendMessage(textOverride) {
|
|
| 1755 |
================================================================ */
|
| 1756 |
document.addEventListener('DOMContentLoaded', init);
|
| 1757 |
|
| 1758 |
-
|
| 1759 |
// --- JARVIS SECURE LOGIN SYSTEM (BACKEND AUTH) ---
|
| 1760 |
async function checkPassword() {
|
| 1761 |
const input = document.getElementById('pass-input').value;
|
|
|
|
| 1 |
/* ================================================================
|
| 2 |
J.A.R.V.I.S Frontend β Main Application Logic
|
| 3 |
================================================================
|
| 4 |
+
|
| 5 |
ARCHITECTURE OVERVIEW
|
| 6 |
---------------------
|
| 7 |
This file powers the entire frontend of the J.A.R.V.I.S AI assistant.
|
| 8 |
It handles:
|
| 9 |
+
|
| 10 |
1. CHAT MESSAGING β The user types (or speaks) a message, which is
|
| 11 |
sent to the backend via a POST request. The backend responds using
|
| 12 |
Server-Sent Events (SSE), allowing the reply to stream in
|
| 13 |
token-by-token (like ChatGPT's typing effect).
|
| 14 |
+
|
| 15 |
2. TEXT-TO-SPEECH (TTS) β When TTS is enabled, the backend also
|
| 16 |
sends base64-encoded audio chunks inside the SSE stream. These
|
| 17 |
are queued up and played sequentially through a single <audio>
|
| 18 |
element. This queue-based approach prevents overlapping audio
|
| 19 |
and supports mobile browsers (especially iOS/Safari).
|
| 20 |
+
|
| 21 |
3. SPEECH RECOGNITION β The Web Speech API captures the user's
|
| 22 |
voice, transcribes it in real time, and auto-sends the final
|
| 23 |
transcript as a chat message.
|
| 24 |
+
|
| 25 |
4. ANIMATED ORB β A WebGL-powered visual orb (rendered by a
|
| 26 |
separate OrbRenderer class) acts as a visual indicator. It
|
| 27 |
"activates" when J.A.R.V.I.S is speaking and goes idle otherwise.
|
| 28 |
+
|
| 29 |
5. MODE SWITCHING β The UI supports two modes:
|
| 30 |
- "General" mode β uses the /chat/stream endpoint
|
| 31 |
- "Realtime" mode β uses the /chat/realtime/stream endpoint
|
| 32 |
The mode determines which backend pipeline processes the message.
|
| 33 |
+
|
| 34 |
6. SESSION MANAGEMENT β A session ID is returned by the server on
|
| 35 |
the first message. Subsequent messages include that ID so the
|
| 36 |
backend can maintain conversation context. Starting a "New Chat"
|
|
|
|
| 40 |
User input β sendMessage() β POST to backend β SSE stream opens β
|
| 41 |
tokens arrive as JSON chunks β rendered into the DOM in real time β
|
| 42 |
optional audio chunks are enqueued in TTSPlayer β played sequentially.
|
| 43 |
+
|
| 44 |
================================================================ */
|
| 45 |
|
| 46 |
/*
|
|
|
|
| 142 |
let PRE_STARTER_CACHE = {};
|
| 143 |
let settings = { ...DEFAULT_SETTINGS };
|
| 144 |
|
|
|
|
| 145 |
/* ================================================================
|
| 146 |
DOM REFERENCES
|
| 147 |
================================================================
|
|
|
|
| 171 |
const statusDot = document.querySelector('.status-dot'); // Green/red dot showing backend status
|
| 172 |
const statusText = document.querySelector('.status-text'); // Text next to the dot ("Online" / "Offline")
|
| 173 |
const orbContainer = $('orb-container'); // The container <div> that holds the WebGL orb canvas
|
|
|
|
| 174 |
const searchResultsToggle = $('search-results-toggle'); // Header button to open search results panel
|
| 175 |
const searchResultsWidget = $('search-results-widget'); // Right-side panel for Tavily search data
|
| 176 |
const searchResultsClose = $('search-results-close'); // Close button inside the panel
|
| 177 |
const searchResultsQuery = $('search-results-query'); // Displays the search query
|
| 178 |
const searchResultsAnswer = $('search-results-answer'); // Displays the AI answer from search
|
| 179 |
const searchResultsList = $('search-results-list'); // Container for source result cards
|
|
|
|
| 180 |
const activityPanel = $('activity-panel'); // Left panel for Jarvis activity flow
|
| 181 |
const activityToggle = $('activity-toggle'); // Header button to open activity panel
|
| 182 |
const activityClose = $('activity-close'); // Close button inside activity panel
|
| 183 |
const activityList = $('activity-list'); // Container for activity items
|
| 184 |
const panelOverlay = $('panel-overlay'); // Backdrop when a side panel is open
|
|
|
|
| 185 |
const speechWidget = $('speech-widget'); // Live speech-to-text display
|
| 186 |
const speechWidgetText = $('speech-widget-text'); // Transcript text element
|
|
|
|
| 187 |
const settingsBtn = $('settings-btn'); // Gear icon to open settings
|
| 188 |
const settingsPanel = $('settings-panel'); // Settings modal/panel
|
| 189 |
const settingsClose = $('settings-close'); // Close settings
|
|
|
|
| 192 |
const toggleThinkingSounds = $('toggle-thinking-sounds'); // Thinking sound effects
|
| 193 |
const toastContainer = $('toast-container'); // Toast container for error/status feedback
|
| 194 |
|
|
|
|
| 195 |
/* ================================================================
|
| 196 |
PRE-STARTER PLAYER (Dedicated β never interrupted by TTS reset)
|
| 197 |
================================================================
|
|
|
|
| 211 |
if (onComplete) onComplete();
|
| 212 |
return;
|
| 213 |
}
|
|
|
|
| 214 |
const file = loaded[Math.floor(Math.random() * loaded.length)];
|
| 215 |
const base64 = PRE_STARTER_CACHE[file];
|
| 216 |
if (!base64) {
|
| 217 |
if (onComplete) onComplete();
|
| 218 |
return;
|
| 219 |
}
|
|
|
|
| 220 |
this.audio.src = 'data:audio/mp3;base64,' + base64;
|
| 221 |
this.audio.currentTime = 0;
|
|
|
|
| 222 |
let fired = false;
|
| 223 |
const done = () => {
|
| 224 |
if (fired) return;
|
|
|
|
| 227 |
this.audio.onerror = null;
|
| 228 |
if (onComplete) onComplete();
|
| 229 |
};
|
|
|
|
| 230 |
this.audio.onended = done;
|
| 231 |
this.audio.onerror = done;
|
| 232 |
const p = this.audio.play();
|
| 233 |
if (p) p.catch(done);
|
| 234 |
}
|
| 235 |
}
|
|
|
|
| 236 |
|
| 237 |
+
let preStarterPlayer = null;
|
| 238 |
|
| 239 |
/* ================================================================
|
| 240 |
TTS AUDIO PLAYER (Text-to-Speech Queue System)
|
| 241 |
================================================================
|
| 242 |
+
|
| 243 |
HOW THE TTS QUEUE WORKS β EXPLAINED FOR LEARNERS
|
| 244 |
-------------------------------------------------
|
| 245 |
When TTS is enabled, the backend doesn't send one giant audio file.
|
|
|
|
| 264 |
that was "unlocked" during a user gesture, all subsequent plays
|
| 265 |
through that same element are allowed. Creating new Audio() objects
|
| 266 |
each time would trigger autoplay blocks on iOS.
|
| 267 |
+
|
| 268 |
================================================================ */
|
| 269 |
class TTSPlayer {
|
| 270 |
/**
|
| 271 |
* Creates a new TTSPlayer instance.
|
| 272 |
*
|
| 273 |
* Properties:
|
| 274 |
+
* queue β Array of base64 audio strings waiting to be played.
|
| 275 |
+
* playing β True if the play loop is currently running.
|
| 276 |
+
* enabled β True if the user has toggled TTS on (via the speaker button).
|
| 277 |
+
* stopped β True if playback was forcibly stopped (e.g., new chat).
|
| 278 |
+
* This prevents queued audio from playing after a stop.
|
| 279 |
+
* audio β A single persistent <audio> element reused for all playback.
|
| 280 |
*/
|
| 281 |
constructor() {
|
| 282 |
this.queue = [];
|
|
|
|
| 294 |
* This should be called during a user gesture (e.g., clicking "Send").
|
| 295 |
*
|
| 296 |
* It does two things:
|
| 297 |
+
* 1. Plays a tiny silent WAV file on the <audio> element, which
|
| 298 |
+
* tells the browser "the user initiated audio playback."
|
| 299 |
+
* 2. Creates a brief AudioContext oscillator at zero volume β this
|
| 300 |
+
* unlocks the Web Audio API context on iOS (a separate lock from
|
| 301 |
+
* the <audio> element).
|
| 302 |
*
|
| 303 |
* After this, the browser treats subsequent .play() calls on the same
|
| 304 |
* <audio> element as user-initiated, even if they happen in an async
|
|
|
|
| 310 |
this.audio.src = silentWav;
|
| 311 |
const p = this.audio.play();
|
| 312 |
if (p) p.catch(() => {});
|
|
|
|
| 313 |
try {
|
| 314 |
// Create a Web Audio context and play a zero-volume oscillator for <1ms
|
| 315 |
const ctx = new (window.AudioContext || window.webkitAudioContext)();
|
|
|
|
| 344 |
* stop() β Immediately halts all audio playback and clears the queue.
|
| 345 |
*
|
| 346 |
* Called when:
|
| 347 |
+
* - The user starts a "New Chat"
|
| 348 |
+
* - The user toggles TTS off while audio is playing
|
| 349 |
+
* - We need to reset before a new streaming response
|
| 350 |
*
|
| 351 |
* It also removes visual indicators (CSS classes on the TTS button,
|
| 352 |
* the orb container, and deactivates the orb animation).
|
|
|
|
| 358 |
this.audio.load(); // Fully resets the audio element
|
| 359 |
this.queue = []; // Discard any pending audio chunks
|
| 360 |
this.playing = false;
|
|
|
|
| 361 |
if (ttsBtn) ttsBtn.classList.remove('tts-speaking');
|
| 362 |
if (orbContainer) orbContainer.classList.remove('speaking');
|
| 363 |
if (orb) orb.setActive(false);
|
|
|
|
| 364 |
if (typeof this.onPlaybackComplete === 'function') this.onPlaybackComplete(); // AI stopped β maybe restart mic
|
| 365 |
}
|
| 366 |
|
|
|
|
| 409 |
// Process queued audio chunks one at a time
|
| 410 |
while (this.queue.length > 0) {
|
| 411 |
if (this.stopped || myId !== this._loopId) break; // Exit if stopped or superseded
|
|
|
|
| 412 |
const b64 = this.queue.shift(); // Take the next chunk from the front
|
| 413 |
try {
|
| 414 |
await this._playB64(b64); // Wait for it to finish playing
|
|
|
|
| 422 |
this.playing = false; // Allow new loop to start
|
| 423 |
return;
|
| 424 |
}
|
|
|
|
| 425 |
this.playing = false;
|
|
|
|
| 426 |
// Deactivate visual indicators
|
| 427 |
if (ttsBtn) ttsBtn.classList.remove('tts-speaking');
|
| 428 |
if (orbContainer) orbContainer.classList.remove('speaking');
|
| 429 |
if (orb) orb.setActive(false);
|
|
|
|
| 430 |
// Notify when playback is fully complete (for auto-restart listening)
|
| 431 |
if (typeof this.onPlaybackComplete === 'function') this.onPlaybackComplete();
|
| 432 |
}
|
|
|
|
| 436 |
*
|
| 437 |
* @param {string} b64 - Base64-encoded MP3 audio data.
|
| 438 |
* @returns {Promise<void>} Resolves when the audio finishes playing
|
| 439 |
+
* (or errors out).
|
| 440 |
*
|
| 441 |
* Sets the <audio> element's src to a data URL and calls .play().
|
| 442 |
* Returns a Promise that resolves on 'ended' or 'error', so the
|
|
|
|
| 448 |
const done = () => { resolve(); };
|
| 449 |
this.audio.onended = done; // Normal completion
|
| 450 |
this.audio.onerror = done; // Error β resolve anyway so the loop continues
|
|
|
|
| 451 |
const p = this.audio.play();
|
| 452 |
if (p) p.catch(done); // Handle play() rejection (e.g., autoplay block)
|
| 453 |
});
|
| 454 |
}
|
| 455 |
}
|
| 456 |
|
|
|
|
| 457 |
/* ================================================================
|
| 458 |
INITIALIZATION
|
| 459 |
================================================================
|
|
|
|
| 475 |
console.error('[JARVIS] Required DOM elements (chat-messages, message-input) not found.');
|
| 476 |
return;
|
| 477 |
}
|
|
|
|
| 478 |
loadSettings();
|
| 479 |
ttsPlayer = new TTSPlayer();
|
| 480 |
ttsPlayer.onPlaybackComplete = maybeRestartListening; // Auto-restart mic when TTS finishes
|
| 481 |
if (ttsBtn) ttsBtn.classList.add('tts-active'); // Show TTS as on by default
|
|
|
|
| 482 |
setGreeting();
|
| 483 |
initOrb();
|
| 484 |
initSpeech();
|
| 485 |
preloadStarterAudio(); // Pre-load MP3s for instant playback
|
| 486 |
preStarterPlayer = new PreStarterPlayer(); // Dedicated player for pre-starter (immune to ttsPlayer.reset)
|
|
|
|
| 487 |
checkHealth();
|
| 488 |
bindEvents();
|
| 489 |
setMode(currentMode); // Sync mode slider, labels, and activity toggle
|
|
|
|
| 531 |
} catch (_) {}
|
| 532 |
}
|
| 533 |
|
|
|
|
| 534 |
/* ================================================================
|
| 535 |
GREETING
|
| 536 |
================================================================ */
|
|
|
|
| 540 |
* time of day.
|
| 541 |
*
|
| 542 |
* Time ranges:
|
| 543 |
+
* 00:00β11:59 β "Good morning."
|
| 544 |
+
* 12:00β16:59 β "Good afternoon."
|
| 545 |
+
* 17:00β21:59 β "Good evening."
|
| 546 |
+
* 22:00β23:59 β "Burning the midnight oil?" (a fun late-night touch)
|
| 547 |
*
|
| 548 |
* This is called on page load and when starting a new chat.
|
| 549 |
*/
|
|
|
|
| 556 |
if (welcomeTitle) welcomeTitle.textContent = g;
|
| 557 |
}
|
| 558 |
|
|
|
|
| 559 |
/* ================================================================
|
| 560 |
WEBGL ORB INITIALIZATION
|
| 561 |
================================================================ */
|
|
|
|
| 568 |
* and we skip initialization gracefully.
|
| 569 |
*
|
| 570 |
* Configuration:
|
| 571 |
+
* hue: 0 β The base hue of the orb color
|
| 572 |
+
* hoverIntensity: 0.3 β How much the orb reacts to mouse hover
|
| 573 |
+
* backgroundColor: [0.02,0.02,0.06] β Near-black dark blue background (RGB, 0β1 range)
|
| 574 |
*
|
| 575 |
* The orb's "active" state (pulsing animation) is toggled via
|
| 576 |
* orb.setActive(true/false), which we call when TTS starts/stops.
|
|
|
|
| 586 |
} catch (e) { console.warn('Orb init failed:', e); }
|
| 587 |
}
|
| 588 |
|
|
|
|
| 589 |
/* ================================================================
|
| 590 |
SPEECH RECOGNITION (Speech-to-Text)
|
| 591 |
================================================================
|
| 592 |
+
|
| 593 |
SPEECH-TO-TEXT REDESIGN β PC-FIRST, ACCURATE, AUTO-RESTART
|
| 594 |
----------------------------------------------------------
|
| 595 |
Design goals:
|
|
|
|
| 625 |
function initSpeech() {
|
| 626 |
const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
|
| 627 |
if (!SR) { micBtn.title = 'Speech not supported in this browser'; return; }
|
| 628 |
+
|
| 629 |
recognition = new SR();
|
| 630 |
|
| 631 |
/* PC: single utterance, interim results for real-time feedback. Avoids Chrome incremental bug. */
|
|
|
|
| 638 |
|
| 639 |
recognition.onresult = e => {
|
| 640 |
if (!e.results || e.results.length === 0) return;
|
|
|
|
| 641 |
/* Chrome sends incremental results β each extends the previous. Use ONLY the last. */
|
| 642 |
const last = e.results[e.results.length - 1];
|
| 643 |
const transcript = (last && last[0]) ? last[0].transcript.trim() : '';
|
|
|
|
| 647 |
if (speechWidget) speechWidget.classList.add('visible');
|
| 648 |
|
| 649 |
if (isFinal && transcript) {
|
| 650 |
+
pendingSendTranscript = transcript;
|
| 651 |
+
clearTimeout(speechSendTimeout);
|
| 652 |
+
speechSendTimeout = setTimeout(() => {
|
| 653 |
+
if (pendingSendTranscript) {
|
| 654 |
+
sendMessage(pendingSendTranscript);
|
| 655 |
+
pendingSendTranscript = null;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 656 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 657 |
speechSendTimeout = null;
|
| 658 |
+
stopListening();
|
| 659 |
+
}, SPEECH_SEND_DELAY_MS);
|
|
|
|
|
|
|
| 660 |
} else if (!isFinal) {
|
| 661 |
pendingSendTranscript = null;
|
| 662 |
clearTimeout(speechSendTimeout);
|
|
|
|
| 665 |
};
|
| 666 |
|
| 667 |
recognition.onstart = () => { speechErrorRetryCount = 0; };
|
| 668 |
+
|
| 669 |
recognition.onerror = e => {
|
| 670 |
stopListening();
|
| 671 |
const msg = (e && e.error) ? String(e.error) : '';
|
| 672 |
const isPermissionDenied = /denied|not-allowed|permission/i.test(msg);
|
|
|
|
| 673 |
if (isPermissionDenied && micBtn) {
|
| 674 |
micBtn.title = 'Microphone access denied. Allow in browser settings.';
|
| 675 |
speechErrorRetryCount = SPEECH_ERROR_MAX_RETRIES;
|
| 676 |
}
|
|
|
|
| 677 |
if (autoListenMode && !isStreaming && speechErrorRetryCount < SPEECH_ERROR_MAX_RETRIES) {
|
| 678 |
speechErrorRetryCount++;
|
| 679 |
setTimeout(() => maybeRestartListening(), SPEECH_RESTART_DELAY_MS);
|
|
|
|
| 701 |
* startListening() β Activates the microphone and begins speech recognition.
|
| 702 |
*
|
| 703 |
* Guards:
|
| 704 |
+
* - Does nothing if recognition isn't available (unsupported browser).
|
| 705 |
+
* - Does nothing if we're currently streaming a response (to avoid
|
| 706 |
+
* accidentally sending a voice message mid-stream).
|
| 707 |
*/
|
| 708 |
function startListening() {
|
| 709 |
if (!recognition || isStreaming || isListening) return;
|
|
|
|
| 710 |
if (isSafariOrIOS() && !safariVoiceHintShown) {
|
| 711 |
showToast('Voice works best in Chrome. Safari has limited support.');
|
| 712 |
safariVoiceHintShown = true;
|
| 713 |
}
|
|
|
|
| 714 |
isListening = true;
|
| 715 |
pendingSendTranscript = null;
|
| 716 |
clearTimeout(speechSendTimeout);
|
| 717 |
speechSendTimeout = null;
|
|
|
|
| 718 |
if (micBtn) micBtn.classList.add('listening');
|
| 719 |
if (speechWidget) speechWidget.classList.add('visible');
|
| 720 |
if (speechWidgetText) speechWidgetText.textContent = '';
|
|
|
|
| 721 |
try {
|
| 722 |
recognition.start();
|
| 723 |
} catch (err) {
|
|
|
|
| 732 |
* stopListening() β Deactivates the microphone and stops recognition.
|
| 733 |
*
|
| 734 |
* Called when:
|
| 735 |
+
* - A final transcript is received (auto-send).
|
| 736 |
+
* - The user clicks the mic button again (manual toggle off).
|
| 737 |
+
* - An error occurs.
|
| 738 |
+
* - The recognition engine stops unexpectedly.
|
| 739 |
*/
|
| 740 |
function stopListening() {
|
| 741 |
clearTimeout(speechSendTimeout);
|
|
|
|
| 757 |
if (!autoListenMode || !recognition) return;
|
| 758 |
if (isStreaming) return;
|
| 759 |
if (ttsPlayer && (ttsPlayer.playing || ttsPlayer.queue.length > 0)) return;
|
|
|
|
| 760 |
setTimeout(() => {
|
| 761 |
if (autoListenMode && !isStreaming && !isListening && recognition) {
|
| 762 |
startListening();
|
|
|
|
| 764 |
}, SPEECH_RESTART_DELAY_MS);
|
| 765 |
}
|
| 766 |
|
|
|
|
| 767 |
/* ================================================================
|
| 768 |
BACKEND HEALTH CHECK
|
| 769 |
================================================================ */
|
|
|
|
| 773 |
* if the server is running and healthy.
|
| 774 |
*
|
| 775 |
* Updates the status indicator in the UI:
|
| 776 |
+
* - Green dot + "Online" if the server responds with { status: "healthy" }
|
| 777 |
+
* - Red dot + "Offline" if the request fails or returns unhealthy
|
| 778 |
*
|
| 779 |
* Uses AbortSignal.timeout(5000) to avoid waiting forever if the
|
| 780 |
* server is down β the request will automatically abort after 5 seconds.
|
|
|
|
| 785 |
const timeoutId = setTimeout(() => controller.abort(), 5000);
|
| 786 |
const r = await fetch(`${API}/health`, { signal: controller.signal });
|
| 787 |
clearTimeout(timeoutId);
|
|
|
|
| 788 |
const d = await r.json().catch(() => null);
|
| 789 |
const ok = d && (d.status === 'healthy' || d.status === 'degraded');
|
|
|
|
| 790 |
if (statusDot) statusDot.classList.toggle('offline', !ok);
|
| 791 |
if (statusText) statusText.textContent = ok ? 'Online' : 'Offline';
|
| 792 |
} catch (e) {
|
|
|
|
| 806 |
el.className = 'toast';
|
| 807 |
el.textContent = msg;
|
| 808 |
toastContainer.appendChild(el);
|
|
|
|
| 809 |
el.offsetHeight; // Force reflow for animation
|
| 810 |
el.classList.add('toast-visible');
|
|
|
|
| 811 |
const t = setTimeout(() => {
|
| 812 |
el.classList.remove('toast-visible');
|
| 813 |
setTimeout(() => el.remove(), 300);
|
| 814 |
}, durationMs);
|
|
|
|
| 815 |
el.addEventListener('click', () => { clearTimeout(t); el.classList.remove('toast-visible'); setTimeout(() => el.remove(), 300); });
|
| 816 |
}
|
| 817 |
|
|
|
|
| 818 |
/* ================================================================
|
| 819 |
EVENT BINDING
|
| 820 |
================================================================
|
|
|
|
| 889 |
if (searchResultsClose && searchResultsWidget) {
|
| 890 |
searchResultsClose.addEventListener('click', () => { searchResultsWidget.classList.remove('open'); updatePanelOverlay(); });
|
| 891 |
}
|
|
|
|
| 892 |
// ACTIVITY PANEL β Toggle open/close from header button; close from panel button
|
| 893 |
if (activityToggle) {
|
| 894 |
activityToggle.addEventListener('click', () => {
|
|
|
|
| 898 |
if (activityClose && activityPanel) {
|
| 899 |
activityClose.addEventListener('click', () => { activityPanel.classList.remove('open'); updatePanelOverlay(); });
|
| 900 |
}
|
|
|
|
| 901 |
// Panels close ONLY via their X button β overlay does not close on click
|
|
|
|
| 902 |
// SETTINGS
|
| 903 |
if (settingsBtn && settingsPanel) {
|
| 904 |
settingsBtn.addEventListener('click', () => {
|
|
|
|
| 912 |
updatePanelOverlay();
|
| 913 |
});
|
| 914 |
}
|
|
|
|
| 915 |
if (toggleAutoActivity) {
|
| 916 |
toggleAutoActivity.addEventListener('change', () => {
|
| 917 |
settings.autoOpenActivity = toggleAutoActivity.checked;
|
|
|
|
| 937 |
* its content, up to a maximum of 120px.
|
| 938 |
*
|
| 939 |
* How it works:
|
| 940 |
+
* 1. Reset height to 'auto' so scrollHeight reflects actual content height.
|
| 941 |
+
* 2. Set height to the smaller of scrollHeight or 120px.
|
| 942 |
+
* This creates a textarea that grows as the user types but doesn't
|
| 943 |
+
* take over the whole screen for very long messages.
|
| 944 |
*/
|
| 945 |
function autoResizeInput() {
|
| 946 |
messageInput.style.height = 'auto';
|
| 947 |
messageInput.style.height = Math.min(messageInput.scrollHeight, 120) + 'px';
|
| 948 |
}
|
| 949 |
|
|
|
|
| 950 |
/* ================================================================
|
| 951 |
MODE SWITCH (General β Realtime)
|
| 952 |
================================================================
|
|
|
|
| 976 |
* @param {string} mode - Either 'general' or 'realtime'.
|
| 977 |
*
|
| 978 |
* Updates:
|
| 979 |
+
* - currentMode variable (used when sending messages)
|
| 980 |
+
* - Button active states (highlights the selected button)
|
| 981 |
+
* - Slider position (slides the pill indicator left or right)
|
| 982 |
*/
|
| 983 |
function setMode(mode) {
|
| 984 |
currentMode = mode;
|
| 985 |
if (btnJarvis) btnJarvis.classList.toggle('active', mode === 'jarvis');
|
| 986 |
if (btnGeneral) btnGeneral.classList.toggle('active', mode === 'general');
|
| 987 |
if (btnRealtime) btnRealtime.classList.toggle('active', mode === 'realtime');
|
|
|
|
| 988 |
if (modeSlider) {
|
| 989 |
modeSlider.classList.remove('center', 'right');
|
| 990 |
if (mode === 'general') modeSlider.classList.add('center');
|
| 991 |
else if (mode === 'realtime') modeSlider.classList.add('right');
|
| 992 |
/* jarvis: no class = left position */
|
| 993 |
}
|
|
|
|
| 994 |
// Activity toggle always visible β panel shows flow in all modes
|
| 995 |
if (activityToggle) activityToggle.style.display = '';
|
| 996 |
}
|
| 997 |
|
|
|
|
| 998 |
/* ================================================================
|
| 999 |
NEW CHAT
|
| 1000 |
================================================================ */
|
|
|
|
| 1003 |
* newChat() β Resets the entire conversation to a fresh state.
|
| 1004 |
*
|
| 1005 |
* Steps:
|
| 1006 |
+
* 1. Stop any playing TTS audio.
|
| 1007 |
+
* 2. Clear the session ID (server will create a new one on next message).
|
| 1008 |
+
* 3. Clear all messages from the chat container.
|
| 1009 |
+
* 4. Re-create and display the welcome screen.
|
| 1010 |
+
* 5. Clear the input field and reset its size.
|
| 1011 |
+
* 6. Update the greeting text (in case time-of-day changed).
|
| 1012 |
*/
|
| 1013 |
function newChat() {
|
| 1014 |
if (ttsPlayer) ttsPlayer.stop();
|
|
|
|
| 1018 |
messageInput.value = '';
|
| 1019 |
autoResizeInput();
|
| 1020 |
setGreeting();
|
|
|
|
| 1021 |
if (searchResultsWidget) searchResultsWidget.classList.remove('open');
|
| 1022 |
if (searchResultsToggle) searchResultsToggle.style.display = 'none';
|
| 1023 |
if (activityPanel) activityPanel.classList.remove('open');
|
| 1024 |
if (settingsPanel) settingsPanel.classList.remove('open');
|
| 1025 |
if (activityToggle) activityToggle.style.display = 'none';
|
|
|
|
| 1026 |
if (activityList) {
|
| 1027 |
activityList.innerHTML = '<div class="activity-empty" id="activity-empty">Send a message to see the flow here.</div>';
|
| 1028 |
}
|
|
|
|
| 1033 |
* createWelcome() β Builds and returns the welcome screen DOM element.
|
| 1034 |
*
|
| 1035 |
* @returns {HTMLDivElement} The welcome screen element, ready to be
|
| 1036 |
+
* appended to the chat container.
|
| 1037 |
*
|
| 1038 |
* The welcome screen includes:
|
| 1039 |
+
* - A decorative SVG icon
|
| 1040 |
+
* - A time-based greeting (same logic as setGreeting)
|
| 1041 |
+
* - A subtitle prompt ("How may I assist you today?")
|
| 1042 |
+
* - Quick-action chip buttons with predefined messages
|
| 1043 |
*
|
| 1044 |
* The chip buttons get their own click listeners here because they
|
| 1045 |
* are dynamically created (not present in the original HTML).
|
|
|
|
| 1071 |
div.querySelectorAll('.chip').forEach(c => {
|
| 1072 |
c.addEventListener('click', () => { if (!isStreaming) sendMessage(c.dataset.msg); });
|
| 1073 |
});
|
|
|
|
| 1074 |
return div;
|
| 1075 |
}
|
| 1076 |
|
|
|
|
| 1077 |
/* ================================================================
|
| 1078 |
MESSAGE RENDERING
|
| 1079 |
================================================================
|
|
|
|
| 1128 |
if (!payload) return;
|
| 1129 |
if (searchResultsQuery) searchResultsQuery.textContent = (payload.query || '').trim() || 'Search';
|
| 1130 |
if (searchResultsAnswer) searchResultsAnswer.textContent = (payload.answer || '').trim() || '';
|
|
|
|
| 1131 |
if (!searchResultsList) return;
|
| 1132 |
searchResultsList.innerHTML = '';
|
| 1133 |
const results = payload.results || [];
|
| 1134 |
const maxContentLen = 220;
|
|
|
|
| 1135 |
for (const r of results) {
|
| 1136 |
let title = (r.title || '').trim();
|
| 1137 |
let content = (r.content || '').trim();
|
| 1138 |
const url = (r.url || '').trim();
|
|
|
|
| 1139 |
if (isUrlLike(title)) title = friendlyUrlLabel(url) || 'Source';
|
| 1140 |
if (!title) title = friendlyUrlLabel(url) || 'Source';
|
| 1141 |
if (isUrlLike(content)) content = '';
|
| 1142 |
content = truncateSnippet(content, maxContentLen);
|
|
|
|
| 1143 |
const score = r.score != null ? Math.round((r.score || 0) * 100) : null;
|
|
|
|
| 1144 |
const card = document.createElement('div');
|
| 1145 |
card.className = 'search-result-card';
|
|
|
|
| 1146 |
const urlDisplay = url ? escapeHtml(friendlyUrlLabel(url)) : '';
|
| 1147 |
const hrefSafe = safeUrlForHref(url);
|
| 1148 |
const urlMarkup = urlDisplay
|
| 1149 |
? (hrefSafe ? `<a href="${hrefSafe}" target="_blank" rel="noopener" class="card-url" title="${escapeAttr(url)}">${urlDisplay}</a>` : `<span class="card-url">${urlDisplay}</span>`)
|
| 1150 |
: '';
|
|
|
|
| 1151 |
card.innerHTML = `
|
| 1152 |
<div class="card-title">${escapeHtml(title)}</div>
|
| 1153 |
${content ? `<div class="card-content">${escapeHtml(content)}</div>` : ''}
|
|
|
|
| 1200 |
*/
|
| 1201 |
function appendActivity(activity) {
|
| 1202 |
if (!activityList || !activity) return;
|
|
|
|
| 1203 |
const item = document.createElement('div');
|
| 1204 |
item.className = 'activity-item';
|
| 1205 |
item.setAttribute('data-event', activity.event || '');
|
|
|
|
| 1206 |
const stepInfo = ACTIVITY_STEPS[activity.event] || { step: 0, label: activity.event || 'Activity', icon: 'dot' };
|
| 1207 |
let detail = '';
|
|
|
|
| 1208 |
if (activity.event === 'query_detected') {
|
| 1209 |
detail = activity.message || '';
|
| 1210 |
} else if (activity.event === 'decision') {
|
|
|
|
| 1241 |
} else {
|
| 1242 |
detail = activity.message || (typeof activity === 'object' ? JSON.stringify(activity) : String(activity));
|
| 1243 |
}
|
|
|
|
| 1244 |
const stepNum = stepInfo.step ? `<span class="activity-step">${stepInfo.step}</span>` : '';
|
| 1245 |
item.innerHTML = `
|
| 1246 |
<div class="activity-event">${stepNum}${escapeHtml(stepInfo.label)}</div>
|
| 1247 |
<div class="activity-detail">${escapeHtml(detail || '')}</div>`;
|
|
|
|
| 1248 |
const emptyEl = activityList.querySelector('.activity-empty');
|
| 1249 |
if (emptyEl) emptyEl.style.display = 'none';
|
|
|
|
| 1250 |
activityList.appendChild(item);
|
| 1251 |
activityList.scrollTop = activityList.scrollHeight;
|
| 1252 |
}
|
|
|
|
| 1276 |
* addMessage(role, text) β Creates and appends a chat message bubble.
|
| 1277 |
*
|
| 1278 |
* @param {string} role - Either 'user' or 'assistant'. Determines
|
| 1279 |
+
* styling, avatar letter, and label text.
|
| 1280 |
* @param {string} text - The message content to display.
|
| 1281 |
* @returns {HTMLDivElement} The inner content element β returned so
|
| 1282 |
+
* the caller (sendMessage) can update it
|
| 1283 |
+
* later during streaming.
|
| 1284 |
*
|
| 1285 |
* DOM structure created:
|
| 1286 |
+
* <div class="message user|assistant">
|
| 1287 |
+
* <div class="msg-avatar"><svg>...</svg></div>
|
| 1288 |
+
* <div class="msg-body">
|
| 1289 |
+
* <div class="msg-label">Jarvis (General) | You</div>
|
| 1290 |
+
* <div class="msg-content">...text...</div>
|
| 1291 |
+
* </div>
|
| 1292 |
+
* </div>
|
| 1293 |
*/
|
|
|
|
| 1294 |
/* Inline SVG icons for chat avatars (user = person, assistant = bot). */
|
| 1295 |
const AVATAR_ICON_USER = '<svg class="msg-avatar-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>';
|
| 1296 |
const AVATAR_ICON_ASSISTANT = '<svg class="msg-avatar-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="10" rx="2"/><circle cx="12" cy="5" r="2"/><path d="M12 7v4"/><circle cx="9" cy="16" r="1" fill="currentColor"/><circle cx="15" cy="16" r="1" fill="currentColor"/></svg>';
|
| 1297 |
|
| 1298 |
function addMessage(role, text) {
|
| 1299 |
hideWelcome();
|
|
|
|
| 1300 |
const msg = document.createElement('div');
|
| 1301 |
msg.className = `message ${role}`;
|
| 1302 |
|
|
|
|
| 1321 |
body.appendChild(content);
|
| 1322 |
msg.appendChild(avatar);
|
| 1323 |
msg.appendChild(body);
|
|
|
|
| 1324 |
chatMessages.appendChild(msg);
|
| 1325 |
scrollToBottom();
|
|
|
|
| 1326 |
return content; // Returned so the streaming logic can update it in real time
|
| 1327 |
}
|
| 1328 |
|
|
|
|
| 1339 |
*/
|
| 1340 |
function addTypingIndicator() {
|
| 1341 |
hideWelcome();
|
|
|
|
| 1342 |
const msg = document.createElement('div');
|
| 1343 |
msg.className = 'message assistant';
|
| 1344 |
msg.id = 'typing-msg'; // ID so we can find and remove it later
|
|
|
|
| 1362 |
body.appendChild(content);
|
| 1363 |
msg.appendChild(avatar);
|
| 1364 |
msg.appendChild(body);
|
|
|
|
| 1365 |
chatMessages.appendChild(msg);
|
| 1366 |
scrollToBottom();
|
|
|
|
| 1367 |
return content;
|
| 1368 |
}
|
| 1369 |
|
|
|
|
| 1371 |
* removeTypingIndicator() β Removes the typing indicator from the DOM.
|
| 1372 |
*
|
| 1373 |
* Called when:
|
| 1374 |
+
* - The first token of the response arrives (replaced by real content).
|
| 1375 |
+
* - An error occurs (replaced by an error message).
|
| 1376 |
*/
|
| 1377 |
function removeTypingIndicator() {
|
| 1378 |
const t = document.getElementById('typing-msg');
|
|
|
|
| 1393 |
});
|
| 1394 |
}
|
| 1395 |
|
|
|
|
| 1396 |
/* ================================================================
|
| 1397 |
SEND MESSAGE + SSE STREAMING
|
| 1398 |
================================================================
|
| 1399 |
+
|
| 1400 |
HOW SSE (Server-Sent Events) STREAMING WORKS β EXPLAINED FOR LEARNERS
|
| 1401 |
----------------------------------------------------------------------
|
| 1402 |
Instead of waiting for the entire AI response to generate (which
|
|
|
|
| 1438 |
accumulates raw text. We split by '\n', process all complete lines,
|
| 1439 |
and keep the last (potentially incomplete) line in the buffer for
|
| 1440 |
the next iteration.
|
| 1441 |
+
|
| 1442 |
================================================================ */
|
| 1443 |
|
| 1444 |
/**
|
| 1445 |
* sendMessage(textOverride) β Sends a user message and streams the AI response.
|
| 1446 |
*
|
| 1447 |
* AUDIO WORKFLOW (minimizes waiting):
|
| 1448 |
+
* 1. Pre-starter: Play random cached audio on dedicated PreStarterPlayer (immune to reset).
|
| 1449 |
+
* 2. Main: Stream from chatbot; when first real chunk arrives, reset() and main TTS plays.
|
| 1450 |
*/
|
| 1451 |
async function sendMessage(textOverride) {
|
| 1452 |
// Step 1: Get the message text, trimming whitespace
|
|
|
|
| 1495 |
if (ttsPlayer?.enabled && settings.thinkingSounds && preStarterPlayer) {
|
| 1496 |
preStarterPlayer.play(() => {});
|
| 1497 |
}
|
|
|
|
| 1498 |
// 2. Main: stream from chatbot (general or realtime)
|
| 1499 |
timeoutId = setTimeout(() => controller.abort(), 300000);
|
| 1500 |
const res = await fetch(`${API}${endpoint}`, {
|
|
|
|
| 1547 |
for (const line of lines) {
|
| 1548 |
// SSE lines that don't start with "data: " are empty lines or comments β skip them
|
| 1549 |
if (!line.startsWith('data: ')) continue;
|
|
|
|
| 1550 |
try {
|
| 1551 |
// Parse the JSON payload (everything after "data: ")
|
| 1552 |
const data = JSON.parse(line.slice(6));
|
|
|
|
| 1571 |
// TEXT CHUNK β Append to the displayed response (chunk can be "" in some streams)
|
| 1572 |
if ('chunk' in data) {
|
| 1573 |
const chunkText = data.chunk || '';
|
|
|
|
| 1574 |
// Only treat as "main started" when we get actual content β the initial event
|
| 1575 |
// has chunk: "" for session_id; that would wrongly reset
|
| 1576 |
if (chunkText && !firstChunkReceived) {
|
| 1577 |
firstChunkReceived = true;
|
| 1578 |
if (ttsPlayer) ttsPlayer.reset(); // Stop pre-starter, play main immediately
|
| 1579 |
}
|
|
|
|
| 1580 |
fullResponse += chunkText;
|
|
|
|
| 1581 |
const textSpan = contentEl.querySelector('.msg-stream-text');
|
| 1582 |
if (textSpan) {
|
| 1583 |
+
textSpan.textContent = fullResponse;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1584 |
textSpan.classList.remove('stream-placeholder');
|
| 1585 |
}
|
| 1586 |
|
|
|
|
| 1604 |
|
| 1605 |
// DONE β The server signals that the response is complete
|
| 1606 |
if (data.done) { streamDone = true; break; }
|
|
|
|
| 1607 |
} catch (parseErr) {
|
| 1608 |
// Ignore JSON parse errors (e.g., partial lines) but re-throw real errors
|
| 1609 |
if (parseErr.message && !parseErr.message.includes('JSON'))
|
|
|
|
| 1632 |
if (sendBtn) sendBtn.disabled = false;
|
| 1633 |
if (messageInput) messageInput.disabled = false;
|
| 1634 |
if (orbContainer) orbContainer.classList.remove('active');
|
|
|
|
| 1635 |
maybeRestartListening(); // Auto-restart mic when stream ends (TTS may still be playing)
|
| 1636 |
}
|
| 1637 |
}
|
|
|
|
| 1645 |
================================================================ */
|
| 1646 |
document.addEventListener('DOMContentLoaded', init);
|
| 1647 |
|
|
|
|
| 1648 |
// --- JARVIS SECURE LOGIN SYSTEM (BACKEND AUTH) ---
|
| 1649 |
async function checkPassword() {
|
| 1650 |
const input = document.getElementById('pass-input').value;
|