agentgui 1.0.146 → 1.0.147

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/speech.js CHANGED
@@ -167,7 +167,11 @@ async function transcribe(audioBuffer) {
167
167
  const decoded = decodeWavToFloat32(buf);
168
168
  audio = resampleTo16k(decoded.audio, decoded.sampleRate);
169
169
  } else {
170
- audio = new Float32Array(buf.buffer, buf.byteOffset, buf.byteLength / 4);
170
+ const sampleCount = Math.floor(buf.byteLength / 4);
171
+ if (sampleCount === 0) throw new Error('Audio buffer too small');
172
+ const aligned = new ArrayBuffer(sampleCount * 4);
173
+ new Uint8Array(aligned).set(buf.subarray(0, sampleCount * 4));
174
+ audio = new Float32Array(aligned);
171
175
  }
172
176
  const result = await stt(audio);
173
177
  return result.text || '';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentgui",
3
- "version": "1.0.146",
3
+ "version": "1.0.147",
4
4
  "description": "Multi-agent ACP client with real-time communication",
5
5
  "type": "module",
6
6
  "main": "server.js",
package/server.js CHANGED
@@ -15,7 +15,7 @@ const express = require('express');
15
15
  const Busboy = require('busboy');
16
16
  const fsbrowse = require('fsbrowse');
17
17
 
18
- const SYSTEM_PROMPT = `Always write your responses in ripple-ui enhanced HTML. Avoid overriding light/dark mode CSS variables. Use all the benefits of HTML to express technical details with proper semantic markup, tables, code blocks, headings, and lists. Write clean, well-structured HTML that respects the existing design system.`;
18
+ const SYSTEM_PROMPT = `Write all responses as clean semantic HTML. Use tags like <h3>, <p>, <ul>, <li>, <ol>, <table>, <code>, <pre>, <strong>, <em>, <a>, <blockquote>, <details>, <summary>. Your HTML will be rendered directly in a styled container that already provides fonts, colors, spacing, and dark mode support. Do not include <html>, <head>, <body>, <style>, or <script> tags. Do not use inline styles unless necessary for layout like tables. Do not use CSS class names. Just write semantic HTML content.`;
19
19
 
20
20
  const activeExecutions = new Map();
21
21
  const messageQueues = new Map();
@@ -522,7 +522,7 @@ const server = http.createServer(async (req, res) => {
522
522
  const mimeTypes = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp', '.svg': 'image/svg+xml' };
523
523
  const contentType = mimeTypes[ext] || 'application/octet-stream';
524
524
  const fileContent = fs.readFileSync(normalizedPath);
525
- res.writeHead(200, { 'Content-Type': contentType, 'Cache-Control': 'public, max-age=3600' });
525
+ res.writeHead(200, { 'Content-Type': contentType, 'Cache-Control': 'no-cache' });
526
526
  res.end(fileContent);
527
527
  } catch (err) {
528
528
  res.writeHead(400, { 'Content-Type': 'application/json' });
@@ -574,7 +574,7 @@ function serveFile(filePath, res) {
574
574
  res.writeHead(200, {
575
575
  'Content-Type': contentType,
576
576
  'Content-Length': stats.size,
577
- 'Cache-Control': 'public, max-age=3600'
577
+ 'Cache-Control': 'no-cache, must-revalidate'
578
578
  });
579
579
  fs.createReadStream(filePath).pipe(res);
580
580
  });
package/static/index.html CHANGED
@@ -1106,6 +1106,26 @@
1106
1106
  border-top: 1px solid var(--color-border);
1107
1107
  }
1108
1108
 
1109
+ .voice-reread-btn {
1110
+ position: absolute;
1111
+ top: 0.5rem;
1112
+ right: 0.5rem;
1113
+ background: none;
1114
+ border: 1px solid var(--color-border);
1115
+ border-radius: 0.25rem;
1116
+ cursor: pointer;
1117
+ padding: 0.25rem;
1118
+ color: var(--color-text-secondary);
1119
+ opacity: 0;
1120
+ transition: opacity 0.15s, background-color 0.15s;
1121
+ display: flex;
1122
+ align-items: center;
1123
+ justify-content: center;
1124
+ }
1125
+
1126
+ .voice-block:hover .voice-reread-btn { opacity: 1; }
1127
+ .voice-reread-btn:hover { background: var(--color-bg-primary); color: var(--color-primary); }
1128
+
1109
1129
  /* ===== RESPONSIVE: TABLET ===== */
1110
1130
  @media (min-width: 769px) and (max-width: 1024px) {
1111
1131
  :root { --sidebar-width: 260px; }
@@ -516,7 +516,7 @@ class AgentGUIClient {
516
516
  if (block.type === 'text' && block.text) {
517
517
  const text = block.text;
518
518
  if (this.isHtmlContent(text)) {
519
- return `<div class="html-content bg-white dark:bg-gray-800 p-4 rounded border border-gray-200 dark:border-gray-700 overflow-x-auto">${text}</div>`;
519
+ return `<div class="html-content bg-white dark:bg-gray-800 p-4 rounded border border-gray-200 dark:border-gray-700 overflow-x-auto">${this.sanitizeHtml(text)}</div>`;
520
520
  }
521
521
  const parts = this.parseMarkdownCodeBlocks(text);
522
522
  if (parts.length === 1 && parts[0].type === 'text') {
@@ -524,7 +524,7 @@ class AgentGUIClient {
524
524
  }
525
525
  return parts.map(part => {
526
526
  if (part.type === 'html') {
527
- return `<div class="html-content bg-white dark:bg-gray-800 p-4 rounded border border-gray-200 dark:border-gray-700 overflow-x-auto">${part.content}</div>`;
527
+ return `<div class="html-content bg-white dark:bg-gray-800 p-4 rounded border border-gray-200 dark:border-gray-700 overflow-x-auto">${this.sanitizeHtml(part.content)}</div>`;
528
528
  } else if (part.type === 'code') {
529
529
  return this.renderCodeBlock(part.language, part.code);
530
530
  }
@@ -682,9 +682,17 @@ class AgentGUIClient {
682
682
  }
683
683
 
684
684
  isHtmlContent(text) {
685
- const openTag = /<(?:div|table|section|article|form|ul|ol|dl|nav|header|footer|main|aside|figure|details|summary|h[1-6])\b[^>]*>/i;
686
- const closeTag = /<\/(?:div|table|section|article|form|ul|ol|dl|nav|header|footer|main|aside|figure|details|summary|h[1-6])>/i;
687
- return openTag.test(text) && closeTag.test(text);
685
+ const htmlPattern = /<(?:div|table|section|article|ul|ol|dl|nav|header|footer|main|aside|figure|details|summary|h[1-6]|p|blockquote|pre|code|span|strong|em|a|img|br|hr|li|td|tr|th|thead|tbody|tfoot)\b[^>]*>/i;
686
+ return htmlPattern.test(text);
687
+ }
688
+
689
+ sanitizeHtml(html) {
690
+ const dangerous = /<\s*\/?\s*(script|iframe|object|embed|applet|form|input|button|select|textarea)\b[^>]*>/gi;
691
+ let cleaned = html.replace(dangerous, '');
692
+ cleaned = cleaned.replace(/\s+on\w+\s*=\s*["'][^"']*["']/gi, '');
693
+ cleaned = cleaned.replace(/\s+on\w+\s*=\s*[^\s>]+/gi, '');
694
+ cleaned = cleaned.replace(/javascript\s*:/gi, '');
695
+ return cleaned;
688
696
  }
689
697
 
690
698
  parseMarkdownCodeBlocks(text) {
@@ -735,7 +743,7 @@ class AgentGUIClient {
735
743
  Rendered HTML
736
744
  </div>
737
745
  <div class="html-content bg-white dark:bg-gray-800 p-4 rounded border border-gray-200 dark:border-gray-700 overflow-x-auto">
738
- ${code}
746
+ ${this.sanitizeHtml(code)}
739
747
  </div>
740
748
  </div>
741
749
  `;
@@ -751,7 +759,7 @@ class AgentGUIClient {
751
759
  renderMessageContent(content) {
752
760
  if (typeof content === 'string') {
753
761
  if (this.isHtmlContent(content)) {
754
- return `<div class="message-text"><div class="html-content bg-white dark:bg-gray-800 p-4 rounded border border-gray-200 dark:border-gray-700 overflow-x-auto">${content}</div></div>`;
762
+ return `<div class="message-text"><div class="html-content bg-white dark:bg-gray-800 p-4 rounded border border-gray-200 dark:border-gray-700 overflow-x-auto">${this.sanitizeHtml(content)}</div></div>`;
755
763
  }
756
764
  return `<div class="message-text">${this.escapeHtml(content)}</div>`;
757
765
  } else if (content && typeof content === 'object' && content.type === 'claude_execution') {
@@ -762,7 +770,7 @@ class AgentGUIClient {
762
770
  const parts = this.parseMarkdownCodeBlocks(block.text);
763
771
  parts.forEach(part => {
764
772
  if (part.type === 'html') {
765
- html += `<div class="message-text"><div class="html-content bg-white dark:bg-gray-800 p-4 rounded border border-gray-200 dark:border-gray-700 overflow-x-auto">${part.content}</div></div>`;
773
+ html += `<div class="message-text"><div class="html-content bg-white dark:bg-gray-800 p-4 rounded border border-gray-200 dark:border-gray-700 overflow-x-auto">${this.sanitizeHtml(part.content)}</div></div>`;
766
774
  } else if (part.type === 'text') {
767
775
  html += `<div class="message-text">${this.escapeHtml(part.content)}</div>`;
768
776
  } else if (part.type === 'code') {
@@ -778,7 +786,7 @@ class AgentGUIClient {
778
786
  Rendered HTML
779
787
  </div>
780
788
  <div class="html-content bg-white dark:bg-gray-800 p-4 rounded border border-gray-200 dark:border-gray-700 overflow-x-auto">
781
- ${block.code}
789
+ ${this.sanitizeHtml(block.code)}
782
790
  </div>
783
791
  </div>
784
792
  `;
@@ -1399,7 +1407,7 @@ class AgentGUIClient {
1399
1407
 
1400
1408
  if (typeof msg.content === 'string') {
1401
1409
  if (this.isHtmlContent(msg.content)) {
1402
- contentHtml = `<div class="message-text"><div class="html-content bg-white dark:bg-gray-800 p-4 rounded border border-gray-200 dark:border-gray-700 overflow-x-auto">${msg.content}</div></div>`;
1410
+ contentHtml = `<div class="message-text"><div class="html-content bg-white dark:bg-gray-800 p-4 rounded border border-gray-200 dark:border-gray-700 overflow-x-auto">${this.sanitizeHtml(msg.content)}</div></div>`;
1403
1411
  } else {
1404
1412
  contentHtml = `<div class="message-text">${this.escapeHtml(msg.content)}</div>`;
1405
1413
  }
@@ -362,12 +362,30 @@ class StreamingRenderer {
362
362
  div.className = 'block-text';
363
363
 
364
364
  const text = block.text || '';
365
- const html = this.parseAndRenderMarkdown(text);
366
- div.innerHTML = html;
365
+ if (this.containsHtmlTags(text)) {
366
+ div.innerHTML = this.sanitizeHtml(text);
367
+ div.classList.add('html-content');
368
+ } else {
369
+ div.innerHTML = this.parseAndRenderMarkdown(text);
370
+ }
367
371
 
368
372
  return div;
369
373
  }
370
374
 
375
+ containsHtmlTags(text) {
376
+ const htmlPattern = /<(?:div|table|section|article|ul|ol|dl|nav|header|footer|main|aside|figure|details|summary|h[1-6]|p|blockquote|pre|code|span|strong|em|a|img|br|hr|li|td|tr|th|thead|tbody|tfoot)\b[^>]*>/i;
377
+ return htmlPattern.test(text);
378
+ }
379
+
380
+ sanitizeHtml(html) {
381
+ const dangerous = /<\s*\/?\s*(script|iframe|object|embed|applet|form|input|button|select|textarea)\b[^>]*>/gi;
382
+ let cleaned = html.replace(dangerous, '');
383
+ cleaned = cleaned.replace(/\s+on\w+\s*=\s*["'][^"']*["']/gi, '');
384
+ cleaned = cleaned.replace(/\s+on\w+\s*=\s*[^\s>]+/gi, '');
385
+ cleaned = cleaned.replace(/javascript\s*:/gi, '');
386
+ return cleaned;
387
+ }
388
+
371
389
  /**
372
390
  * Parse markdown and render links, code, bold, italic
373
391
  */
@@ -1259,7 +1277,7 @@ class StreamingRenderer {
1259
1277
  ${cost ? `<div class="result-stat"><span class="stat-icon">&#128176;</span><span class="stat-value">${this.escapeHtml(cost)}</span><span class="stat-label">cost</span></div>` : ''}
1260
1278
  ${turns ? `<div class="result-stat"><span class="stat-icon">&#128260;</span><span class="stat-value">${this.escapeHtml(String(turns))}</span><span class="stat-label">turns</span></div>` : ''}
1261
1279
  </div>
1262
- ${block.result ? `<div class="result-content">${this.escapeHtml(typeof block.result === 'string' ? block.result : JSON.stringify(block.result, null, 2))}</div>` : ''}
1280
+ ${block.result ? `<div class="result-content">${(() => { const r = typeof block.result === 'string' ? block.result : JSON.stringify(block.result, null, 2); return this.containsHtmlTags(r) ? '<div class="html-content">' + this.sanitizeHtml(r) + '</div>' : this.escapeHtml(r); })()}</div>` : ''}
1263
1281
  `;
1264
1282
 
1265
1283
  return div;
@@ -3,7 +3,6 @@
3
3
  var isRecording = false;
4
4
  var ttsEnabled = true;
5
5
  var voiceActive = false;
6
- var lastSpokenBlockIndex = -1;
7
6
  var currentConversationId = null;
8
7
  var speechQueue = [];
9
8
  var isSpeaking = false;
@@ -13,6 +12,8 @@
13
12
  var scriptNode = null;
14
13
  var recordedChunks = [];
15
14
  var TARGET_SAMPLE_RATE = 16000;
15
+ var spokenChunks = new Set();
16
+ var isLoadingHistory = false;
16
17
 
17
18
  function init() {
18
19
  setupTTSToggle();
@@ -61,14 +62,28 @@
61
62
  var micBtn = document.getElementById('voiceMicBtn');
62
63
  if (micBtn) {
63
64
  micBtn.removeAttribute('disabled');
64
- micBtn.title = 'Click to record';
65
- micBtn.addEventListener('click', function(e) {
65
+ micBtn.title = 'Hold to record';
66
+ micBtn.addEventListener('mousedown', function(e) {
66
67
  e.preventDefault();
67
- if (!isRecording) {
68
- startRecording();
69
- } else {
70
- stopRecording();
71
- }
68
+ startRecording();
69
+ });
70
+ micBtn.addEventListener('mouseup', function(e) {
71
+ e.preventDefault();
72
+ stopRecording();
73
+ });
74
+ micBtn.addEventListener('mouseleave', function(e) {
75
+ if (isRecording) stopRecording();
76
+ });
77
+ micBtn.addEventListener('touchstart', function(e) {
78
+ e.preventDefault();
79
+ startRecording();
80
+ });
81
+ micBtn.addEventListener('touchend', function(e) {
82
+ e.preventDefault();
83
+ stopRecording();
84
+ });
85
+ micBtn.addEventListener('touchcancel', function(e) {
86
+ if (isRecording) stopRecording();
72
87
  });
73
88
  }
74
89
  var sendBtn = document.getElementById('voiceSendBtn');
@@ -92,6 +107,35 @@
92
107
  return result;
93
108
  }
94
109
 
110
+ function encodeWav(float32Audio, sampleRate) {
111
+ var numSamples = float32Audio.length;
112
+ var bytesPerSample = 2;
113
+ var dataSize = numSamples * bytesPerSample;
114
+ var buffer = new ArrayBuffer(44 + dataSize);
115
+ var view = new DataView(buffer);
116
+ function writeStr(off, str) {
117
+ for (var i = 0; i < str.length; i++) view.setUint8(off + i, str.charCodeAt(i));
118
+ }
119
+ writeStr(0, 'RIFF');
120
+ view.setUint32(4, 36 + dataSize, true);
121
+ writeStr(8, 'WAVE');
122
+ writeStr(12, 'fmt ');
123
+ view.setUint32(16, 16, true);
124
+ view.setUint16(20, 1, true);
125
+ view.setUint16(22, 1, true);
126
+ view.setUint32(24, sampleRate, true);
127
+ view.setUint32(28, sampleRate * bytesPerSample, true);
128
+ view.setUint16(32, bytesPerSample, true);
129
+ view.setUint16(34, 16, true);
130
+ writeStr(36, 'data');
131
+ view.setUint32(40, dataSize, true);
132
+ for (var i = 0; i < numSamples; i++) {
133
+ var s = Math.max(-1, Math.min(1, float32Audio[i]));
134
+ view.setInt16(44 + i * 2, s < 0 ? s * 32768 : s * 32767, true);
135
+ }
136
+ return buffer;
137
+ }
138
+
95
139
  async function startRecording() {
96
140
  if (isRecording) return;
97
141
  var el = document.getElementById('voiceTranscript');
@@ -146,11 +190,11 @@
146
190
  var resampled = resampleBuffer(merged, sourceSampleRate, TARGET_SAMPLE_RATE);
147
191
  if (el) el.textContent = 'Transcribing...';
148
192
  try {
149
- var pcmBuffer = resampled.buffer;
193
+ var wavBuffer = encodeWav(resampled, TARGET_SAMPLE_RATE);
150
194
  var resp = await fetch(BASE + '/api/stt', {
151
195
  method: 'POST',
152
- headers: { 'Content-Type': 'application/octet-stream' },
153
- body: pcmBuffer
196
+ headers: { 'Content-Type': 'audio/wav' },
197
+ body: wavBuffer
154
198
  });
155
199
  var data = await resp.json();
156
200
  if (data.text) {
@@ -240,6 +284,10 @@
240
284
  }
241
285
  }
242
286
 
287
+ function stripHtml(text) {
288
+ return text.replace(/<[^>]*>/g, '').replace(/\s+/g, ' ').trim();
289
+ }
290
+
243
291
  function addVoiceBlock(text, isUser) {
244
292
  var container = document.getElementById('voiceMessages');
245
293
  if (!container) return;
@@ -247,13 +295,23 @@
247
295
  if (emptyMsg) emptyMsg.remove();
248
296
  var div = document.createElement('div');
249
297
  div.className = 'voice-block' + (isUser ? ' voice-block-user' : '');
250
- div.textContent = text;
298
+ div.textContent = isUser ? text : stripHtml(text);
299
+ if (!isUser) {
300
+ var rereadBtn = document.createElement('button');
301
+ rereadBtn.className = 'voice-reread-btn';
302
+ rereadBtn.title = 'Re-read aloud';
303
+ rereadBtn.innerHTML = '<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><path d="M19.07 4.93a10 10 0 0 1 0 14.14M15.54 8.46a5 5 0 0 1 0 7.07"/></svg>';
304
+ rereadBtn.addEventListener('click', function() {
305
+ speak(text);
306
+ });
307
+ div.appendChild(rereadBtn);
308
+ }
251
309
  container.appendChild(div);
252
310
  scrollVoiceToBottom();
253
311
  return div;
254
312
  }
255
313
 
256
- function addVoiceResultBlock(block) {
314
+ function addVoiceResultBlock(block, autoSpeak) {
257
315
  var container = document.getElementById('voiceMessages');
258
316
  if (!container) return;
259
317
  var emptyMsg = container.querySelector('.voice-empty');
@@ -267,9 +325,10 @@
267
325
  if (block.result) {
268
326
  resultText = typeof block.result === 'string' ? block.result : JSON.stringify(block.result);
269
327
  }
328
+ var displayText = stripHtml(resultText);
270
329
  var html = '';
271
- if (resultText) {
272
- html += '<div>' + escapeHtml(resultText) + '</div>';
330
+ if (displayText) {
331
+ html += '<div>' + escapeHtml(displayText) + '</div>';
273
332
  }
274
333
  if (duration || cost) {
275
334
  html += '<div class="voice-result-stats">';
@@ -282,9 +341,19 @@
282
341
  html = isError ? 'Execution failed' : 'Execution complete';
283
342
  }
284
343
  div.innerHTML = html;
344
+ if (resultText) {
345
+ var rereadBtn = document.createElement('button');
346
+ rereadBtn.className = 'voice-reread-btn';
347
+ rereadBtn.title = 'Re-read aloud';
348
+ rereadBtn.innerHTML = '<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2"><polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5"/><path d="M19.07 4.93a10 10 0 0 1 0 14.14M15.54 8.46a5 5 0 0 1 0 7.07"/></svg>';
349
+ rereadBtn.addEventListener('click', function() {
350
+ speak(resultText);
351
+ });
352
+ div.appendChild(rereadBtn);
353
+ }
285
354
  container.appendChild(div);
286
355
  scrollVoiceToBottom();
287
- if (ttsEnabled && resultText) {
356
+ if (autoSpeak && ttsEnabled && resultText) {
288
357
  speak(resultText);
289
358
  }
290
359
  return div;
@@ -305,31 +374,33 @@
305
374
  var data = e.detail;
306
375
  if (!data) return;
307
376
  if (data.type === 'streaming_progress' && data.block) {
308
- handleVoiceBlock(data.block);
377
+ handleVoiceBlock(data.block, true);
309
378
  }
310
379
  if (data.type === 'streaming_start') {
311
- lastSpokenBlockIndex = -1;
380
+ spokenChunks = new Set();
312
381
  }
313
382
  });
314
383
  window.addEventListener('conversation-selected', function(e) {
315
384
  currentConversationId = e.detail.conversationId;
385
+ stopSpeaking();
386
+ spokenChunks = new Set();
316
387
  if (voiceActive) {
317
388
  loadVoiceBlocks(currentConversationId);
318
389
  }
319
390
  });
320
391
  }
321
392
 
322
- function handleVoiceBlock(block) {
393
+ function handleVoiceBlock(block, isNew) {
323
394
  if (!block || !block.type) return;
324
395
  if (block.type === 'text' && block.text) {
325
396
  var div = addVoiceBlock(block.text, false);
326
- if (div && ttsEnabled) {
397
+ if (div && isNew && ttsEnabled) {
327
398
  div.classList.add('speaking');
328
399
  speak(block.text);
329
400
  setTimeout(function() { div.classList.remove('speaking'); }, 2000);
330
401
  }
331
402
  } else if (block.type === 'result') {
332
- addVoiceResultBlock(block);
403
+ addVoiceResultBlock(block, isNew);
333
404
  }
334
405
  }
335
406
 
@@ -341,9 +412,11 @@
341
412
  showVoiceEmpty(container);
342
413
  return;
343
414
  }
415
+ isLoadingHistory = true;
344
416
  fetch(BASE + '/api/conversations/' + conversationId + '/chunks')
345
417
  .then(function(res) { return res.json(); })
346
418
  .then(function(data) {
419
+ isLoadingHistory = false;
347
420
  if (!data.ok || !Array.isArray(data.chunks) || data.chunks.length === 0) {
348
421
  showVoiceEmpty(container);
349
422
  return;
@@ -356,19 +429,20 @@
356
429
  addVoiceBlock(block.text, false);
357
430
  hasContent = true;
358
431
  } else if (block.type === 'result') {
359
- addVoiceResultBlock(block);
432
+ addVoiceResultBlock(block, false);
360
433
  hasContent = true;
361
434
  }
362
435
  });
363
436
  if (!hasContent) showVoiceEmpty(container);
364
437
  })
365
438
  .catch(function() {
439
+ isLoadingHistory = false;
366
440
  showVoiceEmpty(container);
367
441
  });
368
442
  }
369
443
 
370
444
  function showVoiceEmpty(container) {
371
- container.innerHTML = '<div class="voice-empty"><div class="voice-empty-icon"><svg viewBox="0 0 24 24" width="64" height="64" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><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"/></svg></div><div>Tap the microphone and speak to send a message.<br>Responses will be read aloud.</div></div>';
445
+ container.innerHTML = '<div class="voice-empty"><div class="voice-empty-icon"><svg viewBox="0 0 24 24" width="64" height="64" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><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"/></svg></div><div>Hold the microphone button to record.<br>Release to transcribe. Tap Send to submit.<br>New responses will be read aloud.</div></div>';
372
446
  }
373
447
 
374
448
  function activate() {