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) {
-
+