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:
parent
8601a49ff6
commit
44b62a1d19
@ -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
|
||||||
|
|||||||
@ -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 isn’t 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')}>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user