diff --git a/src/renderer/src/components/QuickPanel/types.ts b/src/renderer/src/components/QuickPanel/types.ts index 8cf79fb270..d8e2ff26b0 100644 --- a/src/renderer/src/components/QuickPanel/types.ts +++ b/src/renderer/src/components/QuickPanel/types.ts @@ -54,6 +54,12 @@ export type QuickPanelListItem = { isSelected?: boolean isMenu?: boolean disabled?: boolean + /** + * 固定显示项:不参与过滤,始终出现在列表顶部。 + * 例如“清除”按钮可设置为 alwaysVisible,从而在有匹配项时始终可见; + * 折叠判定依然仅依据非固定项数量,从而在无匹配时整体折叠隐藏。 + */ + alwaysVisible?: boolean action?: (options: QuickPanelCallBackOptions) => void } diff --git a/src/renderer/src/components/QuickPanel/view.tsx b/src/renderer/src/components/QuickPanel/view.tsx index 74cbc763c1..08878b8478 100644 --- a/src/renderer/src/components/QuickPanel/view.tsx +++ b/src/renderer/src/components/QuickPanel/view.tsx @@ -1,10 +1,12 @@ import { RightOutlined } from '@ant-design/icons' import { DynamicVirtualList, type DynamicVirtualListRef } from '@renderer/components/VirtualList' import { isMac } from '@renderer/config/constant' +import { useTimer } from '@renderer/hooks/useTimer' import useUserTheme from '@renderer/hooks/useUserTheme' import { classNames } from '@renderer/utils' import { Flex } from 'antd' import { t } from 'i18next' +import { debounce } from 'lodash' import { Check } from 'lucide-react' import React, { use, useCallback, useDeferredValue, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import styled from 'styled-components' @@ -62,20 +64,32 @@ export const QuickPanelView: React.FC = ({ setInputText }) => { const searchText = useDeferredValue(_searchText) const searchTextRef = useRef('') + // 缓存:按 item 缓存拼音文本,避免重复转换 + const pinyinCacheRef = useRef>(new WeakMap()) + + // 轻量防抖:减少高频输入时的过滤调用 + const setSearchTextDebounced = useMemo(() => debounce((val: string) => setSearchText(val), 50), []) + // 跟踪上一次的搜索文本和符号,用于判断是否需要重置index const prevSearchTextRef = useRef('') const prevSymbolRef = useRef('') - - // 无匹配项自动关闭的定时器 - const noMatchTimeoutRef = useRef(null) - const clearSearchTimerRef = useRef(undefined) - const focusTimerRef = useRef(undefined) - - // 处理搜索,过滤列表 + const { setTimeoutTimer } = useTimer() + // 处理搜索,过滤列表(始终保留 alwaysVisible 项在顶部) const list = useMemo(() => { if (!ctx.isVisible && !ctx.symbol) return [] - const newList = ctx.list?.filter((item) => { - const _searchText = searchText.replace(/^[/@]/, '') + const _searchText = searchText.replace(/^[/@]/, '') + const lowerSearchText = _searchText.toLowerCase() + const fuzzyPattern = lowerSearchText + .split('') + .map((char) => char.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')) + .join('.*') + const fuzzyRegex = new RegExp(fuzzyPattern, 'ig') + + // 拆分:固定显示项(不参与过滤)与普通项 + const pinnedItems = (ctx.list || []).filter((item) => item.alwaysVisible) + const normalItems = (ctx.list || []).filter((item) => !item.alwaysVisible) + + const filteredNormalItems = normalItems.filter((item) => { if (!_searchText) return true let filterText = item.filterText || '' @@ -87,29 +101,24 @@ export const QuickPanelView: React.FC = ({ setInputText }) => { } const lowerFilterText = filterText.toLowerCase() - const lowerSearchText = _searchText.toLowerCase() if (lowerFilterText.includes(lowerSearchText)) { return true } - const pattern = lowerSearchText - .split('') - .map((char) => { - return char.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') - }) - .join('.*') if (tinyPinyin.isSupported() && /[\u4e00-\u9fa5]/.test(filterText)) { try { - const pinyinText = tinyPinyin.convertToPinyin(filterText, '', true).toLowerCase() - const regex = new RegExp(pattern, 'ig') - return regex.test(pinyinText) + let pinyinText = pinyinCacheRef.current.get(item) + if (!pinyinText) { + pinyinText = tinyPinyin.convertToPinyin(filterText, '', true).toLowerCase() + pinyinCacheRef.current.set(item, pinyinText) + } + return fuzzyRegex.test(pinyinText) } catch (error) { return true } } else { - const regex = new RegExp(pattern, 'ig') - return regex.test(filterText.toLowerCase()) + return fuzzyRegex.test(filterText.toLowerCase()) } }) @@ -122,8 +131,9 @@ export const QuickPanelView: React.FC = ({ setInputText }) => { } else { // 如果当前index超出范围,调整到有效范围内 setIndex((prevIndex) => { - if (prevIndex >= newList.length) { - return newList.length > 0 ? newList.length - 1 : -1 + const combinedLength = pinnedItems.length + filteredNormalItems.length + if (prevIndex >= combinedLength) { + return combinedLength > 0 ? combinedLength - 1 : -1 } return prevIndex }) @@ -132,81 +142,52 @@ export const QuickPanelView: React.FC = ({ setInputText }) => { prevSearchTextRef.current = searchText prevSymbolRef.current = ctx.symbol - return newList + // 固定项置顶 + 过滤后的普通项 + return [...pinnedItems, ...filteredNormalItems] }, [ctx.isVisible, ctx.symbol, ctx.list, searchText]) const canForwardAndBackward = useMemo(() => { return list.some((item) => item.isMenu) || historyPanel.length > 0 }, [list, historyPanel]) - // 智能关闭逻辑:当有搜索文本但无匹配项时,延迟关闭面板 - useEffect(() => { - const _searchText = searchText.replace(/^[/@]/, '') - - // 清除之前的定时器(无论面板是否可见都要清理) - if (noMatchTimeoutRef.current) { - clearTimeout(noMatchTimeoutRef.current) - noMatchTimeoutRef.current = null - clearTimeout(clearSearchTimerRef.current) - clearTimeout(focusTimerRef.current) - } - - // 面板不可见时不设置新定时器 - if (!ctx.isVisible) { - return - } - - // 只有在有搜索文本但无匹配项时才设置延迟关闭 - if (_searchText && _searchText.length > 0 && list.length === 0) { - noMatchTimeoutRef.current = setTimeout(() => { - ctx.close('no-matches') - }, 300) - } - - // 清理函数 - return () => { - if (noMatchTimeoutRef.current) { - clearTimeout(noMatchTimeoutRef.current) - noMatchTimeoutRef.current = null - } - clearTimeout(clearSearchTimerRef.current) - clearTimeout(focusTimerRef.current) - } - // eslint-disable-next-line react-hooks/exhaustive-deps -- ctx对象引用不稳定,使用具体属性避免过度重渲染 - }, [ctx.isVisible, searchText, list.length, ctx.close]) - const clearSearchText = useCallback( (includeSymbol = false) => { const textArea = document.querySelector('.inputbar textarea') as HTMLTextAreaElement + if (!textArea) return + const cursorPosition = textArea.selectionStart ?? 0 - const prevChar = textArea.value[cursorPosition - 1] - if ((prevChar === '/' || prevChar === '@') && !searchTextRef.current) { - searchTextRef.current = prevChar - } + const textBeforeCursor = textArea.value.slice(0, cursorPosition) - const _searchText = includeSymbol ? searchTextRef.current : searchTextRef.current.replace(/^[/@]/, '') - if (!_searchText) return + // 查找最后一个 @ 或 / 符号的位置 + const lastAtIndex = textBeforeCursor.lastIndexOf('@') + const lastSlashIndex = textBeforeCursor.lastIndexOf('/') + const lastSymbolIndex = Math.max(lastAtIndex, lastSlashIndex) - const inputText = textArea.value - let newText = inputText - const searchPattern = new RegExp(`${_searchText}$`) + if (lastSymbolIndex === -1) return - 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) + // 根据 includeSymbol 决定是否删除符号 + const deleteStart = includeSymbol ? lastSymbolIndex : lastSymbolIndex + 1 + const deleteEnd = cursorPosition - clearTimeout(focusTimerRef.current) - focusTimerRef.current = setTimeout(() => { + if (deleteStart >= deleteEnd) return + + // 删除文本 + const newText = textArea.value.slice(0, deleteStart) + textArea.value.slice(deleteEnd) + setInputText(newText) + + // 设置光标位置 + setTimeoutTimer( + 'quickpanel_focus', + () => { textArea.focus() - textArea.setSelectionRange(start, start) - }, 0) - } + textArea.setSelectionRange(deleteStart, deleteStart) + }, + 0 + ) + setSearchText('') }, - [setInputText] + [setInputText, setTimeoutTimer] ) const handleClose = useCallback( @@ -317,9 +298,10 @@ export const QuickPanelView: React.FC = ({ setInputText }) => { if (lastSymbolIndex !== -1) { const newSearchText = textBeforeCursor.slice(lastSymbolIndex) - setSearchText(newSearchText) + setSearchTextDebounced(newSearchText) } else { - ctx.close('delete-symbol') + // 使用本地 handleClose,确保在删除触发符时同步受控输入值 + handleClose('delete-symbol') } } @@ -340,10 +322,14 @@ export const QuickPanelView: React.FC = ({ setInputText }) => { textArea.removeEventListener('input', handleInput) textArea.removeEventListener('compositionupdate', handleCompositionUpdate) textArea.removeEventListener('compositionend', handleCompositionEnd) - clearTimeout(clearSearchTimerRef.current) - clearSearchTimerRef.current = setTimeout(() => { - setSearchText('') - }, 200) // 等待面板关闭动画结束后,再清空搜索词 + setSearchTextDebounced.cancel() + setTimeoutTimer( + 'quickpanel_clear_search', + () => { + setSearchText('') + }, + 200 + ) // 等待面板关闭动画结束后,再清空搜索词 } // eslint-disable-next-line react-hooks/exhaustive-deps }, [ctx.isVisible]) @@ -357,9 +343,11 @@ export const QuickPanelView: React.FC = ({ setInputText }) => { scrollTriggerRef.current = 'none' }, [index]) - // 处理键盘事件 + // 处理键盘事件(折叠时不拦截全局键盘) useEffect(() => { - if (!ctx.isVisible) return + const hasSearchTextFlag = searchText.replace(/^[/@]/, '').length > 0 + const isCollapsed = hasSearchTextFlag && list.length === 0 + if (!ctx.isVisible || isCollapsed) return const handleKeyDown = (e: KeyboardEvent) => { if (isMac ? e.metaKey : e.ctrlKey) { @@ -495,7 +483,17 @@ export const QuickPanelView: React.FC = ({ setInputText }) => { window.removeEventListener('keyup', handleKeyUp, true) window.removeEventListener('click', handleClickOutside, true) } - }, [index, isAssistiveKeyPressed, historyPanel, ctx, list, handleItemAction, handleClose, clearSearchText]) + }, [ + index, + isAssistiveKeyPressed, + historyPanel, + ctx, + list, + handleItemAction, + handleClose, + clearSearchText, + searchText + ]) const [footerWidth, setFooterWidth] = useState(0) @@ -515,6 +513,10 @@ export const QuickPanelView: React.FC = ({ setInputText }) => { const listHeight = useMemo(() => { return Math.min(ctx.pageSize, list.length) * ITEM_HEIGHT }, [ctx.pageSize, list.length]) + const hasSearchText = useMemo(() => searchText.replace(/^[/@]/, '').length > 0, [searchText]) + // 折叠仅依据“非固定项”的匹配数;仅剩固定项(如“清除”)时仍视为无匹配,保持折叠 + const visibleNonPinnedCount = useMemo(() => list.filter((i) => !i.alwaysVisible).length, [list]) + const collapsed = hasSearchText && visibleNonPinnedCount === 0 const estimateSize = useCallback(() => ITEM_HEIGHT, []) @@ -562,6 +564,7 @@ export const QuickPanelView: React.FC = ({ setInputText }) => { $pageSize={ctx.pageSize} $selectedColor={selectedColor} $selectedColorHover={selectedColorHover} + $collapsed={collapsed} className={ctx.isVisible ? 'visible' : ''} data-testid="quick-panel"> = ({ setInputText }) => { return prev ? prev : true }) }> - - {rowRenderer} - + {!collapsed && ( + + {rowRenderer} + + )} {ctx.title || ''} @@ -626,6 +631,7 @@ const QuickPanelContainer = styled.div<{ $pageSize: number $selectedColor: string $selectedColorHover: string + $collapsed?: boolean }>` --focused-color: rgba(0, 0, 0, 0.06); --selected-color: ${(props) => props.$selectedColor}; @@ -644,8 +650,8 @@ const QuickPanelContainer = styled.div<{ pointer-events: none; &.visible { - pointer-events: auto; - max-height: ${(props) => props.$pageSize * ITEM_HEIGHT + 100}px; + pointer-events: ${(props) => (props.$collapsed ? 'none' : 'auto')}; + max-height: ${(props) => (props.$collapsed ? 0 : props.$pageSize * ITEM_HEIGHT + 100)}px; } body[theme-mode='dark'] & { --focused-color: rgba(255, 255, 255, 0.1); diff --git a/src/renderer/src/pages/home/Inputbar/Inputbar.tsx b/src/renderer/src/pages/home/Inputbar/Inputbar.tsx index e2de99df8c..d97a0164e1 100644 --- a/src/renderer/src/pages/home/Inputbar/Inputbar.tsx +++ b/src/renderer/src/pages/home/Inputbar/Inputbar.tsx @@ -111,7 +111,6 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = const [textareaHeight, setTextareaHeight] = useState() const startDragY = useRef(0) const startHeight = useRef(0) - const currentMessageId = useRef('') const { bases: knowledgeBases } = useKnowledgeBases() const isMultiSelectMode = useAppSelector((state) => state.runtime.chat.isMultiSelectMode) const isVisionAssistant = useMemo(() => isVisionModel(model), [model]) @@ -251,7 +250,6 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = const { message, blocks } = getUserMessage(baseUserMessage) message.traceId = parent?.spanContext().traceId - currentMessageId.current = message.id dispatch(_sendMessage(message, blocks, assistantWithTopicPrompt, topic.id)) // Clear input @@ -512,30 +510,42 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = const cursorPosition = textArea?.selectionStart ?? 0 const lastSymbol = newText[cursorPosition - 1] - if (enableQuickPanelTriggers && !quickPanel.isVisible && lastSymbol === '/') { - const quickPanelMenu = - inputbarToolsRef.current?.getQuickPanelMenu({ - t, - files, - couldAddImageFile, - text: newText, - openSelectFileMenu, - translate - }) || [] + // 触发符号为 '/':若当前未打开或符号不同,则切换/打开 + if (enableQuickPanelTriggers && lastSymbol === '/') { + if (quickPanel.isVisible && quickPanel.symbol !== '/') { + quickPanel.close('switch-symbol') + } + if (!quickPanel.isVisible || quickPanel.symbol !== '/') { + const quickPanelMenu = + inputbarToolsRef.current?.getQuickPanelMenu({ + t, + files, + couldAddImageFile, + text: newText, + openSelectFileMenu, + translate + }) || [] - quickPanel.open({ - title: t('settings.quickPanel.title'), - list: quickPanelMenu, - symbol: '/' - }) + quickPanel.open({ + title: t('settings.quickPanel.title'), + list: quickPanelMenu, + symbol: '/' + }) + } } - if (enableQuickPanelTriggers && !quickPanel.isVisible && lastSymbol === '@') { - inputbarToolsRef.current?.openMentionModelsPanel({ - type: 'input', - position: cursorPosition - 1, - originalText: newText - }) + // 触发符号为 '@':若当前未打开或符号不同,则切换/打开 + if (enableQuickPanelTriggers && lastSymbol === '@') { + if (quickPanel.isVisible && quickPanel.symbol !== '@') { + quickPanel.close('switch-symbol') + } + if (!quickPanel.isVisible || quickPanel.symbol !== '@') { + inputbarToolsRef.current?.openMentionModelsPanel({ + type: 'input', + position: cursorPosition - 1, + originalText: newText + }) + } } }, [enableQuickPanelTriggers, quickPanel, t, files, couldAddImageFile, openSelectFileMenu, translate] diff --git a/src/renderer/src/pages/home/Inputbar/MentionModelsButton.tsx b/src/renderer/src/pages/home/Inputbar/MentionModelsButton.tsx index 06c1a38695..cf48743b12 100644 --- a/src/renderer/src/pages/home/Inputbar/MentionModelsButton.tsx +++ b/src/renderer/src/pages/home/Inputbar/MentionModelsButton.tsx @@ -48,6 +48,66 @@ const MentionModelsButton: FC = ({ // 记录是否有模型被选择的动作发生 const hasModelActionRef = useRef(false) + // 记录触发信息,用于清除操作 + const triggerInfoRef = useRef<{ type: 'input' | 'button'; position?: number; originalText?: string } | undefined>( + undefined + ) + + // 基于光标 + 搜索词定位并删除最近一次触发的 @ 及搜索文本 + const removeAtSymbolAndText = useCallback( + (currentText: string, caretPosition: number, searchText?: string, fallbackPosition?: number) => { + const safeCaret = Math.max(0, Math.min(caretPosition ?? 0, currentText.length)) + + // ESC/精确删除:优先按 pattern = "@" + searchText 从光标向左最近匹配 + if (searchText !== undefined) { + const pattern = '@' + searchText + const fromIndex = Math.max(0, safeCaret - 1) + const start = currentText.lastIndexOf(pattern, fromIndex) + if (start !== -1) { + const end = start + pattern.length + return currentText.slice(0, start) + currentText.slice(end) + } + + // 兜底:使用打开时的 position 做校验后再删 + if (typeof fallbackPosition === 'number' && currentText[fallbackPosition] === '@') { + const expected = pattern + const actual = currentText.slice(fallbackPosition, fallbackPosition + expected.length) + if (actual === expected) { + return currentText.slice(0, fallbackPosition) + currentText.slice(fallbackPosition + expected.length) + } + // 如果不完全匹配,安全起见仅删除单个 '@' + return currentText.slice(0, fallbackPosition) + currentText.slice(fallbackPosition + 1) + } + + // 未找到匹配则不改动 + return currentText + } + + // 清除按钮:未知搜索词,删除离光标最近的 '@' 及后续连续非空白(到空格/换行/结尾) + { + const fromIndex = Math.max(0, safeCaret - 1) + const start = currentText.lastIndexOf('@', fromIndex) + if (start === -1) { + // 兜底:使用打开时的 position(若存在),按空白边界删除 + if (typeof fallbackPosition === 'number' && currentText[fallbackPosition] === '@') { + let endPos = fallbackPosition + 1 + while (endPos < currentText.length && currentText[endPos] !== ' ' && currentText[endPos] !== '\n') { + endPos++ + } + return currentText.slice(0, fallbackPosition) + currentText.slice(endPos) + } + return currentText + } + + let endPos = start + 1 + while (endPos < currentText.length && currentText[endPos] !== ' ' && currentText[endPos] !== '\n') { + endPos++ + } + return currentText.slice(0, start) + currentText.slice(endPos) + } + }, + [] + ) const pinnedModels = useLiveQuery( async () => { @@ -140,9 +200,20 @@ const MentionModelsButton: FC = ({ label: t('settings.input.clear.all'), description: t('settings.input.clear.models'), icon: , + alwaysVisible: true, isSelected: false, action: () => { onClearMentionModels() + + // 只有输入触发时才需要删除 @ 与搜索文本(未知搜索词,按光标就近删除) + if (triggerInfoRef.current?.type === 'input') { + setText((currentText) => { + const textArea = document.querySelector('.inputbar textarea') as HTMLTextAreaElement | null + const caret = textArea ? (textArea.selectionStart ?? currentText.length) : currentText.length + return removeAtSymbolAndText(currentText, caret, undefined, triggerInfoRef.current?.position) + }) + } + quickPanel.close() } }) @@ -157,13 +228,17 @@ const MentionModelsButton: FC = ({ onMentionModel, navigate, quickPanel, - onClearMentionModels + onClearMentionModels, + setText, + removeAtSymbolAndText ]) const openQuickPanel = useCallback( (triggerInfo?: { type: 'input' | 'button'; position?: number; originalText?: string }) => { // 重置模型动作标记 hasModelActionRef.current = false + // 保存触发信息 + triggerInfoRef.current = triggerInfo quickPanel.open({ title: t('agents.edit.model.select.title'), @@ -183,28 +258,11 @@ const MentionModelsButton: FC = ({ closeTriggerInfo?.type === 'input' && closeTriggerInfo?.position !== undefined ) { - // 使用React的setText来更新状态 + // 基于当前光标 + 搜索词精确定位并删除,position 仅作兜底 setText((currentText) => { - const position = closeTriggerInfo.position! - // 验证位置的字符是否仍是 @ - if (currentText[position] !== '@') { - return currentText - } - - // 计算删除范围:@ + searchText - const deleteLength = 1 + (searchText?.length || 0) - - // 验证要删除的内容是否匹配预期 - const expectedText = '@' + (searchText || '') - const actualText = currentText.slice(position, position + deleteLength) - - if (actualText !== expectedText) { - // 如果实际文本不匹配,只删除 @ 字符 - return currentText.slice(0, position) + currentText.slice(position + 1) - } - - // 删除 @ 和搜索文本 - return currentText.slice(0, position) + currentText.slice(position + deleteLength) + const textArea = document.querySelector('.inputbar textarea') as HTMLTextAreaElement | null + const caret = textArea ? (textArea.selectionStart ?? currentText.length) : currentText.length + return removeAtSymbolAndText(currentText, caret, searchText || '', closeTriggerInfo.position!) }) } } @@ -213,7 +271,7 @@ const MentionModelsButton: FC = ({ } }) }, - [modelItems, quickPanel, t, setText] + [modelItems, quickPanel, t, setText, removeAtSymbolAndText] ) const handleOpenQuickPanel = useCallback(() => {