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:
parent
e7b7ac9e94
commit
8601a49ff6
@ -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>
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user