@@ -62,6 +62,7 @@ import AttachmentIcon from "../icons/paperclip.svg";
6262import ToolboxIcon from "../icons/toolbox.svg" ;
6363import EraserIcon from "../icons/eraser.svg" ;
6464import DualModelIcon from "../icons/dual-model.svg" ;
65+ import ConfigIcon from "../icons/config.svg" ;
6566
6667import {
6768 ChatMessage ,
@@ -1902,6 +1903,14 @@ function DualModelView(props: {
19021903 const secondaryVirtuosoRef = useRef < VirtuosoHandle > ( null ) ;
19031904 const [ primaryHitBottom , setPrimaryHitBottom ] = useState ( true ) ;
19041905 const [ secondaryHitBottom , setSecondaryHitBottom ] = useState ( true ) ;
1906+ const [ primaryVisibleRange , setPrimaryVisibleRange ] = useState < {
1907+ startIndex : number ;
1908+ endIndex : number ;
1909+ } | null > ( null ) ;
1910+ const [ secondaryVisibleRange , setSecondaryVisibleRange ] = useState < {
1911+ startIndex : number ;
1912+ endIndex : number ;
1913+ } | null > ( null ) ;
19051914
19061915 // 合并 context 和消息
19071916 const primaryRenderMessages : RenderMessageType [ ] = useMemo ( ( ) => {
@@ -1976,6 +1985,26 @@ function DualModelView(props: {
19761985 props . onScrollBothToBottom ?.( scrollBothToBottom ) ;
19771986 } , [ scrollBothToBottom , props . onScrollBothToBottom ] ) ;
19781987
1988+ // 计算当前可视范围对应的用户消息索引
1989+ const getCurrentUserIndex = useCallback (
1990+ (
1991+ visibleRange : { startIndex : number ; endIndex : number } | null ,
1992+ msgs : RenderMessageType [ ] ,
1993+ ) => {
1994+ if ( ! visibleRange ) return null ;
1995+ const midIndex = Math . floor (
1996+ ( visibleRange . startIndex + visibleRange . endIndex ) / 2 ,
1997+ ) ;
1998+ for ( let i = midIndex ; i >= 0 ; i -- ) {
1999+ if ( msgs [ i ] ?. role === "user" ) {
2000+ return i ;
2001+ }
2002+ }
2003+ return null ;
2004+ } ,
2005+ [ ] ,
2006+ ) ;
2007+
19792008 return (
19802009 < div className = { styles [ "dual-model-container" ] } >
19812010 { /* 主模型面板 */ }
@@ -2010,6 +2039,7 @@ function DualModelView(props: {
20102039 atBottomThreshold = { 64 }
20112040 increaseViewportBy = { { top : 400 , bottom : 800 } }
20122041 computeItemKey = { ( index , m ) => `primary-${ m . id || index } ` }
2042+ rangeChanged = { ( range ) => setPrimaryVisibleRange ( range ) }
20132043 itemContent = { ( index , message ) =>
20142044 props . renderMessage ( message , index , false )
20152045 }
@@ -2022,6 +2052,21 @@ function DualModelView(props: {
20222052 < BottomIcon />
20232053 </ div >
20242054 ) }
2055+ < ChatNavigator
2056+ messages = { primaryRenderMessages }
2057+ currentIndex = { getCurrentUserIndex (
2058+ primaryVisibleRange ,
2059+ primaryRenderMessages ,
2060+ ) }
2061+ onJumpTo = { ( index ) => {
2062+ primaryVirtuosoRef . current ?. scrollToIndex ( {
2063+ index,
2064+ align : "start" ,
2065+ behavior : "auto" ,
2066+ } ) ;
2067+ } }
2068+ inPanel
2069+ />
20252070 </ div >
20262071 </ div >
20272072
@@ -2060,6 +2105,7 @@ function DualModelView(props: {
20602105 atBottomThreshold = { 64 }
20612106 increaseViewportBy = { { top : 400 , bottom : 800 } }
20622107 computeItemKey = { ( index , m ) => `secondary-${ m . id || index } ` }
2108+ rangeChanged = { ( range ) => setSecondaryVisibleRange ( range ) }
20632109 itemContent = { ( index , message ) =>
20642110 props . renderMessage ( message , index , true )
20652111 }
@@ -2072,6 +2118,100 @@ function DualModelView(props: {
20722118 < BottomIcon />
20732119 </ div >
20742120 ) }
2121+ < ChatNavigator
2122+ messages = { secondaryRenderMessages }
2123+ currentIndex = { getCurrentUserIndex (
2124+ secondaryVisibleRange ,
2125+ secondaryRenderMessages ,
2126+ ) }
2127+ onJumpTo = { ( index ) => {
2128+ secondaryVirtuosoRef . current ?. scrollToIndex ( {
2129+ index,
2130+ align : "start" ,
2131+ behavior : "auto" ,
2132+ } ) ;
2133+ } }
2134+ inPanel
2135+ />
2136+ </ div >
2137+ </ div >
2138+ </ div >
2139+ ) ;
2140+ }
2141+
2142+ // 对话缩略导航组件
2143+ function ChatNavigator ( props : {
2144+ messages : ChatMessage [ ] ;
2145+ currentIndex : number | null ;
2146+ onJumpTo : ( index : number ) => void ;
2147+ inPanel ?: boolean ; // 是否在双模型 panel 内
2148+ } ) {
2149+ const PREVIEW_LENGTH = 20 ;
2150+ const listRef = useRef < HTMLDivElement > ( null ) ;
2151+ const activeItemRef = useRef < HTMLDivElement > ( null ) ;
2152+
2153+ // 过滤用户消息并生成缩略列表
2154+ const userMessages = useMemo ( ( ) => {
2155+ return props . messages
2156+ . map ( ( msg , index ) => ( {
2157+ id : msg . id ,
2158+ index,
2159+ preview : getMessageTextContent ( msg ) . slice ( 0 , PREVIEW_LENGTH ) ,
2160+ role : msg . role ,
2161+ } ) )
2162+ . filter ( ( msg ) => msg . role === "user" ) ;
2163+ } , [ props . messages ] ) ;
2164+
2165+ // 当 hover 面板时,滚动到当前高亮项
2166+ const scrollToActiveItem = useCallback ( ( ) => {
2167+ if ( activeItemRef . current && listRef . current ) {
2168+ activeItemRef . current . scrollIntoView ( {
2169+ block : "center" ,
2170+ behavior : "auto" ,
2171+ } ) ;
2172+ }
2173+ } , [ ] ) ;
2174+
2175+ return (
2176+ < div
2177+ className = { clsx (
2178+ styles [ "chat-navigator" ] ,
2179+ props . inPanel && styles [ "chat-navigator-in-panel" ] ,
2180+ ) }
2181+ onMouseEnter = { scrollToActiveItem }
2182+ >
2183+ < div className = { styles [ "chat-navigator-toggle" ] } >
2184+ < ConfigIcon />
2185+ </ div >
2186+ < div className = { styles [ "chat-navigator-panel" ] } >
2187+ < div className = { styles [ "chat-navigator-header" ] } >
2188+ { Locale . Chat . Navigator . Title }
2189+ </ div >
2190+ < div className = { styles [ "chat-navigator-list" ] } ref = { listRef } >
2191+ { userMessages . length === 0 ? (
2192+ < div className = { styles [ "chat-navigator-empty" ] } >
2193+ { Locale . Chat . Navigator . Empty }
2194+ </ div >
2195+ ) : (
2196+ userMessages . map ( ( item ) => {
2197+ const isActive = props . currentIndex === item . index ;
2198+ return (
2199+ < div
2200+ key = { item . id }
2201+ ref = { isActive ? activeItemRef : null }
2202+ className = { clsx (
2203+ styles [ "chat-navigator-item" ] ,
2204+ isActive && styles [ "chat-navigator-item-active" ] ,
2205+ ) }
2206+ onClick = { ( ) => props . onJumpTo ( item . index ) }
2207+ >
2208+ < div className = { styles [ "chat-navigator-item-preview" ] } >
2209+ { item . preview || "(空消息)" }
2210+ </ div >
2211+ </ div >
2212+ ) ;
2213+ } )
2214+ ) }
20752215 </ div >
20762216 </ div >
20772217 </ div >
@@ -3453,6 +3593,10 @@ function ChatComponent() {
34533593 ? Math . max ( 0 , Math . min ( location . state . jumpToIndex , messages . length - 1 ) )
34543594 : undefined ;
34553595 const [ highlightIndex , setHighlightIndex ] = useState < number | null > ( null ) ;
3596+ const [ visibleRange , setVisibleRange ] = useState < {
3597+ startIndex : number ;
3598+ endIndex : number ;
3599+ } | null > ( null ) ;
34563600
34573601 // 跳转到引用的消息并高亮具体文本
34583602 const scrollToQuotedMessage = useCallback (
@@ -4976,6 +5120,7 @@ function ChatComponent() {
49765120 atBottomThreshold = { 64 }
49775121 increaseViewportBy = { { top : 400 , bottom : 800 } }
49785122 computeItemKey = { ( index , m ) => m . id }
5123+ rangeChanged = { ( range ) => setVisibleRange ( range ) }
49795124 itemContent = { ( index , message ) => {
49805125 const i = index ;
49815126 const isUser = message . role === "user" ;
@@ -5348,6 +5493,39 @@ function ChatComponent() {
53485493 } }
53495494 />
53505495 ) }
5496+
5497+ { /* 对话缩略导航 - 仅在非双模型模式下显示 */ }
5498+ { ! isDualMode && (
5499+ < ChatNavigator
5500+ messages = { messages }
5501+ currentIndex = {
5502+ // 计算当前可视范围中心的消息,如果是 assistant 则归属到其对应的 user
5503+ visibleRange
5504+ ? ( ( ) => {
5505+ const midIndex = Math . floor (
5506+ ( visibleRange . startIndex + visibleRange . endIndex ) / 2 ,
5507+ ) ;
5508+ // 从中间位置向前找到最近的 user 消息
5509+ for ( let i = midIndex ; i >= 0 ; i -- ) {
5510+ if ( messages [ i ] ?. role === "user" ) {
5511+ return i ;
5512+ }
5513+ }
5514+ return null ;
5515+ } ) ( )
5516+ : null
5517+ }
5518+ onJumpTo = { ( index ) => {
5519+ virtuosoRef . current ?. scrollToIndex ( {
5520+ index,
5521+ align : "start" ,
5522+ behavior : "auto" ,
5523+ } ) ;
5524+ setHighlightIndex ( index ) ;
5525+ setTimeout ( ( ) => setHighlightIndex ( null ) , 3000 ) ;
5526+ } }
5527+ />
5528+ ) }
53515529 </ div >
53525530 < div className = { styles [ "chat-input-panel" ] } >
53535531 < PromptHints prompts = { promptHints } onPromptSelect = { onPromptSelect } />
0 commit comments