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:
@@ -68,6 +68,10 @@ const timingParts = computed((): string[] => {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</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 class="message-content prose" v-html="rendered"></div>
|
||||||
<div v-if="message.tool_calls?.length" class="tool-calls">
|
<div v-if="message.tool_calls?.length" class="tool-calls">
|
||||||
<ToolCallCard
|
<ToolCallCard
|
||||||
@@ -147,6 +151,49 @@ const timingParts = computed((): string[] => {
|
|||||||
color: var(--color-text-muted);
|
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 {
|
.message-content {
|
||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
line-height: 1.55;
|
line-height: 1.55;
|
||||||
|
|||||||
@@ -197,8 +197,16 @@ function promoteAutoNote(note: { id: number; title: string }) {
|
|||||||
<div class="message-header">
|
<div class="message-header">
|
||||||
<span class="role-label">{{ settingsStore.assistantName }}</span>
|
<span class="role-label">{{ settingsStore.assistantName }}</span>
|
||||||
</div>
|
</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>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -375,7 +383,49 @@ function promoteAutoNote(note: { id: number; title: string }) {
|
|||||||
line-height: 1.55;
|
line-height: 1.55;
|
||||||
word-break: break-word;
|
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 {
|
.typing-indicator {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
width: 6px;
|
width: 6px;
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ export const useChatStore = defineStore("chat", () => {
|
|||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const streaming = ref(false);
|
const streaming = ref(false);
|
||||||
const streamingContent = ref("");
|
const streamingContent = ref("");
|
||||||
|
const streamingThinking = ref("");
|
||||||
const streamingToolCalls = ref<ToolCallRecord[]>([]);
|
const streamingToolCalls = ref<ToolCallRecord[]>([]);
|
||||||
const streamingStatus = ref("");
|
const streamingStatus = ref("");
|
||||||
const streamingPendingTool = ref<ToolPendingRecord | null>(null);
|
const streamingPendingTool = ref<ToolPendingRecord | null>(null);
|
||||||
@@ -206,6 +207,9 @@ export const useChatStore = defineStore("chat", () => {
|
|||||||
case "status":
|
case "status":
|
||||||
streamingStatus.value = event.data.status as string;
|
streamingStatus.value = event.data.status as string;
|
||||||
break;
|
break;
|
||||||
|
case "thinking_chunk":
|
||||||
|
streamingThinking.value += event.data.chunk as string;
|
||||||
|
break;
|
||||||
case "chunk":
|
case "chunk":
|
||||||
streamingContent.value += event.data.chunk as string;
|
streamingContent.value += event.data.chunk as string;
|
||||||
streamingStatus.value = "";
|
streamingStatus.value = "";
|
||||||
@@ -235,11 +239,13 @@ export const useChatStore = defineStore("chat", () => {
|
|||||||
: null,
|
: null,
|
||||||
created_at: new Date().toISOString(),
|
created_at: new Date().toISOString(),
|
||||||
timing: event.data.timing as GenerationTiming | undefined,
|
timing: event.data.timing as GenerationTiming | undefined,
|
||||||
|
thinking: streamingThinking.value || undefined,
|
||||||
};
|
};
|
||||||
if (currentConversation.value?.id === convId) {
|
if (currentConversation.value?.id === convId) {
|
||||||
currentConversation.value.messages.push(assistantMsg);
|
currentConversation.value.messages.push(assistantMsg);
|
||||||
}
|
}
|
||||||
streamingContent.value = "";
|
streamingContent.value = "";
|
||||||
|
streamingThinking.value = "";
|
||||||
streamingToolCalls.value = [];
|
streamingToolCalls.value = [];
|
||||||
streamingStatus.value = "";
|
streamingStatus.value = "";
|
||||||
streamingPendingTool.value = null;
|
streamingPendingTool.value = null;
|
||||||
@@ -257,6 +263,7 @@ export const useChatStore = defineStore("chat", () => {
|
|||||||
gotDone = true;
|
gotDone = true;
|
||||||
streaming.value = false;
|
streaming.value = false;
|
||||||
streamingContent.value = "";
|
streamingContent.value = "";
|
||||||
|
streamingThinking.value = "";
|
||||||
streamingToolCalls.value = [];
|
streamingToolCalls.value = [];
|
||||||
streamingStatus.value = "";
|
streamingStatus.value = "";
|
||||||
streamingPendingTool.value = null;
|
streamingPendingTool.value = null;
|
||||||
@@ -296,6 +303,7 @@ export const useChatStore = defineStore("chat", () => {
|
|||||||
|
|
||||||
streaming.value = false;
|
streaming.value = false;
|
||||||
streamingContent.value = "";
|
streamingContent.value = "";
|
||||||
|
streamingThinking.value = "";
|
||||||
streamingToolCalls.value = [];
|
streamingToolCalls.value = [];
|
||||||
streamingStatus.value = "";
|
streamingStatus.value = "";
|
||||||
streamingPendingTool.value = null;
|
streamingPendingTool.value = null;
|
||||||
@@ -397,6 +405,7 @@ export const useChatStore = defineStore("chat", () => {
|
|||||||
loading,
|
loading,
|
||||||
streaming,
|
streaming,
|
||||||
streamingContent,
|
streamingContent,
|
||||||
|
streamingThinking,
|
||||||
streamingToolCalls,
|
streamingToolCalls,
|
||||||
streamingStatus,
|
streamingStatus,
|
||||||
streamingPendingTool,
|
streamingPendingTool,
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ export interface Message {
|
|||||||
tool_calls?: ToolCallRecord[] | null;
|
tool_calls?: ToolCallRecord[] | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
timing?: GenerationTiming;
|
timing?: GenerationTiming;
|
||||||
|
thinking?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SendMessageResponse {
|
export interface SendMessageResponse {
|
||||||
|
|||||||
@@ -418,8 +418,16 @@ onUnmounted(() => {
|
|||||||
<span class="streaming-status-dot"></span>
|
<span class="streaming-status-dot"></span>
|
||||||
{{ store.streamingStatus }}
|
{{ store.streamingStatus }}
|
||||||
</div>
|
</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>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -844,6 +852,49 @@ onUnmounted(() => {
|
|||||||
font-style: italic;
|
font-style: italic;
|
||||||
margin-bottom: 0.3rem;
|
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 {
|
.streaming-status-dot {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
width: 6px;
|
width: 6px;
|
||||||
|
|||||||
@@ -442,7 +442,10 @@ async def run_generation(
|
|||||||
cancelled = True
|
cancelled = True
|
||||||
break
|
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:
|
if timing["ttft_ms"] is None:
|
||||||
timing["ttft_ms"] = int((time.monotonic() - t_start) * 1000)
|
timing["ttft_ms"] = int((time.monotonic() - t_start) * 1000)
|
||||||
buf.content_so_far += chunk.content
|
buf.content_so_far += chunk.content
|
||||||
|
|||||||
@@ -131,7 +131,7 @@ async def stream_chat(
|
|||||||
@dataclass
|
@dataclass
|
||||||
class ChatChunk:
|
class ChatChunk:
|
||||||
"""A chunk yielded by stream_chat_with_tools."""
|
"""A chunk yielded by stream_chat_with_tools."""
|
||||||
type: Literal["content", "tool_calls", "done"]
|
type: Literal["content", "thinking", "tool_calls", "done"]
|
||||||
content: str = ""
|
content: str = ""
|
||||||
tool_calls: list[dict] | None = None
|
tool_calls: list[dict] | None = None
|
||||||
|
|
||||||
@@ -178,6 +178,11 @@ async def stream_chat_with_tools(
|
|||||||
data = json.loads(line)
|
data = json.loads(line)
|
||||||
msg = data.get("message", {})
|
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
|
# Content chunks
|
||||||
chunk = msg.get("content", "")
|
chunk = msg.get("content", "")
|
||||||
if chunk:
|
if chunk:
|
||||||
|
|||||||
Reference in New Issue
Block a user