feat: Add chat navigation button
This commit is contained in:
parent
2d2a9ea299
commit
a090984c67
@ -170,7 +170,13 @@
|
||||
"topics.prompt.tips": "Topic Prompts: Additional supplementary prompts provided for the current topic",
|
||||
"topics.title": "Topics",
|
||||
"topics.unpinned": "Unpinned Topics",
|
||||
"translate": "Translate"
|
||||
"translate": "Translate",
|
||||
"navigation": {
|
||||
"prev": "Previous Message",
|
||||
"next": "Next Message",
|
||||
"first": "Already at the first message",
|
||||
"last": "Already at the last message"
|
||||
}
|
||||
},
|
||||
"code_block": {
|
||||
"collapse": "Collapse",
|
||||
|
||||
@ -170,7 +170,13 @@
|
||||
"topics.prompt.tips": "トピック提示語:現在のトピックに対して追加の補足提示語を提供",
|
||||
"topics.title": "トピック",
|
||||
"topics.unpinned": "固定解除",
|
||||
"translate": "翻訳"
|
||||
"translate": "翻訳",
|
||||
"navigation": {
|
||||
"prev": "前のメッセージ",
|
||||
"next": "次のメッセージ",
|
||||
"first": "最初のメッセージです",
|
||||
"last": "最後のメッセージです"
|
||||
}
|
||||
},
|
||||
"code_block": {
|
||||
"collapse": "折りたたむ",
|
||||
|
||||
@ -170,7 +170,13 @@
|
||||
"topics.prompt.tips": "Тематические подсказки: Дополнительные подсказки, предоставленные для текущей темы",
|
||||
"topics.title": "Топики",
|
||||
"topics.unpinned": "Открепленные темы",
|
||||
"translate": "Перевести"
|
||||
"translate": "Перевести",
|
||||
"navigation": {
|
||||
"prev": "Предыдущее сообщение",
|
||||
"next": "Следующее сообщение",
|
||||
"first": "Уже первое сообщение",
|
||||
"last": "Уже последнее сообщение"
|
||||
}
|
||||
},
|
||||
"code_block": {
|
||||
"collapse": "Свернуть",
|
||||
|
||||
@ -170,7 +170,13 @@
|
||||
"topics.prompt.tips": "话题提示词: 针对当前话题提供额外的补充提示词",
|
||||
"topics.title": "话题",
|
||||
"topics.unpinned": "取消固定",
|
||||
"translate": "翻译"
|
||||
"translate": "翻译",
|
||||
"navigation": {
|
||||
"prev": "上一条消息",
|
||||
"next": "下一条消息",
|
||||
"first": "已经是第一条消息",
|
||||
"last": "已经是最后一条消息"
|
||||
}
|
||||
},
|
||||
"code_block": {
|
||||
"collapse": "收起",
|
||||
|
||||
@ -170,7 +170,13 @@
|
||||
"topics.prompt.tips": "話題提示詞:針對目前話題提供額外的補充提示詞",
|
||||
"topics.title": "話題",
|
||||
"topics.unpinned": "取消固定",
|
||||
"translate": "翻譯"
|
||||
"translate": "翻譯",
|
||||
"navigation": {
|
||||
"prev": "上一條訊息",
|
||||
"next": "下一條訊息",
|
||||
"first": "已經是第一條訊息",
|
||||
"last": "已經是最後一條訊息"
|
||||
}
|
||||
},
|
||||
"code_block": {
|
||||
"collapse": "折疊",
|
||||
|
||||
258
src/renderer/src/pages/home/Messages/ChatNavigation.tsx
Normal file
258
src/renderer/src/pages/home/Messages/ChatNavigation.tsx
Normal file
@ -0,0 +1,258 @@
|
||||
import { DownOutlined, UpOutlined } from '@ant-design/icons'
|
||||
import { Button, message, Tooltip } from 'antd'
|
||||
import { FC, useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import styled from 'styled-components'
|
||||
|
||||
interface ChatNavigationProps {
|
||||
containerId: string
|
||||
}
|
||||
|
||||
const ChatNavigation: FC<ChatNavigationProps> = ({ containerId }) => {
|
||||
const { t } = useTranslation()
|
||||
const [isVisible, setIsVisible] = useState(false)
|
||||
const [hideTimer, setHideTimer] = useState<NodeJS.Timeout | null>(null)
|
||||
|
||||
const resetHideTimer = () => {
|
||||
if (hideTimer) {
|
||||
clearTimeout(hideTimer)
|
||||
}
|
||||
setIsVisible(true)
|
||||
const timer = setTimeout(() => {
|
||||
setIsVisible(false)
|
||||
}, 1000)
|
||||
setHideTimer(timer)
|
||||
}
|
||||
|
||||
const findUserMessages = () => {
|
||||
const container = document.getElementById(containerId)
|
||||
if (!container) return []
|
||||
|
||||
const userMessages = Array.from(container.getElementsByClassName('message-user'))
|
||||
return userMessages as HTMLElement[]
|
||||
}
|
||||
|
||||
const findAssistantMessages = () => {
|
||||
const container = document.getElementById(containerId)
|
||||
if (!container) return []
|
||||
|
||||
const assistantMessages = Array.from(container.getElementsByClassName('message-assistant'))
|
||||
return assistantMessages as HTMLElement[]
|
||||
}
|
||||
|
||||
const scrollToMessage = (element: HTMLElement) => {
|
||||
element.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||||
}
|
||||
|
||||
const getCurrentVisibleIndex = (direction: 'up' | 'down') => {
|
||||
const userMessages = findUserMessages()
|
||||
const assistantMessages = findAssistantMessages()
|
||||
const container = document.getElementById(containerId)
|
||||
if (!container) return -1
|
||||
|
||||
const containerRect = container.getBoundingClientRect()
|
||||
const visibleThreshold = containerRect.height * 0.1
|
||||
|
||||
let visibleIndices: number[] = []
|
||||
|
||||
for (let i = 0; i < userMessages.length; i++) {
|
||||
const messageRect = userMessages[i].getBoundingClientRect()
|
||||
const visibleHeight =
|
||||
Math.min(messageRect.bottom, containerRect.bottom) - Math.max(messageRect.top, containerRect.top)
|
||||
if (visibleHeight > 0 && visibleHeight >= Math.min(messageRect.height, visibleThreshold)) {
|
||||
visibleIndices.push(i)
|
||||
}
|
||||
}
|
||||
|
||||
if (visibleIndices.length > 0) {
|
||||
return direction === 'up' ? Math.max(...visibleIndices) : Math.min(...visibleIndices)
|
||||
}
|
||||
|
||||
visibleIndices = []
|
||||
for (let i = 0; i < assistantMessages.length; i++) {
|
||||
const messageRect = assistantMessages[i].getBoundingClientRect()
|
||||
const visibleHeight =
|
||||
Math.min(messageRect.bottom, containerRect.bottom) - Math.max(messageRect.top, containerRect.top)
|
||||
if (visibleHeight > 0 && visibleHeight >= Math.min(messageRect.height, visibleThreshold)) {
|
||||
visibleIndices.push(i)
|
||||
}
|
||||
}
|
||||
|
||||
if (visibleIndices.length > 0) {
|
||||
const assistantIndex = direction === 'up' ? Math.max(...visibleIndices) : Math.min(...visibleIndices)
|
||||
return assistantIndex < userMessages.length ? assistantIndex : userMessages.length - 1
|
||||
}
|
||||
|
||||
return -1
|
||||
}
|
||||
|
||||
const handleNextMessage = () => {
|
||||
resetHideTimer()
|
||||
const userMessages = findUserMessages()
|
||||
const assistantMessages = findAssistantMessages()
|
||||
if (userMessages.length === 0 && assistantMessages.length === 0) {
|
||||
message.info(t('chat.navigation.last'))
|
||||
return
|
||||
}
|
||||
|
||||
const visibleIndex = getCurrentVisibleIndex('down')
|
||||
if (visibleIndex === -1) {
|
||||
message.info(t('chat.navigation.last'))
|
||||
return
|
||||
}
|
||||
|
||||
const targetIndex = visibleIndex - 1
|
||||
|
||||
if (targetIndex < 0) {
|
||||
message.info(t('chat.navigation.last'))
|
||||
return
|
||||
}
|
||||
|
||||
scrollToMessage(userMessages[targetIndex])
|
||||
}
|
||||
|
||||
const handlePrevMessage = () => {
|
||||
resetHideTimer()
|
||||
const userMessages = findUserMessages()
|
||||
const assistantMessages = findAssistantMessages()
|
||||
if (userMessages.length === 0 && assistantMessages.length === 0) {
|
||||
message.info(t('chat.navigation.first'))
|
||||
return
|
||||
}
|
||||
|
||||
const visibleIndex = getCurrentVisibleIndex('up')
|
||||
if (visibleIndex === -1) {
|
||||
message.info(t('chat.navigation.first'))
|
||||
return
|
||||
}
|
||||
|
||||
const targetIndex = visibleIndex + 1
|
||||
|
||||
if (targetIndex >= userMessages.length) {
|
||||
message.info(t('chat.navigation.first'))
|
||||
return
|
||||
}
|
||||
|
||||
scrollToMessage(userMessages[targetIndex])
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const container = document.getElementById(containerId)
|
||||
if (!container) return
|
||||
|
||||
const handleScroll = () => {
|
||||
setIsVisible(true)
|
||||
resetHideTimer()
|
||||
}
|
||||
|
||||
container.addEventListener('scroll', handleScroll)
|
||||
return () => {
|
||||
container.removeEventListener('scroll', handleScroll)
|
||||
if (hideTimer) {
|
||||
clearTimeout(hideTimer)
|
||||
}
|
||||
}
|
||||
}, [containerId, hideTimer, resetHideTimer])
|
||||
|
||||
return (
|
||||
<>
|
||||
<TriggerArea onMouseEnter={() => setIsVisible(true)} onMouseLeave={() => resetHideTimer()} />
|
||||
<NavigationContainer $isVisible={isVisible}>
|
||||
<ButtonGroup>
|
||||
<Tooltip title={t('chat.navigation.prev')} placement="left">
|
||||
<NavigationButton
|
||||
type="text"
|
||||
icon={<UpOutlined />}
|
||||
onClick={handlePrevMessage}
|
||||
aria-label={t('chat.navigation.prev')}
|
||||
onMouseLeave={() => resetHideTimer()}
|
||||
/>
|
||||
</Tooltip>
|
||||
<Divider />
|
||||
<Tooltip title={t('chat.navigation.next')} placement="left">
|
||||
<NavigationButton
|
||||
type="text"
|
||||
icon={<DownOutlined />}
|
||||
onClick={handleNextMessage}
|
||||
aria-label={t('chat.navigation.next')}
|
||||
onMouseLeave={() => resetHideTimer()}
|
||||
/>
|
||||
</Tooltip>
|
||||
</ButtonGroup>
|
||||
</NavigationContainer>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
const TriggerArea = styled.div`
|
||||
position: fixed;
|
||||
right: 0;
|
||||
top: 40%;
|
||||
width: 20px;
|
||||
height: 20%;
|
||||
z-index: 998;
|
||||
background: transparent;
|
||||
`
|
||||
|
||||
interface NavigationContainerProps {
|
||||
$isVisible: boolean
|
||||
}
|
||||
|
||||
const NavigationContainer = styled.div<NavigationContainerProps>`
|
||||
position: fixed;
|
||||
right: 16px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%) translateX(${(props) => (props.$isVisible ? 0 : '100%')});
|
||||
z-index: 999;
|
||||
opacity: ${(props) => (props.$isVisible ? 1 : 0)};
|
||||
transition:
|
||||
transform 0.3s ease-in-out,
|
||||
opacity 0.3s ease-in-out;
|
||||
pointer-events: ${(props) => (props.$isVisible ? 'auto' : 'none')};
|
||||
|
||||
&:hover {
|
||||
transform: translateY(-50%) translateX(0);
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
`
|
||||
|
||||
const ButtonGroup = styled.div`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--bg-color);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
overflow: hidden;
|
||||
backdrop-filter: blur(8px);
|
||||
border: 1px solid var(--color-border);
|
||||
`
|
||||
|
||||
const NavigationButton = styled(Button)`
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: 0;
|
||||
border: none;
|
||||
color: var(--color-text);
|
||||
transition: all 0.2s ease-in-out;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--color-hover);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.anticon {
|
||||
font-size: 14px;
|
||||
}
|
||||
`
|
||||
|
||||
const Divider = styled.div`
|
||||
height: 1px;
|
||||
background: var(--color-border);
|
||||
margin: 0;
|
||||
`
|
||||
|
||||
export default ChatNavigation
|
||||
@ -31,6 +31,7 @@ import InfiniteScroll from 'react-infinite-scroll-component'
|
||||
import BeatLoader from 'react-spinners/BeatLoader'
|
||||
import styled from 'styled-components'
|
||||
|
||||
import ChatNavigation from './ChatNavigation'
|
||||
import MessageGroup from './MessageGroup'
|
||||
import NarrowLayout from './NarrowLayout'
|
||||
import Prompt from './Prompt'
|
||||
@ -254,6 +255,7 @@ const Messages: React.FC<MessagesProps> = ({ assistant, topic, setActiveTopic })
|
||||
</InfiniteScroll>
|
||||
<Prompt assistant={assistant} key={assistant.prompt} topic={topic} />
|
||||
</NarrowLayout>
|
||||
<ChatNavigation containerId="messages" />
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user