feat: enhance thinking UI, support structured steps, and record total thinking time

- **ThinkingBlock**
  - Added `ThoughtStep` type and UI handling for step kinds: `thought`, `tool_call`, `tool_output`, and `done`.
  - Integrated `Check` icon for completed steps and formatted duration (seconds) display.
  - Implemented streaming paragraph extraction, fade‑in/out animation, and improved loading state handling.
  - Updated header to show dynamic titles (thinking/thought + duration) and disabled expand/collapse while loading.
  - Utilized `cn` utility for conditional class names and added relevant imports.

- **ThreadContent**
  - Defined `ToolCall` and `ThoughtStep` types for type safety.
  - Constructed `allSteps` via `useMemo`, extracting thought paragraphs, tool calls/outputs, and a final `done` step with total thinking time.
  - Passed `steps`, `loading`, and `duration` props to `ThinkingBlock`.
  - Introduced `hasReasoning` flag to conditionally render the reasoning block and avoid duplicate tool call rendering.
  - Adjusted rendering logic to hide empty reasoning and ensure tool call blocks only appear when no reasoning is present.

- **useChat**
  - Refactored `getCurrentThread` for clearer async flow while preserving temporary‑chat behavior.
  - Captured `startTime` at message creation and computed `totalThinkingTime` on completion.
  - Included `totalThinkingTime` in message metadata when appropriate.
  - Minor cleanup: improved error handling for image ingestion and formatting adjustments.

Overall, these changes provide a richer, step‑by‑step thinking UI, better state handling during streaming, and expose total thinking duration for downstream components.
This commit is contained in:
Akarshan 2025-10-18 18:45:30 +05:30
parent e7b7ac9e94
commit 8601a49ff6
No known key found for this signature in database
GPG Key ID: D75C9634A870665F
3 changed files with 427 additions and 84 deletions

View File

@ -1,13 +1,26 @@
import { ChevronDown, ChevronUp, Loader } from 'lucide-react' import { ChevronDown, ChevronUp, Loader, Check } from 'lucide-react'
import { create } from 'zustand' 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 { cn } from '@/lib/utils'
// Define ThoughtStep type
type ThoughtStep = {
type: 'thought' | 'tool_call' | 'tool_output' | 'done'
content: string
metadata?: any
time?: number
}
interface Props { interface Props {
text: string text: string
id: string id: string
steps?: ThoughtStep[]
loading?: boolean
duration?: number
} }
// Zustand store for thinking block state // Zustand store for thinking block state
@ -27,25 +40,193 @@ const useThinkingStore = create<ThinkingBlockState>((set) => ({
})), })),
})) }))
const ThinkingBlock = ({ id, text }: Props) => { // Helper to format duration in seconds
const formatDuration = (ms: number) => {
// Only show seconds if duration is present and non-zero
if (ms > 0) {
return Math.round(ms / 1000)
}
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 = ({
id,
text,
steps,
loading: propLoading,
duration,
}: Props) => {
const thinkingState = useThinkingStore((state) => state.thinkingState) const thinkingState = useThinkingStore((state) => state.thinkingState)
const setThinkingState = useThinkingStore((state) => state.setThinkingState) const setThinkingState = useThinkingStore((state) => state.setThinkingState)
const isStreaming = useAppState((state) => !!state.streamingContent) const isStreamingApp = useAppState((state) => !!state.streamingContent)
const { t } = useTranslation() const { t } = useTranslation()
// Check for thinking formats
// Determine actual loading state
const hasThinkTag = text.includes('<think>') && !text.includes('</think>') const hasThinkTag = text.includes('<think>') && !text.includes('</think>')
const hasAnalysisChannel = const hasAnalysisChannel =
text.includes('<|channel|>analysis<|message|>') && text.includes('<|channel|>analysis<|message|>') &&
!text.includes('<|start|>assistant<|channel|>final<|message|>') !text.includes('<|start|>assistant<|channel|>final<|message|>')
const loading = (hasThinkTag || hasAnalysisChannel) && isStreaming
const loading =
propLoading ?? ((hasThinkTag || hasAnalysisChannel) && isStreamingApp)
// 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 handleClick = () => {
const newExpandedState = !isExpanded
setThinkingState(id, newExpandedState)
}
const thinkingContent = extractThinkingContent(text) const thinkingContent = extractThinkingContent(text)
if (!thinkingContent) return null
// If we are not loading AND there is no content/steps, hide the block entirely.
const hasContent = !!thinkingContent || (steps && steps.length >= 1)
if (!loading && !hasContent) return null
// --- Streaming Logic ---
const streamingParagraphs = extractStreamingParagraphs(thinkingContent)
const currentParagraph =
streamingParagraphs[streamingParagraphs.length - 1] || ''
// State for replacement animation
const [visibleParagraph, setVisibleParagraph] = useState(currentParagraph)
const [transitioning, setTransitioning] = useState(false)
useEffect(() => {
if (loading && currentParagraph !== visibleParagraph) {
// Start transition out (opacity: 0)
setTransitioning(true)
// Simulate subtle easeIn replacement after a short delay
const timeout = setTimeout(() => {
setVisibleParagraph(currentParagraph)
// After content replacement, transition in (opacity: 1)
setTransitioning(false)
}, 150)
return () => clearTimeout(timeout)
} else if (!loading) {
// Ensure the last state is captured when streaming stops
setVisibleParagraph(currentParagraph)
}
// Update immediately on initial render or if content is stable
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
const isInitialStreaming =
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 (isInitialStreaming) {
return null
}
const handleClick = () => {
// Only allow toggling expansion if not currently loading
if (!loading) {
setThinkingState(id, !isExpanded)
}
}
// --- Rendering Functions for Expanded View ---
const renderStepContent = (step: ThoughtStep, index: number) => {
if (step.type === 'done') {
const timeInSeconds = formatDuration(step.time ?? 0)
// TODO: Add translations
const timeDisplay =
timeInSeconds > 0
? `(${t('for')} ${timeInSeconds} ${t('seconds')})`
: ''
return (
<div key={index} className="flex items-center gap-2 mt-2 text-accent">
{/* Use Check icon for done state */}
<Check className="size-4" />
<span className="font-medium">{t('common:done')}</span>
{timeDisplay && (
<span className="text-main-view-fg/60 text-xs">{timeDisplay}</span>
)}
</div>
)
}
let contentDisplay
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') {
contentDisplay = (
<>
<p className="font-medium text-main-view-fg/90">Tool Output:</p>
<div className="mt-1">
<RenderMarkdown
isWrapping={true}
content={'```json\n' + step.content + '\n```'}
/>
</div>
</>
)
} else {
// thought
contentDisplay = (
<RenderMarkdown isWrapping={true} content={step.content} />
)
}
return (
<div key={index} className="py-1 text-main-view-fg/80">
{contentDisplay}
</div>
)
}
const headerTitle = useMemo(() => {
if (loading) return t('thinking')
const timeInSeconds = formatDuration(duration ?? 0)
if (timeInSeconds > 0) {
// Ensure translated strings are used correctly
return `${t('thought')} ${t('for')} ${timeInSeconds} ${t('seconds')}`
}
return t('thought')
}, [loading, duration, t])
return ( return (
<div <div
@ -57,21 +238,54 @@ const ThinkingBlock = ({ id, text }: Props) => {
{loading && ( {loading && (
<Loader className="size-4 animate-spin text-main-view-fg/60" /> <Loader className="size-4 animate-spin text-main-view-fg/60" />
)} )}
<button className="flex items-center gap-2 focus:outline-none"> <button
{isExpanded ? ( className="flex items-center gap-2 focus:outline-none"
<ChevronUp className="size-4 text-main-view-fg/60" /> disabled={loading}
) : ( >
<ChevronDown className="size-4 text-main-view-fg/60" /> {/* Display chevron only if not loading AND steps exist to expand */}
)} {!loading &&
<span className="font-medium"> steps &&
{loading ? t('common:thinking') : t('common:thought')} steps.length > 0 &&
</span> (isExpanded ? (
<ChevronUp className="size-4 text-main-view-fg/60" />
) : (
<ChevronDown className="size-4 text-main-view-fg/60" />
))}
<span className="font-medium">{headerTitle}</span>
</button> </button>
</div> </div>
{isExpanded && ( {/* Streaming/Condensed View (Visible ONLY when loading) */}
{loading && (
<div
className={cn(
'mt-2 pl-6 pr-4 text-main-view-fg/60 transition-opacity duration-150 ease-in',
transitioning ? 'opacity-0' : 'opacity-100'
)}
>
<RenderMarkdown content={visibleParagraph} />
</div>
)}
{/* Expanded View (Req 5) */}
{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">
<RenderMarkdown content={thinkingContent} /> <div className="relative border-l border-dashed border-main-view-fg/20 ml-1.5">
{steps?.map((step, index) => (
<div key={index} className="relative pl-6 pb-2">
{/* Bullet point/Icon position relative to line */}
<div
className={cn(
'absolute left-[-5px] top-[10px] size-2 rounded-full',
step.type === 'done' ? 'bg-accent' : 'bg-main-view-fg/60'
)}
/>
{/* Step Content */}
{renderStepContent(step, index)}
</div>
))}
</div>
</div> </div>
)} )}
</div> </div>

View File

@ -28,6 +28,28 @@ import { useTranslation } from '@/i18n/react-i18next-compat'
import { useModelProvider } from '@/hooks/useModelProvider' import { useModelProvider } from '@/hooks/useModelProvider'
import { extractFilesFromPrompt } from '@/lib/fileMetadata' import { extractFilesFromPrompt } from '@/lib/fileMetadata'
import { createImageAttachment } from '@/types/attachment' import { createImageAttachment } from '@/types/attachment'
import { extractThinkingContent } from '@/lib/utils'
// Define ToolCall interface for type safety when accessing metadata
interface ToolCall {
tool?: {
id: number
function?: {
name: string
arguments?: object | string
}
}
response?: any
state?: 'pending' | 'completed'
}
// Define ThoughtStep type
type ThoughtStep = {
type: 'thought' | 'tool_call' | 'tool_output' | 'done'
content: string
metadata?: any
time?: number
}
const CopyButton = ({ text }: { text: string }) => { const CopyButton = ({ text }: { text: string }) => {
const [copied, setCopied] = useState(false) const [copied, setCopied] = useState(false)
@ -147,6 +169,9 @@ export const ThreadContent = memo(
return { reasoningSegment: undefined, textSegment: text } return { reasoningSegment: undefined, textSegment: text }
}, [text]) }, [text])
// Check if reasoning segment is actually present (i.e., non-empty string)
const hasReasoning = !!reasoningSegment
const getMessages = useMessages((state) => state.getMessages) const getMessages = useMessages((state) => state.getMessages)
const deleteMessage = useMessages((state) => state.deleteMessage) const deleteMessage = useMessages((state) => state.deleteMessage)
const sendMessage = useChat() const sendMessage = useChat()
@ -164,7 +189,8 @@ export const ThreadContent = memo(
deleteMessage(toSendMessage.thread_id, toSendMessage.id ?? '') deleteMessage(toSendMessage.thread_id, toSendMessage.id ?? '')
// Extract text content and any attachments // Extract text content and any attachments
const rawText = const rawText =
toSendMessage.content?.find((c) => c.type === 'text')?.text?.value || '' toSendMessage.content?.find((c) => c.type === 'text')?.text?.value ||
''
const { cleanPrompt: textContent } = extractFilesFromPrompt(rawText) const { cleanPrompt: textContent } = extractFilesFromPrompt(rawText)
const attachments = toSendMessage.content const attachments = toSendMessage.content
?.filter((c) => (c.type === 'image_url' && c.image_url?.url) || false) ?.filter((c) => (c.type === 'image_url' && c.image_url?.url) || false)
@ -226,6 +252,71 @@ export const ThreadContent = memo(
| { avatar?: React.ReactNode; name?: React.ReactNode } | { avatar?: React.ReactNode; name?: React.ReactNode }
| undefined | undefined
// START: Constructing allSteps for ThinkingBlock (Req 5)
const allSteps: ThoughtStep[] = useMemo(() => {
const steps: ThoughtStep[] = []
// 1. Extract thought paragraphs
const thoughtText = extractThinkingContent(reasoningSegment || '')
const thoughtParagraphs = thoughtText
? thoughtText
.split(/\n\s*\n/)
.filter((s) => s.trim().length > 0)
.map((content) => ({
type: 'thought' as const,
content: content.trim(),
}))
: []
steps.push(...thoughtParagraphs)
// 2. Extract tool steps
if (isToolCalls && item.metadata?.tool_calls) {
const toolCalls = item.metadata.tool_calls as ToolCall[]
for (const call of toolCalls) {
// Tool Call Step
steps.push({
type: 'tool_call',
content: call.tool?.function?.name || 'Tool Call',
metadata: call.tool?.function?.arguments as string, // Arguments are typically a JSON string
})
// Tool Output Step
if (call.response) {
// Response object usually needs stringifying for display
const outputContent =
typeof call.response === 'string'
? call.response
: JSON.stringify(call.response, null, 2)
steps.push({
type: 'tool_output',
content: outputContent,
})
}
}
}
// 3. Add Done step if not streaming
const totalTime = item.metadata?.totalThinkingTime as number | undefined
if (!isStreamingThisThread && (hasReasoning || isToolCalls)) {
steps.push({
type: 'done',
content: 'Done',
time: totalTime,
})
}
return steps
}, [
reasoningSegment,
isToolCalls,
item.metadata,
isStreamingThisThread,
t,
hasReasoning,
])
// END: Constructing allSteps
return ( return (
<Fragment> <Fragment>
{item.role === 'user' && ( {item.role === 'user' && (
@ -360,14 +451,19 @@ export const ThreadContent = memo(
</div> </div>
)} )}
{reasoningSegment && ( {hasReasoning && (
<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!.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
loading={isStreamingThisThread} // Pass streaming status
duration={
item.metadata?.totalThinkingTime as number | undefined
} // Pass calculated duration
/> />
)} )}
@ -376,7 +472,9 @@ export const ThreadContent = memo(
components={linkComponents} components={linkComponents}
/> />
{isToolCalls && item.metadata?.tool_calls ? ( {/* 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) => ( {(item.metadata.tool_calls as ToolCall[]).map((toolCall) => (
<ToolCallBlock <ToolCallBlock

View File

@ -93,66 +93,74 @@ export const useChat = () => {
const setModelLoadError = useModelLoad((state) => state.setModelLoadError) const setModelLoadError = useModelLoad((state) => state.setModelLoadError)
const router = useRouter() const router = useRouter()
const getCurrentThread = useCallback(async (projectId?: string) => { const getCurrentThread = useCallback(
let currentThread = retrieveThread() async (projectId?: string) => {
let currentThread = retrieveThread()
// Check if we're in temporary chat mode // Check if we're in temporary chat mode
const isTemporaryMode = window.location.search.includes(`${TEMPORARY_CHAT_QUERY_ID}=true`) const isTemporaryMode = window.location.search.includes(
`${TEMPORARY_CHAT_QUERY_ID}=true`
// Clear messages for existing temporary thread on reload to ensure fresh start
if (isTemporaryMode && currentThread?.id === TEMPORARY_CHAT_ID) {
setMessages(TEMPORARY_CHAT_ID, [])
}
if (!currentThread) {
// Get prompt directly from store when needed
const currentPrompt = usePrompt.getState().prompt
const currentAssistant = useAssistant.getState().currentAssistant
const assistants = useAssistant.getState().assistants
const selectedModel = useModelProvider.getState().selectedModel
const selectedProvider = useModelProvider.getState().selectedProvider
// Get project metadata if projectId is provided
let projectMetadata: { id: string; name: string; updated_at: number } | undefined
if (projectId) {
const project = await serviceHub.projects().getProjectById(projectId)
if (project) {
projectMetadata = {
id: project.id,
name: project.name,
updated_at: project.updated_at,
}
}
}
currentThread = await createThread(
{
id: selectedModel?.id ?? defaultModel(selectedProvider),
provider: selectedProvider,
},
isTemporaryMode ? 'Temporary Chat' : currentPrompt,
assistants.find((a) => a.id === currentAssistant?.id) || assistants[0],
projectMetadata,
isTemporaryMode // pass temporary flag
) )
// Clear messages for temporary chat to ensure fresh start on reload // Clear messages for existing temporary thread on reload to ensure fresh start
if (isTemporaryMode && currentThread?.id === TEMPORARY_CHAT_ID) { if (isTemporaryMode && currentThread?.id === TEMPORARY_CHAT_ID) {
setMessages(TEMPORARY_CHAT_ID, []) setMessages(TEMPORARY_CHAT_ID, [])
} }
// Set flag for temporary chat navigation if (!currentThread) {
if (currentThread.id === TEMPORARY_CHAT_ID) { // Get prompt directly from store when needed
sessionStorage.setItem('temp-chat-nav', 'true') const currentPrompt = usePrompt.getState().prompt
} const currentAssistant = useAssistant.getState().currentAssistant
const assistants = useAssistant.getState().assistants
const selectedModel = useModelProvider.getState().selectedModel
const selectedProvider = useModelProvider.getState().selectedProvider
router.navigate({ // Get project metadata if projectId is provided
to: route.threadsDetail, let projectMetadata:
params: { threadId: currentThread.id }, | { id: string; name: string; updated_at: number }
}) | undefined
} if (projectId) {
return currentThread const project = await serviceHub.projects().getProjectById(projectId)
}, [createThread, retrieveThread, router, setMessages, serviceHub]) if (project) {
projectMetadata = {
id: project.id,
name: project.name,
updated_at: project.updated_at,
}
}
}
currentThread = await createThread(
{
id: selectedModel?.id ?? defaultModel(selectedProvider),
provider: selectedProvider,
},
isTemporaryMode ? 'Temporary Chat' : currentPrompt,
assistants.find((a) => a.id === currentAssistant?.id) ||
assistants[0],
projectMetadata,
isTemporaryMode // pass temporary flag
)
// Clear messages for temporary chat to ensure fresh start on reload
if (isTemporaryMode && currentThread?.id === TEMPORARY_CHAT_ID) {
setMessages(TEMPORARY_CHAT_ID, [])
}
// Set flag for temporary chat navigation
if (currentThread.id === TEMPORARY_CHAT_ID) {
sessionStorage.setItem('temp-chat-nav', 'true')
}
router.navigate({
to: route.threadsDetail,
params: { threadId: currentThread.id },
})
}
return currentThread
},
[createThread, retrieveThread, router, setMessages, serviceHub]
)
const restartModel = useCallback( const restartModel = useCallback(
async (provider: ProviderObject, modelId: string) => { async (provider: ProviderObject, modelId: string) => {
@ -297,7 +305,9 @@ export const useChat = () => {
updateAttachmentProcessing(img.name, 'processing') updateAttachmentProcessing(img.name, 'processing')
} }
// Upload image, get id/URL // Upload image, get id/URL
const res = await serviceHub.uploads().ingestImage(activeThread.id, img) const res = await serviceHub
.uploads()
.ingestImage(activeThread.id, img)
processedAttachments.push({ processedAttachments.push({
...img, ...img,
id: res.id, id: res.id,
@ -313,7 +323,9 @@ export const useChat = () => {
updateAttachmentProcessing(img.name, 'error') updateAttachmentProcessing(img.name, 'error')
} }
const desc = err instanceof Error ? err.message : String(err) const desc = err instanceof Error ? err.message : String(err)
toast.error('Failed to ingest image attachment', { description: desc }) toast.error('Failed to ingest image attachment', {
description: desc,
})
return return
} }
} }
@ -394,6 +406,9 @@ export const useChat = () => {
updateThreadTimestamp(activeThread.id) updateThreadTimestamp(activeThread.id)
usePrompt.getState().setPrompt('') usePrompt.getState().setPrompt('')
const selectedModel = useModelProvider.getState().selectedModel const selectedModel = useModelProvider.getState().selectedModel
const startTime = Date.now() // Start timer here
try { try {
if (selectedModel?.id) { if (selectedModel?.id) {
updateLoadingModel(true) updateLoadingModel(true)
@ -705,14 +720,22 @@ export const useChat = () => {
throw new Error('No response received from the model') throw new Error('No response received from the model')
} }
const totalThinkingTime = Date.now() - startTime // Calculate total elapsed time
// Create a final content object for adding to the thread // Create a final content object for adding to the thread
const messageMetadata: Record<string, any> = {
tokenSpeed: useAppState.getState().tokenSpeed,
assistant: currentAssistant,
}
if (accumulatedText.includes('<think>') || toolCalls.length > 0) {
messageMetadata.totalThinkingTime = totalThinkingTime
}
const finalContent = newAssistantThreadContent( const finalContent = newAssistantThreadContent(
activeThread.id, activeThread.id,
accumulatedText, accumulatedText,
{ messageMetadata
tokenSpeed: useAppState.getState().tokenSpeed,
assistant: currentAssistant,
}
) )
builder.addAssistantMessage(accumulatedText, undefined, toolCalls) builder.addAssistantMessage(accumulatedText, undefined, toolCalls)
@ -730,6 +753,14 @@ export const useChat = () => {
allowAllMCPPermissions, allowAllMCPPermissions,
isProactiveMode isProactiveMode
) )
if (updatedMessage && updatedMessage.metadata) {
if (finalContent.metadata?.totalThinkingTime !== undefined) {
updatedMessage.metadata.totalThinkingTime =
finalContent.metadata.totalThinkingTime
}
}
addMessage(updatedMessage ?? finalContent) addMessage(updatedMessage ?? finalContent)
updateStreamingContent(emptyThreadContent) updateStreamingContent(emptyThreadContent)
updatePromptProgress(undefined) updatePromptProgress(undefined)