feat: Highlighted search in chat page (#3302)

* feat: Highlighted search in chat page

* Bug fixes and added a temporary F3 shortcut

* Bug fixes

* Bug fixes

* feat: Implement content search functionality with keyboard shortcuts

- Added a new `ContentSearch` component for searching text within a specified target.
- Integrated search functionality with keyboard shortcuts, allowing users to enable search via a new shortcut key.
- Updated internationalization files to include new search-related messages in multiple languages.
- Enhanced shortcut management to accommodate the new search feature, including a migration for shortcut updates.
- Refactored the `Chat` component to utilize the new content search capabilities.

* fix(ContentSearch): Update search index check and enhance container styling

* feat(ContentSearch): Enhance search functionality with case sensitivity and whole word options

- Added options for case sensitivity and whole word matching in the search feature.
- Updated the highlightText function to accommodate new search parameters.
- Improved styling and layout of the search input and buttons for better user experience.
- Refactored related components to support the new search options.

* refactor(useShortcuts): Remove console log for shortcut invocation

* feat: Add user filter and initial text to search

- Improve ContentSearch UI:
  - Add tooltips for search options
  - Enhance result display (e.g., "No results", "0/0")
  - Disable navigation buttons when no matches
  - Relocate search bar to the top of the chat view
- Apply case-sensitivity logic only to Latin characters
- Add translations for new options

* i18n: Translate "No results" message

* Add in-chat search shortcut, update global search to Cmd/Ctrl+Shift+F.

* feat: Allow users to scroll during input and optimize the search result index update logic

* feat: Update search message shortcut to include Shift for improved accessibility

* feat: Refactor search component layout and update highlight color variables

* fix: Adjust margin-bottom for SearchBarContainer

---------

Co-authored-by: suyao <sy20010504@gmail.com>
This commit is contained in:
icinggslits 2025-05-17 21:14:03 +08:00 committed by GitHub
parent 0f97d0302e
commit 501611670e
14 changed files with 934 additions and 34 deletions

View File

@ -53,6 +53,10 @@
--modal-background: #1f1f1f;
--color-highlight: rgba(0, 0, 0, 1);
--color-background-highlight: rgba(255, 255, 0, 0.9);
--color-background-highlight-accent: rgba(255, 150, 50, 0.9);
--navbar-background-mac: rgba(20, 20, 20, 0.55);
--navbar-background: #1f1f1f;
@ -127,6 +131,10 @@ body[theme-mode='light'] {
--modal-background: var(--color-white);
--color-highlight: initial;
--color-background-highlight: rgba(255, 255, 0, 0.5);
--color-background-highlight-accent: rgba(255, 150, 50, 0.5);
--navbar-background-mac: rgba(255, 255, 255, 0.55);
--navbar-background: rgba(244, 244, 244);
@ -291,3 +299,11 @@ body,
.lucide {
color: var(--color-icon);
}
span.highlight {
background-color: var(--color-background-highlight);
color: var(--color-highlight);
}
span.highlight.selected {
background-color: var(--color-background-highlight-accent);
}

View File

@ -0,0 +1,710 @@
import { ToolbarButton } from '@renderer/pages/home/Inputbar/Inputbar'
import NarrowLayout from '@renderer/pages/home/Messages/NarrowLayout'
import { Tooltip } from 'antd'
import { debounce } from 'lodash'
import { CaseSensitive, ChevronDown, ChevronUp, User, WholeWord, X } from 'lucide-react'
import React, { useCallback, useEffect, useImperativeHandle, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
const HIGHLIGHT_CLASS = 'highlight'
const HIGHLIGHT_SELECT_CLASS = 'selected'
interface Props {
children?: React.ReactNode
searchTarget: React.RefObject<React.ReactNode> | React.RefObject<HTMLElement> | HTMLElement
/**
* `node``node``Node.TEXT_NODE`
*
* `true``node`
*/
filter: (node: Node) => boolean
includeUser?: boolean
onIncludeUserChange?: (value: boolean) => void
}
enum SearchCompletedState {
NotSearched,
FirstSearched
}
enum SearchTargetIndex {
Next,
Prev
}
export interface ContentSearchRef {
disable(): void
enable(initialText?: string): void
// 搜索下一个并定位
searchNext(): void
// 搜索上一个并定位
searchPrev(): void
// 搜索并定位
search(): void
// 搜索但不定位,或者说是更新
silentSearch(): void
focus(): void
}
interface MatchInfo {
index: number
length: number
text: string
}
const escapeRegExp = (string: string): string => {
return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') // $& means the whole matched string
}
const findWindowVerticalCenterElementIndex = (elementList: HTMLElement[]): number | null => {
if (!elementList || elementList.length === 0) {
return null
}
let closestElementIndex: number | null = null
let minVerticalDistance = Infinity
const windowCenterY = window.innerHeight / 2
for (let i = 0; i < elementList.length; i++) {
const element = elementList[i]
if (!(element instanceof HTMLElement)) {
continue
}
const rect = element.getBoundingClientRect()
if (rect.bottom < 0 || rect.top > window.innerHeight) {
continue
}
const elementCenterY = rect.top + rect.height / 2
const verticalDistance = Math.abs(elementCenterY - windowCenterY)
if (verticalDistance < minVerticalDistance) {
minVerticalDistance = verticalDistance
closestElementIndex = i
}
}
return closestElementIndex
}
const highlightText = (
textNode: Node,
searchText: string,
highlightClass: string,
isCaseSensitive: boolean,
isWholeWord: boolean
): HTMLSpanElement[] | null => {
const textNodeParentNode: HTMLElement | null = textNode.parentNode as HTMLElement
if (textNodeParentNode) {
if (textNodeParentNode.classList.contains(highlightClass)) {
return null
}
}
if (textNode.nodeType !== Node.TEXT_NODE || !textNode.textContent) {
return null
}
const textContent = textNode.textContent
const escapedSearchText = escapeRegExp(searchText)
// 检查搜索文本是否仅包含拉丁字母
const hasOnlyLatinLetters = /^[a-zA-Z\s]+$/.test(searchText)
// 只有当搜索文本仅包含拉丁字母时才应用大小写敏感
const regexFlags = hasOnlyLatinLetters && isCaseSensitive ? 'g' : 'gi'
const regexPattern = isWholeWord ? `\\b${escapedSearchText}\\b` : escapedSearchText
const regex = new RegExp(regexPattern, regexFlags)
let match
const matches: MatchInfo[] = []
while ((match = regex.exec(textContent)) !== null) {
if (typeof match.index === 'number' && typeof match[0] === 'string') {
matches.push({ index: match.index, length: match[0].length, text: match[0] })
} else {
console.error('Unexpected match format:', match)
}
}
if (matches.length === 0) {
return null
}
const parentNode = textNode.parentNode
if (!parentNode) {
return null
}
const fragment = document.createDocumentFragment()
let currentIndex = 0
const highlightTextSet = new Set<HTMLSpanElement>()
matches.forEach(({ index, length, text }) => {
if (index > currentIndex) {
fragment.appendChild(document.createTextNode(textContent.substring(currentIndex, index)))
}
const highlightSpan = document.createElement('span')
highlightSpan.className = highlightClass
highlightSpan.textContent = text // Use the matched text to preserve case if not case-sensitive
fragment.appendChild(highlightSpan)
highlightTextSet.add(highlightSpan)
currentIndex = index + length
})
if (currentIndex < textContent.length) {
fragment.appendChild(document.createTextNode(textContent.substring(currentIndex)))
}
parentNode.replaceChild(fragment, textNode)
return [...highlightTextSet]
}
const mergeAdjacentTextNodes = (node: HTMLElement) => {
const children = Array.from(node.childNodes)
const groups: Array<Node | { text: string; nodes: Node[] }> = []
let currentTextGroup: { text: string; nodes: Node[] } | null = null
for (const child of children) {
if (child.nodeType === Node.TEXT_NODE) {
if (currentTextGroup === null) {
currentTextGroup = {
text: child.textContent ?? '',
nodes: [child]
}
} else {
currentTextGroup.text += child.textContent
currentTextGroup.nodes.push(child)
}
} else {
if (currentTextGroup !== null) {
groups.push(currentTextGroup!)
currentTextGroup = null
}
groups.push(child)
}
}
if (currentTextGroup !== null) {
groups.push(currentTextGroup)
}
const newChildren = groups.map((group) => {
if (group instanceof Node) {
return group
} else {
return document.createTextNode(group.text)
}
})
node.replaceChildren(...newChildren)
}
// eslint-disable-next-line @eslint-react/no-forward-ref
export const ContentSearch = React.forwardRef<ContentSearchRef, Props>(
({ searchTarget, filter, includeUser = false, onIncludeUserChange }, ref) => {
const target: HTMLElement | null = (() => {
if (searchTarget instanceof HTMLElement) {
return searchTarget
} else {
return (searchTarget.current as HTMLElement) ?? null
}
})()
const containerRef = React.useRef<HTMLDivElement>(null)
const searchInputRef = React.useRef<HTMLInputElement>(null)
const [searchResultIndex, setSearchResultIndex] = useState(0)
const [totalCount, setTotalCount] = useState(0)
const [enableContentSearch, setEnableContentSearch] = useState(false)
const [searchCompleted, setSearchCompleted] = useState(SearchCompletedState.NotSearched)
const [isCaseSensitive, setIsCaseSensitive] = useState(false)
const [isWholeWord, setIsWholeWord] = useState(false)
const [shouldScroll, setShouldScroll] = useState(false)
const highlightTextSet = useState(new Set<Node>())[0]
const prevSearchText = useRef('')
const { t } = useTranslation()
const locateByIndex = (index: number, shouldScroll = true) => {
if (target) {
const highlightTextNodes = [...highlightTextSet] as HTMLElement[]
highlightTextNodes.sort((a, b) => {
const { top: aTop } = a.getBoundingClientRect()
const { top: bTop } = b.getBoundingClientRect()
return aTop - bTop
})
for (const node of highlightTextNodes) {
node.classList.remove(HIGHLIGHT_SELECT_CLASS)
}
setSearchResultIndex(index)
if (highlightTextNodes.length > 0) {
const highlightTextNode = highlightTextNodes[index] ?? null
if (highlightTextNode) {
highlightTextNode.classList.add(HIGHLIGHT_SELECT_CLASS)
if (shouldScroll) {
highlightTextNode.scrollIntoView({
behavior: 'smooth',
block: 'center',
inline: 'center'
})
}
}
}
}
}
const restoreHighlight = () => {
const highlightTextParentNodeSet = new Set<HTMLElement>()
// Make a copy because the set might be modified during iteration indirectly
const nodesToRestore = [...highlightTextSet]
for (const highlightTextNode of nodesToRestore) {
if (highlightTextNode.textContent) {
const textNode = document.createTextNode(highlightTextNode.textContent)
const node = highlightTextNode as HTMLElement
if (node.parentNode) {
highlightTextParentNodeSet.add(node.parentNode as HTMLElement)
node.replaceWith(textNode) // This removes the node from the DOM
}
}
}
highlightTextSet.clear() // Clear the original set after processing
for (const parentNode of highlightTextParentNodeSet) {
mergeAdjacentTextNodes(parentNode)
}
// highlightTextSet.clear() // Already cleared
}
const search = (searchTargetIndex?: SearchTargetIndex): number | null => {
const searchText = searchInputRef.current?.value.trim() ?? null
if (target && searchText !== null && searchText !== '') {
restoreHighlight()
const iter = document.createNodeIterator(target, NodeFilter.SHOW_TEXT)
let textNode: Node | null
const textNodeSet: Set<Node> = new Set()
while ((textNode = iter.nextNode())) {
if (filter(textNode)) {
textNodeSet.add(textNode)
}
}
const highlightTextSetTemp = new Set<HTMLSpanElement>()
for (const node of textNodeSet) {
const list = highlightText(node, searchText, HIGHLIGHT_CLASS, isCaseSensitive, isWholeWord)
if (list) {
list.forEach((node) => highlightTextSetTemp.add(node))
}
}
const highlightTextList = [...highlightTextSetTemp]
setTotalCount(highlightTextList.length)
highlightTextSetTemp.forEach((node) => highlightTextSet.add(node))
const changeIndex = () => {
let index: number
switch (searchTargetIndex) {
case SearchTargetIndex.Next:
{
index = (searchResultIndex + 1) % highlightTextList.length
}
break
case SearchTargetIndex.Prev:
{
index = (searchResultIndex - 1 + highlightTextList.length) % highlightTextList.length
}
break
default: {
index = searchResultIndex
}
}
return Math.max(index, 0)
}
const targetIndex = (() => {
switch (searchCompleted) {
case SearchCompletedState.NotSearched: {
setSearchCompleted(SearchCompletedState.FirstSearched)
const index = findWindowVerticalCenterElementIndex(highlightTextList)
if (index !== null) {
setSearchResultIndex(index)
return index
} else {
setSearchResultIndex(0)
return 0
}
}
case SearchCompletedState.FirstSearched: {
return changeIndex()
}
default: {
return null
}
}
})()
if (targetIndex === null) {
return null
} else {
const totalCount = highlightTextSet.size
if (targetIndex >= totalCount) {
return totalCount - 1
} else {
return targetIndex
}
}
} else {
return null
}
}
const _searchHandlerDebounce = debounce(() => {
implementation.search()
}, 300)
const searchHandler = useCallback(_searchHandlerDebounce, [_searchHandlerDebounce])
const userInputHandler = (event: React.ChangeEvent<HTMLInputElement>) => {
const value = event.target.value.trim()
if (value.length === 0) {
restoreHighlight()
setTotalCount(0)
setSearchResultIndex(0)
setSearchCompleted(SearchCompletedState.NotSearched)
} else {
// 用户输入时允许滚动
setShouldScroll(true)
searchHandler()
}
prevSearchText.current = value
}
const keyDownHandler = (event: React.KeyboardEvent<HTMLInputElement>) => {
const { code, key, shiftKey } = event
if (key === 'Process') {
return
}
switch (code) {
case 'Enter':
{
if (shiftKey) {
implementation.searchPrev()
} else {
implementation.searchNext()
}
event.preventDefault()
}
break
case 'Escape':
{
implementation.disable()
}
break
}
}
const searchInputFocus = () => requestAnimationFrame(() => searchInputRef.current?.focus())
const userOutlinedButtonOnClick = () => {
if (onIncludeUserChange) {
onIncludeUserChange(!includeUser)
}
searchInputFocus()
}
const implementation = {
disable() {
setEnableContentSearch(false)
restoreHighlight()
setShouldScroll(false)
},
enable(initialText?: string) {
setEnableContentSearch(true)
setShouldScroll(false) // Default to false, search itself might set it to true
if (searchInputRef.current) {
const inputEl = searchInputRef.current
if (initialText && initialText.trim().length > 0) {
inputEl.value = initialText
// Trigger search after setting initial text
// Need to make sure search() uses the new value
// and also to focus and select
requestAnimationFrame(() => {
inputEl.focus()
inputEl.select()
setShouldScroll(true)
const targetIndex = search()
if (targetIndex !== null) {
locateByIndex(targetIndex, true) // Ensure scrolling
} else {
// If search returns null (e.g., empty input or no matches with initial text), clear state
restoreHighlight()
setTotalCount(0)
setSearchResultIndex(0)
setSearchCompleted(SearchCompletedState.NotSearched)
}
})
} else {
requestAnimationFrame(() => {
inputEl.focus()
inputEl.select()
})
// Only search if there's existing text and no new initialText
if (inputEl.value.trim()) {
const targetIndex = search()
if (targetIndex !== null) {
setSearchResultIndex(targetIndex)
// locateByIndex(targetIndex, false); // Don't scroll if just enabling with existing text
}
}
}
}
},
searchNext() {
if (enableContentSearch) {
const targetIndex = search(SearchTargetIndex.Next)
if (targetIndex !== null) {
locateByIndex(targetIndex)
}
}
},
searchPrev() {
if (enableContentSearch) {
const targetIndex = search(SearchTargetIndex.Prev)
if (targetIndex !== null) {
locateByIndex(targetIndex)
}
}
},
resetSearchState() {
if (enableContentSearch) {
setSearchCompleted(SearchCompletedState.NotSearched)
// Maybe also reset index? Depends on desired behavior
// setSearchResultIndex(0);
}
},
search() {
if (enableContentSearch) {
const targetIndex = search()
if (targetIndex !== null) {
locateByIndex(targetIndex, shouldScroll)
} else {
// If search returns null (e.g., empty input), clear state
restoreHighlight()
setTotalCount(0)
setSearchResultIndex(0)
setSearchCompleted(SearchCompletedState.NotSearched)
}
}
},
silentSearch() {
if (enableContentSearch) {
const targetIndex = search()
if (targetIndex !== null) {
// 只更新索引,不触发滚动
locateByIndex(targetIndex, false)
}
}
},
focus() {
searchInputFocus()
}
}
useImperativeHandle(ref, () => ({
disable() {
implementation.disable()
},
enable(initialText?: string) {
implementation.enable(initialText)
},
searchNext() {
implementation.searchNext()
},
searchPrev() {
implementation.searchPrev()
},
search() {
implementation.search()
},
silentSearch() {
implementation.silentSearch()
},
focus() {
implementation.focus()
}
}))
// Re-run search when options change and search is active
useEffect(() => {
if (enableContentSearch && searchInputRef.current?.value.trim()) {
implementation.search()
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isCaseSensitive, isWholeWord, enableContentSearch]) // Add enableContentSearch dependency
const prevButtonOnClick = () => {
implementation.searchPrev()
searchInputFocus()
}
const nextButtonOnClick = () => {
implementation.searchNext()
searchInputFocus()
}
const closeButtonOnClick = () => {
implementation.disable()
}
const caseSensitiveButtonOnClick = () => {
setIsCaseSensitive(!isCaseSensitive)
searchInputFocus()
}
const wholeWordButtonOnClick = () => {
setIsWholeWord(!isWholeWord)
searchInputFocus()
}
return (
<Container ref={containerRef} style={enableContentSearch ? {} : { display: 'none' }}>
<NarrowLayout style={{ width: '100%' }}>
<SearchBarContainer>
<InputWrapper>
<Input ref={searchInputRef} onInput={userInputHandler} onKeyDown={keyDownHandler} />
<ToolBar>
<Tooltip title={t('button.includes_user_questions')} mouseEnterDelay={0.8} placement="bottom">
<ToolbarButton type="text" onClick={userOutlinedButtonOnClick}>
<User size={18} style={{ color: includeUser ? 'var(--color-link)' : 'var(--color-icon)' }} />
</ToolbarButton>
</Tooltip>
<Tooltip title={t('button.case_sensitive')} mouseEnterDelay={0.8} placement="bottom">
<ToolbarButton type="text" onClick={caseSensitiveButtonOnClick}>
<CaseSensitive
size={18}
style={{ color: isCaseSensitive ? 'var(--color-link)' : 'var(--color-icon)' }}
/>
</ToolbarButton>
</Tooltip>
<Tooltip title={t('button.whole_word')} mouseEnterDelay={0.8} placement="bottom">
<ToolbarButton type="text" onClick={wholeWordButtonOnClick}>
<WholeWord size={18} style={{ color: isWholeWord ? 'var(--color-link)' : 'var(--color-icon)' }} />
</ToolbarButton>
</Tooltip>
</ToolBar>
</InputWrapper>
<Separator></Separator>
<SearchResults>
{searchCompleted !== SearchCompletedState.NotSearched ? (
totalCount > 0 ? (
<>
<SearchResultCount>{searchResultIndex + 1}</SearchResultCount>
<SearchResultSeparator>/</SearchResultSeparator>
<SearchResultTotalCount>{totalCount}</SearchResultTotalCount>
</>
) : (
<NoResults>{t('common.no_results')}</NoResults>
)
) : (
<SearchResultsPlaceholder>0/0</SearchResultsPlaceholder>
)}
</SearchResults>
<ToolBar>
<ToolbarButton type="text" onClick={prevButtonOnClick} disabled={totalCount === 0}>
<ChevronUp size={18} />
</ToolbarButton>
<ToolbarButton type="text" onClick={nextButtonOnClick} disabled={totalCount === 0}>
<ChevronDown size={18} />
</ToolbarButton>
<ToolbarButton type="text" onClick={closeButtonOnClick}>
<X size={18} />
</ToolbarButton>
</ToolBar>
</SearchBarContainer>
</NarrowLayout>
<Placeholder />
</Container>
)
}
)
ContentSearch.displayName = 'ContentSearch'
const Container = styled.div`
display: flex;
flex-direction: row;
z-index: 2;
`
const SearchBarContainer = styled.div`
border: 1px solid var(--color-border);
border-radius: 10px;
transition: all 0.2s ease;
position: relative;
margin: 5px 20px;
margin-bottom: 0;
padding: 6px 15px 8px;
display: flex;
align-items: center;
justify-content: center;
background-color: var(--color-background-opacity);
flex: 1 1 auto; /* Take up input's previous space */
`
const Placeholder = styled.div`
width: 5px;
`
const InputWrapper = styled.div`
display: flex;
align-items: center;
flex: 1 1 auto; /* Take up input's previous space */
`
const Input = styled.input`
border: none;
color: var(--color-text);
background-color: transparent;
outline: none;
width: 100%;
padding: 0 5px; /* Adjust padding, wrapper will handle spacing */
flex: 1; /* Allow input to grow */
font-size: 14px;
font-family: Ubuntu;
`
const ToolBar = styled.div`
display: flex;
flex-direction: row;
align-items: center;
gap: tpx;
`
const Separator = styled.div`
width: 1px;
height: 1.5em;
background-color: var(--color-border);
margin-left: 2px;
margin-right: 2px;
flex: 0 0 auto;
`
const SearchResults = styled.div`
display: flex;
justify-content: center;
width: 80px;
margin: 0 2px;
flex: 0 0 auto;
color: var(--color-text-secondary);
font-size: 14px;
font-family: Ubuntu;
`
const SearchResultsPlaceholder = styled.span`
color: var(--color-text-secondary);
opacity: 0.5;
`
const NoResults = styled.span`
color: var(--color-text-secondary);
`
const SearchResultCount = styled.span`
color: var(--color-text);
`
const SearchResultSeparator = styled.span`
color: var(--color-text);
margin: 0 4px;
`
const SearchResultTotalCount = styled.span`
color: var(--color-text);
`

View File

@ -131,7 +131,10 @@
"manage": "Manage",
"select_model": "Select Model",
"show.all": "Show All",
"update_available": "Update Available"
"update_available": "Update Available",
"includes_user_questions": "Include Your Questions",
"case_sensitive": "Case Sensitive",
"whole_word": "Whole Word"
},
"chat": {
"add.assistant.title": "Add Assistant",
@ -397,7 +400,8 @@
"pinyin": "Sort by Pinyin",
"pinyin.asc": "Sort by Pinyin (A-Z)",
"pinyin.desc": "Sort by Pinyin (Z-A)"
}
},
"no_results": "No results"
},
"docs": {
"title": "Docs"
@ -1554,6 +1558,7 @@
"reset_defaults_confirm": "Are you sure you want to reset all shortcuts?",
"reset_to_default": "Reset to Default",
"search_message": "Search Message",
"search_message_in_chat": "Search Message in Current Chat",
"show_app": "Show/Hide App",
"show_settings": "Open Settings",
"title": "Keyboard Shortcuts",

View File

@ -131,7 +131,10 @@
"manage": "管理",
"select_model": "モデルを選択",
"show.all": "すべて表示",
"update_available": "更新可能"
"update_available": "更新可能",
"includes_user_questions": "ユーザーからの質問を含む",
"case_sensitive": "大文字と小文字の区別",
"whole_word": "全語一致"
},
"chat": {
"add.assistant.title": "アシスタントを追加",
@ -397,7 +400,8 @@
"pinyin": "ピンインでソート",
"pinyin.asc": "ピンインで昇順ソート",
"pinyin.desc": "ピンインで降順ソート"
}
},
"no_results": "検索結果なし"
},
"docs": {
"title": "ドキュメント"
@ -1550,6 +1554,7 @@
"reset_defaults_confirm": "すべてのショートカットをリセットしてもよろしいですか?",
"reset_to_default": "デフォルトにリセット",
"search_message": "メッセージを検索",
"search_message_in_chat": "現在のチャットでメッセージを検索",
"show_app": "アプリを表示/非表示",
"show_settings": "設定を開く",
"title": "ショートカット",
@ -1644,7 +1649,9 @@
"title": "ページズーム",
"reset": "リセット"
},
"input.show_translate_confirm": "翻訳確認ダイアログを表示"
"input.show_translate_confirm": "翻訳確認ダイアログを表示",
"about.debug.title": "デバッグ",
"about.debug.open": "開く"
},
"translate": {
"any.language": "任意の言語",

View File

@ -131,7 +131,10 @@
"manage": "Редактировать",
"select_model": "Выбрать модель",
"show.all": "Показать все",
"update_available": "Доступно обновление"
"update_available": "Доступно обновление",
"includes_user_questions": "Включает вопросы пользователей",
"case_sensitive": "Чувствительность к регистру",
"whole_word": "Полное слово"
},
"chat": {
"add.assistant.title": "Добавить ассистента",
@ -397,7 +400,8 @@
"pinyin": "Сортировать по пиньинь",
"pinyin.asc": "Сортировать по пиньинь (А-Я)",
"pinyin.desc": "Сортировать по пиньинь (Я-А)"
}
},
"no_results": "Результатов не найдено"
},
"docs": {
"title": "Документация"
@ -1551,6 +1555,7 @@
"reset_defaults_confirm": "Вы уверены, что хотите сбросить все горячие клавиши?",
"reset_to_default": "Сбросить настройки по умолчанию",
"search_message": "Поиск сообщения",
"search_message_in_chat": "Поиск сообщения в текущем диалоге",
"show_app": "Показать/скрыть приложение",
"show_settings": "Открыть настройки",
"title": "Горячие клавиши",
@ -1645,7 +1650,9 @@
"title": "Масштаб страницы",
"reset": "Сбросить"
},
"input.show_translate_confirm": "Показать диалоговое окно подтверждения перевода"
"input.show_translate_confirm": "Показать диалоговое окно подтверждения перевода",
"about.debug.title": "Отладка",
"about.debug.open": "Открыть"
},
"translate": {
"any.language": "Любой язык",

View File

@ -131,7 +131,10 @@
"manage": "管理",
"select_model": "选择模型",
"show.all": "显示全部",
"update_available": "有可用更新"
"update_available": "有可用更新",
"includes_user_questions": "包含用户提问",
"case_sensitive": "区分大小写",
"whole_word": "全字匹配"
},
"chat": {
"add.assistant.title": "添加助手",
@ -397,7 +400,8 @@
"pinyin": "按拼音排序",
"pinyin.asc": "按拼音升序",
"pinyin.desc": "按拼音降序"
}
},
"no_results": "无结果"
},
"docs": {
"title": "帮助文档"
@ -753,13 +757,13 @@
"type": {
"embedding": "嵌入",
"free": "免费",
"function_calling": "工具",
"reasoning": "推理",
"rerank": "重排",
"select": "选择模型类型",
"text": "文本",
"vision": "视觉",
"websearch": "联网"
"vision": "图像",
"function_calling": "函数调用",
"websearch": "[to be translated]:WebSearch"
}
},
"navbar": {
@ -1554,6 +1558,7 @@
"reset_defaults_confirm": "确定要重置所有快捷键吗?",
"reset_to_default": "重置为默认",
"search_message": "搜索消息",
"search_message_in_chat": "在当前对话中搜索消息",
"show_app": "显示/隐藏应用",
"show_settings": "打开设置",
"title": "快捷方式",

View File

@ -131,7 +131,10 @@
"manage": "管理",
"select_model": "選擇模型",
"show.all": "顯示全部",
"update_available": "有可用更新"
"update_available": "有可用更新",
"includes_user_questions": "包含使用者提問",
"case_sensitive": "區分大小寫",
"whole_word": "全字匹配"
},
"chat": {
"add.assistant.title": "新增助手",
@ -397,7 +400,8 @@
"pinyin": "按拼音排序",
"pinyin.asc": "按拼音升序",
"pinyin.desc": "按拼音降序"
}
},
"no_results": "沒有結果"
},
"docs": {
"title": "說明文件"
@ -1552,6 +1556,7 @@
"reset_defaults_confirm": "確定要重設所有快捷鍵嗎?",
"reset_to_default": "重設為預設",
"search_message": "搜尋訊息",
"search_message_in_chat": "在當前對話中搜尋訊息",
"show_app": "顯示/隱藏應用程式",
"show_settings": "開啟設定",
"title": "快速方式",
@ -1560,7 +1565,8 @@
"toggle_show_topics": "切換話題顯示",
"zoom_in": "放大介面",
"zoom_out": "縮小介面",
"zoom_reset": "重設縮放"
"zoom_reset": "重設縮放",
"exit_fullscreen": "退出全螢幕"
},
"theme.auto": "自動",
"theme.dark": "深色",

View File

@ -1,10 +1,14 @@
import { ContentSearch, ContentSearchRef } from '@renderer/components/ContentSearch'
import { QuickPanelProvider } from '@renderer/components/QuickPanel'
import { useAssistant } from '@renderer/hooks/useAssistant'
import { useSettings } from '@renderer/hooks/useSettings'
import { useShortcut } from '@renderer/hooks/useShortcuts'
import { useShowTopics } from '@renderer/hooks/useStore'
import { Assistant, Topic } from '@renderer/types'
import { Flex } from 'antd'
import { FC } from 'react'
import { debounce } from 'lodash'
import React, { FC, useMemo, useState } from 'react'
import { useHotkeys } from 'react-hotkeys-hook'
import styled from 'styled-components'
import Inputbar from './Inputbar/Inputbar'
@ -20,18 +24,103 @@ interface Props {
const Chat: FC<Props> = (props) => {
const { assistant } = useAssistant(props.assistant.id)
const { topicPosition, messageStyle } = useSettings()
const { topicPosition, messageStyle, showAssistants } = useSettings()
const { showTopics } = useShowTopics()
const mainRef = React.useRef<HTMLDivElement>(null)
const contentSearchRef = React.useRef<ContentSearchRef>(null)
const [filterIncludeUser, setFilterIncludeUser] = useState(false)
const maxWidth = useMemo(() => {
const showRightTopics = showTopics && topicPosition === 'right'
const minusAssistantsWidth = showAssistants ? '- var(--assistants-width)' : ''
const minusRightTopicsWidth = showRightTopics ? '- var(--assistants-width)' : ''
return `calc(100vw - var(--sidebar-width) ${minusAssistantsWidth} ${minusRightTopicsWidth} - 5px)`
}, [showAssistants, showTopics, topicPosition])
useHotkeys('esc', () => {
contentSearchRef.current?.disable()
})
useShortcut('search_message_in_chat', () => {
try {
const selectedText = window.getSelection()?.toString().trim()
contentSearchRef.current?.enable(selectedText)
} catch (error) {
console.error('Error enabling content search:', error)
}
})
const contentSearchFilter = (node: Node): boolean => {
if (node.parentNode) {
let parentNode: HTMLElement | null = node.parentNode as HTMLElement
while (parentNode?.parentNode) {
if (parentNode.classList.contains('MessageFooter')) {
return false
}
if (filterIncludeUser) {
if (parentNode?.classList.contains('message-content-container')) {
return true
}
} else {
if (parentNode?.classList.contains('message-content-container-assistant')) {
return true
}
}
parentNode = parentNode.parentNode as HTMLElement
}
return false
} else {
return false
}
}
const userOutlinedItemClickHandler = () => {
setFilterIncludeUser(!filterIncludeUser)
requestAnimationFrame(() => {
requestAnimationFrame(() => {
setTimeout(() => {
contentSearchRef.current?.search()
contentSearchRef.current?.focus()
}, 0)
})
})
}
let firstUpdateCompleted = false
const firstUpdateOrNoFirstUpdateHandler = debounce(() => {
contentSearchRef.current?.silentSearch()
}, 10)
const messagesComponentUpdateHandler = () => {
if (firstUpdateCompleted) {
firstUpdateOrNoFirstUpdateHandler()
}
}
const messagesComponentFirstUpdateHandler = () => {
setTimeout(() => (firstUpdateCompleted = true), 300)
firstUpdateOrNoFirstUpdateHandler()
}
return (
<Container id="chat" className={messageStyle}>
<Main id="chat-main" vertical flex={1} justify="space-between">
<Messages
key={props.activeTopic.id}
assistant={assistant}
topic={props.activeTopic}
setActiveTopic={props.setActiveTopic}
<Main ref={mainRef} id="chat-main" vertical flex={1} justify="space-between" style={{ maxWidth }}>
<ContentSearch
ref={contentSearchRef}
searchTarget={mainRef as React.RefObject<HTMLElement>}
filter={contentSearchFilter}
includeUser={filterIncludeUser}
onIncludeUserChange={userOutlinedItemClickHandler}
/>
<MessagesContainer>
<Messages
key={props.activeTopic.id}
assistant={assistant}
topic={props.activeTopic}
setActiveTopic={props.setActiveTopic}
onComponentUpdate={messagesComponentUpdateHandler}
onFirstUpdate={messagesComponentFirstUpdateHandler}
/>
</MessagesContainer>
<QuickPanelProvider>
<Inputbar assistant={assistant} setActiveTopic={props.setActiveTopic} topic={props.activeTopic} />
</QuickPanelProvider>
@ -49,18 +138,25 @@ const Chat: FC<Props> = (props) => {
)
}
const MessagesContainer = styled.div`
display: flex;
flex-direction: column;
overflow: hidden;
flex: 1;
`
const Container = styled.div`
display: flex;
flex-direction: row;
height: 100%;
flex: 1;
justify-content: space-between;
`
const Main = styled(Flex)`
height: calc(100vh - var(--navbar-height));
// 设置为containing block方便子元素fixed定位
transform: translateZ(0);
position: relative;
`
export default Chat

View File

@ -1170,7 +1170,7 @@ const ToolbarMenu = styled.div`
gap: 6px;
`
const ToolbarButton = styled(Button)`
export const ToolbarButton = styled(Button)`
width: 30px;
height: 30px;
font-size: 16px;

View File

@ -10,7 +10,7 @@ import { Assistant, Topic } from '@renderer/types'
import type { Message } from '@renderer/types/newMessage'
import { classNames } from '@renderer/utils'
import { Divider } from 'antd'
import { Dispatch, FC, memo, SetStateAction, useCallback, useEffect, useMemo, useRef } from 'react'
import React, { Dispatch, FC, memo, SetStateAction, useCallback, useEffect, useMemo, useRef } from 'react'
import { useTranslation } from 'react-i18next'
import styled from 'styled-components'
@ -106,13 +106,20 @@ const MessageItem: FC<Props> = ({
<ContextMenu>
<MessageHeader message={message} assistant={assistant} model={model} key={getModelUniqId(model)} />
<MessageContentContainer
className="message-content-container"
className={
message.role === 'user'
? 'message-content-container message-content-container-user'
: message.role === 'assistant'
? 'message-content-container message-content-container-assistant'
: 'message-content-container'
}
style={{ fontFamily, fontSize, background: messageBackground, overflowY: 'visible' }}>
<MessageErrorBoundary>
<MessageContent message={message} />
</MessageErrorBoundary>
{showMenubar && (
<MessageFooter
className="MessageFooter"
style={{
border: messageBorder,
flexDirection: isLastMessage || isBubbleStyle ? 'row-reverse' : undefined

View File

@ -26,7 +26,7 @@ import { updateCodeBlock } from '@renderer/utils/markdown'
import { getMainTextContent } from '@renderer/utils/messageUtils/find'
import { isTextLikeBlock } from '@renderer/utils/messageUtils/is'
import { last } from 'lodash'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { FC, useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import InfiniteScroll from 'react-infinite-scroll-component'
import styled from 'styled-components'
@ -41,9 +41,11 @@ interface MessagesProps {
assistant: Assistant
topic: Topic
setActiveTopic: (topic: Topic) => void
onComponentUpdate?(): void
onFirstUpdate?(): void
}
const Messages: React.FC<MessagesProps> = ({ assistant, topic, setActiveTopic }) => {
const Messages: FC<MessagesProps> = ({ assistant, topic, setActiveTopic, onComponentUpdate, onFirstUpdate }) => {
const { t } = useTranslation()
const { showPrompt, showTopics, topicPosition, showAssistants, messageNavigation } = useSettings()
const { updateTopic, addTopic } = useAssistant(assistant.id)
@ -224,8 +226,8 @@ const Messages: React.FC<MessagesProps> = ({ assistant, topic, setActiveTopic })
tokensCount: await estimateHistoryTokens(assistant, messages),
contextCount: getContextCount(assistant, messages)
})
})
}, [assistant, messages])
}).then(() => onFirstUpdate?.())
}, [assistant, messages, onFirstUpdate])
const loadMoreMessages = useCallback(() => {
if (!hasMore || isLoadingMore) return
@ -249,6 +251,10 @@ const Messages: React.FC<MessagesProps> = ({ assistant, topic, setActiveTopic })
}
})
useEffect(() => {
requestAnimationFrame(() => onComponentUpdate?.())
}, [])
const groupedMessages = useMemo(() => Object.entries(getGroupedMessages(displayMessages)), [displayMessages])
return (
<Container

View File

@ -46,7 +46,7 @@ const persistedReducer = persistReducer(
{
key: 'cherry-studio',
storage,
version: 102,
version: 103,
blacklist: ['runtime', 'messages', 'messageBlocks'],
migrate
},

View File

@ -1377,6 +1377,34 @@ const migrateConfig = {
} catch (error) {
return state
}
},
'103': (state: RootState) => {
try {
if (state.shortcuts) {
if (!state.shortcuts.shortcuts.find((shortcut) => shortcut.key === 'search_message_in_chat')) {
state.shortcuts.shortcuts.push({
key: 'search_message_in_chat',
shortcut: [isMac ? 'Command' : 'Ctrl', 'F'],
editable: true,
enabled: true,
system: false
})
}
const searchMessageShortcut = state.shortcuts.shortcuts.find((shortcut) => shortcut.key === 'search_message')
const targetShortcut = [isMac ? 'Command' : 'Ctrl', 'F']
if (
searchMessageShortcut &&
Array.isArray(searchMessageShortcut.shortcut) &&
searchMessageShortcut.shortcut.length === targetShortcut.length &&
searchMessageShortcut.shortcut.every((v, i) => v === targetShortcut[i])
) {
searchMessageShortcut.shortcut = [isMac ? 'Command' : 'Ctrl', 'Shift', 'F']
}
}
return state
} catch (error) {
return state
}
}
}

View File

@ -60,12 +60,19 @@ const initialState: ShortcutsState = {
system: false
},
{
key: 'search_message',
key: 'search_message_in_chat',
shortcut: [isMac ? 'Command' : 'Ctrl', 'F'],
editable: true,
enabled: true,
system: false
},
{
key: 'search_message',
shortcut: [isMac ? 'Command' : 'Ctrl', 'Shift', 'F'],
editable: true,
enabled: true,
system: false
},
{
key: 'clear_topic',
shortcut: [isMac ? 'Command' : 'Ctrl', 'L'],