fix(desktop): render reasoning text in the Thinking widget (#63999)
This commit is contained in:
parent
2ccfdb2db4
commit
19641fab72
2 changed files with 19 additions and 118 deletions
|
|
@ -15,7 +15,6 @@ import {
|
|||
useDeferredValue,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState
|
||||
} from 'react'
|
||||
|
||||
|
|
@ -348,112 +347,6 @@ function MarkdownImage({ className, src, alt, ...props }: ComponentProps<'img'>)
|
|||
)
|
||||
}
|
||||
|
||||
// Steady character-reveal for streaming text: decouples visible cadence from
|
||||
// bursty arrival so text flows instead of popping (cf. assistant-ui's useSmooth,
|
||||
// reimplemented for a tunable rate). Proportional drain — each frame reveals a
|
||||
// slice of the backlog so the reveal converges within ~REVEAL_DRAIN_MS whatever
|
||||
// the size; the per-frame cap stops a huge dump rendering as one slab. The loop
|
||||
// is gated on backlog, not isRunning, so a stream that completes mid-reveal
|
||||
// keeps draining its tail instead of snapping.
|
||||
const REVEAL_DRAIN_MS = 500
|
||||
const REVEAL_MAX_CHARS_PER_FRAME = 30
|
||||
// Floor between reveal commits. Each commit republishes the text context and
|
||||
// re-runs the whole Streamdown pipeline (preprocess → remend → lex → micromark
|
||||
// on the open block) over the full accumulated text — at raw rAF cadence
|
||||
// that's 60 full parses/second and was the dominant streaming cost for
|
||||
// reasoning text. ~33ms keeps the reveal visually fluid (2 frames) while
|
||||
// halving the parse work.
|
||||
const REVEAL_MIN_COMMIT_MS = 33
|
||||
|
||||
function useSmoothReveal(text: string, isRunning: boolean): string {
|
||||
const [displayed, setDisplayed] = useState(isRunning ? '' : text)
|
||||
const targetRef = useRef(text)
|
||||
const shownRef = useRef(displayed)
|
||||
const frameRef = useRef<number | null>(null)
|
||||
const lastTickRef = useRef(0)
|
||||
|
||||
shownRef.current = displayed
|
||||
targetRef.current = text
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') {
|
||||
return
|
||||
}
|
||||
|
||||
// Non-extending change (regenerate / branch / history swap): restart from
|
||||
// empty while streaming, else snap to the replacement.
|
||||
if (!text.startsWith(shownRef.current)) {
|
||||
shownRef.current = isRunning ? '' : text
|
||||
setDisplayed(shownRef.current)
|
||||
}
|
||||
|
||||
if (shownRef.current.length >= text.length || frameRef.current !== null) {
|
||||
return
|
||||
}
|
||||
|
||||
lastTickRef.current = performance.now()
|
||||
|
||||
const tick = () => {
|
||||
const now = performance.now()
|
||||
const dt = now - lastTickRef.current
|
||||
|
||||
// Skip this frame if the floor hasn't elapsed — the backlog math below
|
||||
// is dt-proportional, so delayed commits reveal proportionally more.
|
||||
if (dt < REVEAL_MIN_COMMIT_MS) {
|
||||
frameRef.current = requestAnimationFrame(tick)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
lastTickRef.current = now
|
||||
|
||||
const remaining = targetRef.current.length - shownRef.current.length
|
||||
|
||||
const add = Math.min(
|
||||
remaining,
|
||||
// dt-scaled so the per-commit cap stays equivalent to the old
|
||||
// per-frame cap at any commit cadence.
|
||||
Math.ceil((REVEAL_MAX_CHARS_PER_FRAME * dt) / 16.7),
|
||||
Math.max(1, Math.ceil((remaining * dt) / REVEAL_DRAIN_MS))
|
||||
)
|
||||
|
||||
shownRef.current = targetRef.current.slice(0, shownRef.current.length + add)
|
||||
setDisplayed(shownRef.current)
|
||||
|
||||
frameRef.current = shownRef.current.length < targetRef.current.length ? requestAnimationFrame(tick) : null
|
||||
}
|
||||
|
||||
frameRef.current = requestAnimationFrame(tick)
|
||||
}, [text, isRunning])
|
||||
|
||||
useEffect(
|
||||
() => () => {
|
||||
if (frameRef.current !== null && typeof window !== 'undefined') {
|
||||
cancelAnimationFrame(frameRef.current)
|
||||
}
|
||||
},
|
||||
[]
|
||||
)
|
||||
|
||||
return displayed
|
||||
}
|
||||
|
||||
// Re-publish the part context with a smooth character-reveal, above
|
||||
// DeferStreamingText so the reveal feeds the deferred markdown pipeline. Status
|
||||
// stays running while revealing so the caret persists past the underlying part
|
||||
// settling.
|
||||
function SmoothStreamingText({ children }: { children: ReactNode }) {
|
||||
const { text, status } = useMessagePartText()
|
||||
const isRunning = status.type === 'running'
|
||||
const revealed = useSmoothReveal(text, isRunning)
|
||||
|
||||
return (
|
||||
<TextMessagePartProvider isRunning={isRunning || revealed !== text} text={revealed}>
|
||||
{children}
|
||||
</TextMessagePartProvider>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-publish the active message-part context with React's `useDeferredValue`
|
||||
* applied to the streaming text and status. The outer wrapper still re-renders
|
||||
|
|
@ -694,13 +587,14 @@ interface MarkdownTextContentProps extends MarkdownTextSurfaceProps {
|
|||
}
|
||||
|
||||
export function MarkdownTextContent({ isRunning, text, ...surfaceProps }: MarkdownTextContentProps) {
|
||||
// Same path as the assistant answer. A reasoning-only smoothing wrapper used
|
||||
// to sit here but stalled its char-reveal at empty (the part stays running
|
||||
// the whole message), blanking the Thinking widget.
|
||||
return (
|
||||
<TextMessagePartProvider isRunning={isRunning} text={text}>
|
||||
<SmoothStreamingText>
|
||||
<DeferStreamingText>
|
||||
<MarkdownTextSurface {...surfaceProps} />
|
||||
</DeferStreamingText>
|
||||
</SmoothStreamingText>
|
||||
<DeferStreamingText>
|
||||
<MarkdownTextSurface {...surfaceProps} />
|
||||
</DeferStreamingText>
|
||||
</TextMessagePartProvider>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,9 @@
|
|||
import { type ToolCallMessagePartProps, useAuiState } from '@assistant-ui/react'
|
||||
import {
|
||||
type ReasoningMessagePartComponent,
|
||||
type ToolCallMessagePartProps,
|
||||
useAuiState,
|
||||
useMessagePartReasoning
|
||||
} from '@assistant-ui/react'
|
||||
import { type ComponentProps, type FC, type ReactNode, useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { ClarifyTool } from '@/components/assistant-ui/clarify-tool'
|
||||
|
|
@ -174,17 +179,19 @@ const ReasoningAccordionGroup: FC<{ children?: ReactNode; endIndex: number; star
|
|||
)
|
||||
}
|
||||
|
||||
const ReasoningTextPart: FC<{ text: string; status?: { type: string } }> = ({ text, status }) => {
|
||||
const displayText = text.trimStart()
|
||||
// Read the part from context, same contract as MarkdownText's
|
||||
// useMessagePartText — the reasoning-only smoothing wrapper (removed) stalled
|
||||
// the char-reveal at empty, blanking the widget.
|
||||
const ReasoningTextPart: ReasoningMessagePartComponent = () => {
|
||||
const { status, text } = useMessagePartReasoning()
|
||||
const messageRunning = useAuiState(s => s.message.status?.type === 'running')
|
||||
const isRunning = status?.type === 'running' || messageRunning
|
||||
|
||||
return (
|
||||
<MarkdownTextContent
|
||||
containerClassName="text-xs leading-snug text-muted-foreground/85"
|
||||
containerProps={{ 'data-slot': 'aui_reasoning-text' } as ComponentProps<'div'>}
|
||||
isRunning={isRunning}
|
||||
text={displayText}
|
||||
isRunning={status.type === 'running' || messageRunning}
|
||||
text={text.trimStart()}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue