diff --git a/frontend/src/components/ChatMessage.vue b/frontend/src/components/ChatMessage.vue
index a859d53..b153750 100644
--- a/frontend/src/components/ChatMessage.vue
+++ b/frontend/src/components/ChatMessage.vue
@@ -68,6 +68,10 @@ const timingParts = computed((): string[] => {
+
+ Reasoning
+ {{ message.thinking }}
+
@@ -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;
diff --git a/frontend/src/stores/chat.ts b/frontend/src/stores/chat.ts
index 63fb236..639e130 100644
--- a/frontend/src/stores/chat.ts
+++ b/frontend/src/stores/chat.ts
@@ -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([]);
const streamingStatus = ref("");
const streamingPendingTool = ref(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,
diff --git a/frontend/src/types/chat.ts b/frontend/src/types/chat.ts
index 8c6bbe1..6667022 100644
--- a/frontend/src/types/chat.ts
+++ b/frontend/src/types/chat.ts
@@ -30,6 +30,7 @@ export interface Message {
tool_calls?: ToolCallRecord[] | null;
created_at: string;
timing?: GenerationTiming;
+ thinking?: string;
}
export interface SendMessageResponse {
diff --git a/frontend/src/views/ChatView.vue b/frontend/src/views/ChatView.vue
index 0486ac0..d42e7fc 100644
--- a/frontend/src/views/ChatView.vue
+++ b/frontend/src/views/ChatView.vue
@@ -418,8 +418,16 @@ onUnmounted(() => {
{{ store.streamingStatus }}
+
+ Reasoning
+ {{ store.streamingThinking }}
+
-
+
@@ -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;
diff --git a/src/fabledassistant/services/generation_task.py b/src/fabledassistant/services/generation_task.py
index 4edff59..fa22cac 100644
--- a/src/fabledassistant/services/generation_task.py
+++ b/src/fabledassistant/services/generation_task.py
@@ -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
diff --git a/src/fabledassistant/services/llm.py b/src/fabledassistant/services/llm.py
index 1564aee..045494d 100644
--- a/src/fabledassistant/services/llm.py
+++ b/src/fabledassistant/services/llm.py
@@ -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: