From a0be911dc9a409bd26d13ef115c3d636f90c18b9 Mon Sep 17 00:00:00 2001 From: Teo Date: Wed, 9 Apr 2025 17:00:34 +0800 Subject: [PATCH] feat: Optimize QuickPanel (#4604) * feat(QuickPanel): enhance close action options and improve input handling - Added 'enter_empty' as a new close action option for QuickPanel. - Refactored input handling to include a delay before clearing search text after panel closure. - Updated keyboard event handling to prevent default actions for specific keys. - Improved styling for selected and focused states in QuickPanel components. - Enhanced AttachmentPreview to utilize a separate FileNameRender component for better readability and functionality. * feat(AttachmentPreview): enhance file icon rendering and styling * feat(CustomTag): add closable functionality and improve styling - Enhanced CustomTag component to support closable tags with an onClose callback. - Updated styling for better visual integration and added hover effects for the close icon. - Refactored usage of CustomTag in AttachmentPreview, KnowledgeBaseInput, and MentionModelsInput components for consistency. * feat(SelectModelPopup, QuickPanel): update tag component and enhance search functionality * feat(Inputbar, SettingsTab): add enable quick panel triggers setting and update translations * feat(QuickPanel): integrate color library for dynamic styling and update package dependencies --- package.json | 1 + src/renderer/src/components/CustomTag.tsx | 33 +++- .../src/components/ModelTagsWithLabel.tsx | 6 +- .../components/Popups/SelectModelPopup.tsx | 10 +- .../src/components/QuickPanel/types.ts | 2 +- .../src/components/QuickPanel/view.tsx | 93 ++++++++-- src/renderer/src/i18n/locales/en-us.json | 1 + src/renderer/src/i18n/locales/ja-jp.json | 1 + src/renderer/src/i18n/locales/ru-ru.json | 1 + src/renderer/src/i18n/locales/zh-cn.json | 1 + src/renderer/src/i18n/locales/zh-tw.json | 1 + .../pages/home/Inputbar/AttachmentPreview.tsx | 173 ++++++++++++------ .../src/pages/home/Inputbar/Inputbar.tsx | 24 ++- .../home/Inputbar/KnowledgeBaseInput.tsx | 41 ++--- .../home/Inputbar/MentionModelsButton.tsx | 2 +- .../home/Inputbar/MentionModelsInput.tsx | 45 ++--- .../src/pages/home/Messages/Messages.tsx | 1 + .../src/pages/home/Tabs/SettingsTab.tsx | 13 +- .../src/pages/translate/TranslatePage.tsx | 1 - src/renderer/src/store/index.ts | 2 +- src/renderer/src/store/migrate.ts | 8 + src/renderer/src/store/settings.ts | 6 + yarn.lock | 36 ++++ 23 files changed, 363 insertions(+), 139 deletions(-) diff --git a/package.json b/package.json index 0da54041..2cbbf43f 100644 --- a/package.json +++ b/package.json @@ -72,6 +72,7 @@ "@types/react-infinite-scroll-component": "^5.0.0", "@xyflow/react": "^12.4.4", "adm-zip": "^0.5.16", + "color": "^5.0.0", "diff": "^7.0.0", "docx": "^9.0.2", "electron-log": "^5.1.5", diff --git a/src/renderer/src/components/CustomTag.tsx b/src/renderer/src/components/CustomTag.tsx index 8ad42ac3..72f637ad 100644 --- a/src/renderer/src/components/CustomTag.tsx +++ b/src/renderer/src/components/CustomTag.tsx @@ -1,3 +1,4 @@ +import { CloseOutlined } from '@ant-design/icons' import { Tooltip } from 'antd' import { FC } from 'react' import styled from 'styled-components' @@ -8,13 +9,16 @@ interface CustomTagProps { color: string size?: number tooltip?: string + closable?: boolean + onClose?: () => void } -const CustomTag: FC = ({ children, icon, color, size = 12, tooltip }) => { +const CustomTag: FC = ({ children, icon, color, size = 12, tooltip, closable = false, onClose }) => { return ( - + {icon && icon} {children} + {closable && } ) @@ -22,19 +26,42 @@ const CustomTag: FC = ({ children, icon, color, size = 12, toolt export default CustomTag -const Tag = styled.div<{ $color: string; $size: number }>` +const Tag = styled.div<{ $color: string; $size: number; $closable: boolean }>` display: inline-flex; align-items: center; gap: 4px; padding: ${({ $size }) => $size / 3}px ${({ $size }) => $size * 0.8}px; + padding-right: ${({ $closable, $size }) => ($closable ? $size * 1.8 : $size * 0.8)}px; border-radius: 99px; color: ${({ $color }) => $color}; background-color: ${({ $color }) => $color + '20'}; font-size: ${({ $size }) => $size}px; line-height: 1; white-space: nowrap; + position: relative; .iconfont { font-size: ${({ $size }) => $size}px; color: ${({ $color }) => $color}; } ` + +const CloseIcon = styled(CloseOutlined)<{ $size: number; $color: string }>` + cursor: pointer; + font-size: ${({ $size }) => $size * 0.8}px; + color: ${({ $color }) => $color}; + display: flex; + align-items: center; + justify-content: center; + position: absolute; + right: ${({ $size }) => $size * 0.2}px; + top: ${({ $size }) => $size * 0.2}px; + bottom: ${({ $size }) => $size * 0.2}px; + border-radius: 99px; + transition: all 0.2s ease; + aspect-ratio: 1; + line-height: 1; + &:hover { + background-color: #da8a8a; + color: #ffffff; + } +` diff --git a/src/renderer/src/components/ModelTagsWithLabel.tsx b/src/renderer/src/components/ModelTagsWithLabel.tsx index d119eeb3..b6cfa1a7 100644 --- a/src/renderer/src/components/ModelTagsWithLabel.tsx +++ b/src/renderer/src/components/ModelTagsWithLabel.tsx @@ -23,6 +23,7 @@ interface ModelTagsProps { showToolsCalling?: boolean size?: number showLabel?: boolean + style?: React.CSSProperties } const ModelTagsWithLabel: FC = ({ @@ -31,7 +32,8 @@ const ModelTagsWithLabel: FC = ({ showReasoning = true, showToolsCalling = true, size = 12, - showLabel = true + showLabel = true, + style }) => { const { t } = useTranslation() const [_showLabel, _setShowLabel] = useState(showLabel) @@ -64,7 +66,7 @@ const ModelTagsWithLabel: FC = ({ }, [showLabel]) return ( - + {isVisionModel(model) && ( ['items'][number] @@ -130,7 +130,7 @@ const PopupContainer: React.FC = ({ model, resolve }) => { label: ( - {m?.name} + {m?.name} { @@ -184,7 +184,7 @@ const PopupContainer: React.FC = ({ model, resolve }) => { {m.model?.name} | {m.provider.isSystem ? t(`provider.${m.provider.id}`) : m.provider.name} {' '} - + { @@ -481,6 +481,10 @@ const StyledMenu = styled(Menu)` } } } + + .anticon { + min-width: auto; + } } ` diff --git a/src/renderer/src/components/QuickPanel/types.ts b/src/renderer/src/components/QuickPanel/types.ts index c26a7842..e122aa1d 100644 --- a/src/renderer/src/components/QuickPanel/types.ts +++ b/src/renderer/src/components/QuickPanel/types.ts @@ -1,6 +1,6 @@ import React from 'react' -export type QuickPanelCloseAction = 'enter' | 'click' | 'esc' | 'outsideclick' | string | undefined +export type QuickPanelCloseAction = 'enter' | 'click' | 'esc' | 'outsideclick' | 'enter_empty' | string | undefined export type QuickPanelCallBackOptions = { symbol: string action: QuickPanelCloseAction diff --git a/src/renderer/src/components/QuickPanel/view.tsx b/src/renderer/src/components/QuickPanel/view.tsx index d41c1871..d89ccc3a 100644 --- a/src/renderer/src/components/QuickPanel/view.tsx +++ b/src/renderer/src/components/QuickPanel/view.tsx @@ -2,9 +2,12 @@ import { CheckOutlined, RightOutlined } from '@ant-design/icons' import { isMac } from '@renderer/config/constant' import { classNames } from '@renderer/utils' import { Flex } from 'antd' +import { theme } from 'antd' +import Color from 'color' import { t } from 'i18next' import React, { use, useCallback, useDeferredValue, useEffect, useMemo, useRef, useState } from 'react' import styled from 'styled-components' +import * as tinyPinyin from 'tiny-pinyin' import { QuickPanelContext } from './provider' import { QuickPanelCallBackOptions, QuickPanelCloseAction, QuickPanelListItem, QuickPanelOpenOptions } from './types' @@ -27,13 +30,19 @@ export const QuickPanelView: React.FC = ({ setInputText }) => { throw new Error('QuickPanel must be used within a QuickPanelProvider') } + const { token } = theme.useToken() + const colorPrimary = Color(token.colorPrimary || '#008000') + const selectedColor = colorPrimary.alpha(0.15).toString() + const selectedColorHover = colorPrimary.alpha(0.2).toString() + const ASSISTIVE_KEY = isMac ? '⌘' : 'Ctrl' const [isAssistiveKeyPressed, setIsAssistiveKeyPressed] = useState(false) // 避免上下翻页时,鼠标干扰 const [isMouseOver, setIsMouseOver] = useState(false) - const [index, setIndex] = useState(ctx.defaultIndex) + const [_index, setIndex] = useState(ctx.defaultIndex) + const index = useDeferredValue(_index) const [historyPanel, setHistoryPanel] = useState([]) const bodyRef = useRef(null) @@ -65,7 +74,21 @@ export const QuickPanelView: React.FC = ({ setInputText }) => { filterText += item.description } - return filterText.toLowerCase().includes(_searchText.toLowerCase()) + const lowerFilterText = filterText.toLowerCase() + const lowerSearchText = _searchText.toLowerCase() + + if (lowerFilterText.includes(lowerSearchText)) { + return true + } + + if (tinyPinyin.isSupported() && /[\u4e00-\u9fa5]/.test(filterText)) { + const pinyinText = tinyPinyin.convertToPinyin(filterText, '', true) + if (pinyinText.toLowerCase().includes(lowerSearchText)) { + return true + } + } + + return false }) setIndex(newList.length > 0 ? ctx.defaultIndex || 0 : -1) @@ -120,7 +143,7 @@ export const QuickPanelView: React.FC = ({ setInputText }) => { if (textArea) { setInputText(textArea.value) } - } else if (action && !['outsideclick', 'esc'].includes(action)) { + } else if (action && !['outsideclick', 'esc', 'enter_empty'].includes(action)) { clearSearchText(true) } }, @@ -175,6 +198,7 @@ export const QuickPanelView: React.FC = ({ setInputText }) => { }, [searchText]) // 获取当前输入的搜索词 + const isComposing = useRef(false) useEffect(() => { if (!ctx.isVisible) return @@ -196,11 +220,25 @@ export const QuickPanelView: React.FC = ({ setInputText }) => { } } + const handleCompositionUpdate = () => { + isComposing.current = true + } + + const handleCompositionEnd = () => { + isComposing.current = false + } + textArea.addEventListener('input', handleInput) + textArea.addEventListener('compositionupdate', handleCompositionUpdate) + textArea.addEventListener('compositionend', handleCompositionEnd) return () => { textArea.removeEventListener('input', handleInput) - setSearchText('') + textArea.removeEventListener('compositionupdate', handleCompositionUpdate) + textArea.removeEventListener('compositionend', handleCompositionEnd) + setTimeout(() => { + setSearchText('') + }, 200) // 等待面板关闭动画结束后,再清空搜索词 } // eslint-disable-next-line react-hooks/exhaustive-deps }, [ctx.isVisible]) @@ -236,7 +274,7 @@ export const QuickPanelView: React.FC = ({ setInputText }) => { } } - if (['ArrowUp', 'ArrowDown', 'PageUp', 'PageDown', 'Enter', 'Escape'].includes(e.key)) { + if (['ArrowUp', 'ArrowDown', 'PageUp', 'PageDown', 'Escape'].includes(e.key)) { e.preventDefault() e.stopPropagation() setIsMouseOver(false) @@ -312,8 +350,16 @@ export const QuickPanelView: React.FC = ({ setInputText }) => { break case 'Enter': + if (isComposing.current) return + if (list?.[index]) { + e.preventDefault() + e.stopPropagation() + setIsMouseOver(false) + handleItemAction(list[index], 'enter') + } else { + handleClose('enter_empty') } break case 'Escape': @@ -366,7 +412,11 @@ export const QuickPanelView: React.FC = ({ setInputText }) => { }, [ctx.isVisible]) return ( - + setIsMouseOver(true)}> {list.map((item, i) => ( @@ -450,9 +500,14 @@ export const QuickPanelView: React.FC = ({ setInputText }) => { ) } -const QuickPanelContainer = styled.div<{ $pageSize: number }>` +const QuickPanelContainer = styled.div<{ + $pageSize: number + $selectedColor: string + $selectedColorHover: string +}>` --focused-color: rgba(0, 0, 0, 0.06); - --selected-color: rgba(0, 0, 0, 0.03); + --selected-color: ${(props) => props.$selectedColor}; + --selected-color-dark: ${(props) => props.$selectedColorHover}; max-height: 0; position: absolute; top: 1px; @@ -465,26 +520,35 @@ const QuickPanelContainer = styled.div<{ $pageSize: number }>` transition: max-height 0.2s ease; overflow: hidden; pointer-events: none; + &.visible { pointer-events: auto; max-height: ${(props) => props.$pageSize * 31 + 100}px; } body[theme-mode='dark'] & { --focused-color: rgba(255, 255, 255, 0.1); - --selected-color: rgba(255, 255, 255, 0.03); } ` const QuickPanelBody = styled.div` - background-color: rgba(240, 240, 240, 0.5); - backdrop-filter: blur(35px) saturate(150%); border-radius: 8px 8px 0 0; padding: 5px 0; border-width: 0.5px 0.5px 0 0.5px; border-style: solid; border-color: var(--color-border); - body[theme-mode='dark'] & { - background-color: rgba(40, 40, 40, 0.4); + position: relative; + + &::before { + content: ''; + position: absolute; + inset: 0; + background-color: rgba(240, 240, 240, 0.5); + backdrop-filter: blur(35px) saturate(150%); + z-index: -1; + + body[theme-mode='dark'] & { + background-color: rgba(40, 40, 40, 0.4); + } } ` @@ -541,6 +605,9 @@ const QuickPanelItem = styled.div` margin-bottom: 1px; &.selected { background-color: var(--selected-color); + &.focused { + background-color: var(--selected-color-dark); + } } &.focused { background-color: var(--focused-color); diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index 96c0366e..164266d0 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -1113,6 +1113,7 @@ "messages.input.send_shortcuts": "Send shortcuts", "messages.input.show_estimated_tokens": "Show estimated tokens", "messages.input.title": "Input Settings", + "messages.input.enable_quick_triggers": "Enable '/' and '@' triggers", "messages.markdown_rendering_input_message": "Markdown render input message", "messages.math_engine": "Math engine", "messages.metrics": "{{time_first_token_millsec}}ms to first token | {{token_speed}} tok/sec", diff --git a/src/renderer/src/i18n/locales/ja-jp.json b/src/renderer/src/i18n/locales/ja-jp.json index ef40a614..a7908d37 100644 --- a/src/renderer/src/i18n/locales/ja-jp.json +++ b/src/renderer/src/i18n/locales/ja-jp.json @@ -1112,6 +1112,7 @@ "messages.input.send_shortcuts": "送信ショートカット", "messages.input.show_estimated_tokens": "推定トークン数を表示", "messages.input.title": "入力設定", + "messages.input.enable_quick_triggers": "'/' と '@' を有効にしてクイックメニューを表示します。", "messages.markdown_rendering_input_message": "Markdownで入力メッセージをレンダリング", "messages.math_engine": "数式エンジン", "messages.metrics": "最初のトークンまでの時間 {{time_first_token_millsec}}ms | トークン速度 {{token_speed}} tok/sec", diff --git a/src/renderer/src/i18n/locales/ru-ru.json b/src/renderer/src/i18n/locales/ru-ru.json index 334bfbf3..54e7b6a7 100644 --- a/src/renderer/src/i18n/locales/ru-ru.json +++ b/src/renderer/src/i18n/locales/ru-ru.json @@ -1112,6 +1112,7 @@ "messages.input.send_shortcuts": "Горячие клавиши для отправки", "messages.input.show_estimated_tokens": "Показывать затраты токенов", "messages.input.title": "Настройки ввода", + "messages.input.enable_quick_triggers": "Включите '/' и '@', чтобы вызвать быстрое меню.", "messages.markdown_rendering_input_message": "Отображение ввода в формате Markdown", "messages.math_engine": "Математический движок", "messages.metrics": "{{time_first_token_millsec}}ms до первого токена | {{token_speed}} tok/sec", diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index 45aa2270..0cccfd4b 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -1113,6 +1113,7 @@ "messages.input.send_shortcuts": "发送快捷键", "messages.input.show_estimated_tokens": "显示预估 Token 数", "messages.input.title": "输入设置", + "messages.input.enable_quick_triggers": "启用 '/' 和 '@' 触发快捷菜单", "messages.markdown_rendering_input_message": "Markdown 渲染输入消息", "messages.math_engine": "数学公式引擎", "messages.metrics": "首字时延 {{time_first_token_millsec}}ms | 每秒 {{token_speed}} tokens", diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index 9301d45b..a2b6ed20 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -1112,6 +1112,7 @@ "messages.input.send_shortcuts": "傳送快捷鍵", "messages.input.show_estimated_tokens": "顯示預估 Token 數", "messages.input.title": "輸入設定", + "messages.input.enable_quick_triggers": "啟用 '/' 和 '@' 觸發快捷選單", "messages.markdown_rendering_input_message": "Markdown 渲染輸入訊息", "messages.math_engine": "Markdown 渲染輸入訊息", "messages.metrics": "首字延遲 {{time_first_token_millsec}}ms | 每秒 {{token_speed}} tokens", diff --git a/src/renderer/src/pages/home/Inputbar/AttachmentPreview.tsx b/src/renderer/src/pages/home/Inputbar/AttachmentPreview.tsx index fc763982..343e4f6a 100644 --- a/src/renderer/src/pages/home/Inputbar/AttachmentPreview.tsx +++ b/src/renderer/src/pages/home/Inputbar/AttachmentPreview.tsx @@ -1,7 +1,22 @@ -import { FileOutlined } from '@ant-design/icons' +import { + FileExcelFilled, + FileImageFilled, + FileMarkdownFilled, + FilePdfFilled, + FilePptFilled, + FileTextFilled, + FileUnknownFilled, + FileWordFilled, + FileZipFilled, + FolderOpenFilled, + GlobalOutlined, + LinkOutlined +} from '@ant-design/icons' +import CustomTag from '@renderer/components/CustomTag' import FileManager from '@renderer/services/FileManager' import { FileType } from '@renderer/types' -import { ConfigProvider, Image, Tag } from 'antd' +import { formatFileSize } from '@renderer/utils' +import { Flex, Image, Tooltip } from 'antd' import { isEmpty } from 'lodash' import { FC, useState } from 'react' import styled from 'styled-components' @@ -11,74 +26,128 @@ interface Props { setFiles: (files: FileType[]) => void } -const AttachmentPreview: FC = ({ files, setFiles }) => { - const [visibleId, setVisibleId] = useState('') - +const FileNameRender: FC<{ file: FileType }> = ({ file }) => { + const [visible, setVisible] = useState(false) const isImage = (ext: string) => { return ['.png', '.jpg', '.jpeg', '.gif', '.bmp', '.webp'].includes(ext) } + return ( + + {isImage(file.ext) && ( + + )} + {formatFileSize(file.size)} + + }> + { + if (isImage(file.ext)) { + setVisible(true) + return + } + const path = FileManager.getSafePath(file) + if (path) { + window.api.file.openPath(path) + } + }}> + {FileManager.formatFileName(file)} + + + ) +} + +const AttachmentPreview: FC = ({ files, setFiles }) => { + const getFileIcon = (type?: string) => { + if (!type) return + + const ext = type.toLowerCase() + + if (['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp'].includes(ext)) { + return + } + + if (['.doc', '.docx'].includes(ext)) { + return + } + if (['.xls', '.xlsx'].includes(ext)) { + return + } + if (['.ppt', '.pptx'].includes(ext)) { + return + } + if (ext === '.pdf') { + return + } + if (['.md', '.markdown'].includes(ext)) { + return + } + + if (['.zip', '.rar', '.7z', '.tar', '.gz'].includes(ext)) { + return + } + + if (['.txt', '.json', '.log', '.yml', '.yaml', '.xml', '.csv'].includes(ext)) { + return + } + + if (['.url'].includes(ext)) { + return + } + + if (['.sitemap'].includes(ext)) { + return + } + + if (['.folder'].includes(ext)) { + return + } + + return + } + if (isEmpty(files)) { return null } return ( - - {files.map((file) => ( - } - bordered={false} - color="cyan" - closable - onClose={() => setFiles(files.filter((f) => f.id !== file.id))}> - { - if (isImage(file.ext)) { - setVisibleId(file.id) - return - } - const path = FileManager.getSafePath(file) - if (path) { - window.api.file.openPath(path) - } - }}> - {FileManager.formatFileName(file)} - {isImage(file.ext) && ( - { - setVisibleId(value ? file.id : '') - } - }} - /> - )} - - - ))} - + {files.map((file) => ( + setFiles(files.filter((f) => f.id !== file.id))}> + + + ))} ) } const ContentContainer = styled.div` width: 100%; + padding: 5px 15px 5px 15px; display: flex; flex-wrap: wrap; - gap: 4px 0; - padding: 5px 15px 0 10px; + gap: 4px 4px; ` const FileName = styled.span` diff --git a/src/renderer/src/pages/home/Inputbar/Inputbar.tsx b/src/renderer/src/pages/home/Inputbar/Inputbar.tsx index 899f2380..79f377e1 100644 --- a/src/renderer/src/pages/home/Inputbar/Inputbar.tsx +++ b/src/renderer/src/pages/home/Inputbar/Inputbar.tsx @@ -84,7 +84,8 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = pasteLongTextAsFile, pasteLongTextThreshold, showInputEstimatedTokens, - autoTranslateWithSpace + autoTranslateWithSpace, + enableQuickPanelTriggers } = useSettings() const [expended, setExpend] = useState(false) const [estimateTokenCount, setEstimateTokenCount] = useState(0) @@ -533,7 +534,7 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = const cursorPosition = textArea?.selectionStart ?? 0 const lastSymbol = newText[cursorPosition - 1] - if (!quickPanel.isVisible && lastSymbol === '/') { + if (enableQuickPanelTriggers && !quickPanel.isVisible && lastSymbol === '/') { quickPanel.open({ title: t('settings.quickPanel.title'), list: quickPanelMenu, @@ -541,7 +542,7 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = }) } - if (!quickPanel.isVisible && lastSymbol === '@') { + if (enableQuickPanelTriggers && !quickPanel.isVisible && lastSymbol === '@') { mentionModelsButtonRef.current?.openQuickPanel() } } @@ -881,12 +882,16 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = id="inputbar" className={classNames('inputbar-container', inputFocus && 'focus')} ref={containerRef}> - - - + {files.length > 0 && } + {selectedKnowledgeBases.length > 0 && ( + + )} + {mentionModels.length > 0 && ( + + )}