feat: Implement live reasoning streaming with timing display
- Update ReasoningBlock to start expanded and show live reasoning content - Add timing tracking with start/end timestamps for reasoning duration - Auto-collapse reasoning blocks after completion with 500ms delay - Show timing summary (e.g. 'Thought for 0.4s') instead of content when collapsed - Add streaming cursor animation and real-time content updates - Update types to support reasoning timing and streaming state - Stream reasoning content live as tokens arrive during thinking phase This provides a much better UX where users can see the model thinking in real-time, then get a clean timing summary after completion.
This commit is contained in:
parent
68ecdebbc0
commit
bfe2bd5816
4 changed files with 137 additions and 43 deletions
|
|
@ -39,8 +39,13 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{/* Show reasoning block for assistant messages with reasoning */}
|
{/* Show reasoning block for assistant messages with reasoning */}
|
||||||
{message.role === "assistant" && "reasoning" in message && message.reasoning && (
|
{message.role === "assistant" && "reasoning" in message && (message.reasoning || message.isReasoningStreaming) && (
|
||||||
<ReasoningBlock reasoning={message.reasoning} />
|
<ReasoningBlock
|
||||||
|
reasoning={message.reasoning || ""}
|
||||||
|
isStreaming={message.isReasoningStreaming}
|
||||||
|
startTime={message.reasoningStartTime}
|
||||||
|
endTime={message.reasoningEndTime}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="prose prose-zinc">
|
<div className="prose prose-zinc">
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,30 @@
|
||||||
import React, { useState } from "react";
|
import React, { useState, useEffect } from "react";
|
||||||
|
|
||||||
interface ReasoningBlockProps {
|
interface ReasoningBlockProps {
|
||||||
reasoning: string;
|
reasoning: string;
|
||||||
|
isStreaming?: boolean;
|
||||||
|
startTime?: number;
|
||||||
|
endTime?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ReasoningBlock: React.FC<ReasoningBlockProps> = ({ reasoning }) => {
|
const ReasoningBlock: React.FC<ReasoningBlockProps> = ({
|
||||||
const [isExpanded, setIsExpanded] = useState(false);
|
reasoning,
|
||||||
|
isStreaming = false,
|
||||||
|
startTime,
|
||||||
|
endTime
|
||||||
|
}) => {
|
||||||
|
const [isExpanded, setIsExpanded] = useState(isStreaming); // Start expanded when streaming
|
||||||
|
|
||||||
|
// Auto-collapse when streaming finishes
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isStreaming && startTime && endTime) {
|
||||||
|
// Add a small delay before collapsing so user can see completion
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setIsExpanded(false);
|
||||||
|
}, 500);
|
||||||
|
return () => clearTimeout(timer);
|
||||||
|
}
|
||||||
|
}, [isStreaming, startTime, endTime]);
|
||||||
|
|
||||||
if (!reasoning || reasoning.trim().length === 0) {
|
if (!reasoning || reasoning.trim().length === 0) {
|
||||||
return null;
|
return null;
|
||||||
|
|
@ -15,36 +34,56 @@ const ReasoningBlock: React.FC<ReasoningBlockProps> = ({ reasoning }) => {
|
||||||
setIsExpanded(!isExpanded);
|
setIsExpanded(!isExpanded);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Truncate the reasoning text for the collapsed state
|
// Calculate thinking duration
|
||||||
const maxLength = 120;
|
const duration = startTime && endTime ? (endTime - startTime) / 1000 : null;
|
||||||
const shouldTruncate = reasoning.length > maxLength;
|
|
||||||
const displayText = isExpanded || !shouldTruncate
|
// Format duration nicely
|
||||||
? reasoning
|
const formatDuration = (seconds: number) => {
|
||||||
: reasoning.substring(0, maxLength) + "...";
|
if (seconds < 1) {
|
||||||
|
return `${Math.round(seconds * 1000)}ms`;
|
||||||
|
}
|
||||||
|
return `${seconds.toFixed(1)}s`;
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mb-3">
|
<div className="mb-3">
|
||||||
<div
|
<div
|
||||||
className={`text-xs text-zinc-600 border border-zinc-200 rounded-lg p-3 *bg-zinc-50 cursor-pointer hover:bg-zinc-50 transition-colors ${shouldTruncate && !isExpanded ? "line-clamp-1" : ""
|
className={`text-sm text-zinc-600 border border-zinc-200 rounded-lg p-3 bg-zinc-50 cursor-pointer hover:bg-zinc-100 transition-all duration-300 ${
|
||||||
}`}
|
isExpanded ? "border-zinc-300" : ""
|
||||||
|
}`}
|
||||||
onClick={toggleExpanded}
|
onClick={toggleExpanded}
|
||||||
>
|
>
|
||||||
<div className="flex items-start gap-2">
|
<div className="flex items-start gap-2">
|
||||||
<span className="text-zinc-400 text-xs mt-0.5 flex-shrink-0">💭</span>
|
<span className="text-zinc-400 text-xs mt-0.5 flex-shrink-0">💭</span>
|
||||||
<div className={`${shouldTruncate && !isExpanded ? "truncate" : "whitespace-pre-wrap"}`}>
|
|
||||||
{displayText}
|
{isExpanded ? (
|
||||||
</div>
|
// Expanded view: show reasoning content
|
||||||
{/*{shouldTruncate && (*/}
|
<div className="whitespace-pre-wrap flex-1">
|
||||||
{/* <button*/}
|
{reasoning}
|
||||||
{/* className="text-zinc-400 hover:text-zinc-600 text-xs ml-auto flex-shrink-0"*/}
|
{isStreaming && (
|
||||||
{/* onClick={(e) => {*/}
|
<span className="inline-block w-2 h-4 bg-zinc-400 ml-1 animate-pulse"></span>
|
||||||
{/* e.stopPropagation();*/}
|
)}
|
||||||
{/* toggleExpanded();*/}
|
</div>
|
||||||
{/* }}*/}
|
) : (
|
||||||
{/* >*/}
|
// Collapsed view: show timing summary
|
||||||
{/* {isExpanded ? "↑" : "↓"}*/}
|
<div className="flex-1">
|
||||||
{/* </button>*/}
|
<span className="text-zinc-500">
|
||||||
{/*)}*/}
|
{isStreaming ? "Thinking..." :
|
||||||
|
duration ? `Thought for ${formatDuration(duration)}` :
|
||||||
|
"Thought process"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="text-zinc-400 hover:text-zinc-600 text-xs ml-auto flex-shrink-0"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
toggleExpanded();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{isExpanded ? "↑" : "↓"}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -134,6 +134,8 @@ export const useStreamResponse = ({
|
||||||
const streamResponse = async (messages: Message[]) => {
|
const streamResponse = async (messages: Message[]) => {
|
||||||
let aiResponse = ''
|
let aiResponse = ''
|
||||||
let reasoning = ''
|
let reasoning = ''
|
||||||
|
let reasoningStartTime: number | undefined = undefined
|
||||||
|
let reasoningEndTime: number | undefined = undefined
|
||||||
let assistantMessageIndex = -1 // Track the index of our assistant message
|
let assistantMessageIndex = -1 // Track the index of our assistant message
|
||||||
let assistantMessageCreated = false // Track if we've created the assistant message yet
|
let assistantMessageCreated = false // Track if we've created the assistant message yet
|
||||||
|
|
||||||
|
|
@ -200,8 +202,37 @@ export const useStreamResponse = ({
|
||||||
|
|
||||||
if (event.type === 'reasoning') {
|
if (event.type === 'reasoning') {
|
||||||
debugLog(`[useStreamResponse] Reasoning event:`, event)
|
debugLog(`[useStreamResponse] Reasoning event:`, event)
|
||||||
|
|
||||||
|
// Track reasoning start time
|
||||||
|
if (!reasoningStartTime) {
|
||||||
|
reasoningStartTime = Date.now()
|
||||||
|
debugLog(`[useStreamResponse] Reasoning started at:`, reasoningStartTime)
|
||||||
|
}
|
||||||
|
|
||||||
reasoning += (event as any).textDelta || ''
|
reasoning += (event as any).textDelta || ''
|
||||||
debugLog(`[useStreamResponse] Reasoning content so far:`, reasoning.length, 'chars')
|
debugLog(`[useStreamResponse] Reasoning content so far:`, reasoning.length, 'chars')
|
||||||
|
|
||||||
|
// Update the assistant message with streaming reasoning in real-time
|
||||||
|
if (assistantMessageCreated) {
|
||||||
|
setConversations((prev) => {
|
||||||
|
const updated = [...prev]
|
||||||
|
const conv = updated.find((c) => c.id === conversationId)
|
||||||
|
if (conv && assistantMessageIndex >= 0) {
|
||||||
|
const assistantMessage = conv.messages[assistantMessageIndex]
|
||||||
|
if (assistantMessage && hasContent(assistantMessage) && assistantMessage.role === 'assistant') {
|
||||||
|
// Clean the reasoning content as we stream
|
||||||
|
let cleanedReasoning = reasoning
|
||||||
|
cleanedReasoning = cleanedReasoning.replace(/<think>/g, '').replace(/<\/think>/g, '')
|
||||||
|
cleanedReasoning = cleanedReasoning.trim()
|
||||||
|
|
||||||
|
assistantMessage.reasoning = cleanedReasoning
|
||||||
|
assistantMessage.isReasoningStreaming = true
|
||||||
|
assistantMessage.reasoningStartTime = reasoningStartTime
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return updated
|
||||||
|
})
|
||||||
|
}
|
||||||
} else if (event.type === 'tool-call') {
|
} else if (event.type === 'tool-call') {
|
||||||
const eventKey = `tool-call-${event.toolCallId}`
|
const eventKey = `tool-call-${event.toolCallId}`
|
||||||
if (processedEvents.has(eventKey)) {
|
if (processedEvents.has(eventKey)) {
|
||||||
|
|
@ -241,6 +272,13 @@ export const useStreamResponse = ({
|
||||||
// Create assistant message on first text delta (after all tool calls/results)
|
// Create assistant message on first text delta (after all tool calls/results)
|
||||||
if (!assistantMessageCreated) {
|
if (!assistantMessageCreated) {
|
||||||
debugLog(`[useStreamResponse] Creating assistant message after tool calls/results`)
|
debugLog(`[useStreamResponse] Creating assistant message after tool calls/results`)
|
||||||
|
|
||||||
|
// Mark reasoning end time when text starts (reasoning is done)
|
||||||
|
if (reasoningStartTime && !reasoningEndTime) {
|
||||||
|
reasoningEndTime = Date.now()
|
||||||
|
debugLog(`[useStreamResponse] Reasoning ended at:`, reasoningEndTime, 'Duration:', reasoningEndTime - reasoningStartTime, 'ms')
|
||||||
|
}
|
||||||
|
|
||||||
setConversations((prev) => {
|
setConversations((prev) => {
|
||||||
const updated = [...prev]
|
const updated = [...prev]
|
||||||
const conv = updated.find((c) => c.id === conversationId)
|
const conv = updated.find((c) => c.id === conversationId)
|
||||||
|
|
@ -252,6 +290,22 @@ export const useStreamResponse = ({
|
||||||
return updated
|
return updated
|
||||||
})
|
})
|
||||||
assistantMessageCreated = true
|
assistantMessageCreated = true
|
||||||
|
|
||||||
|
// Update reasoning end time and stop streaming
|
||||||
|
if (reasoningEndTime) {
|
||||||
|
setConversations((prev) => {
|
||||||
|
const updated = [...prev]
|
||||||
|
const conv = updated.find((c) => c.id === conversationId)
|
||||||
|
if (conv && assistantMessageIndex >= 0) {
|
||||||
|
const assistantMessage = conv.messages[assistantMessageIndex]
|
||||||
|
if (assistantMessage && hasContent(assistantMessage) && assistantMessage.role === 'assistant') {
|
||||||
|
assistantMessage.isReasoningStreaming = false
|
||||||
|
assistantMessage.reasoningEndTime = reasoningEndTime
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return updated
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
aiResponse += event.textDelta
|
aiResponse += event.textDelta
|
||||||
|
|
@ -320,18 +374,12 @@ export const useStreamResponse = ({
|
||||||
debugLog(`[useStreamResponse] Final aiResponse content:`, JSON.stringify(aiResponse))
|
debugLog(`[useStreamResponse] Final aiResponse content:`, JSON.stringify(aiResponse))
|
||||||
debugLog(`[useStreamResponse] Final reasoning content:`, reasoning.length, 'chars:', reasoning.substring(0, 100))
|
debugLog(`[useStreamResponse] Final reasoning content:`, reasoning.length, 'chars:', reasoning.substring(0, 100))
|
||||||
|
|
||||||
// Add reasoning to the final message if we collected any
|
// Final cleanup - ensure reasoning streaming is stopped
|
||||||
if (reasoning && assistantMessageCreated) {
|
if (assistantMessageCreated && reasoningStartTime) {
|
||||||
debugLog(`[useStreamResponse] Adding collected reasoning:`, reasoning.substring(0, 100))
|
if (!reasoningEndTime) {
|
||||||
|
reasoningEndTime = Date.now()
|
||||||
// Clean the reasoning content by removing think tags
|
debugLog(`[useStreamResponse] Final reasoning end time:`, reasoningEndTime)
|
||||||
let cleanedReasoning = reasoning
|
}
|
||||||
// Remove opening and closing think tags
|
|
||||||
cleanedReasoning = cleanedReasoning.replace(/<think>/g, '').replace(/<\/think>/g, '')
|
|
||||||
// Trim any leading/trailing whitespace
|
|
||||||
cleanedReasoning = cleanedReasoning.trim()
|
|
||||||
|
|
||||||
debugLog(`[useStreamResponse] Cleaned reasoning:`, cleanedReasoning.substring(0, 100))
|
|
||||||
|
|
||||||
setConversations((prev) => {
|
setConversations((prev) => {
|
||||||
const updated = [...prev]
|
const updated = [...prev]
|
||||||
|
|
@ -339,14 +387,13 @@ export const useStreamResponse = ({
|
||||||
if (conv && assistantMessageIndex >= 0) {
|
if (conv && assistantMessageIndex >= 0) {
|
||||||
const assistantMessage = conv.messages[assistantMessageIndex]
|
const assistantMessage = conv.messages[assistantMessageIndex]
|
||||||
if (assistantMessage && hasContent(assistantMessage) && assistantMessage.role === 'assistant') {
|
if (assistantMessage && hasContent(assistantMessage) && assistantMessage.role === 'assistant') {
|
||||||
assistantMessage.reasoning = cleanedReasoning
|
assistantMessage.isReasoningStreaming = false
|
||||||
debugLog(`[useStreamResponse] Added cleaned reasoning to assistant message at index ${assistantMessageIndex}`)
|
assistantMessage.reasoningEndTime = reasoningEndTime
|
||||||
|
debugLog(`[useStreamResponse] Final reasoning cleanup completed`)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return updated
|
return updated
|
||||||
})
|
})
|
||||||
} else {
|
|
||||||
debugLog(`[useStreamResponse] No reasoning to add. reasoning length:`, reasoning.length, 'assistantMessageCreated:', assistantMessageCreated)
|
|
||||||
}
|
}
|
||||||
} catch (error: unknown) {
|
} catch (error: unknown) {
|
||||||
if (controller.signal.aborted) {
|
if (controller.signal.aborted) {
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,9 @@ export interface SystemMessage extends BaseMessage {
|
||||||
export interface AssistantMessage extends BaseMessage {
|
export interface AssistantMessage extends BaseMessage {
|
||||||
role: "assistant";
|
role: "assistant";
|
||||||
reasoning?: string;
|
reasoning?: string;
|
||||||
|
reasoningStartTime?: number;
|
||||||
|
reasoningEndTime?: number;
|
||||||
|
isReasoningStreaming?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ToolCallMessage {
|
export interface ToolCallMessage {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue