diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index 878a238e..c4ccfa81 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -926,6 +926,10 @@ "jsonSaveError": "Failed to save JSON configuration." }, "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_popover_trigger": "Grid detail trigger", "messages.grid_popover_trigger.click": "Click to display", diff --git a/src/renderer/src/i18n/locales/ja-jp.json b/src/renderer/src/i18n/locales/ja-jp.json index 9a5678c7..0989e358 100644 --- a/src/renderer/src/i18n/locales/ja-jp.json +++ b/src/renderer/src/i18n/locales/ja-jp.json @@ -926,6 +926,10 @@ "jsonSaveError": "JSON設定の保存に失敗しました" }, "messages.divider": "メッセージ間に区切り線を表示", + "messages.navigation": "メッセージナビゲーション", + "messages.navigation.none": "表示しない", + "messages.navigation.buttons": "上下ボタン", + "messages.navigation.anchor": "会話アンカー", "messages.grid_columns": "メッセージグリッドの表示列数", "messages.grid_popover_trigger": "グリッド詳細トリガー", "messages.grid_popover_trigger.click": "クリックで表示", diff --git a/src/renderer/src/i18n/locales/ru-ru.json b/src/renderer/src/i18n/locales/ru-ru.json index 1624ef18..264aca8e 100644 --- a/src/renderer/src/i18n/locales/ru-ru.json +++ b/src/renderer/src/i18n/locales/ru-ru.json @@ -926,6 +926,10 @@ "jsonSaveError": "Не удалось сохранить конфигурацию JSON" }, "messages.divider": "Показывать разделитель между сообщениями", + "messages.navigation": "Навигация сообщений", + "messages.navigation.none": "Не показывать", + "messages.navigation.buttons": "Кнопки пагинации", + "messages.navigation.anchor": "Диалог анкор", "messages.grid_columns": "Количество столбцов сетки сообщений", "messages.grid_popover_trigger": "Триггер для отображения подробной информации в сетке", "messages.grid_popover_trigger.click": "Нажатие для отображения", diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index 81b2145e..72538db0 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -926,6 +926,10 @@ "jsonSaveError": "保存JSON配置失败" }, "messages.divider": "消息分割线", + "messages.navigation": "对话导航按钮", + "messages.navigation.none": "不显示", + "messages.navigation.buttons": "上下按钮", + "messages.navigation.anchor": "对话锚点", "messages.grid_columns": "消息网格展示列数", "messages.grid_popover_trigger": "网格详情触发", "messages.grid_popover_trigger.click": "点击显示", diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index 0e48c2bc..08f2e933 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -926,6 +926,10 @@ "jsonSaveError": "保存JSON配置失敗" }, "messages.divider": "訊息間顯示分隔線", + "messages.navigation": "訊息導航", + "messages.navigation.none": "不顯示", + "messages.navigation.buttons": "上下按鈕", + "messages.navigation.anchor": "對話錨點", "messages.grid_columns": "訊息網格展示列數", "messages.grid_popover_trigger": "網格詳細資訊觸發", "messages.grid_popover_trigger.click": "點選顯示", diff --git a/src/renderer/src/pages/home/Messages/MessageAnchorLine.tsx b/src/renderer/src/pages/home/Messages/MessageAnchorLine.tsx new file mode 100644 index 00000000..2b314014 --- /dev/null +++ b/src/renderer/src/pages/home/Messages/MessageAnchorLine.tsx @@ -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 = ({ messages }) => { + const { t } = useTranslation() + const avatar = useAvatar() + const { theme } = useTheme() + const dispatch = useAppDispatch() + + const { userName } = useSettings() + const messagesListRef = useRef(null) + const messageItemsRef = useRef>(new Map()) + const containerRef = useRef(null) + const [mouseY, setMouseY] = useState(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(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 ( + + + {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 ( + { + 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)}> + + {username} + {message.content.substring(0, 50)} + + + {message.role === 'assistant' ? ( + + A + + ) : ( + <> + {isEmoji(avatar) ? ( + {avatar} + ) : ( + + )} + + )} + + ) + })} + + + ) +} + +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 diff --git a/src/renderer/src/pages/home/Messages/MessageGroup.tsx b/src/renderer/src/pages/home/Messages/MessageGroup.tsx index ca75e87a..89586b9c 100644 --- a/src/renderer/src/pages/home/Messages/MessageGroup.tsx +++ b/src/renderer/src/pages/home/Messages/MessageGroup.tsx @@ -1,4 +1,5 @@ import Scrollbar from '@renderer/components/Scrollbar' +import { useMessageOperations } from '@renderer/hooks/useMessageOperations' import { useSettings } from '@renderer/hooks/useSettings' import { MultiModelMessageStyle } from '@renderer/store/settings' import type { Message, Topic } from '@renderer/types' @@ -17,10 +18,12 @@ interface Props { } const MessageGroup = ({ messages, topic, hidePresetMessages }: Props) => { + const { editMessage } = useMessageOperations(topic) const { multiModelMessageStyle: multiModelMessageStyleSetting, gridColumns, gridPopoverTrigger } = useSettings() - const [multiModelMessageStyle, setMultiModelMessageStyle] = - useState(multiModelMessageStyleSetting) + const [multiModelMessageStyle, setMultiModelMessageStyle] = useState( + messages[0].multiModelMessageStyle || multiModelMessageStyleSetting + ) const messageLength = messages.length const [selectedIndex, setSelectedIndex] = useState(messageLength - 1) @@ -33,6 +36,22 @@ const MessageGroup = ({ messages, topic, hidePresetMessages }: Props) => { setSelectedIndex(messageLength - 1) }, [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( (message: Message & { index: number }, index: number) => { const isGridGroupMessage = isGrid && message.role === 'assistant' && isGrouped @@ -49,11 +68,16 @@ const MessageGroup = ({ messages, topic, hidePresetMessages }: Props) => { const messageWrapper = ( + className={classNames({ + 'group-message-wrapper': message.role === 'assistant' && isHorizontal && isGrouped, + [multiModelMessageStyle]: true, + selected: 'foldSelected' in message ? message.foldSelected : index === 0 + })}> ) @@ -95,6 +119,7 @@ const MessageGroup = ({ messages, topic, hidePresetMessages }: Props) => { return ( @@ -108,10 +133,14 @@ const MessageGroup = ({ messages, topic, hidePresetMessages }: Props) => { {isGrouped && ( { + setMultiModelMessageStyle(style) + messages.forEach((message) => { + editMessage(message.id, { multiModelMessageStyle: style }) + }) + }} messages={messages} - selectedIndex={selectedIndex} - setSelectedIndex={setSelectedIndex} + setSelectedMessage={setSelectedMessage} topic={topic} /> )} @@ -173,15 +202,18 @@ interface MessageWrapperProps { const MessageWrapper = styled(Scrollbar)` width: 100%; - display: ${(props) => { - if (props.$layout === 'fold') { - return props.$selected ? 'block' : 'none' + &.horizontal { + display: inline-block; + } + &.grid { + display: inline-block; + } + &.fold { + display: none; + &.selected { + display: inline-block; } - if (props.$layout === 'horizontal') { - return 'inline-block' - } - return 'block' - }}; + } ${({ $layout, $isGrouped }) => { if ($layout === 'horizontal' && $isGrouped) { diff --git a/src/renderer/src/pages/home/Messages/MessageGroupMenuBar.tsx b/src/renderer/src/pages/home/Messages/MessageGroupMenuBar.tsx index c19e7cc1..0dceb3ad 100644 --- a/src/renderer/src/pages/home/Messages/MessageGroupMenuBar.tsx +++ b/src/renderer/src/pages/home/Messages/MessageGroupMenuBar.tsx @@ -21,8 +21,7 @@ interface Props { multiModelMessageStyle: MultiModelMessageStyle setMultiModelMessageStyle: (style: MultiModelMessageStyle) => void messages: Message[] - selectedIndex: number - setSelectedIndex: (index: number) => void + setSelectedMessage: (message: Message) => void topic: Topic } @@ -30,8 +29,7 @@ const MessageGroupMenuBar: FC = ({ multiModelMessageStyle, setMultiModelMessageStyle, messages, - selectedIndex, - setSelectedIndex, + setSelectedMessage, topic }) => { const { t } = useTranslation() @@ -77,11 +75,7 @@ const MessageGroupMenuBar: FC = ({ ))} {multiModelMessageStyle === 'fold' && ( - + )} {multiModelMessageStyle === 'grid' && } diff --git a/src/renderer/src/pages/home/Messages/MessageGroupModelList.tsx b/src/renderer/src/pages/home/Messages/MessageGroupModelList.tsx index dc098bc5..315db3aa 100644 --- a/src/renderer/src/pages/home/Messages/MessageGroupModelList.tsx +++ b/src/renderer/src/pages/home/Messages/MessageGroupModelList.tsx @@ -1,7 +1,6 @@ import { ArrowsAltOutlined, ShrinkOutlined } from '@ant-design/icons' import ModelAvatar from '@renderer/components/Avatar/ModelAvatar' import Scrollbar from '@renderer/components/Scrollbar' -import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService' import { Message, Model } from '@renderer/types' import { Avatar, Segmented as AntdSegmented, Tooltip } from 'antd' import { FC, useState } from 'react' @@ -10,13 +9,12 @@ import styled from 'styled-components' interface MessageGroupModelListProps { messages: Message[] - selectedIndex: number - setSelectedIndex: (index: number) => void + setSelectedMessage: (message: Message) => void } type DisplayMode = 'compact' | 'expanded' -const MessageGroupModelList: FC = ({ messages, selectedIndex, setSelectedIndex }) => { +const MessageGroupModelList: FC = ({ messages, setSelectedMessage }) => { const { t } = useTranslation() const [displayMode, setDisplayMode] = useState('expanded') const isCompact = displayMode === 'compact' @@ -43,10 +41,9 @@ const MessageGroupModelList: FC = ({ messages, selec { - setSelectedIndex(index) - EventEmitter.emit(EVENT_NAMES.LOCATE_MESSAGE + ':' + messages[index].id, false) + setSelectedMessage(message) }}> @@ -56,19 +53,19 @@ const MessageGroupModelList: FC = ({ messages, selec ) : ( /* Expanded style display */ message.foldSelected)?.id || messages[0].id} onChange={(value) => { - setSelectedIndex(Number(value)) - EventEmitter.emit(EVENT_NAMES.LOCATE_MESSAGE + ':' + messages[Number(value)].id, false) + const message = messages.find((message) => message.id === value) as Message + setSelectedMessage(message) }} - options={messages.map((message, index) => ({ + options={messages.map((message) => ({ label: ( {message.model?.name} ), - value: index.toString() + value: message.id }))} size="small" /> diff --git a/src/renderer/src/pages/home/Messages/Messages.tsx b/src/renderer/src/pages/home/Messages/Messages.tsx index fb34ed9d..47ffa60d 100644 --- a/src/renderer/src/pages/home/Messages/Messages.tsx +++ b/src/renderer/src/pages/home/Messages/Messages.tsx @@ -27,6 +27,7 @@ import styled from 'styled-components' import ChatNavigation from './ChatNavigation' import MessageGroup from './MessageGroup' +import MessageAnchorLine from './MessageAnchorLine' import NarrowLayout from './NarrowLayout' import NewTopicButton from './NewTopicButton' import Prompt from './Prompt' @@ -39,7 +40,7 @@ interface MessagesProps { const Messages: React.FC = ({ assistant, topic, setActiveTopic }) => { const { t } = useTranslation() - const { showTopics, topicPosition, showAssistants } = useSettings() + const { showTopics, topicPosition, showAssistants, messageNavigation } = useSettings() const { updateTopic, addTopic } = useAssistant(assistant.id) const dispatch = useAppDispatch() const containerRef = useRef(null) @@ -225,7 +226,10 @@ const Messages: React.FC = ({ assistant, topic, setActiveTopic }) - + + {messageNavigation === 'anchor' && } + + {messageNavigation === 'buttons' && } ) } diff --git a/src/renderer/src/pages/home/Tabs/SettingsTab.tsx b/src/renderer/src/pages/home/Tabs/SettingsTab.tsx index fe386f12..68d5fc99 100644 --- a/src/renderer/src/pages/home/Tabs/SettingsTab.tsx +++ b/src/renderer/src/pages/home/Tabs/SettingsTab.tsx @@ -31,6 +31,7 @@ import { setRenderInputMessageAsMarkdown, setShowInputEstimatedTokens, setShowMessageDivider, + setMessageNavigation, setThoughtAutoCollapse } from '@renderer/store/settings' import { Assistant, AssistantSettings, CodeStyleVarious, ThemeMode, TranslateLanguageVarious } from '@renderer/types' @@ -76,7 +77,8 @@ const SettingsTab: FC = (props) => { autoTranslateWithSpace, pasteLongTextThreshold, multiModelMessageStyle, - thoughtAutoCollapse + thoughtAutoCollapse, + messageNavigation } = useSettings() const onUpdateAssistantSettings = (settings: Partial) => { @@ -361,6 +363,19 @@ const SettingsTab: FC = (props) => { + + {t('settings.messages.navigation')} + dispatch(setMessageNavigation(value as 'none' | 'buttons' | 'anchor'))} + style={{ width: 135 }}> + {t('settings.messages.navigation.none')} + {t('settings.messages.navigation.buttons')} + {t('settings.messages.navigation.anchor')} + + + {t('message.message.code_style')} { + state.settings.messageNavigation = 'buttons' + return state } } diff --git a/src/renderer/src/store/settings.ts b/src/renderer/src/store/settings.ts index 21c03934..b532c8e6 100644 --- a/src/renderer/src/store/settings.ts +++ b/src/renderer/src/store/settings.ts @@ -48,6 +48,7 @@ export interface SettingsState { codeStyle: CodeStyleVarious gridColumns: number gridPopoverTrigger: 'hover' | 'click' + messageNavigation: 'none' | 'buttons' | 'anchor' // webdav 配置 host, user, pass, path webdavHost: string webdavUser: string @@ -122,6 +123,7 @@ const initialState: SettingsState = { codeStyle: 'auto', gridColumns: 2, gridPopoverTrigger: 'hover', + messageNavigation: 'none', webdavHost: '', webdavUser: '', webdavPass: '', @@ -363,6 +365,9 @@ const settingsSlice = createSlice({ }, setJoplinUrl: (state, action: PayloadAction) => { state.joplinUrl = action.payload + }, + setMessageNavigation: (state, action: PayloadAction<'none' | 'buttons' | 'anchor'>) => { + state.messageNavigation = action.payload } } }) @@ -432,7 +437,8 @@ export const { setObsidianApiKey, setObsidianUrl, setJoplinToken, - setJoplinUrl + setJoplinUrl, + setMessageNavigation } = settingsSlice.actions export default settingsSlice.reducer diff --git a/src/renderer/src/types/index.ts b/src/renderer/src/types/index.ts index be44269e..1e3c3735 100644 --- a/src/renderer/src/types/index.ts +++ b/src/renderer/src/types/index.ts @@ -78,6 +78,10 @@ export type Message = { // MCP Tools mcpTools?: MCPToolResponse[] } + // 多模型消息样式 + multiModelMessageStyle?: 'horizontal' | 'vertical' | 'fold' | 'grid' + // fold时是否选中 + foldSelected?: boolean } export type Metrics = {