mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2025-12-24 10:40:07 +08:00
* feat(QuickPanel): 软隐藏与符号切换;性能优化与清理
- 交互改进
- 无匹配时“软隐藏”(不销毁、折叠且不拦截)
- 回删修正后有结果自动展开
- 输入新符号(/ 或 @)即切换到对应面板
- 性能优化
- 搜索 50ms 防抖,降低高频输入开销
- 按搜索词只构建一次模糊匹配正则
- 使用 WeakMap 缓存每项拼音,避免重复转换
- 折叠时不渲染列表、不注册全局键盘监听
- 代码清理
- 删除 noMatchTimeoutRef 及其清理 effect
- 删除未使用的 currentMessageId 引用
- 移除重复的 setText('') 清空逻辑
- 保持不变
- 多选/固定/清空等既有模型面板逻辑
- ESC、外部点击、删除符号的关闭语义
- 初始空查询直接展示可选项
* feat(quickpanel): 清除模型时同时删除@符号和搜索文本
- 在MentionModelsButton中记录触发信息
- 清除操作时根据触发类型删除@符号
- 仅处理输入触发的场景,按钮触发不需要处理
* refactor(quickpanel): 提取通用的删除@符号函数
- 创建 removeAtSymbolAndText 函数统一处理删除逻辑
- 支持两种模式:精确删除(ESC,使用searchText)和自动查找(清除)
- ESC和清除操作现在使用相同的核心逻辑
- 提高代码可维护性和一致性
* handleInput 中的 ctx.close('delete-symbol') 替换为本地 handleClose('delete-symbol'),确保 Backspace 删除触发符时同步受控输入值。
* - 统一 @ 清除逻辑:基于光标+搜索词的锚点定位
- 修复 ESC/清除误删邮箱/URL 中 @ 的问题
- 精确匹配优先:从光标左侧最近的 “@+searchText”
- 失败兜底:验证触发位 position,一致删整段,不一致仅删单个 @
- 清除按钮:未知搜索词时按光标左侧最近 @ 删至空格/换行
- 保持行为一致:ESC 与“清除模型”共用同一删除函数
* - 修复:无匹配时“清除”被过滤导致不可用的问题
- 方案:为“清除”项添加 alwaysVisible 标记,不参与过滤并始终置顶展示
- 过滤改造:QuickPanel 将列表拆分为固定项与普通项,仅对普通项执行包含/模糊/拼音过滤,最终合并渲染
- 折叠逻辑:collapsed 仅依据“非固定项”的匹配数;当仅剩“清除”时仍折叠隐藏,UI 不受影响
This commit is contained in:
parent
c49201f365
commit
a398010213
@ -54,6 +54,12 @@ export type QuickPanelListItem = {
|
||||
isSelected?: boolean
|
||||
isMenu?: boolean
|
||||
disabled?: boolean
|
||||
/**
|
||||
* 固定显示项:不参与过滤,始终出现在列表顶部。
|
||||
* 例如“清除”按钮可设置为 alwaysVisible,从而在有匹配项时始终可见;
|
||||
* 折叠判定依然仅依据非固定项数量,从而在无匹配时整体折叠隐藏。
|
||||
*/
|
||||
alwaysVisible?: boolean
|
||||
action?: (options: QuickPanelCallBackOptions) => void
|
||||
}
|
||||
|
||||
|
||||
@ -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<Props> = ({ setInputText }) => {
|
||||
const searchText = useDeferredValue(_searchText)
|
||||
const searchTextRef = useRef('')
|
||||
|
||||
// 缓存:按 item 缓存拼音文本,避免重复转换
|
||||
const pinyinCacheRef = useRef<WeakMap<QuickPanelListItem, string>>(new WeakMap())
|
||||
|
||||
// 轻量防抖:减少高频输入时的过滤调用
|
||||
const setSearchTextDebounced = useMemo(() => debounce((val: string) => setSearchText(val), 50), [])
|
||||
|
||||
// 跟踪上一次的搜索文本和符号,用于判断是否需要重置index
|
||||
const prevSearchTextRef = useRef('')
|
||||
const prevSymbolRef = useRef('')
|
||||
|
||||
// 无匹配项自动关闭的定时器
|
||||
const noMatchTimeoutRef = useRef<NodeJS.Timeout | null>(null)
|
||||
const clearSearchTimerRef = useRef<NodeJS.Timeout>(undefined)
|
||||
const focusTimerRef = useRef<NodeJS.Timeout>(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<Props> = ({ 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<Props> = ({ 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<Props> = ({ 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<Props> = ({ 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<Props> = ({ 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<Props> = ({ 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<Props> = ({ 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<Props> = ({ 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<Props> = ({ setInputText }) => {
|
||||
$pageSize={ctx.pageSize}
|
||||
$selectedColor={selectedColor}
|
||||
$selectedColorHover={selectedColorHover}
|
||||
$collapsed={collapsed}
|
||||
className={ctx.isVisible ? 'visible' : ''}
|
||||
data-testid="quick-panel">
|
||||
<QuickPanelBody
|
||||
@ -572,17 +575,19 @@ export const QuickPanelView: React.FC<Props> = ({ setInputText }) => {
|
||||
return prev ? prev : true
|
||||
})
|
||||
}>
|
||||
<DynamicVirtualList
|
||||
ref={listRef}
|
||||
list={list}
|
||||
size={listHeight}
|
||||
estimateSize={estimateSize}
|
||||
overscan={5}
|
||||
scrollerStyle={{
|
||||
pointerEvents: isMouseOver ? 'auto' : 'none'
|
||||
}}>
|
||||
{rowRenderer}
|
||||
</DynamicVirtualList>
|
||||
{!collapsed && (
|
||||
<DynamicVirtualList
|
||||
ref={listRef}
|
||||
list={list}
|
||||
size={listHeight}
|
||||
estimateSize={estimateSize}
|
||||
overscan={5}
|
||||
scrollerStyle={{
|
||||
pointerEvents: isMouseOver ? 'auto' : 'none'
|
||||
}}>
|
||||
{rowRenderer}
|
||||
</DynamicVirtualList>
|
||||
)}
|
||||
<QuickPanelFooter ref={footerRef}>
|
||||
<QuickPanelFooterTitle>{ctx.title || ''}</QuickPanelFooterTitle>
|
||||
<QuickPanelFooterTips $footerWidth={footerWidth}>
|
||||
@ -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);
|
||||
|
||||
@ -111,7 +111,6 @@ const Inputbar: FC<Props> = ({ assistant: _assistant, setActiveTopic, topic }) =
|
||||
const [textareaHeight, setTextareaHeight] = useState<number>()
|
||||
const startDragY = useRef<number>(0)
|
||||
const startHeight = useRef<number>(0)
|
||||
const currentMessageId = useRef<string>('')
|
||||
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<Props> = ({ 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<Props> = ({ 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]
|
||||
|
||||
@ -48,6 +48,66 @@ const MentionModelsButton: FC<Props> = ({
|
||||
|
||||
// 记录是否有模型被选择的动作发生
|
||||
const hasModelActionRef = useRef<boolean>(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<Props> = ({
|
||||
label: t('settings.input.clear.all'),
|
||||
description: t('settings.input.clear.models'),
|
||||
icon: <CircleX />,
|
||||
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<Props> = ({
|
||||
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<Props> = ({
|
||||
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<Props> = ({
|
||||
}
|
||||
})
|
||||
},
|
||||
[modelItems, quickPanel, t, setText]
|
||||
[modelItems, quickPanel, t, setText, removeAtSymbolAndText]
|
||||
)
|
||||
|
||||
const handleOpenQuickPanel = useCallback(() => {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user