@@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react' +import { useEffect, useRef, useState } from 'react' import DsMarkdown, { ConfigProvider } from 'ds-markdown' import { katexPlugin } from 'ds-markdown/plugins' @@ -13,29 +13,103 @@ interface DsMarkdownContentProps { const STREAMING_INTERVAL = 20 +function getNextRevealLength(currentLength: number, target: string): number { + if (currentLength >= target.length) { + return target.length + } + + const rest = target.slice(currentLength) + + if (typeof Intl !== 'undefined' && 'Segmenter' in Intl) { + const segmenter = new Intl.Segmenter(undefined, { granularity: 'grapheme' }) + const first = segmenter.segment(rest)[Symbol.iterator]().next().value + return currentLength + (first?.segment.length ?? 1) + } + + const chars = Array.from(rest) + return currentLength + (chars[0]?.length ?? 1) +} + +function getCommonPrefixLength(a: string, b: string): number { + const max = Math.min(a.length, b.length) + let index = 0 + while (index < max && a[index] === b[index]) { + index += 1 + } + return index +} + export function DsMarkdownContent({ content, streaming }: DsMarkdownContentProps) { - const [isTypingComplete, setIsTypingComplete] = useState(!streaming) - const typingEnabled = streaming || !isTypingComplete + const sessionRef = useRef(streaming) + const [displayed, setDisplayed] = useState(content) useEffect(() => { if (streaming) { - setIsTypingComplete(false) + sessionRef.current = true } }, [streaming]) + useEffect(() => { + if (content === displayed) { + if (!streaming) { + sessionRef.current = false + } + return + } + + // Outside a stream session — show full content immediately (history, static messages). + if (!sessionRef.current && !streaming) { + setDisplayed(content) + return + } + + if (content.startsWith(displayed)) { + return + } + + if (displayed.startsWith(content)) { + setDisplayed(content) + return + } + + // Content was rewritten (waiting → first token, done split, etc.) — keep progress. + setDisplayed(content.slice(0, getCommonPrefixLength(displayed, content))) + }, [content, displayed, streaming]) + + useEffect(() => { + if (!sessionRef.current && !streaming) { + return + } + + if (!content.startsWith(displayed) || displayed === content) { + return + } + + const timer = window.setTimeout(() => { + setDisplayed(content.slice(0, getNextRevealLength(displayed.length, content))) + }, STREAMING_INTERVAL) + + return () => window.clearTimeout(timer) + }, [content, displayed, streaming]) + + const animating = displayed !== content + return ( setIsTypingComplete(true)} > - {content} + {displayed} ) @@ -472,7 +472,16 @@ export function useChatModel( const { think, main } = splitFinalStreamContent(content, runtime.thinkContent) clearChatStreamSession(currentChat) - runtime.modelContent = main + + // Keep typewriter prefix continuity: ds-markdown resets and dumps the + // whole string when the new content is not a prefix of what was streamed. + const waitingContent = getWaitingForModelContent(modelType) + const prevMain = + runtime.modelContent && runtime.modelContent !== waitingContent ? runtime.modelContent : '' + const nextMain = + !prevMain || main === prevMain || main.startsWith(prevMain) ? main : prevMain + + runtime.modelContent = nextMain runtime.thinkContent = think setMessages((prev) => { @@ -483,7 +492,7 @@ export function useChatModel( const modelUid = findModelMessageUid(prev, inputUuid) return prev.map((message) => message.uid === modelUid - ? { ...message, content: main, think: think || undefined } + ? { ...message, content: nextMain, think: think || undefined } : message ) }) @@ -29,7 +29,7 @@ interface IMessagesList { paginationLoading?: boolean } -const STREAM_STICK_BOTTOM_THRESHOLD_PX = 200 +const STREAM_STICK_BOTTOM_THRESHOLD_PX = 100 export const ChatMessagesList: React.FC = memo( ({ messageResponse, onLoadImage, setResendValue, setQuoteValue, modelTitle, device, modelType, botParams, getMessagesPagination, deleteMessage, loading, paginationLoading = false }) => { @@ -54,6 +54,9 @@ export const ChatMessagesList: React.FC = memo( const initialScrollTimeoutRef = useRef | null>(null) const isRestoringScrollRef = useRef(false) const scrollBottomRef = useRef(0) + const stickToBottomRef = useRef(true) + const streamContentRef = useRef(null) + const streamStickObserverRef = useRef(null) const lastSeenLastUidRef = useRef(null) const getMessageTopInViewport = (block: HTMLDivElement, el: HTMLElement) => @@ -178,6 +181,7 @@ export const ChatMessagesList: React.FC = memo( return () => { prependRestoreObserverRef.current?.disconnect() initialScrollObserverRef.current?.disconnect() + streamStickObserverRef.current?.disconnect() if (prependRestoreTimeoutRef.current) { clearTimeout(prependRestoreTimeoutRef.current) } @@ -187,6 +191,43 @@ export const ChatMessagesList: React.FC = memo( } }, []) + // Typewriter grows DOM height without updating messageResponse, so stick-to-bottom + // must observe content size — not only React message snapshots. + useEffect(() => { + const block = paginationScroll.current + const contentEl = streamContentRef.current + const lastMessage = messageResponse?.[messageResponse.length - 1] + const isStreamMessage = + Boolean(lastMessage?.from_model) && Boolean(lastMessage?.uid.startsWith('streaming:')) + + streamStickObserverRef.current?.disconnect() + streamStickObserverRef.current = null + + if (!block || !contentEl || !isStreamMessage) { + return + } + + const stickToBottomIfNeeded = () => { + if (!stickToBottomRef.current) { + return + } + + block.scrollTop = block.scrollHeight + scrollBottomRef.current = 0 + } + + const observer = new ResizeObserver(stickToBottomIfNeeded) + streamStickObserverRef.current = observer + observer.observe(contentEl) + + return () => { + observer.disconnect() + if (streamStickObserverRef.current === observer) { + streamStickObserverRef.current = null + } + } + }, [messageResponse]) + useEffect(() => { if (!messageResponse?.length) { prevMessagesSnapshotRef.current = null @@ -220,6 +261,7 @@ export const ChatMessagesList: React.FC = memo( const prev = prevMessagesSnapshotRef.current if (!prev) { + stickToBottomRef.current = true scrollToBottom(block, true) prevMessagesSnapshotRef.current = snapshot return @@ -245,12 +287,14 @@ export const ChatMessagesList: React.FC = memo( if (prepended && scrollAnchorRef.current) { restorePrependScroll(block) } else if (appended) { + stickToBottomRef.current = true block.scrollTo({ top: block.scrollHeight, behavior: 'smooth', }) - } else if (streamingGrowth && scrollBottomRef.current < STREAM_STICK_BOTTOM_THRESHOLD_PX) { + } else if (streamingGrowth && stickToBottomRef.current) { block.scrollTop = block.scrollHeight + scrollBottomRef.current = 0 } prevMessagesSnapshotRef.current = snapshot @@ -277,6 +321,7 @@ export const ChatMessagesList: React.FC = memo( const distanceFromBottom = block.scrollHeight - block.scrollTop - block.clientHeight scrollBottomRef.current = distanceFromBottom + stickToBottomRef.current = distanceFromBottom < STREAM_STICK_BOTTOM_THRESHOLD_PX setScrollBottom(distanceFromBottom) if (messageResponse?.length && block.scrollTop === 0 && getMessagesPagination && !paginationLoading && !isRestoringScrollRef.current) { @@ -366,38 +411,40 @@ export const ChatMessagesList: React.FC = memo( )} - {messageResponse?.length === 0 && status === 'authenticated' ? ( - <>{desktop && modelType !== 'deepl' && } - ) : ( - messageResponse?.map((message, idx) => { - const isStreaming = - loading && - !paginationLoading && - message.from_model && - message.uid.startsWith('streaming:') && - idx === messageResponse.length - 1 - - return ( - - ) - }) - )} + + {messageResponse?.length === 0 && status === 'authenticated' ? ( + <>{desktop && modelType !== 'deepl' && } + ) : ( + messageResponse?.map((message, idx) => { + const isStreaming = + loading && + !paginationLoading && + message.from_model && + message.uid.startsWith('streaming:') && + idx === messageResponse.length - 1 + + return ( + + ) + }) + )} + )