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 <think> 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 <noreply@anthropic.com>
This commit is contained in:
mARTin-B78 2026-07-08 22:54:20 +02:00
parent 2a2c34fb56
commit ed76af4976
7 changed files with 201 additions and 40 deletions

View File

@ -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 `<think>...</think>` blocks — including chat templates that inject the opening `<think>` 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 ## [1.14.5] — 2026-07-08
### Fixed ### Fixed

View File

@ -1 +1 @@
1.14.5 1.14.6

View File

@ -2063,7 +2063,7 @@ async def conversation_turn(
llm_payload["model"] = eff_llm_model llm_payload["model"] = eff_llm_model
_loop = asyncio.get_event_loop() _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: def _llm_thread() -> None:
try: try:
@ -2084,20 +2084,104 @@ async def conversation_turn(
try: try:
obj = json.loads(payload_str) obj = json.loads(payload_str)
d_obj = ((obj.get("choices") or [{}])[0].get("delta") or {}) d_obj = ((obj.get("choices") or [{}])[0].get("delta") or {})
delta = d_obj.get("content") or d_obj.get("reasoning_content") or "" reasoning = d_obj.get("reasoning_content") or ""
if not delta and isinstance(obj.get("message"), dict): content = d_obj.get("content") or ""
delta = obj["message"].get("content") or "" if not content and not reasoning and isinstance(obj.get("message"), dict):
if delta: content = obj["message"].get("content") or ""
_loop.call_soon_threadsafe(_token_q.put_nowait, delta) 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: except Exception:
continue continue
except Exception as exc: 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: finally:
_loop.call_soon_threadsafe(_token_q.put_nowait, None) _loop.call_soon_threadsafe(_token_q.put_nowait, None)
threading.Thread(target=_llm_thread, daemon=True).start() 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 <think>...</think> block inline
# inside content, split across arbitrary delta chunks. Many chat
# templates also inject the opening "<think>" as a fixed prompt prefix,
# so it never appears in the generated stream at all — only the closing
# "</think>" 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 <think>/</think>
# 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 = "</think>" if _think_state["in_think"] else "<think>"
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 "<think>".startswith(suffix) or "</think>".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("<think>")
close_idx = buf.find("</think>")
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>"):]
_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 # Streaming backend (8023) has per-process voice state — serialize to prevent
# concurrent requests from clobbering each other's voice context. # concurrent requests from clobbering each other's voice context.
tts_sem: asyncio.Semaphore | None = asyncio.Semaphore(1) if tts_be == "streaming" else None tts_sem: asyncio.Semaphore | None = asyncio.Semaphore(1) if tts_be == "streaming" else None
@ -2112,18 +2196,24 @@ async def conversation_turn(
try: try:
while True: while True:
delta = await _token_q.get() item = await _token_q.get()
if delta is None: if item is None:
break break
if delta.startswith("\x00ERR:"): kind, text = item
yield sse({"type": "error", "stage": "llm", "message": delta[5:]}) if kind == "error":
yield sse({"type": "error", "stage": "llm", "message": text})
return return
if not ttft_done: if not ttft_done:
llm_ttft_ms = int((time.monotonic() - t_llm) * 1000) llm_ttft_ms = int((time.monotonic() - t_llm) * 1000)
ttft_done = True ttft_done = True
llm_text += delta thinking_piece, answer_piece = (text, "") if kind == "reasoning" else _split_think(text)
sent_buf += delta if thinking_piece:
yield sse({"type": "token", "delta": delta}) 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 # Fire TTS on sentence boundary — runs concurrently with LLM
split = _sentence_split(sent_buf) split = _sentence_split(sent_buf)
if split > 0: if split > 0:
@ -2138,6 +2228,15 @@ async def conversation_turn(
yield sse({"type": "error", "stage": "llm", "message": str(exc)}) yield sse({"type": "error", "stage": "llm", "message": str(exc)})
return 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 # Flush any remaining text as a final TTS task
if sent_buf.strip(): if sent_buf.strip():
if tts_first_start is None: if tts_first_start is None:

View File

@ -10,7 +10,7 @@
<meta name="format-detection" content="telephone=no"> <meta name="format-detection" content="telephone=no">
<meta name="color-scheme" content="light dark"> <meta name="color-scheme" content="light dark">
<meta name="theme-color" content="#2563EB"> <meta name="theme-color" content="#2563EB">
<meta name="app-version" content="1.14.5"> <meta name="app-version" content="1.14.6">
<link rel="manifest" href="/manifest.webmanifest"> <link rel="manifest" href="/manifest.webmanifest">
<link rel="icon" href="/static/icon.svg" type="image/svg+xml"> <link rel="icon" href="/static/icon.svg" type="image/svg+xml">
<link rel="apple-touch-icon" href="/static/icon.svg"> <link rel="apple-touch-icon" href="/static/icon.svg">
@ -27,7 +27,7 @@
<!-- ── Core styles (local — no CDN dependency for first paint) ────────── --> <!-- ── Core styles (local — no CDN dependency for first paint) ────────── -->
<link rel="stylesheet" href="/static/vendor/mdi/materialdesignicons.min.css"> <link rel="stylesheet" href="/static/vendor/mdi/materialdesignicons.min.css">
<link rel="stylesheet" href="/static/style.css?v=1.14.5"> <link rel="stylesheet" href="/static/style.css?v=1.14.6">
<!-- ── Flag icons — non-blocking (loaded async, icons appear after JS) ── --> <!-- ── Flag icons — non-blocking (loaded async, icons appear after JS) ── -->
@ -365,7 +365,7 @@ window.toggleNavTree = function(treeId, chevronId) {
</script> </script>
<!-- loader.js: fetches sections → loads JS modules → removes skeleton --> <!-- loader.js: fetches sections → loads JS modules → removes skeleton -->
<script src="/static/loader.js?v=1.14.5"></script> <script src="/static/loader.js?v=1.14.6"></script>
</body> </body>
</html> </html>

View File

@ -398,6 +398,40 @@ $('s-import-voices-file')?.addEventListener('change', async function () {
return bubble; 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 =
'<div class="conv-think-wrap" hidden>' +
'<button type="button" class="conv-think-toggle" aria-expanded="false">' +
'<span class="mdi mdi-chevron-right"></span> Thinking' +
'</button>' +
'<div class="conv-think-body" hidden></div>' +
'</div>' +
'<span class="conv-answer-text"></span>';
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) { function addErrorBubble(msg) {
removeWelcome(); removeWelcome();
const wrap = document.createElement('div'); const wrap = document.createElement('div');
@ -580,11 +614,11 @@ $('s-import-voices-file')?.addEventListener('change', async function () {
} }
audioQueuePlaying = true; audioQueuePlaying = true;
const item = audioQueue.shift(); // {url, text} 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 && item.text) {
if (convCurrentSentenceBubble.querySelector('.conv-typing')) { const answerSpan = convCurrentSentenceBubble.querySelector('.conv-answer-text');
convCurrentSentenceBubble.innerHTML = ''; if (convCurrentSentenceBubble.querySelector('.conv-typing') || (answerSpan && !answerSpan.textContent)) {
convCurrentSentenceBubble.textContent = item.text; setAnswerText(convCurrentSentenceBubble, item.text);
} }
} }
const el = new Audio(item.url); const el = new Audio(item.url);
@ -951,15 +985,16 @@ $('s-import-voices-file')?.addEventListener('change', async function () {
if (evt.type === 'transcript') { if (evt.type === 'transcript') {
userBubble.textContent = evt.text || '(empty)'; userBubble.textContent = evt.text || '(empty)';
if (micStatus) micStatus.textContent = 'Generating reply…'; if (micStatus) micStatus.textContent = 'Generating reply…';
} else if (evt.type === 'thinking') {
appendThinking(assistantBubble, evt.delta);
} else if (evt.type === 'token') { } else if (evt.type === 'token') {
if (assistantBubble.querySelector('.conv-typing')) assistantBubble.innerHTML = '';
convCurrentSentenceBubble = null; // LLM tokens take over the bubble now convCurrentSentenceBubble = null; // LLM tokens take over the bubble now
assistantText += evt.delta; assistantText += evt.delta;
assistantBubble.textContent = assistantText; setAnswerText(assistantBubble, assistantText);
chatWindow.scrollTop = chatWindow.scrollHeight; chatWindow.scrollTop = chatWindow.scrollHeight;
} else if (evt.type === 'llm_done') { } else if (evt.type === 'llm_done') {
assistantText = evt.text || assistantText; assistantText = evt.text || assistantText;
assistantBubble.textContent = assistantText; setAnswerText(assistantBubble, assistantText);
convCurrentSentenceBubble = null; convCurrentSentenceBubble = null;
if (micStatus) micStatus.textContent = 'Synthesising speech…'; if (micStatus) micStatus.textContent = 'Synthesising speech…';
} else if (evt.type === 'audio') { } else if (evt.type === 'audio') {
@ -1073,15 +1108,16 @@ $('s-import-voices-file')?.addEventListener('change', async function () {
if (evt.type === 'transcript') { if (evt.type === 'transcript') {
if (micStatus) micStatus.textContent = 'Generating reply…'; if (micStatus) micStatus.textContent = 'Generating reply…';
} else if (evt.type === 'thinking') {
appendThinking(assistantBubble, evt.delta);
} else if (evt.type === 'token') { } else if (evt.type === 'token') {
if (assistantBubble.querySelector('.conv-typing')) assistantBubble.innerHTML = '';
convCurrentSentenceBubble = null; convCurrentSentenceBubble = null;
assistantText += evt.delta; assistantText += evt.delta;
assistantBubble.textContent = assistantText; setAnswerText(assistantBubble, assistantText);
chatWindow.scrollTop = chatWindow.scrollHeight; chatWindow.scrollTop = chatWindow.scrollHeight;
} else if (evt.type === 'llm_done') { } else if (evt.type === 'llm_done') {
assistantText = evt.text || assistantText; assistantText = evt.text || assistantText;
assistantBubble.textContent = assistantText; setAnswerText(assistantBubble, assistantText);
convCurrentSentenceBubble = null; convCurrentSentenceBubble = null;
if (micStatus) micStatus.textContent = 'Synthesising speech…'; if (micStatus) micStatus.textContent = 'Synthesising speech…';
} else if (evt.type === 'audio') { } else if (evt.type === 'audio') {

View File

@ -59,14 +59,6 @@
<!-- Input bar: text field + send + mic --> <!-- Input bar: text field + send + mic -->
<div class="conv-input-bar"> <div class="conv-input-bar">
<div class="conv-mic-status" id="conv-mic-status">Ready</div> <div class="conv-mic-status" id="conv-mic-status">Ready</div>
<div class="conv-gate-row">
<div class="conv-level-wrap" id="conv-level-wrap">
<div class="conv-level-fill" id="conv-level-fill"></div>
<div class="conv-level-gate" id="conv-level-gate"></div>
</div>
<span class="conv-gate-label" title="Noise gate — raise to ignore background noise"><span class="mdi mdi-tune-variant"></span></span>
<input type="range" id="conv-gate-slider" class="conv-gate-slider" min="1" max="80" value="20" title="Noise gate threshold (raise to ignore background noise)">
</div>
<div class="conv-text-row"> <div class="conv-text-row">
<input id="conv-text-input" class="conv-text-inp" <input id="conv-text-input" class="conv-text-inp"
type="text" placeholder="Type a message and press Enter or →" type="text" placeholder="Type a message and press Enter or →"
@ -79,6 +71,14 @@
<span class="mdi mdi-microphone" id="conv-mic-icon"></span> <span class="mdi mdi-microphone" id="conv-mic-icon"></span>
</button> </button>
<div class="conv-mic-timer" id="conv-mic-timer"></div> <div class="conv-mic-timer" id="conv-mic-timer"></div>
<div class="conv-gate-row">
<div class="conv-level-wrap" id="conv-level-wrap">
<div class="conv-level-fill" id="conv-level-fill"></div>
<div class="conv-level-gate" id="conv-level-gate"></div>
</div>
<span class="conv-gate-label" title="Noise gate — raise to ignore background noise"><span class="mdi mdi-tune-variant"></span></span>
<input type="range" id="conv-gate-slider" class="conv-gate-slider" min="1" max="80" value="20" title="Noise gate threshold (raise to ignore background noise)">
</div>
<label class="conv-vad-label" title="Auto-stop recording when silence is detected"> <label class="conv-vad-label" title="Auto-stop recording when silence is detected">
<input type="checkbox" id="conv-vad-toggle" checked> <input type="checkbox" id="conv-vad-toggle" checked>
<span>Auto-stop</span> <span>Auto-stop</span>

View File

@ -3762,6 +3762,22 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
.conv-typing span:nth-child(3) { animation-delay: .4s; } .conv-typing span:nth-child(3) { animation-delay: .4s; }
@keyframes convDot { 0%,80%,100% { transform: scale(.7); opacity:.3; } 40% { transform: scale(1); opacity:.9; } } @keyframes convDot { 0%,80%,100% { transform: scale(.7); opacity:.3; } 40% { transform: scale(1); opacity:.9; } }
/* ── Collapsible "thinking" (reasoning) panel inside an assistant bubble ── */
.conv-think-wrap { border: 1px solid var(--border); border-radius: 10px; margin-bottom: 8px; background: var(--surface); overflow: hidden; }
.conv-think-toggle {
width: 100%; display: flex; align-items: center; gap: 6px; padding: 7px 10px;
background: none; border: none; cursor: pointer; font-family: inherit;
font-size: 12.5px; font-weight: 600; color: var(--subtext); text-align: left;
}
.conv-think-toggle:hover { color: var(--text); }
.conv-think-toggle .mdi { transition: transform .15s; font-size: 15px; }
.conv-think-toggle[aria-expanded="true"] .mdi { transform: rotate(90deg); }
.conv-think-body {
padding: 0 12px 10px; font-size: 12.5px; line-height: 1.5; color: var(--subtext);
max-height: 220px; overflow-y: auto; white-space: pre-wrap; word-break: break-word;
}
.conv-answer-text { white-space: pre-wrap; word-break: break-word; }
/* ── Conversation input bar (text field + send + mic) ───────────────────── */ /* ── Conversation input bar (text field + send + mic) ───────────────────── */
.conv-input-bar { .conv-input-bar {
background: var(--surface); border: 1px solid var(--border); border-top: none; background: var(--surface); border: 1px solid var(--border); border-top: none;
@ -3803,11 +3819,11 @@ code { background: var(--panel); border-radius: 4px; padding: 1px 5px; font-fami
.conv-mic-timer { font-size: 13px; font-variant-numeric: tabular-nums; color: var(--red); min-width: 36px; text-align: right; } .conv-mic-timer { font-size: 13px; font-variant-numeric: tabular-nums; color: var(--red); min-width: 36px; text-align: right; }
.conv-vad-label { display: flex; align-items: center; gap: 4px; font-size: 11px; color: var(--subtext); cursor: pointer; white-space: nowrap; user-select: none; } .conv-vad-label { display: flex; align-items: center; gap: 4px; font-size: 11px; color: var(--subtext); cursor: pointer; white-space: nowrap; user-select: none; }
.conv-vad-label input { cursor: pointer; accent-color: var(--accent); } .conv-vad-label input { cursor: pointer; accent-color: var(--accent); }
/* Noise gate row */ /* Noise gate row — grouped with the mic controls, not a full-width row */
.conv-gate-row { display: flex; align-items: center; gap: 8px; } .conv-gate-row { display: flex; align-items: center; gap: 6px; flex-shrink: 0; width: 96px; }
.conv-gate-label { font-size: 13px; color: var(--subtext); flex-shrink: 0; line-height: 1; } .conv-gate-label { font-size: 13px; color: var(--subtext); flex-shrink: 0; line-height: 1; }
.conv-gate-slider { flex: 1; max-width: 100px; height: 3px; cursor: pointer; accent-color: var(--accent); } .conv-gate-slider { flex: 1; min-width: 0; height: 3px; cursor: pointer; accent-color: var(--accent); }
.conv-level-wrap { position: relative; flex: 1; height: 6px; background: var(--panel); border-radius: 3px; margin: 0; overflow: visible; display: none; } .conv-level-wrap { position: relative; flex: 1; min-width: 0; height: 6px; background: var(--panel); border-radius: 3px; margin: 0; overflow: visible; display: none; }
.conv-level-wrap.active { display: block; } .conv-level-wrap.active { display: block; }
.conv-level-fill { height: 100%; background: var(--red); border-radius: 3px; width: 0%; transition: width .07s linear; } .conv-level-fill { height: 100%; background: var(--red); border-radius: 3px; width: 0%; transition: width .07s linear; }
.conv-level-gate { position: absolute; top: -2px; bottom: -2px; width: 2px; background: var(--accent); border-radius: 1px; pointer-events: none; } .conv-level-gate { position: absolute; top: -2px; bottom: -2px; width: 2px; background: var(--accent); border-radius: 1px; pointer-events: none; }