Show a thinking indicator while the model reasons
qwen3.8-27b is a reasoning model: it can think for hundreds of tokens
before any visible content streams. The SSE stream only forwarded
content deltas, so the UI showed dead air for the whole think and long
answers looked hung (users cancel ~1 min in).
The stream now emits a throttled {"type": "thinking"} heartbeat while
the current model turn has produced no content yet, and the frontend
renders a pulsing "Thinking..." pill until content or a tool call
arrives. The heartbeat also keeps bytes flowing on the SSE connection
during long thinks, so idle-timeout disconnects in the proxy chain
can't kill the stream.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
import uuid
|
||||
|
||||
from quart import Blueprint, jsonify, make_response, request
|
||||
@@ -218,6 +219,11 @@ async def stream_query():
|
||||
async def event_generator():
|
||||
final_message = None
|
||||
streamed = ""
|
||||
# Reasoning models emit empty-content deltas while thinking; surface a
|
||||
# throttled heartbeat so the UI shows progress (and bytes keep flowing
|
||||
# on the SSE connection) instead of dead air for the whole think.
|
||||
last_thinking = 0.0
|
||||
turn_has_content = False
|
||||
|
||||
async def persist(text: str) -> None:
|
||||
await add_message_to_conversation(
|
||||
@@ -248,12 +254,21 @@ async def stream_query():
|
||||
user=user,
|
||||
)
|
||||
|
||||
elif event_type == "on_chat_model_start":
|
||||
turn_has_content = False
|
||||
|
||||
elif event_type == "on_chat_model_stream":
|
||||
chunk = event.get("data", {}).get("chunk")
|
||||
content = getattr(chunk, "content", None)
|
||||
if isinstance(content, str) and content:
|
||||
turn_has_content = True
|
||||
streamed += content
|
||||
yield f"data: {json.dumps({'type': 'content', 'delta': content})}\n\n"
|
||||
elif not turn_has_content:
|
||||
now = time.monotonic()
|
||||
if now - last_thinking >= 1.0:
|
||||
last_thinking = now
|
||||
yield f"data: {json.dumps({'type': 'thinking'})}\n\n"
|
||||
|
||||
elif event_type == "on_chain_end":
|
||||
output = event.get("data", {}).get("output")
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { userService } from "./userService";
|
||||
|
||||
export type SSEEvent =
|
||||
| { type: "thinking" }
|
||||
| { type: "tool_start"; tool: string }
|
||||
| { type: "tool_end"; tool: string }
|
||||
| { type: "content"; delta: string }
|
||||
@@ -12,7 +13,7 @@ export type SSEEventCallback = (event: SSEEvent) => void;
|
||||
interface Message {
|
||||
id: string;
|
||||
text: string;
|
||||
speaker: "user" | "simba";
|
||||
speaker: "user" | "simba" | "tool";
|
||||
created_at: string;
|
||||
image_key?: string | null;
|
||||
}
|
||||
|
||||
@@ -53,6 +53,7 @@ export const ChatScreen = ({ setAuthenticated, isAdmin }: ChatScreenProps) => {
|
||||
messages,
|
||||
isLoading,
|
||||
streaming,
|
||||
thinking,
|
||||
messagesLoading,
|
||||
pendingImage,
|
||||
setPendingImage,
|
||||
@@ -249,7 +250,11 @@ export const ChatScreen = ({ setAuthenticated, isAdmin }: ChatScreenProps) => {
|
||||
return <QuestionBubble key={index} text={msg.text} image_key={msg.image_key} />;
|
||||
})}
|
||||
|
||||
{((isLoading && !streaming) || messagesLoading) && (
|
||||
{thinking && !streaming && (
|
||||
<ToolBubble text="Thinking..." pulse />
|
||||
)}
|
||||
|
||||
{((isLoading && !streaming && !thinking) || messagesLoading) && (
|
||||
<AnswerBubble text="" loading={true} />
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,16 +1,19 @@
|
||||
import React from "react";
|
||||
import { cn } from "../lib/utils";
|
||||
|
||||
export const ToolBubble = React.memo(({ text }: { text: string }) => (
|
||||
<div className="flex justify-center message-enter">
|
||||
<div
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 px-3 py-1 rounded-full",
|
||||
"bg-leaf-pale border border-leaf-light/50",
|
||||
"text-xs text-leaf-dark italic",
|
||||
)}
|
||||
>
|
||||
{text}
|
||||
export const ToolBubble = React.memo(
|
||||
({ text, pulse = false }: { text: string; pulse?: boolean }) => (
|
||||
<div className="flex justify-center message-enter">
|
||||
<div
|
||||
className={cn(
|
||||
"inline-flex items-center gap-1.5 px-3 py-1 rounded-full",
|
||||
"bg-leaf-pale border border-leaf-light/50",
|
||||
"text-xs text-leaf-dark italic",
|
||||
pulse && "animate-pulse",
|
||||
)}
|
||||
>
|
||||
{text}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
));
|
||||
),
|
||||
);
|
||||
|
||||
@@ -52,6 +52,7 @@ export function useChat({
|
||||
const [pendingImage, setPendingImage] = useState<File | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [streaming, setStreaming] = useState(false);
|
||||
const [thinking, setThinking] = useState(false);
|
||||
|
||||
const isMountedRef = useRef(true);
|
||||
// True once tokens for the current assistant reply have started arriving, so
|
||||
@@ -186,15 +187,19 @@ export function useChat({
|
||||
activeId,
|
||||
(event) => {
|
||||
if (!isMountedRef.current) return;
|
||||
if (event.type === "tool_start") {
|
||||
if (event.type === "thinking") {
|
||||
setThinking(true);
|
||||
} else if (event.type === "tool_start") {
|
||||
// Any text after a tool call belongs in a fresh bubble.
|
||||
streamingActiveRef.current = false;
|
||||
setThinking(false);
|
||||
const friendly =
|
||||
TOOL_MESSAGES[event.tool] ?? `Using ${event.tool}...`;
|
||||
updateMessages((prev) =>
|
||||
prev.concat([{ text: friendly, speaker: "tool" }]),
|
||||
);
|
||||
} else if (event.type === "content") {
|
||||
setThinking(false);
|
||||
if (streamingActiveRef.current) {
|
||||
updateMessages((prev) => {
|
||||
const updated = [...prev];
|
||||
@@ -258,6 +263,7 @@ export function useChat({
|
||||
if (isMountedRef.current) {
|
||||
setIsLoading(false);
|
||||
setStreaming(false);
|
||||
setThinking(false);
|
||||
// Refresh after every send so the just-used conversation floats to
|
||||
// the top of the sidebar (sorted by last activity) and picks up the
|
||||
// auto-generated title on the first message.
|
||||
@@ -290,6 +296,7 @@ export function useChat({
|
||||
setMessages: updateMessages,
|
||||
isLoading,
|
||||
streaming,
|
||||
thinking,
|
||||
messagesLoading,
|
||||
pendingImage,
|
||||
setPendingImage,
|
||||
|
||||
Reference in New Issue
Block a user