Show Qwen3 thinking output in chat as collapsible Reasoning block

Ollama streams message.thinking tokens alongside message.content when
think=True — previously silently dropped. Now forwarded end-to-end.

Backend:
- llm.py: ChatChunk type gains "thinking" variant; stream_chat_with_tools
  yields ChatChunk(type="thinking") for msg.thinking chunks before content
- generation_task.py: thinking chunks emit "thinking_chunk" SSE events
  (not added to content_so_far — not persisted to DB)

Frontend:
- types/chat.ts: Message.thinking?: string (session-only, not from DB)
- stores/chat.ts: streamingThinking ref; thinking_chunk handler accumulates
  chunks; on done, thinking carried into committed Message object then cleared
- ChatMessage.vue: collapsible <details class="thinking-block"> shown for
  messages that have .thinking content (collapsed by default)
- ChatView.vue + ChatPanel.vue: live thinking block in streaming bubble —
  open while only thinking is flowing, auto-collapses when content arrives;
  typing indicator hidden while thinking is active

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-26 23:16:59 -05:00
parent 5e83c8a56d
commit 432e0bd2a0
7 changed files with 171 additions and 5 deletions
+47
View File
@@ -68,6 +68,10 @@ const timingParts = computed((): string[] => {
</button>
</div>
</div>
<details v-if="message.thinking" class="thinking-block">
<summary class="thinking-summary">Reasoning</summary>
<pre class="thinking-text">{{ message.thinking }}</pre>
</details>
<div class="message-content prose" v-html="rendered"></div>
<div v-if="message.tool_calls?.length" class="tool-calls">
<ToolCallCard
@@ -147,6 +151,49 @@ const timingParts = computed((): string[] => {
color: var(--color-text-muted);
}
.thinking-block {
margin-bottom: 0.5rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
overflow: hidden;
}
.thinking-summary {
padding: 0.25rem 0.5rem;
font-size: 0.72rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--color-text-muted);
cursor: pointer;
user-select: none;
list-style: none;
display: flex;
align-items: center;
gap: 0.3rem;
background: var(--color-bg-secondary);
}
.thinking-summary::-webkit-details-marker { display: none; }
.thinking-summary::before {
content: "▶";
font-size: 0.6rem;
transition: transform 0.15s;
}
details[open] .thinking-summary::before {
transform: rotate(90deg);
}
.thinking-text {
margin: 0;
padding: 0.5rem;
font-size: 0.8rem;
line-height: 1.5;
color: var(--color-text-secondary);
white-space: pre-wrap;
word-break: break-word;
max-height: 300px;
overflow-y: auto;
background: var(--color-bg-secondary);
border-top: 1px solid var(--color-border);
}
.message-content {
font-size: 0.95rem;
line-height: 1.55;
+52 -2
View File
@@ -197,8 +197,16 @@ function promoteAutoNote(note: { id: number; title: string }) {
<div class="message-header">
<span class="role-label">{{ settingsStore.assistantName }}</span>
</div>
<details
v-if="store.streamingThinking"
class="thinking-block"
:open="!store.streamingContent"
>
<summary class="thinking-summary">Reasoning</summary>
<pre class="thinking-text">{{ store.streamingThinking }}</pre>
</details>
<div class="message-content prose" v-html="streamingRendered"></div>
<span class="typing-indicator"></span>
<span v-if="!store.streamingThinking" class="typing-indicator"></span>
</div>
</div>
@@ -375,7 +383,49 @@ function promoteAutoNote(note: { id: number; title: string }) {
line-height: 1.55;
word-break: break-word;
}
.thinking-block {
margin-bottom: 0.5rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
overflow: hidden;
}
.thinking-summary {
padding: 0.25rem 0.5rem;
font-size: 0.72rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--color-text-muted);
cursor: pointer;
user-select: none;
list-style: none;
display: flex;
align-items: center;
gap: 0.3rem;
background: var(--color-bg-secondary);
}
.thinking-summary::-webkit-details-marker { display: none; }
.thinking-summary::before {
content: "▶";
font-size: 0.6rem;
transition: transform 0.15s;
}
details[open] .thinking-summary::before {
transform: rotate(90deg);
}
.thinking-text {
margin: 0;
padding: 0.5rem;
font-size: 0.8rem;
line-height: 1.5;
color: var(--color-text-secondary);
white-space: pre-wrap;
word-break: break-word;
max-height: 300px;
overflow-y: auto;
background: var(--color-bg-secondary);
border-top: 1px solid var(--color-border);
}
.typing-indicator {
display: inline-block;
width: 6px;
+9
View File
@@ -27,6 +27,7 @@ export const useChatStore = defineStore("chat", () => {
const loading = ref(false);
const streaming = ref(false);
const streamingContent = ref("");
const streamingThinking = ref("");
const streamingToolCalls = ref<ToolCallRecord[]>([]);
const streamingStatus = ref("");
const streamingPendingTool = ref<ToolPendingRecord | null>(null);
@@ -206,6 +207,9 @@ export const useChatStore = defineStore("chat", () => {
case "status":
streamingStatus.value = event.data.status as string;
break;
case "thinking_chunk":
streamingThinking.value += event.data.chunk as string;
break;
case "chunk":
streamingContent.value += event.data.chunk as string;
streamingStatus.value = "";
@@ -235,11 +239,13 @@ export const useChatStore = defineStore("chat", () => {
: null,
created_at: new Date().toISOString(),
timing: event.data.timing as GenerationTiming | undefined,
thinking: streamingThinking.value || undefined,
};
if (currentConversation.value?.id === convId) {
currentConversation.value.messages.push(assistantMsg);
}
streamingContent.value = "";
streamingThinking.value = "";
streamingToolCalls.value = [];
streamingStatus.value = "";
streamingPendingTool.value = null;
@@ -257,6 +263,7 @@ export const useChatStore = defineStore("chat", () => {
gotDone = true;
streaming.value = false;
streamingContent.value = "";
streamingThinking.value = "";
streamingToolCalls.value = [];
streamingStatus.value = "";
streamingPendingTool.value = null;
@@ -296,6 +303,7 @@ export const useChatStore = defineStore("chat", () => {
streaming.value = false;
streamingContent.value = "";
streamingThinking.value = "";
streamingToolCalls.value = [];
streamingStatus.value = "";
streamingPendingTool.value = null;
@@ -397,6 +405,7 @@ export const useChatStore = defineStore("chat", () => {
loading,
streaming,
streamingContent,
streamingThinking,
streamingToolCalls,
streamingStatus,
streamingPendingTool,
+1
View File
@@ -30,6 +30,7 @@ export interface Message {
tool_calls?: ToolCallRecord[] | null;
created_at: string;
timing?: GenerationTiming;
thinking?: string;
}
export interface SendMessageResponse {
+52 -1
View File
@@ -418,8 +418,16 @@ onUnmounted(() => {
<span class="streaming-status-dot"></span>
{{ store.streamingStatus }}
</div>
<details
v-if="store.streamingThinking"
class="thinking-block"
:open="!store.streamingContent"
>
<summary class="thinking-summary">Reasoning</summary>
<pre class="thinking-text">{{ store.streamingThinking }}</pre>
</details>
<div class="message-content prose" v-html="streamingRendered"></div>
<span v-if="!store.streamingStatus" class="typing-indicator"></span>
<span v-if="!store.streamingStatus && !store.streamingThinking" class="typing-indicator"></span>
</div>
</div>
@@ -844,6 +852,49 @@ onUnmounted(() => {
font-style: italic;
margin-bottom: 0.3rem;
}
.thinking-block {
margin-bottom: 0.5rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
overflow: hidden;
}
.thinking-summary {
padding: 0.25rem 0.5rem;
font-size: 0.72rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--color-text-muted);
cursor: pointer;
user-select: none;
list-style: none;
display: flex;
align-items: center;
gap: 0.3rem;
background: var(--color-bg-secondary);
}
.thinking-summary::-webkit-details-marker { display: none; }
.thinking-summary::before {
content: "▶";
font-size: 0.6rem;
transition: transform 0.15s;
}
details[open] .thinking-summary::before {
transform: rotate(90deg);
}
.thinking-text {
margin: 0;
padding: 0.5rem;
font-size: 0.8rem;
line-height: 1.5;
color: var(--color-text-secondary);
white-space: pre-wrap;
word-break: break-word;
max-height: 300px;
overflow-y: auto;
background: var(--color-bg-secondary);
border-top: 1px solid var(--color-border);
}
.streaming-status-dot {
display: inline-block;
width: 6px;
@@ -442,7 +442,10 @@ async def run_generation(
cancelled = True
break
if chunk.type == "content":
if chunk.type == "thinking":
buf.append_event("thinking_chunk", {"chunk": chunk.content})
elif chunk.type == "content":
if timing["ttft_ms"] is None:
timing["ttft_ms"] = int((time.monotonic() - t_start) * 1000)
buf.content_so_far += chunk.content
+6 -1
View File
@@ -131,7 +131,7 @@ async def stream_chat(
@dataclass
class ChatChunk:
"""A chunk yielded by stream_chat_with_tools."""
type: Literal["content", "tool_calls", "done"]
type: Literal["content", "thinking", "tool_calls", "done"]
content: str = ""
tool_calls: list[dict] | None = None
@@ -178,6 +178,11 @@ async def stream_chat_with_tools(
data = json.loads(line)
msg = data.get("message", {})
# Thinking chunks (qwen3 chain-of-thought, only when think=True)
thinking = msg.get("thinking", "")
if thinking:
yield ChatChunk(type="thinking", content=thinking)
# Content chunks
chunk = msg.get("content", "")
if chunk: