feat(Messages): 新增消息锚点功能,在右侧显示消息大纲 (#3674)

* feat(Messages): 添加消息线和分页按钮的设置选项

* feat(Messages): 将“消息线”更名为“对话锚点”,并更新相关设置和国际化文本

* fix(Messages): 调整消息透明度和缩放效果,优化消息项的样式和交互

* feat(Messages): 添加容器高度自适应功能,优化消息线样式和交互效果

* fix(Messages): 调整消息容器高度计算和样式,优化交互效果

* feat(settings): 更新消息导航相关翻译和设置
This commit is contained in:
Teo 2025-03-21 16:21:12 +08:00 committed by GitHub
parent 998c4bc459
commit 852274b4b1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 420 additions and 40 deletions

View File

@ -926,6 +926,10 @@
"jsonSaveError": "Failed to save JSON configuration." "jsonSaveError": "Failed to save JSON configuration."
}, },
"messages.divider": "Show divider between messages", "messages.divider": "Show divider between messages",
"messages.navigation": "Message Navigation",
"messages.navigation.none": "None",
"messages.navigation.buttons": "Navigation Buttons",
"messages.navigation.anchor": "Message Anchor",
"messages.grid_columns": "Message grid display columns", "messages.grid_columns": "Message grid display columns",
"messages.grid_popover_trigger": "Grid detail trigger", "messages.grid_popover_trigger": "Grid detail trigger",
"messages.grid_popover_trigger.click": "Click to display", "messages.grid_popover_trigger.click": "Click to display",

View File

@ -926,6 +926,10 @@
"jsonSaveError": "JSON設定の保存に失敗しました" "jsonSaveError": "JSON設定の保存に失敗しました"
}, },
"messages.divider": "メッセージ間に区切り線を表示", "messages.divider": "メッセージ間に区切り線を表示",
"messages.navigation": "メッセージナビゲーション",
"messages.navigation.none": "表示しない",
"messages.navigation.buttons": "上下ボタン",
"messages.navigation.anchor": "会話アンカー",
"messages.grid_columns": "メッセージグリッドの表示列数", "messages.grid_columns": "メッセージグリッドの表示列数",
"messages.grid_popover_trigger": "グリッド詳細トリガー", "messages.grid_popover_trigger": "グリッド詳細トリガー",
"messages.grid_popover_trigger.click": "クリックで表示", "messages.grid_popover_trigger.click": "クリックで表示",

View File

@ -926,6 +926,10 @@
"jsonSaveError": "Не удалось сохранить конфигурацию JSON" "jsonSaveError": "Не удалось сохранить конфигурацию JSON"
}, },
"messages.divider": "Показывать разделитель между сообщениями", "messages.divider": "Показывать разделитель между сообщениями",
"messages.navigation": "Навигация сообщений",
"messages.navigation.none": "Не показывать",
"messages.navigation.buttons": "Кнопки пагинации",
"messages.navigation.anchor": "Диалог анкор",
"messages.grid_columns": "Количество столбцов сетки сообщений", "messages.grid_columns": "Количество столбцов сетки сообщений",
"messages.grid_popover_trigger": "Триггер для отображения подробной информации в сетке", "messages.grid_popover_trigger": "Триггер для отображения подробной информации в сетке",
"messages.grid_popover_trigger.click": "Нажатие для отображения", "messages.grid_popover_trigger.click": "Нажатие для отображения",

View File

@ -926,6 +926,10 @@
"jsonSaveError": "保存JSON配置失败" "jsonSaveError": "保存JSON配置失败"
}, },
"messages.divider": "消息分割线", "messages.divider": "消息分割线",
"messages.navigation": "对话导航按钮",
"messages.navigation.none": "不显示",
"messages.navigation.buttons": "上下按钮",
"messages.navigation.anchor": "对话锚点",
"messages.grid_columns": "消息网格展示列数", "messages.grid_columns": "消息网格展示列数",
"messages.grid_popover_trigger": "网格详情触发", "messages.grid_popover_trigger": "网格详情触发",
"messages.grid_popover_trigger.click": "点击显示", "messages.grid_popover_trigger.click": "点击显示",

View File

@ -926,6 +926,10 @@
"jsonSaveError": "保存JSON配置失敗" "jsonSaveError": "保存JSON配置失敗"
}, },
"messages.divider": "訊息間顯示分隔線", "messages.divider": "訊息間顯示分隔線",
"messages.navigation": "訊息導航",
"messages.navigation.none": "不顯示",
"messages.navigation.buttons": "上下按鈕",
"messages.navigation.anchor": "對話錨點",
"messages.grid_columns": "訊息網格展示列數", "messages.grid_columns": "訊息網格展示列數",
"messages.grid_popover_trigger": "網格詳細資訊觸發", "messages.grid_popover_trigger": "網格詳細資訊觸發",
"messages.grid_popover_trigger.click": "點選顯示", "messages.grid_popover_trigger.click": "點選顯示",

View File

@ -0,0 +1,304 @@
import { APP_NAME, AppLogo, isLocalAi } from '@renderer/config/env'
import { getModelLogo } from '@renderer/config/models'
import { useTheme } from '@renderer/context/ThemeProvider'
import useAvatar from '@renderer/hooks/useAvatar'
import { useSettings } from '@renderer/hooks/useSettings'
import { getMessageModelId } from '@renderer/services/MessagesService'
import { getModelName } from '@renderer/services/ModelService'
import { useAppDispatch } from '@renderer/store'
import { updateMessage } from '@renderer/store/messages'
import { Message } from '@renderer/types'
import { isEmoji, removeLeadingEmoji } from '@renderer/utils'
import { Avatar } from 'antd'
import { FC, useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
interface MessageLineProps {
messages: Message[]
}
const getAvatarSource = (isLocalAi: boolean, modelId: string | undefined) => {
if (isLocalAi) return AppLogo
return modelId ? getModelLogo(modelId) : undefined
}
const MessageAnchorLine: FC<MessageLineProps> = ({ messages }) => {
const { t } = useTranslation()
const avatar = useAvatar()
const { theme } = useTheme()
const dispatch = useAppDispatch()
const { userName } = useSettings()
const messagesListRef = useRef<HTMLDivElement>(null)
const messageItemsRef = useRef<Map<string, HTMLDivElement>>(new Map())
const containerRef = useRef<HTMLDivElement>(null)
const [mouseY, setMouseY] = useState<number | null>(null)
const { topicPosition, showTopics } = useSettings()
const showRightTopics = topicPosition === 'right' && showTopics
const right = showRightTopics ? 'calc(var(--topic-list-width) + 15px)' : '15px'
const [listOffsetY, setListOffsetY] = useState(0)
const [containerHeight, setContainerHeight] = useState<number | null>(null)
useEffect(() => {
const updateHeight = () => {
if (containerRef.current) {
const parentElement = containerRef.current.parentElement
if (parentElement) {
setContainerHeight(parentElement.clientHeight)
}
}
}
updateHeight()
window.addEventListener('resize', updateHeight)
return () => {
window.removeEventListener('resize', updateHeight)
}
}, [messages])
// 函数用于计算根据距离的变化值
const calculateValueByDistance = useCallback(
(itemId: string, maxValue: number) => {
if (mouseY === null) return 0
const element = messageItemsRef.current.get(itemId)
if (!element) return 0
const rect = element.getBoundingClientRect()
const centerY = rect.top + rect.height / 2
const distance = Math.abs(centerY - (messagesListRef.current?.getBoundingClientRect().top || 0) - mouseY)
const maxDistance = 100
return Math.max(0, maxValue * (1 - distance / maxDistance))
},
[mouseY]
)
const getUserName = useCallback(
(message: Message) => {
if (isLocalAi && message.role !== 'user') {
return APP_NAME
}
if (message.role === 'assistant') {
if (message.model) {
return getModelName(message.model) || message.model.name || message.modelId || ''
}
const modelId = getMessageModelId(message)
return modelId || ''
}
return userName || t('common.you')
},
[userName, t]
)
const setSelectedMessage = useCallback(
(message: Message) => {
const groupMessages = messages.filter((m) => m.askId === message.askId)
if (groupMessages.length > 1) {
groupMessages.forEach((m) => {
dispatch(
updateMessage({
topicId: m.topicId,
messageId: m.id,
updates: { foldSelected: m.id === message.id }
})
)
})
setTimeout(() => {
const messageElement = document.getElementById(`message-${message.id}`)
if (messageElement) {
messageElement.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
}
}, 100)
}
},
[dispatch, messages]
)
const scrollToMessage = useCallback(
(message: Message) => {
const messageElement = document.getElementById(`message-${message.id}`)
if (!messageElement) return
const display = messageElement ? window.getComputedStyle(messageElement).display : null
if (display === 'none') {
setSelectedMessage(message)
return
}
messageElement.scrollIntoView({ behavior: 'smooth', block: 'start' })
},
[messages, setSelectedMessage]
)
if (messages.length === 0) return null
const handleMouseMove = (e: React.MouseEvent) => {
if (messagesListRef.current) {
const containerRect = e.currentTarget.getBoundingClientRect()
const listRect = messagesListRef.current.getBoundingClientRect()
setMouseY(e.clientY - listRect.top)
if (listRect.height > containerRect.height) {
const mousePositionRatio = (e.clientY - containerRect.top) / containerRect.height
const maxOffset = (containerRect.height - listRect.height) / 2 - 20
setListOffsetY(-maxOffset + mousePositionRatio * (maxOffset * 2))
} else {
setListOffsetY(0)
}
}
}
const handleMouseLeave = () => {
setMouseY(null)
setListOffsetY(0)
}
return (
<MessageLineContainer
ref={containerRef}
onMouseMove={handleMouseMove}
onMouseLeave={handleMouseLeave}
$right={right}
$height={containerHeight}>
<MessagesList ref={messagesListRef} style={{ transform: `translateY(${listOffsetY}px)` }}>
{messages.map((message, index) => {
const opacity = 0.5 + calculateValueByDistance(message.id, 1)
const scale = 1 + calculateValueByDistance(message.id, 1)
const size = 10 + calculateValueByDistance(message.id, 20)
const avatarSource = getAvatarSource(isLocalAi, getMessageModelId(message))
const username = removeLeadingEmoji(getUserName(message))
return (
<MessageItem
key={message.id}
ref={(el) => {
if (el) messageItemsRef.current.set(message.id, el)
else messageItemsRef.current.delete(message.id)
}}
style={{
opacity: mouseY ? opacity : Math.max(0, 0.6 - (0.3 * Math.abs(index - messages.length / 2)) / 5)
}}
onClick={() => scrollToMessage(message)}>
<MessageItemContainer style={{ transform: ` scale(${scale})` }}>
<MessageItemTitle>{username}</MessageItemTitle>
<MessageItemContent>{message.content.substring(0, 50)}</MessageItemContent>
</MessageItemContainer>
{message.role === 'assistant' ? (
<Avatar
src={avatarSource}
size={size}
style={{
border: isLocalAi ? '1px solid var(--color-border-soft)' : 'none',
filter: theme === 'dark' ? 'invert(0.05)' : undefined
}}>
A
</Avatar>
) : (
<>
{isEmoji(avatar) ? (
<EmojiAvatar size={size}>{avatar}</EmojiAvatar>
) : (
<Avatar src={avatar} size={size} />
)}
</>
)}
</MessageItem>
)
})}
</MessagesList>
</MessageLineContainer>
)
}
const MessageItemContainer = styled.div`
line-height: 1;
display: flex;
flex-direction: column;
align-items: flex-end;
justify-content: space-between;
text-align: right;
gap: 4px;
text-shadow: 0 0 2px rgba(255, 255, 255, 0.5);
opacity: 0;
transform-origin: right center;
`
const MessageLineContainer = styled.div<{ $right: string; $height: number | null }>`
width: 14px;
position: fixed;
top: ${(props) => (props.$height ? `calc(${props.$height / 2}px + var(--status-bar-height))` : '50%')};
right: ${(props) => props.$right};
max-height: ${(props) => (props.$height ? `${props.$height}px` : 'calc(100% - var(--status-bar-height) * 2)')};
transform: translateY(-50%);
z-index: 0;
user-select: none;
display: flex;
align-items: center;
justify-content: flex-end;
font-size: 5px;
overflow: hidden;
&:hover {
width: 440px;
overflow-x: visible;
overflow-y: hidden;
${MessageItemContainer} {
opacity: 1;
}
}
`
const MessagesList = styled.div`
display: flex;
flex-direction: column-reverse;
will-change: transform;
`
const MessageItem = styled.div`
display: flex;
position: relative;
cursor: pointer;
justify-content: flex-end;
align-items: center;
gap: 10px;
transform-origin: right center;
padding: 2px 0;
will-change: opacity;
opacity: 0.4;
transition: opacity 0.1s linear;
`
const MessageItemTitle = styled.div`
font-weight: 500;
color: var(--color-text);
white-space: nowrap;
`
const MessageItemContent = styled.div`
color: var(--color-text-2);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 200px;
`
const EmojiAvatar = styled.div<{ size: number }>`
width: ${(props) => props.size}px;
height: ${(props) => props.size}px;
background-color: var(--color-background-soft);
border-radius: 20%;
display: flex;
align-items: center;
justify-content: center;
font-size: ${(props) => props.size * 0.6}px;
border: 0.5px solid var(--color-border);
`
export default MessageAnchorLine

View File

@ -1,4 +1,5 @@
import Scrollbar from '@renderer/components/Scrollbar' import Scrollbar from '@renderer/components/Scrollbar'
import { useMessageOperations } from '@renderer/hooks/useMessageOperations'
import { useSettings } from '@renderer/hooks/useSettings' import { useSettings } from '@renderer/hooks/useSettings'
import { MultiModelMessageStyle } from '@renderer/store/settings' import { MultiModelMessageStyle } from '@renderer/store/settings'
import type { Message, Topic } from '@renderer/types' import type { Message, Topic } from '@renderer/types'
@ -17,10 +18,12 @@ interface Props {
} }
const MessageGroup = ({ messages, topic, hidePresetMessages }: Props) => { const MessageGroup = ({ messages, topic, hidePresetMessages }: Props) => {
const { editMessage } = useMessageOperations(topic)
const { multiModelMessageStyle: multiModelMessageStyleSetting, gridColumns, gridPopoverTrigger } = useSettings() const { multiModelMessageStyle: multiModelMessageStyleSetting, gridColumns, gridPopoverTrigger } = useSettings()
const [multiModelMessageStyle, setMultiModelMessageStyle] = const [multiModelMessageStyle, setMultiModelMessageStyle] = useState<MultiModelMessageStyle>(
useState<MultiModelMessageStyle>(multiModelMessageStyleSetting) messages[0].multiModelMessageStyle || multiModelMessageStyleSetting
)
const messageLength = messages.length const messageLength = messages.length
const [selectedIndex, setSelectedIndex] = useState(messageLength - 1) const [selectedIndex, setSelectedIndex] = useState(messageLength - 1)
@ -33,6 +36,22 @@ const MessageGroup = ({ messages, topic, hidePresetMessages }: Props) => {
setSelectedIndex(messageLength - 1) setSelectedIndex(messageLength - 1)
}, [messageLength]) }, [messageLength])
const setSelectedMessage = useCallback(
(message: Message) => {
messages.forEach(async (m) => {
await editMessage(m.id, { foldSelected: m.id === message.id })
})
setTimeout(() => {
const messageElement = document.getElementById(`message-${message.id}`)
if (messageElement) {
messageElement.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
}, 100)
},
[editMessage, messages]
)
const renderMessage = useCallback( const renderMessage = useCallback(
(message: Message & { index: number }, index: number) => { (message: Message & { index: number }, index: number) => {
const isGridGroupMessage = isGrid && message.role === 'assistant' && isGrouped const isGridGroupMessage = isGrid && message.role === 'assistant' && isGrouped
@ -49,11 +68,16 @@ const MessageGroup = ({ messages, topic, hidePresetMessages }: Props) => {
const messageWrapper = ( const messageWrapper = (
<MessageWrapper <MessageWrapper
id={`message-${message.id}`}
$layout={multiModelMessageStyle} $layout={multiModelMessageStyle}
$selected={index === selectedIndex} $selected={index === selectedIndex}
$isGrouped={isGrouped} $isGrouped={isGrouped}
key={message.id} key={message.id}
className={message.role === 'assistant' && isHorizontal && isGrouped ? 'group-message-wrapper' : ''}> className={classNames({
'group-message-wrapper': message.role === 'assistant' && isHorizontal && isGrouped,
[multiModelMessageStyle]: true,
selected: 'foldSelected' in message ? message.foldSelected : index === 0
})}>
<MessageStream {...messageProps} /> <MessageStream {...messageProps} />
</MessageWrapper> </MessageWrapper>
) )
@ -95,6 +119,7 @@ const MessageGroup = ({ messages, topic, hidePresetMessages }: Props) => {
return ( return (
<GroupContainer <GroupContainer
id={`message-group-${messages[0].askId}`}
$isGrouped={isGrouped} $isGrouped={isGrouped}
$layout={multiModelMessageStyle} $layout={multiModelMessageStyle}
className={classNames([isGrouped && 'group-container', isHorizontal && 'horizontal', isGrid && 'grid'])}> className={classNames([isGrouped && 'group-container', isHorizontal && 'horizontal', isGrid && 'grid'])}>
@ -108,10 +133,14 @@ const MessageGroup = ({ messages, topic, hidePresetMessages }: Props) => {
{isGrouped && ( {isGrouped && (
<MessageGroupMenuBar <MessageGroupMenuBar
multiModelMessageStyle={multiModelMessageStyle} multiModelMessageStyle={multiModelMessageStyle}
setMultiModelMessageStyle={setMultiModelMessageStyle} setMultiModelMessageStyle={(style) => {
setMultiModelMessageStyle(style)
messages.forEach((message) => {
editMessage(message.id, { multiModelMessageStyle: style })
})
}}
messages={messages} messages={messages}
selectedIndex={selectedIndex} setSelectedMessage={setSelectedMessage}
setSelectedIndex={setSelectedIndex}
topic={topic} topic={topic}
/> />
)} )}
@ -173,15 +202,18 @@ interface MessageWrapperProps {
const MessageWrapper = styled(Scrollbar)<MessageWrapperProps>` const MessageWrapper = styled(Scrollbar)<MessageWrapperProps>`
width: 100%; width: 100%;
display: ${(props) => { &.horizontal {
if (props.$layout === 'fold') { display: inline-block;
return props.$selected ? 'block' : 'none' }
&.grid {
display: inline-block;
}
&.fold {
display: none;
&.selected {
display: inline-block;
} }
if (props.$layout === 'horizontal') { }
return 'inline-block'
}
return 'block'
}};
${({ $layout, $isGrouped }) => { ${({ $layout, $isGrouped }) => {
if ($layout === 'horizontal' && $isGrouped) { if ($layout === 'horizontal' && $isGrouped) {

View File

@ -21,8 +21,7 @@ interface Props {
multiModelMessageStyle: MultiModelMessageStyle multiModelMessageStyle: MultiModelMessageStyle
setMultiModelMessageStyle: (style: MultiModelMessageStyle) => void setMultiModelMessageStyle: (style: MultiModelMessageStyle) => void
messages: Message[] messages: Message[]
selectedIndex: number setSelectedMessage: (message: Message) => void
setSelectedIndex: (index: number) => void
topic: Topic topic: Topic
} }
@ -30,8 +29,7 @@ const MessageGroupMenuBar: FC<Props> = ({
multiModelMessageStyle, multiModelMessageStyle,
setMultiModelMessageStyle, setMultiModelMessageStyle,
messages, messages,
selectedIndex, setSelectedMessage,
setSelectedIndex,
topic topic
}) => { }) => {
const { t } = useTranslation() const { t } = useTranslation()
@ -77,11 +75,7 @@ const MessageGroupMenuBar: FC<Props> = ({
))} ))}
</LayoutContainer> </LayoutContainer>
{multiModelMessageStyle === 'fold' && ( {multiModelMessageStyle === 'fold' && (
<MessageGroupModelList <MessageGroupModelList messages={messages} setSelectedMessage={setSelectedMessage} />
messages={messages}
selectedIndex={selectedIndex}
setSelectedIndex={setSelectedIndex}
/>
)} )}
{multiModelMessageStyle === 'grid' && <MessageGroupSettings />} {multiModelMessageStyle === 'grid' && <MessageGroupSettings />}
</HStack> </HStack>

View File

@ -1,7 +1,6 @@
import { ArrowsAltOutlined, ShrinkOutlined } from '@ant-design/icons' import { ArrowsAltOutlined, ShrinkOutlined } from '@ant-design/icons'
import ModelAvatar from '@renderer/components/Avatar/ModelAvatar' import ModelAvatar from '@renderer/components/Avatar/ModelAvatar'
import Scrollbar from '@renderer/components/Scrollbar' import Scrollbar from '@renderer/components/Scrollbar'
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
import { Message, Model } from '@renderer/types' import { Message, Model } from '@renderer/types'
import { Avatar, Segmented as AntdSegmented, Tooltip } from 'antd' import { Avatar, Segmented as AntdSegmented, Tooltip } from 'antd'
import { FC, useState } from 'react' import { FC, useState } from 'react'
@ -10,13 +9,12 @@ import styled from 'styled-components'
interface MessageGroupModelListProps { interface MessageGroupModelListProps {
messages: Message[] messages: Message[]
selectedIndex: number setSelectedMessage: (message: Message) => void
setSelectedIndex: (index: number) => void
} }
type DisplayMode = 'compact' | 'expanded' type DisplayMode = 'compact' | 'expanded'
const MessageGroupModelList: FC<MessageGroupModelListProps> = ({ messages, selectedIndex, setSelectedIndex }) => { const MessageGroupModelList: FC<MessageGroupModelListProps> = ({ messages, setSelectedMessage }) => {
const { t } = useTranslation() const { t } = useTranslation()
const [displayMode, setDisplayMode] = useState<DisplayMode>('expanded') const [displayMode, setDisplayMode] = useState<DisplayMode>('expanded')
const isCompact = displayMode === 'compact' const isCompact = displayMode === 'compact'
@ -43,10 +41,9 @@ const MessageGroupModelList: FC<MessageGroupModelListProps> = ({ messages, selec
<Tooltip key={index} title={message.model?.name} placement="top" mouseEnterDelay={0.2}> <Tooltip key={index} title={message.model?.name} placement="top" mouseEnterDelay={0.2}>
<AvatarWrapper <AvatarWrapper
className="avatar-wrapper" className="avatar-wrapper"
isSelected={selectedIndex === index} isSelected={'foldSelected' in message ? message.foldSelected! : index === 0}
onClick={() => { onClick={() => {
setSelectedIndex(index) setSelectedMessage(message)
EventEmitter.emit(EVENT_NAMES.LOCATE_MESSAGE + ':' + messages[index].id, false)
}}> }}>
<ModelAvatar model={message.model as Model} size={28} /> <ModelAvatar model={message.model as Model} size={28} />
</AvatarWrapper> </AvatarWrapper>
@ -56,19 +53,19 @@ const MessageGroupModelList: FC<MessageGroupModelListProps> = ({ messages, selec
) : ( ) : (
/* Expanded style display */ /* Expanded style display */
<Segmented <Segmented
value={selectedIndex.toString()} value={messages.find((message) => message.foldSelected)?.id || messages[0].id}
onChange={(value) => { onChange={(value) => {
setSelectedIndex(Number(value)) const message = messages.find((message) => message.id === value) as Message
EventEmitter.emit(EVENT_NAMES.LOCATE_MESSAGE + ':' + messages[Number(value)].id, false) setSelectedMessage(message)
}} }}
options={messages.map((message, index) => ({ options={messages.map((message) => ({
label: ( label: (
<SegmentedLabel> <SegmentedLabel>
<ModelAvatar model={message.model as Model} size={20} /> <ModelAvatar model={message.model as Model} size={20} />
<ModelName>{message.model?.name}</ModelName> <ModelName>{message.model?.name}</ModelName>
</SegmentedLabel> </SegmentedLabel>
), ),
value: index.toString() value: message.id
}))} }))}
size="small" size="small"
/> />

View File

@ -27,6 +27,7 @@ import styled from 'styled-components'
import ChatNavigation from './ChatNavigation' import ChatNavigation from './ChatNavigation'
import MessageGroup from './MessageGroup' import MessageGroup from './MessageGroup'
import MessageAnchorLine from './MessageAnchorLine'
import NarrowLayout from './NarrowLayout' import NarrowLayout from './NarrowLayout'
import NewTopicButton from './NewTopicButton' import NewTopicButton from './NewTopicButton'
import Prompt from './Prompt' import Prompt from './Prompt'
@ -39,7 +40,7 @@ interface MessagesProps {
const Messages: React.FC<MessagesProps> = ({ assistant, topic, setActiveTopic }) => { const Messages: React.FC<MessagesProps> = ({ assistant, topic, setActiveTopic }) => {
const { t } = useTranslation() const { t } = useTranslation()
const { showTopics, topicPosition, showAssistants } = useSettings() const { showTopics, topicPosition, showAssistants, messageNavigation } = useSettings()
const { updateTopic, addTopic } = useAssistant(assistant.id) const { updateTopic, addTopic } = useAssistant(assistant.id)
const dispatch = useAppDispatch() const dispatch = useAppDispatch()
const containerRef = useRef<HTMLDivElement>(null) const containerRef = useRef<HTMLDivElement>(null)
@ -225,7 +226,10 @@ const Messages: React.FC<MessagesProps> = ({ assistant, topic, setActiveTopic })
</InfiniteScroll> </InfiniteScroll>
<Prompt assistant={assistant} key={assistant.prompt} topic={topic} /> <Prompt assistant={assistant} key={assistant.prompt} topic={topic} />
</NarrowLayout> </NarrowLayout>
<ChatNavigation containerId="messages" />
{messageNavigation === 'anchor' && <MessageAnchorLine messages={displayMessages} />}
{messageNavigation === 'buttons' && <ChatNavigation containerId="messages" />}
</Container> </Container>
) )
} }

View File

@ -31,6 +31,7 @@ import {
setRenderInputMessageAsMarkdown, setRenderInputMessageAsMarkdown,
setShowInputEstimatedTokens, setShowInputEstimatedTokens,
setShowMessageDivider, setShowMessageDivider,
setMessageNavigation,
setThoughtAutoCollapse setThoughtAutoCollapse
} from '@renderer/store/settings' } from '@renderer/store/settings'
import { Assistant, AssistantSettings, CodeStyleVarious, ThemeMode, TranslateLanguageVarious } from '@renderer/types' import { Assistant, AssistantSettings, CodeStyleVarious, ThemeMode, TranslateLanguageVarious } from '@renderer/types'
@ -76,7 +77,8 @@ const SettingsTab: FC<Props> = (props) => {
autoTranslateWithSpace, autoTranslateWithSpace,
pasteLongTextThreshold, pasteLongTextThreshold,
multiModelMessageStyle, multiModelMessageStyle,
thoughtAutoCollapse thoughtAutoCollapse,
messageNavigation
} = useSettings() } = useSettings()
const onUpdateAssistantSettings = (settings: Partial<AssistantSettings>) => { const onUpdateAssistantSettings = (settings: Partial<AssistantSettings>) => {
@ -361,6 +363,19 @@ const SettingsTab: FC<Props> = (props) => {
</StyledSelect> </StyledSelect>
</SettingRow> </SettingRow>
<SettingDivider /> <SettingDivider />
<SettingRow>
<SettingRowTitleSmall>{t('settings.messages.navigation')}</SettingRowTitleSmall>
<StyledSelect
size="small"
value={messageNavigation}
onChange={(value) => dispatch(setMessageNavigation(value as 'none' | 'buttons' | 'anchor'))}
style={{ width: 135 }}>
<Select.Option value="none">{t('settings.messages.navigation.none')}</Select.Option>
<Select.Option value="buttons">{t('settings.messages.navigation.buttons')}</Select.Option>
<Select.Option value="anchor">{t('settings.messages.navigation.anchor')}</Select.Option>
</StyledSelect>
</SettingRow>
<SettingDivider />
<SettingRow> <SettingRow>
<SettingRowTitleSmall>{t('message.message.code_style')}</SettingRowTitleSmall> <SettingRowTitleSmall>{t('message.message.code_style')}</SettingRowTitleSmall>
<StyledSelect <StyledSelect

View File

@ -40,7 +40,7 @@ const persistedReducer = persistReducer(
{ {
key: 'cherry-studio', key: 'cherry-studio',
storage, storage,
version: 82, version: 83,
blacklist: ['runtime', 'messages'], blacklist: ['runtime', 'messages'],
migrate migrate
}, },

View File

@ -788,6 +788,10 @@ const migrateConfig = {
delete runtimeState.webdavSync delete runtimeState.webdavSync
} }
return state return state
},
'83': (state: RootState) => {
state.settings.messageNavigation = 'buttons'
return state
} }
} }

View File

@ -48,6 +48,7 @@ export interface SettingsState {
codeStyle: CodeStyleVarious codeStyle: CodeStyleVarious
gridColumns: number gridColumns: number
gridPopoverTrigger: 'hover' | 'click' gridPopoverTrigger: 'hover' | 'click'
messageNavigation: 'none' | 'buttons' | 'anchor'
// webdav 配置 host, user, pass, path // webdav 配置 host, user, pass, path
webdavHost: string webdavHost: string
webdavUser: string webdavUser: string
@ -122,6 +123,7 @@ const initialState: SettingsState = {
codeStyle: 'auto', codeStyle: 'auto',
gridColumns: 2, gridColumns: 2,
gridPopoverTrigger: 'hover', gridPopoverTrigger: 'hover',
messageNavigation: 'none',
webdavHost: '', webdavHost: '',
webdavUser: '', webdavUser: '',
webdavPass: '', webdavPass: '',
@ -363,6 +365,9 @@ const settingsSlice = createSlice({
}, },
setJoplinUrl: (state, action: PayloadAction<string>) => { setJoplinUrl: (state, action: PayloadAction<string>) => {
state.joplinUrl = action.payload state.joplinUrl = action.payload
},
setMessageNavigation: (state, action: PayloadAction<'none' | 'buttons' | 'anchor'>) => {
state.messageNavigation = action.payload
} }
} }
}) })
@ -432,7 +437,8 @@ export const {
setObsidianApiKey, setObsidianApiKey,
setObsidianUrl, setObsidianUrl,
setJoplinToken, setJoplinToken,
setJoplinUrl setJoplinUrl,
setMessageNavigation
} = settingsSlice.actions } = settingsSlice.actions
export default settingsSlice.reducer export default settingsSlice.reducer

View File

@ -78,6 +78,10 @@ export type Message = {
// MCP Tools // MCP Tools
mcpTools?: MCPToolResponse[] mcpTools?: MCPToolResponse[]
} }
// 多模型消息样式
multiModelMessageStyle?: 'horizontal' | 'vertical' | 'fold' | 'grid'
// fold时是否选中
foldSelected?: boolean
} }
export type Metrics = { export type Metrics = {