aaditkumar commited on
Commit
d9d0b0f
·
verified ·
1 Parent(s): cd9fa64

Upload 14 files

Browse files
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, '&amp;')
1179
+ .replace(/"/g, '&quot;')
1180
+ .replace(/</g, '&lt;')
1181
+ .replace(/>/g, '&gt;');
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
+ }