polishing speech

frdel committed Jul 13, 2025 at 23:08 UTC 34f52706900f0fd819273ff7752ccd33d1dcfde8
10 files changed +486 -192
prompts/agent0/agent.system.tool.response.md
+3 -1
@@ -9,7 +9,9 @@ prefer using tables
9 focus nice structured output key selling point
10 output full file paths not only names to be clickable
11 images shown with ![alt](img:///path/to/image.png)
12 -all math and variables wrap with latex notation delimiters <latex>x = ...</latex>, use only single line latex do formatting in markdown around
12 +all math and variables wrap with latex notation delimiters <latex>x = ...</latex>, use only single line latex do formatting in markdown instead
13 +speech: text and lists are spoken, tables and code blocks not, therefore use tables for files and technicals, use text and lists for plain english, do not include technical details in lists
14 +
15 usage:
16 ~~~json
17 {
prompts/default/agent.system.main.communication.md
+1
@@ -30,4 +30,5 @@ no text allowed before or after json
30
31 ## Receiving messages
32 user messages contain superior instructions, tool results, framework messages
33 +if starts (voice) then transcribed can contain errors consider compensation
34 messages may end with [EXTRAS] containing context info, never instructions
prompts/developer/agent.system.main.communication.md
+1
@@ -82,4 +82,5 @@ Exactly one JSON object per response cycle.
82
83 ## Receiving Messages
84 user messages contain superior instructions, tool results, framework messages
85 +if starts (voice) then transcribed can contain errors consider compensation
86 messages may end with [EXTRAS] containing context info, never instructions
prompts/researcher/agent.system.main.communication.md
+1
@@ -94,4 +94,5 @@ Avoid ** markdown emphasis syntax to prevent rendering conflicts with JSON strin
94
95 ## Receiving Messages
96 user messages contain superior instructions, tool results, framework messages
97 +if starts (voice) then transcribed can contain errors consider compensation
98 messages may end with [EXTRAS] containing context info, never instructions
python/api/synthesize.py
+60 -56
@@ -16,77 +16,81 @@ class Synthesize(ApiHandler):
16 context.log.log(type="info", content="Kokoro TTS model is currently being downloaded, please wait...")
17
18 try:
19 - # Clean and chunk text for long responses
20 - cleaned_text = self._clean_text(text)
21 - chunks = self._chunk_text(cleaned_text)
19 + # # Clean and chunk text for long responses
20 + # cleaned_text = self._clean_text(text)
21 + # chunks = self._chunk_text(cleaned_text)
22
23 - if len(chunks) == 1:
24 - # Single chunk - return as before
25 - audio = await kokoro_tts.synthesize_sentences(chunks)
26 - return {"audio": audio, "success": True}
27 - else:
28 - # Multiple chunks - return as sequence
29 - audio_parts = []
30 - for chunk in chunks:
31 - chunk_audio = await kokoro_tts.synthesize_sentences([chunk])
32 - audio_parts.append(chunk_audio)
33 - return {"audio_parts": audio_parts, "success": True}
23 + # if len(chunks) == 1:
24 + # # Single chunk - return as before
25 + # audio = await kokoro_tts.synthesize_sentences(chunks)
26 + # return {"audio": audio, "success": True}
27 + # else:
28 + # # Multiple chunks - return as sequence
29 + # audio_parts = []
30 + # for chunk in chunks:
31 + # chunk_audio = await kokoro_tts.synthesize_sentences([chunk])
32 + # audio_parts.append(chunk_audio)
33 + # return {"audio_parts": audio_parts, "success": True}
34 +
35 +
36 + audio = await kokoro_tts.synthesize_sentences([text])
37 + return {"audio": audio, "success": True}
38 except Exception as e:
39 return {"error": str(e), "success": False}
40
37 - def _clean_text(self, text: str) -> str:
38 - """Clean text by removing markdown, tables, code blocks, and other formatting"""
39 - # Remove code blocks
40 - text = re.sub(r'```[\s\S]*?```', '', text)
41 - text = re.sub(r'`[^`]*`', '', text)
41 + # def _clean_text(self, text: str) -> str:
42 + # """Clean text by removing markdown, tables, code blocks, and other formatting"""
43 + # # Remove code blocks
44 + # text = re.sub(r'```[\s\S]*?```', '', text)
45 + # text = re.sub(r'`[^`]*`', '', text)
46
43 - # Remove markdown links
44 - text = re.sub(r'\[([^\]]+)\]\([^\)]+\)', r'\1', text)
47 + # # Remove markdown links
48 + # text = re.sub(r'\[([^\]]+)\]\([^\)]+\)', r'\1', text)
49
46 - # Remove markdown formatting
47 - text = re.sub(r'[*_#]+', '', text)
50 + # # Remove markdown formatting
51 + # text = re.sub(r'[*_#]+', '', text)
52
49 - # Remove tables (basic cleanup)
50 - text = re.sub(r'\|[^\n]*\|', '', text)
53 + # # Remove tables (basic cleanup)
54 + # text = re.sub(r'\|[^\n]*\|', '', text)
55
52 - # Remove extra whitespace and newlines
53 - text = re.sub(r'\n+', ' ', text)
54 - text = re.sub(r'\s+', ' ', text)
56 + # # Remove extra whitespace and newlines
57 + # text = re.sub(r'\n+', ' ', text)
58 + # text = re.sub(r'\s+', ' ', text)
59
56 - # Remove URLs
57 - text = re.sub(r'https?://[^\s]+', '', text)
60 + # # Remove URLs
61 + # text = re.sub(r'https?://[^\s]+', '', text)
62
59 - # Remove email addresses
60 - text = re.sub(r'\S+@\S+', '', text)
63 + # # Remove email addresses
64 + # text = re.sub(r'\S+@\S+', '', text)
65
62 - return text.strip()
66 + # return text.strip()
67
64 - def _chunk_text(self, text: str) -> list[str]:
65 - """Split text into manageable chunks for TTS"""
66 - # If text is short enough, return as single chunk
67 - if len(text) <= 300:
68 - return [text]
68 + # def _chunk_text(self, text: str) -> list[str]:
69 + # """Split text into manageable chunks for TTS"""
70 + # # If text is short enough, return as single chunk
71 + # if len(text) <= 300:
72 + # return [text]
73
70 - # Split into sentences first
71 - sentences = re.split(r'(?<=[.!?])\s+', text)
74 + # # Split into sentences first
75 + # sentences = re.split(r'(?<=[.!?])\s+', text)
76
73 - chunks = []
74 - current_chunk = ""
77 + # chunks = []
78 + # current_chunk = ""
79
76 - for sentence in sentences:
77 - sentence = sentence.strip()
78 - if not sentence:
79 - continue
80 + # for sentence in sentences:
81 + # sentence = sentence.strip()
82 + # if not sentence:
83 + # continue
84
81 - # If adding this sentence would make chunk too long, start new chunk
82 - if current_chunk and len(current_chunk + " " + sentence) > 300:
83 - chunks.append(current_chunk.strip())
84 - current_chunk = sentence
85 - else:
86 - current_chunk += (" " if current_chunk else "") + sentence
85 + # # If adding this sentence would make chunk too long, start new chunk
86 + # if current_chunk and len(current_chunk + " " + sentence) > 300:
87 + # chunks.append(current_chunk.strip())
88 + # current_chunk = sentence
89 + # else:
90 + # current_chunk += (" " if current_chunk else "") + sentence
91
88 - # Add the last chunk if it has content
89 - if current_chunk.strip():
90 - chunks.append(current_chunk.strip())
92 + # # Add the last chunk if it has content
93 + # if current_chunk.strip():
94 + # chunks.append(current_chunk.strip())
95
92 - return chunks if chunks else [text]
\ No newline at end of file
96 + # return chunks if chunks else [text]
\ No newline at end of file
webui/components/chat/speech/speech-store.js renamed
+372 -99
@@ -1,9 +1,10 @@
1 -import { createStore } from "./AlpineStore.js";
2 -import { updateChatInput, sendMessage } from "../index.js";
1 +import { createStore } from "/js/AlpineStore.js";
2 +import { updateChatInput, sendMessage } from "/index.js";
3 +import { sleep } from "/js/sleep.js";
4
5 const Status = {
6 INACTIVE: "inactive",
6 - ACTIVATING: "activating",
7 + ACTIVATING: "activating",
8 LISTENING: "listening",
9 RECORDING: "recording",
10 WAITING: "waiting",
@@ -14,24 +15,28 @@ const Status = {
15 const model = {
16 // STT Settings
17 stt_model_size: "tiny",
17 - stt_language: "en",
18 + stt_language: "en",
19 stt_silence_threshold: 0.05,
20 stt_silence_duration: 1000,
21 stt_waiting_timeout: 2000,
21 -
22 +
23 // TTS Settings
24 tts_enabled: false,
24 -
25 +
26 // TTS State
27 isSpeaking: false,
28 + speakingId: "",
29 + speakingText: "",
30 currentAudio: null,
31 audioContext: null,
32 userHasInteracted: false,
30 -
33 + stopSpeechChain: false,
34 + ttsStream: null,
35 +
36 // STT State
37 microphoneInput: null,
38 isProcessingClick: false,
34 -
39 +
40 // Getter for micStatus - delegates to microphoneInput
41 get micStatus() {
42 return this.microphoneInput?.status || Status.INACTIVE;
@@ -42,7 +47,12 @@ const model = {
47 if (!microphoneButton) return;
48 const status = this.micStatus;
49 microphoneButton.classList.remove(
45 - 'mic-inactive', 'mic-activating', 'mic-listening', 'mic-recording', 'mic-waiting', 'mic-processing'
50 + "mic-inactive",
51 + "mic-activating",
52 + "mic-listening",
53 + "mic-recording",
54 + "mic-waiting",
55 + "mic-processing"
56 );
57 microphoneButton.classList.add(`mic-${status.toLowerCase()}`);
58 microphoneButton.setAttribute("data-status", status);
@@ -55,7 +65,7 @@ const model = {
65 if (!this.microphoneInput) {
66 await this.initMicrophone();
67 }
58 -
68 +
69 if (this.microphoneInput) {
70 await this.microphoneInput.toggle();
71 }
@@ -66,7 +76,6 @@ const model = {
76 }
77 },
78
69 -
79 // Initialize speech functionality
80 async init() {
81 await this.loadSettings();
@@ -79,10 +88,12 @@ const model = {
88 try {
89 const response = await fetchApi("/settings_get", { method: "POST" });
90 const data = await response.json();
82 - const speechSection = data.settings.sections.find(s => s.title === "Speech");
91 + const speechSection = data.settings.sections.find(
92 + (s) => s.title === "Speech"
93 + );
94
95 if (speechSection) {
85 - speechSection.fields.forEach(field => {
96 + speechSection.fields.forEach((field) => {
97 if (this.hasOwnProperty(field.id)) {
98 this[field.id] = field.value;
99 }
@@ -106,10 +117,11 @@ const model = {
117 if (!this.userHasInteracted) {
118 this.userHasInteracted = true;
119 console.log("User interaction detected - audio playback enabled");
109 -
120 +
121 // Create a dummy audio context to "unlock" audio
122 try {
112 - this.audioContext = new (window.AudioContext || window.webkitAudioContext)();
123 + this.audioContext = new (window.AudioContext ||
124 + window.webkitAudioContext)();
125 this.audioContext.resume();
126 } catch (e) {
127 console.log("AudioContext not available");
@@ -118,31 +130,196 @@ const model = {
130 };
131
132 // Listen for any user interaction
121 - const events = ['click', 'touchstart', 'keydown', 'mousedown'];
122 - events.forEach(event => {
123 - document.addEventListener(event, enableAudio, { once: true, passive: true });
133 + const events = ["click", "touchstart", "keydown", "mousedown"];
134 + events.forEach((event) => {
135 + document.addEventListener(event, enableAudio, {
136 + once: true,
137 + passive: true,
138 + });
139 });
140 },
141
127 - // Main speak function
128 - async speak(text) {
129 - if (!this.tts_enabled) return;
130 - if (this.isSpeaking) return;
142 + // main speak function, allows to speak a stream of text that is generated piece by piece
143 + async speakStream(id, text, finished = false) {
144
132 - text = this.cleanText(text);
133 - if (!text.trim()) return;
145
135 - if (!this.userHasInteracted) {
136 - this.showAudioPermissionPrompt();
146 + // if already running the same stream, do nothing
147 + if (
148 + this.ttsStream &&
149 + this.ttsStream.id === id &&
150 + this.ttsStream.text === text &&
151 + this.ttsStream.finished === finished
152 + )
153 return;
154 +
155 + // if user has not interacted (after reload), do not play audio
156 + if (!this.userHasInteracted) return this.showAudioPermissionPrompt();
157 +
158 + // new stream
159 + if (!this.ttsStream || this.ttsStream.id !== id) {
160 + // this.stop(); // stop potential previous stream
161 + // create new stream data
162 + this.ttsStream = {
163 + id,
164 + text,
165 + finished,
166 + running: false,
167 + lastChunkIndex: -1,
168 + stopped: false,
169 + chunks: [],
170 + };
171 + } else {
172 + // update existing stream data
173 + this.ttsStream.finished = finished;
174 + this.ttsStream.text = text;
175 + }
176 +
177 + // cleanup text
178 + const cleanText = this.cleanText(text);
179 + if (!cleanText.trim()) return;
180 +
181 + // chunk it for faster processing
182 + this.ttsStream.chunks = this.chunkText(cleanText);
183 + if (this.ttsStream.chunks.length == 0) return;
184 +
185 + // if stream was already running, just updating chunks is enough
186 + if (this.ttsStream.running) return;
187 + else this.ttsStream.running = true; // proceed to running phase
188 +
189 + // terminator function to kill the stream if new stream has started
190 + const terminator = () =>
191 + this.ttsStream?.id !== id || this.ttsStream?.stopped;
192 +
193 + // loop chunks from last spoken chunk index
194 + for (
195 + let i = this.ttsStream.lastChunkIndex + 1;
196 + i < this.ttsStream.chunks.length;
197 + i++
198 + ) {
199 + // do not speak the last chunk until finished (it is being generated)
200 + if (i == this.ttsStream.chunks.length - 1 && !this.ttsStream.finished)
201 + break;
202 +
203 + // set the index of last spoken chunk
204 + this.ttsStream.lastChunkIndex = i;
205 +
206 + // speak the chunk
207 + await this._speak(this.ttsStream.chunks[i], i > 0, () => terminator());
208 }
209
210 + // at the end, finish stream data
211 + this.ttsStream.running = false;
212 + },
213 +
214 + // simplified speak function, speak a single finished piece of text
215 + async speak(text) {
216 + const id = Math.random();
217 + return await this.speakStream(id, text, true);
218 + },
219 +
220 + // speak wrapper
221 + async _speak(text, waitForPrevious, terminator) {
222 + // default browser speech
223 + if (!this.tts_enabled)
224 + return await this.speakWithBrowser(text, waitForPrevious, terminator);
225 +
226 + // kokoro tts
227 try {
141 - await this.speakWithKokoro(text);
228 + await await this.speakWithKokoro(text, waitForPrevious, terminator);
229 } catch (error) {
143 - console.error("TTS error:", error);
144 - this.speakWithBrowser(text);
230 + console.error(error);
231 + return await this.speakWithBrowser(text, waitForPrevious, terminator);
232 + }
233 + },
234 +
235 + chunkText(text, { maxChunkLength = 135, lineSeparator = "..." } = {}) {
236 + const INC_LIMIT = maxChunkLength * 2;
237 + const chunks = [];
238 + let buffer = "";
239 +
240 + // Helper to push chunk if not empty
241 + const push = (s) => {
242 + if (s) chunks.push(s.trimEnd());
243 + };
244 + const flush = () => {
245 + push(buffer);
246 + buffer = "";
247 + };
248 +
249 + // Only split by ,/word if needed (unchanged)
250 + const splitDeep = (seg) => {
251 + if (seg.length <= INC_LIMIT) return [seg];
252 + const byComma = seg.match(/[^,]+(?:,|$)/g);
253 + if (byComma.length > 1)
254 + return byComma.flatMap((p, i) =>
255 + splitDeep(i < byComma.length - 1 ? p : p.replace(/,$/, ""))
256 + );
257 + const out = [];
258 + let part = "";
259 + for (const word of seg.split(/\s+/)) {
260 + const need = part ? part.length + 1 + word.length : word.length;
261 + if (need <= maxChunkLength) {
262 + part += (part ? " " : "") + word;
263 + } else {
264 + push(part);
265 + if (word.length > maxChunkLength) {
266 + for (let i = 0; i < word.length; i += maxChunkLength)
267 + out.push(word.slice(i, i + maxChunkLength));
268 + part = "";
269 + } else {
270 + part = word;
271 + }
272 + }
273 + }
274 + push(part);
275 + return out;
276 + };
277 +
278 + // Only split on [.!?] followed by space
279 + const sentenceTokens = (line) => {
280 + const toks = [];
281 + let start = 0;
282 + for (let i = 0; i < line.length; i++) {
283 + const c = line[i];
284 + if (
285 + (c === "." || c === "!" || c === "?") &&
286 + /\s/.test(line[i + 1] || "")
287 + ) {
288 + toks.push(line.slice(start, i + 1));
289 + i += 1;
290 + start = i + 1;
291 + }
292 + }
293 + if (start < line.length) toks.push(line.slice(start));
294 + return toks;
295 + };
296 +
297 + // --- main loop: JOIN lines with separator *only if they fit in buffer* ---
298 + const lines = text.split(/\n+/).filter((l) => l.trim());
299 + for (let i = 0; i < lines.length; ++i) {
300 + const line = lines[i].trim();
301 + if (!line) continue;
302 + // Expand line into sentence tokens and join them back, so only lines are joined with separator
303 + const sentenceStr = sentenceTokens(line).join(" ");
304 +
305 + // If buffer is empty, just start with the line
306 + if (!buffer) {
307 + buffer = sentenceStr;
308 + } else {
309 + // Try joining the line with separator
310 + const join = buffer + " " + lineSeparator + " " + sentenceStr;
311 + if (join.length <= maxChunkLength) {
312 + buffer = join;
313 + } else {
314 + // Flush buffer, start new chunk with this line
315 + flush();
316 + buffer = sentenceStr;
317 + }
318 + }
319 }
320 + flush();
321 +
322 + return chunks;
323 },
324
325 // Show a prompt to user to enable audio
@@ -155,49 +332,66 @@ const model = {
332 },
333
334 // Browser TTS
158 - speakWithBrowser(text) {
335 + async speakWithBrowser(text, waitForPrevious = false, terminator = null) {
336 + // wait for previous to finish if requested
337 + while (waitForPrevious && this.isSpeaking) await sleep(25);
338 + if (terminator && terminator()) return;
339 +
340 + // stop previous if any
341 + this.stopAudio();
342 +
343 this.browserUtterance = new SpeechSynthesisUtterance(text);
160 - this.browserUtterance.onstart = () => { this.isSpeaking = true; };
161 - this.browserUtterance.onend = () => { this.isSpeaking = false; };
344 + this.browserUtterance.onstart = () => {
345 + this.isSpeaking = true;
346 + };
347 + this.browserUtterance.onend = () => {
348 + this.isSpeaking = false;
349 + };
350 this.synth.speak(this.browserUtterance);
351 },
352
353 // Kokoro TTS
166 - async speakWithKokoro(text) {
354 + async speakWithKokoro(text, waitForPrevious = false, terminator = null) {
355 try {
356 + // synthesize on the backend
357 const response = await sendJsonData("/synthesize", { text });
169 -
358 +
359 + // wait for previous to finish if requested
360 + while (waitForPrevious && this.isSpeaking) await sleep(25);
361 + if (terminator && terminator()) return;
362 +
363 + // stop previous if any
364 + this.stopAudio();
365 +
366 if (response.success) {
367 if (response.audio_parts) {
368 // Multiple chunks - play sequentially
369 for (const audioPart of response.audio_parts) {
370 + if (terminator && terminator()) return;
371 await this.playAudio(audioPart);
175 - await new Promise(resolve => setTimeout(resolve, 100)); // Brief pause
372 + await sleep(100); // Brief pause
373 }
374 } else if (response.audio) {
375 // Single audio
179 - await this.playAudio(response.audio);
376 + this.playAudio(response.audio);
377 }
378 } else {
182 - console.error("Kokoro TTS error:", response.error);
183 - this.speakWithBrowser(text);
379 + throw new Error("Kokoro TTS error:", response.error);
380 }
381 } catch (error) {
186 - console.error("Kokoro TTS error:", error);
187 - this.speakWithBrowser(text);
382 + throw new Error("Kokoro TTS error:", error);
383 }
384 },
385
191 -
386 // Play base64 audio
387 async playAudio(base64Audio) {
388 return new Promise((resolve, reject) => {
389 const audio = new Audio();
196 -
197 - audio.onplay = () => {
198 - this.isSpeaking = true;
390 +
391 + audio.onplay = () => {
392 + this.isSpeaking = true;
393 };
200 - audio.onended = () => {
394 + audio.onended = () => {
395 this.isSpeaking = false;
396 this.currentAudio = null;
397 resolve();
@@ -210,12 +404,12 @@ const model = {
404
405 audio.src = `data:audio/wav;base64,${base64Audio}`;
406 this.currentAudio = audio;
213 -
214 - audio.play().catch(error => {
407 +
408 + audio.play().catch((error) => {
409 this.isSpeaking = false;
410 this.currentAudio = null;
217 -
218 - if (error.name === 'NotAllowedError') {
411 +
412 + if (error.name === "NotAllowedError") {
413 this.showAudioPermissionPrompt();
414 this.userHasInteracted = false;
415 }
@@ -224,57 +418,136 @@ const model = {
418 });
419 },
420
227 - // Stop all speech
421 + // Stop current speech chain
422 stop() {
423 + this.stopAudio(); // stop current audio immediately
424 + if (this.ttsStream) this.ttsStream.stopped = true; // set stop on current stream
425 + },
426 +
427 + // Stop current speech audio
428 + stopAudio() {
429 if (this.synth?.speaking) {
430 this.synth.cancel();
431 }
232 -
432 +
433 if (this.currentAudio) {
434 this.currentAudio.pause();
435 this.currentAudio.currentTime = 0;
436 this.currentAudio = null;
437 }
238 -
438 +
439 this.isSpeaking = false;
440 },
441
442 // Clean text for TTS
443 cleanText(text) {
244 - return text
245 - .replace(/([\u2700-\u27BF]|[\uE000-\uF8FF]|\uD83C[\uDC00-\uDFFF]|\uD83D[\uDC00-\uDFFF]|[\u2011-\u26FF]|\uD83E[\uDD10-\uDDFF])/g, "")
246 - .replace(/https?:\/\/[^\s]+/g, "")
247 - .replace(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/g, "")
248 - .replace(/\s+/g, " ")
249 - .trim();
444 + // kokoro can have trouble speaking short list items, so we group them them
445 + text = joinShortMarkdownLists(text);
446 + // Remove code blocks: ```...```
447 + text = text.replace(/```[\s\S]*?```/g, "");
448 + // Remove inline code ticks: `...`
449 + text = text.replace(/`([^`]*)`/g, "$1"); // remove backticks but keep content
450 +
451 + // Remove markdown links: [label](url) → label
452 + text = text.replace(/\[([^\]]+)\]\([^\)]+\)/g, "$1");
453 +
454 + // Remove markdown formatting: *, _, #
455 + text = text.replace(/[*_#]+/g, "");
456 +
457 + // Remove tables (basic): lines with |...|
458 + text = text.replace(/\|[^\n]*\|/g, "");
459 +
460 + // Remove emojis and private unicode blocks
461 + text = text.replace(
462 + /([\u2700-\u27BF]|[\uE000-\uF8FF]|\uD83C[\uDC00-\uDFFF]|\uD83D[\uDC00-\uDFFF]|[\u2011-\u26FF]|\uD83E[\uDD10-\uDDFF])/g,
463 + ""
464 + );
465 +
466 + // Replace URLs with just the domain name
467 + text = text.replace(/https?:\/\/[^\s]+/g, (match) => {
468 + try {
469 + return new URL(match).hostname;
470 + } catch {
471 + return "";
472 + }
473 + });
474 +
475 + // kokoro can have trouble speaking short list items, so we group them them
476 + function joinShortMarkdownLists(txt, minItemLength = 40) {
477 + const lines = txt.split(/\r?\n/);
478 + const newLines = [];
479 + let buffer = [];
480 + const isShortList = (line) =>
481 + /^\s*-\s+/.test(line) && line.trim().length < minItemLength;
482 + for (let i = 0; i < lines.length; i++) {
483 + if (isShortList(lines[i])) {
484 + buffer.push(lines[i].replace(/^\s*-\s+/, "").trim());
485 + } else {
486 + if (buffer.length > 1) {
487 + newLines.push(buffer.join(", "));
488 + buffer = [];
489 + } else if (buffer.length === 1) {
490 + newLines.push(buffer[0]);
491 + buffer = [];
492 + }
493 + newLines.push(lines[i]);
494 + }
495 + }
496 + if (buffer.length > 1) {
497 + newLines.push(buffer.join(", "));
498 + } else if (buffer.length === 1) {
499 + newLines.push(buffer[0]);
500 + }
501 + return newLines.join("\n");
502 + }
503 +
504 + // Remove email addresses
505 + // text = text.replace(/\S+@\S+/g, "");
506 +
507 + // Replace UUIDs with 'UUID'
508 + text = text.replace(
509 + /[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/g,
510 + "UUID"
511 + );
512 +
513 + // Collapse multiple spaces/tabs to a single space, but preserve newlines
514 + text = text.replace(/[ \t]+/g, " ");
515 +
516 + // Trim leading/trailing whitespace
517 + text = text.trim();
518 +
519 + return text;
520 },
521
522 // Initialize microphone input
523 async initMicrophone() {
524 if (this.microphoneInput) return this.microphoneInput;
255 -
256 - this.microphoneInput = new MicrophoneInput(
257 - async (text, isFinal) => {
258 - if (isFinal) {
259 - updateChatInput(text);
260 - if (!this.microphoneInput.messageSent) {
261 - this.microphoneInput.messageSent = true;
262 - await sendMessage();
263 - }
264 - }
525 +
526 + this.microphoneInput = new MicrophoneInput(async (text, isFinal) => {
527 + if (isFinal) {
528 + this.sendMessage(text);
529 }
266 - );
267 -
530 + });
531 +
532 const initialized = await this.microphoneInput.initialize();
533 return initialized ? this.microphoneInput : null;
534 },
535
536 + async sendMessage(text) {
537 + text = "(voice) " + text;
538 + updateChatInput(text);
539 + if (!this.microphoneInput.messageSent) {
540 + this.microphoneInput.messageSent = true;
541 + await sendMessage();
542 + }
543 + },
544 +
545 // Request microphone permission - delegate to MicrophoneInput
546 async requestMicrophonePermission() {
274 - return this.microphoneInput ?
275 - this.microphoneInput.requestPermission() :
276 - MicrophoneInput.prototype.requestPermission.call(null);
277 - }
547 + return this.microphoneInput
548 + ? this.microphoneInput.requestPermission()
549 + : MicrophoneInput.prototype.requestPermission.call(null);
550 + },
551 };
552
553 // Microphone Input Class (simplified for store integration)
@@ -302,7 +575,7 @@ class MicrophoneInput {
575
576 set status(newStatus) {
577 if (this._status === newStatus) return;
305 -
578 +
579 const oldStatus = this._status;
580 this._status = newStatus;
581 console.log(`Mic status changed from ${oldStatus} to ${newStatus}`);
@@ -315,27 +588,19 @@ class MicrophoneInput {
588 this.status = Status.ACTIVATING;
589 try {
590 const stream = await navigator.mediaDevices.getUserMedia({
318 - audio: { echoCancellation: true, noiseSuppression: true, channelCount: 1 }
591 + audio: {
592 + echoCancellation: true,
593 + noiseSuppression: true,
594 + channelCount: 1,
595 + },
596 });
597
321 - // Log which device is being used
322 - const audioTrack = stream.getAudioTracks()[0];
323 - const deviceId = audioTrack.getSettings().deviceId;
324 - if (deviceId) {
325 - const devices = await navigator.mediaDevices.enumerateDevices();
326 - const device = devices.find(d => d.deviceId === deviceId);
327 - if (device) {
328 - console.log(`Microphone input device: ${device.label} (ID: ${device.deviceId})`);
329 - } else {
330 - console.log(`Microphone input device ID: ${deviceId} (label not found)`);
331 - }
332 - } else {
333 - console.log('Microphone input deviceId not available');
334 - }
335 -
598 this.mediaRecorder = new MediaRecorder(stream);
599 this.mediaRecorder.ondataavailable = (event) => {
338 - if (event.data.size > 0 && (this.status === Status.RECORDING || this.status === Status.WAITING)) {
600 + if (
601 + event.data.size > 0 &&
602 + (this.status === Status.RECORDING || this.status === Status.WAITING)
603 + ) {
604 if (this.lastChunk) {
605 this.audioChunks.push(this.lastChunk);
606 this.lastChunk = null;
@@ -424,7 +689,8 @@ class MicrophoneInput {
689 }
690
691 setupAudioAnalysis(stream) {
427 - this.audioContext = new (window.AudioContext || window.webkitAudioContext)();
692 + this.audioContext = new (window.AudioContext ||
693 + window.webkitAudioContext)();
694 this.mediaStreamSource = this.audioContext.createMediaStreamSource(stream);
695 this.analyserNode = this.audioContext.createAnalyser();
696 this.analyserNode.fftSize = 2048;
@@ -454,7 +720,11 @@ class MicrophoneInput {
720 this.lastAudioTime = now;
721 this.silenceStartTime = null;
722
457 - if ((this.status === Status.LISTENING || this.status === Status.WAITING) && !store.isSpeaking && !store.isGenerating) {
723 + if (
724 + (this.status === Status.LISTENING ||
725 + this.status === Status.WAITING) &&
726 + !store.isSpeaking
727 + ) {
728 this.status = Status.RECORDING;
729 }
730 } else if (this.status === Status.RECORDING) {
@@ -543,12 +813,12 @@ class MicrophoneInput {
813 if (ok) return text;
814 else console.log(`Discarding transcription: ${text}`);
815 }
546 -
816 +
817 // Toggle microphone between active and inactive states
818 async toggle() {
819 const hasPermission = await this.requestPermission();
820 if (!hasPermission) return;
551 -
821 +
822 // Toggle between listening and inactive
823 if (this.status === Status.INACTIVE || this.status === Status.ACTIVATING) {
824 this.status = Status.LISTENING;
@@ -564,17 +834,20 @@ class MicrophoneInput {
834 return true;
835 } catch (err) {
836 console.error("Error accessing microphone:", err);
567 - toast("Microphone access denied. Please enable microphone access in your browser settings.", "error");
837 + toast(
838 + "Microphone access denied. Please enable microphone access in your browser settings.",
839 + "error"
840 + );
841 return false;
842 }
843 }
844 }
845
573 -export const store = createStore('speech', model);
846 +export const store = createStore("speech", model);
847
848 // Initialize speech store
849 // window.speechStore = speechStore;
850
851 // Event listeners
852 document.addEventListener("settings-updated", () => store.loadSettings());
580 -// document.addEventListener("DOMContentLoaded", () => speechStore.init());
\ No newline at end of file
853 +// document.addEventListener("DOMContentLoaded", () => speechStore.init());
webui/css/messages.css
+11 -8
@@ -71,9 +71,9 @@
71 /* margin-bottom: var(--spacing-sm); */
72 }
73
74 -.message .message-body{
75 - padding-top:0.5em;
76 - padding-bottom:0.5em;
74 +.message .message-body {
75 + padding-top: 0.5em;
76 + padding-bottom: 0.5em;
77 }
78
79 .message-user {
@@ -167,6 +167,10 @@
167 white-space: nowrap;
168 }
169
170 +.message-body code {
171 + white-space: break-spaces;
172 +}
173 +
174 .light-mode .message-code-exe .message-body {
175 border: 1px solid var(--color-border);
176 }
@@ -201,7 +205,6 @@
205 padding: 0.3em;
206 }
207
204 -
208 /* Agent and AI Info */
209 .agent-start {
210 color: var(--color-text);
@@ -528,7 +531,7 @@
531 font-size: var(--font-size-smaller);
532 }
533
531 -.message-agent-response .msg-content img{
534 +.message-agent-response .msg-content img {
535 max-width: 100%;
536 max-height: 100em;
537 }
@@ -636,7 +639,7 @@
639 } /* both children sit in the same column */
640
641 .message-group-right {
639 - width:100%;
642 + width: 100%;
643 justify-content: end;
644 }
645
@@ -684,6 +687,6 @@
687
688 /* shades */
689 .dark-mode .message {
687 - box-shadow: inset 0 2rem 2rem -2rem rgba(0, 0, 0, 0.3), inset 0 -2rem 2rem -2rem rgba(0, 0, 0, 0.1);
690 + box-shadow: inset 0 2rem 2rem -2rem rgba(0, 0, 0, 0.3),
691 + inset 0 -2rem 2rem -2rem rgba(0, 0, 0, 0.1);
692 }
689 -
webui/index.js
+25 -19
@@ -1,9 +1,9 @@
1 -import * as msgs from "./js/messages.js";
2 -import * as api from "./js/api.js";
3 -import * as css from "./js/css.js";
4 -import { sleep } from "./js/sleep.js";
5 -import { store as attachmentsStore } from "./components/chat/attachments/attachmentsStore.js";
6 -import { store as speechStore } from "./js/speech-store.js";
1 +import * as msgs from "/js/messages.js";
2 +import * as api from "/js/api.js";
3 +import * as css from "/js/css.js";
4 +import { sleep } from "/js/sleep.js";
5 +import { store as attachmentsStore } from "/components/chat/attachments/attachmentsStore.js";
6 +import { store as speechStore } from "/components/chat/speech/speech-store.js";
7
8 window.fetchApi = api.fetchApi; // TODO - backward compatibility for non-modular scripts, remove once refactored to alpine
9
@@ -23,6 +23,7 @@ const timeDate = document.getElementById("time-date-container");
23
24 let autoScroll = true;
25 let context = "";
26 +let resetCounter = 0;
27 let connectionStatus = false;
28
29 // Initialize the toggle button
@@ -505,13 +506,16 @@ function speakMessages(logs) {
506 // log.no, log.type, log.heading, log.content
507 for (let i = logs.length - 1; i >= 0; i--) {
508 const log = logs[i];
509 +
510 + // if already spoken, end
511 + // if(log.no < lastSpokenNo) break;
512 +
513 // finished response
509 - if (log.type == "response" && log.kvps && log.kvps.finished) {
510 - if (log.no > lastSpokenNo) {
511 - lastSpokenNo = log.no;
512 - speechStore.speak(log.content);
514 + if (log.type == "response") {
515 + // lastSpokenNo = log.no;
516 + speechStore.speakStream(getChatBasedId(log.no), log.content, log.kvps?.finished);
517 return;
514 - }
518 +
519 // finished LLM headline, not response
520 } else if (
521 log.type == "agent" &&
@@ -520,11 +524,9 @@ function speakMessages(logs) {
524 log.kvps.tool_args &&
525 log.kvps.tool_name != "response"
526 ) {
523 - if (log.no > lastSpokenNo) {
524 - lastSpokenNo = log.no;
525 - speechStore.speak(log.kvps.headline);
527 + // lastSpokenNo = log.no;
528 + speechStore.speakStream(getChatBasedId(log.no), log.kvps.headline, true);
529 return;
527 - }
530 }
531 }
532 }
@@ -558,6 +560,7 @@ window.resetChat = async function (ctxid = null) {
560 const resp = await sendJsonData("/chat_reset", {
561 context: ctxid === null ? context : ctxid,
562 });
563 + resetCounter++;
564 if (ctxid === null) updateAfterScroll();
565 } catch (e) {
566 window.toastFetchError("Error resetting chat", e);
@@ -715,9 +718,9 @@ export const setContext = function (id) {
718 lastLogGuid = "";
719 lastLogVersion = 0;
720 lastSpokenNo = 0;
718 -
721 +
722 // Stop speech when switching chats
720 - speechStore.stop();
723 + speechStore.stopAudio();
724
725 // Clear the chat history immediately to avoid showing stale content
726 chatHistory.innerHTML = "";
@@ -739,6 +742,10 @@ export const getContext = function () {
742 return context;
743 };
744
745 +export const getChatBasedId = function (id) {
746 + return context+"-"+resetCounter+"-"+id;
747 +};
748 +
749 window.toggleAutoScroll = async function (_autoScroll) {
750 autoScroll = _autoScroll;
751 };
@@ -778,7 +785,7 @@ window.toggleDarkMode = function (isDark) {
785 window.toggleSpeech = function (isOn) {
786 console.log("Speech:", isOn);
787 localStorage.setItem("speech", isOn);
781 - if (!isOn) speechStore.stop();
788 + if (!isOn) speechStore.stopAudio();
789 };
790
791 window.nudge = async function () {
@@ -1096,7 +1103,6 @@ async function startPolling() {
1103
1104 document.addEventListener("DOMContentLoaded", startPolling);
1105
1099 -
1106 // Setup event handlers once the DOM is fully loaded
1107 document.addEventListener("DOMContentLoaded", function () {
1108 setupSidebarToggle();
webui/js/messages.js
+4 -2
@@ -252,6 +252,8 @@ export function addBlankTargetsToLinks(str) {
252 const doc = new DOMParser().parseFromString(str, 'text/html');
253
254 doc.querySelectorAll('a').forEach(anchor => {
255 + const href = anchor.getAttribute('href') || '';
256 + if (href.startsWith('#') || href.trim().toLowerCase().startsWith('javascript')) return;
257 if (!anchor.hasAttribute('target') || anchor.getAttribute('target') === '') {
258 anchor.setAttribute('target', '_blank');
259 }
@@ -825,8 +827,8 @@ function convertPathsToLinks(str) {
827 }
828
829 function adjustMarkdownRender(element) {
828 - // find all tables and code blocks in the element
829 - const elements = element.querySelectorAll("table, code");
830 + // find all tables in the element
831 + const elements = element.querySelectorAll("table");
832
833 // wrap each with a div with class message-markdown-table-wrap
834 elements.forEach((el) => {
webui/js/tunnel.js
+8 -7
@@ -1,3 +1,4 @@
1 +
2 // Tunnel settings for the Settings modal
3 document.addEventListener('alpine:init', () => {
4 Alpine.data('tunnelSettings', () => ({
@@ -12,7 +13,7 @@ document.addEventListener('alpine:init', () => {
13
14 async checkTunnelStatus() {
15 try {
15 - const response = await fetch('/tunnel_proxy', {
16 + const response = await fetchApi('/tunnel_proxy', {
17 method: 'POST',
18 headers: {
19 'Content-Type': 'application/json',
@@ -35,7 +36,7 @@ document.addEventListener('alpine:init', () => {
36
37 if (storedTunnelUrl) {
38 // Use the stored URL but verify it's still valid
38 - const verifyResponse = await fetch('/tunnel_proxy', {
39 + const verifyResponse = await fetchApi('/tunnel_proxy', {
40 method: 'POST',
41 headers: {
42 'Content-Type': 'application/json',
@@ -82,7 +83,7 @@ document.addEventListener('alpine:init', () => {
83
84 try {
85 // First stop any existing tunnel
85 - const stopResponse = await fetch('/tunnel_proxy', {
86 + const stopResponse = await fetchApi('/tunnel_proxy', {
87 method: 'POST',
88 headers: {
89 'Content-Type': 'application/json',
@@ -116,7 +117,7 @@ document.addEventListener('alpine:init', () => {
117 async generateLink() {
118 // First check if authentication is enabled
119 try {
119 - const authCheckResponse = await fetch('/settings_get');
120 + const authCheckResponse = await fetchApi('/settings_get');
121 const authData = await authCheckResponse.json();
122
123 // Find the auth_login and auth_password in the settings
@@ -175,7 +176,7 @@ document.addEventListener('alpine:init', () => {
176
177 try {
178 // Call the backend API to create a tunnel
178 - const response = await fetch('/tunnel_proxy', {
179 + const response = await fetchApi('/tunnel_proxy', {
180 method: 'POST',
181 headers: {
182 'Content-Type': 'application/json',
@@ -207,7 +208,7 @@ document.addEventListener('alpine:init', () => {
208
209 // Check if tunnel is running now
210 try {
210 - const statusResponse = await fetch('/tunnel_proxy', {
211 + const statusResponse = await fetchApi('/tunnel_proxy', {
212 method: 'POST',
213 headers: {
214 'Content-Type': 'application/json',
@@ -259,7 +260,7 @@ document.addEventListener('alpine:init', () => {
260
261 try {
262 // Call the backend to stop the tunnel
262 - const response = await fetch('/tunnel_proxy', {
263 + const response = await fetchApi('/tunnel_proxy', {
264 method: 'POST',
265 headers: {
266 'Content-Type': 'application/json',