feat: Add a new grid mode for message display. (#1626)

* chore(version): 0.9.23

* feat(renderer): 新增网格模式的消息展示方式

* feat(message): 新增消息网格展示相关设置

* 根据 gridPopoverTrigger 属性动态设置消息分组的样式

* 在 MessageMenubar 组件中,各个按钮 click 事件阻止事件冒泡,避免打开 popover

* 多模型回答样式添加网格模式并优化消息样式

---------

Co-authored-by: kangfenmao <kangfenmao@qq.com>
This commit is contained in:
cl1107 2025-02-17 16:36:01 +08:00 committed by GitHub
parent e3115d00bf
commit bad2f15c1f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 221 additions and 51 deletions

View File

@ -364,6 +364,7 @@
"message.multi_model_style.fold": "Fold",
"message.multi_model_style.horizontal": "Horizontal",
"message.multi_model_style.vertical": "Vertical",
"message.multi_model_style.grid": "Grid",
"message.style": "Message style",
"message.style.bubble": "Bubble",
"message.style.plain": "Plain",
@ -636,6 +637,8 @@
"messages.input.title": "Input Settings",
"messages.markdown_rendering_input_message": "Markdown render input message",
"messages.math_engine": "Math engine",
"messages.grid_columns": "Message grid display columns",
"messages.grid_popover_trigger": "Grid detail trigger",
"messages.metrics": "{{time_first_token_millsec}}ms to first token | {{token_speed}} tok/sec",
"messages.model.title": "Model Settings",
"messages.title": "Message Settings",

View File

@ -363,6 +363,7 @@
"message.multi_model_style.fold": "折りたたむ",
"message.multi_model_style.horizontal": "水平",
"message.multi_model_style.vertical": "垂直",
"message.multi_model_style.grid": "グリッド",
"message.style": "メッセージスタイル",
"message.style.bubble": "バブル",
"message.style.plain": "プレーン",
@ -636,6 +637,8 @@
"messages.input.title": "入力設定",
"messages.markdown_rendering_input_message": "Markdownで入力メッセージをレンダリング",
"messages.math_engine": "数式エンジン",
"messages.grid_columns": "メッセージグリッドの表示列数",
"messages.grid_popover_trigger": "グリッド詳細トリガー",
"messages.metrics": "最初のトークンまでの時間 {{time_first_token_millsec}}ms | トークン速度 {{token_speed}} tok/sec",
"messages.model.title": "モデル設定",
"messages.title": "メッセージ設定",

View File

@ -364,6 +364,7 @@
"message.multi_model_style.fold": "Свернуть",
"message.multi_model_style.horizontal": "Горизонтальный",
"message.multi_model_style.vertical": "Вертикальный",
"message.multi_model_style.grid": "клетчатый вид",
"message.style": "Стиль сообщения",
"message.style.bubble": "Пузырь",
"message.style.plain": "Простой",
@ -637,6 +638,8 @@
"messages.math_engine": "Математический движок",
"messages.metrics": "{{time_first_token_millsec}}ms до первого токена | {{token_speed}} tok/sec",
"messages.model.title": "Настройки модели",
"messages.grid_columns": "Количество столбцов сетки сообщений",
"messages.grid_popover_trigger": "Триггер для отображения подробной информации в сетке",
"messages.title": "Настройки сообщений",
"messages.use_serif_font": "Использовать serif шрифт",
"model": "Модель по умолчанию",

View File

@ -366,6 +366,7 @@
"message.multi_model_style.fold": "折叠",
"message.multi_model_style.horizontal": "水平",
"message.multi_model_style.vertical": "垂直",
"message.multi_model_style.grid": "网格",
"message.style": "消息样式",
"message.style.bubble": "气泡",
"message.style.plain": "简洁",
@ -636,6 +637,8 @@
"messages.input.title": "输入设置",
"messages.markdown_rendering_input_message": "Markdown 渲染输入消息",
"messages.math_engine": "数学公式引擎",
"messages.grid_columns": "消息网格展示列数",
"messages.grid_popover_trigger": "网格详情触发",
"messages.metrics": "首字时延 {{time_first_token_millsec}}ms | 每秒 {{token_speed}} tokens",
"messages.model.title": "模型设置",
"messages.title": "消息设置",

View File

@ -364,6 +364,7 @@
"message.multi_model_style.fold": "折疊",
"message.multi_model_style.horizontal": "水平",
"message.multi_model_style.vertical": "垂直",
"message.multi_model_style.grid": "网格",
"message.style": "消息樣式",
"message.style.bubble": "氣泡",
"message.style.plain": "簡潔",
@ -635,6 +636,8 @@
"messages.input.show_estimated_tokens": "顯示預估 Token 數",
"messages.input.title": "輸入設定",
"messages.math_engine": "Markdown 渲染輸入訊息",
"messages.grid_columns": "消息網格展示列數",
"messages.grid_popover_trigger": "網格詳情觸發",
"messages.metrics": "首字時延 {{time_first_token_millsec}}ms | 每秒 {{token_speed}} tokens",
"messages.model.title": "模型設定",
"messages.title": "訊息設定",

View File

@ -1,4 +1,10 @@
import { ColumnHeightOutlined, ColumnWidthOutlined, DeleteOutlined, FolderOutlined } from '@ant-design/icons'
import {
ColumnHeightOutlined,
ColumnWidthOutlined,
DeleteOutlined,
FolderOutlined,
NumberOutlined
} from '@ant-design/icons'
import ModelAvatar from '@renderer/components/Avatar/ModelAvatar'
import { HStack } from '@renderer/components/Layout'
import Scrollbar from '@renderer/components/Scrollbar'
@ -6,7 +12,7 @@ import { useSettings } from '@renderer/hooks/useSettings'
import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService'
import { MultiModelMessageStyle } from '@renderer/store/settings'
import { Message, Model, Topic } from '@renderer/types'
import { Button, Segmented as AntdSegmented } from 'antd'
import { Button, Popover, Segmented as AntdSegmented } from 'antd'
import { Dispatch, FC, SetStateAction, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled, { css } from 'styled-components'
@ -32,7 +38,7 @@ const MessageGroup: FC<Props> = ({
onGetMessages,
onDeleteGroupMessages
}) => {
const { multiModelMessageStyle: multiModelMessageStyleSetting } = useSettings()
const { multiModelMessageStyle: multiModelMessageStyleSetting, gridColumns, gridPopoverTrigger } = useSettings()
const { t } = useTranslation()
const [multiModelMessageStyle, setMultiModelMessageStyle] =
@ -67,33 +73,89 @@ const MessageGroup: FC<Props> = ({
return (
<GroupContainer $isGrouped={isGrouped} $layout={multiModelMessageStyle}>
<GridContainer $count={messageLength} $layout={multiModelMessageStyle}>
{messages.map((message, index) => (
<MessageWrapper
$layout={multiModelMessageStyle}
$selected={index === selectedIndex}
$isGrouped={isGrouped}
key={message.id}
className={message.role === 'assistant' && isHorizontal && isGrouped ? 'group-message-wrapper' : ''}>
<MessageItem
isGrouped={isGrouped}
message={message}
topic={topic}
index={message.index}
hidePresetMessages={hidePresetMessages}
style={{ paddingTop: isGrouped && multiModelMessageStyle === 'horizontal' ? 0 : 15 }}
onSetMessages={onSetMessages}
onDeleteMessage={onDeleteMessage}
onGetMessages={onGetMessages}
/>
</MessageWrapper>
))}
<GridContainer $count={messageLength} $layout={multiModelMessageStyle} $gridColumns={gridColumns}>
{messages.map((message, index) =>
multiModelMessageStyle === 'grid' && message.role === 'assistant' && isGrouped ? (
<Popover
content={
<MessageWrapper
$layout={multiModelMessageStyle}
$selected={index === selectedIndex}
$isGrouped={isGrouped}
$isInPopover={true}
key={message.id}>
<MessageItem
isGrouped={isGrouped}
message={message}
topic={topic}
index={message.index}
hidePresetMessages={hidePresetMessages}
style={{
paddingTop: isGrouped && ['horizontal', 'grid'].includes(multiModelMessageStyle) ? 0 : 15
}}
onSetMessages={onSetMessages}
onDeleteMessage={onDeleteMessage}
onGetMessages={onGetMessages}
/>
</MessageWrapper>
}
trigger={gridPopoverTrigger}
styles={{ root: { maxWidth: '60vw', minWidth: '550px', overflowY: 'auto', zIndex: 1000 } }}
getPopupContainer={(triggerNode) => triggerNode.parentNode as HTMLElement}
key={message.id}>
<MessageWrapper
$layout={multiModelMessageStyle}
$selected={index === selectedIndex}
$isGrouped={isGrouped}
key={message.id}>
<MessageItem
isGrouped={isGrouped}
message={message}
topic={topic}
index={message.index}
hidePresetMessages={hidePresetMessages}
style={
gridPopoverTrigger === 'hover' && isGrouped
? {
paddingTop: isGrouped && ['horizontal', 'grid'].includes(multiModelMessageStyle) ? 0 : 15,
overflow: isGrouped ? 'hidden' : 'auto',
maxHeight: isGrouped ? '280px' : 'unset'
}
: undefined
}
onSetMessages={onSetMessages}
onDeleteMessage={onDeleteMessage}
onGetMessages={onGetMessages}
/>
</MessageWrapper>
</Popover>
) : (
<MessageWrapper
$layout={multiModelMessageStyle}
$selected={index === selectedIndex}
$isGrouped={isGrouped}
key={message.id}
className={message.role === 'assistant' && isHorizontal && isGrouped ? 'group-message-wrapper' : ''}>
<MessageItem
isGrouped={isGrouped}
message={message}
topic={topic}
index={message.index}
hidePresetMessages={hidePresetMessages}
style={{ paddingTop: isGrouped && ['horizontal', 'grid'].includes(multiModelMessageStyle) ? 0 : 15 }}
onSetMessages={onSetMessages}
onDeleteMessage={onDeleteMessage}
onGetMessages={onGetMessages}
/>
</MessageWrapper>
)
)}
</GridContainer>
{isGrouped && (
<GroupMenuBar className="group-menu-bar" $layout={multiModelMessageStyle}>
<HStack style={{ alignItems: 'center', flex: 1, overflow: 'hidden' }}>
<LayoutContainer>
{['fold', 'vertical', 'horizontal'].map((layout) => (
{['fold', 'vertical', 'horizontal', 'grid'].map((layout) => (
<LayoutOption
key={layout}
active={multiModelMessageStyle === layout}
@ -102,8 +164,10 @@ const MessageGroup: FC<Props> = ({
<FolderOutlined />
) : layout === 'horizontal' ? (
<ColumnWidthOutlined />
) : (
) : layout === 'vertical' ? (
<ColumnHeightOutlined />
) : (
<NumberOutlined />
)}
</LayoutOption>
))}
@ -143,30 +207,38 @@ const MessageGroup: FC<Props> = ({
}
const GroupContainer = styled.div<{ $isGrouped: boolean; $layout: MultiModelMessageStyle }>`
padding-top: ${({ $isGrouped, $layout }) => ($isGrouped && $layout === 'horizontal' ? '15px' : '0')};
padding-top: ${({ $isGrouped, $layout }) => ($isGrouped && 'horizontal' === $layout ? '15px' : '0')};
`
const GridContainer = styled.div<{ $count: number; $layout: MultiModelMessageStyle }>`
const GridContainer = styled.div<{ $count: number; $layout: MultiModelMessageStyle; $gridColumns: number }>`
width: 100%;
display: grid;
grid-template-columns: repeat(
${(props) => (['fold', 'vertical'].includes(props.$layout) ? 1 : props.$count)},
${({ $layout, $count }) => (['fold', 'vertical'].includes($layout) ? 1 : $count)},
minmax(550px, 1fr)
);
gap: ${({ $layout }) => ($layout === 'horizontal' ? '16px' : '0')};
@media (max-width: 800px) {
grid-template-columns: repeat(
${(props) => (['fold', 'vertical'].includes(props.$layout) ? 1 : props.$count)},
${({ $layout, $count }) => (['fold', 'vertical'].includes($layout) ? 1 : $count)},
minmax(400px, 1fr)
);
}
overflow-y: auto;
${({ $gridColumns, $layout, $count }) =>
$layout === 'grid' &&
css`
grid-template-columns: repeat(${$count > 1 ? $gridColumns || 2 : 1}, minmax(0, 1fr));
grid-template-rows: auto;
gap: 16px;
`}
`
interface MessageWrapperProps {
$layout: 'fold' | 'horizontal' | 'vertical'
$layout: 'fold' | 'horizontal' | 'vertical' | 'grid'
$selected: boolean
$isGrouped: boolean
$isInPopover?: boolean
}
const MessageWrapper = styled(Scrollbar)<MessageWrapperProps>`
@ -193,6 +265,22 @@ const MessageWrapper = styled(Scrollbar)<MessageWrapperProps>`
}
return ''
}}
${({ $layout, $isInPopover, $isGrouped }) =>
$layout === 'grid' && $isGrouped
? css`
max-height: ${$isInPopover ? '50vh' : '300px'};
overflow-y: auto;
border: 0.5px solid var(--color-border);
padding: 10px;
border-radius: 6px;
`
: css`
overflow-y: auto;
border: 0.5px solid transparent;
padding: 0 10px;
border-radius: 6px;
`}
`
const GroupMenuBar = styled.div<{ $layout: MultiModelMessageStyle }>`

View File

@ -60,12 +60,16 @@ const MessageMenubar: FC<Props> = (props) => {
const isUserMessage = message.role === 'user'
const onCopy = useCallback(() => {
navigator.clipboard.writeText(removeTrailingDoubleSpaces(message.content))
window.message.success({ content: t('message.copied'), key: 'copy-message' })
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}, [message.content, t])
const onCopy = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation()
navigator.clipboard.writeText(removeTrailingDoubleSpaces(message.content))
window.message.success({ content: t('message.copied'), key: 'copy-message' })
setCopied(true)
setTimeout(() => setCopied(false), 2000)
},
[message.content, t]
)
const onNewBranch = useCallback(async () => {
await modelGenerating()
@ -195,14 +199,16 @@ const MessageMenubar: FC<Props> = (props) => {
[message, onEdit, onNewBranch, t]
)
const onRegenerate = async () => {
const onRegenerate = async (e: React.MouseEvent | undefined) => {
e?.stopPropagation?.()
await modelGenerating()
const selectedModel = isGrouped ? model : assistantModel
const _message = resetAssistantMessage(message, selectedModel)
onEditMessage?.(_message)
}
const onMentionModel = async () => {
const onMentionModel = async (e: React.MouseEvent) => {
e.stopPropagation()
await modelGenerating()
const selectedModel = await SelectModelPopup.show({ model })
if (!selectedModel) return
@ -216,9 +222,13 @@ const MessageMenubar: FC<Props> = (props) => {
onEditMessage?.(_message)
}
const onUseful = useCallback(() => {
onEditMessage?.({ ...message, useful: !message.useful })
}, [message, onEditMessage])
const onUseful = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation()
onEditMessage?.({ ...message, useful: !message.useful })
},
[message, onEditMessage]
)
return (
<MenusBar className={`menubar ${isLastMessage && 'show'}`}>
@ -270,13 +280,14 @@ const MessageMenubar: FC<Props> = (props) => {
key: 'translate-close',
onClick: () => onEditMessage?.({ ...message, translatedContent: undefined })
}
]
],
onClick: (e) => e.domEvent.stopPropagation()
}}
trigger={['click']}
placement="topRight"
arrow>
<Tooltip title={t('chat.translate')} mouseEnterDelay={1.2}>
<ActionButton className="message-action-button">
<ActionButton className="message-action-button" onClick={(e) => e.stopPropagation()}>
<TranslationOutlined />
</ActionButton>
</Tooltip>
@ -298,14 +309,25 @@ const MessageMenubar: FC<Props> = (props) => {
<Tooltip title={t('common.delete')} mouseEnterDelay={1}>
<ActionButton
className="message-action-button"
onClick={isGrouped ? () => onDeleteMessage?.(message) : undefined}>
onClick={
isGrouped
? (e) => {
e.stopPropagation()
onDeleteMessage?.(message)
}
: (e) => e.stopPropagation()
}>
<DeleteOutlined />
</ActionButton>
</Tooltip>
</Popconfirm>
{!isUserMessage && (
<Dropdown menu={{ items: dropdownItems }} trigger={['click']} placement="topRight" arrow>
<ActionButton className="message-action-button">
<Dropdown
menu={{ items: dropdownItems, onClick: (e) => e.domEvent.stopPropagation() }}
trigger={['click']}
placement="topRight"
arrow>
<ActionButton className="message-action-button" onClick={(e) => e.stopPropagation()}>
<MenuOutlined />
</ActionButton>
</Dropdown>

View File

@ -19,6 +19,8 @@ import {
setCodeShowLineNumbers,
setCodeStyle,
setFontSize,
setGridColumns,
setGridPopoverTrigger,
setMathEngine,
setMessageFont,
setMessageStyle,
@ -42,13 +44,14 @@ interface Props {
const SettingsTab: FC<Props> = (props) => {
const { assistant, updateAssistantSettings, updateAssistant } = useAssistant(props.assistant.id)
const { messageStyle, codeStyle, fontSize, language } = useSettings()
const { messageStyle, codeStyle, fontSize, language, gridColumns } = useSettings()
const [temperature, setTemperature] = useState(assistant?.settings?.temperature ?? DEFAULT_TEMPERATURE)
const [contextCount, setContextCount] = useState(assistant?.settings?.contextCount ?? DEFAULT_CONTEXTCOUNT)
const [enableMaxTokens, setEnableMaxTokens] = useState(assistant?.settings?.enableMaxTokens ?? false)
const [maxTokens, setMaxTokens] = useState(assistant?.settings?.maxTokens ?? 0)
const [fontSizeValue, setFontSizeValue] = useState(fontSize)
const [gridColumnsValue, setGridColumnsValue] = useState(gridColumns)
const [streamOutput, setStreamOutput] = useState(assistant?.settings?.streamOutput ?? true)
const { t } = useTranslation()
@ -69,7 +72,8 @@ const SettingsTab: FC<Props> = (props) => {
mathEngine,
autoTranslateWithSpace,
pasteLongTextThreshold,
multiModelMessageStyle
multiModelMessageStyle,
gridPopoverTrigger
} = useSettings()
const onUpdateAssistantSettings = (settings: Partial<AssistantSettings>) => {
@ -283,6 +287,7 @@ const SettingsTab: FC<Props> = (props) => {
<Select.Option value="fold">{t('message.message.multi_model_style.fold')}</Select.Option>
<Select.Option value="vertical">{t('message.message.multi_model_style.vertical')}</Select.Option>
<Select.Option value="horizontal">{t('message.message.multi_model_style.horizontal')}</Select.Option>
<Select.Option value="grid">{t('message.message.multi_model_style.grid')}</Select.Option>
</Select>
</SettingRow>
<SettingDivider />
@ -313,6 +318,34 @@ const SettingsTab: FC<Props> = (props) => {
</Select>
</SettingRow>
<SettingDivider />
<SettingRow>
<SettingRowTitleSmall>{t('settings.messages.grid_popover_trigger')}</SettingRowTitleSmall>
<Select
value={gridPopoverTrigger || 'hover'}
onChange={(value) => dispatch(setGridPopoverTrigger(value))}
style={{ width: 135 }}
size="small">
<Select.Option value="hover">hover</Select.Option>
<Select.Option value="click">click</Select.Option>
</Select>
</SettingRow>
<SettingDivider />
<SettingRow>
<SettingRowTitleSmall>{t('settings.messages.grid_columns')}</SettingRowTitleSmall>
</SettingRow>
<Row align="middle" gutter={10}>
<Col span={24}>
<Slider
value={gridColumnsValue}
onChange={(value) => setGridColumnsValue(value)}
onChangeComplete={(value) => dispatch(setGridColumns(value))}
min={2}
max={9}
step={1}
/>
</Col>
</Row>
<SettingDivider />
<SettingRow>
<SettingRowTitleSmall>{t('settings.font_size.title')}</SettingRowTitleSmall>
</SettingRow>

View File

@ -44,6 +44,8 @@ export interface SettingsState {
mathEngine: 'MathJax' | 'KaTeX'
messageStyle: 'plain' | 'bubble'
codeStyle: CodeStyleVarious
gridColumns: number
gridPopoverTrigger: 'hover' | 'click'
// webdav 配置 host, user, pass, path
webdavHost: string
webdavUser: string
@ -69,7 +71,7 @@ export interface SettingsState {
notionApiKey: string | null
}
export type MultiModelMessageStyle = 'horizontal' | 'vertical' | 'fold'
export type MultiModelMessageStyle = 'horizontal' | 'vertical' | 'fold' | 'grid'
const initialState: SettingsState = {
showAssistants: true,
@ -99,6 +101,8 @@ const initialState: SettingsState = {
mathEngine: 'KaTeX',
messageStyle: 'plain',
codeStyle: 'auto',
gridColumns: 2,
gridPopoverTrigger: 'hover',
webdavHost: '',
webdavUser: '',
webdavPass: '',
@ -224,6 +228,12 @@ const settingsSlice = createSlice({
setMathEngine: (state, action: PayloadAction<'MathJax' | 'KaTeX'>) => {
state.mathEngine = action.payload
},
setGridColumns: (state, action: PayloadAction<number>) => {
state.gridColumns = action.payload
},
setGridPopoverTrigger: (state, action: PayloadAction<'hover' | 'click'>) => {
state.gridPopoverTrigger = action.payload
},
setMessageStyle: (state, action: PayloadAction<'plain' | 'bubble'>) => {
state.messageStyle = action.payload
},
@ -265,7 +275,7 @@ const settingsSlice = createSlice({
setEnableQuickAssistant: (state, action: PayloadAction<boolean>) => {
state.enableQuickAssistant = action.payload
},
setMultiModelMessageStyle: (state, action: PayloadAction<'horizontal' | 'vertical' | 'fold'>) => {
setMultiModelMessageStyle: (state, action: PayloadAction<'horizontal' | 'vertical' | 'fold' | 'grid'>) => {
state.multiModelMessageStyle = action.payload
},
setNotionDatabaseID: (state, action: PayloadAction<string>) => {
@ -310,6 +320,8 @@ export const {
setCodeShowLineNumbers,
setCodeCollapsible,
setMathEngine,
setGridColumns,
setGridPopoverTrigger,
setMessageStyle,
setCodeStyle,
setTranslateModelPrompt,