diff --git a/src/renderer/src/assets/styles/index.scss b/src/renderer/src/assets/styles/index.scss index 3b5d98e941..e44a973fe8 100644 --- a/src/renderer/src/assets/styles/index.scss +++ b/src/renderer/src/assets/styles/index.scss @@ -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); +} diff --git a/src/renderer/src/components/ContentSearch.tsx b/src/renderer/src/components/ContentSearch.tsx new file mode 100644 index 0000000000..1f207ce9db --- /dev/null +++ b/src/renderer/src/components/ContentSearch.tsx @@ -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.RefObject | 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() + + 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 = [] + 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( + ({ 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(null) + const searchInputRef = React.useRef(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())[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() + // 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 = new Set() + while ((textNode = iter.nextNode())) { + if (filter(textNode)) { + textNodeSet.add(textNode) + } + } + + const highlightTextSetTemp = new Set() + 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) => { + 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) => { + 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 ( + + + + + + + + + + + + + + + + + + + + + + + + + + {searchCompleted !== SearchCompletedState.NotSearched ? ( + totalCount > 0 ? ( + <> + {searchResultIndex + 1} + / + {totalCount} + + ) : ( + {t('common.no_results')} + ) + ) : ( + 0/0 + )} + + + + + + + + + + + + + + + + + ) + } +) + +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); +` diff --git a/src/renderer/src/i18n/locales/en-us.json b/src/renderer/src/i18n/locales/en-us.json index 5c1e47f053..3209172739 100644 --- a/src/renderer/src/i18n/locales/en-us.json +++ b/src/renderer/src/i18n/locales/en-us.json @@ -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", diff --git a/src/renderer/src/i18n/locales/ja-jp.json b/src/renderer/src/i18n/locales/ja-jp.json index ca3f98c4a3..34f4be5ea1 100644 --- a/src/renderer/src/i18n/locales/ja-jp.json +++ b/src/renderer/src/i18n/locales/ja-jp.json @@ -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": "任意の言語", diff --git a/src/renderer/src/i18n/locales/ru-ru.json b/src/renderer/src/i18n/locales/ru-ru.json index 003ac5a446..7ecaaad448 100644 --- a/src/renderer/src/i18n/locales/ru-ru.json +++ b/src/renderer/src/i18n/locales/ru-ru.json @@ -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": "Любой язык", diff --git a/src/renderer/src/i18n/locales/zh-cn.json b/src/renderer/src/i18n/locales/zh-cn.json index 9bbdb5aa4e..ccf7033cd6 100644 --- a/src/renderer/src/i18n/locales/zh-cn.json +++ b/src/renderer/src/i18n/locales/zh-cn.json @@ -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": "快捷方式", diff --git a/src/renderer/src/i18n/locales/zh-tw.json b/src/renderer/src/i18n/locales/zh-tw.json index 085b50323a..6ecf39ac47 100644 --- a/src/renderer/src/i18n/locales/zh-tw.json +++ b/src/renderer/src/i18n/locales/zh-tw.json @@ -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": "深色", diff --git a/src/renderer/src/pages/home/Chat.tsx b/src/renderer/src/pages/home/Chat.tsx index 07d89dedcb..8516692309 100644 --- a/src/renderer/src/pages/home/Chat.tsx +++ b/src/renderer/src/pages/home/Chat.tsx @@ -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) => { const { assistant } = useAssistant(props.assistant.id) - const { topicPosition, messageStyle } = useSettings() + const { topicPosition, messageStyle, showAssistants } = useSettings() const { showTopics } = useShowTopics() + const mainRef = React.useRef(null) + const contentSearchRef = React.useRef(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 ( -
- + } + filter={contentSearchFilter} + includeUser={filterIncludeUser} + onIncludeUserChange={userOutlinedItemClickHandler} /> + + + @@ -49,18 +138,25 @@ const Chat: FC = (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 diff --git a/src/renderer/src/pages/home/Inputbar/Inputbar.tsx b/src/renderer/src/pages/home/Inputbar/Inputbar.tsx index 47edb496ed..aa5f310539 100644 --- a/src/renderer/src/pages/home/Inputbar/Inputbar.tsx +++ b/src/renderer/src/pages/home/Inputbar/Inputbar.tsx @@ -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; diff --git a/src/renderer/src/pages/home/Messages/Message.tsx b/src/renderer/src/pages/home/Messages/Message.tsx index 9ae2da00b2..86d5d22c2b 100644 --- a/src/renderer/src/pages/home/Messages/Message.tsx +++ b/src/renderer/src/pages/home/Messages/Message.tsx @@ -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 = ({ {showMenubar && ( void + onComponentUpdate?(): void + onFirstUpdate?(): void } -const Messages: React.FC = ({ assistant, topic, setActiveTopic }) => { +const Messages: FC = ({ 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 = ({ 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 = ({ assistant, topic, setActiveTopic }) } }) + useEffect(() => { + requestAnimationFrame(() => onComponentUpdate?.()) + }, []) + const groupedMessages = useMemo(() => Object.entries(getGroupedMessages(displayMessages)), [displayMessages]) return ( { + 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 + } } } diff --git a/src/renderer/src/store/shortcuts.ts b/src/renderer/src/store/shortcuts.ts index cafe278856..93080f4f3d 100644 --- a/src/renderer/src/store/shortcuts.ts +++ b/src/renderer/src/store/shortcuts.ts @@ -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'],