mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2025-12-25 11:20:07 +08:00
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:
parent
0f97d0302e
commit
501611670e
@ -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);
|
||||
}
|
||||
|
||||
710
src/renderer/src/components/ContentSearch.tsx
Normal file
710
src/renderer/src/components/ContentSearch.tsx
Normal 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);
|
||||
`
|
||||
@ -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",
|
||||
|
||||
@ -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": "任意の言語",
|
||||
|
||||
@ -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": "Любой язык",
|
||||
|
||||
@ -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": "快捷方式",
|
||||
|
||||
@ -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": "深色",
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -46,7 +46,7 @@ const persistedReducer = persistReducer(
|
||||
{
|
||||
key: 'cherry-studio',
|
||||
storage,
|
||||
version: 102,
|
||||
version: 103,
|
||||
blacklist: ['runtime', 'messages', 'messageBlocks'],
|
||||
migrate
|
||||
},
|
||||
|
||||
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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'],
|
||||
|
||||
Loading…
Reference in New Issue
Block a user