fix: Refactor ThinkingBlock streaming, unify tool calls

- Replace raw text parsing with step‑based streaming logic in `ThinkingBlock`.
  - Introduced `stepsWithoutDone`, `currentStreamingStepIndex`, and `displayedStepIndex` to drive the streaming UI.
  - Added placeholder UI for empty streaming state and hide block when there is no content after streaming finishes.
  - Simplified expansion handling and bullet‑point rendering, using `renderStepContent` for both streaming and expanded views.
  - Removed unused `extractThinkingContent` import and related code.
  - Updated translation keys and duration formatting.

- Consolidate reasoning and tool‑call presentation in `ThreadContent`.
  - Introduced `shouldShowThinkingBlock` to render a single `ThinkingBlock` when either reasoning or tool calls are present.
  - Adjusted `ThinkingBlock` props (`text`, `steps`, `loading`) and ID generation.
  - Commented out the now‑redundant `ToolCallBlock` import and removed its conditional rendering block.
  - Cleaned up comments, unused variables, and minor formatting/typo fixes.

- General cleanup:
  - Updated comments for clarity.
  - Fixed typo in deletion loop comment.
  - Minor UI tweaks (bullet styling, border handling).
This commit is contained in:
Akarshan 2025-10-18 20:36:59 +05:30
parent 8601a49ff6
commit 44b62a1d19
No known key found for this signature in database
GPG Key ID: D75C9634A870665F
2 changed files with 67 additions and 102 deletions

View File

@ -3,7 +3,7 @@ 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 { extractThinkingContent } from '@/lib/utils'
import { useMemo, useState, useEffect } from 'react' import { useMemo, useState, useEffect } from 'react'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
@ -42,39 +42,16 @@ const useThinkingStore = create<ThinkingBlockState>((set) => ({
// Helper to format duration in seconds // Helper to format duration in seconds
const formatDuration = (ms: number) => { const formatDuration = (ms: number) => {
// Only show seconds if duration is present and non-zero
if (ms > 0) { if (ms > 0) {
return Math.round(ms / 1000) return Math.round(ms / 1000)
} }
return 0 return 0
} }
// Function to safely extract thought paragraphs from streaming text
const extractStreamingParagraphs = (rawText: string): string[] => {
const cleanedContent = rawText.replace(/<\/?think>/g, '').trim()
// Split by double newline (paragraph boundary)
let paragraphs = cleanedContent
.split(/\n\s*\n/)
.filter((s) => s.trim().length > 0)
// If no explicit double newline paragraphs, treat single newlines as breaks for streaming visualization
if (paragraphs.length <= 1 && cleanedContent.includes('\n')) {
paragraphs = cleanedContent.split('\n').filter((s) => s.trim().length > 0)
}
// Ensure we always return at least one item if content exists
if (paragraphs.length === 0 && cleanedContent.length > 0) {
return [cleanedContent]
}
return paragraphs
}
const ThinkingBlock = ({ const ThinkingBlock = ({
id, id,
text, text,
steps, steps = [],
loading: propLoading, loading: propLoading,
duration, duration,
}: Props) => { }: Props) => {
@ -95,53 +72,63 @@ const ThinkingBlock = ({
// Set default expansion state: expanded if loading, collapsed if done. // Set default expansion state: expanded if loading, collapsed if done.
const isExpanded = thinkingState[id] ?? (loading ? true : false) const isExpanded = thinkingState[id] ?? (loading ? true : false)
const thinkingContent = extractThinkingContent(text) // Filter out the 'done' step for streaming display
const stepsWithoutDone = useMemo(
() => steps.filter((step) => step.type !== 'done'),
[steps]
)
// If we are not loading AND there is no content/steps, hide the block entirely. // Get the current step being streamed (last step that's not 'done')
const hasContent = !!thinkingContent || (steps && steps.length >= 1) const currentStreamingStepIndex = stepsWithoutDone.length - 1
if (!loading && !hasContent) return null const currentStep =
currentStreamingStepIndex >= 0
? stepsWithoutDone[currentStreamingStepIndex]
: null
// --- Streaming Logic --- // Track which step index we're displaying
const streamingParagraphs = extractStreamingParagraphs(thinkingContent) const [displayedStepIndex, setDisplayedStepIndex] = useState(
const currentParagraph = currentStreamingStepIndex
streamingParagraphs[streamingParagraphs.length - 1] || '' )
// State for replacement animation
const [visibleParagraph, setVisibleParagraph] = useState(currentParagraph)
const [transitioning, setTransitioning] = useState(false) const [transitioning, setTransitioning] = useState(false)
// When a new step arrives during streaming, animate the transition
useEffect(() => { useEffect(() => {
if (loading && currentParagraph !== visibleParagraph) { if (loading && currentStreamingStepIndex > displayedStepIndex) {
// Start transition out (opacity: 0)
setTransitioning(true) setTransitioning(true)
// Simulate subtle easeIn replacement after a short delay
const timeout = setTimeout(() => { const timeout = setTimeout(() => {
setVisibleParagraph(currentParagraph) setDisplayedStepIndex(currentStreamingStepIndex)
// After content replacement, transition in (opacity: 1)
setTransitioning(false) setTransitioning(false)
}, 150) }, 150)
return () => clearTimeout(timeout) return () => clearTimeout(timeout)
} else if (!loading) { } else if (!loading) {
// Ensure the last state is captured when streaming stops // When streaming stops, ensure we show the final state
setVisibleParagraph(currentParagraph) setDisplayedStepIndex(currentStreamingStepIndex)
} }
// Update immediately on initial render or if content is stable }, [currentStreamingStepIndex, loading, displayedStepIndex])
if (!loading || streamingParagraphs.length <= 1) {
setVisibleParagraph(currentParagraph)
}
}, [currentParagraph, loading, visibleParagraph, streamingParagraphs.length])
// Check if we are currently streaming but haven't received enough content for a meaningful paragraph // Determine if the block is truly empty (streaming started but no content/steps yet)
const isInitialStreaming = const isStreamingEmpty = loading && stepsWithoutDone.length === 0
loading && currentParagraph.length === 0 && steps?.length === 0
// If loading but we have no content yet, hide the component until the first content piece arrives. // If loading started but no content or steps have arrived yet, display the non-expandable 'Thinking...' block
if (isInitialStreaming) { if (isStreamingEmpty) {
return null return (
<div className="mx-auto w-full break-words">
<div className="mb-4 rounded-lg bg-main-view-fg/4 border border-dashed border-main-view-fg/10 p-2 flex items-center gap-3">
<Loader className="size-4 animate-spin text-main-view-fg/60" />
<span className="font-medium text-main-view-fg/80">
{t('thinking')}...
</span>
</div>
</div>
)
} }
// If not loading, and there are no steps, hide the block entirely.
const hasContent = steps.length > 0
if (!loading && !hasContent) return null
const handleClick = () => { const handleClick = () => {
// Only allow toggling expansion if not currently loading // Only allow toggling expansion if not currently loading
if (!loading) { if (!loading) {
@ -150,12 +137,9 @@ const ThinkingBlock = ({
} }
// --- Rendering Functions for Expanded View --- // --- Rendering Functions for Expanded View ---
const renderStepContent = (step: ThoughtStep, index: number) => { const renderStepContent = (step: ThoughtStep, index: number) => {
if (step.type === 'done') { if (step.type === 'done') {
const timeInSeconds = formatDuration(step.time ?? 0) const timeInSeconds = formatDuration(step.time ?? 0)
// TODO: Add translations
const timeDisplay = const timeDisplay =
timeInSeconds > 0 timeInSeconds > 0
? `(${t('for')} ${timeInSeconds} ${t('seconds')})` ? `(${t('for')} ${timeInSeconds} ${t('seconds')})`
@ -163,9 +147,8 @@ const ThinkingBlock = ({
return ( return (
<div key={index} className="flex items-center gap-2 mt-2 text-accent"> <div key={index} className="flex items-center gap-2 mt-2 text-accent">
{/* Use Check icon for done state */}
<Check className="size-4" /> <Check className="size-4" />
<span className="font-medium">{t('common:done')}</span> <span className="font-medium">{t('done')}</span>
{timeDisplay && ( {timeDisplay && (
<span className="text-main-view-fg/60 text-xs">{timeDisplay}</span> <span className="text-main-view-fg/60 text-xs">{timeDisplay}</span>
)} )}
@ -222,7 +205,6 @@ const ThinkingBlock = ({
const timeInSeconds = formatDuration(duration ?? 0) const timeInSeconds = formatDuration(duration ?? 0)
if (timeInSeconds > 0) { if (timeInSeconds > 0) {
// Ensure translated strings are used correctly
return `${t('thought')} ${t('for')} ${timeInSeconds} ${t('seconds')}` return `${t('thought')} ${t('for')} ${timeInSeconds} ${t('seconds')}`
} }
return t('thought') return t('thought')
@ -244,7 +226,6 @@ const ThinkingBlock = ({
> >
{/* Display chevron only if not loading AND steps exist to expand */} {/* Display chevron only if not loading AND steps exist to expand */}
{!loading && {!loading &&
steps &&
steps.length > 0 && steps.length > 0 &&
(isExpanded ? ( (isExpanded ? (
<ChevronUp className="size-4 text-main-view-fg/60" /> <ChevronUp className="size-4 text-main-view-fg/60" />
@ -255,23 +236,30 @@ const ThinkingBlock = ({
</button> </button>
</div> </div>
{/* Streaming/Condensed View (Visible ONLY when loading) */} {/* Streaming/Condensed View - shows current step one at a time */}
{loading && ( {loading && currentStep && displayedStepIndex >= 0 && (
<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' transitioning ? 'opacity-0' : 'opacity-100'
)} )}
> >
<RenderMarkdown content={visibleParagraph} /> <div className="relative border-l border-dashed border-main-view-fg/20 ml-1.5">
<div className="relative pl-6 pb-2">
{/* Bullet point */}
<div className="absolute left-[-5px] top-[10px] size-2 rounded-full bg-main-view-fg/60" />
{/* Current step content */}
{renderStepContent(currentStep, displayedStepIndex)}
</div>
</div>
</div> </div>
)} )}
{/* Expanded View (Req 5) */} {/* Expanded View - shows all steps */}
{isExpanded && !loading && ( {isExpanded && !loading && (
<div className="mt-2 pl-6 pr-4 text-main-view-fg/60"> <div className="mt-2 pl-6 pr-4 text-main-view-fg/60">
<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">
{steps?.map((step, index) => ( {steps.map((step, index) => (
<div key={index} className="relative pl-6 pb-2"> <div key={index} className="relative pl-6 pb-2">
{/* Bullet point/Icon position relative to line */} {/* Bullet point/Icon position relative to line */}
<div <div

View File

@ -7,7 +7,7 @@ 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 ToolCallBlock from '@/containers/ToolCallBlock'
import { useChat } from '@/hooks/useChat' import { useChat } from '@/hooks/useChat'
import { import {
EditMessageDialog, EditMessageDialog,
@ -229,7 +229,7 @@ export const ThreadContent = memo(
while (toSendMessage && toSendMessage?.role !== 'user') { while (toSendMessage && toSendMessage?.role !== 'user') {
deleteMessage(toSendMessage.thread_id, toSendMessage.id ?? '') deleteMessage(toSendMessage.thread_id, toSendMessage.id ?? '')
toSendMessage = threadMessages.pop() toSendMessage = threadMessages.pop()
// Stop deletion when encountering an assistant message that isnt a tool call // Stop deletion when encountering an assistant message that isn't a tool call
if ( if (
toSendMessage && toSendMessage &&
toSendMessage.role === 'assistant' && toSendMessage.role === 'assistant' &&
@ -277,12 +277,11 @@ export const ThreadContent = memo(
steps.push({ steps.push({
type: 'tool_call', type: 'tool_call',
content: call.tool?.function?.name || 'Tool Call', content: call.tool?.function?.name || 'Tool Call',
metadata: call.tool?.function?.arguments as string, // Arguments are typically a JSON string metadata: call.tool?.function?.arguments as string,
}) })
// Tool Output Step // Tool Output Step
if (call.response) { if (call.response) {
// Response object usually needs stringifying for display
const outputContent = const outputContent =
typeof call.response === 'string' typeof call.response === 'string'
? call.response ? call.response
@ -312,11 +311,13 @@ export const ThreadContent = memo(
isToolCalls, isToolCalls,
item.metadata, item.metadata,
isStreamingThisThread, isStreamingThisThread,
t,
hasReasoning, hasReasoning,
]) ])
// END: Constructing allSteps // END: Constructing allSteps
// Determine if we should show the thinking block (has reasoning OR tool calls)
const shouldShowThinkingBlock = hasReasoning || isToolCalls
return ( return (
<Fragment> <Fragment>
{item.role === 'user' && ( {item.role === 'user' && (
@ -451,19 +452,20 @@ export const ThreadContent = memo(
</div> </div>
)} )}
{hasReasoning && ( {/* Single unified ThinkingBlock for both reasoning and tool calls */}
{shouldShowThinkingBlock && (
<ThinkingBlock <ThinkingBlock
id={ id={
item.isLastMessage item.isLastMessage
? `${item.thread_id}-last-${reasoningSegment!.slice(0, 50).replace(/\s/g, '').slice(-10)}` ? `${item.thread_id}-last-${(reasoningSegment || text).slice(0, 50).replace(/\s/g, '').slice(-10)}`
: `${item.thread_id}-${item.index ?? item.id}` : `${item.thread_id}-${item.index ?? item.id}`
} }
text={reasoningSegment!} text={reasoningSegment || ''}
steps={allSteps} // Pass structured steps steps={allSteps}
loading={isStreamingThisThread} // Pass streaming status loading={isStreamingThisThread}
duration={ duration={
item.metadata?.totalThinkingTime as number | undefined item.metadata?.totalThinkingTime as number | undefined
} // Pass calculated duration }
/> />
)} )}
@ -472,31 +474,6 @@ export const ThreadContent = memo(
components={linkComponents} components={linkComponents}
/> />
{/* Only render external ToolCallBlocks if there is NO dedicated reasoning block
(i.e., when tools are streamed as standalone output and are NOT captured by ThinkingBlock). */}
{!hasReasoning && isToolCalls && item.metadata?.tool_calls ? (
<>
{(item.metadata.tool_calls as ToolCall[]).map((toolCall) => (
<ToolCallBlock
id={toolCall.tool?.id ?? 0}
key={toolCall.tool?.id}
name={
(item.streamTools?.tool_calls?.function?.name ||
toolCall.tool?.function?.name) ??
''
}
args={
item.streamTools?.tool_calls?.function?.arguments ||
toolCall.tool?.function?.arguments ||
undefined
}
result={JSON.stringify(toolCall.response)}
loading={toolCall.state === 'pending'}
/>
))}
</>
) : null}
{!isToolCalls && ( {!isToolCalls && (
<div className="flex items-center gap-2 text-main-view-fg/60 text-xs"> <div className="flex items-center gap-2 text-main-view-fg/60 text-xs">
<div className={cn('flex items-center gap-2')}> <div className={cn('flex items-center gap-2')}>