From ed76af497605720738dacdc3f3c5f8c70520c6a7 Mon Sep 17 00:00:00 2001 From: mARTin-B78 Date: Wed, 8 Jul 2026 22:54:20 +0200 Subject: [PATCH] Add collapsible thinking panel to Conversation Playground, regroup mic controls (v1.14.6) Reasoning-model chain-of-thought is now separated from the spoken/displayed reply on the server (handles both a dedicated reasoning_content field and inline blocks, including chat templates that inject the opening tag as a prompt prefix so it never appears in the stream). The client shows it in a panel collapsed behind a "Thinking" chevron instead of dumping raw reasoning text into the chat or speaking it aloud via TTS. Also moved the noise-gate slider out of its own row and next to the mic button, grouping it with Auto-stop/Hands-free/Live agent. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 10 +++ VERSION | 2 +- routes/conversation.py | 127 +++++++++++++++++++++++++--- static/index.html | 6 +- static/js/conversation.js | 56 +++++++++--- static/sections/s-conversation.html | 16 ++-- static/style.css | 24 +++++- 7 files changed, 201 insertions(+), 40 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a704366..5ef722c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,16 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi --- +## [1.14.6] — 2026-07-08 + +### Added +- **Collapsible "Thinking" panel in the Conversation Playground** — reasoning models' chain-of-thought is now separated from the actual reply: it streams into a collapsed panel behind a "Thinking" chevron (click to expand/collapse) instead of appearing inline in the chat or being spoken aloud. Handles both API styles: a dedicated `reasoning_content` delta field, and inline `...` blocks — including chat templates that inject the opening `` as a fixed prompt prefix, so it never appears in the model's own stream (only the closing tag does). + +### Changed +- **Noise-gate slider moved next to the mic controls** in the Conversation Playground — it was sitting on its own row to the left of the text input; now it's grouped with Auto-stop/Hands-free/Live agent since it's a mic-input setting. + +--- + ## [1.14.5] — 2026-07-08 ### Fixed diff --git a/VERSION b/VERSION index 24a57f2..c6ba3bc 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.14.5 +1.14.6 diff --git a/routes/conversation.py b/routes/conversation.py index c49fa4c..137ea7b 100644 --- a/routes/conversation.py +++ b/routes/conversation.py @@ -2063,7 +2063,7 @@ async def conversation_turn( llm_payload["model"] = eff_llm_model _loop = asyncio.get_event_loop() - _token_q: asyncio.Queue[str | None] = asyncio.Queue() + _token_q: asyncio.Queue[tuple[str, str] | None] = asyncio.Queue() def _llm_thread() -> None: try: @@ -2084,20 +2084,104 @@ async def conversation_turn( try: obj = json.loads(payload_str) d_obj = ((obj.get("choices") or [{}])[0].get("delta") or {}) - delta = d_obj.get("content") or d_obj.get("reasoning_content") or "" - if not delta and isinstance(obj.get("message"), dict): - delta = obj["message"].get("content") or "" - if delta: - _loop.call_soon_threadsafe(_token_q.put_nowait, delta) + reasoning = d_obj.get("reasoning_content") or "" + content = d_obj.get("content") or "" + if not content and not reasoning and isinstance(obj.get("message"), dict): + content = obj["message"].get("content") or "" + if reasoning: + _loop.call_soon_threadsafe(_token_q.put_nowait, ("reasoning", reasoning)) + if content: + _loop.call_soon_threadsafe(_token_q.put_nowait, ("content", content)) except Exception: continue except Exception as exc: - _loop.call_soon_threadsafe(_token_q.put_nowait, f"\x00ERR:{exc}") + _loop.call_soon_threadsafe(_token_q.put_nowait, ("error", str(exc))) finally: _loop.call_soon_threadsafe(_token_q.put_nowait, None) threading.Thread(target=_llm_thread, daemon=True).start() + # Some models (e.g. Qwen3 in "thinking" mode) don't use a separate + # reasoning_content field — they emit a ... block inline + # inside content, split across arbitrary delta chunks. Many chat + # templates also inject the opening "" as a fixed prompt prefix, + # so it never appears in the generated stream at all — only the closing + # "" does. This tracks both explicit-tag and implicit-open + # styles so the answer stays clean for chat history/TTS and the + # reasoning can be shown in a collapsible "thinking" panel instead. + _THINK_SNIFF_LIMIT = 300 # chars buffered before assuming "not a reasoning response" + _think_state = {"buf": "", "resolved": False, "in_think": False} + + def _explicit_tag_split(text: str) -> tuple[str, str]: + # Assumes _think_state["resolved"] is True — scans for / + # tags that may be split across delta chunks. + _think_state["buf"] += text + thinking_parts: list[str] = [] + answer_parts: list[str] = [] + buf = _think_state["buf"] + while True: + tag = "" if _think_state["in_think"] else "" + idx = buf.find(tag) + if idx == -1: + break + before = buf[:idx] + (thinking_parts if _think_state["in_think"] else answer_parts).append(before) + buf = buf[idx + len(tag):] + _think_state["in_think"] = not _think_state["in_think"] + # Hold back a short suffix that could be the start of a split tag + hold = 0 + for cut in range(1, min(8, len(buf)) + 1): + suffix = buf[-cut:] + if "".startswith(suffix) or "".startswith(suffix): + hold = cut + flush = buf[:len(buf) - hold] if hold else buf + _think_state["buf"] = buf[len(buf) - hold:] if hold else "" + (thinking_parts if _think_state["in_think"] else answer_parts).append(flush) + return "".join(thinking_parts), "".join(answer_parts) + + def _split_think(text: str) -> tuple[str, str]: + if _think_state["resolved"]: + return _explicit_tag_split(text) + _think_state["buf"] += text + buf = _think_state["buf"] + open_idx = buf.find("") + close_idx = buf.find("") + if close_idx != -1 and (open_idx == -1 or close_idx < open_idx): + # No opening tag before this close — the model (or its chat + # template) started inside a think block implicitly. + thinking_text = buf[:close_idx] + rest = buf[close_idx + len(""):] + _think_state["resolved"] = True + _think_state["in_think"] = False + _think_state["buf"] = "" + _, rest_answer = _explicit_tag_split(rest) if rest else ("", "") + return thinking_text, rest_answer + if open_idx != -1: + before = buf[:open_idx] + rest = buf[open_idx:] + _think_state["resolved"] = True + _think_state["in_think"] = False + _think_state["buf"] = "" + thinking_rest, answer_rest = _explicit_tag_split(rest) if rest else ("", "") + return thinking_rest, before + answer_rest + if len(buf) >= _THINK_SNIFF_LIMIT: + # No think tag within the sniff window — treat as a plain, + # non-reasoning response from here on. + _think_state["resolved"] = True + _think_state["in_think"] = False + _think_state["buf"] = "" + return "", buf + return "", "" # still buffering — undecided + + def _finish_split_think() -> tuple[str, str]: + if not _think_state["buf"]: + return "", "" + leftover = _think_state["buf"] + _think_state["buf"] = "" + if not _think_state["resolved"]: + return "", leftover # stream ended before we saw any think tag + return (leftover, "") if _think_state["in_think"] else ("", leftover) + # Streaming backend (8023) has per-process voice state — serialize to prevent # concurrent requests from clobbering each other's voice context. tts_sem: asyncio.Semaphore | None = asyncio.Semaphore(1) if tts_be == "streaming" else None @@ -2112,18 +2196,24 @@ async def conversation_turn( try: while True: - delta = await _token_q.get() - if delta is None: + item = await _token_q.get() + if item is None: break - if delta.startswith("\x00ERR:"): - yield sse({"type": "error", "stage": "llm", "message": delta[5:]}) + kind, text = item + if kind == "error": + yield sse({"type": "error", "stage": "llm", "message": text}) return if not ttft_done: llm_ttft_ms = int((time.monotonic() - t_llm) * 1000) ttft_done = True - llm_text += delta - sent_buf += delta - yield sse({"type": "token", "delta": delta}) + thinking_piece, answer_piece = (text, "") if kind == "reasoning" else _split_think(text) + if thinking_piece: + yield sse({"type": "thinking", "delta": thinking_piece}) + if not answer_piece: + continue + llm_text += answer_piece + sent_buf += answer_piece + yield sse({"type": "token", "delta": answer_piece}) # Fire TTS on sentence boundary — runs concurrently with LLM split = _sentence_split(sent_buf) if split > 0: @@ -2138,6 +2228,15 @@ async def conversation_turn( yield sse({"type": "error", "stage": "llm", "message": str(exc)}) return + # Flush any text still held back (partial tag, or sniff buffer never resolved) + _thinking_tail, _answer_tail = _finish_split_think() + if _thinking_tail: + yield sse({"type": "thinking", "delta": _thinking_tail}) + if _answer_tail: + llm_text += _answer_tail + sent_buf += _answer_tail + yield sse({"type": "token", "delta": _answer_tail}) + # Flush any remaining text as a final TTS task if sent_buf.strip(): if tts_first_start is None: diff --git a/static/index.html b/static/index.html index b2d3df5..895bfa4 100644 --- a/static/index.html +++ b/static/index.html @@ -10,7 +10,7 @@ - + @@ -27,7 +27,7 @@ - + @@ -365,7 +365,7 @@ window.toggleNavTree = function(treeId, chevronId) { - + diff --git a/static/js/conversation.js b/static/js/conversation.js index da11b25..9fa1f0d 100644 --- a/static/js/conversation.js +++ b/static/js/conversation.js @@ -398,6 +398,40 @@ $('s-import-voices-file')?.addEventListener('change', async function () { return bubble; } + // Lazily builds the thinking-panel + answer-text structure inside an + // assistant bubble the first time either a 'thinking' or 'token' event + // arrives, replacing the "..." typing dots. + function ensureAssistantParts(bubble) { + if (bubble.querySelector('.conv-answer-text')) return; + bubble.innerHTML = + '' + + ''; + bubble.querySelector('.conv-think-toggle').addEventListener('click', function () { + const open = this.getAttribute('aria-expanded') === 'true'; + this.setAttribute('aria-expanded', String(!open)); + bubble.querySelector('.conv-think-body').hidden = open; + }); + } + + function appendThinking(bubble, delta) { + ensureAssistantParts(bubble); + bubble.querySelector('.conv-think-wrap').hidden = false; + // Stays collapsed regardless of streaming state — the user only sees the + // reasoning text if they click the "Thinking" chevron to expand it. + bubble.querySelector('.conv-think-body').textContent += delta; + } + + function setAnswerText(bubble, text) { + ensureAssistantParts(bubble); + bubble.dataset.answerStarted = '1'; + bubble.querySelector('.conv-answer-text').textContent = text; + } + function addErrorBubble(msg) { removeWelcome(); const wrap = document.createElement('div'); @@ -580,11 +614,11 @@ $('s-import-voices-file')?.addEventListener('change', async function () { } audioQueuePlaying = true; const item = audioQueue.shift(); // {url, text} - // Show the sentence text in the typing bubble if it still shows "..." + // Show the sentence text in the typing bubble if no answer text has landed yet if (convCurrentSentenceBubble && item.text) { - if (convCurrentSentenceBubble.querySelector('.conv-typing')) { - convCurrentSentenceBubble.innerHTML = ''; - convCurrentSentenceBubble.textContent = item.text; + const answerSpan = convCurrentSentenceBubble.querySelector('.conv-answer-text'); + if (convCurrentSentenceBubble.querySelector('.conv-typing') || (answerSpan && !answerSpan.textContent)) { + setAnswerText(convCurrentSentenceBubble, item.text); } } const el = new Audio(item.url); @@ -951,15 +985,16 @@ $('s-import-voices-file')?.addEventListener('change', async function () { if (evt.type === 'transcript') { userBubble.textContent = evt.text || '(empty)'; if (micStatus) micStatus.textContent = 'Generating reply…'; + } else if (evt.type === 'thinking') { + appendThinking(assistantBubble, evt.delta); } else if (evt.type === 'token') { - if (assistantBubble.querySelector('.conv-typing')) assistantBubble.innerHTML = ''; convCurrentSentenceBubble = null; // LLM tokens take over the bubble now assistantText += evt.delta; - assistantBubble.textContent = assistantText; + setAnswerText(assistantBubble, assistantText); chatWindow.scrollTop = chatWindow.scrollHeight; } else if (evt.type === 'llm_done') { assistantText = evt.text || assistantText; - assistantBubble.textContent = assistantText; + setAnswerText(assistantBubble, assistantText); convCurrentSentenceBubble = null; if (micStatus) micStatus.textContent = 'Synthesising speech…'; } else if (evt.type === 'audio') { @@ -1073,15 +1108,16 @@ $('s-import-voices-file')?.addEventListener('change', async function () { if (evt.type === 'transcript') { if (micStatus) micStatus.textContent = 'Generating reply…'; + } else if (evt.type === 'thinking') { + appendThinking(assistantBubble, evt.delta); } else if (evt.type === 'token') { - if (assistantBubble.querySelector('.conv-typing')) assistantBubble.innerHTML = ''; convCurrentSentenceBubble = null; assistantText += evt.delta; - assistantBubble.textContent = assistantText; + setAnswerText(assistantBubble, assistantText); chatWindow.scrollTop = chatWindow.scrollHeight; } else if (evt.type === 'llm_done') { assistantText = evt.text || assistantText; - assistantBubble.textContent = assistantText; + setAnswerText(assistantBubble, assistantText); convCurrentSentenceBubble = null; if (micStatus) micStatus.textContent = 'Synthesising speech…'; } else if (evt.type === 'audio') { diff --git a/static/sections/s-conversation.html b/static/sections/s-conversation.html index 884af80..4ddb71b 100644 --- a/static/sections/s-conversation.html +++ b/static/sections/s-conversation.html @@ -59,14 +59,6 @@
Ready
-
-
-
-
-
- - -
+
+
+
+
+
+ + +