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 }}
+
{ 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; diff --git a/frontend/src/components/ChatPanel.vue b/frontend/src/components/ChatPanel.vue index d8290f2..d84c44c 100644 --- a/frontend/src/components/ChatPanel.vue +++ b/frontend/src/components/ChatPanel.vue @@ -197,8 +197,16 @@ function promoteAutoNote(note: { id: number; title: string }) {
{{ settingsStore.assistantName }}
+
+ Reasoning +
{{ store.streamingThinking }}
+
- +
@@ -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: