mirror of
https://github.com/csunny/DB-GPT.git
synced 2025-09-02 17:45:31 +00:00
154 lines
5.4 KiB
TypeScript
154 lines
5.4 KiB
TypeScript
import ChatHeader from '@/new-components/chat/header/ChatHeader';
|
||
import { ChatContentContext } from '@/pages/chat';
|
||
import { VerticalAlignBottomOutlined, VerticalAlignTopOutlined } from '@ant-design/icons';
|
||
import dynamic from 'next/dynamic';
|
||
import React, { forwardRef, useCallback, useContext, useEffect, useImperativeHandle, useRef, useState } from 'react';
|
||
|
||
const ChatCompletion = dynamic(() => import('@/new-components/chat/content/ChatCompletion'), { ssr: false });
|
||
|
||
// eslint-disable-next-line no-empty-pattern
|
||
const ChatContentContainer = ({}, ref: React.ForwardedRef<any>) => {
|
||
const scrollRef = useRef<HTMLDivElement>(null);
|
||
const [isScrollToTop, setIsScrollToTop] = useState<boolean>(false);
|
||
const [showScrollButtons, setShowScrollButtons] = useState<boolean>(false);
|
||
const [isAtTop, setIsAtTop] = useState<boolean>(true);
|
||
const [isAtBottom, setIsAtBottom] = useState<boolean>(false);
|
||
const { history } = useContext(ChatContentContext);
|
||
const allowAutoScroll = useRef<boolean>(true);
|
||
|
||
useImperativeHandle(ref, () => {
|
||
return scrollRef.current;
|
||
});
|
||
|
||
const handleScroll = () => {
|
||
if (!scrollRef.current) return;
|
||
|
||
const container = scrollRef.current;
|
||
const scrollTop = container.scrollTop;
|
||
const scrollHeight = container.scrollHeight;
|
||
const clientHeight = container.clientHeight;
|
||
const buffer = 20;
|
||
|
||
// Check Scroll direction
|
||
const lastScrollTop = Number(container?.dataset?.lastScrollTop) || 0;
|
||
const direction = scrollTop > lastScrollTop ? 'down' : 'up';
|
||
container.dataset.lastScrollTop = String(scrollTop);
|
||
// only allow auto scroll when user is near bottom
|
||
allowAutoScroll.current = direction === 'down';
|
||
|
||
// Check if we're at the top
|
||
setIsAtTop(scrollTop <= buffer);
|
||
|
||
// Check if we're at the bottom
|
||
setIsAtBottom(scrollTop + clientHeight >= scrollHeight - buffer);
|
||
|
||
// Header visibility
|
||
if (scrollTop >= 42 + 32) {
|
||
setIsScrollToTop(true);
|
||
} else {
|
||
setIsScrollToTop(false);
|
||
}
|
||
|
||
// Show scroll buttons when content is scrollable
|
||
const isScrollable = scrollHeight > clientHeight;
|
||
setShowScrollButtons(isScrollable);
|
||
};
|
||
|
||
useEffect(() => {
|
||
if (scrollRef.current) {
|
||
scrollRef.current.addEventListener('scroll', handleScroll);
|
||
|
||
// Check initially if content is scrollable
|
||
const isScrollable = scrollRef.current.scrollHeight > scrollRef.current.clientHeight;
|
||
setShowScrollButtons(isScrollable);
|
||
}
|
||
|
||
return () => {
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
scrollRef.current && scrollRef.current.removeEventListener('scroll', handleScroll);
|
||
};
|
||
}, []);
|
||
|
||
const scrollToBottomSmooth = useCallback(() => {
|
||
if (!scrollRef.current || !allowAutoScroll.current) return;
|
||
|
||
const container = scrollRef.current;
|
||
const { scrollTop, scrollHeight, clientHeight } = container;
|
||
|
||
// 只有当用户接近底部时才自动滚动
|
||
const buffer = Math.max(50, clientHeight * 0.1);
|
||
const isNearBottom = scrollTop + clientHeight >= scrollHeight - buffer;
|
||
|
||
if (!isNearBottom) {
|
||
return;
|
||
}
|
||
// use requestAnimationFrame to smooth scroll
|
||
const frameId = requestAnimationFrame(() => {
|
||
// 直接设置scrollTop来实现快速滚动,不使用平滑滚动以避免卡顿
|
||
// container.scrollTop = container.scrollHeight;
|
||
container.scrollTo({
|
||
top: container.scrollHeight,
|
||
behavior: 'auto',
|
||
});
|
||
});
|
||
return () => cancelAnimationFrame(frameId);
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
// 监听 history 变化和最后一条消息的 context 变化
|
||
scrollToBottomSmooth();
|
||
}, [history, history[history.length - 1]?.context]);
|
||
|
||
const scrollToTop = () => {
|
||
if (scrollRef.current) {
|
||
scrollRef.current.scrollTo({
|
||
top: 0,
|
||
behavior: 'smooth',
|
||
});
|
||
}
|
||
};
|
||
|
||
const scrollToBottom = () => {
|
||
if (scrollRef.current) {
|
||
scrollRef.current.scrollTo({
|
||
top: scrollRef.current.scrollHeight,
|
||
behavior: 'smooth',
|
||
});
|
||
}
|
||
};
|
||
|
||
return (
|
||
<div className='flex flex-1 overflow-hidden relative'>
|
||
<div ref={scrollRef} className='h-full w-full mx-auto overflow-y-auto'>
|
||
<ChatHeader isScrollToTop={isScrollToTop} />
|
||
<ChatCompletion />
|
||
</div>
|
||
|
||
{showScrollButtons && (
|
||
<div className='absolute right-6 bottom-24 flex flex-col gap-2'>
|
||
{!isAtTop && (
|
||
<button
|
||
onClick={scrollToTop}
|
||
className='w-10 h-10 bg-white dark:bg-[rgba(255,255,255,0.2)] border border-gray-200 dark:border-[rgba(255,255,255,0.2)] rounded-full flex items-center justify-center shadow-md hover:shadow-lg transition-shadow'
|
||
aria-label='Scroll to top'
|
||
>
|
||
<VerticalAlignTopOutlined className='text-[#525964] dark:text-[rgba(255,255,255,0.85)]' />
|
||
</button>
|
||
)}
|
||
{!isAtBottom && (
|
||
<button
|
||
onClick={scrollToBottom}
|
||
className='w-10 h-10 bg-white dark:bg-[rgba(255,255,255,0.2)] border border-gray-200 dark:border-[rgba(255,255,255,0.2)] rounded-full flex items-center justify-center shadow-md hover:shadow-lg transition-shadow'
|
||
aria-label='Scroll to bottom'
|
||
>
|
||
<VerticalAlignBottomOutlined className='text-[#525964] dark:text-[rgba(255,255,255,0.85)]' />
|
||
</button>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default forwardRef(ChatContentContainer);
|