From 341161037e9d9fd4a788ca533ecd1d2eea15fee3 Mon Sep 17 00:00:00 2001 From: Hk-Gosuto Date: Fri, 2 Feb 2024 21:39:43 +0800 Subject: [PATCH] feat: support-search-in-chats --- app/components/home.module.scss | 142 ++++++++++++++++++ app/components/search-bar.tsx | 250 ++++++++++++++++++++++++++++++++ app/components/sidebar.tsx | 66 +++++++-- app/icons/search.svg | 4 + 4 files changed, 453 insertions(+), 9 deletions(-) create mode 100644 app/components/search-bar.tsx create mode 100644 app/icons/search.svg diff --git a/app/components/home.module.scss b/app/components/home.module.scss index b836d2bec..2fc3a9027 100644 --- a/app/components/home.module.scss +++ b/app/components/home.module.scss @@ -62,6 +62,137 @@ } } + .sidebar-search-bar { + display: flex; + flex-direction: column; + margin-bottom: 10px; + + .sidebar-search-bar-input { + position: relative; + width: 100%; + margin-bottom: 5px; + + .search-icon { + position: absolute; + left: 10px; + top: 50%; + transform: translateY(-50%); + width: 16px; + height: 16px; + } + + .clear-icon { + position: absolute; + right: 10px; + top: 50%; + transform: translateY(-50%); + width: 16px; + height: 16px; + } + + input { + text-align: left; + max-width: 100%; + width: inherit; + padding-left: 35px; + padding-right: 35px; + } + } + + .search-item-total-count { + display: flex; + justify-content: space-between; + color: rgb(166, 166, 166); + font-size: 12px; + margin-bottom: 10px; + margin-top: 4px; + margin-left: 4px; + animation: slide-in ease 0.3s; + } + + .search-result { + + .search-result-item { + padding: 10px 14px; + background-color: var(--white); + border-radius: 10px; + margin-bottom: 10px; + box-shadow: var(--card-shadow); + transition: background-color 0.3s ease; + cursor: pointer; + -webkit-user-select: none; + -moz-user-select: none; + user-select: none; + border: 2px solid transparent; + position: relative; + + .search-item-title { + font-size: 14px; + font-weight: bolder; + display: block; + width: calc(100% - 15px); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + animation: slide-in ease 0.3s; + } + + .search-item-text-container { + display: flex; + justify-content: space-between; + flex-direction: column; + color: rgb(166, 166, 166); + font-size: 12px; + margin-top: 8px; + animation: slide-in ease 0.3s; + + gap: 8px; + + .search-item-text { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + + padding-left: 8px; + } + } + + .search-item-info { + display: flex; + justify-content: space-between; + color: rgb(166, 166, 166); + font-size: 12px; + margin-top: 8px; + animation: slide-in ease 0.3s; + } + + .search-item-count, + .search-item-date { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + &:hover { + background-color: var(--hover-color); + } + } + } + + .sidebar-bar-button { + flex-grow: 1; + + &:not(:last-child) { + margin-right: 10px; + } + } + } + + .sidebar-search-bar-isSearching { + flex: 1 1; + overflow: auto; + overflow-x: hidden; + } &:hover, &:active { .sidebar-drag { @@ -252,6 +383,17 @@ } } + .sidebar-search-bar { + flex-direction: column; + + .sidebar-bar-button { + &:not(:last-child) { + margin-right: 0; + margin-bottom: 10px; + } + } + } + .chat-item { padding: 0; min-height: 50px; diff --git a/app/components/search-bar.tsx b/app/components/search-bar.tsx new file mode 100644 index 000000000..e462ef664 --- /dev/null +++ b/app/components/search-bar.tsx @@ -0,0 +1,250 @@ +import { + forwardRef, + Ref, + useCallback, + useEffect, + useImperativeHandle, + useMemo, + useRef, + useState, +} from "react"; +import { ChatMessage, useChatStore } from "../store"; +import styles from "./home.module.scss"; +import SearchIcon from "../icons/search.svg"; +import { IconButton } from "./button"; +import CloseIcon from "../icons/close.svg"; +import { Markdown } from "./markdown"; +import { useNavigate } from "react-router-dom"; +import { Path } from "@/app/constant"; + +interface SearchResult { + sessionId: string; + topic: string; + lastUpdate: number; + message: ChatMessage[]; +} + +interface SearchBarProps { + setIsSearching: (isSearching: boolean) => void; + className?: string; +} + +export interface SearchInputRef { + setInput: (value: string) => void; + clearInput: () => void; + inputElement: HTMLInputElement | null; +} + +function highlightAndShorten(str: string, search: string) { + const index = str.toLowerCase().indexOf(search.toLowerCase()); + const head = Math.max(0, index - 10); + const tail = Math.min(str.length, index + search.length + 40); + // Remove code block syntax + let result = str.slice(head, tail); + + // Use ** to highlight the search result + result = result.replace(new RegExp(`(${search})`), "**$1**"); + + if (head > 0) { + result = "..." + result; + } + + if (tail < str.length) { + result = result + "..."; + } + + return result; +} + +function HighlightedMessage({ + message, + search, +}: { + message: ChatMessage; + search: string; +}) { + const highlightedMessage = useMemo( + () => highlightAndShorten(message.content, search), + [message.content, search], + ); + const ref = useRef(null); + + return ( +
+ +
+ ); +} + +function SearchResultItem({ + result, + input, + selectSession, + index, +}: { + result: SearchResult; + input: string; + selectSession: (id: number) => void; + index: number; +}) { + const navigate = useNavigate(); + + return ( +
{ + navigate(Path.Chat); + selectSession(index); + }} + > +
{result.topic}
+
+ {result.message.map((message) => ( + + ))} +
+
+
+ {result.message.length} messages found +
+
+ {new Date(result.lastUpdate).toLocaleString()} +
+
+
+ ); +} + +function SearchBarComponent( + { setIsSearching, className }: SearchBarProps, + ref: Ref, +) { + const [sessions, selectSession] = useChatStore((state) => [ + state.sessions, + state.selectSession, + ]); + + const [input, setInput] = useState(""); + const [results, setResults] = useState([]); + + const inputRef = useRef(null); + useImperativeHandle(ref, () => ({ + setInput, + clearInput: handleClearInput, + inputElement: inputRef.current, + })); + + const handleClearInput = useCallback(() => { + setInput(""); + setResults([]); + setIsSearching(false); + }, [setIsSearching]); + + const handleChange = useCallback( + (value: string) => { + setIsSearching(true); + setInput(value); + }, + [setIsSearching], + ); + + const handleFocus = useCallback(() => { + if (input && input.trim().length > 0) setIsSearching(true); + }, [setIsSearching]); + + const handleBlur = useCallback(() => { + if ( + (inputRef as React.RefObject).current && + (inputRef as React.RefObject)?.current?.value.trim() === + "" + ) { + setIsSearching(false); + } + }, [setIsSearching]); + + // 当用户输入变化时,执行搜索操作 + useEffect(() => { + if (input.trim().length === 0) { + setResults([]); + setIsSearching(false); + return; + } + const newResults: SearchResult[] = []; + + for (const session of sessions) { + const matchingMessages: ChatMessage[] = []; + + for (const message of session.messages) { + if (message.content.toLowerCase().includes(input.toLowerCase())) { + matchingMessages.push(message!); + } + } + + if (matchingMessages.length > 0) { + newResults.push({ + topic: session.topic, + sessionId: session.id, + lastUpdate: session.lastUpdate, + message: matchingMessages, + }); + } + } + + setResults(newResults); + }, [input, sessions]); + + const displayedResults = useMemo(() => results, [results]); + + return ( + <> +
+ + handleChange(e.target.value)} + onFocus={handleFocus} + onBlur={handleBlur} + /> + {input.trim().length > 0 && ( + } + onClick={handleClearInput} + /> + )} +
+ {input.trim().length > 0 && ( +
+ {displayedResults.length} chats found +
+ )} +
+ {displayedResults.map((result) => ( + session.id === result.sessionId, + )} + /> + ))} +
+ + ); +} +export const SearchBar = forwardRef( + SearchBarComponent, +); diff --git a/app/components/sidebar.tsx b/app/components/sidebar.tsx index b821f705b..68a4b2b1e 100644 --- a/app/components/sidebar.tsx +++ b/app/components/sidebar.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef, useMemo } from "react"; +import { useEffect, useRef, useMemo, useState } from "react"; import styles from "./home.module.scss"; @@ -11,6 +11,7 @@ import CloseIcon from "../icons/close.svg"; import DeleteIcon from "../icons/delete.svg"; import MaskIcon from "../icons/mask.svg"; import PluginIcon from "../icons/plugin.svg"; +import SearchIcon from "../icons/search.svg"; import DragIcon from "../icons/drag.svg"; import Locale from "../locales"; @@ -30,6 +31,7 @@ import { Link, useNavigate } from "react-router-dom"; import { isIOS, useMobileScreen } from "../utils"; import dynamic from "next/dynamic"; import { showConfirm, showToast } from "./ui-lib"; +import { SearchBar, SearchInputRef } from "./search-bar"; const ChatList = dynamic(async () => (await import("./chat-list")).ChatList, { loading: () => null, @@ -71,6 +73,9 @@ function useDragSideBar() { } }); }; + const expandSidebar = () => { + config.update((config) => (config.sidebarWidth = MAX_SIDEBAR_WIDTH)); + }; const onDragStart = (e: MouseEvent) => { // Remembers the initial width each time the mouse is pressed @@ -125,6 +130,7 @@ function useDragSideBar() { return { onDragStart, shouldNarrow, + expandSidebar, }; } @@ -132,7 +138,7 @@ export function SideBar(props: { className?: string }) { const chatStore = useChatStore(); // drag side bar - const { onDragStart, shouldNarrow } = useDragSideBar(); + const { expandSidebar, onDragStart, shouldNarrow } = useDragSideBar(); const navigate = useNavigate(); const config = useAppConfig(); const isMobileScreen = useMobileScreen(); @@ -141,6 +147,19 @@ export function SideBar(props: { className?: string }) { [isMobileScreen], ); + // search bar + const searchBarRef = useRef(null); + const [isSearching, setIsSearching] = useState(false); + + useEffect(() => { + if (shouldNarrow) stopSearch(); + }, [shouldNarrow]); + + const stopSearch = () => { + setIsSearching(false); + searchBarRef.current?.clearInput(); + }; + useHotKey(); return ( @@ -186,19 +205,47 @@ export function SideBar(props: { className?: string }) { onClick={() => navigate(Path.Plugins, { state: { fromHome: true } })} shadow /> + {shouldNarrow && ( + } + className={styles["sidebar-bar-button"]} + onClick={() => { + expandSidebar(); + // use setTimeout to avoid the input element not ready + setTimeout(() => { + searchBarRef.current?.inputElement?.focus(); + }, 0); + }} + shadow + /> + )}
{ - if (e.target === e.currentTarget) { - navigate(Path.Home); - } - }} + className={ + styles["sidebar-search-bar"] + + " " + + (isSearching ? styles["sidebar-search-bar-isSearching"] : "") + } > - + {!shouldNarrow && ( + + )}
+ {!isSearching && ( +
{ + if (e.target === e.currentTarget) { + navigate(Path.Home); + } + }} + > + +
+ )} +
@@ -233,6 +280,7 @@ export function SideBar(props: { className?: string }) { } else { navigate(Path.NewChat); } + stopSearch(); }} shadow /> diff --git a/app/icons/search.svg b/app/icons/search.svg new file mode 100644 index 000000000..1e48aa053 --- /dev/null +++ b/app/icons/search.svg @@ -0,0 +1,4 @@ + + + +