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:
parent
2a2c34fb56
commit
ed76af4976
10
CHANGELOG.md
10
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 `<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
|
||||
|
||||
### Fixed
|
||||
|
||||
@ -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 <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
|
||||
# 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:
|
||||
|
||||
@ -10,7 +10,7 @@
|
||||
<meta name="format-detection" content="telephone=no">
|
||||
<meta name="color-scheme" content="light dark">
|
||||
<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="icon" href="/static/icon.svg" type="image/svg+xml">
|
||||
<link rel="apple-touch-icon" href="/static/icon.svg">
|
||||
@ -27,7 +27,7 @@
|
||||
|
||||
<!-- ── Core styles (local — no CDN dependency for first paint) ────────── -->
|
||||
<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) ── -->
|
||||
@ -365,7 +365,7 @@ window.toggleNavTree = function(treeId, chevronId) {
|
||||
</script>
|
||||
|
||||
<!-- 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>
|
||||
</html>
|
||||
|
||||
@ -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 =
|
||||
'<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) {
|
||||
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') {
|
||||
|
||||
@ -59,14 +59,6 @@
|
||||
<!-- Input bar: text field + send + mic -->
|
||||
<div class="conv-input-bar">
|
||||
<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">
|
||||
<input id="conv-text-input" class="conv-text-inp"
|
||||
type="text" placeholder="Type a message and press Enter or →"
|
||||
@ -79,6 +71,14 @@
|
||||
<span class="mdi mdi-microphone" id="conv-mic-icon"></span>
|
||||
</button>
|
||||
<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">
|
||||
<input type="checkbox" id="conv-vad-toggle" checked>
|
||||
<span>Auto-stop</span>
|
||||
|
||||
@ -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; }
|
||||
@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) ───────────────────── */
|
||||
.conv-input-bar {
|
||||
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-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); }
|
||||
/* Noise gate row */
|
||||
.conv-gate-row { display: flex; align-items: center; gap: 8px; }
|
||||
/* Noise gate row — grouped with the mic controls, not a full-width row */
|
||||
.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-slider { flex: 1; max-width: 100px; 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-gate-slider { flex: 1; min-width: 0; height: 3px; cursor: pointer; accent-color: var(--accent); }
|
||||
.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-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; }
|
||||
|
||||
Loading…
Reference in New Issue
Block a user