@@ -1997,6 +1997,9 @@ function DualModelView(props: {
19971997 const secondaryVirtuosoRef = useRef < VirtuosoHandle > ( null ) ;
19981998 const [ primaryHitBottom , setPrimaryHitBottom ] = useState ( true ) ;
19991999 const [ secondaryHitBottom , setSecondaryHitBottom ] = useState ( true ) ;
2000+ // 使用 ref 跟踪是否应该自动滚动,避免被 atBottomStateChange 覆盖
2001+ const primaryAutoScrollRef = useRef ( true ) ;
2002+ const secondaryAutoScrollRef = useRef ( true ) ;
20002003 const [ primaryVisibleRange , setPrimaryVisibleRange ] = useState < {
20012004 startIndex : number ;
20022005 endIndex : number ;
@@ -2045,6 +2048,8 @@ function DualModelView(props: {
20452048
20462049 const scrollPrimaryToBottom = useCallback (
20472050 ( instant ?: boolean ) => {
2051+ setPrimaryHitBottom ( true ) ;
2052+ primaryAutoScrollRef . current = true ;
20482053 primaryVirtuosoRef . current ?. scrollToIndex ( {
20492054 index : primaryRenderMessages . length - 1 ,
20502055 behavior : instant ? "auto" : "smooth" ,
@@ -2056,6 +2061,8 @@ function DualModelView(props: {
20562061
20572062 const scrollSecondaryToBottom = useCallback (
20582063 ( instant ?: boolean ) => {
2064+ setSecondaryHitBottom ( true ) ;
2065+ secondaryAutoScrollRef . current = true ;
20592066 secondaryVirtuosoRef . current ?. scrollToIndex ( {
20602067 index : secondaryRenderMessages . length - 1 ,
20612068 behavior : instant ? "auto" : "smooth" ,
@@ -2079,6 +2086,61 @@ function DualModelView(props: {
20792086 props . onScrollBothToBottom ?.( scrollBothToBottom ) ;
20802087 } , [ scrollBothToBottom , props . onScrollBothToBottom ] ) ;
20812088
2089+ // 流式输出时自动滚动:followOutput 只在新消息添加时触发,
2090+ // 但不会在现有消息高度变化时自动滚动,需要手动处理
2091+ const primaryLastMessage =
2092+ primaryRenderMessages [ primaryRenderMessages . length - 1 ] ;
2093+ const secondaryLastMessage =
2094+ secondaryRenderMessages [ secondaryRenderMessages . length - 1 ] ;
2095+ const primaryIsStreaming =
2096+ primaryLastMessage ?. streaming || primaryLastMessage ?. preview ;
2097+ const secondaryIsStreaming =
2098+ secondaryLastMessage ?. streaming || secondaryLastMessage ?. preview ;
2099+ const primaryStreamingContent =
2100+ primaryIsStreaming && primaryLastMessage
2101+ ? getMessageTextContent ( primaryLastMessage )
2102+ : "" ;
2103+ const secondaryStreamingContent =
2104+ secondaryIsStreaming && secondaryLastMessage
2105+ ? getMessageTextContent ( secondaryLastMessage )
2106+ : "" ;
2107+
2108+ useEffect ( ( ) => {
2109+ if ( ! primaryAutoScrollRef . current && ! primaryHitBottom ) return ;
2110+ if ( ! primaryIsStreaming ) return ;
2111+ const id = requestAnimationFrame ( ( ) => {
2112+ primaryVirtuosoRef . current ?. scrollToIndex ( {
2113+ index : primaryRenderMessages . length - 1 ,
2114+ align : "end" ,
2115+ behavior : "auto" ,
2116+ } ) ;
2117+ } ) ;
2118+ return ( ) => cancelAnimationFrame ( id ) ;
2119+ } , [
2120+ primaryHitBottom ,
2121+ primaryIsStreaming ,
2122+ primaryStreamingContent ,
2123+ primaryRenderMessages . length ,
2124+ ] ) ;
2125+
2126+ useEffect ( ( ) => {
2127+ if ( ! secondaryAutoScrollRef . current && ! secondaryHitBottom ) return ;
2128+ if ( ! secondaryIsStreaming ) return ;
2129+ const id = requestAnimationFrame ( ( ) => {
2130+ secondaryVirtuosoRef . current ?. scrollToIndex ( {
2131+ index : secondaryRenderMessages . length - 1 ,
2132+ align : "end" ,
2133+ behavior : "auto" ,
2134+ } ) ;
2135+ } ) ;
2136+ return ( ) => cancelAnimationFrame ( id ) ;
2137+ } , [
2138+ secondaryHitBottom ,
2139+ secondaryIsStreaming ,
2140+ secondaryStreamingContent ,
2141+ secondaryRenderMessages . length ,
2142+ ] ) ;
2143+
20822144 // 计算当前可视范围对应的用户消息索引
20832145 const getCurrentUserIndex = useCallback (
20842146 (
@@ -2131,7 +2193,10 @@ function DualModelView(props: {
21312193 style = { { height : "100%" } }
21322194 data = { primaryRenderMessages }
21332195 followOutput = { primaryHitBottom ? "smooth" : false }
2134- atBottomStateChange = { setPrimaryHitBottom }
2196+ atBottomStateChange = { ( atBottom ) => {
2197+ setPrimaryHitBottom ( atBottom ) ;
2198+ primaryAutoScrollRef . current = atBottom ;
2199+ } }
21352200 atBottomThreshold = { 64 }
21362201 increaseViewportBy = { { top : 400 , bottom : 800 } }
21372202 computeItemKey = { ( index , m ) => `primary-${ m . id || index } ` }
@@ -2199,7 +2264,10 @@ function DualModelView(props: {
21992264 style = { { height : "100%" } }
22002265 data = { secondaryRenderMessages }
22012266 followOutput = { secondaryHitBottom ? "smooth" : false }
2202- atBottomStateChange = { setSecondaryHitBottom }
2267+ atBottomStateChange = { ( atBottom ) => {
2268+ setSecondaryHitBottom ( atBottom ) ;
2269+ secondaryAutoScrollRef . current = atBottom ;
2270+ } }
22032271 atBottomThreshold = { 64 }
22042272 increaseViewportBy = { { top : 400 , bottom : 800 } }
22052273 computeItemKey = { ( index , m ) => `secondary-${ m . id || index } ` }
@@ -2894,6 +2962,10 @@ function ChatComponent() {
28942962 if ( ! isMobileScreen ) inputRef . current ?. focus ( ) ;
28952963 // setAutoScroll(true);
28962964 scrollToBottom ( ) ;
2965+ // 双模型模式下同时滚动两个面板
2966+ if ( isDualMode ) {
2967+ dualModelScrollToBottomRef . current ?.( true ) ;
2968+ }
28972969 setLastExpansion ( null ) ;
28982970 } ;
28992971
@@ -3660,6 +3732,9 @@ function ChatComponent() {
36603732 const v = virtuosoRef . current ;
36613733 if ( ! v ) return ;
36623734
3735+ // 显式滚动到底部时,启用自动跟随
3736+ setHitBottom ( true ) ;
3737+
36633738 const behavior : ScrollBehavior = instant ? "auto" : "smooth" ;
36643739
36653740 // Footer 内有预览,或者强制要求到底,就直接滚到最底(含 Footer)
@@ -3693,6 +3768,27 @@ function ChatComponent() {
36933768 hitBottom ,
36943769 ] ) ;
36953770
3771+ // 流式输出时自动滚动:followOutput 只在新消息添加时触发,
3772+ // 但不会在现有消息高度变化时自动滚动,需要手动处理
3773+ const lastMessage = messages [ messages . length - 1 ] ;
3774+ const isStreaming = lastMessage ?. streaming || lastMessage ?. preview ;
3775+ const streamingContent =
3776+ isStreaming && lastMessage ? getMessageTextContent ( lastMessage ) : "" ;
3777+
3778+ useEffect ( ( ) => {
3779+ if ( ! hitBottom ) return ;
3780+ if ( ! isStreaming ) return ;
3781+ // 流式输出时滚动到底部
3782+ const id = requestAnimationFrame ( ( ) => {
3783+ virtuosoRef . current ?. scrollToIndex ( {
3784+ index : messages . length - 1 ,
3785+ align : "end" ,
3786+ behavior : "auto" ,
3787+ } ) ;
3788+ } ) ;
3789+ return ( ) => cancelAnimationFrame ( id ) ;
3790+ } , [ hitBottom , isStreaming , streamingContent , messages . length ] ) ;
3791+
36963792 const location = useLocation ( ) as { state ?: { jumpToIndex ?: number } } ;
36973793 const jumpToIndex =
36983794 typeof location . state ?. jumpToIndex === "number"
0 commit comments