jan/web-app/src/containers/ThinkingBlock.tsx
Akarshan 0f7994e03b
refactor: streamline ThinkingBlock empty streaming handling
Remove the separate “Thinking…” placeholder component and embed the empty‑streaming state directly inside the main block. Adjust the click handler and button disabled logic to only allow toggling when content is available, preventing accidental collapse during loading. This change simplifies the component, eliminates duplicate markup, and improves UX by consistently showing the thinking indicator within the block.
2025-10-29 22:49:54 +05:30

379 lines
13 KiB
TypeScript

/* 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<ThinkingBlockState>((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 (
<div
key={index}
className="flex items-center gap-1 text-accent transition-all"
>
<Check className="size-4" />
<span className="font-medium">{t('done')}</span>
{timeDisplay && (
<span className="text-main-view-fg/60 text-xs">{timeDisplay}</span>
)}
</div>
)
}
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 = (
<>
<p className="font-medium text-main-view-fg/90">
Tool Call: <span className="text-accent">{step.content}</span>
</p>
{args && (
<div className="mt-1">
<RenderMarkdown
isWrapping={true}
content={'```json\n' + args + '\n```'}
/>
</div>
)}
</>
)
} else if (step.type === 'tool_output') {
if (hasImages) {
// Display each image
contentDisplay = (
<>
<p className="font-medium text-main-view-fg/90">
Tool Output (Images):
</p>
<div className="mt-2 space-y-2">
{mcpContent.map((item: any, index: number) =>
item.type === 'image' && item.data && item.mimeType ? (
<div key={index} className="my-2">
<img
src={createDataUrl(item.data, item.mimeType)}
alt={`MCP Image ${index + 1}`}
className="max-w-full max-h-64 object-contain rounded-md border border-main-view-fg/10 cursor-pointer hover:opacity-80 transition-opacity"
onError={(e) => (e.currentTarget.style.display = 'none')}
onClick={() =>
handleImageClick(
createDataUrl(item.data, item.mimeType),
`MCP Image ${index + 1}`
)
}
/>
</div>
) : null
)}
</div>
</>
)
} 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 = (
<>
<p className="font-medium text-main-view-fg/90">Tool Output:</p>
<div className="mt-1">
<RenderMarkdown isWrapping={true} content={content} />
</div>
</>
)
}
} else {
contentDisplay = (
<RenderMarkdown isWrapping={true} content={step.content} />
)
}
return (
<div key={index} className="text-main-view-fg/80">
{contentDisplay}
</div>
)
}
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 (
<div
className="mx-auto w-full break-words"
// Only set onClick handler if not loading AND we have content to expand
onClick={loading || !hasContent ? undefined : handleClick}
>
<div className="mb-4 rounded-lg bg-main-view-fg/4 p-2 transition-all duration-200">
<div className="flex items-center gap-3">
{loading && (
<Loader className="size-4 animate-spin text-main-view-fg/60" />
)}
<button
className="flex items-center gap-2 focus:outline-none"
// Button is disabled/non-expandable if loading OR if there's no content to show
disabled={loading || !hasContent}
>
{/* Display chevron only if not loading AND steps exist to expand */}
{!loading &&
hasContent && // Use hasContent instead of steps.length > 0
(isExpanded ? (
<ChevronUp className="size-4 text-main-view-fg/60 transition-transform duration-200" />
) : (
<ChevronDown className="size-4 text-main-view-fg/60 transition-transform duration-200" />
))}
<span className="font-medium transition-all duration-200">
{headerTitle}
</span>
</button>
</div>
{isStreamingEmpty && (
<div className="mt-2 pl-2 pr-4 text-main-view-fg/80">
<span className="font-medium text-main-view-fg/80">
{t('chat:thinking')}
</span>
</div>
)}
{/* Streaming/Condensed View - shows active step (N-1) */}
{loading && activeStep && (
<div
key={`streaming-${N - 1}`}
className={cn(
'mt-4 pl-2 pr-4 text-main-view-fg/60',
// Only animate fade-in if it's not the very first step (N > 1)
N > 1 && 'animate-in fade-in slide-in-from-top-2 duration-300'
)}
>
<div className="relative border-main-view-fg/20">
<div className="relative pl-5">
{/* Bullet point/Icon position relative to line */}
<div
className={cn(
'absolute left-[-2px] top-1.5 size-2 rounded-full bg-main-view-fg/60',
activeStep.type !== 'done' && 'animate-pulse' // Pulse if active/streaming
)}
/>
{/* Active step content */}
{renderStepContent(activeStep, N - 1, handleImageClick, t)}
</div>
</div>
</div>
)}
{/* Expanded View - shows all steps */}
{isExpanded && !loading && hasContent && (
<div className="mt-4 pl-2 pr-4 text-main-view-fg/60 animate-in fade-in slide-in-from-top-2 duration-300">
<div className="relative border-main-view-fg/20">
{steps.map((step, index) => (
<div
key={index}
className={cn(
'relative pl-5 pb-2',
'fade-in slide-in-from-left-1 duration-200',
step.type !== 'done' &&
'after:content-[] after:border-l after:border-dashed after:border-main-view-fg/20 after:absolute after:left-0.5 after:bottom-0 after:w-1 after:h-[calc(100%-8px)]'
)}
style={{ animationDelay: `${index * 50}ms` }}
>
{/* Bullet point/Icon position relative to line */}
<div
className={cn(
'absolute left-[-2px] top-1.5 size-2 rounded-full transition-colors duration-200',
step.type === 'done' ? 'bg-accent' : 'bg-main-view-fg/60'
)}
/>
{/* Step Content */}
{renderStepContent(step, index, handleImageClick, t)}
</div>
))}
</div>
</div>
)}
</div>
{/* Render ImageModal once at the top level */}
<ImageModal image={modalImage} onClose={closeModal} />
</div>
)
}
export default ThinkingBlock