From f9be0e0d26f3c8147d1fd8ca91b32312c757f4ae Mon Sep 17 00:00:00 2001 From: Teo Date: Sat, 5 Apr 2025 16:05:28 +0800 Subject: [PATCH] feat(QuickPanel): Add new feature QuickPanel, unify input box operation. (#4356) * feat(QuickPanel): Add new feature QuickPanel, unify input box operation. * refactor(Inputbar): Remove unused quickPanelSymbol reference and update navigation action in KnowledgeBaseButton * fix(Inputbar): Prevent translation action when input text is empty and reorder MentionModelsInput component * refactor(Inputbar): Add resizeTextArea prop to QuickPhrasesButton for better text area management * feat(i18n): Add translation strings for input actions and quick phrases in multiple languages * feat(Inputbar): Enhance AttachmentButton to support ref forwarding and quick panel opening * feat(i18n, Inputbar): Add upload file translation strings and enhance file count display in multiple languages * style(QuickPanel): Update background color for QuickPanelBody and add dark theme support * fix(Inputbar): Update upload label for vision model support * feat(QuickPanel): Add outside click handling and update close action type * feat(QuickPanel): Improve scrolling behavior with key press handling and add PageUp/PageDown support * feat(i18n): Add translation strings for menu description in multiple languages * refactor(QuickPhrasesButton): simplify phrase mapping by removing index-based disabling * fix(QuickPanel): correct regex pattern for search functionality * refactor(QuickPanel): remove searchText state and related logic for cleaner context management * refactor(QuickPanel): enhance search text handling and input management * refactor(Inputbar): update file name handling in AttachmentPreview and Inputbar components --- .../src/components/CustomCollapse.tsx | 1 + .../src/components/QuickPanel/hook.ts | 11 + .../src/components/QuickPanel/index.ts | 4 + .../src/components/QuickPanel/provider.tsx | 83 +++ .../src/components/QuickPanel/types.ts | 66 ++ .../src/components/QuickPanel/view.tsx | 566 ++++++++++++++++++ src/renderer/src/databases/index.ts | 7 +- src/renderer/src/i18n/locales/en-us.json | 35 +- src/renderer/src/i18n/locales/ja-jp.json | 37 +- src/renderer/src/i18n/locales/ru-ru.json | 37 +- src/renderer/src/i18n/locales/zh-cn.json | 31 +- src/renderer/src/i18n/locales/zh-tw.json | 37 +- src/renderer/src/pages/files/FileList.tsx | 2 +- src/renderer/src/pages/home/Chat.tsx | 5 +- .../pages/home/Inputbar/AttachmentButton.tsx | 21 +- .../pages/home/Inputbar/AttachmentPreview.tsx | 2 +- .../src/pages/home/Inputbar/Inputbar.tsx | 317 +++++++--- .../home/Inputbar/KnowledgeBaseButton.tsx | 136 +++-- .../home/Inputbar/KnowledgeBaseInput.tsx | 42 ++ .../pages/home/Inputbar/MCPToolsButton.tsx | 210 ++----- .../home/Inputbar/MentionModelsButton.tsx | 439 +++----------- .../home/Inputbar/MentionModelsInput.tsx | 38 +- .../home/Inputbar/QuickPhrasesButton.tsx | 108 ++++ .../src/pages/home/Messages/NarrowLayout.tsx | 1 + .../settings/QuickPhraseSettings/index.tsx | 161 +++++ .../src/pages/settings/SettingsPage.tsx | 11 +- .../src/services/QuickPhraseService.ts | 68 +++ src/renderer/src/types/index.ts | 9 + 28 files changed, 1784 insertions(+), 701 deletions(-) create mode 100644 src/renderer/src/components/QuickPanel/hook.ts create mode 100644 src/renderer/src/components/QuickPanel/index.ts create mode 100644 src/renderer/src/components/QuickPanel/provider.tsx create mode 100644 src/renderer/src/components/QuickPanel/types.ts create mode 100644 src/renderer/src/components/QuickPanel/view.tsx create mode 100644 src/renderer/src/pages/home/Inputbar/KnowledgeBaseInput.tsx create mode 100644 src/renderer/src/pages/home/Inputbar/QuickPhrasesButton.tsx create mode 100644 src/renderer/src/pages/settings/QuickPhraseSettings/index.tsx create mode 100644 src/renderer/src/services/QuickPhraseService.ts diff --git a/src/renderer/src/components/CustomCollapse.tsx b/src/renderer/src/components/CustomCollapse.tsx index 95db8526..bcafec7a 100644 --- a/src/renderer/src/components/CustomCollapse.tsx +++ b/src/renderer/src/components/CustomCollapse.tsx @@ -9,6 +9,7 @@ interface CustomCollapseProps { const CustomCollapse: FC = ({ label, extra, children }) => { const CollapseStyle = { + width: '100%', background: 'transparent', border: '0.5px solid var(--color-border)' } diff --git a/src/renderer/src/components/QuickPanel/hook.ts b/src/renderer/src/components/QuickPanel/hook.ts new file mode 100644 index 00000000..5d48ad21 --- /dev/null +++ b/src/renderer/src/components/QuickPanel/hook.ts @@ -0,0 +1,11 @@ +import { use } from 'react' + +import { QuickPanelContext } from './provider' + +export const useQuickPanel = () => { + const context = use(QuickPanelContext) + if (!context) { + throw new Error('useQuickPanel must be used within a QuickPanelProvider') + } + return context +} diff --git a/src/renderer/src/components/QuickPanel/index.ts b/src/renderer/src/components/QuickPanel/index.ts new file mode 100644 index 00000000..ec3bed20 --- /dev/null +++ b/src/renderer/src/components/QuickPanel/index.ts @@ -0,0 +1,4 @@ +export * from './hook' +export * from './provider' +export * from './types' +export * from './view' diff --git a/src/renderer/src/components/QuickPanel/provider.tsx b/src/renderer/src/components/QuickPanel/provider.tsx new file mode 100644 index 00000000..4dd588e0 --- /dev/null +++ b/src/renderer/src/components/QuickPanel/provider.tsx @@ -0,0 +1,83 @@ +import React, { createContext, useCallback, useMemo, useState } from 'react' + +import { + QuickPanelCallBackOptions, + QuickPanelCloseAction, + QuickPanelContextType, + QuickPanelListItem, + QuickPanelOpenOptions +} from './types' + +const QuickPanelContext = createContext(null) + +export const QuickPanelProvider: React.FC = ({ children }) => { + const [isVisible, setIsVisible] = useState(false) + const [symbol, setSymbol] = useState('') + + const [list, setList] = useState([]) + const [title, setTitle] = useState() + const [defaultIndex, setDefaultIndex] = useState(0) + const [pageSize, setPageSize] = useState(7) + const [multiple, setMultiple] = useState(false) + const [onClose, setOnClose] = useState< + ((Options: Pick) => void) | undefined + >() + const [beforeAction, setBeforeAction] = useState<((Options: QuickPanelCallBackOptions) => void) | undefined>() + const [afterAction, setAfterAction] = useState<((Options: QuickPanelCallBackOptions) => void) | undefined>() + + const open = useCallback((options: QuickPanelOpenOptions) => { + setTitle(options.title) + setList(options.list) + setDefaultIndex(options.defaultIndex ?? 0) + setPageSize(options.pageSize ?? 7) + setMultiple(options.multiple ?? false) + setSymbol(options.symbol) + + setOnClose(() => options.onClose) + setBeforeAction(() => options.beforeAction) + setAfterAction(() => options.afterAction) + + setIsVisible(true) + }, []) + + const close = useCallback( + (action?: QuickPanelCloseAction) => { + setIsVisible(false) + onClose?.({ symbol, action }) + + setTimeout(() => { + setList([]) + setOnClose(undefined) + setBeforeAction(undefined) + setAfterAction(undefined) + setTitle(undefined) + setSymbol('') + }, 200) + }, + [onClose, symbol] + ) + + const value = useMemo( + () => ({ + open, + close, + + isVisible, + symbol, + + list, + title, + defaultIndex, + pageSize, + multiple, + onClose, + beforeAction, + afterAction + }), + [open, close, isVisible, symbol, list, title, defaultIndex, pageSize, multiple, onClose, beforeAction, afterAction] + ) + + return {children} +} + +export { QuickPanelContext } diff --git a/src/renderer/src/components/QuickPanel/types.ts b/src/renderer/src/components/QuickPanel/types.ts new file mode 100644 index 00000000..c26a7842 --- /dev/null +++ b/src/renderer/src/components/QuickPanel/types.ts @@ -0,0 +1,66 @@ +import React from 'react' + +export type QuickPanelCloseAction = 'enter' | 'click' | 'esc' | 'outsideclick' | string | undefined +export type QuickPanelCallBackOptions = { + symbol: string + action: QuickPanelCloseAction + item: QuickPanelListItem + searchText?: string + /** 是否处于多选状态 */ + multiple?: boolean +} + +export type QuickPanelOpenOptions = { + /** 显示在底部左边,类似于Placeholder */ + title?: string + /** default: [] */ + list: QuickPanelListItem[] + /** default: 0 */ + defaultIndex?: number + /** default: 7 */ + pageSize?: number + /** 是否支持按住cmd/ctrl键多选,default: false */ + multiple?: boolean + /** + * 用于标识是哪个快捷面板,不是用于触发显示 + * 可以是/@#符号,也可以是其他字符串 + */ + symbol: string + beforeAction?: (options: QuickPanelCallBackOptions) => void + afterAction?: (options: QuickPanelCallBackOptions) => void + onClose?: (options: QuickPanelCallBackOptions) => void +} + +export type QuickPanelListItem = { + label: React.ReactNode | string + description?: React.ReactNode | string + /** + * 由于title跟description可能是ReactNode, + * 所以需要单独提供一个用于搜索过滤的文本, + * 这个filterText可以是title跟description的字符串组合 + */ + filterText?: string + icon: React.ReactNode | string + suffix?: React.ReactNode | string + isSelected?: boolean + isMenu?: boolean + disabled?: boolean + action?: (options: QuickPanelCallBackOptions) => void +} + +// 定义上下文类型 +export interface QuickPanelContextType { + readonly open: (options: QuickPanelOpenOptions) => void + readonly close: (action?: QuickPanelCloseAction) => void + readonly isVisible: boolean + readonly symbol: string + readonly list: QuickPanelListItem[] + readonly title?: string + readonly defaultIndex: number + readonly pageSize: number + readonly multiple: boolean + + readonly onClose?: (Options: QuickPanelCallBackOptions) => void + readonly beforeAction?: (Options: QuickPanelCallBackOptions) => void + readonly afterAction?: (Options: QuickPanelCallBackOptions) => void +} diff --git a/src/renderer/src/components/QuickPanel/view.tsx b/src/renderer/src/components/QuickPanel/view.tsx new file mode 100644 index 00000000..0a2cdd0a --- /dev/null +++ b/src/renderer/src/components/QuickPanel/view.tsx @@ -0,0 +1,566 @@ +import { CheckOutlined, RightOutlined } from '@ant-design/icons' +import { isMac } from '@renderer/config/constant' +import { classNames } from '@renderer/utils' +import { Flex } from 'antd' +import { t } from 'i18next' +import React, { use, useCallback, useEffect, useMemo, useRef, useState } from 'react' +import styled from 'styled-components' + +import { QuickPanelContext } from './provider' +import { QuickPanelCallBackOptions, QuickPanelCloseAction, QuickPanelListItem, QuickPanelOpenOptions } from './types' + +/** + * @description 快捷面板内容视图; + * 请不要往这里添加入参,避免耦合; + * 这里只读取来自上下文QuickPanelContext的数据 + * + * 无奈之举,为了清除输入框搜索文本,所以传了个setInputText进来 + */ +export const QuickPanelView: React.FC<{ + setInputText: React.Dispatch> +}> = ({ setInputText }) => { + const ctx = use(QuickPanelContext) + if (!ctx) { + throw new Error('QuickPanel must be used within a QuickPanelProvider') + } + + const ASSISTIVE_KEY = isMac ? '⌘' : 'Ctrl' + const [isAssistiveKeyPressed, setIsAssistiveKeyPressed] = useState(false) + // 避免上下翻页时,鼠标干扰 + const [isMouseOver, setIsMouseOver] = useState(false) + + const [index, setIndex] = useState(ctx.defaultIndex) + const [historyPanel, setHistoryPanel] = useState([]) + + const bodyRef = useRef(null) + const contentRef = useRef(null) + + const scrollBlock = useRef('nearest') + + const [searchText, setSearchText] = useState('') + const searchTextRef = useRef('') + + // 解决长按上下键时滚动太慢问题 + const keyPressCount = useRef(0) + const scrollBehavior = useRef<'auto' | 'smooth'>('smooth') + + // 处理搜索,过滤列表 + const list = useMemo(() => { + if (!ctx.isVisible && !ctx.symbol) return [] + const newList = ctx.list?.filter((item) => { + const _searchText = searchText.replace(/^[/@]/, '') + if (!_searchText) return true + + let filterText = item.filterText || '' + if (typeof item.label === 'string') { + filterText += item.label + } + if (typeof item.description === 'string') { + filterText += item.description + } + + return filterText.toLowerCase().includes(_searchText.toLowerCase()) + }) + + setIndex(newList.length > 0 ? ctx.defaultIndex || 0 : -1) + + return newList + }, [ctx.defaultIndex, ctx.isVisible, ctx.list, ctx.symbol, searchText]) + + const canForwardAndBackward = useMemo(() => { + return list.some((item) => item.isMenu) || historyPanel.length > 0 + }, [list, historyPanel]) + + const clearSearchText = useCallback( + (includeSymbol = false) => { + const textArea = document.querySelector('.inputbar textarea') as HTMLTextAreaElement + const cursorPosition = textArea.selectionStart ?? 0 + const prevChar = textArea.value[cursorPosition - 1] + if ((prevChar === '/' || prevChar === '@') && !searchTextRef.current) { + searchTextRef.current = prevChar + } + + const _searchText = includeSymbol ? searchTextRef.current : searchTextRef.current.replace(/^[/@]/, '') + if (!_searchText) return + + const inputText = textArea.value + let newText = inputText + const searchPattern = new RegExp(`${_searchText}$`) + + const match = inputText.slice(0, cursorPosition).match(searchPattern) + if (match) { + const start = match.index || 0 + const end = start + match[0].length + newText = inputText.slice(0, start) + inputText.slice(end) + setInputText(newText) + + setTimeout(() => { + textArea.focus() + textArea.setSelectionRange(start, start) + }, 0) + } + setSearchText('') + }, + [setInputText] + ) + + const handleClose = useCallback( + (action?: QuickPanelCloseAction) => { + ctx.close(action) + setHistoryPanel([]) + + if (action === 'delete-symbol') { + const textArea = document.querySelector('.inputbar textarea') as HTMLTextAreaElement + if (textArea) { + setInputText(textArea.value) + } + } else if (action && !['outsideclick', 'esc'].includes(action)) { + clearSearchText(true) + } + }, + [ctx, clearSearchText, setInputText] + ) + + const handleItemAction = useCallback( + (item: QuickPanelListItem, action?: QuickPanelCloseAction) => { + if (item.disabled) return + + const quickPanelCallBackOptions: QuickPanelCallBackOptions = { + symbol: ctx.symbol, + action, + item, + searchText: searchText, + multiple: isAssistiveKeyPressed + } + ctx.beforeAction?.(quickPanelCallBackOptions) + item?.action?.(quickPanelCallBackOptions) + ctx.afterAction?.(quickPanelCallBackOptions) + + if (item.isMenu) { + // 保存上一个打开的选项,用于回退 + setHistoryPanel((prev) => [ + ...(prev || []), + { + title: ctx.title, + list: ctx.list, + symbol: ctx.symbol, + multiple: ctx.multiple, + defaultIndex: index, + pageSize: ctx.pageSize, + onClose: ctx.onClose, + beforeAction: ctx.beforeAction, + afterAction: ctx.afterAction + } + ]) + clearSearchText(false) + return + } + + if (ctx.multiple && isAssistiveKeyPressed) return + + handleClose(action) + }, + [ctx, searchText, isAssistiveKeyPressed, handleClose, clearSearchText, index] + ) + + useEffect(() => { + searchTextRef.current = searchText + }, [searchText]) + + // 获取当前输入的搜索词 + useEffect(() => { + if (!ctx.isVisible) return + + const textArea = document.querySelector('.inputbar textarea') as HTMLTextAreaElement + + const handleInput = (e: Event) => { + const target = e.target as HTMLTextAreaElement + const cursorPosition = target.selectionStart + const textBeforeCursor = target.value.slice(0, cursorPosition) + const lastSlashIndex = textBeforeCursor.lastIndexOf('/') + const lastAtIndex = textBeforeCursor.lastIndexOf('@') + const lastSymbolIndex = Math.max(lastSlashIndex, lastAtIndex) + + if (lastSymbolIndex !== -1) { + const newSearchText = textBeforeCursor.slice(lastSymbolIndex) + setSearchText(newSearchText) + } else { + handleClose('delete-symbol') + } + } + + textArea.addEventListener('input', handleInput) + + return () => { + textArea.removeEventListener('input', handleInput) + setSearchText('') + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ctx.isVisible]) + + // 处理上下翻时滚动到选中的元素 + useEffect(() => { + if (!contentRef.current) return + + const selectedElement = contentRef.current.children[index] as HTMLElement + if (selectedElement) { + selectedElement.scrollIntoView({ + block: scrollBlock.current, + behavior: scrollBehavior.current + }) + scrollBlock.current = 'nearest' + } + }, [index]) + + // 处理键盘事件 + useEffect(() => { + if (!ctx.isVisible) return + + const handleKeyDown = (e: KeyboardEvent) => { + if (isMac ? e.metaKey : e.ctrlKey) { + setIsAssistiveKeyPressed(true) + } + + // 处理上下翻页时,滚动太慢问题 + if (['ArrowUp', 'ArrowDown'].includes(e.key)) { + keyPressCount.current++ + if (keyPressCount.current > 5) { + scrollBehavior.current = 'auto' + } + } + + if (['ArrowUp', 'ArrowDown', 'PageUp', 'PageDown', 'Enter', 'Escape'].includes(e.key)) { + e.preventDefault() + e.stopPropagation() + setIsMouseOver(false) + } + if (['ArrowLeft', 'ArrowRight'].includes(e.key) && isAssistiveKeyPressed) { + e.preventDefault() + e.stopPropagation() + setIsMouseOver(false) + } + + switch (e.key) { + case 'ArrowUp': + if (isAssistiveKeyPressed) { + scrollBlock.current = 'start' + setIndex((prev) => { + const newIndex = prev - ctx.pageSize + if (prev === 0) return list.length - 1 + return newIndex < 0 ? 0 : newIndex + }) + } else { + scrollBlock.current = 'nearest' + setIndex((prev) => (prev > 0 ? prev - 1 : list.length - 1)) + } + break + + case 'ArrowDown': + if (isAssistiveKeyPressed) { + scrollBlock.current = 'start' + setIndex((prev) => { + const newIndex = prev + ctx.pageSize + if (prev + 1 === list.length) return 0 + return newIndex >= list.length ? list.length - 1 : newIndex + }) + } else { + scrollBlock.current = 'nearest' + setIndex((prev) => (prev < list.length - 1 ? prev + 1 : 0)) + } + break + + case 'PageUp': + scrollBlock.current = 'start' + setIndex((prev) => { + const newIndex = prev - ctx.pageSize + return newIndex < 0 ? 0 : newIndex + }) + break + + case 'PageDown': + scrollBlock.current = 'start' + setIndex((prev) => { + const newIndex = prev + ctx.pageSize + return newIndex >= list.length ? list.length - 1 : newIndex + }) + break + + case 'ArrowLeft': + if (!isAssistiveKeyPressed) return + if (!historyPanel.length) return + clearSearchText(false) + if (historyPanel.length > 0) { + const lastPanel = historyPanel.pop() + if (lastPanel) { + ctx.open(lastPanel) + } + } + break + + case 'ArrowRight': + if (!isAssistiveKeyPressed) return + if (!list?.[index]?.isMenu) return + clearSearchText(false) + handleItemAction(list[index], 'enter') + break + + case 'Enter': + if (list?.[index]) { + handleItemAction(list[index], 'enter') + } + break + case 'Escape': + handleClose('esc') + break + } + } + + const handleKeyUp = (e: KeyboardEvent) => { + if (isMac ? !e.metaKey : !e.ctrlKey) { + setIsAssistiveKeyPressed(false) + } + + keyPressCount.current = 0 + scrollBehavior.current = 'smooth' + } + + const handleClickOutside = (e: MouseEvent) => { + const target = e.target as HTMLElement + if (target.closest('#inputbar')) return + if (bodyRef.current && !bodyRef.current.contains(target)) { + handleClose('outsideclick') + } + } + + window.addEventListener('keydown', handleKeyDown) + window.addEventListener('keyup', handleKeyUp) + window.addEventListener('click', handleClickOutside) + + return () => { + window.removeEventListener('keydown', handleKeyDown) + window.removeEventListener('keyup', handleKeyUp) + window.removeEventListener('click', handleClickOutside) + } + }, [index, isAssistiveKeyPressed, historyPanel, ctx, list, handleItemAction, handleClose, clearSearchText]) + + return ( + + setIsMouseOver(true)}> + + {list.map((item, i) => ( + handleItemAction(item, 'click')} + onMouseEnter={() => setIndex(i)}> + + {item.icon} + {item.label} + + + + {item.description && {item.description}} + + {item.suffix ? ( + item.suffix + ) : item.isSelected ? ( + + ) : ( + item.isMenu && !item.disabled && + )} + + + + ))} + + + + {ctx.title || ''} + + ESC {t('settings.quickPanel.close')} + + + ▲▼ {t('settings.quickPanel.select')} + + + + + {ASSISTIVE_KEY} + + + ▲▼ {t('settings.quickPanel.page')} + + + {canForwardAndBackward && ( + + + {ASSISTIVE_KEY} + + + ◀︎▶︎ {t('settings.quickPanel.back')}/{t('settings.quickPanel.forward')} + + )} + + + ↩︎ {t('settings.quickPanel.confirm')} + + + {ctx.multiple && ( + + + {ASSISTIVE_KEY} + + + ↩︎ {t('settings.quickPanel.multiple')} + + )} + + + + + + ) +} + +const QuickPanelContainer = styled.div<{ $pageSize: number }>` + --focused-color: rgba(0, 0, 0, 0.06); + --selected-color: rgba(0, 0, 0, 0.03); + max-height: 0; + position: absolute; + top: 1px; + left: 0; + right: 0; + width: 100%; + padding: 0 30px 0 30px; + transform: translateY(-100%); + transform-origin: bottom; + 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); + } +` + +const QuickPanelFooter = styled.div` + width: 100%; +` + +const QuickPanelFooterTips = styled.div` + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + font-size: 10px; + color: var(--color-text-3); + padding: 8px 12px 5px; +` + +const QuickPanelTitle = styled.div` + font-size: 11px; + color: var(--color-text-3); +` + +const QuickPanelContent = styled.div<{ $pageSize: number; $isMouseOver: boolean }>` + width: 100%; + max-height: ${(props) => props.$pageSize * 31}px; + padding: 0 5px; + overflow-x: hidden; + overflow-y: auto; + pointer-events: ${(props) => (props.$isMouseOver ? 'auto' : 'none')}; + + &::-webkit-scrollbar { + width: 3px; + } +` + +const QuickPanelItem = styled.div` + height: 30px; + display: flex; + align-items: center; + gap: 20px; + justify-content: space-between; + padding: 5px; + border-radius: 6px; + cursor: pointer; + transition: background-color 0.1s ease; + margin-bottom: 1px; + &.selected { + background-color: var(--selected-color); + } + &.focused { + background-color: var(--focused-color); + } + &.disabled { + --selected-color: rgba(0, 0, 0, 0.02); + opacity: 0.4; + cursor: not-allowed; + } +` + +const QuickPanelItemLeft = styled.div` + max-width: 60%; + display: flex; + align-items: center; + gap: 5px; + flex: 1; + flex-shrink: 0; +` + +const QuickPanelItemIcon = styled.span` + font-size: 12px; + color: var(--color-text-3); +` + +const QuickPanelItemLabel = styled.span` + flex: 1; + font-size: 12px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + flex-shrink: 0; +` + +const QuickPanelItemRight = styled.div` + min-width: 20%; + font-size: 11px; + display: flex; + align-items: center; + justify-content: flex-end; + gap: 2px; + color: var(--color-text-3); +` + +const QuickPanelItemDescription = styled.span` + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +` + +const QuickPanelItemSuffixIcon = styled.span` + min-width: 12px; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: flex-end; + gap: 3px; +` diff --git a/src/renderer/src/databases/index.ts b/src/renderer/src/databases/index.ts index 6f027338..bc56f4d0 100644 --- a/src/renderer/src/databases/index.ts +++ b/src/renderer/src/databases/index.ts @@ -1,7 +1,8 @@ -import { FileType, KnowledgeItem, Topic, TranslateHistory } from '@renderer/types' +import { FileType, KnowledgeItem, QuickPhrase, Topic, TranslateHistory } from '@renderer/types' import { Dexie, type EntityTable } from 'dexie' import { upgradeToV5 } from './upgrades' + // Database declaration (move this to its own module also) export const db = new Dexie('CherryStudio') as Dexie & { files: EntityTable @@ -9,6 +10,7 @@ export const db = new Dexie('CherryStudio') as Dexie & { settings: EntityTable<{ id: string; value: any }, 'id'> knowledge_notes: EntityTable translate_history: EntityTable + quick_phrases: EntityTable } db.version(1).stores({ @@ -42,7 +44,8 @@ db.version(5) topics: '&id, messages', settings: '&id, value', knowledge_notes: '&id, baseId, type, content, created_at, updated_at', - translate_history: '&id, sourceText, targetText, sourceLanguage, targetLanguage, createdAt' + translate_history: '&id, sourceText, targetText, sourceLanguage, targetLanguage, createdAt', + quick_phrases: 'id' }) .upgrade((tx) => upgradeToV5(tx)) diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index 8cf06eca..71a4313c 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -235,7 +235,9 @@ "topics.export.siyuan": "Export to Siyuan Note", "topics.export.wait_for_title_naming": "Generating title...", "topics.export.title_naming_success": "Title generated successfully", - "topics.export.title_naming_failed": "Failed to generate title, using default title" + "topics.export.title_naming_failed": "Failed to generate title, using default title", + "input.translating": "Translating...", + "input.upload.upload_from_local": "Upload local file..." }, "code_block": { "collapse": "Collapse", @@ -328,7 +330,7 @@ "files": { "actions": "Actions", "all": "All Files", - "count": "Count", + "count": "files", "created_at": "Created At", "delete": "Delete", "delete.content": "Deleting a file will delete its reference from all messages. Are you sure you want to delete this file?", @@ -1063,7 +1065,8 @@ "deleteServerConfirm": "Are you sure you want to delete this server?", "registry": "Package Registry", "registryTooltip": "Choose the registry for package installation to resolve network issues with the default registry.", - "registryDefault": "Default" + "registryDefault": "Default", + "not_support": "Model not supported" }, "messages.divider": "Show divider between messages", "messages.grid_columns": "Message grid display columns", @@ -1261,6 +1264,27 @@ "title": "Tavily" }, "title": "Web Search" + }, + "quickPhrase": { + "title": "Quick Phrases", + "add": "Add Phrase", + "edit": "Edit Phrase", + "titleLabel": "Title", + "contentLabel": "Content", + "titlePlaceholder": "Please enter phrase title", + "contentPlaceholder": "Please enter phrase content, support using variables, and press Tab to quickly locate the variable to modify. For example: \nHelp me plan a route from ${from} to ${to}, and send it to ${email}.", + "delete": "Delete Phrase", + "deleteConfirm": "The phrase cannot be recovered after deletion, continue?" + }, + "quickPanel": { + "title": "Quick Menu", + "close": "Close", + "select": "Select", + "page": "Page", + "confirm": "Confirm", + "back": "Back", + "forward": "Forward", + "multiple": "Multiple Select" } }, "translate": { @@ -1286,7 +1310,10 @@ "scroll_sync.disable": "Disable synced scroll", "scroll_sync.enable": "Enable synced scroll", "title": "Translation", - "tooltip.newline": "Newline" + "tooltip.newline": "Newline", + "menu": { + "description": "Translate the content of the current input box" + } }, "tray": { "quit": "Quit", diff --git a/src/renderer/src/i18n/locales/ja-jp.json b/src/renderer/src/i18n/locales/ja-jp.json index 8cdda7be..cd2a48a6 100644 --- a/src/renderer/src/i18n/locales/ja-jp.json +++ b/src/renderer/src/i18n/locales/ja-jp.json @@ -235,7 +235,9 @@ "topics.export.siyuan": "思源笔记にエクスポート", "topics.export.wait_for_title_naming": "タイトルを生成中...", "topics.export.title_naming_success": "タイトルの生成に成功しました", - "topics.export.title_naming_failed": "タイトルの生成に失敗しました。デフォルトのタイトルを使用します" + "topics.export.title_naming_failed": "タイトルの生成に失敗しました。デフォルトのタイトルを使用します", + "input.translating": "翻訳中...", + "input.upload.upload_from_local": "ローカルファイルをアップロード..." }, "code_block": { "collapse": "折りたたむ", @@ -328,7 +330,7 @@ "files": { "actions": "操作", "all": "すべてのファイル", - "count": "数", + "count": "ファイル", "created_at": "作成日", "delete": "削除", "delete.content": "ファイルを削除すると、ファイルがすべてのメッセージで参照されることを削除します。このファイルを削除してもよろしいですか?", @@ -1062,7 +1064,8 @@ "deleteServerConfirm": "このサーバーを削除してもよろしいですか?", "registry": "パッケージ管理レジストリ", "registryTooltip": "デフォルトのレジストリでネットワークの問題が発生した場合、パッケージインストールに使用するレジストリを選択してください。", - "registryDefault": "デフォルト" + "registryDefault": "デフォルト", + "not_support": "モデルはサポートされていません" }, "messages.divider": "メッセージ間に区切り線を表示", "messages.grid_columns": "メッセージグリッドの表示列数", @@ -1261,7 +1264,28 @@ }, "title": "ウェブ検索" }, - "general.auto_check_update.title": "自動更新チェックを有効にする" + "general.auto_check_update.title": "自動更新チェックを有効にする", + "quickPhrase": { + "title": "クイックフレーズ", + "add": "フレーズを追加", + "edit": "フレーズを編集", + "titleLabel": "タイトル", + "contentLabel": "内容", + "titlePlaceholder": "フレーズのタイトルを入力してください", + "contentPlaceholder": "フレーズの内容を入力してください。変数を使用することもできます。変数を使用する場合は、Tabキーを押して変数を選択し、変数を変更してください。例:\n私の名前は${name}です。", + "delete": "フレーズを削除", + "deleteConfirm": "削除後は復元できません。続行しますか?" + }, + "quickPanel": { + "title": "クイックメニュー", + "close": "閉じる", + "select": "選択", + "page": "ページ", + "confirm": "確認", + "back": "戻る", + "forward": "進む", + "multiple": "複数選択" + } }, "translate": { "any.language": "任意の言語", @@ -1286,7 +1310,10 @@ "scroll_sync.disable": "關閉滾動同步", "scroll_sync.enable": "開啟滾動同步", "title": "翻訳", - "tooltip.newline": "改行" + "tooltip.newline": "改行", + "menu": { + "description": "對當前輸入框內容進行翻譯" + } }, "tray": { "quit": "終了", diff --git a/src/renderer/src/i18n/locales/ru-ru.json b/src/renderer/src/i18n/locales/ru-ru.json index 13ff15e0..d23d4dc2 100644 --- a/src/renderer/src/i18n/locales/ru-ru.json +++ b/src/renderer/src/i18n/locales/ru-ru.json @@ -235,7 +235,9 @@ "topics.export.siyuan": "Экспорт в Siyuan Note", "topics.export.wait_for_title_naming": "Создание заголовка...", "topics.export.title_naming_success": "Заголовок успешно создан", - "topics.export.title_naming_failed": "Не удалось создать заголовок, используется заголовок по умолчанию" + "topics.export.title_naming_failed": "Не удалось создать заголовок, используется заголовок по умолчанию", + "input.translating": "Перевод...", + "input.upload.upload_from_local": "Загрузить локальный файл..." }, "code_block": { "collapse": "Свернуть", @@ -328,7 +330,7 @@ "files": { "actions": "Действия", "all": "Все файлы", - "count": "Количество", + "count": "файлов", "created_at": "Дата создания", "delete": "Удалить", "delete.content": "Удаление файла удалит его из всех сообщений, вы уверены, что хотите удалить этот файл?", @@ -1062,7 +1064,8 @@ "deleteServerConfirm": "Вы уверены, что хотите удалить этот сервер?", "registry": "Реестр пакетов", "registryTooltip": "Выберите реестр для установки пакетов, если возникают проблемы с сетью при использовании реестра по умолчанию.", - "registryDefault": "По умолчанию" + "registryDefault": "По умолчанию", + "not_support": "Модель не поддерживается" }, "messages.divider": "Показывать разделитель между сообщениями", "messages.grid_columns": "Количество столбцов сетки сообщений", @@ -1261,7 +1264,28 @@ }, "title": "Поиск в Интернете" }, - "general.auto_check_update.title": "Включить автоматическую проверку обновлений" + "general.auto_check_update.title": "Включить автоматическую проверку обновлений", + "quickPhrase": { + "title": "Быстрые фразы", + "add": "Добавить фразу", + "edit": "Редактировать фразу", + "titleLabel": "Заголовок", + "contentLabel": "Содержание", + "titlePlaceholder": "Введите заголовок фразы", + "contentPlaceholder": "Введите содержание фразы, поддерживает использование переменных, и нажмите Tab для быстрого перехода к переменной для изменения. Например: \nПомоги мне спланировать маршрут от ${from} до ${to} и отправить его на ${email}.", + "delete": "Удалить фразу", + "deleteConfirm": "После удаления фраза не может быть восстановлена, продолжить?" + }, + "quickPanel": { + "title": "Быстрое меню", + "close": "Закрыть", + "select": "Выбрать", + "page": "Страница", + "confirm": "Подтвердить", + "back": "Назад", + "forward": "Вперед", + "multiple": "Множественный выбор" + } }, "translate": { "any.language": "Любой язык", @@ -1286,7 +1310,10 @@ "scroll_sync.disable": "Отключить синхронизацию прокрутки", "scroll_sync.enable": "Включить синхронизацию прокрутки", "title": "Перевод", - "tooltip.newline": "Перевести" + "tooltip.newline": "Перевести", + "menu": { + "description": "Перевести содержимое текущего ввода" + } }, "tray": { "quit": "Выйти", diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index 76ee4b18..89a02e10 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -129,11 +129,13 @@ "input.new_topic": "新话题 {{Command}}", "input.pause": "暂停", "input.placeholder": "在这里输入消息...", + "input.translating": "翻译中...", "input.send": "发送", "input.settings": "设置", "input.topics": " 话题 ", "input.translate": "翻译成{{target_language}}", "input.upload": "上传图片或文档", + "input.upload.upload_from_local": "上传本地文件...", "input.upload.document": "上传文档(模型不支持图片)", "input.web_search": "开启网络搜索", "input.web_search.button.ok": "去设置", @@ -328,7 +330,7 @@ "files": { "actions": "操作", "all": "所有文件", - "count": "文件数", + "count": "个文件", "created_at": "创建时间", "delete": "删除", "delete.content": "删除文件会删除文件在所有消息中的引用,确定要删除此文件吗?", @@ -1063,7 +1065,8 @@ "deleteServerConfirm": "确定要删除此服务器吗?", "registry": "包管理源", "registryTooltip": "选择用于安装包的源,以解决默认源的网络问题。", - "registryDefault": "默认" + "registryDefault": "默认", + "not_support": "模型不支持" }, "messages.divider": "消息分割线", "messages.grid_columns": "消息网格展示列数", @@ -1261,6 +1264,27 @@ "title": "Tavily" }, "title": "网络搜索" + }, + "quickPhrase": { + "title": "快捷短语", + "add": "添加短语", + "edit": "编辑短语", + "titleLabel": "标题", + "contentLabel": "内容", + "titlePlaceholder": "请输入短语标题", + "contentPlaceholder": "请输入短语内容,支持使用变量,然后按Tab键可以快速定位到变量进行修改。比如:\n帮我规划从${from}到${to}的路线,然后发送到${email}。", + "delete": "删除短语", + "deleteConfirm": "删除短语后将无法恢复,是否继续?" + }, + "quickPanel": { + "title": "快捷菜单", + "close": "关闭", + "select": "选择", + "page": "翻页", + "confirm": "确认", + "back": "后退", + "forward": "前进", + "multiple": "多选" } }, "translate": { @@ -1280,6 +1304,9 @@ "empty": "暂无翻译历史", "title": "翻译历史" }, + "menu": { + "description": "对当前输入框内容进行翻译" + }, "input.placeholder": "输入文本进行翻译", "output.placeholder": "翻译", "processing": "翻译中...", diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index aa057446..e6311633 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -235,7 +235,9 @@ "topics.export.siyuan": "匯出到思源筆記", "topics.export.wait_for_title_naming": "正在生成標題...", "topics.export.title_naming_success": "標題生成成功", - "topics.export.title_naming_failed": "標題生成失敗,使用預設標題" + "topics.export.title_naming_failed": "標題生成失敗,使用預設標題", + "input.translating": "翻譯中...", + "input.upload.upload_from_local": "上傳本地文件..." }, "code_block": { "collapse": "折疊", @@ -328,7 +330,7 @@ "files": { "actions": "操作", "all": "所有檔案", - "count": "數量", + "count": "個檔案", "created_at": "建立時間", "delete": "刪除", "delete.content": "刪除檔案會刪除檔案在所有訊息中的引用,確定要刪除此檔案嗎?", @@ -1062,7 +1064,8 @@ "deleteServerConfirm": "確定要刪除此伺服器嗎?", "registry": "套件管理源", "registryTooltip": "選擇用於安裝套件的源,以解決預設源的網路問題。", - "registryDefault": "預設" + "registryDefault": "預設", + "not_support": "不支援此模型" }, "messages.divider": "訊息間顯示分隔線", "messages.grid_columns": "訊息網格展示列數", @@ -1261,7 +1264,28 @@ }, "title": "網路搜尋" }, - "general.auto_check_update.title": "啟用自動更新檢查" + "general.auto_check_update.title": "啟用自動更新檢查", + "quickPhrase": { + "title": "快捷短語", + "add": "新增短語", + "edit": "編輯短語", + "titleLabel": "標題", + "contentLabel": "內容", + "titlePlaceholder": "請輸入短語標題", + "contentPlaceholder": "請輸入短語內容,支持使用變量,然後按Tab鍵可以快速定位到變量進行修改。比如:\n幫我規劃從${from}到${to}的行程,然後發送到${email}。", + "delete": "刪除短語", + "deleteConfirm": "刪除後無法復原,是否繼續?" + }, + "quickPanel": { + "title": "快捷選單", + "close": "關閉", + "select": "選擇", + "page": "翻頁", + "confirm": "確認", + "back": "後退", + "forward": "前進", + "multiple": "多選" + } }, "translate": { "any.language": "任意語言", @@ -1286,7 +1310,10 @@ "scroll_sync.disable": "關閉滾動同步", "scroll_sync.enable": "開啟滾動同步", "title": "翻譯", - "tooltip.newline": "換行" + "tooltip.newline": "換行", + "menu": { + "description": "對當前輸入框內容進行翻譯" + } }, "tray": { "quit": "結束", diff --git a/src/renderer/src/pages/files/FileList.tsx b/src/renderer/src/pages/files/FileList.tsx index d81b5d50..41b1ebb1 100644 --- a/src/renderer/src/pages/files/FileList.tsx +++ b/src/renderer/src/pages/files/FileList.tsx @@ -88,7 +88,7 @@ const FileList: React.FC = ({ id, list, files }) => { fileInfo={{ name: item.file, ext: item.ext, - extra: `${item.created_at} · ${t('files.count')} ${item.count} · ${item.size}`, + extra: `${item.created_at} · ${item.count}${t('files.count')} · ${item.size}`, actions: item.actions }} /> diff --git a/src/renderer/src/pages/home/Chat.tsx b/src/renderer/src/pages/home/Chat.tsx index d44b53c6..07d89ded 100644 --- a/src/renderer/src/pages/home/Chat.tsx +++ b/src/renderer/src/pages/home/Chat.tsx @@ -1,3 +1,4 @@ +import { QuickPanelProvider } from '@renderer/components/QuickPanel' import { useAssistant } from '@renderer/hooks/useAssistant' import { useSettings } from '@renderer/hooks/useSettings' import { useShowTopics } from '@renderer/hooks/useStore' @@ -31,7 +32,9 @@ const Chat: FC = (props) => { topic={props.activeTopic} setActiveTopic={props.setActiveTopic} /> - + + + {topicPosition === 'right' && showTopics && ( void +} + interface Props { + ref?: React.RefObject model: Model files: FileType[] setFiles: (files: FileType[]) => void @@ -14,13 +19,13 @@ interface Props { disabled?: boolean } -const AttachmentButton: FC = ({ model, files, setFiles, ToolbarButton, disabled }) => { +const AttachmentButton: FC = ({ ref, model, files, setFiles, ToolbarButton, disabled }) => { const { t } = useTranslation() const extensions = isVisionModel(model) ? [...imageExts, ...documentExts, ...textExts] : [...documentExts, ...textExts] - const onSelectFile = async () => { + const onSelectFile = useCallback(async () => { const _files = await window.api.file.select({ properties: ['openFile', 'multiSelections'], filters: [ @@ -34,7 +39,15 @@ const AttachmentButton: FC = ({ model, files, setFiles, ToolbarButton, di if (_files) { setFiles([...files, ..._files]) } - } + }, [files, setFiles]) + + const openQuickPanel = useCallback(() => { + onSelectFile() + }, [onSelectFile]) + + useImperativeHandle(ref, () => ({ + openQuickPanel + })) return ( = ({ files, setFiles }) => { uid: file.id, url: 'file://' + FileManager.getSafePath(file), status: 'done', - name: file.name + name: file.origin_name || file.name }) as UploadFile )} onRemove={(item) => setFiles(files.filter((file) => item.uid !== file.id))} diff --git a/src/renderer/src/pages/home/Inputbar/Inputbar.tsx b/src/renderer/src/pages/home/Inputbar/Inputbar.tsx index f0fd08c8..bc671015 100644 --- a/src/renderer/src/pages/home/Inputbar/Inputbar.tsx +++ b/src/renderer/src/pages/home/Inputbar/Inputbar.tsx @@ -1,18 +1,25 @@ import { ClearOutlined, + CodeOutlined, ColumnHeightOutlined, + FileSearchOutlined, FormOutlined, FullscreenExitOutlined, FullscreenOutlined, GlobalOutlined, HolderOutlined, + PaperClipOutlined, PauseCircleOutlined, - QuestionCircleOutlined + QuestionCircleOutlined, + ThunderboltOutlined, + TranslationOutlined } from '@ant-design/icons' +import { QuickPanelListItem, QuickPanelView, useQuickPanel } from '@renderer/components/QuickPanel' import TranslateButton from '@renderer/components/TranslateButton' import { isFunctionCallingModel, isGenerateImageModel, isVisionModel, isWebSearchModel } from '@renderer/config/models' import db from '@renderer/databases' import { useAssistant } from '@renderer/hooks/useAssistant' +import { useKnowledgeBases } from '@renderer/hooks/useKnowledge' import { useMCPServers } from '@renderer/hooks/useMCPServers' import { useMessageOperations, useTopicLoading } from '@renderer/hooks/useMessageOperations' import { modelGenerating, useRuntime } from '@renderer/hooks/useRuntime' @@ -23,18 +30,20 @@ import { addAssistantMessagesToTopic, getDefaultTopic } from '@renderer/services import { EVENT_NAMES, EventEmitter } from '@renderer/services/EventService' import FileManager from '@renderer/services/FileManager' import { checkRateLimit, getUserMessage } from '@renderer/services/MessagesService' +import { getModelUniqId } from '@renderer/services/ModelService' import { estimateMessageUsage, estimateTextTokens as estimateTxtTokens } from '@renderer/services/TokenService' import { translateText } from '@renderer/services/TranslateService' import WebSearchService from '@renderer/services/WebSearchService' import { useAppDispatch } from '@renderer/store' import { sendMessage as _sendMessage } from '@renderer/store/messages' import { setSearching } from '@renderer/store/runtime' -import { Assistant, FileType, KnowledgeBase, MCPServer, Message, Model, Topic } from '@renderer/types' -import { classNames, delay, getFileExtension } from '@renderer/utils' +import { Assistant, FileType, KnowledgeBase, KnowledgeItem, MCPServer, Message, Model, Topic } from '@renderer/types' +import { classNames, delay, formatFileSize, getFileExtension } from '@renderer/utils' import { getFilesFromDropEvent } from '@renderer/utils/input' import { documentExts, imageExts, textExts } from '@shared/config/constant' import { Button, Popconfirm, Tooltip } from 'antd' import TextArea, { TextAreaRef } from 'antd/es/input/TextArea' +import dayjs from 'dayjs' import Logger from 'electron-log/renderer' import { debounce, isEmpty } from 'lodash' import React, { CSSProperties, FC, useCallback, useEffect, useMemo, useRef, useState } from 'react' @@ -43,16 +52,19 @@ import { useNavigate } from 'react-router-dom' import styled from 'styled-components' import NarrowLayout from '../Messages/NarrowLayout' -import AttachmentButton from './AttachmentButton' +import AttachmentButton, { AttachmentButtonRef } from './AttachmentButton' import AttachmentPreview from './AttachmentPreview' import GenerateImageButton from './GenerateImageButton' -import KnowledgeBaseButton from './KnowledgeBaseButton' -import MCPToolsButton from './MCPToolsButton' -import MentionModelsButton from './MentionModelsButton' +import KnowledgeBaseButton, { KnowledgeBaseButtonRef } from './KnowledgeBaseButton' +import KnowledgeBaseInput from './KnowledgeBaseInput' +import MCPToolsButton, { MCPToolsButtonRef } from './MCPToolsButton' +import MentionModelsButton, { MentionModelsButtonRef } from './MentionModelsButton' import MentionModelsInput from './MentionModelsInput' import NewContextButton from './NewContextButton' +import QuickPhrasesButton, { QuickPhrasesButtonRef } from './QuickPhrasesButton' import SendMessageButton from './SendMessageButton' import TokenCount from './TokenCount' + interface Props { assistant: Assistant setActiveTopic: (topic: Topic) => void @@ -93,7 +105,6 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = const [selectedKnowledgeBases, setSelectedKnowledgeBases] = useState([]) const [mentionModels, setMentionModels] = useState([]) const [enabledMCPs, setEnabledMCPs] = useState(assistant.mcpServers || []) - const [isMentionPopupOpen, setIsMentionPopupOpen] = useState(false) const [isDragging, setIsDragging] = useState(false) const [textareaHeight, setTextareaHeight] = useState() const startDragY = useRef(0) @@ -103,13 +114,20 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = const supportExts = useMemo(() => [...textExts, ...documentExts, ...(isVision ? imageExts : [])], [isVision]) const navigate = useNavigate() const { activedMcpServers } = useMCPServers() + const { bases: knowledgeBases } = useKnowledgeBases() + + const quickPanel = useQuickPanel() const showKnowledgeIcon = useSidebarIconShow('knowledge') const showMCPToolsIcon = isFunctionCallingModel(model) const [tokenCount, setTokenCount] = useState(0) - const [mentionFromKeyboard, setMentionFromKeyboard] = useState(false) + const quickPhrasesButtonRef = useRef(null) + const mentionModelsButtonRef = useRef(null) + const knowledgeBaseButtonRef = useRef(null) + const mcpToolsButtonRef = useRef(null) + const attachmentButtonRef = useRef(null) // eslint-disable-next-line react-hooks/exhaustive-deps const debouncedEstimate = useCallback( @@ -208,6 +226,7 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = }, [ assistant, dispatch, + enabledMCPs, files, inputEmpty, loading, @@ -217,11 +236,10 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = selectedKnowledgeBases, text, topic, - enabledMCPs, activedMcpServers ]) - const translate = async () => { + const translate = useCallback(async () => { if (isTranslating) { return } @@ -236,28 +254,161 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = } finally { setIsTranslating(false) } - } + }, [isTranslating, text, targetLanguage, resizeTextArea]) + + const openKnowledgeFileList = useCallback( + (base: KnowledgeBase) => { + quickPanel.open({ + title: base.name, + list: base.items + .filter((file): file is KnowledgeItem => ['file'].includes(file.type)) + .map((file) => { + const fileContent = file.content as FileType + return { + label: fileContent.origin_name || fileContent.name, + description: + formatFileSize(fileContent.size) + ' · ' + dayjs(fileContent.created_at).format('YYYY-MM-DD HH:mm'), + icon: , + isSelected: files.some((f) => f.path === fileContent.path), + action: async ({ item }) => { + item.isSelected = !item.isSelected + if (fileContent.path) { + setFiles((prevFiles) => { + const fileExists = prevFiles.some((f) => f.path === fileContent.path) + if (fileExists) { + return prevFiles.filter((f) => f.path !== fileContent.path) + } else { + return fileContent ? [...prevFiles, fileContent] : prevFiles + } + }) + } + } + } + }), + symbol: 'file', + multiple: true + }) + }, + [files, quickPanel] + ) + + const openSelectFileMenu = useCallback(() => { + quickPanel.open({ + title: t('chat.input.upload'), + list: [ + { + label: t('chat.input.upload.upload_from_local'), + description: '', + icon: , + action: () => { + attachmentButtonRef.current?.openQuickPanel() + } + }, + ...knowledgeBases.map((base) => { + const length = base.items?.filter( + (item): item is KnowledgeItem => ['file', 'note'].includes(item.type) && typeof item.content !== 'string' + ).length + return { + label: base.name, + description: `${length} ${t('files.count')}`, + icon: , + disabled: length === 0, + isMenu: true, + action: () => openKnowledgeFileList(base) + } + }) + ], + symbol: 'file' + }) + }, [knowledgeBases, openKnowledgeFileList, quickPanel, t]) + + const quickPanelMenu = useMemo(() => { + return [ + { + label: t('settings.quickPhrase.title'), + description: '', + icon: , + isMenu: true, + action: () => { + quickPhrasesButtonRef.current?.openQuickPanel() + } + }, + { + label: t('agents.edit.model.select.title'), + description: '', + icon: '@', + isMenu: true, + action: () => { + mentionModelsButtonRef.current?.openQuickPanel() + } + }, + { + label: t('chat.input.knowledge_base'), + description: '', + icon: , + isMenu: true, + disabled: !showKnowledgeIcon || files.length > 0, + action: () => { + knowledgeBaseButtonRef.current?.openQuickPanel() + } + }, + { + label: t('settings.mcp.title'), + description: showMCPToolsIcon ? '' : t('settings.mcp.not_support'), + icon: , + isMenu: true, + disabled: !showMCPToolsIcon, + action: () => { + mcpToolsButtonRef.current?.openQuickPanel() + } + }, + { + label: isVisionModel(model) ? t('chat.input.upload') : t('chat.input.upload.document'), + description: '', + icon: , + isMenu: true, + action: openSelectFileMenu + }, + { + label: t('translate.title'), + description: t('translate.menu.description'), + icon: , + action: () => { + if (!text) return + translate() + } + } + ] + }, [files.length, model, openSelectFileMenu, showKnowledgeIcon, showMCPToolsIcon, t, text, translate]) const handleKeyDown = (event: React.KeyboardEvent) => { const isEnterPressed = event.keyCode == 13 - if (event.key === '@') { + // 按下Tab键,自动选中${xxx} + if (event.key === 'Tab' && inputFocus) { + event.preventDefault() const textArea = textareaRef.current?.resizableTextArea?.textArea - if (textArea) { - const cursorPosition = textArea.selectionStart - const textBeforeCursor = text.substring(0, cursorPosition) - if (cursorPosition === 0 || textBeforeCursor.endsWith(' ')) { - setMentionFromKeyboard(true) - EventEmitter.emit(EVENT_NAMES.SHOW_MODEL_SELECTOR) - setIsMentionPopupOpen(true) - return - } - } - } + if (!textArea) return - if (event.key === 'Escape' && isMentionPopupOpen) { - setIsMentionPopupOpen(false) - return + const cursorPosition = textArea.selectionStart + const selectionLength = textArea.selectionEnd - textArea.selectionStart + const text = textArea.value + + let match = text.slice(cursorPosition + selectionLength).match(/\$\{[^}]+\}/) + let startIndex = -1 + + if (!match) { + match = text.match(/\$\{[^}]+\}/) + startIndex = match?.index ?? -1 + } else { + startIndex = cursorPosition + selectionLength + match.index! + } + + if (startIndex !== -1) { + const endIndex = startIndex + match![0].length + textArea.setSelectionRange(startIndex, endIndex) + return + } } if (autoTranslateWithSpace) { @@ -289,33 +440,29 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = } if (isEnterPressed && !event.shiftKey && sendMessageShortcut === 'Enter') { - if (isMentionPopupOpen) { - return event.preventDefault() - } + if (quickPanel.isVisible) return event.preventDefault() + sendMessage() return event.preventDefault() } if (sendMessageShortcut === 'Shift+Enter' && isEnterPressed && event.shiftKey) { - if (isMentionPopupOpen) { - return event.preventDefault() - } + if (quickPanel.isVisible) return event.preventDefault() + sendMessage() return event.preventDefault() } if (sendMessageShortcut === 'Ctrl+Enter' && isEnterPressed && event.ctrlKey) { - if (isMentionPopupOpen) { - return event.preventDefault() - } + if (quickPanel.isVisible) return event.preventDefault() + sendMessage() return event.preventDefault() } if (sendMessageShortcut === 'Command+Enter' && isEnterPressed && event.metaKey) { - if (isMentionPopupOpen) { - return event.preventDefault() - } + if (quickPanel.isVisible) return event.preventDefault() + sendMessage() return event.preventDefault() } @@ -324,6 +471,15 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = setMentionModels((prev) => prev.slice(0, -1)) return event.preventDefault() } + + if (event.key === 'Backspace' && text.trim() === '' && selectedKnowledgeBases.length > 0) { + setSelectedKnowledgeBases((prev) => { + const newSelectedKnowledgeBases = prev.slice(0, -1) + updateAssistant({ ...assistant, knowledge_bases: newSelectedKnowledgeBases }) + return newSelectedKnowledgeBases + }) + return event.preventDefault() + } } const addNewTopic = useCallback(async () => { @@ -388,16 +544,20 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = const newText = e.target.value setText(newText) - // Check if @ was deleted const textArea = textareaRef.current?.resizableTextArea?.textArea - if (textArea) { - const cursorPosition = textArea.selectionStart - const textBeforeCursor = newText.substring(0, cursorPosition) - const lastAtIndex = textBeforeCursor.lastIndexOf('@') + const cursorPosition = textArea?.selectionStart ?? 0 + const lastSymbol = newText[cursorPosition - 1] - if (lastAtIndex === -1 || textBeforeCursor.slice(lastAtIndex + 1).includes(' ')) { - setIsMentionPopupOpen(false) - } + if (!quickPanel.isVisible && lastSymbol === '/') { + quickPanel.open({ + title: t('settings.quickPanel.title'), + list: quickPanelMenu, + symbol: '/' + }) + } + + if (!quickPanel.isVisible && lastSymbol === '@') { + mentionModelsButtonRef.current?.openQuickPanel() } } @@ -603,33 +763,14 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = setSelectedKnowledgeBases(bases ?? []) } - const onMentionModel = (model: Model, fromKeyboard: boolean = false) => { - const textArea = textareaRef.current?.resizableTextArea?.textArea - if (textArea) { - if (fromKeyboard) { - const cursorPosition = textArea.selectionStart - const textBeforeCursor = text.substring(0, cursorPosition) - const lastAtIndex = textBeforeCursor.lastIndexOf('@') - - if (lastAtIndex !== -1) { - const newText = text.substring(0, lastAtIndex) + text.substring(cursorPosition) - setText(newText) - } - } - - setMentionModels((prev) => [...prev, model]) - setIsMentionPopupOpen(false) - setTimeout(() => { - textareaRef.current?.focus() - }, 0) - setMentionFromKeyboard(false) - } - } - const handleRemoveModel = (model: Model) => { setMentionModels(mentionModels.filter((m) => m.id !== model.id)) } + const handleRemoveKnowledgeBase = (knowledgeBase: KnowledgeBase) => { + setSelectedKnowledgeBases(selectedKnowledgeBases.filter((kb) => kb.id !== knowledgeBase.id)) + } + const toggelEnableMCP = (mcp: MCPServer) => { setEnabledMCPs((prev) => { const exists = prev.some((item) => item.id === mcp.id) @@ -687,15 +828,27 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = } }) } + const onMentionModel = (model: Model) => { + setMentionModels((prev) => { + const modelId = getModelUniqId(model) + const exists = prev.some((m) => getModelUniqId(m) === modelId) + return exists ? prev.filter((m) => getModelUniqId(m) !== modelId) : [...prev, model] + }) + } return ( + +