Spaces:
Sleeping
Sleeping
Upload 14 files
Browse files- frontend/audio/starter_1.mp3 +0 -0
- frontend/audio/starter_10.mp3 +0 -0
- frontend/audio/starter_2.mp3 +0 -0
- frontend/audio/starter_3.mp3 +0 -0
- frontend/audio/starter_4.mp3 +0 -0
- frontend/audio/starter_5.mp3 +0 -0
- frontend/audio/starter_6.mp3 +0 -0
- frontend/audio/starter_7.mp3 +0 -0
- frontend/audio/starter_8.mp3 +0 -0
- frontend/audio/starter_9.mp3 +0 -0
- frontend/index.html +185 -0
- frontend/orb.js +217 -0
- frontend/script.js +1646 -0
- frontend/style.css +1166 -0
frontend/audio/starter_1.mp3
ADDED
|
Binary file (11.1 kB). View file
|
|
|
frontend/audio/starter_10.mp3
ADDED
|
Binary file (11.5 kB). View file
|
|
|
frontend/audio/starter_2.mp3
ADDED
|
Binary file (12.1 kB). View file
|
|
|
frontend/audio/starter_3.mp3
ADDED
|
Binary file (12.1 kB). View file
|
|
|
frontend/audio/starter_4.mp3
ADDED
|
Binary file (10.5 kB). View file
|
|
|
frontend/audio/starter_5.mp3
ADDED
|
Binary file (13.5 kB). View file
|
|
|
frontend/audio/starter_6.mp3
ADDED
|
Binary file (13.5 kB). View file
|
|
|
frontend/audio/starter_7.mp3
ADDED
|
Binary file (12.1 kB). View file
|
|
|
frontend/audio/starter_8.mp3
ADDED
|
Binary file (11.4 kB). View file
|
|
|
frontend/audio/starter_9.mp3
ADDED
|
Binary file (10.1 kB). View file
|
|
|
frontend/index.html
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="UTF-8">
|
| 5 |
+
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no, viewport-fit=cover">
|
| 6 |
+
<meta name="apple-mobile-web-app-capable" content="yes">
|
| 7 |
+
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
| 8 |
+
<meta name="theme-color" content="#050510">
|
| 9 |
+
<title>J.A.R.V.I.S</title>
|
| 10 |
+
<link href="https://fonts.googleapis.com/css2?family=Poppins:wght@300;400;500;600;700&display=swap" rel="stylesheet">
|
| 11 |
+
<link rel="stylesheet" href="style.css">
|
| 12 |
+
</head>
|
| 13 |
+
<body>
|
| 14 |
+
<div class="app">
|
| 15 |
+
<div id="orb-container"></div>
|
| 16 |
+
<header class="header glass-panel">
|
| 17 |
+
<div class="header-left">
|
| 18 |
+
<h1 class="logo">J.A.R.V.I.S</h1>
|
| 19 |
+
<span class="tagline">Just A Rather Very Intelligent System</span>
|
| 20 |
+
</div>
|
| 21 |
+
<div class="header-center">
|
| 22 |
+
<div class="mode-switch mode-switch-three" id="mode-switch">
|
| 23 |
+
<div class="mode-slider" id="mode-slider"></div>
|
| 24 |
+
<button class="mode-btn active" data-mode="jarvis" id="btn-jarvis" title="Jarvis — brain auto-routes to General or Realtime">
|
| 25 |
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg>
|
| 26 |
+
<span class="mode-btn-text">Jarvis</span>
|
| 27 |
+
</button>
|
| 28 |
+
<button class="mode-btn" data-mode="general" id="btn-general" title="General — no web search">
|
| 29 |
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>
|
| 30 |
+
<span class="mode-btn-text">General</span>
|
| 31 |
+
</button>
|
| 32 |
+
<button class="mode-btn" data-mode="realtime" id="btn-realtime" title="Realtime — live web search">
|
| 33 |
+
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>
|
| 34 |
+
<span class="mode-btn-text">Realtime</span>
|
| 35 |
+
</button>
|
| 36 |
+
</div>
|
| 37 |
+
</div>
|
| 38 |
+
<div class="header-right">
|
| 39 |
+
<div class="status-badge" id="status-badge">
|
| 40 |
+
<span class="status-dot"></span>
|
| 41 |
+
<span class="status-text">Online</span>
|
| 42 |
+
</div>
|
| 43 |
+
<button class="btn-icon activity-toggle" id="activity-toggle" title="View activity flow">
|
| 44 |
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/><line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/></svg>
|
| 45 |
+
</button>
|
| 46 |
+
<button class="btn-icon search-results-toggle" id="search-results-toggle" title="View search results" style="display: none;">
|
| 47 |
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
| 48 |
+
<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>
|
| 49 |
+
</svg>
|
| 50 |
+
</button>
|
| 51 |
+
<button class="btn-icon settings-btn" id="settings-btn" title="Settings">
|
| 52 |
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
| 53 |
+
<circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1 0 2.83 2 2 0 0 1-2.83 0l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-2 2 2 2 0 0 1-2-2v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83 0 2 2 0 0 1 0-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1-2-2 2 2 0 0 1 2-2h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 0-2.83 2 2 0 0 1 2.83 0l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 2-2 2 2 0 0 1 2 2v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 0 2 2 0 0 1 0 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 2 2 2 2 0 0 1-2 2h-.09a1.65 1.65 0 0 0-1.51 1z"/>
|
| 54 |
+
</svg>
|
| 55 |
+
</button>
|
| 56 |
+
<button class="btn-icon new-chat-btn" id="new-chat-btn" title="New Chat">
|
| 57 |
+
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
| 58 |
+
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
|
| 59 |
+
</svg>
|
| 60 |
+
</button>
|
| 61 |
+
</div>
|
| 62 |
+
</header>
|
| 63 |
+
<aside class="activity-panel glass-panel" id="activity-panel" aria-hidden="true">
|
| 64 |
+
<div class="activity-header">
|
| 65 |
+
<h3 class="activity-title">Activity</h3>
|
| 66 |
+
<button class="activity-close" id="activity-close" title="Close" aria-label="Close activity panel">
|
| 67 |
+
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
| 68 |
+
</button>
|
| 69 |
+
</div>
|
| 70 |
+
<div class="activity-list" id="activity-list">
|
| 71 |
+
<div class="activity-empty" id="activity-empty">Send a message to see the flow here.</div>
|
| 72 |
+
</div>
|
| 73 |
+
</aside>
|
| 74 |
+
<main class="chat-area" id="chat-area">
|
| 75 |
+
<div class="chat-messages" id="chat-messages">
|
| 76 |
+
<div class="welcome-screen" id="welcome-screen">
|
| 77 |
+
<div class="welcome-icon">
|
| 78 |
+
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
| 79 |
+
<path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/>
|
| 80 |
+
</svg>
|
| 81 |
+
</div>
|
| 82 |
+
<h2 class="welcome-title" id="welcome-title">Good evening.</h2>
|
| 83 |
+
<p class="welcome-sub">How may I assist you today?</p>
|
| 84 |
+
<div class="welcome-chips">
|
| 85 |
+
<button class="chip" data-msg="What can you do?">What can you do?</button>
|
| 86 |
+
<button class="chip" data-msg="Open YouTube for me">Open YouTube</button>
|
| 87 |
+
<button class="chip" data-msg="Tell me a fun fact">Fun fact</button>
|
| 88 |
+
<button class="chip" data-msg="Play some music">Play music</button>
|
| 89 |
+
</div>
|
| 90 |
+
</div>
|
| 91 |
+
</div>
|
| 92 |
+
</main>
|
| 93 |
+
<footer class="input-bar glass-panel">
|
| 94 |
+
<div class="input-wrapper">
|
| 95 |
+
<textarea id="message-input"
|
| 96 |
+
placeholder="Ask Jarvis anything..."
|
| 97 |
+
rows="1"
|
| 98 |
+
maxlength="32000"></textarea>
|
| 99 |
+
<div class="input-actions">
|
| 100 |
+
<button class="action-btn mic-btn" id="mic-btn" title="Voice input — click to start auto-listen (restarts after each response)">
|
| 101 |
+
<svg class="mic-icon" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
| 102 |
+
<path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2"/><line x1="12" y1="19" x2="12" y2="23"/><line x1="8" y1="23" x2="16" y2="23"/>
|
| 103 |
+
</svg>
|
| 104 |
+
<svg class="mic-icon-active" width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
|
| 105 |
+
<rect x="4" y="4" width="16" height="16" rx="3"/>
|
| 106 |
+
</svg>
|
| 107 |
+
</button>
|
| 108 |
+
<button class="action-btn tts-btn" id="tts-btn" title="Text to Speech">
|
| 109 |
+
<svg class="tts-icon-off" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
| 110 |
+
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/>
|
| 111 |
+
<line x1="23" y1="9" x2="17" y2="15"/><line x1="17" y1="9" x2="23" y2="15"/>
|
| 112 |
+
</svg>
|
| 113 |
+
<svg class="tts-icon-on" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
| 114 |
+
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/>
|
| 115 |
+
<path d="M15.54 8.46a5 5 0 0 1 0 7.07"/>
|
| 116 |
+
<path d="M19.07 4.93a10 10 0 0 1 0 14.14"/>
|
| 117 |
+
</svg>
|
| 118 |
+
</button>
|
| 119 |
+
<button class="action-btn send-btn" id="send-btn" title="Send message">
|
| 120 |
+
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
| 121 |
+
<line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/>
|
| 122 |
+
</svg>
|
| 123 |
+
</button>
|
| 124 |
+
</div>
|
| 125 |
+
</div>
|
| 126 |
+
<div class="input-meta" id="input-meta">
|
| 127 |
+
<span class="char-count" id="char-count"></span>
|
| 128 |
+
</div>
|
| 129 |
+
</footer>
|
| 130 |
+
<aside class="search-results-widget glass-panel" id="search-results-widget" aria-hidden="true">
|
| 131 |
+
<div class="search-results-header">
|
| 132 |
+
<h3 class="search-results-title">Live search</h3>
|
| 133 |
+
<button class="search-results-close" id="search-results-close" title="Close" aria-label="Close search results">
|
| 134 |
+
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
| 135 |
+
</button>
|
| 136 |
+
</div>
|
| 137 |
+
<div class="search-results-query" id="search-results-query"></div>
|
| 138 |
+
<div class="search-results-answer" id="search-results-answer"></div>
|
| 139 |
+
<div class="search-results-list" id="search-results-list"></div>
|
| 140 |
+
</aside>
|
| 141 |
+
<div class="settings-panel glass-panel" id="settings-panel" aria-hidden="true">
|
| 142 |
+
<div class="settings-header">
|
| 143 |
+
<h3 class="settings-title">Settings</h3>
|
| 144 |
+
<button class="settings-close btn-icon" id="settings-close" title="Close" aria-label="Close settings">
|
| 145 |
+
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
| 146 |
+
</button>
|
| 147 |
+
</div>
|
| 148 |
+
<div class="settings-body">
|
| 149 |
+
<div class="settings-item">
|
| 150 |
+
<label class="settings-label" for="toggle-auto-activity">Auto-open activity panel</label>
|
| 151 |
+
<label class="toggle-switch">
|
| 152 |
+
<input type="checkbox" id="toggle-auto-activity" checked>
|
| 153 |
+
<span class="toggle-slider"></span>
|
| 154 |
+
</label>
|
| 155 |
+
</div>
|
| 156 |
+
<div class="settings-item">
|
| 157 |
+
<label class="settings-label" for="toggle-auto-search">Auto-open search results</label>
|
| 158 |
+
<label class="toggle-switch">
|
| 159 |
+
<input type="checkbox" id="toggle-auto-search" checked>
|
| 160 |
+
<span class="toggle-slider"></span>
|
| 161 |
+
</label>
|
| 162 |
+
</div>
|
| 163 |
+
<div class="settings-item">
|
| 164 |
+
<label class="settings-label" for="toggle-thinking-sounds">Thinking sound effects</label>
|
| 165 |
+
<label class="toggle-switch">
|
| 166 |
+
<input type="checkbox" id="toggle-thinking-sounds" checked>
|
| 167 |
+
<span class="toggle-slider"></span>
|
| 168 |
+
</label>
|
| 169 |
+
</div>
|
| 170 |
+
<p class="settings-hint">When enabled, the activity and search panels open automatically when data is available. Thinking sounds play a short cue while the AI processes your message.</p>
|
| 171 |
+
</div>
|
| 172 |
+
</div>
|
| 173 |
+
<div class="panel-overlay" id="panel-overlay" aria-hidden="true"></div>
|
| 174 |
+
<div class="speech-widget" id="speech-widget" aria-hidden="true">
|
| 175 |
+
<div class="speech-widget-inner">
|
| 176 |
+
<span class="speech-widget-label">Listening...</span>
|
| 177 |
+
<span class="speech-widget-text" id="speech-widget-text"></span>
|
| 178 |
+
</div>
|
| 179 |
+
</div>
|
| 180 |
+
<div class="toast-container" id="toast-container" aria-live="polite"></div>
|
| 181 |
+
</div>
|
| 182 |
+
<script src="orb.js"></script>
|
| 183 |
+
<script src="script.js"></script>
|
| 184 |
+
</body>
|
| 185 |
+
</html>
|
frontend/orb.js
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
|
| 3 |
+
class OrbRenderer {
|
| 4 |
+
|
| 5 |
+
constructor(container, opts = {}) {
|
| 6 |
+
this.container = container;
|
| 7 |
+
this.hue = opts.hue ?? 0;
|
| 8 |
+
this.hoverIntensity = opts.hoverIntensity ?? 0.2;
|
| 9 |
+
this.bgColor = opts.backgroundColor ?? [0.02, 0.02, 0.06];
|
| 10 |
+
|
| 11 |
+
this.targetHover = 0;
|
| 12 |
+
this.currentHover = 0;
|
| 13 |
+
this.currentRot = 0;
|
| 14 |
+
this.lastTs = 0;
|
| 15 |
+
|
| 16 |
+
this.canvas = document.createElement('canvas');
|
| 17 |
+
this.canvas.style.width = '100%';
|
| 18 |
+
this.canvas.style.height = '100%';
|
| 19 |
+
this.container.appendChild(this.canvas);
|
| 20 |
+
|
| 21 |
+
this.gl = this.canvas.getContext('webgl', { alpha: true, premultipliedAlpha: false, antialias: false });
|
| 22 |
+
if (!this.gl) { console.warn('WebGL not available'); return; }
|
| 23 |
+
|
| 24 |
+
this._build();
|
| 25 |
+
|
| 26 |
+
this._resize();
|
| 27 |
+
|
| 28 |
+
this._onResize = this._resize.bind(this);
|
| 29 |
+
window.addEventListener('resize', this._onResize);
|
| 30 |
+
|
| 31 |
+
this._raf = requestAnimationFrame(this._loop.bind(this));
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
static VERT = `
|
| 35 |
+
precision highp float;
|
| 36 |
+
attribute vec2 position;
|
| 37 |
+
attribute vec2 uv;
|
| 38 |
+
varying vec2 vUv;
|
| 39 |
+
void main(){ vUv=uv; gl_Position=vec4(position,0.0,1.0); }`;
|
| 40 |
+
|
| 41 |
+
static FRAG = `
|
| 42 |
+
precision highp float;
|
| 43 |
+
uniform float iTime;
|
| 44 |
+
uniform vec3 iResolution;
|
| 45 |
+
uniform float hue;
|
| 46 |
+
uniform float hover;
|
| 47 |
+
uniform float rot;
|
| 48 |
+
uniform float hoverIntensity;
|
| 49 |
+
uniform vec3 backgroundColor;
|
| 50 |
+
varying vec2 vUv;
|
| 51 |
+
|
| 52 |
+
vec3 rgb2yiq(vec3 c){float y=dot(c,vec3(.299,.587,.114));float i=dot(c,vec3(.596,-.274,-.322));float q=dot(c,vec3(.211,-.523,.312));return vec3(y,i,q);}
|
| 53 |
+
vec3 yiq2rgb(vec3 c){return vec3(c.x+.956*c.y+.621*c.z,c.x-.272*c.y-.647*c.z,c.x-1.106*c.y+1.703*c.z);}
|
| 54 |
+
|
| 55 |
+
vec3 adjustHue(vec3 color,float hueDeg){float h=hueDeg*3.14159265/180.0;vec3 yiq=rgb2yiq(color);float cosA=cos(h);float sinA=sin(h);float i2=yiq.y*cosA-yiq.z*sinA;float q2=yiq.y*sinA+yiq.z*cosA;yiq.y=i2;yiq.z=q2;return yiq2rgb(yiq);}
|
| 56 |
+
|
| 57 |
+
vec3 hash33(vec3 p3){p3=fract(p3*vec3(.1031,.11369,.13787));p3+=dot(p3,p3.yxz+19.19);return -1.0+2.0*fract(vec3(p3.x+p3.y,p3.x+p3.z,p3.y+p3.z)*p3.zyx);}
|
| 58 |
+
|
| 59 |
+
float snoise3(vec3 p){const float K1=.333333333;const float K2=.166666667;vec3 i=floor(p+(p.x+p.y+p.z)*K1);vec3 d0=p-(i-(i.x+i.y+i.z)*K2);vec3 e=step(vec3(0.0),d0-d0.yzx);vec3 i1=e*(1.0-e.zxy);vec3 i2=1.0-e.zxy*(1.0-e);vec3 d1=d0-(i1-K2);vec3 d2=d0-(i2-K1);vec3 d3=d0-0.5;vec4 h=max(0.6-vec4(dot(d0,d0),dot(d1,d1),dot(d2,d2),dot(d3,d3)),0.0);vec4 n=h*h*h*h*vec4(dot(d0,hash33(i)),dot(d1,hash33(i+i1)),dot(d2,hash33(i+i2)),dot(d3,hash33(i+1.0)));return dot(vec4(31.316),n);}
|
| 60 |
+
|
| 61 |
+
vec4 extractAlpha(vec3 c){float a=max(max(c.r,c.g),c.b);return vec4(c/(a+1e-5),a);}
|
| 62 |
+
|
| 63 |
+
const vec3 baseColor1=vec3(.611765,.262745,.996078);
|
| 64 |
+
const vec3 baseColor2=vec3(.298039,.760784,.913725);
|
| 65 |
+
const vec3 baseColor3=vec3(.062745,.078431,.600000);
|
| 66 |
+
|
| 67 |
+
const float innerRadius=0.6;
|
| 68 |
+
const float noiseScale=0.65;
|
| 69 |
+
|
| 70 |
+
float light1(float i,float a,float d){return i/(1.0+d*a);}
|
| 71 |
+
float light2(float i,float a,float d){return i/(1.0+d*d*a);}
|
| 72 |
+
|
| 73 |
+
vec4 draw(vec2 uv){
|
| 74 |
+
vec3 c1=adjustHue(baseColor1,hue);vec3 c2=adjustHue(baseColor2,hue);vec3 c3=adjustHue(baseColor3,hue);
|
| 75 |
+
float ang=atan(uv.y,uv.x);float len=length(uv);float invLen=len>0.0?1.0/len:0.0;
|
| 76 |
+
float bgLum=dot(backgroundColor,vec3(.299,.587,.114));
|
| 77 |
+
float n0=snoise3(vec3(uv*noiseScale,iTime*0.5))*0.5+0.5;
|
| 78 |
+
float r0=mix(mix(innerRadius,1.0,0.4),mix(innerRadius,1.0,0.6),n0);
|
| 79 |
+
float d0=distance(uv,(r0*invLen)*uv);
|
| 80 |
+
float v0=light1(1.0,10.0,d0);
|
| 81 |
+
v0*=smoothstep(r0*1.05,r0,len);
|
| 82 |
+
float innerFade=smoothstep(r0*0.8,r0*0.95,len);
|
| 83 |
+
v0*=mix(innerFade,1.0,bgLum*0.7);
|
| 84 |
+
float cl=cos(ang+iTime*2.0)*0.5+0.5;
|
| 85 |
+
float a2=iTime*-1.0;vec2 pos=vec2(cos(a2),sin(a2))*r0;float d=distance(uv,pos);
|
| 86 |
+
float v1=light2(1.5,5.0,d);v1*=light1(1.0,50.0,d0);
|
| 87 |
+
float v2=smoothstep(1.0,mix(innerRadius,1.0,n0*0.5),len);
|
| 88 |
+
float v3=smoothstep(innerRadius,mix(innerRadius,1.0,0.5),len);
|
| 89 |
+
vec3 colBase=mix(c1,c2,cl);
|
| 90 |
+
float fadeAmt=mix(1.0,0.1,bgLum);
|
| 91 |
+
|
| 92 |
+
vec3 darkCol=mix(c3,colBase,v0);darkCol=(darkCol+v1)*v2*v3;darkCol=clamp(darkCol,0.0,1.0);
|
| 93 |
+
|
| 94 |
+
vec3 lightCol=(colBase+v1)*mix(1.0,v2*v3,fadeAmt);lightCol=mix(backgroundColor,lightCol,v0);lightCol=clamp(lightCol,0.0,1.0);
|
| 95 |
+
|
| 96 |
+
vec3 fc=mix(darkCol,lightCol,bgLum);
|
| 97 |
+
return extractAlpha(fc);
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
vec4 mainImage(vec2 fragCoord){
|
| 101 |
+
vec2 center=iResolution.xy*0.5;float sz=min(iResolution.x,iResolution.y);
|
| 102 |
+
vec2 uv=(fragCoord-center)/sz*2.0;
|
| 103 |
+
|
| 104 |
+
float s2=sin(rot);float c2=cos(rot);uv=vec2(c2*uv.x-s2*uv.y,s2*uv.x+c2*uv.y);
|
| 105 |
+
|
| 106 |
+
uv.x+=hover*hoverIntensity*0.1*sin(uv.y*10.0+iTime);
|
| 107 |
+
uv.y+=hover*hoverIntensity*0.1*sin(uv.x*10.0+iTime);
|
| 108 |
+
return draw(uv);
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
void main(){
|
| 112 |
+
vec2 fc=vUv*iResolution.xy;vec4 col=mainImage(fc);
|
| 113 |
+
gl_FragColor=vec4(col.rgb*col.a,col.a);
|
| 114 |
+
}`;
|
| 115 |
+
|
| 116 |
+
_compile(type, src) {
|
| 117 |
+
const gl = this.gl;
|
| 118 |
+
const s = gl.createShader(type);
|
| 119 |
+
gl.shaderSource(s, src);
|
| 120 |
+
gl.compileShader(s);
|
| 121 |
+
if (!gl.getShaderParameter(s, gl.COMPILE_STATUS)) {
|
| 122 |
+
console.error('Shader compile error:', gl.getShaderInfoLog(s));
|
| 123 |
+
gl.deleteShader(s);
|
| 124 |
+
return null;
|
| 125 |
+
}
|
| 126 |
+
return s;
|
| 127 |
+
}
|
| 128 |
+
|
| 129 |
+
_build() {
|
| 130 |
+
const gl = this.gl;
|
| 131 |
+
const vs = this._compile(gl.VERTEX_SHADER, OrbRenderer.VERT);
|
| 132 |
+
const fs = this._compile(gl.FRAGMENT_SHADER, OrbRenderer.FRAG);
|
| 133 |
+
if (!vs || !fs) return;
|
| 134 |
+
|
| 135 |
+
this.pgm = gl.createProgram();
|
| 136 |
+
gl.attachShader(this.pgm, vs);
|
| 137 |
+
gl.attachShader(this.pgm, fs);
|
| 138 |
+
gl.linkProgram(this.pgm);
|
| 139 |
+
if (!gl.getProgramParameter(this.pgm, gl.LINK_STATUS)) {
|
| 140 |
+
console.error('Program link error:', gl.getProgramInfoLog(this.pgm));
|
| 141 |
+
return;
|
| 142 |
+
}
|
| 143 |
+
gl.useProgram(this.pgm);
|
| 144 |
+
|
| 145 |
+
const posLoc = gl.getAttribLocation(this.pgm, 'position');
|
| 146 |
+
const uvLoc = gl.getAttribLocation(this.pgm, 'uv');
|
| 147 |
+
|
| 148 |
+
const posBuf = gl.createBuffer();
|
| 149 |
+
gl.bindBuffer(gl.ARRAY_BUFFER, posBuf);
|
| 150 |
+
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([-1,-1, 3,-1, -1,3]), gl.STATIC_DRAW);
|
| 151 |
+
gl.enableVertexAttribArray(posLoc);
|
| 152 |
+
gl.vertexAttribPointer(posLoc, 2, gl.FLOAT, false, 0, 0);
|
| 153 |
+
|
| 154 |
+
const uvBuf = gl.createBuffer();
|
| 155 |
+
gl.bindBuffer(gl.ARRAY_BUFFER, uvBuf);
|
| 156 |
+
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array([0,0, 2,0, 0,2]), gl.STATIC_DRAW);
|
| 157 |
+
gl.enableVertexAttribArray(uvLoc);
|
| 158 |
+
gl.vertexAttribPointer(uvLoc, 2, gl.FLOAT, false, 0, 0);
|
| 159 |
+
|
| 160 |
+
this.u = {};
|
| 161 |
+
['iTime','iResolution','hue','hover','rot','hoverIntensity','backgroundColor'].forEach(name => {
|
| 162 |
+
this.u[name] = gl.getUniformLocation(this.pgm, name);
|
| 163 |
+
});
|
| 164 |
+
|
| 165 |
+
gl.enable(gl.BLEND);
|
| 166 |
+
gl.blendFunc(gl.SRC_ALPHA, gl.ONE_MINUS_SRC_ALPHA);
|
| 167 |
+
gl.clearColor(0,0,0,0);
|
| 168 |
+
}
|
| 169 |
+
|
| 170 |
+
_resize() {
|
| 171 |
+
const dpr = window.devicePixelRatio || 1;
|
| 172 |
+
const w = this.container.clientWidth;
|
| 173 |
+
const h = this.container.clientHeight;
|
| 174 |
+
this.canvas.width = w * dpr;
|
| 175 |
+
this.canvas.height = h * dpr;
|
| 176 |
+
if (this.gl) this.gl.viewport(0, 0, this.canvas.width, this.canvas.height);
|
| 177 |
+
}
|
| 178 |
+
|
| 179 |
+
_loop(ts) {
|
| 180 |
+
this._raf = requestAnimationFrame(this._loop.bind(this));
|
| 181 |
+
if (!this.pgm) return;
|
| 182 |
+
const gl = this.gl;
|
| 183 |
+
const t = ts * 0.001;
|
| 184 |
+
const dt = this.lastTs ? t - this.lastTs : 0.016;
|
| 185 |
+
this.lastTs = t;
|
| 186 |
+
|
| 187 |
+
this.currentHover += (this.targetHover - this.currentHover) * Math.min(dt * 4, 1);
|
| 188 |
+
|
| 189 |
+
if (this.currentHover > 0.5) this.currentRot += dt * 0.3;
|
| 190 |
+
|
| 191 |
+
gl.clear(gl.COLOR_BUFFER_BIT);
|
| 192 |
+
gl.useProgram(this.pgm);
|
| 193 |
+
gl.uniform1f(this.u.iTime, t);
|
| 194 |
+
gl.uniform3f(this.u.iResolution, this.canvas.width, this.canvas.height, this.canvas.width / this.canvas.height);
|
| 195 |
+
gl.uniform1f(this.u.hue, this.hue);
|
| 196 |
+
gl.uniform1f(this.u.hover, this.currentHover);
|
| 197 |
+
gl.uniform1f(this.u.rot, this.currentRot);
|
| 198 |
+
gl.uniform1f(this.u.hoverIntensity, this.hoverIntensity);
|
| 199 |
+
gl.uniform3f(this.u.backgroundColor, this.bgColor[0], this.bgColor[1], this.bgColor[2]);
|
| 200 |
+
gl.drawArrays(gl.TRIANGLES, 0, 3);
|
| 201 |
+
}
|
| 202 |
+
|
| 203 |
+
setActive(active) {
|
| 204 |
+
this.targetHover = active ? 1.0 : 0.0;
|
| 205 |
+
const ctn = this.container;
|
| 206 |
+
if (active) ctn.classList.add('active');
|
| 207 |
+
else ctn.classList.remove('active');
|
| 208 |
+
}
|
| 209 |
+
|
| 210 |
+
destroy() {
|
| 211 |
+
cancelAnimationFrame(this._raf);
|
| 212 |
+
window.removeEventListener('resize', this._onResize);
|
| 213 |
+
if (this.canvas.parentNode) this.canvas.parentNode.removeChild(this.canvas);
|
| 214 |
+
const ext = this.gl.getExtension('WEBGL_lose_context');
|
| 215 |
+
if (ext) ext.loseContext();
|
| 216 |
+
}
|
| 217 |
+
}
|
frontend/script.js
ADDED
|
@@ -0,0 +1,1646 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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"
|
| 37 |
+
clears the session.
|
| 38 |
+
|
| 39 |
+
DATA FLOW (simplified):
|
| 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 |
+
/*
|
| 47 |
+
* API — The base URL for all backend requests.
|
| 48 |
+
*
|
| 49 |
+
* In production, this resolves to the same origin the page was loaded from
|
| 50 |
+
* (e.g., "https://jarvis.example.com"). During local development, it falls
|
| 51 |
+
* back to "http://localhost:8000" (the default FastAPI dev server port).
|
| 52 |
+
*
|
| 53 |
+
* `window.location.origin` gives us the protocol + host + port of the
|
| 54 |
+
* current page, making the frontend deployment-agnostic (no hardcoded URLs).
|
| 55 |
+
*/
|
| 56 |
+
const API = (typeof window !== 'undefined' && window.location.origin)
|
| 57 |
+
? window.location.origin
|
| 58 |
+
: 'http://localhost:8000';
|
| 59 |
+
|
| 60 |
+
/* ================================================================
|
| 61 |
+
APPLICATION STATE
|
| 62 |
+
================================================================
|
| 63 |
+
These variables track the global state of the application. They are
|
| 64 |
+
intentionally kept as simple top-level variables rather than in a
|
| 65 |
+
class or store, since this is a single-page app with one chat view.
|
| 66 |
+
================================================================ */
|
| 67 |
+
|
| 68 |
+
/*
|
| 69 |
+
* sessionId — Unique conversation identifier returned by the server.
|
| 70 |
+
* Starts as null (no conversation yet). Once the first server response
|
| 71 |
+
* arrives, it contains a UUID string that we send back with every
|
| 72 |
+
* subsequent message so the backend knows which conversation we're in.
|
| 73 |
+
*/
|
| 74 |
+
let sessionId = null;
|
| 75 |
+
|
| 76 |
+
/*
|
| 77 |
+
* currentMode — Which AI pipeline to use: 'jarvis', 'general', or 'realtime'.
|
| 78 |
+
* - jarvis: Unified route; brain classifies, then routes to general or realtime.
|
| 79 |
+
* - general: Direct /chat/stream (no web search).
|
| 80 |
+
* - realtime: Direct /chat/realtime/stream (with Tavily web search).
|
| 81 |
+
*/
|
| 82 |
+
let currentMode = 'jarvis';
|
| 83 |
+
|
| 84 |
+
/*
|
| 85 |
+
* isStreaming — Guard flag that is true while an SSE response is being
|
| 86 |
+
* received. Prevents the user from sending another message while the
|
| 87 |
+
* assistant is still replying (avoids race conditions and garbled output).
|
| 88 |
+
*/
|
| 89 |
+
let isStreaming = false;
|
| 90 |
+
|
| 91 |
+
/*
|
| 92 |
+
* isListening — True while the speech recognition engine is actively
|
| 93 |
+
* capturing audio from the microphone. Used to toggle the mic button
|
| 94 |
+
* styling and to decide whether to start or stop listening on click.
|
| 95 |
+
*/
|
| 96 |
+
let isListening = false;
|
| 97 |
+
|
| 98 |
+
/*
|
| 99 |
+
* autoListenMode — When true, mic stays "on": after each voice-sent message,
|
| 100 |
+
* we stop listening during the AI response, then auto-restart when the AI
|
| 101 |
+
* and TTS playback are complete. User clicks mic again to turn off.
|
| 102 |
+
*/
|
| 103 |
+
let autoListenMode = false;
|
| 104 |
+
|
| 105 |
+
/* Speech recognition config */
|
| 106 |
+
const SPEECH_ERROR_MAX_RETRIES = 3;
|
| 107 |
+
let speechErrorRetryCount = 0;
|
| 108 |
+
const SPEECH_SEND_DELAY_MS = 500; /* Pause after final transcript before sending (lets user add more) */
|
| 109 |
+
const SPEECH_RESTART_DELAY_MS = 700; /* Delay before restarting mic after AI+TTS complete (avoids echo) */
|
| 110 |
+
let speechSendTimeout = null;
|
| 111 |
+
let pendingSendTranscript = null;
|
| 112 |
+
let safariVoiceHintShown = false;
|
| 113 |
+
|
| 114 |
+
/*
|
| 115 |
+
* orb — Reference to the OrbRenderer instance (the animated WebGL orb).
|
| 116 |
+
* Null if OrbRenderer is unavailable or failed to initialize.
|
| 117 |
+
* We call orb.setActive(true/false) to animate it during TTS playback.
|
| 118 |
+
*/
|
| 119 |
+
let orb = null;
|
| 120 |
+
|
| 121 |
+
/*
|
| 122 |
+
* recognition — The SpeechRecognition instance from the Web Speech API.
|
| 123 |
+
* Null if the browser doesn't support speech recognition.
|
| 124 |
+
*/
|
| 125 |
+
let recognition = null;
|
| 126 |
+
|
| 127 |
+
/*
|
| 128 |
+
* ttsPlayer — Instance of the TTSPlayer class (defined below) that
|
| 129 |
+
* manages queuing and playing audio segments received from the server.
|
| 130 |
+
*/
|
| 131 |
+
let ttsPlayer = null;
|
| 132 |
+
|
| 133 |
+
/*
|
| 134 |
+
* settings — User preferences (auto-open panels). Stored in localStorage.
|
| 135 |
+
*/
|
| 136 |
+
const SETTINGS_KEY = 'jarvis_settings';
|
| 137 |
+
const DEFAULT_SETTINGS = { autoOpenActivity: true, autoOpenSearchResults: true, thinkingSounds: true };
|
| 138 |
+
|
| 139 |
+
/* Pre-starter: pre-generated MP3 file names (from app.generate_thinking_audio) */
|
| 140 |
+
const PRE_STARTER_FILES = ['starter_1', 'starter_2', 'starter_3', 'starter_4', 'starter_5', 'starter_6', 'starter_7', 'starter_8', 'starter_9', 'starter_10'];
|
| 141 |
+
/* Pre-loaded base64 audio cache — populated at init for instant playback */
|
| 142 |
+
let PRE_STARTER_CACHE = {};
|
| 143 |
+
let settings = { ...DEFAULT_SETTINGS };
|
| 144 |
+
|
| 145 |
+
/* ================================================================
|
| 146 |
+
DOM REFERENCES
|
| 147 |
+
================================================================
|
| 148 |
+
We grab references to frequently-used DOM elements once at startup
|
| 149 |
+
rather than querying for them every time we need them. This is both
|
| 150 |
+
a performance optimization and a readability convenience.
|
| 151 |
+
================================================================ */
|
| 152 |
+
|
| 153 |
+
/*
|
| 154 |
+
* $ — Shorthand helper for document.getElementById. Writing $('foo')
|
| 155 |
+
* is more concise than document.getElementById('foo').
|
| 156 |
+
*/
|
| 157 |
+
const $ = id => document.getElementById(id);
|
| 158 |
+
|
| 159 |
+
const chatMessages = $('chat-messages'); // The scrollable container that holds all chat messages
|
| 160 |
+
const messageInput = $('message-input'); // The <textarea> where the user types their message
|
| 161 |
+
const sendBtn = $('send-btn'); // The send button (arrow icon)
|
| 162 |
+
const micBtn = $('mic-btn'); // The microphone button for speech-to-text
|
| 163 |
+
const ttsBtn = $('tts-btn'); // The speaker button to toggle text-to-speech
|
| 164 |
+
const newChatBtn = $('new-chat-btn'); // The "New Chat" button that resets the conversation
|
| 165 |
+
const charCount = $('char-count'); // Shows character count when the message gets long
|
| 166 |
+
const welcomeTitle = $('welcome-title'); // The greeting text on the welcome screen ("Good morning.", etc.)
|
| 167 |
+
const modeSlider = $('mode-slider'); // The sliding pill indicator behind the mode toggle buttons
|
| 168 |
+
const btnJarvis = $('btn-jarvis'); // The "Jarvis" mode button (unified, brain-routed)
|
| 169 |
+
const btnGeneral = $('btn-general'); // The "General" mode button
|
| 170 |
+
const btnRealtime = $('btn-realtime'); // The "Realtime" mode button
|
| 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
|
| 190 |
+
const toggleAutoActivity = $('toggle-auto-activity'); // Auto-open activity panel
|
| 191 |
+
const toggleAutoSearch = $('toggle-auto-search'); // Auto-open search results
|
| 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 |
+
================================================================
|
| 198 |
+
Plays one random pre-generated clip ("Oh wait.", etc.) on its own
|
| 199 |
+
audio element. Runs independently so ttsPlayer.reset() cannot stop it.
|
| 200 |
+
Flow: pre-starter plays; main TTS plays when first real chunk arrives.
|
| 201 |
+
================================================================ */
|
| 202 |
+
class PreStarterPlayer {
|
| 203 |
+
constructor() {
|
| 204 |
+
this.audio = document.createElement('audio');
|
| 205 |
+
this.audio.preload = 'auto';
|
| 206 |
+
}
|
| 207 |
+
|
| 208 |
+
play(onComplete) {
|
| 209 |
+
const loaded = PRE_STARTER_FILES.filter(f => PRE_STARTER_CACHE[f]);
|
| 210 |
+
if (loaded.length === 0) {
|
| 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;
|
| 225 |
+
fired = true;
|
| 226 |
+
this.audio.onended = null;
|
| 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.
|
| 246 |
+
Instead, it sends many small base64-encoded MP3 *chunks* as part of
|
| 247 |
+
the SSE stream (one chunk per sentence or phrase). This approach has
|
| 248 |
+
two advantages:
|
| 249 |
+
1. Audio starts playing before the full response is generated
|
| 250 |
+
(lower latency — the user hears the first sentence immediately).
|
| 251 |
+
2. Each chunk is small, so there's no long download wait.
|
| 252 |
+
|
| 253 |
+
The TTSPlayer works like a conveyor belt:
|
| 254 |
+
- enqueue() adds a new audio chunk to the end of the queue.
|
| 255 |
+
- _playLoop() picks up chunks one by one and plays them.
|
| 256 |
+
- When a chunk finishes playing (audio.onended), the loop moves
|
| 257 |
+
to the next chunk.
|
| 258 |
+
- When the queue is empty and no more chunks are arriving, playback
|
| 259 |
+
stops and the orb goes back to idle.
|
| 260 |
+
|
| 261 |
+
WHY A SINGLE <audio> ELEMENT?
|
| 262 |
+
iOS Safari has strict autoplay policies — it only allows audio
|
| 263 |
+
playback from a user-initiated event. By reusing one <audio> element
|
| 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 = [];
|
| 283 |
+
this.playing = false;
|
| 284 |
+
this.enabled = true; // TTS on by default
|
| 285 |
+
this.stopped = false;
|
| 286 |
+
this.audio = document.createElement('audio');
|
| 287 |
+
this.audio.preload = 'auto';
|
| 288 |
+
}
|
| 289 |
+
|
| 290 |
+
/**
|
| 291 |
+
* unlock() — "Warms up" the audio element so browsers (especially iOS
|
| 292 |
+
* Safari) allow subsequent programmatic playback.
|
| 293 |
+
*
|
| 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
|
| 305 |
+
* callback (like our SSE stream handler).
|
| 306 |
+
*/
|
| 307 |
+
unlock() {
|
| 308 |
+
// A minimal valid WAV file (44-byte header + 2 bytes of silence)
|
| 309 |
+
const silentWav = 'data:audio/wav;base64,UklGRigAAABXQVZFZm10IBIAAAABAAEARKwAAIhYAQACABAAAABkYXRhAgAAAAEA';
|
| 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)();
|
| 316 |
+
const g = ctx.createGain();
|
| 317 |
+
g.gain.value = 0;
|
| 318 |
+
const o = ctx.createOscillator();
|
| 319 |
+
o.connect(g);
|
| 320 |
+
g.connect(ctx.destination);
|
| 321 |
+
o.start(0);
|
| 322 |
+
o.stop(ctx.currentTime + 0.001);
|
| 323 |
+
setTimeout(() => ctx.close(), 200);
|
| 324 |
+
} catch (_) {}
|
| 325 |
+
}
|
| 326 |
+
|
| 327 |
+
/**
|
| 328 |
+
* enqueue(base64Audio) — Adds a base64-encoded MP3 chunk to the
|
| 329 |
+
* playback queue.
|
| 330 |
+
*
|
| 331 |
+
* @param {string} base64Audio - The base64 string of the MP3 audio data.
|
| 332 |
+
*
|
| 333 |
+
* If TTS is disabled or playback has been force-stopped, the chunk
|
| 334 |
+
* is silently discarded. Otherwise it's pushed onto the queue.
|
| 335 |
+
* If the play loop isn't already running, we kick it off.
|
| 336 |
+
*/
|
| 337 |
+
enqueue(base64Audio) {
|
| 338 |
+
if (!this.enabled || this.stopped) return;
|
| 339 |
+
this.queue.push(base64Audio);
|
| 340 |
+
if (!this.playing) this._playLoop();
|
| 341 |
+
}
|
| 342 |
+
|
| 343 |
+
/**
|
| 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).
|
| 353 |
+
*/
|
| 354 |
+
stop() {
|
| 355 |
+
this.stopped = true;
|
| 356 |
+
this.audio.pause();
|
| 357 |
+
this.audio.removeAttribute('src');
|
| 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 |
+
|
| 367 |
+
/**
|
| 368 |
+
* reset() — Stops playback AND clears the "stopped" flag so new
|
| 369 |
+
* audio can be enqueued again.
|
| 370 |
+
*
|
| 371 |
+
* Called at the beginning of each new message send, or when the main
|
| 372 |
+
* response arrives (to cut off thinking audio). Increments _loopId so
|
| 373 |
+
* any in-flight _playLoop exits immediately.
|
| 374 |
+
*/
|
| 375 |
+
reset() {
|
| 376 |
+
this.stop();
|
| 377 |
+
this.stopped = false;
|
| 378 |
+
this._loopId = (this._loopId || 0) + 1; // Supersede in-flight play loop
|
| 379 |
+
}
|
| 380 |
+
|
| 381 |
+
/**
|
| 382 |
+
* _playLoop() — The internal playback engine. Processes the queue
|
| 383 |
+
* one chunk at a time in a while-loop.
|
| 384 |
+
*
|
| 385 |
+
* WHY THE LOOP ID (_loopId)?
|
| 386 |
+
* If stop() is called and then a new stream starts, there could be
|
| 387 |
+
* two concurrent _playLoop() calls — the old one (still awaiting a
|
| 388 |
+
* Promise) and the new one. The loop ID lets us detect when a loop
|
| 389 |
+
* has been superseded: each invocation gets a unique ID, and if the
|
| 390 |
+
* ID changes mid-loop (because a new loop started), the old loop
|
| 391 |
+
* exits gracefully. This prevents double-playback or stale loops.
|
| 392 |
+
*
|
| 393 |
+
* VISUAL INDICATORS:
|
| 394 |
+
* While playing, we add CSS classes 'tts-speaking' (to the button)
|
| 395 |
+
* and 'speaking' (to the orb container) for visual feedback. These
|
| 396 |
+
* are removed when the queue is drained or playback is stopped.
|
| 397 |
+
*/
|
| 398 |
+
async _playLoop() {
|
| 399 |
+
if (this.playing) return;
|
| 400 |
+
this.playing = true;
|
| 401 |
+
this._loopId = (this._loopId || 0) + 1;
|
| 402 |
+
const myId = this._loopId;
|
| 403 |
+
|
| 404 |
+
// Activate visual indicators: button glow + orb animation
|
| 405 |
+
if (ttsBtn) ttsBtn.classList.add('tts-speaking');
|
| 406 |
+
if (orbContainer) orbContainer.classList.add('speaking');
|
| 407 |
+
if (orb) orb.setActive(true);
|
| 408 |
+
|
| 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
|
| 415 |
+
} catch (e) {
|
| 416 |
+
console.warn('TTS segment error:', e);
|
| 417 |
+
}
|
| 418 |
+
}
|
| 419 |
+
|
| 420 |
+
// If another loop took over, don't touch the shared state
|
| 421 |
+
if (myId !== this._loopId) {
|
| 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 |
+
}
|
| 433 |
+
|
| 434 |
+
/**
|
| 435 |
+
* _playB64(b64) — Plays a single base64-encoded MP3 chunk.
|
| 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
|
| 443 |
+
* _playLoop() can await it and move to the next chunk.
|
| 444 |
+
*/
|
| 445 |
+
_playB64(b64) {
|
| 446 |
+
return new Promise(resolve => {
|
| 447 |
+
this.audio.src = 'data:audio/mp3;base64,' + b64;
|
| 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 |
+
================================================================
|
| 460 |
+
init() is the entry point for the entire application. It is called
|
| 461 |
+
once when the DOM is fully loaded (see the DOMContentLoaded listener
|
| 462 |
+
at the bottom of this file).
|
| 463 |
+
|
| 464 |
+
It sets up every subsystem in the correct order:
|
| 465 |
+
1. TTSPlayer — so audio is ready before any messages
|
| 466 |
+
2. Greeting — display a time-appropriate welcome message
|
| 467 |
+
3. Orb — initialize the WebGL visual
|
| 468 |
+
4. Speech — set up the microphone / speech recognition
|
| 469 |
+
5. Health — ping the backend to check if it's online
|
| 470 |
+
6. Events — wire up all button clicks and keyboard shortcuts
|
| 471 |
+
7. Input — auto-resize the textarea to fit content
|
| 472 |
+
================================================================ */
|
| 473 |
+
function init() {
|
| 474 |
+
if (!chatMessages || !messageInput) {
|
| 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
|
| 490 |
+
autoResizeInput();
|
| 491 |
+
}
|
| 492 |
+
|
| 493 |
+
/**
|
| 494 |
+
* preloadStarterAudio() — Fetches pre-generated starter MP3s and caches as base64.
|
| 495 |
+
* Enables instant playback when user sends (no network delay).
|
| 496 |
+
*/
|
| 497 |
+
async function preloadStarterAudio() {
|
| 498 |
+
const base = (typeof window !== 'undefined' && window.location.origin) ? window.location.origin : '';
|
| 499 |
+
for (const file of PRE_STARTER_FILES) {
|
| 500 |
+
try {
|
| 501 |
+
const r = await fetch(`${base}/app/audio/${file}.mp3`);
|
| 502 |
+
if (!r.ok) continue;
|
| 503 |
+
const blob = await r.blob();
|
| 504 |
+
const base64 = await new Promise((resolve, reject) => {
|
| 505 |
+
const reader = new FileReader();
|
| 506 |
+
reader.onloadend = () => resolve((reader.result || '').split(',')[1] || '');
|
| 507 |
+
reader.onerror = reject;
|
| 508 |
+
reader.readAsDataURL(blob);
|
| 509 |
+
});
|
| 510 |
+
if (base64) PRE_STARTER_CACHE[file] = base64;
|
| 511 |
+
} catch (_) {}
|
| 512 |
+
}
|
| 513 |
+
}
|
| 514 |
+
|
| 515 |
+
function loadSettings() {
|
| 516 |
+
try {
|
| 517 |
+
const s = localStorage.getItem(SETTINGS_KEY);
|
| 518 |
+
if (s) {
|
| 519 |
+
const parsed = JSON.parse(s);
|
| 520 |
+
settings = { ...DEFAULT_SETTINGS, ...parsed };
|
| 521 |
+
}
|
| 522 |
+
if (toggleAutoActivity) toggleAutoActivity.checked = settings.autoOpenActivity;
|
| 523 |
+
if (toggleAutoSearch) toggleAutoSearch.checked = settings.autoOpenSearchResults;
|
| 524 |
+
if (toggleThinkingSounds) toggleThinkingSounds.checked = settings.thinkingSounds;
|
| 525 |
+
} catch (_) {}
|
| 526 |
+
}
|
| 527 |
+
|
| 528 |
+
function saveSettings() {
|
| 529 |
+
try {
|
| 530 |
+
localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings));
|
| 531 |
+
} catch (_) {}
|
| 532 |
+
}
|
| 533 |
+
|
| 534 |
+
/* ================================================================
|
| 535 |
+
GREETING
|
| 536 |
+
================================================================ */
|
| 537 |
+
|
| 538 |
+
/**
|
| 539 |
+
* setGreeting() — Sets the welcome screen title based on the current
|
| 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 |
+
*/
|
| 550 |
+
function setGreeting() {
|
| 551 |
+
const h = new Date().getHours();
|
| 552 |
+
let g = 'Good evening.';
|
| 553 |
+
if (h < 12) g = 'Good morning.';
|
| 554 |
+
else if (h < 17) g = 'Good afternoon.';
|
| 555 |
+
else if (h >= 22) g = 'Burning the midnight oil?';
|
| 556 |
+
if (welcomeTitle) welcomeTitle.textContent = g;
|
| 557 |
+
}
|
| 558 |
+
|
| 559 |
+
/* ================================================================
|
| 560 |
+
WEBGL ORB INITIALIZATION
|
| 561 |
+
================================================================ */
|
| 562 |
+
|
| 563 |
+
/**
|
| 564 |
+
* initOrb() — Creates the animated WebGL orb inside the orbContainer.
|
| 565 |
+
*
|
| 566 |
+
* OrbRenderer is defined in a separate JS file (orb.js). If that file
|
| 567 |
+
* hasn't loaded (e.g., network error), OrbRenderer will be undefined
|
| 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.
|
| 577 |
+
*/
|
| 578 |
+
function initOrb() {
|
| 579 |
+
if (typeof OrbRenderer === 'undefined') return;
|
| 580 |
+
try {
|
| 581 |
+
orb = new OrbRenderer(orbContainer, {
|
| 582 |
+
hue: 0,
|
| 583 |
+
hoverIntensity: 0.3,
|
| 584 |
+
backgroundColor: [0.02, 0.02, 0.06]
|
| 585 |
+
});
|
| 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:
|
| 596 |
+
1. Work reliably on every PC (Chrome, Edge, etc.)
|
| 597 |
+
2. Accurate transcription — no duplication or concatenation bugs
|
| 598 |
+
3. Auto-restart after AI finishes speaking (stream + TTS complete)
|
| 599 |
+
4. Single utterance per session — clean, predictable behavior
|
| 600 |
+
|
| 601 |
+
Flow:
|
| 602 |
+
- User clicks mic → startListening() → recognition.start()
|
| 603 |
+
- User speaks → interim results shown in real time
|
| 604 |
+
- User pauses → final result → brief delay → send message → stopListening()
|
| 605 |
+
- AI responds (stream + TTS) → when TTS queue empty → maybeRestartListening()
|
| 606 |
+
- After SPEECH_RESTART_DELAY_MS → startListening() again
|
| 607 |
+
|
| 608 |
+
Chrome sends INCREMENTAL results (each extends the previous). We use
|
| 609 |
+
ONLY the last result to avoid "hello Ja hello jar..." duplication.
|
| 610 |
+
================================================================ */
|
| 611 |
+
|
| 612 |
+
/** Detect Safari/iOS — needs different settings for stability */
|
| 613 |
+
function isSafariOrIOS() {
|
| 614 |
+
if (typeof navigator === 'undefined') return false;
|
| 615 |
+
const ua = navigator.userAgent || '';
|
| 616 |
+
return /iPad|iPhone|iPod/.test(ua) ||
|
| 617 |
+
(navigator.vendor && navigator.vendor.indexOf('Apple') > -1) ||
|
| 618 |
+
(/Safari/.test(ua) && !/Chrome|Chromium|CriOS/.test(ua));
|
| 619 |
+
}
|
| 620 |
+
|
| 621 |
+
/**
|
| 622 |
+
* initSpeech() — Sets up SpeechRecognition with PC-optimized settings.
|
| 623 |
+
* Uses single-utterance mode (continuous: false) for clean, accurate results.
|
| 624 |
+
*/
|
| 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. */
|
| 632 |
+
/* Safari: no interim (unstable), single utterance. */
|
| 633 |
+
const safariMode = isSafariOrIOS();
|
| 634 |
+
recognition.continuous = false;
|
| 635 |
+
recognition.interimResults = !safariMode;
|
| 636 |
+
recognition.maxAlternatives = 1;
|
| 637 |
+
recognition.lang = 'en-US';
|
| 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() : '';
|
| 644 |
+
const isFinal = last && last.isFinal;
|
| 645 |
+
|
| 646 |
+
if (speechWidgetText) speechWidgetText.textContent = transcript;
|
| 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);
|
| 663 |
+
speechSendTimeout = null;
|
| 664 |
+
}
|
| 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);
|
| 680 |
+
} else if (speechErrorRetryCount >= SPEECH_ERROR_MAX_RETRIES && micBtn) {
|
| 681 |
+
micBtn.title = 'Voice input — click to try again';
|
| 682 |
+
}
|
| 683 |
+
};
|
| 684 |
+
|
| 685 |
+
recognition.onend = () => {
|
| 686 |
+
if (pendingSendTranscript) {
|
| 687 |
+
clearTimeout(speechSendTimeout);
|
| 688 |
+
speechSendTimeout = null;
|
| 689 |
+
sendMessage(pendingSendTranscript);
|
| 690 |
+
pendingSendTranscript = null;
|
| 691 |
+
} else {
|
| 692 |
+
clearTimeout(speechSendTimeout);
|
| 693 |
+
speechSendTimeout = null;
|
| 694 |
+
}
|
| 695 |
+
if (isListening) stopListening();
|
| 696 |
+
maybeRestartListening();
|
| 697 |
+
};
|
| 698 |
+
}
|
| 699 |
+
|
| 700 |
+
/**
|
| 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) {
|
| 724 |
+
isListening = false;
|
| 725 |
+
if (micBtn) micBtn.classList.remove('listening');
|
| 726 |
+
if (speechWidget) speechWidget.classList.remove('visible');
|
| 727 |
+
if (isSafariOrIOS()) showToast('Tap the mic to continue voice input.');
|
| 728 |
+
}
|
| 729 |
+
}
|
| 730 |
+
|
| 731 |
+
/**
|
| 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);
|
| 742 |
+
speechSendTimeout = null;
|
| 743 |
+
pendingSendTranscript = null;
|
| 744 |
+
isListening = false;
|
| 745 |
+
if (micBtn) micBtn.classList.remove('listening'); // Remove visual highlight
|
| 746 |
+
if (speechWidget) speechWidget.classList.remove('visible');
|
| 747 |
+
if (speechWidgetText) speechWidgetText.textContent = '';
|
| 748 |
+
try { recognition.stop(); } catch (_) {}
|
| 749 |
+
}
|
| 750 |
+
|
| 751 |
+
/**
|
| 752 |
+
* maybeRestartListening() — If autoListenMode is on and the AI response
|
| 753 |
+
* (stream + TTS) is fully complete, restart listening after a short delay.
|
| 754 |
+
* Called from: sendMessage finally block, TTSPlayer.onPlaybackComplete.
|
| 755 |
+
*/
|
| 756 |
+
function maybeRestartListening() {
|
| 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();
|
| 763 |
+
}
|
| 764 |
+
}, SPEECH_RESTART_DELAY_MS);
|
| 765 |
+
}
|
| 766 |
+
|
| 767 |
+
/* ================================================================
|
| 768 |
+
BACKEND HEALTH CHECK
|
| 769 |
+
================================================================ */
|
| 770 |
+
|
| 771 |
+
/**
|
| 772 |
+
* checkHealth() — Pings the backend's /health endpoint to determine
|
| 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.
|
| 781 |
+
*/
|
| 782 |
+
async function checkHealth() {
|
| 783 |
+
try {
|
| 784 |
+
const controller = new AbortController();
|
| 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) {
|
| 793 |
+
if (statusDot) statusDot.classList.add('offline');
|
| 794 |
+
if (statusText) statusText.textContent = 'Offline';
|
| 795 |
+
if (typeof console !== 'undefined' && console.warn) console.warn('[Health] Check failed:', e);
|
| 796 |
+
}
|
| 797 |
+
}
|
| 798 |
+
|
| 799 |
+
/**
|
| 800 |
+
* showToast(msg, durationMs) — Brief feedback for errors/status.
|
| 801 |
+
* Auto-dismisses after durationMs (default 5000).
|
| 802 |
+
*/
|
| 803 |
+
function showToast(msg, durationMs = 5000) {
|
| 804 |
+
if (!toastContainer || !msg) return;
|
| 805 |
+
const el = document.createElement('div');
|
| 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 |
+
================================================================
|
| 821 |
+
All user-interaction event listeners are centralized here for
|
| 822 |
+
clarity. This function is called once during init().
|
| 823 |
+
================================================================ */
|
| 824 |
+
|
| 825 |
+
/**
|
| 826 |
+
* bindEvents() — Wires up all click, keydown, and input event
|
| 827 |
+
* listeners for the UI.
|
| 828 |
+
*/
|
| 829 |
+
function bindEvents() {
|
| 830 |
+
// SEND BUTTON — Send the message when clicked (if not already streaming)
|
| 831 |
+
if (sendBtn) sendBtn.addEventListener('click', () => { if (!isStreaming) sendMessage(); });
|
| 832 |
+
|
| 833 |
+
// ENTER KEY — Send on Enter (but allow Shift+Enter for new lines)
|
| 834 |
+
if (messageInput) messageInput.addEventListener('keydown', e => {
|
| 835 |
+
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); if (!isStreaming) sendMessage(); }
|
| 836 |
+
});
|
| 837 |
+
|
| 838 |
+
// INPUT CHANGE — Auto-resize the textarea and show character count for long messages
|
| 839 |
+
if (messageInput) messageInput.addEventListener('input', () => {
|
| 840 |
+
autoResizeInput();
|
| 841 |
+
const len = messageInput.value.length;
|
| 842 |
+
if (charCount) charCount.textContent = len > 100 ? `${len.toLocaleString()} / 32,000` : '';
|
| 843 |
+
});
|
| 844 |
+
|
| 845 |
+
// MIC BUTTON — Toggle speech recognition. When ON: auto mode — listen, stop on send, restart after AI+TTS done.
|
| 846 |
+
if (micBtn) micBtn.addEventListener('click', () => {
|
| 847 |
+
if (isListening) {
|
| 848 |
+
autoListenMode = false;
|
| 849 |
+
stopListening();
|
| 850 |
+
if (micBtn) micBtn.classList.remove('auto-listen');
|
| 851 |
+
} else {
|
| 852 |
+
autoListenMode = true;
|
| 853 |
+
speechErrorRetryCount = 0; // Reset retry count on fresh start
|
| 854 |
+
if (micBtn) {
|
| 855 |
+
micBtn.classList.add('auto-listen');
|
| 856 |
+
micBtn.title = 'Voice input — click to stop auto-listen';
|
| 857 |
+
}
|
| 858 |
+
startListening();
|
| 859 |
+
}
|
| 860 |
+
});
|
| 861 |
+
|
| 862 |
+
// TTS BUTTON — Toggle text-to-speech on/off
|
| 863 |
+
if (ttsBtn) ttsBtn.addEventListener('click', () => {
|
| 864 |
+
if (ttsPlayer) ttsPlayer.enabled = !ttsPlayer.enabled;
|
| 865 |
+
ttsBtn.classList.toggle('tts-active', ttsPlayer && ttsPlayer.enabled);
|
| 866 |
+
if (ttsPlayer && !ttsPlayer.enabled) ttsPlayer.stop();
|
| 867 |
+
});
|
| 868 |
+
|
| 869 |
+
// NEW CHAT BUTTON — Reset the conversation
|
| 870 |
+
if (newChatBtn) newChatBtn.addEventListener('click', newChat);
|
| 871 |
+
|
| 872 |
+
// MODE TOGGLE BUTTONS — Switch between Jarvis, General, and Realtime modes
|
| 873 |
+
if (btnJarvis) btnJarvis.addEventListener('click', () => setMode('jarvis'));
|
| 874 |
+
if (btnGeneral) btnGeneral.addEventListener('click', () => setMode('general'));
|
| 875 |
+
if (btnRealtime) btnRealtime.addEventListener('click', () => setMode('realtime'));
|
| 876 |
+
|
| 877 |
+
// QUICK-ACTION CHIPS — Predefined messages on the welcome screen
|
| 878 |
+
// Each chip has a data-msg attribute containing the message to send
|
| 879 |
+
document.querySelectorAll('.chip').forEach(c => {
|
| 880 |
+
c.addEventListener('click', () => { if (!isStreaming) sendMessage(c.dataset.msg); });
|
| 881 |
+
});
|
| 882 |
+
|
| 883 |
+
// SEARCH RESULTS WIDGET — Toggle panel open/close from header button; close from panel button
|
| 884 |
+
if (searchResultsToggle) {
|
| 885 |
+
searchResultsToggle.addEventListener('click', () => {
|
| 886 |
+
if (searchResultsWidget) { searchResultsWidget.classList.toggle('open'); updatePanelOverlay(); }
|
| 887 |
+
});
|
| 888 |
+
}
|
| 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', () => {
|
| 895 |
+
if (activityPanel) { activityPanel.classList.toggle('open'); updatePanelOverlay(); }
|
| 896 |
+
});
|
| 897 |
+
}
|
| 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', () => {
|
| 905 |
+
settingsPanel.classList.toggle('open');
|
| 906 |
+
updatePanelOverlay();
|
| 907 |
+
});
|
| 908 |
+
}
|
| 909 |
+
if (settingsClose && settingsPanel) {
|
| 910 |
+
settingsClose.addEventListener('click', () => {
|
| 911 |
+
settingsPanel.classList.remove('open');
|
| 912 |
+
updatePanelOverlay();
|
| 913 |
+
});
|
| 914 |
+
}
|
| 915 |
+
if (toggleAutoActivity) {
|
| 916 |
+
toggleAutoActivity.addEventListener('change', () => {
|
| 917 |
+
settings.autoOpenActivity = toggleAutoActivity.checked;
|
| 918 |
+
saveSettings();
|
| 919 |
+
});
|
| 920 |
+
}
|
| 921 |
+
if (toggleAutoSearch) {
|
| 922 |
+
toggleAutoSearch.addEventListener('change', () => {
|
| 923 |
+
settings.autoOpenSearchResults = toggleAutoSearch.checked;
|
| 924 |
+
saveSettings();
|
| 925 |
+
});
|
| 926 |
+
}
|
| 927 |
+
if (toggleThinkingSounds) {
|
| 928 |
+
toggleThinkingSounds.addEventListener('change', () => {
|
| 929 |
+
settings.thinkingSounds = toggleThinkingSounds.checked;
|
| 930 |
+
saveSettings();
|
| 931 |
+
});
|
| 932 |
+
}
|
| 933 |
+
}
|
| 934 |
+
|
| 935 |
+
/**
|
| 936 |
+
* autoResizeInput() — Dynamically adjusts the textarea height to fit
|
| 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 |
+
================================================================
|
| 953 |
+
The app supports two AI modes, each hitting a different backend
|
| 954 |
+
endpoint:
|
| 955 |
+
- "General" → /chat/stream (standard LLM pipeline)
|
| 956 |
+
- "Realtime" → /chat/realtime/stream (realtime/low-latency pipeline)
|
| 957 |
+
|
| 958 |
+
The mode is purely a UI + routing concern — the frontend logic for
|
| 959 |
+
streaming and rendering is identical for both modes.
|
| 960 |
+
================================================================ */
|
| 961 |
+
|
| 962 |
+
/**
|
| 963 |
+
* updatePanelOverlay() — Shows/hides the backdrop overlay when any side panel is open.
|
| 964 |
+
*/
|
| 965 |
+
function updatePanelOverlay() {
|
| 966 |
+
if (!panelOverlay) return;
|
| 967 |
+
const anyOpen = (activityPanel && activityPanel.classList.contains('open')) ||
|
| 968 |
+
(searchResultsWidget && searchResultsWidget.classList.contains('open')) ||
|
| 969 |
+
(settingsPanel && settingsPanel.classList.contains('open'));
|
| 970 |
+
panelOverlay.classList.toggle('visible', !!anyOpen);
|
| 971 |
+
}
|
| 972 |
+
|
| 973 |
+
/**
|
| 974 |
+
* setMode(mode) — Switches the active mode and updates the UI.
|
| 975 |
+
*
|
| 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 |
+
================================================================ */
|
| 1001 |
+
|
| 1002 |
+
/**
|
| 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();
|
| 1015 |
+
sessionId = null;
|
| 1016 |
+
if (chatMessages) chatMessages.innerHTML = '';
|
| 1017 |
+
chatMessages.appendChild(createWelcome());
|
| 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 |
+
}
|
| 1029 |
+
updatePanelOverlay();
|
| 1030 |
+
}
|
| 1031 |
+
|
| 1032 |
+
/**
|
| 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).
|
| 1046 |
+
*/
|
| 1047 |
+
function createWelcome() {
|
| 1048 |
+
const h = new Date().getHours();
|
| 1049 |
+
let g = 'Good evening.';
|
| 1050 |
+
if (h < 12) g = 'Good morning.';
|
| 1051 |
+
else if (h < 17) g = 'Good afternoon.';
|
| 1052 |
+
else if (h >= 22) g = 'Burning the midnight oil?';
|
| 1053 |
+
|
| 1054 |
+
const div = document.createElement('div');
|
| 1055 |
+
div.className = 'welcome-screen';
|
| 1056 |
+
div.id = 'welcome-screen';
|
| 1057 |
+
div.innerHTML = `
|
| 1058 |
+
<div class="welcome-icon">
|
| 1059 |
+
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg>
|
| 1060 |
+
</div>
|
| 1061 |
+
<h2 class="welcome-title">${g}</h2>
|
| 1062 |
+
<p class="welcome-sub">How may I assist you today?</p>
|
| 1063 |
+
<div class="welcome-chips">
|
| 1064 |
+
<button class="chip" data-msg="What can you do?">What can you do?</button>
|
| 1065 |
+
<button class="chip" data-msg="Open YouTube for me">Open YouTube</button>
|
| 1066 |
+
<button class="chip" data-msg="Tell me a fun fact">Fun fact</button>
|
| 1067 |
+
<button class="chip" data-msg="Play some music">Play music</button>
|
| 1068 |
+
</div>`;
|
| 1069 |
+
|
| 1070 |
+
// Attach click handlers to the dynamically created chip buttons
|
| 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 |
+
================================================================
|
| 1080 |
+
These functions build the chat message DOM elements. Each message
|
| 1081 |
+
consists of:
|
| 1082 |
+
- An avatar circle ("J" for Jarvis, "U" for user)
|
| 1083 |
+
- A body containing a label (name + mode) and the content text
|
| 1084 |
+
|
| 1085 |
+
The structure mirrors common chat UIs (Slack, Discord, ChatGPT).
|
| 1086 |
+
================================================================ */
|
| 1087 |
+
|
| 1088 |
+
/**
|
| 1089 |
+
* isUrlLike(str) — True if the string looks like a URL or encoded path (not a readable title/snippet).
|
| 1090 |
+
*/
|
| 1091 |
+
function isUrlLike(str) {
|
| 1092 |
+
if (!str || typeof str !== 'string') return false;
|
| 1093 |
+
const s = str.trim();
|
| 1094 |
+
return s.length > 40 && (/^https?:\/\//i.test(s) || /\%2f|\%3a|\.com\/|\.org\//i.test(s));
|
| 1095 |
+
}
|
| 1096 |
+
|
| 1097 |
+
/**
|
| 1098 |
+
* friendlyUrlLabel(url) — Short, readable label for a URL (domain + path hint) for display.
|
| 1099 |
+
*/
|
| 1100 |
+
function friendlyUrlLabel(url) {
|
| 1101 |
+
if (!url || typeof url !== 'string') return 'View source';
|
| 1102 |
+
try {
|
| 1103 |
+
const u = new URL(url.startsWith('http') ? url : 'https://' + url);
|
| 1104 |
+
const host = u.hostname.replace(/^www\./, '');
|
| 1105 |
+
const path = u.pathname !== '/' ? u.pathname.slice(0, 20) + (u.pathname.length > 20 ? '…' : '') : '';
|
| 1106 |
+
return path ? host + path : host;
|
| 1107 |
+
} catch (_) {
|
| 1108 |
+
return url.length > 40 ? url.slice(0, 37) + '…' : url;
|
| 1109 |
+
}
|
| 1110 |
+
}
|
| 1111 |
+
|
| 1112 |
+
/**
|
| 1113 |
+
* truncateSnippet(text, maxLen) — Truncate to maxLen with ellipsis, one line for card content.
|
| 1114 |
+
*/
|
| 1115 |
+
function truncateSnippet(text, maxLen) {
|
| 1116 |
+
if (!text || typeof text !== 'string') return '';
|
| 1117 |
+
const t = text.trim();
|
| 1118 |
+
if (t.length <= maxLen) return t;
|
| 1119 |
+
return t.slice(0, maxLen).trim() + '…';
|
| 1120 |
+
}
|
| 1121 |
+
|
| 1122 |
+
/**
|
| 1123 |
+
* renderSearchResults(payload) — Fills the right-side search results widget
|
| 1124 |
+
* with Tavily data (query, AI answer, and source cards). Filters junk, truncates
|
| 1125 |
+
* content, and shows friendly URL labels so layout stays clean and responsive.
|
| 1126 |
+
*/
|
| 1127 |
+
function renderSearchResults(payload) {
|
| 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>` : ''}
|
| 1154 |
+
${urlMarkup}
|
| 1155 |
+
${score != null ? `<div class="card-score">Relevance: ${escapeHtml(String(score))}%</div>` : ''}`;
|
| 1156 |
+
searchResultsList.appendChild(card);
|
| 1157 |
+
}
|
| 1158 |
+
}
|
| 1159 |
+
|
| 1160 |
+
/**
|
| 1161 |
+
* safeUrlForHref(url) — Returns URL only if it's http/https; otherwise empty.
|
| 1162 |
+
* Prevents XSS via javascript:, data:, or other dangerous protocols.
|
| 1163 |
+
*/
|
| 1164 |
+
function safeUrlForHref(url) {
|
| 1165 |
+
if (!url || typeof url !== 'string') return '';
|
| 1166 |
+
const u = url.trim();
|
| 1167 |
+
if (u.startsWith('https://') || u.startsWith('http://')) return escapeAttr(u);
|
| 1168 |
+
return '';
|
| 1169 |
+
}
|
| 1170 |
+
|
| 1171 |
+
/**
|
| 1172 |
+
* escapeAttr(str) — Escape for HTML attribute (e.g. href, title).
|
| 1173 |
+
* Order matters: & first, then ", <, >.
|
| 1174 |
+
*/
|
| 1175 |
+
function escapeAttr(str) {
|
| 1176 |
+
if (typeof str !== 'string') return '';
|
| 1177 |
+
return String(str)
|
| 1178 |
+
.replace(/&/g, '&')
|
| 1179 |
+
.replace(/"/g, '"')
|
| 1180 |
+
.replace(/</g, '<')
|
| 1181 |
+
.replace(/>/g, '>');
|
| 1182 |
+
}
|
| 1183 |
+
|
| 1184 |
+
/** Step labels for activity events (left panel). */
|
| 1185 |
+
const ACTIVITY_STEPS = {
|
| 1186 |
+
query_detected: { step: 1, label: 'Query detected' },
|
| 1187 |
+
decision: { step: 2, label: 'Brain decision' },
|
| 1188 |
+
routing: { step: 3, label: 'Route selected' },
|
| 1189 |
+
streaming_started: { step: 4, label: 'Streaming response' },
|
| 1190 |
+
extracting_query: { step: 0, label: 'Extracting query' },
|
| 1191 |
+
searching_web: { step: 0, label: 'Searching web' },
|
| 1192 |
+
search_completed: { step: 0, label: 'Search completed' },
|
| 1193 |
+
context_retrieved: { step: 0, label: 'Context retrieved' },
|
| 1194 |
+
first_chunk: { step: 5, label: 'Core responded' },
|
| 1195 |
+
};
|
| 1196 |
+
|
| 1197 |
+
/**
|
| 1198 |
+
* appendActivity(activity) — Appends an activity event to the left panel.
|
| 1199 |
+
* Structured with step numbers, icons, and clear hierarchy.
|
| 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') {
|
| 1211 |
+
const ms = activity.elapsed_ms;
|
| 1212 |
+
const timing = ms != null ? ` (Cortex: ${ms < 1000 ? ms + ' ms' : (ms / 1000).toFixed(2) + ' s'})` : '';
|
| 1213 |
+
detail = `${(activity.query_type || '?').charAt(0).toUpperCase() + (activity.query_type || '').slice(1)} — ${activity.reasoning || ''}${timing}`;
|
| 1214 |
+
if (activity.query_type === 'general') item.classList.add('route-general');
|
| 1215 |
+
if (activity.query_type === 'realtime') item.classList.add('route-realtime');
|
| 1216 |
+
} else if (activity.event === 'routing') {
|
| 1217 |
+
detail = `→ ${(activity.route || '?').charAt(0).toUpperCase() + (activity.route || '').slice(1)}`;
|
| 1218 |
+
if (activity.route === 'general') item.classList.add('route-general');
|
| 1219 |
+
if (activity.route === 'realtime') item.classList.add('route-realtime');
|
| 1220 |
+
} else if (activity.event === 'streaming_started') {
|
| 1221 |
+
detail = `Generating via ${(activity.route || '?').charAt(0).toUpperCase() + (activity.route || '').slice(1)}`;
|
| 1222 |
+
if (activity.route === 'general') item.classList.add('route-general');
|
| 1223 |
+
if (activity.route === 'realtime') item.classList.add('route-realtime');
|
| 1224 |
+
} else if (activity.event === 'first_chunk') {
|
| 1225 |
+
const ms = activity.elapsed_ms;
|
| 1226 |
+
detail = ms != null ? `Core responded in ${ms < 1000 ? ms + ' ms' : (ms / 1000).toFixed(2) + ' s'}` : 'Response started';
|
| 1227 |
+
if (activity.route === 'general') item.classList.add('route-general');
|
| 1228 |
+
if (activity.route === 'realtime') item.classList.add('route-realtime');
|
| 1229 |
+
} else if (activity.event === 'extracting_query') {
|
| 1230 |
+
detail = activity.message || 'Parsing your question for search...';
|
| 1231 |
+
item.classList.add('activity-sub');
|
| 1232 |
+
} else if (activity.event === 'searching_web') {
|
| 1233 |
+
detail = activity.message || (activity.query ? `Query: "${activity.query}"` : 'Scanning Pulse...');
|
| 1234 |
+
item.classList.add('activity-sub', 'route-realtime');
|
| 1235 |
+
} else if (activity.event === 'search_completed') {
|
| 1236 |
+
detail = activity.message || 'Search completed';
|
| 1237 |
+
item.classList.add('activity-sub', 'route-realtime');
|
| 1238 |
+
} else if (activity.event === 'context_retrieved') {
|
| 1239 |
+
detail = activity.message || 'Knowledge base ready';
|
| 1240 |
+
item.classList.add('activity-sub', 'route-general');
|
| 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 |
+
}
|
| 1253 |
+
|
| 1254 |
+
/**
|
| 1255 |
+
* escapeHtml(str) — Escapes & < > " ' for safe insertion into HTML.
|
| 1256 |
+
*/
|
| 1257 |
+
function escapeHtml(str) {
|
| 1258 |
+
if (typeof str !== 'string') return '';
|
| 1259 |
+
const div = document.createElement('div');
|
| 1260 |
+
div.textContent = str;
|
| 1261 |
+
return div.innerHTML;
|
| 1262 |
+
}
|
| 1263 |
+
|
| 1264 |
+
/**
|
| 1265 |
+
* hideWelcome() — Removes the welcome screen from the DOM.
|
| 1266 |
+
*
|
| 1267 |
+
* Called before adding the first message, since the welcome screen
|
| 1268 |
+
* should disappear once a conversation begins.
|
| 1269 |
+
*/
|
| 1270 |
+
function hideWelcome() {
|
| 1271 |
+
const w = document.getElementById('welcome-screen');
|
| 1272 |
+
if (w) w.remove();
|
| 1273 |
+
}
|
| 1274 |
+
|
| 1275 |
+
/**
|
| 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 |
+
|
| 1303 |
+
const avatar = document.createElement('div');
|
| 1304 |
+
avatar.className = 'msg-avatar';
|
| 1305 |
+
avatar.innerHTML = role === 'assistant' ? AVATAR_ICON_ASSISTANT : AVATAR_ICON_USER;
|
| 1306 |
+
|
| 1307 |
+
const body = document.createElement('div');
|
| 1308 |
+
body.className = 'msg-body';
|
| 1309 |
+
|
| 1310 |
+
const label = document.createElement('div');
|
| 1311 |
+
label.className = 'msg-label';
|
| 1312 |
+
label.textContent = role === 'assistant'
|
| 1313 |
+
? `Jarvis (${currentMode === 'jarvis' ? 'Jarvis' : currentMode === 'realtime' ? 'Realtime' : 'General'})`
|
| 1314 |
+
: 'You';
|
| 1315 |
+
|
| 1316 |
+
const content = document.createElement('div');
|
| 1317 |
+
content.className = 'msg-content';
|
| 1318 |
+
content.textContent = text;
|
| 1319 |
+
|
| 1320 |
+
body.appendChild(label);
|
| 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 |
+
|
| 1329 |
+
/**
|
| 1330 |
+
* addTypingIndicator() — Shows an animated "..." typing indicator
|
| 1331 |
+
* while waiting for the assistant's response to begin streaming.
|
| 1332 |
+
*
|
| 1333 |
+
* @returns {HTMLDivElement} The content element (containing the dots).
|
| 1334 |
+
*
|
| 1335 |
+
* This creates a message bubble that looks like the assistant is
|
| 1336 |
+
* typing. It's removed once actual content starts arriving.
|
| 1337 |
+
* The three <span> elements inside .typing-dots are animated via CSS
|
| 1338 |
+
* to create the bouncing dots effect.
|
| 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
|
| 1345 |
+
|
| 1346 |
+
const avatar = document.createElement('div');
|
| 1347 |
+
avatar.className = 'msg-avatar';
|
| 1348 |
+
avatar.innerHTML = AVATAR_ICON_ASSISTANT;
|
| 1349 |
+
|
| 1350 |
+
const body = document.createElement('div');
|
| 1351 |
+
body.className = 'msg-body';
|
| 1352 |
+
|
| 1353 |
+
const label = document.createElement('div');
|
| 1354 |
+
label.className = 'msg-label';
|
| 1355 |
+
label.textContent = `Jarvis (${currentMode === 'jarvis' ? 'Jarvis' : currentMode === 'realtime' ? 'Realtime' : 'General'})`;
|
| 1356 |
+
|
| 1357 |
+
const content = document.createElement('div');
|
| 1358 |
+
content.className = 'msg-content';
|
| 1359 |
+
content.innerHTML = '<span class="msg-stream-text">...</span>';
|
| 1360 |
+
|
| 1361 |
+
body.appendChild(label);
|
| 1362 |
+
body.appendChild(content);
|
| 1363 |
+
msg.appendChild(avatar);
|
| 1364 |
+
msg.appendChild(body);
|
| 1365 |
+
chatMessages.appendChild(msg);
|
| 1366 |
+
scrollToBottom();
|
| 1367 |
+
return content;
|
| 1368 |
+
}
|
| 1369 |
+
|
| 1370 |
+
/**
|
| 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');
|
| 1379 |
+
if (t) t.remove();
|
| 1380 |
+
}
|
| 1381 |
+
|
| 1382 |
+
/**
|
| 1383 |
+
* scrollToBottom() — Scrolls the chat container to show the latest message.
|
| 1384 |
+
*
|
| 1385 |
+
* Uses requestAnimationFrame so the scroll runs after the browser has
|
| 1386 |
+
* laid out newly added content (typing indicator, "Thinking...", or
|
| 1387 |
+
* streamed chunks). Without this, scroll can happen before layout and
|
| 1388 |
+
* the user would have to scroll manually to see new content.
|
| 1389 |
+
*/
|
| 1390 |
+
function scrollToBottom() {
|
| 1391 |
+
requestAnimationFrame(() => {
|
| 1392 |
+
chatMessages.scrollTop = chatMessages.scrollHeight;
|
| 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
|
| 1403 |
+
could take seconds), we use SSE streaming to receive the response
|
| 1404 |
+
token-by-token as it's generated. This creates the "typing" effect.
|
| 1405 |
+
|
| 1406 |
+
STANDARD SSE FORMAT:
|
| 1407 |
+
The server sends a stream of lines like:
|
| 1408 |
+
data: {"chunk": "Hello"}
|
| 1409 |
+
data: {"chunk": " there"}
|
| 1410 |
+
data: {"chunk": "!"}
|
| 1411 |
+
data: {"done": true}
|
| 1412 |
+
|
| 1413 |
+
Each line starts with "data: " followed by a JSON payload. Lines
|
| 1414 |
+
are separated by newlines ("\n"). An empty line separates events.
|
| 1415 |
+
|
| 1416 |
+
HOW WE READ THE STREAM:
|
| 1417 |
+
1. We POST the user's message to the backend.
|
| 1418 |
+
2. The server responds with Content-Type: text/event-stream.
|
| 1419 |
+
3. We use res.body.getReader() to read the response body as a
|
| 1420 |
+
stream of raw bytes (Uint8Array chunks).
|
| 1421 |
+
4. We decode each chunk to text and append it to an SSE buffer.
|
| 1422 |
+
5. We split the buffer by newlines and process each complete line.
|
| 1423 |
+
6. Lines starting with "data: " are parsed as JSON.
|
| 1424 |
+
7. Each JSON payload may contain:
|
| 1425 |
+
- chunk: a piece of the text response (appended to the UI)
|
| 1426 |
+
- audio: a base64 MP3 segment (enqueued for TTS playback)
|
| 1427 |
+
- session_id: the conversation ID (saved for future messages)
|
| 1428 |
+
- error: an error message from the server
|
| 1429 |
+
- done: true when the response is complete
|
| 1430 |
+
|
| 1431 |
+
WHY NOT USE EventSource?
|
| 1432 |
+
The native EventSource API only supports GET requests. We need POST
|
| 1433 |
+
(to send the message body), so we use fetch() + manual SSE parsing.
|
| 1434 |
+
|
| 1435 |
+
THE SSE BUFFER:
|
| 1436 |
+
Network chunks don't align with SSE line boundaries — one chunk
|
| 1437 |
+
might contain half a line, or multiple lines. The sseBuffer variable
|
| 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
|
| 1453 |
+
const text = (textOverride || messageInput.value).trim();
|
| 1454 |
+
if (!text || isStreaming) return; // Ignore empty messages or if already streaming
|
| 1455 |
+
|
| 1456 |
+
// Step 2: Clear the input field immediately (responsive UX)
|
| 1457 |
+
messageInput.value = '';
|
| 1458 |
+
autoResizeInput();
|
| 1459 |
+
charCount.textContent = '';
|
| 1460 |
+
|
| 1461 |
+
// Step 3: Display the user's message and show typing indicator
|
| 1462 |
+
addMessage('user', text);
|
| 1463 |
+
addTypingIndicator();
|
| 1464 |
+
|
| 1465 |
+
// Step 4: Lock the UI to prevent double-sending
|
| 1466 |
+
isStreaming = true;
|
| 1467 |
+
if (sendBtn) sendBtn.disabled = true;
|
| 1468 |
+
if (messageInput) messageInput.disabled = true;
|
| 1469 |
+
if (orbContainer) orbContainer.classList.add('active');
|
| 1470 |
+
|
| 1471 |
+
// Step 5: Reset TTS for this new response and unlock audio (iOS)
|
| 1472 |
+
if (ttsPlayer) { ttsPlayer.reset(); ttsPlayer.unlock(); }
|
| 1473 |
+
|
| 1474 |
+
// Step 6: Choose the endpoint based on the current mode
|
| 1475 |
+
const endpoint = currentMode === 'jarvis'
|
| 1476 |
+
? '/chat/jarvis/stream'
|
| 1477 |
+
: currentMode === 'realtime'
|
| 1478 |
+
? '/chat/realtime/stream'
|
| 1479 |
+
: '/chat/stream';
|
| 1480 |
+
|
| 1481 |
+
// Clear activity panel (query_detected only — no duplicate user message)
|
| 1482 |
+
if (activityList) {
|
| 1483 |
+
activityList.innerHTML = '<div class="activity-empty" id="activity-empty">Processing...</div>';
|
| 1484 |
+
if (activityToggle) activityToggle.style.display = '';
|
| 1485 |
+
if (activityPanel && settings.autoOpenActivity) { activityPanel.classList.add('open'); updatePanelOverlay(); }
|
| 1486 |
+
}
|
| 1487 |
+
|
| 1488 |
+
let firstChunkReceived = false;
|
| 1489 |
+
let timeoutId = null;
|
| 1490 |
+
const controller = new AbortController();
|
| 1491 |
+
|
| 1492 |
+
try {
|
| 1493 |
+
// ─── Pre-starter + Main stream ───
|
| 1494 |
+
// 1. Pre-starter: play immediately on dedicated audio element (immune to ttsPlayer.reset)
|
| 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}`, {
|
| 1501 |
+
method: 'POST',
|
| 1502 |
+
headers: { 'Content-Type': 'application/json' },
|
| 1503 |
+
body: JSON.stringify({
|
| 1504 |
+
message: text, // The user's message
|
| 1505 |
+
session_id: sessionId, // null on first message; UUID after that
|
| 1506 |
+
tts: !!(ttsPlayer && ttsPlayer.enabled) // Tell the backend whether to generate audio
|
| 1507 |
+
}),
|
| 1508 |
+
signal: controller.signal,
|
| 1509 |
+
});
|
| 1510 |
+
|
| 1511 |
+
// Handle HTTP errors (4xx, 5xx)
|
| 1512 |
+
if (!res.ok) {
|
| 1513 |
+
const err = await res.json().catch(() => null);
|
| 1514 |
+
throw new Error(err?.detail || `HTTP ${res.status}`);
|
| 1515 |
+
}
|
| 1516 |
+
|
| 1517 |
+
// Step 8: Replace the typing indicator with an empty assistant message
|
| 1518 |
+
removeTypingIndicator();
|
| 1519 |
+
const contentEl = addMessage('assistant', '');
|
| 1520 |
+
contentEl.innerHTML = '<span class="msg-stream-text">...</span>';
|
| 1521 |
+
scrollToBottom(); // Scroll so placeholder is visible without manual scroll
|
| 1522 |
+
|
| 1523 |
+
// Set up the stream reader and SSE parser
|
| 1524 |
+
if (!res.body) throw new Error('No response body');
|
| 1525 |
+
const reader = res.body.getReader(); // ReadableStream reader for the response body
|
| 1526 |
+
const decoder = new TextDecoder(); // Converts raw bytes (Uint8Array) to strings
|
| 1527 |
+
let sseBuffer = ''; // Accumulates partial SSE lines between chunks
|
| 1528 |
+
let fullResponse = ''; // The complete assistant response text so far
|
| 1529 |
+
let cursorEl = null; // The blinking "|" cursor shown during streaming
|
| 1530 |
+
|
| 1531 |
+
// Step 9: Read the stream in a loop until it's done
|
| 1532 |
+
let streamDone = false;
|
| 1533 |
+
while (!streamDone) {
|
| 1534 |
+
const { done, value } = await reader.read();
|
| 1535 |
+
if (done) break; // Stream has ended
|
| 1536 |
+
|
| 1537 |
+
// Decode the bytes and add to our SSE buffer
|
| 1538 |
+
sseBuffer += decoder.decode(value, { stream: true });
|
| 1539 |
+
|
| 1540 |
+
// Split by newlines to get individual SSE lines
|
| 1541 |
+
const lines = sseBuffer.split('\n');
|
| 1542 |
+
|
| 1543 |
+
// The last element might be an incomplete line — keep it in the buffer
|
| 1544 |
+
sseBuffer = lines.pop();
|
| 1545 |
+
|
| 1546 |
+
// Process each complete line
|
| 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));
|
| 1553 |
+
|
| 1554 |
+
// Save the session ID if the server sends one
|
| 1555 |
+
if (data.session_id) sessionId = data.session_id;
|
| 1556 |
+
|
| 1557 |
+
// ACTIVITY — Jarvis flow (query detected, decision, routing): show in left panel
|
| 1558 |
+
if (data.activity) {
|
| 1559 |
+
appendActivity(data.activity);
|
| 1560 |
+
if (activityToggle) activityToggle.style.display = '';
|
| 1561 |
+
if (activityPanel && settings.autoOpenActivity) { activityPanel.classList.add('open'); updatePanelOverlay(); }
|
| 1562 |
+
}
|
| 1563 |
+
|
| 1564 |
+
// SEARCH RESULTS — Tavily data (realtime only): show in right-side widget and reveal toggle
|
| 1565 |
+
if (data.search_results) {
|
| 1566 |
+
renderSearchResults(data.search_results);
|
| 1567 |
+
if (searchResultsToggle) searchResultsToggle.style.display = '';
|
| 1568 |
+
if (searchResultsWidget && settings.autoOpenSearchResults) { searchResultsWidget.classList.add('open'); updatePanelOverlay(); }
|
| 1569 |
+
}
|
| 1570 |
+
|
| 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 |
+
|
| 1587 |
+
// Add a blinking cursor at the end (created once, on the first chunk)
|
| 1588 |
+
if (!cursorEl) {
|
| 1589 |
+
cursorEl = document.createElement('span');
|
| 1590 |
+
cursorEl.className = 'stream-cursor';
|
| 1591 |
+
cursorEl.textContent = '|';
|
| 1592 |
+
contentEl.appendChild(cursorEl);
|
| 1593 |
+
}
|
| 1594 |
+
scrollToBottom();
|
| 1595 |
+
}
|
| 1596 |
+
|
| 1597 |
+
// AUDIO CHUNK — Enqueue for TTS playback
|
| 1598 |
+
if (data.audio && ttsPlayer) {
|
| 1599 |
+
ttsPlayer.enqueue(data.audio);
|
| 1600 |
+
}
|
| 1601 |
+
|
| 1602 |
+
// ERROR — The server reported an error in the stream
|
| 1603 |
+
if (data.error) throw new Error(data.error);
|
| 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'))
|
| 1610 |
+
throw parseErr;
|
| 1611 |
+
}
|
| 1612 |
+
}
|
| 1613 |
+
if (streamDone) break;
|
| 1614 |
+
}
|
| 1615 |
+
|
| 1616 |
+
// Step 10: Clean up — remove the blinking cursor
|
| 1617 |
+
if (cursorEl) cursorEl.remove();
|
| 1618 |
+
|
| 1619 |
+
// If the server sent nothing, show a placeholder
|
| 1620 |
+
const textSpan = contentEl.querySelector('.msg-stream-text');
|
| 1621 |
+
if (textSpan && !fullResponse) textSpan.textContent = '(No response)';
|
| 1622 |
+
|
| 1623 |
+
} catch (err) {
|
| 1624 |
+
clearTimeout(timeoutId);
|
| 1625 |
+
removeTypingIndicator();
|
| 1626 |
+
const msg = err.name === 'AbortError' ? 'Request timed out. Please try again.' : `Something went wrong: ${err.message}`;
|
| 1627 |
+
addMessage('assistant', msg);
|
| 1628 |
+
showToast(msg);
|
| 1629 |
+
} finally {
|
| 1630 |
+
clearTimeout(timeoutId);
|
| 1631 |
+
isStreaming = false;
|
| 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 |
+
}
|
| 1638 |
+
|
| 1639 |
+
/* ================================================================
|
| 1640 |
+
BOOT — Application Entry Point
|
| 1641 |
+
================================================================
|
| 1642 |
+
DOMContentLoaded fires when the HTML document has been fully parsed
|
| 1643 |
+
(but before images/stylesheets finish loading). This is the ideal
|
| 1644 |
+
time to initialize our app because all DOM elements are available.
|
| 1645 |
+
================================================================ */
|
| 1646 |
+
document.addEventListener('DOMContentLoaded', init);
|
frontend/style.css
ADDED
|
@@ -0,0 +1,1166 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
|
| 2 |
+
|
| 3 |
+
:root {
|
| 4 |
+
|
| 5 |
+
--bg: #050510;
|
| 6 |
+
--glass-bg: rgba(10, 10, 28, 0.72);
|
| 7 |
+
--glass-border: rgba(255, 255, 255, 0.06);
|
| 8 |
+
--glass-hover: rgba(255, 255, 255, 0.10);
|
| 9 |
+
|
| 10 |
+
--accent: #7c6aef;
|
| 11 |
+
--accent-glow: rgba(124, 106, 239, 0.35);
|
| 12 |
+
--accent-secondary: #4ecdc4;
|
| 13 |
+
|
| 14 |
+
--text: rgba(255, 255, 255, 0.93);
|
| 15 |
+
--text-dim: rgba(255, 255, 255, 0.50);
|
| 16 |
+
--text-muted: rgba(255, 255, 255, 0.28);
|
| 17 |
+
|
| 18 |
+
--danger: #ff6b6b;
|
| 19 |
+
--success: #51cf66;
|
| 20 |
+
|
| 21 |
+
--radius: 16px;
|
| 22 |
+
--radius-sm: 10px;
|
| 23 |
+
--radius-xs: 6px;
|
| 24 |
+
|
| 25 |
+
--header-h: 64px;
|
| 26 |
+
--input-bar-h: calc(88px + env(safe-area-inset-bottom, 0px));
|
| 27 |
+
--content-gap: 16px;
|
| 28 |
+
--panel-gap: 14px;
|
| 29 |
+
|
| 30 |
+
--transition: 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
| 31 |
+
|
| 32 |
+
--font: 'Poppins', -apple-system, BlinkMacSystemFont, sans-serif;
|
| 33 |
+
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
|
| 37 |
+
|
| 38 |
+
html, body { height: 100%; overflow: hidden; overflow-x: hidden; }
|
| 39 |
+
|
| 40 |
+
body {
|
| 41 |
+
font-family: var(--font);
|
| 42 |
+
background: var(--bg);
|
| 43 |
+
color: var(--text);
|
| 44 |
+
-webkit-font-smoothing: antialiased;
|
| 45 |
+
-webkit-tap-highlight-color: transparent;
|
| 46 |
+
}
|
| 47 |
+
|
| 48 |
+
button { font-family: var(--font); cursor: pointer; border: none; background: none; color: inherit; }
|
| 49 |
+
textarea { font-family: var(--font); color: var(--text); }
|
| 50 |
+
|
| 51 |
+
.glass-panel {
|
| 52 |
+
background: var(--glass-bg);
|
| 53 |
+
backdrop-filter: blur(32px) saturate(1.2);
|
| 54 |
+
-webkit-backdrop-filter: blur(32px) saturate(1.2);
|
| 55 |
+
border: 1px solid var(--glass-border);
|
| 56 |
+
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.12);
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
.app {
|
| 60 |
+
position: relative;
|
| 61 |
+
display: flex;
|
| 62 |
+
flex-direction: column;
|
| 63 |
+
height: 100vh;
|
| 64 |
+
height: 100dvh;
|
| 65 |
+
overflow: hidden;
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
#orb-container {
|
| 69 |
+
position: fixed;
|
| 70 |
+
top: 50%;
|
| 71 |
+
left: 50%;
|
| 72 |
+
translate: -50% -50%;
|
| 73 |
+
width: min(600px, 80vw);
|
| 74 |
+
height: min(600px, 80vw);
|
| 75 |
+
z-index: 0;
|
| 76 |
+
pointer-events: none;
|
| 77 |
+
opacity: 0.35;
|
| 78 |
+
transition: opacity 0.5s ease, transform 0.5s ease;
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
#orb-container.active,
|
| 82 |
+
#orb-container.speaking {
|
| 83 |
+
opacity: 1;
|
| 84 |
+
animation: orbPulse 1.6s ease-in-out infinite;
|
| 85 |
+
}
|
| 86 |
+
|
| 87 |
+
.header {
|
| 88 |
+
position: relative;
|
| 89 |
+
z-index: 10;
|
| 90 |
+
display: flex;
|
| 91 |
+
align-items: center;
|
| 92 |
+
justify-content: space-between;
|
| 93 |
+
gap: 20px;
|
| 94 |
+
height: var(--header-h);
|
| 95 |
+
padding: 0 24px;
|
| 96 |
+
margin-bottom: var(--content-gap);
|
| 97 |
+
border-radius: 0 0 var(--radius) var(--radius);
|
| 98 |
+
border-top: none;
|
| 99 |
+
flex-shrink: 0;
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
.logo {
|
| 103 |
+
font-size: 1.1rem;
|
| 104 |
+
font-weight: 700;
|
| 105 |
+
letter-spacing: 3px;
|
| 106 |
+
background: linear-gradient(135deg, var(--accent), var(--accent-secondary));
|
| 107 |
+
-webkit-background-clip: text;
|
| 108 |
+
-webkit-text-fill-color: transparent;
|
| 109 |
+
background-clip: text;
|
| 110 |
+
}
|
| 111 |
+
|
| 112 |
+
.tagline {
|
| 113 |
+
font-size: 0.68rem;
|
| 114 |
+
font-weight: 300;
|
| 115 |
+
color: var(--text-muted);
|
| 116 |
+
letter-spacing: 0.5px;
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
.mode-switch {
|
| 120 |
+
position: relative;
|
| 121 |
+
display: flex;
|
| 122 |
+
background: rgba(255, 255, 255, 0.04);
|
| 123 |
+
border-radius: 12px;
|
| 124 |
+
padding: 3px;
|
| 125 |
+
gap: 2px;
|
| 126 |
+
}
|
| 127 |
+
.mode-slider {
|
| 128 |
+
position: absolute;
|
| 129 |
+
top: 3px;
|
| 130 |
+
left: 3px;
|
| 131 |
+
width: calc(50% - 4px);
|
| 132 |
+
height: calc(100% - 6px);
|
| 133 |
+
background: var(--accent);
|
| 134 |
+
border-radius: 10px;
|
| 135 |
+
transition: transform var(--transition);
|
| 136 |
+
opacity: 0.18;
|
| 137 |
+
}
|
| 138 |
+
.mode-slider.right {
|
| 139 |
+
transform: translateX(calc(100% + 2px));
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
+
.mode-switch-three {
|
| 143 |
+
min-width: 0;
|
| 144 |
+
}
|
| 145 |
+
.mode-switch-three .mode-btn {
|
| 146 |
+
flex: 1;
|
| 147 |
+
min-width: 0;
|
| 148 |
+
justify-content: center;
|
| 149 |
+
padding: 7px 10px;
|
| 150 |
+
}
|
| 151 |
+
.mode-switch-three .mode-slider {
|
| 152 |
+
width: calc(33.333% - 4px);
|
| 153 |
+
}
|
| 154 |
+
.mode-switch-three .mode-slider.center {
|
| 155 |
+
transform: translateX(calc(100% + 2px));
|
| 156 |
+
}
|
| 157 |
+
.mode-switch-three .mode-slider.right {
|
| 158 |
+
transform: translateX(calc(200% + 4px));
|
| 159 |
+
}
|
| 160 |
+
.mode-btn {
|
| 161 |
+
position: relative;
|
| 162 |
+
z-index: 1;
|
| 163 |
+
display: flex;
|
| 164 |
+
align-items: center;
|
| 165 |
+
gap: 6px;
|
| 166 |
+
padding: 7px 16px;
|
| 167 |
+
font-size: 0.76rem;
|
| 168 |
+
font-weight: 500;
|
| 169 |
+
border-radius: 10px;
|
| 170 |
+
color: var(--text-dim);
|
| 171 |
+
transition: color var(--transition);
|
| 172 |
+
white-space: nowrap;
|
| 173 |
+
}
|
| 174 |
+
.mode-btn.active { color: var(--text); }
|
| 175 |
+
.mode-btn svg {
|
| 176 |
+
opacity: 0.7;
|
| 177 |
+
width: 16px;
|
| 178 |
+
height: 16px;
|
| 179 |
+
min-width: 16px;
|
| 180 |
+
min-height: 16px;
|
| 181 |
+
flex-shrink: 0;
|
| 182 |
+
}
|
| 183 |
+
.mode-btn.active svg { opacity: 1; }
|
| 184 |
+
|
| 185 |
+
.header-left { display: flex; align-items: baseline; gap: 10px; flex-shrink: 0; min-width: 0; }
|
| 186 |
+
.header-center { flex: 1; min-width: 0; display: flex; justify-content: center; align-items: center; }
|
| 187 |
+
.header-right {
|
| 188 |
+
display: flex;
|
| 189 |
+
align-items: center;
|
| 190 |
+
gap: 10px;
|
| 191 |
+
flex-shrink: 0;
|
| 192 |
+
flex-wrap: nowrap;
|
| 193 |
+
}
|
| 194 |
+
|
| 195 |
+
.status-badge {
|
| 196 |
+
display: flex;
|
| 197 |
+
align-items: center;
|
| 198 |
+
gap: 6px;
|
| 199 |
+
font-size: 0.7rem;
|
| 200 |
+
font-weight: 400;
|
| 201 |
+
color: var(--text-dim);
|
| 202 |
+
}
|
| 203 |
+
|
| 204 |
+
.status-dot {
|
| 205 |
+
width: 7px;
|
| 206 |
+
height: 7px;
|
| 207 |
+
border-radius: 50%;
|
| 208 |
+
background: var(--success);
|
| 209 |
+
box-shadow: 0 0 6px var(--success);
|
| 210 |
+
animation: pulse-dot 2s ease-in-out infinite;
|
| 211 |
+
}
|
| 212 |
+
|
| 213 |
+
.status-dot.offline {
|
| 214 |
+
background: var(--danger);
|
| 215 |
+
box-shadow: 0 0 6px var(--danger);
|
| 216 |
+
animation: none;
|
| 217 |
+
}
|
| 218 |
+
|
| 219 |
+
.btn-icon {
|
| 220 |
+
display: grid;
|
| 221 |
+
place-items: center;
|
| 222 |
+
width: 34px;
|
| 223 |
+
height: 34px;
|
| 224 |
+
min-width: 34px;
|
| 225 |
+
min-height: 34px;
|
| 226 |
+
border-radius: var(--radius-sm);
|
| 227 |
+
background: rgba(255, 255, 255, 0.04);
|
| 228 |
+
border: 1px solid var(--glass-border);
|
| 229 |
+
transition: background var(--transition), border-color var(--transition);
|
| 230 |
+
touch-action: manipulation;
|
| 231 |
+
-webkit-tap-highlight-color: transparent;
|
| 232 |
+
}
|
| 233 |
+
.btn-icon:hover {
|
| 234 |
+
background: var(--glass-hover);
|
| 235 |
+
border-color: rgba(255, 255, 255, 0.14);
|
| 236 |
+
}
|
| 237 |
+
|
| 238 |
+
.chat-area {
|
| 239 |
+
position: relative;
|
| 240 |
+
z-index: 5;
|
| 241 |
+
flex: 1;
|
| 242 |
+
overflow: hidden;
|
| 243 |
+
display: flex;
|
| 244 |
+
flex-direction: column;
|
| 245 |
+
min-height: 0;
|
| 246 |
+
}
|
| 247 |
+
.chat-messages {
|
| 248 |
+
flex: 1;
|
| 249 |
+
overflow-y: auto;
|
| 250 |
+
overflow-x: hidden;
|
| 251 |
+
padding: 24px 24px 28px;
|
| 252 |
+
display: flex;
|
| 253 |
+
flex-direction: column;
|
| 254 |
+
gap: 14px;
|
| 255 |
+
scroll-behavior: smooth;
|
| 256 |
+
-webkit-overflow-scrolling: touch;
|
| 257 |
+
}
|
| 258 |
+
|
| 259 |
+
.welcome-screen {
|
| 260 |
+
display: flex;
|
| 261 |
+
flex-direction: column;
|
| 262 |
+
align-items: center;
|
| 263 |
+
justify-content: center;
|
| 264 |
+
text-align: center;
|
| 265 |
+
flex: 1;
|
| 266 |
+
gap: 16px;
|
| 267 |
+
padding: 48px 24px;
|
| 268 |
+
animation: fadeIn 0.6s ease;
|
| 269 |
+
}
|
| 270 |
+
.welcome-icon {
|
| 271 |
+
color: var(--accent);
|
| 272 |
+
opacity: 0.5;
|
| 273 |
+
margin-bottom: 6px;
|
| 274 |
+
}
|
| 275 |
+
|
| 276 |
+
.welcome-title {
|
| 277 |
+
font-size: 1.7rem;
|
| 278 |
+
font-weight: 600;
|
| 279 |
+
background: linear-gradient(135deg, var(--text), var(--accent));
|
| 280 |
+
-webkit-background-clip: text;
|
| 281 |
+
-webkit-text-fill-color: transparent;
|
| 282 |
+
background-clip: text;
|
| 283 |
+
}
|
| 284 |
+
.welcome-sub {
|
| 285 |
+
font-size: 0.9rem;
|
| 286 |
+
color: var(--text-dim);
|
| 287 |
+
font-weight: 300;
|
| 288 |
+
}
|
| 289 |
+
|
| 290 |
+
.welcome-chips {
|
| 291 |
+
display: flex;
|
| 292 |
+
flex-wrap: wrap;
|
| 293 |
+
justify-content: center;
|
| 294 |
+
gap: 12px;
|
| 295 |
+
margin-top: 24px;
|
| 296 |
+
}
|
| 297 |
+
.chip {
|
| 298 |
+
padding: 10px 20px;
|
| 299 |
+
font-size: 0.76rem;
|
| 300 |
+
font-weight: 400;
|
| 301 |
+
border-radius: 20px;
|
| 302 |
+
background: rgba(255, 255, 255, 0.04);
|
| 303 |
+
border: 1px solid var(--glass-border);
|
| 304 |
+
color: var(--text-dim);
|
| 305 |
+
transition: all var(--transition);
|
| 306 |
+
}
|
| 307 |
+
.chip:hover {
|
| 308 |
+
background: var(--accent);
|
| 309 |
+
color: #fff;
|
| 310 |
+
border-color: var(--accent);
|
| 311 |
+
transform: translateY(-2px);
|
| 312 |
+
box-shadow: 0 4px 12px rgba(124, 106, 239, 0.25);
|
| 313 |
+
}
|
| 314 |
+
|
| 315 |
+
.message {
|
| 316 |
+
display: flex;
|
| 317 |
+
gap: 12px;
|
| 318 |
+
max-width: 720px;
|
| 319 |
+
width: 100%;
|
| 320 |
+
margin: 0 auto;
|
| 321 |
+
animation: msgIn 0.35s cubic-bezier(0.4, 0, 0.2, 1);
|
| 322 |
+
}
|
| 323 |
+
.message.user { flex-direction: row-reverse; }
|
| 324 |
+
|
| 325 |
+
.msg-avatar {
|
| 326 |
+
width: 30px;
|
| 327 |
+
height: 30px;
|
| 328 |
+
border-radius: 10px;
|
| 329 |
+
display: grid;
|
| 330 |
+
place-items: center;
|
| 331 |
+
font-size: 0.7rem;
|
| 332 |
+
font-weight: 600;
|
| 333 |
+
flex-shrink: 0;
|
| 334 |
+
margin-top: 4px;
|
| 335 |
+
}
|
| 336 |
+
|
| 337 |
+
.msg-avatar .msg-avatar-icon {
|
| 338 |
+
width: 18px;
|
| 339 |
+
height: 18px;
|
| 340 |
+
}
|
| 341 |
+
|
| 342 |
+
.message.assistant .msg-avatar {
|
| 343 |
+
background: linear-gradient(135deg, var(--accent), var(--accent-secondary));
|
| 344 |
+
color: #fff;
|
| 345 |
+
}
|
| 346 |
+
|
| 347 |
+
.message.user .msg-avatar {
|
| 348 |
+
background: rgba(255, 255, 255, 0.08);
|
| 349 |
+
color: var(--text-dim);
|
| 350 |
+
}
|
| 351 |
+
|
| 352 |
+
.msg-body {
|
| 353 |
+
display: flex;
|
| 354 |
+
flex-direction: column;
|
| 355 |
+
gap: 3px;
|
| 356 |
+
min-width: 0;
|
| 357 |
+
}
|
| 358 |
+
|
| 359 |
+
.msg-content {
|
| 360 |
+
padding: 14px 18px;
|
| 361 |
+
border-radius: var(--radius);
|
| 362 |
+
font-size: 0.87rem;
|
| 363 |
+
line-height: 1.65;
|
| 364 |
+
font-weight: 400;
|
| 365 |
+
word-wrap: break-word;
|
| 366 |
+
white-space: pre-wrap;
|
| 367 |
+
}
|
| 368 |
+
|
| 369 |
+
.message.assistant .msg-content {
|
| 370 |
+
background: rgba(255, 255, 255, 0.05);
|
| 371 |
+
border: 1px solid rgba(255, 255, 255, 0.07);
|
| 372 |
+
border-top-left-radius: var(--radius-xs);
|
| 373 |
+
}
|
| 374 |
+
|
| 375 |
+
.message.user .msg-content {
|
| 376 |
+
background: rgba(124, 106, 239, 0.13);
|
| 377 |
+
border: 1px solid rgba(124, 106, 239, 0.16);
|
| 378 |
+
border-top-right-radius: var(--radius-xs);
|
| 379 |
+
}
|
| 380 |
+
|
| 381 |
+
.msg-label {
|
| 382 |
+
font-size: 0.66rem;
|
| 383 |
+
font-weight: 500;
|
| 384 |
+
color: var(--text-muted);
|
| 385 |
+
padding: 0 4px;
|
| 386 |
+
}
|
| 387 |
+
.message.user .msg-label { text-align: right; }
|
| 388 |
+
|
| 389 |
+
.stream-cursor {
|
| 390 |
+
animation: blink 0.8s step-end infinite;
|
| 391 |
+
color: var(--accent);
|
| 392 |
+
margin-left: 1px;
|
| 393 |
+
}
|
| 394 |
+
|
| 395 |
+
.input-bar {
|
| 396 |
+
position: relative;
|
| 397 |
+
z-index: 10;
|
| 398 |
+
padding: 14px 24px 14px;
|
| 399 |
+
padding-bottom: max(14px, env(safe-area-inset-bottom, 14px));
|
| 400 |
+
margin-top: var(--content-gap);
|
| 401 |
+
border-radius: var(--radius) var(--radius) 0 0;
|
| 402 |
+
border-bottom: none;
|
| 403 |
+
flex-shrink: 0;
|
| 404 |
+
box-shadow: none;
|
| 405 |
+
}
|
| 406 |
+
|
| 407 |
+
.input-wrapper {
|
| 408 |
+
display: flex;
|
| 409 |
+
align-items: flex-end;
|
| 410 |
+
gap: 8px;
|
| 411 |
+
background: rgba(255, 255, 255, 0.04);
|
| 412 |
+
border: 1px solid var(--glass-border);
|
| 413 |
+
border-radius: 14px;
|
| 414 |
+
padding: 8px 8px 8px 18px;
|
| 415 |
+
transition: border-color var(--transition);
|
| 416 |
+
box-shadow: none;
|
| 417 |
+
}
|
| 418 |
+
|
| 419 |
+
.input-wrapper:focus-within {
|
| 420 |
+
border-color: rgba(255, 255, 255, 0.14);
|
| 421 |
+
}
|
| 422 |
+
|
| 423 |
+
.input-wrapper textarea {
|
| 424 |
+
flex: 1;
|
| 425 |
+
background: none;
|
| 426 |
+
border: none;
|
| 427 |
+
outline: none;
|
| 428 |
+
resize: none;
|
| 429 |
+
font-size: 0.87rem;
|
| 430 |
+
line-height: 1.5;
|
| 431 |
+
padding: 8px 0;
|
| 432 |
+
max-height: 120px;
|
| 433 |
+
color: var(--text);
|
| 434 |
+
}
|
| 435 |
+
.input-wrapper textarea::placeholder { color: var(--text-muted); }
|
| 436 |
+
.input-wrapper textarea:disabled {
|
| 437 |
+
opacity: 0.6;
|
| 438 |
+
cursor: not-allowed;
|
| 439 |
+
}
|
| 440 |
+
|
| 441 |
+
.input-actions {
|
| 442 |
+
display: flex;
|
| 443 |
+
gap: 6px;
|
| 444 |
+
padding-bottom: 2px;
|
| 445 |
+
flex-shrink: 0;
|
| 446 |
+
}
|
| 447 |
+
|
| 448 |
+
.action-btn {
|
| 449 |
+
display: grid;
|
| 450 |
+
place-items: center;
|
| 451 |
+
width: 38px;
|
| 452 |
+
height: 38px;
|
| 453 |
+
min-width: 38px;
|
| 454 |
+
border-radius: 10px;
|
| 455 |
+
background: rgba(255, 255, 255, 0.06);
|
| 456 |
+
border: 1px solid rgba(255, 255, 255, 0.08);
|
| 457 |
+
transition: all var(--transition);
|
| 458 |
+
color: var(--text-dim);
|
| 459 |
+
flex-shrink: 0;
|
| 460 |
+
}
|
| 461 |
+
.action-btn:hover {
|
| 462 |
+
background: rgba(255, 255, 255, 0.12);
|
| 463 |
+
border-color: rgba(255, 255, 255, 0.16);
|
| 464 |
+
color: var(--text);
|
| 465 |
+
transform: translateY(-1px);
|
| 466 |
+
}
|
| 467 |
+
.action-btn:active {
|
| 468 |
+
transform: translateY(0);
|
| 469 |
+
}
|
| 470 |
+
|
| 471 |
+
.send-btn {
|
| 472 |
+
background: var(--accent) !important;
|
| 473 |
+
border-color: var(--accent) !important;
|
| 474 |
+
color: #fff !important;
|
| 475 |
+
box-shadow: 0 2px 8px rgba(124, 106, 239, 0.25);
|
| 476 |
+
}
|
| 477 |
+
.send-btn:hover {
|
| 478 |
+
background: #6a58e0 !important;
|
| 479 |
+
border-color: #6a58e0 !important;
|
| 480 |
+
box-shadow: 0 4px 14px rgba(124, 106, 239, 0.35);
|
| 481 |
+
}
|
| 482 |
+
|
| 483 |
+
.send-btn:disabled {
|
| 484 |
+
opacity: 0.5;
|
| 485 |
+
cursor: default;
|
| 486 |
+
box-shadow: none;
|
| 487 |
+
transform: none;
|
| 488 |
+
animation: sendBtnPulse 1.2s ease-in-out infinite;
|
| 489 |
+
}
|
| 490 |
+
@keyframes sendBtnPulse {
|
| 491 |
+
0%, 100% { opacity: 0.5; }
|
| 492 |
+
50% { opacity: 0.7; }
|
| 493 |
+
}
|
| 494 |
+
|
| 495 |
+
.mic-btn .mic-icon-active { display: none; }
|
| 496 |
+
.mic-btn.listening .mic-icon { display: none; }
|
| 497 |
+
.mic-btn.listening .mic-icon-active { display: block; }
|
| 498 |
+
.mic-btn.listening {
|
| 499 |
+
background: rgba(255, 107, 107, 0.18);
|
| 500 |
+
border-color: rgba(255, 107, 107, 0.3);
|
| 501 |
+
color: var(--danger);
|
| 502 |
+
animation: micPulse 1.5s ease-in-out infinite;
|
| 503 |
+
}
|
| 504 |
+
|
| 505 |
+
.mic-btn.auto-listen:not(.listening) {
|
| 506 |
+
border-color: rgba(78, 205, 196, 0.4);
|
| 507 |
+
box-shadow: 0 0 0 1px rgba(78, 205, 196, 0.2);
|
| 508 |
+
}
|
| 509 |
+
|
| 510 |
+
.tts-btn .tts-icon-on { display: none; }
|
| 511 |
+
.tts-btn.tts-active .tts-icon-off { display: none; }
|
| 512 |
+
.tts-btn.tts-active .tts-icon-on { display: block; }
|
| 513 |
+
.tts-btn.tts-active {
|
| 514 |
+
background: rgba(124, 106, 239, 0.18);
|
| 515 |
+
border-color: rgba(124, 106, 239, 0.3);
|
| 516 |
+
color: var(--accent);
|
| 517 |
+
}
|
| 518 |
+
.tts-btn.tts-speaking {
|
| 519 |
+
animation: ttsPulse 1.5s ease-in-out infinite;
|
| 520 |
+
}
|
| 521 |
+
|
| 522 |
+
.input-meta {
|
| 523 |
+
display: flex;
|
| 524 |
+
justify-content: flex-end;
|
| 525 |
+
align-items: center;
|
| 526 |
+
padding: 6px 12px 0;
|
| 527 |
+
min-height: 0;
|
| 528 |
+
font-size: 0.66rem;
|
| 529 |
+
color: var(--text-muted);
|
| 530 |
+
}
|
| 531 |
+
|
| 532 |
+
.panel-overlay {
|
| 533 |
+
position: fixed;
|
| 534 |
+
inset: 0;
|
| 535 |
+
z-index: 19;
|
| 536 |
+
background: transparent;
|
| 537 |
+
opacity: 0;
|
| 538 |
+
visibility: hidden;
|
| 539 |
+
transition: opacity 0.3s ease, visibility 0.3s ease;
|
| 540 |
+
pointer-events: none;
|
| 541 |
+
}
|
| 542 |
+
.panel-overlay.visible {
|
| 543 |
+
opacity: 1;
|
| 544 |
+
visibility: visible;
|
| 545 |
+
pointer-events: none;
|
| 546 |
+
}
|
| 547 |
+
|
| 548 |
+
.speech-widget {
|
| 549 |
+
position: fixed;
|
| 550 |
+
bottom: calc(var(--input-bar-h) + var(--panel-gap));
|
| 551 |
+
left: 50%;
|
| 552 |
+
transform: translateX(-50%) translateY(8px);
|
| 553 |
+
z-index: 15;
|
| 554 |
+
min-width: min(320px, 90vw);
|
| 555 |
+
max-width: min(560px, 95vw);
|
| 556 |
+
padding: 14px 20px;
|
| 557 |
+
border-radius: 16px;
|
| 558 |
+
background: rgba(10, 10, 28, 0.65);
|
| 559 |
+
backdrop-filter: blur(24px) saturate(1.3);
|
| 560 |
+
-webkit-backdrop-filter: blur(24px) saturate(1.3);
|
| 561 |
+
border: 1px solid rgba(255, 255, 255, 0.1);
|
| 562 |
+
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.35), 0 0 0 1px rgba(124, 106, 239, 0.08);
|
| 563 |
+
opacity: 0;
|
| 564 |
+
visibility: hidden;
|
| 565 |
+
pointer-events: none;
|
| 566 |
+
transition: opacity 0.3s ease, visibility 0.3s ease, transform 0.3s ease;
|
| 567 |
+
}
|
| 568 |
+
.speech-widget.visible {
|
| 569 |
+
opacity: 1;
|
| 570 |
+
visibility: visible;
|
| 571 |
+
transform: translateX(-50%) translateY(0);
|
| 572 |
+
animation: speechWidgetIn 0.35s cubic-bezier(0.4, 0, 0.2, 1);
|
| 573 |
+
}
|
| 574 |
+
@keyframes speechWidgetIn {
|
| 575 |
+
from { opacity: 0; transform: translateX(-50%) translateY(12px); }
|
| 576 |
+
to { opacity: 1; transform: translateX(-50%) translateY(0); }
|
| 577 |
+
}
|
| 578 |
+
.speech-widget-inner {
|
| 579 |
+
display: flex;
|
| 580 |
+
flex-direction: column;
|
| 581 |
+
gap: 6px;
|
| 582 |
+
}
|
| 583 |
+
.speech-widget-label {
|
| 584 |
+
font-size: 0.7rem;
|
| 585 |
+
font-weight: 600;
|
| 586 |
+
color: var(--accent);
|
| 587 |
+
text-transform: uppercase;
|
| 588 |
+
letter-spacing: 1px;
|
| 589 |
+
display: flex;
|
| 590 |
+
align-items: center;
|
| 591 |
+
gap: 8px;
|
| 592 |
+
}
|
| 593 |
+
.speech-widget-label::before {
|
| 594 |
+
content: '';
|
| 595 |
+
width: 6px;
|
| 596 |
+
height: 6px;
|
| 597 |
+
border-radius: 50%;
|
| 598 |
+
background: var(--danger);
|
| 599 |
+
animation: pulse-dot 1.2s ease-in-out infinite;
|
| 600 |
+
}
|
| 601 |
+
.speech-widget-text {
|
| 602 |
+
font-size: 0.9rem;
|
| 603 |
+
line-height: 1.5;
|
| 604 |
+
color: var(--text);
|
| 605 |
+
min-height: 1.5em;
|
| 606 |
+
word-wrap: break-word;
|
| 607 |
+
overflow-wrap: break-word;
|
| 608 |
+
}
|
| 609 |
+
.speech-widget-text:empty::before {
|
| 610 |
+
content: 'Speak now...';
|
| 611 |
+
color: var(--text-muted);
|
| 612 |
+
font-style: italic;
|
| 613 |
+
}
|
| 614 |
+
|
| 615 |
+
.toast-container {
|
| 616 |
+
position: fixed;
|
| 617 |
+
bottom: 100px;
|
| 618 |
+
left: 50%;
|
| 619 |
+
transform: translateX(-50%);
|
| 620 |
+
z-index: 100;
|
| 621 |
+
display: flex;
|
| 622 |
+
flex-direction: column;
|
| 623 |
+
gap: 8px;
|
| 624 |
+
pointer-events: none;
|
| 625 |
+
}
|
| 626 |
+
.toast {
|
| 627 |
+
padding: 12px 20px;
|
| 628 |
+
background: rgba(20, 20, 35, 0.95);
|
| 629 |
+
border: 1px solid rgba(255, 107, 107, 0.4);
|
| 630 |
+
border-radius: var(--radius-sm);
|
| 631 |
+
color: var(--text);
|
| 632 |
+
font-size: 0.9rem;
|
| 633 |
+
max-width: min(400px, 90vw);
|
| 634 |
+
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
|
| 635 |
+
opacity: 0;
|
| 636 |
+
transform: translateY(12px);
|
| 637 |
+
transition: opacity 0.3s ease, transform 0.3s ease;
|
| 638 |
+
pointer-events: auto;
|
| 639 |
+
cursor: pointer;
|
| 640 |
+
}
|
| 641 |
+
.toast.toast-visible {
|
| 642 |
+
opacity: 1;
|
| 643 |
+
transform: translateY(0);
|
| 644 |
+
}
|
| 645 |
+
|
| 646 |
+
.activity-panel {
|
| 647 |
+
position: fixed;
|
| 648 |
+
top: calc(var(--header-h) + var(--panel-gap));
|
| 649 |
+
left: 0;
|
| 650 |
+
bottom: calc(var(--input-bar-h) + var(--panel-gap));
|
| 651 |
+
width: min(340px, 90vw);
|
| 652 |
+
min-width: 0;
|
| 653 |
+
height: auto;
|
| 654 |
+
z-index: 6;
|
| 655 |
+
display: flex;
|
| 656 |
+
flex-direction: column;
|
| 657 |
+
border-radius: 0 var(--radius) var(--radius) 0;
|
| 658 |
+
box-shadow: 8px 0 40px rgba(0, 0, 0, 0.35), 0 0 0 1px rgba(255, 255, 255, 0.04);
|
| 659 |
+
overflow: hidden;
|
| 660 |
+
transform: translateX(-100%);
|
| 661 |
+
transition: transform 0.35s cubic-bezier(0.4, 0, 0.2, 1);
|
| 662 |
+
}
|
| 663 |
+
.activity-panel.open {
|
| 664 |
+
transform: translateX(0);
|
| 665 |
+
}
|
| 666 |
+
.activity-header {
|
| 667 |
+
display: flex;
|
| 668 |
+
align-items: center;
|
| 669 |
+
justify-content: space-between;
|
| 670 |
+
padding: 18px 20px;
|
| 671 |
+
border-bottom: 1px solid var(--glass-border);
|
| 672 |
+
flex-shrink: 0;
|
| 673 |
+
}
|
| 674 |
+
.activity-title {
|
| 675 |
+
font-size: 0.9rem;
|
| 676 |
+
font-weight: 600;
|
| 677 |
+
color: var(--text);
|
| 678 |
+
display: flex;
|
| 679 |
+
align-items: center;
|
| 680 |
+
gap: 8px;
|
| 681 |
+
}
|
| 682 |
+
.activity-title::before {
|
| 683 |
+
content: '';
|
| 684 |
+
width: 8px;
|
| 685 |
+
height: 8px;
|
| 686 |
+
border-radius: 50%;
|
| 687 |
+
background: var(--accent);
|
| 688 |
+
box-shadow: 0 0 8px rgba(124, 106, 239, 0.5);
|
| 689 |
+
}
|
| 690 |
+
.activity-close {
|
| 691 |
+
display: grid;
|
| 692 |
+
place-items: center;
|
| 693 |
+
width: 32px;
|
| 694 |
+
height: 32px;
|
| 695 |
+
border-radius: var(--radius-sm);
|
| 696 |
+
background: rgba(255, 255, 255, 0.06);
|
| 697 |
+
border: 1px solid var(--glass-border);
|
| 698 |
+
color: var(--text-dim);
|
| 699 |
+
cursor: pointer;
|
| 700 |
+
transition: all var(--transition);
|
| 701 |
+
}
|
| 702 |
+
.activity-close:hover {
|
| 703 |
+
background: rgba(255, 255, 255, 0.12);
|
| 704 |
+
color: var(--text);
|
| 705 |
+
}
|
| 706 |
+
.activity-list {
|
| 707 |
+
flex: 1;
|
| 708 |
+
min-height: 0;
|
| 709 |
+
overflow-y: auto;
|
| 710 |
+
overflow-x: hidden;
|
| 711 |
+
padding: 16px 20px 24px;
|
| 712 |
+
display: flex;
|
| 713 |
+
flex-direction: column;
|
| 714 |
+
gap: 14px;
|
| 715 |
+
scroll-behavior: smooth;
|
| 716 |
+
scrollbar-width: none;
|
| 717 |
+
-ms-overflow-style: none;
|
| 718 |
+
}
|
| 719 |
+
.activity-list::-webkit-scrollbar { display: none; }
|
| 720 |
+
.activity-empty {
|
| 721 |
+
padding: 24px 16px;
|
| 722 |
+
font-size: 0.8rem;
|
| 723 |
+
color: var(--text-muted);
|
| 724 |
+
text-align: center;
|
| 725 |
+
line-height: 1.5;
|
| 726 |
+
}
|
| 727 |
+
.activity-item {
|
| 728 |
+
padding: 14px 16px;
|
| 729 |
+
border-radius: var(--radius-sm);
|
| 730 |
+
background: rgba(255, 255, 255, 0.04);
|
| 731 |
+
border: 1px solid rgba(255, 255, 255, 0.07);
|
| 732 |
+
font-size: 0.78rem;
|
| 733 |
+
line-height: 1.55;
|
| 734 |
+
animation: activityIn 0.25s ease-out;
|
| 735 |
+
}
|
| 736 |
+
.activity-item .activity-event {
|
| 737 |
+
font-weight: 600;
|
| 738 |
+
color: var(--accent);
|
| 739 |
+
margin-bottom: 6px;
|
| 740 |
+
display: flex;
|
| 741 |
+
align-items: center;
|
| 742 |
+
gap: 8px;
|
| 743 |
+
}
|
| 744 |
+
.activity-step {
|
| 745 |
+
display: inline-flex;
|
| 746 |
+
align-items: center;
|
| 747 |
+
justify-content: center;
|
| 748 |
+
min-width: 20px;
|
| 749 |
+
height: 20px;
|
| 750 |
+
padding: 0 5px;
|
| 751 |
+
border-radius: 6px;
|
| 752 |
+
background: rgba(124, 106, 239, 0.2);
|
| 753 |
+
color: var(--accent);
|
| 754 |
+
font-size: 0.7rem;
|
| 755 |
+
font-weight: 700;
|
| 756 |
+
}
|
| 757 |
+
.activity-item .activity-detail {
|
| 758 |
+
color: var(--text-dim);
|
| 759 |
+
word-wrap: break-word;
|
| 760 |
+
overflow-wrap: break-word;
|
| 761 |
+
white-space: pre-wrap;
|
| 762 |
+
max-height: 300px;
|
| 763 |
+
overflow-y: auto;
|
| 764 |
+
}
|
| 765 |
+
.activity-item.route-general .activity-event,
|
| 766 |
+
.activity-item.route-general .activity-step { color: #7dd3fc; }
|
| 767 |
+
.activity-item.route-general .activity-step { background: rgba(125, 211, 252, 0.15); }
|
| 768 |
+
.activity-item.route-realtime .activity-event,
|
| 769 |
+
.activity-item.route-realtime .activity-step { color: var(--success); }
|
| 770 |
+
.activity-item.route-realtime .activity-step { background: rgba(81, 207, 102, 0.15); }
|
| 771 |
+
.activity-item.activity-sub {
|
| 772 |
+
padding: 12px 14px;
|
| 773 |
+
font-size: 0.75rem;
|
| 774 |
+
border-left: 3px solid rgba(124, 106, 239, 0.4);
|
| 775 |
+
}
|
| 776 |
+
.activity-item.activity-sub .activity-event { font-size: 0.76rem; }
|
| 777 |
+
@keyframes activityIn {
|
| 778 |
+
from { opacity: 0; transform: translateX(-8px); }
|
| 779 |
+
to { opacity: 1; transform: translateX(0); }
|
| 780 |
+
}
|
| 781 |
+
|
| 782 |
+
.search-results-widget {
|
| 783 |
+
position: fixed;
|
| 784 |
+
top: calc(var(--header-h) + var(--panel-gap));
|
| 785 |
+
right: 0;
|
| 786 |
+
bottom: calc(var(--input-bar-h) + var(--panel-gap));
|
| 787 |
+
width: min(380px, 95vw);
|
| 788 |
+
min-width: 0;
|
| 789 |
+
height: auto;
|
| 790 |
+
z-index: 6;
|
| 791 |
+
display: flex;
|
| 792 |
+
flex-direction: column;
|
| 793 |
+
border-radius: var(--radius) 0 0 var(--radius);
|
| 794 |
+
border-right: none;
|
| 795 |
+
box-shadow: -8px 0 40px rgba(0, 0, 0, 0.35), 0 0 0 1px rgba(255, 255, 255, 0.04);
|
| 796 |
+
overflow: hidden;
|
| 797 |
+
transform: translateX(100%);
|
| 798 |
+
transition: transform 0.35s cubic-bezier(0.4, 0, 0.2, 1);
|
| 799 |
+
}
|
| 800 |
+
.search-results-widget.open {
|
| 801 |
+
transform: translateX(0);
|
| 802 |
+
}
|
| 803 |
+
.search-results-header {
|
| 804 |
+
display: flex;
|
| 805 |
+
align-items: center;
|
| 806 |
+
justify-content: space-between;
|
| 807 |
+
padding: 18px 20px;
|
| 808 |
+
border-bottom: 1px solid var(--glass-border);
|
| 809 |
+
flex-shrink: 0;
|
| 810 |
+
}
|
| 811 |
+
.search-results-title {
|
| 812 |
+
font-size: 0.9rem;
|
| 813 |
+
font-weight: 600;
|
| 814 |
+
color: var(--text);
|
| 815 |
+
display: flex;
|
| 816 |
+
align-items: center;
|
| 817 |
+
gap: 8px;
|
| 818 |
+
min-width: 0;
|
| 819 |
+
}
|
| 820 |
+
.search-results-title::before {
|
| 821 |
+
content: '';
|
| 822 |
+
width: 8px;
|
| 823 |
+
height: 8px;
|
| 824 |
+
border-radius: 50%;
|
| 825 |
+
background: var(--success);
|
| 826 |
+
box-shadow: 0 0 8px var(--success);
|
| 827 |
+
animation: pulse-dot 2s ease-in-out infinite;
|
| 828 |
+
flex-shrink: 0;
|
| 829 |
+
}
|
| 830 |
+
.search-results-close {
|
| 831 |
+
display: grid;
|
| 832 |
+
place-items: center;
|
| 833 |
+
width: 32px;
|
| 834 |
+
height: 32px;
|
| 835 |
+
border-radius: var(--radius-sm);
|
| 836 |
+
background: rgba(255, 255, 255, 0.06);
|
| 837 |
+
border: 1px solid var(--glass-border);
|
| 838 |
+
color: var(--text-dim);
|
| 839 |
+
cursor: pointer;
|
| 840 |
+
transition: all var(--transition);
|
| 841 |
+
flex-shrink: 0;
|
| 842 |
+
}
|
| 843 |
+
.search-results-close:hover {
|
| 844 |
+
background: rgba(255, 255, 255, 0.12);
|
| 845 |
+
color: var(--text);
|
| 846 |
+
}
|
| 847 |
+
.search-results-query {
|
| 848 |
+
padding: 12px 20px 14px;
|
| 849 |
+
font-size: 0.75rem;
|
| 850 |
+
color: var(--accent);
|
| 851 |
+
font-weight: 500;
|
| 852 |
+
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
| 853 |
+
flex-shrink: 0;
|
| 854 |
+
word-wrap: break-word;
|
| 855 |
+
overflow-wrap: break-word;
|
| 856 |
+
word-break: break-word;
|
| 857 |
+
}
|
| 858 |
+
.search-results-answer {
|
| 859 |
+
padding: 16px 20px 18px;
|
| 860 |
+
font-size: 0.85rem;
|
| 861 |
+
line-height: 1.55;
|
| 862 |
+
color: var(--text);
|
| 863 |
+
background: rgba(124, 106, 239, 0.08);
|
| 864 |
+
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
|
| 865 |
+
flex-shrink: 0;
|
| 866 |
+
max-height: 200px;
|
| 867 |
+
min-height: 0;
|
| 868 |
+
overflow-y: auto;
|
| 869 |
+
overflow-x: hidden;
|
| 870 |
+
word-wrap: break-word;
|
| 871 |
+
overflow-wrap: break-word;
|
| 872 |
+
}
|
| 873 |
+
.search-results-list {
|
| 874 |
+
flex: 1;
|
| 875 |
+
min-height: 0;
|
| 876 |
+
overflow-y: auto;
|
| 877 |
+
overflow-x: hidden;
|
| 878 |
+
padding: 16px 20px 24px;
|
| 879 |
+
display: flex;
|
| 880 |
+
flex-direction: column;
|
| 881 |
+
gap: 14px;
|
| 882 |
+
scroll-behavior: smooth;
|
| 883 |
+
}
|
| 884 |
+
.search-result-card {
|
| 885 |
+
padding: 14px 16px;
|
| 886 |
+
border-radius: var(--radius-sm);
|
| 887 |
+
background: rgba(255, 255, 255, 0.04);
|
| 888 |
+
border: 1px solid rgba(255, 255, 255, 0.07);
|
| 889 |
+
transition: background var(--transition), border-color var(--transition);
|
| 890 |
+
min-width: 0;
|
| 891 |
+
display: flex;
|
| 892 |
+
flex-direction: column;
|
| 893 |
+
gap: 8px;
|
| 894 |
+
}
|
| 895 |
+
.search-result-card:hover {
|
| 896 |
+
background: rgba(255, 255, 255, 0.07);
|
| 897 |
+
border-color: rgba(255, 255, 255, 0.1);
|
| 898 |
+
}
|
| 899 |
+
.search-result-card .card-title {
|
| 900 |
+
font-size: 0.8rem;
|
| 901 |
+
font-weight: 600;
|
| 902 |
+
color: var(--text);
|
| 903 |
+
line-height: 1.35;
|
| 904 |
+
word-wrap: break-word;
|
| 905 |
+
overflow-wrap: break-word;
|
| 906 |
+
word-break: break-word;
|
| 907 |
+
}
|
| 908 |
+
.search-result-card .card-content {
|
| 909 |
+
font-size: 0.76rem;
|
| 910 |
+
color: var(--text-dim);
|
| 911 |
+
line-height: 1.5;
|
| 912 |
+
word-wrap: break-word;
|
| 913 |
+
overflow-wrap: break-word;
|
| 914 |
+
word-break: break-word;
|
| 915 |
+
display: -webkit-box;
|
| 916 |
+
-webkit-line-clamp: 4;
|
| 917 |
+
-webkit-box-orient: vertical;
|
| 918 |
+
overflow: hidden;
|
| 919 |
+
}
|
| 920 |
+
.search-result-card .card-url {
|
| 921 |
+
font-size: 0.7rem;
|
| 922 |
+
color: var(--accent);
|
| 923 |
+
text-decoration: none;
|
| 924 |
+
overflow: hidden;
|
| 925 |
+
text-overflow: ellipsis;
|
| 926 |
+
white-space: nowrap;
|
| 927 |
+
display: block;
|
| 928 |
+
}
|
| 929 |
+
.search-result-card .card-url:hover {
|
| 930 |
+
text-decoration: underline;
|
| 931 |
+
}
|
| 932 |
+
.search-result-card .card-score {
|
| 933 |
+
font-size: 0.68rem;
|
| 934 |
+
color: var(--text-muted);
|
| 935 |
+
}
|
| 936 |
+
|
| 937 |
+
.search-results-answer::-webkit-scrollbar,
|
| 938 |
+
.search-results-list::-webkit-scrollbar {
|
| 939 |
+
width: 6px;
|
| 940 |
+
}
|
| 941 |
+
.search-results-answer::-webkit-scrollbar-track,
|
| 942 |
+
.search-results-list::-webkit-scrollbar-track {
|
| 943 |
+
background: rgba(255, 255, 255, 0.03);
|
| 944 |
+
border-radius: 10px;
|
| 945 |
+
}
|
| 946 |
+
.search-results-answer::-webkit-scrollbar-thumb,
|
| 947 |
+
.search-results-list::-webkit-scrollbar-thumb {
|
| 948 |
+
background: rgba(255, 255, 255, 0.12);
|
| 949 |
+
border-radius: 10px;
|
| 950 |
+
}
|
| 951 |
+
.search-results-answer::-webkit-scrollbar-thumb:hover,
|
| 952 |
+
.search-results-list::-webkit-scrollbar-thumb:hover {
|
| 953 |
+
background: rgba(255, 255, 255, 0.2);
|
| 954 |
+
}
|
| 955 |
+
@supports (scrollbar-color: rgba(255,255,255,0.12) rgba(255,255,255,0.03)) {
|
| 956 |
+
.search-results-answer,
|
| 957 |
+
.search-results-list {
|
| 958 |
+
scrollbar-color: rgba(255, 255, 255, 0.12) rgba(255, 255, 255, 0.03);
|
| 959 |
+
scrollbar-width: thin;
|
| 960 |
+
}
|
| 961 |
+
}
|
| 962 |
+
|
| 963 |
+
.settings-panel {
|
| 964 |
+
position: fixed;
|
| 965 |
+
top: 50%;
|
| 966 |
+
left: 50%;
|
| 967 |
+
transform: translate(-50%, -50%) scale(0.95);
|
| 968 |
+
width: min(360px, 92vw);
|
| 969 |
+
z-index: 21;
|
| 970 |
+
border-radius: var(--radius);
|
| 971 |
+
padding: 0;
|
| 972 |
+
overflow: hidden;
|
| 973 |
+
opacity: 0;
|
| 974 |
+
visibility: hidden;
|
| 975 |
+
pointer-events: none;
|
| 976 |
+
transition: opacity 0.25s ease, visibility 0.25s ease, transform 0.25s ease;
|
| 977 |
+
}
|
| 978 |
+
.settings-panel.open {
|
| 979 |
+
opacity: 1;
|
| 980 |
+
visibility: visible;
|
| 981 |
+
pointer-events: auto;
|
| 982 |
+
transform: translate(-50%, -50%) scale(1);
|
| 983 |
+
}
|
| 984 |
+
.settings-header {
|
| 985 |
+
display: flex;
|
| 986 |
+
align-items: center;
|
| 987 |
+
justify-content: space-between;
|
| 988 |
+
padding: 18px 20px;
|
| 989 |
+
border-bottom: 1px solid var(--glass-border);
|
| 990 |
+
}
|
| 991 |
+
.settings-title {
|
| 992 |
+
font-size: 1rem;
|
| 993 |
+
font-weight: 600;
|
| 994 |
+
color: var(--text);
|
| 995 |
+
}
|
| 996 |
+
.settings-body {
|
| 997 |
+
padding: 24px 20px 28px;
|
| 998 |
+
display: flex;
|
| 999 |
+
flex-direction: column;
|
| 1000 |
+
gap: 18px;
|
| 1001 |
+
}
|
| 1002 |
+
.settings-item {
|
| 1003 |
+
display: flex;
|
| 1004 |
+
align-items: center;
|
| 1005 |
+
justify-content: space-between;
|
| 1006 |
+
gap: 12px;
|
| 1007 |
+
}
|
| 1008 |
+
.settings-label {
|
| 1009 |
+
font-size: 0.88rem;
|
| 1010 |
+
color: var(--text);
|
| 1011 |
+
cursor: pointer;
|
| 1012 |
+
flex: 1;
|
| 1013 |
+
}
|
| 1014 |
+
.settings-hint {
|
| 1015 |
+
font-size: 0.75rem;
|
| 1016 |
+
color: var(--text-muted);
|
| 1017 |
+
line-height: 1.5;
|
| 1018 |
+
margin-top: 4px;
|
| 1019 |
+
}
|
| 1020 |
+
|
| 1021 |
+
.toggle-switch {
|
| 1022 |
+
position: relative;
|
| 1023 |
+
display: inline-block;
|
| 1024 |
+
width: 44px;
|
| 1025 |
+
height: 24px;
|
| 1026 |
+
flex-shrink: 0;
|
| 1027 |
+
}
|
| 1028 |
+
.toggle-switch input {
|
| 1029 |
+
opacity: 0;
|
| 1030 |
+
width: 0;
|
| 1031 |
+
height: 0;
|
| 1032 |
+
}
|
| 1033 |
+
.toggle-slider {
|
| 1034 |
+
position: absolute;
|
| 1035 |
+
cursor: pointer;
|
| 1036 |
+
inset: 0;
|
| 1037 |
+
background: rgba(255, 255, 255, 0.12);
|
| 1038 |
+
border-radius: 24px;
|
| 1039 |
+
transition: background var(--transition);
|
| 1040 |
+
}
|
| 1041 |
+
.toggle-slider::before {
|
| 1042 |
+
content: '';
|
| 1043 |
+
position: absolute;
|
| 1044 |
+
height: 18px;
|
| 1045 |
+
width: 18px;
|
| 1046 |
+
left: 3px;
|
| 1047 |
+
bottom: 3px;
|
| 1048 |
+
background: #fff;
|
| 1049 |
+
border-radius: 50%;
|
| 1050 |
+
transition: transform var(--transition);
|
| 1051 |
+
}
|
| 1052 |
+
.toggle-switch input:checked + .toggle-slider {
|
| 1053 |
+
background: var(--accent);
|
| 1054 |
+
}
|
| 1055 |
+
.toggle-switch input:checked + .toggle-slider::before {
|
| 1056 |
+
transform: translateX(20px);
|
| 1057 |
+
}
|
| 1058 |
+
|
| 1059 |
+
.chat-messages::-webkit-scrollbar { width: 4px; }
|
| 1060 |
+
.chat-messages::-webkit-scrollbar-track { background: transparent; }
|
| 1061 |
+
.chat-messages::-webkit-scrollbar-thumb {
|
| 1062 |
+
background: rgba(255, 255, 255, 0.08);
|
| 1063 |
+
border-radius: 10px;
|
| 1064 |
+
}
|
| 1065 |
+
.chat-messages::-webkit-scrollbar-thumb:hover { background: rgba(255, 255, 255, 0.14); }
|
| 1066 |
+
|
| 1067 |
+
@keyframes fadeIn {
|
| 1068 |
+
from { opacity: 0; transform: translateY(12px); }
|
| 1069 |
+
to { opacity: 1; transform: translateY(0); }
|
| 1070 |
+
}
|
| 1071 |
+
@keyframes msgIn {
|
| 1072 |
+
from { opacity: 0; transform: translateY(8px) scale(0.98); }
|
| 1073 |
+
to { opacity: 1; transform: translateY(0) scale(1); }
|
| 1074 |
+
}
|
| 1075 |
+
@keyframes dotBounce {
|
| 1076 |
+
0%, 60%, 100% { transform: translateY(0); opacity: 0.4; }
|
| 1077 |
+
30% { transform: translateY(-5px); opacity: 1; }
|
| 1078 |
+
}
|
| 1079 |
+
@keyframes blink {
|
| 1080 |
+
50% { opacity: 0; }
|
| 1081 |
+
}
|
| 1082 |
+
@keyframes pulse-dot {
|
| 1083 |
+
0%, 100% { opacity: 1; }
|
| 1084 |
+
50% { opacity: 0.4; }
|
| 1085 |
+
}
|
| 1086 |
+
@keyframes micPulse {
|
| 1087 |
+
0%, 100% { box-shadow: 0 0 0 0 rgba(255, 107, 107, 0.3); }
|
| 1088 |
+
50% { box-shadow: 0 0 0 8px rgba(255, 107, 107, 0); }
|
| 1089 |
+
}
|
| 1090 |
+
@keyframes ttsPulse {
|
| 1091 |
+
0%, 100% { box-shadow: 0 0 0 0 rgba(124, 106, 239, 0.3); }
|
| 1092 |
+
50% { box-shadow: 0 0 0 8px rgba(124, 106, 239, 0); }
|
| 1093 |
+
}
|
| 1094 |
+
@keyframes orbPulse {
|
| 1095 |
+
0%, 100% { transform: scale(1); opacity: 0.92; }
|
| 1096 |
+
50% { transform: scale(1.10); opacity: 1; }
|
| 1097 |
+
}
|
| 1098 |
+
|
| 1099 |
+
@media (max-width: 768px) {
|
| 1100 |
+
:root { --input-bar-h: calc(100px + env(safe-area-inset-bottom, 0px)); --content-gap: 12px; --panel-gap: 12px; }
|
| 1101 |
+
.header { padding: 0 16px; gap: 12px; margin-bottom: var(--content-gap); }
|
| 1102 |
+
.header-right { gap: 8px; }
|
| 1103 |
+
.btn-icon { width: 38px; height: 38px; min-width: 38px; min-height: 38px; }
|
| 1104 |
+
.tagline { display: none; }
|
| 1105 |
+
.logo { font-size: 1rem; }
|
| 1106 |
+
.mode-btn { padding: 6px 10px; font-size: 0.72rem; }
|
| 1107 |
+
.mode-btn .mode-btn-text { display: none; }
|
| 1108 |
+
.status-badge .status-text { display: none; }
|
| 1109 |
+
.chat-messages { padding: 18px 16px 22px; gap: 12px; }
|
| 1110 |
+
.input-bar { padding: 12px 16px 12px; padding-bottom: max(12px, env(safe-area-inset-bottom, 12px)); margin-top: var(--content-gap); }
|
| 1111 |
+
.input-wrapper { padding: 4px 4px 4px 12px; }
|
| 1112 |
+
.action-btn { width: 36px; height: 36px; min-width: 36px; border-radius: 9px; }
|
| 1113 |
+
.msg-content { font-size: 0.84rem; padding: 10px 13px; }
|
| 1114 |
+
.welcome-title { font-size: 1.3rem; }
|
| 1115 |
+
.message { gap: 8px; }
|
| 1116 |
+
.msg-avatar { width: 26px; height: 26px; font-size: 0.62rem; }
|
| 1117 |
+
.msg-avatar .msg-avatar-icon { width: 16px; height: 16px; }
|
| 1118 |
+
.search-results-widget { width: min(100vw, 360px); }
|
| 1119 |
+
.search-results-header { padding: 14px 16px; }
|
| 1120 |
+
.search-results-query,
|
| 1121 |
+
.search-results-answer { padding: 12px 16px; }
|
| 1122 |
+
.search-results-list { padding: 12px 16px 20px; gap: 12px; }
|
| 1123 |
+
.search-result-card { padding: 12px 14px; }
|
| 1124 |
+
.activity-panel { width: min(100vw, 320px); }
|
| 1125 |
+
.activity-header { padding: 14px 16px; }
|
| 1126 |
+
.activity-list { padding: 12px 16px 20px; gap: 12px; }
|
| 1127 |
+
.speech-widget { bottom: calc(var(--input-bar-h) + var(--panel-gap)); min-width: min(280px, 88vw); padding: 12px 16px; }
|
| 1128 |
+
.speech-widget-text { font-size: 0.85rem; }
|
| 1129 |
+
}
|
| 1130 |
+
|
| 1131 |
+
@media (max-width: 480px) {
|
| 1132 |
+
:root { --input-bar-h: calc(95px + env(safe-area-inset-bottom, 0px)); --content-gap: 10px; --panel-gap: 10px; }
|
| 1133 |
+
.header { padding: 0 12px; margin-bottom: var(--content-gap); gap: 8px; }
|
| 1134 |
+
.header-left { flex-shrink: 0; }
|
| 1135 |
+
.header-center { flex: 1; justify-content: center; display: flex; min-width: 0; }
|
| 1136 |
+
.header-right { gap: 6px; }
|
| 1137 |
+
.btn-icon { width: 36px; height: 36px; min-width: 36px; min-height: 36px; }
|
| 1138 |
+
.logo { font-size: 0.9rem; letter-spacing: 2px; }
|
| 1139 |
+
.mode-switch { width: 100%; }
|
| 1140 |
+
.mode-btn { flex: 1; justify-content: center; }
|
| 1141 |
+
.new-chat-btn { display: none; }
|
| 1142 |
+
.chat-messages { padding: 14px 12px 18px; gap: 10px; }
|
| 1143 |
+
.input-bar { padding: 10px 12px 10px; padding-bottom: max(10px, env(safe-area-inset-bottom, 10px)); margin-top: var(--content-gap); }
|
| 1144 |
+
.welcome-chips { gap: 8px; }
|
| 1145 |
+
.chip { font-size: 0.72rem; padding: 8px 16px; }
|
| 1146 |
+
.action-btn { width: 34px; height: 34px; min-width: 34px; border-radius: 8px; }
|
| 1147 |
+
.action-btn svg { width: 17px; height: 17px; }
|
| 1148 |
+
.input-actions { gap: 5px; }
|
| 1149 |
+
.input-wrapper { gap: 4px; }
|
| 1150 |
+
.search-results-widget { width: 100vw; max-width: 100%; }
|
| 1151 |
+
.search-results-header { padding: 12px 14px; }
|
| 1152 |
+
.search-results-query { font-size: 0.72rem; padding: 10px 14px 12px; }
|
| 1153 |
+
.search-results-answer { font-size: 0.82rem; padding: 12px 14px; max-height: 160px; }
|
| 1154 |
+
.search-results-list { padding: 12px 14px 18px; gap: 10px; }
|
| 1155 |
+
.search-result-card { padding: 12px 14px; }
|
| 1156 |
+
.search-result-card .card-title { font-size: 0.76rem; }
|
| 1157 |
+
.search-result-card .card-content { font-size: 0.72rem; -webkit-line-clamp: 3; }
|
| 1158 |
+
.activity-panel { width: 100vw; }
|
| 1159 |
+
.activity-header { padding: 12px 14px; }
|
| 1160 |
+
.activity-list { padding: 12px 14px 18px; gap: 10px; }
|
| 1161 |
+
.mode-switch-three .mode-btn { padding: 8px 10px; font-size: 0.68rem; }
|
| 1162 |
+
.mode-switch-three .mode-btn .mode-btn-text { display: none; }
|
| 1163 |
+
.speech-widget { bottom: calc(var(--input-bar-h) + var(--panel-gap)); min-width: min(260px, 92vw); padding: 10px 14px; }
|
| 1164 |
+
.speech-widget-label { font-size: 0.65rem; }
|
| 1165 |
+
.speech-widget-text { font-size: 0.82rem; }
|
| 1166 |
+
}
|