fix: don't stream thought steps

This commit is contained in:
Akarshan 2025-10-19 09:41:36 +05:30
parent 44b62a1d19
commit 6d4d7d371f
No known key found for this signature in database
GPG Key ID: D75C9634A870665F
2 changed files with 65 additions and 61 deletions

View File

@ -1,10 +1,9 @@
import { ChevronDown, ChevronUp, Loader, Check } from 'lucide-react' import { ChevronDown, ChevronUp, Loader, Check } from 'lucide-react'
import { create } from 'zustand' import { create } from 'zustand'
import { RenderMarkdown } from './RenderMarkdown' import { RenderMarkdown } from './RenderMarkdown'
import { useAppState } from '@/hooks/useAppState' // import { useAppState } from '@/hooks/useAppState'
import { useTranslation } from '@/i18n/react-i18next-compat' import { useTranslation } from '@/i18n/react-i18next-compat'
// import { extractThinkingContent } from '@/lib/utils' import { useMemo } from 'react'
import { useMemo, useState, useEffect } from 'react'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
// Define ThoughtStep type // Define ThoughtStep type
@ -50,26 +49,20 @@ const formatDuration = (ms: number) => {
const ThinkingBlock = ({ const ThinkingBlock = ({
id, id,
text, // text, // Unused internally
steps = [], steps = [],
loading: propLoading, loading: propLoading,
duration, duration,
}: Props) => { }: Props) => {
const thinkingState = useThinkingStore((state) => state.thinkingState) const thinkingState = useThinkingStore((state) => state.thinkingState)
const setThinkingState = useThinkingStore((state) => state.setThinkingState) const setThinkingState = useThinkingStore((state) => state.setThinkingState)
const isStreamingApp = useAppState((state) => !!state.streamingContent)
const { t } = useTranslation() const { t } = useTranslation()
// Determine actual loading state // Actual loading state comes from prop, determined by whether final text started streaming (Req 2)
const hasThinkTag = text.includes('<think>') && !text.includes('</think>') const loading = propLoading
const hasAnalysisChannel =
text.includes('<|channel|>analysis<|message|>') &&
!text.includes('<|start|>assistant<|channel|>final<|message|>')
const loading =
propLoading ?? ((hasThinkTag || hasAnalysisChannel) && isStreamingApp)
// Set default expansion state: expanded if loading, collapsed if done. // Set default expansion state: expanded if loading, collapsed if done.
// If loading transitions to false (textSegment starts), this defaults to collapsed if state is absent.
const isExpanded = thinkingState[id] ?? (loading ? true : false) const isExpanded = thinkingState[id] ?? (loading ? true : false)
// Filter out the 'done' step for streaming display // Filter out the 'done' step for streaming display
@ -78,38 +71,21 @@ const ThinkingBlock = ({
[steps] [steps]
) )
// Get the current step being streamed (last step that's not 'done') const N = stepsWithoutDone.length
const currentStreamingStepIndex = stepsWithoutDone.length - 1
const currentStep =
currentStreamingStepIndex >= 0
? stepsWithoutDone[currentStreamingStepIndex]
: null
// Track which step index we're displaying // Determine the step to display in the condensed streaming view (Req 3)
const [displayedStepIndex, setDisplayedStepIndex] = useState( // Show step N-2 when N >= 2 (i.e., when step N-1 is streaming, show the previously finished step)
currentStreamingStepIndex const stepToRenderWhenStreaming = useMemo(() => {
) if (!loading) return null // Only apply this logic when actively loading
const [transitioning, setTransitioning] = useState(false) if (N >= 2) {
// Show the penultimate step (index N-2)
// When a new step arrives during streaming, animate the transition return stepsWithoutDone[N - 2]
useEffect(() => {
if (loading && currentStreamingStepIndex > displayedStepIndex) {
setTransitioning(true)
const timeout = setTimeout(() => {
setDisplayedStepIndex(currentStreamingStepIndex)
setTransitioning(false)
}, 150)
return () => clearTimeout(timeout)
} else if (!loading) {
// When streaming stops, ensure we show the final state
setDisplayedStepIndex(currentStreamingStepIndex)
} }
}, [currentStreamingStepIndex, loading, displayedStepIndex]) return null
}, [loading, N, stepsWithoutDone])
// Determine if the block is truly empty (streaming started but no content/steps yet) // Determine if the block is truly empty (streaming started but no content/steps yet)
const isStreamingEmpty = loading && stepsWithoutDone.length === 0 const isStreamingEmpty = loading && N === 0
// If loading started but no content or steps have arrived yet, display the non-expandable 'Thinking...' block // If loading started but no content or steps have arrived yet, display the non-expandable 'Thinking...' block
if (isStreamingEmpty) { if (isStreamingEmpty) {
@ -236,20 +212,19 @@ const ThinkingBlock = ({
</button> </button>
</div> </div>
{/* Streaming/Condensed View - shows current step one at a time */} {/* Streaming/Condensed View - shows previous finished step */}
{loading && currentStep && displayedStepIndex >= 0 && ( {loading && stepToRenderWhenStreaming && (
<div <div
className={cn( className={cn(
'mt-2 pl-6 pr-4 text-main-view-fg/60 transition-opacity duration-150 ease-in', 'mt-2 pl-6 pr-4 text-main-view-fg/60 transition-opacity duration-150 ease-in'
transitioning ? 'opacity-0' : 'opacity-100'
)} )}
> >
<div className="relative border-l border-dashed border-main-view-fg/20 ml-1.5"> <div className="relative border-l border-dashed border-main-view-fg/20 ml-1.5">
<div className="relative pl-6 pb-2"> <div className="relative pl-6 pb-2">
{/* Bullet point */} {/* Bullet point */}
<div className="absolute left-[-5px] top-[10px] size-2 rounded-full bg-main-view-fg/60" /> <div className="absolute left-[-5px] top-[10px] size-2 rounded-full bg-main-view-fg/60" />
{/* Current step content */} {/* Previous completed step content */}
{renderStepContent(currentStep, displayedStepIndex)} {renderStepContent(stepToRenderWhenStreaming, N - 2)}
</div> </div>
</div> </div>
</div> </div>

View File

@ -7,7 +7,6 @@ import { useAppState } from '@/hooks/useAppState'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useMessages } from '@/hooks/useMessages' import { useMessages } from '@/hooks/useMessages'
import ThinkingBlock from '@/containers/ThinkingBlock' import ThinkingBlock from '@/containers/ThinkingBlock'
// import ToolCallBlock from '@/containers/ToolCallBlock'
import { useChat } from '@/hooks/useChat' import { useChat } from '@/hooks/useChat'
import { import {
EditMessageDialog, EditMessageDialog,
@ -252,27 +251,36 @@ export const ThreadContent = memo(
| { avatar?: React.ReactNode; name?: React.ReactNode } | { avatar?: React.ReactNode; name?: React.ReactNode }
| undefined | undefined
// START: Constructing allSteps for ThinkingBlock (Req 5) // Constructing allSteps for ThinkingBlock
const allSteps: ThoughtStep[] = useMemo(() => { const allSteps: ThoughtStep[] = useMemo(() => {
const steps: ThoughtStep[] = [] const steps: ThoughtStep[] = []
// 1. Extract thought paragraphs // Extract thought paragraphs from reasoningSegment. We assume these are ordered
// relative to tool calls.
const thoughtText = extractThinkingContent(reasoningSegment || '') const thoughtText = extractThinkingContent(reasoningSegment || '')
const thoughtParagraphs = thoughtText const thoughtParagraphs = thoughtText
? thoughtText ? thoughtText
.split(/\n\s*\n/) .split(/\n\s*\n/)
.filter((s) => s.trim().length > 0) .filter((s) => s.trim().length > 0)
.map((content) => ({ .map((content) => content.trim())
type: 'thought' as const,
content: content.trim(),
}))
: [] : []
steps.push(...thoughtParagraphs)
// 2. Extract tool steps let thoughtIndex = 0
// 2. Interleave tool steps and thought steps
if (isToolCalls && item.metadata?.tool_calls) { if (isToolCalls && item.metadata?.tool_calls) {
const toolCalls = item.metadata.tool_calls as ToolCall[] const toolCalls = item.metadata.tool_calls as ToolCall[]
for (const call of toolCalls) { for (const call of toolCalls) {
// Check for thought chunk preceding this tool call
if (thoughtIndex < thoughtParagraphs.length) {
steps.push({
type: 'thought',
content: thoughtParagraphs[thoughtIndex],
})
thoughtIndex++
}
// Tool Call Step // Tool Call Step
steps.push({ steps.push({
type: 'tool_call', type: 'tool_call',
@ -295,9 +303,23 @@ export const ThreadContent = memo(
} }
} }
// 3. Add Done step if not streaming // Add remaining thoughts (e.g., final answer formulation thought)
while (thoughtIndex < thoughtParagraphs.length) {
steps.push({
type: 'thought',
content: thoughtParagraphs[thoughtIndex],
})
thoughtIndex++
}
// Add Done step if not streaming AND the reasoning/tooling process is concluded
const totalTime = item.metadata?.totalThinkingTime as number | undefined const totalTime = item.metadata?.totalThinkingTime as number | undefined
if (!isStreamingThisThread && (hasReasoning || isToolCalls)) {
// If the thread is not streaming, and we had steps or final text output, we add 'done'.
if (
!isStreamingThisThread &&
(hasReasoning || isToolCalls || textSegment)
) {
steps.push({ steps.push({
type: 'done', type: 'done',
content: 'Done', content: 'Done',
@ -312,11 +334,18 @@ export const ThreadContent = memo(
item.metadata, item.metadata,
isStreamingThisThread, isStreamingThisThread,
hasReasoning, hasReasoning,
textSegment,
]) ])
// END: Constructing allSteps // END: Constructing allSteps
// Determine if we should show the thinking block (has reasoning OR tool calls) // Determine if reasoning phase is actively loading (Req 2)
const shouldShowThinkingBlock = hasReasoning || isToolCalls // Loading is true only if streaming is happening AND we haven't started outputting final text yet.
const isReasoningActiveLoading =
isStreamingThisThread && textSegment.length === 0
// Determine if we should show the thinking block (has reasoning OR tool calls OR currently loading reasoning)
const shouldShowThinkingBlock =
hasReasoning || isToolCalls || isReasoningActiveLoading
return ( return (
<Fragment> <Fragment>
@ -462,7 +491,7 @@ export const ThreadContent = memo(
} }
text={reasoningSegment || ''} text={reasoningSegment || ''}
steps={allSteps} steps={allSteps}
loading={isStreamingThisThread} loading={isReasoningActiveLoading} // Req 2: False if textSegment is starting
duration={ duration={
item.metadata?.totalThinkingTime as number | undefined item.metadata?.totalThinkingTime as number | undefined
} }