/* eslint-disable react-hooks/rules-of-hooks */ /* eslint-disable @typescript-eslint/no-explicit-any */ import { ChevronDown, ChevronUp, Loader, Check } from 'lucide-react' import { create } from 'zustand' import { RenderMarkdown } from './RenderMarkdown' import { useTranslation } from '@/i18n/react-i18next-compat' import { useMemo, useState } from 'react' import { cn } from '@/lib/utils' import ImageModal from '@/containers/dialogs/ImageModal' // Define ReActStep type (Reasoning-Action Step) type ReActStep = { type: 'reasoning' | 'tool_call' | 'tool_output' | 'done' content: string metadata?: any time?: number } interface Props { text: string id: string steps?: ReActStep[] // Updated type loading?: boolean duration?: number } // Utility function to safely parse JSON const safeParseJSON = (text: string) => { try { return JSON.parse(text) } catch { return null } } // Utility to create data URL for images const createDataUrl = (base64Data: string, mimeType: string): string => { if (base64Data.startsWith('data:')) return base64Data return `data:${mimeType};base64,${base64Data}` } // Zustand store for thinking block state type ThinkingBlockState = { thinkingState: { [id: string]: boolean } setThinkingState: (id: string, expanded: boolean) => void } const useThinkingStore = create((set) => ({ thinkingState: {}, setThinkingState: (id, expanded) => set((state) => ({ thinkingState: { ...state.thinkingState, [id]: expanded, }, })), })) // Helper to format duration in seconds const formatDuration = (ms: number) => { if (ms > 0) { return Math.round(ms / 1000) } return 0 } const ThinkingBlock = ({ id, steps = [], loading: propLoading, duration, }: Props) => { const thinkingState = useThinkingStore((state) => state.thinkingState) const setThinkingState = useThinkingStore((state) => state.setThinkingState) const { t } = useTranslation() // Move useState for modal management to the top level of the component const [modalImage, setModalImage] = useState<{ url: string alt: string } | null>(null) const closeModal = () => setModalImage(null) const handleImageClick = (url: string, alt: string) => setModalImage({ url, alt }) // Actual loading state comes from prop, determined by whether final text started streaming (Req 2) const loading = propLoading // Set default expansion state: collapsed if done (not loading). // If loading transitions to false (textSegment starts), this defaults to collapsed. const isExpanded = thinkingState[id] ?? (loading ? true : false) // Filter out the 'done' step for streaming display const stepsWithoutDone = useMemo( () => steps.filter((step) => step.type !== 'done'), [steps] ) const N = stepsWithoutDone.length // Determine the step to display in the condensed streaming view // When loading, we show the last available step (N-1), which is currently accumulating content. const activeStep = useMemo(() => { if (!loading || N === 0) return null return stepsWithoutDone[N - 1] }, [loading, N, stepsWithoutDone]) // Determine if the block is truly empty (streaming started but no content/steps yet) const isStreamingEmpty = loading && N === 0 // If not loading, and there are no steps, hide the block entirely. const hasContent = steps.length > 0 if (!loading && !hasContent) return null const handleClick = () => { // Only allow toggling expansion if not currently loading // Also only allow if there is content (to prevent collapsing the simple 'Thinking') if (!loading && hasContent) { setThinkingState(id, !isExpanded) } } // --- Rendering Functions for Expanded View --- const renderStepContent = ( step: ReActStep, index: number, handleImageClick: (url: string, alt: string) => void, t: (key: string) => string ) => { // Updated type if (step.type === 'done') { const timeInSeconds = formatDuration(step.time ?? 0) const timeDisplay = timeInSeconds > 0 ? `(${t('chat:for')} ${timeInSeconds} ${t('chat:seconds')})` : '' return (
{t('done')} {timeDisplay && ( {timeDisplay} )}
) } const parsed = safeParseJSON(step.content) const mcpContent = parsed?.content ?? [] const hasImages = Array.isArray(mcpContent) && mcpContent.some((c) => c.type === 'image' && c.data && c.mimeType) let contentDisplay: React.ReactNode if (step.type === 'tool_call') { const args = step.metadata ? step.metadata : '' contentDisplay = ( <>

Tool Call: {step.content}

{args && (
)} ) } else if (step.type === 'tool_output') { if (hasImages) { // Display each image contentDisplay = ( <>

Tool Output (Images):

{mcpContent.map((item: any, index: number) => item.type === 'image' && item.data && item.mimeType ? (
{`MCP (e.currentTarget.style.display = 'none')} onClick={() => handleImageClick( createDataUrl(item.data, item.mimeType), `MCP Image ${index + 1}` ) } />
) : null )}
) } else { // Default behavior: wrap text in code block if no backticks let content = step.content.substring(0, 1000) if (!content.includes('```')) { content = '```json\n' + content + '\n```' } contentDisplay = ( <>

Tool Output:

) } } else { contentDisplay = ( ) } return (
{contentDisplay}
) } const headerTitle: string = useMemo(() => { // Check if any step was a tool call const hasToolCalls = steps.some((step) => step.type === 'tool_call') const hasReasoning = steps.some((step) => step.type === 'reasoning') const timeInSeconds = formatDuration(duration ?? 0) if (loading) { // Logic for streaming (loading) state: if (activeStep) { if ( activeStep.type === 'tool_call' || activeStep.type === 'tool_output' ) { return `${t('chat:calling_tool')}` // Use a specific translation key for tool } else if (activeStep.type === 'reasoning') { return `${t('chat:thinking')}` // Use the generic thinking key } } // Fallback for isStreamingEmpty state (N=0) return `${t('chat:thinking')}` } // Logic for finalized (not loading) state: // Build label based on what steps occurred let label = '' if (hasReasoning && hasToolCalls) { // Use a more descriptive label when both were involved label = t('chat:thought_and_tool_call') } else if (hasToolCalls) { label = t('chat:tool_called') } else { label = t('chat:thought') } if (timeInSeconds > 0) { return `${label} ${t('chat:for')} ${timeInSeconds} ${t('chat:seconds')}` } return label }, [loading, duration, t, activeStep, steps]) return (
{loading && ( )}
{isStreamingEmpty && (
{t('chat:thinking')}
)} {/* Streaming/Condensed View - shows active step (N-1) */} {loading && activeStep && (
1) N > 1 && 'animate-in fade-in slide-in-from-top-2 duration-300' )} >
{/* Bullet point/Icon position relative to line */}
{/* Active step content */} {renderStepContent(activeStep, N - 1, handleImageClick, t)}
)} {/* Expanded View - shows all steps */} {isExpanded && !loading && hasContent && (
{steps.map((step, index) => (
{/* Bullet point/Icon position relative to line */}
{/* Step Content */} {renderStepContent(step, index, handleImageClick, t)}
))}
)}
{/* Render ImageModal once at the top level */}
) } export default ThinkingBlock