aaditkumar commited on
Commit
b1f23d2
Β·
verified Β·
1 Parent(s): 485d740

Update frontend/script.js

Browse files
Files changed (1) hide show
  1. 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
- * 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,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
- * 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,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
- * - The user starts a "New Chat"
349
- * - The user toggles TTS off while audio is playing
350
- * - We need to reset before a new streaming response
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
- * (or errors out).
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
- * 00:00–11:59 β†’ "Good morning."
557
- * 12:00–16:59 β†’ "Good afternoon."
558
- * 17:00–21:59 β†’ "Good evening."
559
- * 22:00–23:59 β†’ "Burning the midnight oil?" (a fun late-night touch)
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
- * hue: 0 β€” The base hue of the orb color
586
- * hoverIntensity: 0.3 β€” How much the orb reacts to mouse hover
587
- * backgroundColor: [0.02,0.02,0.06] β€” Near-black dark blue background (RGB, 0–1 range)
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
- // --- πŸš€ THE WAKE WORD LOCK (ZERO DELAY) ---
666
- let lowerText = transcript.toLowerCase();
667
-
668
- if (lowerText.includes("jarvis")) {
669
- // "Jarvis" sun liya! Ab uska naam text se hatao aur command padho
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(); // Ye function mic band karke wapas chaalu kar dega
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
- * - Does nothing if recognition isn't available (unsupported browser).
743
- * - Does nothing if we're currently streaming a response (to avoid
744
- * accidentally sending a voice message mid-stream).
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
- * - A final transcript is received (auto-send).
778
- * - The user clicks the mic button again (manual toggle off).
779
- * - An error occurs.
780
- * - The recognition engine stops unexpectedly.
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
- * - Green dot + "Online" if the server responds with { status: "healthy" }
821
- * - Red dot + "Offline" if the request fails or returns unhealthy
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
- * 1. Reset height to 'auto' so scrollHeight reflects actual content height.
995
- * 2. Set height to the smaller of scrollHeight or 120px.
996
- * This creates a textarea that grows as the user types but doesn't
997
- * take over the whole screen for very long messages.
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
- * - currentMode variable (used when sending messages)
1035
- * - Button active states (highlights the selected button)
1036
- * - Slider position (slides the pill indicator left or right)
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
- * 1. Stop any playing TTS audio.
1065
- * 2. Clear the session ID (server will create a new one on next message).
1066
- * 3. Clear all messages from the chat container.
1067
- * 4. Re-create and display the welcome screen.
1068
- * 5. Clear the input field and reset its size.
1069
- * 6. Update the greeting text (in case time-of-day changed).
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
- * appended to the chat container.
1097
  *
1098
  * The welcome screen includes:
1099
- * - A decorative SVG icon
1100
- * - A time-based greeting (same logic as setGreeting)
1101
- * - A subtitle prompt ("How may I assist you today?")
1102
- * - Quick-action chip buttons with predefined messages
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
- * styling, avatar letter, and label text.
1355
  * @param {string} text - The message content to display.
1356
  * @returns {HTMLDivElement} The inner content element β€” returned so
1357
- * the caller (sendMessage) can update it
1358
- * later during streaming.
1359
  *
1360
  * DOM structure created:
1361
- * <div class="message user|assistant">
1362
- * <div class="msg-avatar"><svg>...</svg></div>
1363
- * <div class="msg-body">
1364
- * <div class="msg-label">Jarvis (General) | You</div>
1365
- * <div class="msg-content">...text...</div>
1366
- * </div>
1367
- * </div>
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
- * - The first token of the response arrives (replaced by real content).
1457
- * - An error occurs (replaced by an error message).
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
- * 1. Pre-starter: Play random cached audio on dedicated PreStarterPlayer (immune to reset).
1530
- * 2. Main: Stream from chatbot; when first real chunk arrives, reset() and main TTS plays.
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
- // --- SAFE MAGIC INTERCEPTOR (No Brackets Touched) ---
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;