refactor: simplify reasoning handling in ThreadContent and related hooks

- Remove legacy <think> tag parsing and accumulation of reasoning chunks in the main text buffer.
- Rely exclusively on `streamEvents` to derive reasoning and loading state.
- Update loading logic to account for both tool calls and reasoning events.
- Adjust memo dependencies and return values to avoid stale references.
- Update `useChat` and `completion.ts` to stop mutating the accumulated text with reasoning, keeping the logic purely event‑driven.
- Ensure the ThinkingBlock always renders from the structured steps list, improving consistency and eliminating duplicate content.
This commit is contained in:
Akarshan 2025-10-29 18:58:35 +05:30
parent 37c4a65dbd
commit 0a5e107d0f
No known key found for this signature in database
GPG Key ID: D75C9634A870665F
3 changed files with 27 additions and 71 deletions

View File

@ -146,66 +146,11 @@ export const ThreadContent = memo(
isReasoningActiveLoading, isReasoningActiveLoading,
hasReasoningSteps, hasReasoningSteps,
} = useMemo(() => { } = useMemo(() => {
const thinkStartTag = '<think>' // With the streaming functions updated, the text variable now only contains the final output.
const thinkEndTag = '</think>' const currentFinalText = text.trim()
let currentFinalText = '' const currentReasoning = '' // Reasoning is now only derived from streamEvents/allSteps
let currentReasoning = ''
let hasSteps = false
const firstThinkStart = text.indexOf(thinkStartTag) // Check for tool calls or reasoning events in metadata to determine steps/loading
const lastThinkStart = text.lastIndexOf(thinkStartTag)
const lastThinkEnd = text.lastIndexOf(thinkEndTag)
// Check if there's an unclosed <think> tag
const hasOpenThink = lastThinkStart > lastThinkEnd
if (firstThinkStart === -1) {
// No <think> tags at all - everything is final output
currentFinalText = text
} else if (hasOpenThink && isStreamingThisThread) {
// CASE 1: There's an open <think> tag during streaming
// Everything from FIRST <think> onward is reasoning
hasSteps = true
// Text before first <think> is final output
currentFinalText = text.substring(0, firstThinkStart)
// Everything from first <think> onward is reasoning
const reasoningText = text.substring(firstThinkStart)
// Extract content from all <think> blocks (both closed and open)
const reasoningRegex = /<think>([\s\S]*?)(?:<\/think>|$)/g
const matches = [...reasoningText.matchAll(reasoningRegex)]
const reasoningParts = matches.map((match) => cleanReasoning(match[1]))
currentReasoning = reasoningParts.join('\n\n')
} else {
// CASE 2: All <think> tags are closed
// Extract reasoning from inside tags, everything else is final output
hasSteps = true
const reasoningRegex = /<think>[\s\S]*?<\/think>/g
const matches = [...text.matchAll(reasoningRegex)]
let lastIndex = 0
// Build final output from text between/outside <think> blocks
for (const match of matches) {
currentFinalText += text.substring(lastIndex, match.index)
lastIndex = match.index + match[0].length
}
// Add remaining text after last </think>
currentFinalText += text.substring(lastIndex)
// Extract reasoning content
const reasoningParts = matches.map((match) => {
const content = match[0].replace(/<think>|<\/think>/g, '')
return cleanReasoning(content)
})
currentReasoning = reasoningParts.join('\n\n')
}
// Check for tool calls
const isToolCallsPresent = !!( const isToolCallsPresent = !!(
item.metadata && item.metadata &&
'tool_calls' in item.metadata && 'tool_calls' in item.metadata &&
@ -213,19 +158,29 @@ export const ThreadContent = memo(
item.metadata.tool_calls.length > 0 item.metadata.tool_calls.length > 0
) )
hasSteps = hasSteps || isToolCallsPresent // Check for any reasoning chunks in the streamEvents
const hasReasoningEvents = !!(
item.metadata &&
'streamEvents' in item.metadata &&
Array.isArray(item.metadata.streamEvents) &&
item.metadata.streamEvents.some(
(e: StreamEvent) => e.type === 'reasoning_chunk'
)
)
// Loading if streaming and no final output yet const hasSteps = isToolCallsPresent || hasReasoningEvents
// Loading if streaming, no final output yet, but we expect steps (reasoning or tool calls)
const loading = const loading =
isStreamingThisThread && currentFinalText.trim().length === 0 isStreamingThisThread && currentFinalText.length === 0 && hasSteps
return { return {
finalOutputText: currentFinalText.trim(), finalOutputText: currentFinalText,
streamedReasoningText: currentReasoning, streamedReasoningText: currentReasoning,
isReasoningActiveLoading: loading, isReasoningActiveLoading: loading,
hasReasoningSteps: hasSteps, hasReasoningSteps: hasSteps,
} }
}, [item.content, isStreamingThisThread, item.metadata, text]) }, [item.metadata, text, isStreamingThisThread])
const isToolCalls = const isToolCalls =
item.metadata && item.metadata &&
@ -516,7 +471,6 @@ export const ThreadContent = memo(
// END: Constructing allSteps // END: Constructing allSteps
// ==================================================================== // ====================================================================
// FIX: Determine which text prop to pass to ThinkingBlock
// If we have streamEvents, rely on 'steps' and pass an empty text buffer. // If we have streamEvents, rely on 'steps' and pass an empty text buffer.
const streamingTextBuffer = useMemo(() => { const streamingTextBuffer = useMemo(() => {
const streamEvents = item.metadata?.streamEvents const streamEvents = item.metadata?.streamEvents
@ -528,9 +482,11 @@ export const ThreadContent = memo(
return '' return ''
} }
// Otherwise, rely on the raw text buffer for rendering (used during initial stream fallback) // Since we no longer concatenate reasoning to the main text,
return streamedReasoningText // the only time we'd rely on text buffer is if streamEvents fails to load.
}, [item.metadata?.streamEvents, streamedReasoningText]) // Use the object reference for dependency array // For robustness, we can simply return an empty string to force use of 'steps'.
return ''
}, [item.metadata?.streamEvents]) // Use the object reference for dependency array
// ==================================================================== // ====================================================================
// Determine if we should show the thinking block // Determine if we should show the thinking block

View File

@ -671,7 +671,7 @@ export const useChat = () => {
const deltaReasoning = const deltaReasoning =
reasoningProcessor.processReasoningChunk(part) reasoningProcessor.processReasoningChunk(part)
if (deltaReasoning) { if (deltaReasoning) {
accumulatedText += deltaReasoning // accumulatedText += deltaReasoning
// Track reasoning event // Track reasoning event
streamEvents.push({ streamEvents.push({
timestamp: Date.now(), timestamp: Date.now(),
@ -705,7 +705,7 @@ export const useChat = () => {
// Only finalize and flush if not aborted // Only finalize and flush if not aborted
if (!abortController.signal.aborted) { if (!abortController.signal.aborted) {
// Finalize reasoning (close any open think tags) // Finalize reasoning (close any open think tags)
accumulatedText += reasoningProcessor.finalize() // accumulatedText += reasoningProcessor.finalize()
// Ensure any pending buffered content is rendered at the end // Ensure any pending buffered content is rendered at the end
flushIfPending() flushIfPending()
} }

View File

@ -777,7 +777,7 @@ export const postMessageProcessing = async (
const deltaContent = chunk.choices[0]?.delta?.content || '' const deltaContent = chunk.choices[0]?.delta?.content || ''
if (textContent?.text) { if (textContent?.text) {
if (deltaReasoning) textContent.text.value += deltaReasoning // if (deltaReasoning) textContent.text.value += deltaReasoning
if (deltaContent) { if (deltaContent) {
textContent.text.value += deltaContent textContent.text.value += deltaContent
followUpText += deltaContent followUpText += deltaContent