@@ -151,28 +151,24 @@ const AudioModelPage: NextPageWithLayout = () => { type={'Аудио'} linkBack={'/audio'} /> -
- {botParams && - botParams.tags.map((tag, index) => ( -
- + {desktop && ( +
+ {botParams && + botParams.tags.map((tag, index) => ( +
+ - {tag.title} -
- ))} -
+ {tag.title} +
+ ))} +
+ )} {desktop && ( )} - {!desktop && ( - - - - )} - { type={'Изображения'} linkBack={'/images'} /> -
- {botParams && - botParams.tags.map((tag, index) => ( -
- + {desktop && ( +
+ {botParams && + botParams.tags.map((tag, index) => ( +
+ - {tag.title} -
- ))} -
+ {tag.title} +
+ ))} +
+ )} {desktop && ( )} - {!desktop && ( - - - - )} { type={'Видео'} linkBack={'/videos'} /> -
- {botParams && - botParams.tags.map((tag, index) => ( -
- + {desktop && ( +
+ {botParams && + botParams.tags.map((tag, index) => ( +
+ - {tag.title} -
- ))} -
+ {tag.title} +
+ ))} +
+ )} {desktop && ( )} - {!desktop && ( - - - - )} - { type={'Клонирование голоса'} linkBack={'/voice-cloning'} /> -
- {botParams && - botParams.tags.map((tag, index) => ( -
- + {desktop && ( +
+ {botParams && + botParams.tags.map((tag, index) => ( +
+ - {tag.title} -
- ))} -
+ {tag.title} +
+ ))} +
+ )} {desktop && ( @@ -256,14 +258,6 @@ const VoiceCloningModelPage: NextPageWithLayout = () => { )} - {!desktop && ( - - - - - - )} - = 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 sessionRef = useRef(streaming) + const [displayed, setDisplayed] = useState(content) + + useEffect(() => { + if (streaming) { + 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 ( - {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,11 +29,18 @@ interface IMessagesList { paginationLoading?: boolean } -const STREAM_STICK_BOTTOM_THRESHOLD_PX = 200 +const STREAM_STICK_BOTTOM_THRESHOLD_PX = 100 +const GENERATION_SPACER_TOP_OFFSET_PX = 16 export const ChatMessagesList: React.FC = memo( ({ messageResponse, onLoadImage, setResendValue, setQuoteValue, modelTitle, device, modelType, botParams, getMessagesPagination, deleteMessage, loading, paginationLoading = false }) => { const paginationScroll = React.useRef(null) + const messagesContentRef = React.useRef(null) + const generationSpacerRef = React.useRef(null) + const generationSpacerHeightRef = useRef(0) + const generationObserverRef = useRef(null) + const messageResponseRef = useRef(messageResponse) + messageResponseRef.current = messageResponse const [scrollBottom, setScrollBottom] = React.useState(0) const prevMessagesSnapshotRef = useRef<{ length: number @@ -54,11 +61,66 @@ export const ChatMessagesList: React.FC = memo( const initialScrollTimeoutRef = useRef | null>(null) const isRestoringScrollRef = useRef(false) const scrollBottomRef = useRef(0) + const stickToBottomRef = useRef(true) + const streamStickObserverRef = useRef(null) const lastSeenLastUidRef = useRef(null) + const isGenerating = loading && !paginationLoading const getMessageTopInViewport = (block: HTMLDivElement, el: HTMLElement) => el.getBoundingClientRect().top - block.getBoundingClientRect().top + const setGenerationSpacerHeight = (height: number) => { + const next = Math.max(0, Math.round(height)) + generationSpacerHeightRef.current = next + if (generationSpacerRef.current) { + generationSpacerRef.current.style.height = `${next}px` + } + } + + const computeGenerationSpacerHeight = (block: HTMLDivElement) => { + const messages = messageResponseRef.current + if (!messages?.length) { + return 0 + } + + let lastUserUid: string | undefined + for (let i = messages.length - 1; i >= 0; i -= 1) { + if (!messages[i].from_model) { + lastUserUid = messages[i].uid + break + } + } + + if (!lastUserUid) { + return 0 + } + + const lastUserEl = block.querySelector( + `[data-message-uid="${CSS.escape(lastUserUid)}"]` + ) as HTMLElement | null + const lastUid = messages[messages.length - 1]?.uid + const lastMsgEl = lastUid + ? (block.querySelector(`[data-message-uid="${CSS.escape(lastUid)}"]`) as HTMLElement | null) + : null + + if (!lastUserEl || !lastMsgEl) { + return Math.max(0, block.clientHeight - GENERATION_SPACER_TOP_OFFSET_PX) + } + + const containerRect = block.getBoundingClientRect() + const userMsgScrollTop = + lastUserEl.getBoundingClientRect().top - containerRect.top + block.scrollTop + const lastMsgScrollBottom = + lastMsgEl.getBoundingClientRect().bottom - containerRect.top + block.scrollTop + const turnHeight = Math.max(0, lastMsgScrollBottom - userMsgScrollTop) + + return Math.max(0, block.clientHeight - turnHeight - GENERATION_SPACER_TOP_OFFSET_PX) + } + + const applyGenerationSpacer = (block: HTMLDivElement) => { + setGenerationSpacerHeight(computeGenerationSpacerHeight(block)) + } + const captureScrollAnchor = (block: HTMLDivElement) => { const anchorUid = messageResponse?.[0]?.uid if (!anchorUid) { @@ -178,6 +240,8 @@ export const ChatMessagesList: React.FC = memo( return () => { prependRestoreObserverRef.current?.disconnect() initialScrollObserverRef.current?.disconnect() + streamStickObserverRef.current?.disconnect() + generationObserverRef.current?.disconnect() if (prependRestoreTimeoutRef.current) { clearTimeout(prependRestoreTimeoutRef.current) } @@ -187,11 +251,53 @@ 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 = messagesContentRef.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 + } + + applyGenerationSpacer(block) + // While the spacer absorbs growth, scrollHeight stays stable; only stick when it collapses. + if (generationSpacerHeightRef.current === 0) { + 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 lastSeenLastUidRef.current = null scrollAnchorRef.current = null + setGenerationSpacerHeight(0) initialScrollObserverRef.current?.disconnect() if (initialScrollTimeoutRef.current) { clearTimeout(initialScrollTimeoutRef.current) @@ -199,6 +305,39 @@ export const ChatMessagesList: React.FC = memo( } }, [messageResponse]) + useLayoutEffect(() => { + const block = paginationScroll.current + if (!block) { + return + } + + if (!isGenerating) { + generationObserverRef.current?.disconnect() + generationObserverRef.current = null + setGenerationSpacerHeight(0) + return + } + + const syncSpacer = () => { + applyGenerationSpacer(block) + } + + syncSpacer() + + generationObserverRef.current?.disconnect() + const content = messagesContentRef.current + if (content) { + const observer = new ResizeObserver(syncSpacer) + generationObserverRef.current = observer + observer.observe(content) + } + + return () => { + generationObserverRef.current?.disconnect() + generationObserverRef.current = null + } + }, [isGenerating]) + useLayoutEffect(() => { if (!messageResponse?.length) { return @@ -220,7 +359,16 @@ export const ChatMessagesList: React.FC = memo( const prev = prevMessagesSnapshotRef.current if (!prev) { - scrollToBottom(block, true) + stickToBottomRef.current = true + if (isGenerating) { + applyGenerationSpacer(block) + block.scrollTo({ + top: block.scrollHeight, + behavior: 'smooth', + }) + } else { + scrollToBottom(block, true) + } prevMessagesSnapshotRef.current = snapshot return } @@ -245,16 +393,25 @@ export const ChatMessagesList: React.FC = memo( if (prepended && scrollAnchorRef.current) { restorePrependScroll(block) } else if (appended) { + stickToBottomRef.current = true + if (isGenerating) { + applyGenerationSpacer(block) + } block.scrollTo({ top: block.scrollHeight, behavior: 'smooth', }) - } else if (streamingGrowth && scrollBottomRef.current < STREAM_STICK_BOTTOM_THRESHOLD_PX) { - block.scrollTop = block.scrollHeight + } else if (streamingGrowth && stickToBottomRef.current) { + applyGenerationSpacer(block) + // While the spacer absorbs growth, scrollHeight stays stable; only stick when it collapses. + if (generationSpacerHeightRef.current === 0) { + block.scrollTop = block.scrollHeight + scrollBottomRef.current = 0 + } } prevMessagesSnapshotRef.current = snapshot - }, [messageResponse]) + }, [messageResponse, isGenerating]) useEffect(() => { const lastUid = messageResponse?.[messageResponse.length - 1]?.uid @@ -277,6 +434,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) { @@ -334,6 +492,10 @@ export const ChatMessagesList: React.FC = memo( return } + if (isGenerating) { + applyGenerationSpacer(block) + } + block.scrollTo({ top: block.scrollHeight, behavior: 'smooth', @@ -366,38 +528,51 @@ 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 ( + + ) + }) + )} + + + ) @@ -68,9 +68,7 @@ const Page: NextPageWithLayout = () => { const deleteMessageMemo = useCallback(deleteMessage, [currentChat, messages]) - const chatWindowHeight = desktop - ? '75vh' - : `calc(100dvh - ${(botParams?.tags ?? []).length === 0 ? '200px' : '285px'})` + const chatWindowHeight = desktop ? '75vh' : 'calc(100dvh - 200px)' const isBotReady = botParams !== null && botParams.slug === routeSlug && modelType === routeSlug const isContentLoading = !isBotReady || chatsLoading || messages === null @@ -256,15 +254,17 @@ const Page: NextPageWithLayout = () => {
- <div className={styles.tags}> - {botParams && - botParams.tags.map((tag, index) => ( - <div key={index} className={styles.tag}> - <SvgIcon width={23} height={23} url={tag.icon} className={styles.tag__icon} /> - <span className={styles.tag__text}>{tag.title}</span> - </div> - ))} - </div> + {desktop && ( + <div className={styles.tags}> + {botParams && + botParams.tags.map((tag, index) => ( + <div key={index} className={styles.tag}> + <SvgIcon width={23} height={23} url={tag.icon} className={styles.tag__icon} /> + <span className={styles.tag__text}>{tag.title}</span> + </div> + ))} + </div> + )} {desktop && <ChatsContainer desktop={desktop} modelType={modelType} />} {desktop && ( @@ -282,12 +282,6 @@ const Page: NextPageWithLayout<ChatBotPageProps> = () => { </Stack> )} </div> - {!desktop && ( - <Stack sx={{ display: 'flex', alignItems: 'center', width: '100%', marginBottom: '15px', marginTop: '10px' }}> - <StaticTabs scope={scope} setScope={setScope} /> - </Stack> - )} - {!desktop && <ChatsContainer desktop={desktop} modelType={modelType} />} <Box sx={{ height: 'calc(100% - 100px)' }} className={styles.main}> <Box className={styles.chatWindow}> @@ -332,7 +326,7 @@ const Page: NextPageWithLayout<ChatBotPageProps> = () => { modelType={modelType} modelTitle={botParams?.title} currentVersion={version} - tags={botParams?.tags ?? []} + tags={desktop ? botParams?.tags ?? [] : []} inputValue={inputContent} onInputValueChange={setInputContent} predictedPrice={predictedPrice} @@ -351,9 +345,7 @@ const Page: NextPageWithLayout<ChatBotPageProps> = () => { padding: desktop ? (botParams?.blocked ? 2 : 2) : 1.25, paddingBlock: 1.5, width: '100%', - height: desktop - ? '75vh' - : `calc(100dvh - ${botParams?.tags && botParams?.tags.length == 0 ? '200px' : '240px'})`, + height: desktop ? '75vh' : 'calc(100dvh - 200px)', overflowY: 'auto', }} > @@ -65,7 +65,6 @@ function Chat<T>({ blocked, currentVersion, deviceOs, - tags, inputValue, onInputValueChange, predictedPrice, @@ -118,7 +117,7 @@ function Chat<T>({ paddingTop: 0, paddingBottom: 1.25, width: '100%', - height: desktop ? '75vh' : `calc(100dvh - ${tags.length == 0 ? '200px' : '285px'})`, + height: desktop ? '75vh' : 'calc(100dvh - 200px)', overflow: 'hidden', }} >