From 67a6a6a4451fb3728489863abb059c647c85db5f Mon Sep 17 00:00:00 2001 From: SuYao Date: Mon, 22 Sep 2025 00:11:27 +0800 Subject: [PATCH] fix: support leadingspace to avoid normal text (#10264) * fix: support leadingspace to avoid normal text * Close QuickPanel when no search results found Add automatic closing of QuickPanel when search yields no results for single-select input triggers, preventing users from getting stuck with empty result lists. * fix: reopen quick panel while editing trigger text * fix: hide quick trigger hints when disabled * Update zh-tw.json --- .../src/components/QuickPanel/view.tsx | 33 ++++-- src/renderer/src/i18n/locales/en-us.json | 3 +- src/renderer/src/i18n/locales/zh-cn.json | 3 +- src/renderer/src/i18n/locales/zh-tw.json | 3 +- .../src/pages/home/Inputbar/Inputbar.tsx | 106 ++++++++++++++---- .../home/Inputbar/MentionModelsButton.tsx | 4 +- 6 files changed, 116 insertions(+), 36 deletions(-) diff --git a/src/renderer/src/components/QuickPanel/view.tsx b/src/renderer/src/components/QuickPanel/view.tsx index 59d72b2de2..52c33607c7 100644 --- a/src/renderer/src/components/QuickPanel/view.tsx +++ b/src/renderer/src/components/QuickPanel/view.tsx @@ -158,15 +158,22 @@ export const QuickPanelView: React.FC = ({ setInputText }) => { const cursorPosition = textArea.selectionStart ?? 0 const textBeforeCursor = textArea.value.slice(0, cursorPosition) - // 查找最后一个 @ 或 / 符号的位置 - const lastAtIndex = textBeforeCursor.lastIndexOf('@') - const lastSlashIndex = textBeforeCursor.lastIndexOf('/') - const lastSymbolIndex = Math.max(lastAtIndex, lastSlashIndex) + // 查找末尾最近的触发符号(@ 或 /),允许位于文本起始或空格后 + const match = textBeforeCursor.match(/(^| )([@/][^\s]*)$/) + if (!match) return - if (lastSymbolIndex === -1) return + const matchIndex = match.index ?? -1 + if (matchIndex === -1) return + + const boundarySegment = match[1] ?? '' + const symbolSegment = match[2] ?? '' + if (!symbolSegment) return + + const boundaryStart = matchIndex + const symbolStart = boundaryStart + boundarySegment.length // 根据 includeSymbol 决定是否删除符号 - const deleteStart = includeSymbol ? lastSymbolIndex : lastSymbolIndex + 1 + const deleteStart = includeSymbol ? boundaryStart : symbolStart + 1 const deleteEnd = cursorPosition if (deleteStart >= deleteEnd) return @@ -203,7 +210,7 @@ export const QuickPanelView: React.FC = ({ setInputText }) => { if (textArea) { setInputText(textArea.value) } - } else if (action && !['outsideclick', 'esc', 'enter_empty'].includes(action)) { + } else if (action && !['outsideclick', 'esc', 'enter_empty', 'no_result'].includes(action)) { clearSearchText(true) } }, @@ -533,6 +540,18 @@ export const QuickPanelView: React.FC = ({ setInputText }) => { const visibleNonPinnedCount = useMemo(() => list.filter((i) => !i.alwaysVisible).length, [list]) const collapsed = hasSearchText && visibleNonPinnedCount === 0 + useEffect(() => { + if (!ctx.isVisible) return + if (!collapsed) return + if (ctx.triggerInfo?.type !== 'input') return + if (ctx.multiple) return + + const trimmedSearch = searchText.replace(/^[/@]/, '').trim() + if (!trimmedSearch) return + + handleClose('no_result') + }, [collapsed, ctx.isVisible, ctx.triggerInfo, ctx.multiple, handleClose, searchText]) + const estimateSize = useCallback(() => ITEM_HEIGHT, []) const rowRenderer = useCallback( diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index 27a3c655df..d576d5544f 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -332,7 +332,8 @@ }, "new_topic": "New Topic {{Command}}", "pause": "Pause", - "placeholder": "Type your message here, press {{key}} to send...", + "placeholder": "Type your message here, press {{key}} to send - @ to Select Model, / to Include Tools", + "placeholder_without_triggers": "Type your message here, press {{key}} to send", "send": "Send", "settings": "Settings", "thinking": { diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index f0c674c3bd..28a53923e5 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -332,7 +332,8 @@ }, "new_topic": "新话题 {{Command}}", "pause": "暂停", - "placeholder": "在这里输入消息,按 {{key}} 发送...", + "placeholder": "在这里输入消息,按 {{key}} 发送 - @ 选择模型, / 选择工具", + "placeholder_without_triggers": "在这里输入消息,按 {{key}} 发送", "send": "发送", "settings": "设置", "thinking": { diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index ab2fc860d9..e191e940c7 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -332,7 +332,8 @@ }, "new_topic": "新話題 {{Command}}", "pause": "暫停", - "placeholder": "在此輸入您的訊息,按 {{key}} 傳送...", + "placeholder": "在此輸入您的訊息,按 {{key}} 傳送 - @ 選擇模型,/ 包含工具", + "placeholder_without_triggers": "在此輸入您的訊息,按 {{key}} 傳送", "send": "傳送", "settings": "設定", "thinking": { diff --git a/src/renderer/src/pages/home/Inputbar/Inputbar.tsx b/src/renderer/src/pages/home/Inputbar/Inputbar.tsx index 5897c4fe45..282f62656d 100644 --- a/src/renderer/src/pages/home/Inputbar/Inputbar.tsx +++ b/src/renderer/src/pages/home/Inputbar/Inputbar.tsx @@ -162,6 +162,7 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = const [tokenCount, setTokenCount] = useState(0) const inputbarToolsRef = useRef(null) + const prevTextRef = useRef(text) // eslint-disable-next-line react-hooks/exhaustive-deps const debouncedEstimate = useCallback( @@ -178,8 +179,21 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = debouncedEstimate(text) }, [text, debouncedEstimate]) + useEffect(() => { + prevTextRef.current = text + }, [text]) + const inputTokenCount = showInputEstimatedTokens ? tokenCount : 0 + const placeholderText = enableQuickPanelTriggers + ? t('chat.input.placeholder', { key: getSendMessageShortcutLabel(sendMessageShortcut) }) + : t('chat.input.placeholder_without_triggers', { + key: getSendMessageShortcutLabel(sendMessageShortcut), + defaultValue: t('chat.input.placeholder', { + key: getSendMessageShortcutLabel(sendMessageShortcut) + }) + }) + const inputEmpty = isEmpty(text.trim()) && files.length === 0 _text = text @@ -441,43 +455,91 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = const newText = e.target.value setText(newText) + const prevText = prevTextRef.current + const isDeletion = newText.length < prevText.length + const textArea = textareaRef.current?.resizableTextArea?.textArea - const cursorPosition = textArea?.selectionStart ?? 0 + const cursorPosition = textArea?.selectionStart ?? newText.length const lastSymbol = newText[cursorPosition - 1] + const previousChar = newText[cursorPosition - 2] + const isCursorAtTextStart = cursorPosition <= 1 + const hasValidTriggerBoundary = previousChar === ' ' || isCursorAtTextStart + + const openRootPanelAt = (position: number) => { + const quickPanelMenu = + inputbarToolsRef.current?.getQuickPanelMenu({ + text: newText, + translate + }) || [] + + quickPanel.open({ + title: t('settings.quickPanel.title'), + list: quickPanelMenu, + symbol: QuickPanelReservedSymbol.Root, + triggerInfo: { + type: 'input', + position, + originalText: newText + } + }) + } + + const openMentionPanelAt = (position: number) => { + inputbarToolsRef.current?.openMentionModelsPanel({ + type: 'input', + position, + originalText: newText + }) + } + + if (enableQuickPanelTriggers && !quickPanel.isVisible) { + const textBeforeCursor = newText.slice(0, cursorPosition) + const lastRootIndex = textBeforeCursor.lastIndexOf(QuickPanelReservedSymbol.Root) + const lastMentionIndex = textBeforeCursor.lastIndexOf(QuickPanelReservedSymbol.MentionModels) + const lastTriggerIndex = Math.max(lastRootIndex, lastMentionIndex) + + if (lastTriggerIndex !== -1 && cursorPosition > lastTriggerIndex) { + const triggerChar = newText[lastTriggerIndex] + const boundaryChar = newText[lastTriggerIndex - 1] ?? '' + const hasBoundary = lastTriggerIndex === 0 || /\s/.test(boundaryChar) + const searchSegment = newText.slice(lastTriggerIndex + 1, cursorPosition) + const hasSearchContent = searchSegment.trim().length > 0 + + if (hasBoundary && (!hasSearchContent || isDeletion)) { + if (triggerChar === QuickPanelReservedSymbol.Root) { + openRootPanelAt(lastTriggerIndex) + } else if (triggerChar === QuickPanelReservedSymbol.MentionModels) { + openMentionPanelAt(lastTriggerIndex) + } + } + } + } // 触发符号为 '/':若当前未打开或符号不同,则切换/打开 - if (enableQuickPanelTriggers && lastSymbol === QuickPanelReservedSymbol.Root) { + if (enableQuickPanelTriggers && lastSymbol === QuickPanelReservedSymbol.Root && hasValidTriggerBoundary) { if (quickPanel.isVisible && quickPanel.symbol !== QuickPanelReservedSymbol.Root) { quickPanel.close('switch-symbol') } if (!quickPanel.isVisible || quickPanel.symbol !== QuickPanelReservedSymbol.Root) { - const quickPanelMenu = - inputbarToolsRef.current?.getQuickPanelMenu({ - text: newText, - translate - }) || [] - - quickPanel.open({ - title: t('settings.quickPanel.title'), - list: quickPanelMenu, - symbol: QuickPanelReservedSymbol.Root - }) + openRootPanelAt(cursorPosition - 1) } } // 触发符号为 '@':若当前未打开或符号不同,则切换/打开 - if (enableQuickPanelTriggers && lastSymbol === QuickPanelReservedSymbol.MentionModels) { + if ( + enableQuickPanelTriggers && + lastSymbol === QuickPanelReservedSymbol.MentionModels && + hasValidTriggerBoundary + ) { if (quickPanel.isVisible && quickPanel.symbol !== QuickPanelReservedSymbol.MentionModels) { quickPanel.close('switch-symbol') } if (!quickPanel.isVisible || quickPanel.symbol !== QuickPanelReservedSymbol.MentionModels) { - inputbarToolsRef.current?.openMentionModelsPanel({ - type: 'input', - position: cursorPosition - 1, - originalText: newText - }) + openMentionPanelAt(cursorPosition - 1) } } + + prevTextRef.current = newText }, [enableQuickPanelTriggers, quickPanel, t, translate] ) @@ -783,11 +845,7 @@ const Inputbar: FC = ({ assistant: _assistant, setActiveTopic, topic }) = value={text} onChange={onChange} onKeyDown={handleKeyDown} - placeholder={ - isTranslating - ? t('chat.input.translating') - : t('chat.input.placeholder', { key: getSendMessageShortcutLabel(sendMessageShortcut) }) - } + placeholder={isTranslating ? t('chat.input.translating') : placeholderText} autoFocus variant="borderless" spellCheck={enableSpellCheck} diff --git a/src/renderer/src/pages/home/Inputbar/MentionModelsButton.tsx b/src/renderer/src/pages/home/Inputbar/MentionModelsButton.tsx index ceaa748bf5..6bb36f988a 100644 --- a/src/renderer/src/pages/home/Inputbar/MentionModelsButton.tsx +++ b/src/renderer/src/pages/home/Inputbar/MentionModelsButton.tsx @@ -89,7 +89,7 @@ const MentionModelsButton: FC = ({ // 兜底:使用打开时的 position(若存在),按空白边界删除 if (typeof fallbackPosition === 'number' && currentText[fallbackPosition] === '@') { let endPos = fallbackPosition + 1 - while (endPos < currentText.length && currentText[endPos] !== ' ' && currentText[endPos] !== '\n') { + while (endPos < currentText.length && !/\s/.test(currentText[endPos])) { endPos++ } return currentText.slice(0, fallbackPosition) + currentText.slice(endPos) @@ -98,7 +98,7 @@ const MentionModelsButton: FC = ({ } let endPos = start + 1 - while (endPos < currentText.length && currentText[endPos] !== ' ' && currentText[endPos] !== '\n') { + while (endPos < currentText.length && !/\s/.test(currentText[endPos])) { endPos++ } return currentText.slice(0, start) + currentText.slice(endPos)