feat: support-search-in-chats

This commit is contained in:
Hk-Gosuto 2024-02-02 21:39:43 +08:00
parent ecdfb6c753
commit 341161037e
4 changed files with 453 additions and 9 deletions

View File

@ -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, &:hover,
&:active { &:active {
.sidebar-drag { .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 { .chat-item {
padding: 0; padding: 0;
min-height: 50px; min-height: 50px;

View File

@ -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<HTMLDivElement>(null);
return (
<div className={styles["search-item-text"]}>
<Markdown
content={highlightedMessage}
loading={false}
defaultShow={true}
parentRef={ref}
/>
</div>
);
}
function SearchResultItem({
result,
input,
selectSession,
index,
}: {
result: SearchResult;
input: string;
selectSession: (id: number) => void;
index: number;
}) {
const navigate = useNavigate();
return (
<div
className={styles["search-result-item"]}
onClick={() => {
navigate(Path.Chat);
selectSession(index);
}}
>
<div className={styles["search-item-title"]}>{result.topic}</div>
<div className={styles["search-item-text-container"]}>
{result.message.map((message) => (
<HighlightedMessage
key={message.id}
message={message}
search={input}
/>
))}
</div>
<div className={styles["search-item-info"]}>
<div className={styles["search-item-count"]}>
{result.message.length} messages found
</div>
<div className={styles["search-item-date"]}>
{new Date(result.lastUpdate).toLocaleString()}
</div>
</div>
</div>
);
}
function SearchBarComponent(
{ setIsSearching, className }: SearchBarProps,
ref: Ref<SearchInputRef>,
) {
const [sessions, selectSession] = useChatStore((state) => [
state.sessions,
state.selectSession,
]);
const [input, setInput] = useState("");
const [results, setResults] = useState<SearchResult[]>([]);
const inputRef = useRef<HTMLInputElement | null>(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<HTMLInputElement>).current &&
(inputRef as React.RefObject<HTMLInputElement>)?.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 (
<>
<div className={styles["sidebar-search-bar-input"]}>
<SearchIcon className={styles["search-icon"]} />
<input
ref={inputRef}
type="text"
value={input}
onChange={(e) => handleChange(e.target.value)}
onFocus={handleFocus}
onBlur={handleBlur}
/>
{input.trim().length > 0 && (
<IconButton
className={styles["clear-icon"]}
icon={<CloseIcon />}
onClick={handleClearInput}
/>
)}
</div>
{input.trim().length > 0 && (
<div className={styles["search-item-total-count"]}>
{displayedResults.length} chats found
</div>
)}
<div className={styles["search-result"]}>
{displayedResults.map((result) => (
<SearchResultItem
key={result.sessionId}
result={result}
input={input}
selectSession={selectSession}
index={sessions.findIndex(
(session) => session.id === result.sessionId,
)}
/>
))}
</div>
</>
);
}
export const SearchBar = forwardRef<SearchInputRef, SearchBarProps>(
SearchBarComponent,
);

View File

@ -1,4 +1,4 @@
import { useEffect, useRef, useMemo } from "react"; import { useEffect, useRef, useMemo, useState } from "react";
import styles from "./home.module.scss"; import styles from "./home.module.scss";
@ -11,6 +11,7 @@ import CloseIcon from "../icons/close.svg";
import DeleteIcon from "../icons/delete.svg"; import DeleteIcon from "../icons/delete.svg";
import MaskIcon from "../icons/mask.svg"; import MaskIcon from "../icons/mask.svg";
import PluginIcon from "../icons/plugin.svg"; import PluginIcon from "../icons/plugin.svg";
import SearchIcon from "../icons/search.svg";
import DragIcon from "../icons/drag.svg"; import DragIcon from "../icons/drag.svg";
import Locale from "../locales"; import Locale from "../locales";
@ -30,6 +31,7 @@ import { Link, useNavigate } from "react-router-dom";
import { isIOS, useMobileScreen } from "../utils"; import { isIOS, useMobileScreen } from "../utils";
import dynamic from "next/dynamic"; import dynamic from "next/dynamic";
import { showConfirm, showToast } from "./ui-lib"; import { showConfirm, showToast } from "./ui-lib";
import { SearchBar, SearchInputRef } from "./search-bar";
const ChatList = dynamic(async () => (await import("./chat-list")).ChatList, { const ChatList = dynamic(async () => (await import("./chat-list")).ChatList, {
loading: () => null, loading: () => null,
@ -71,6 +73,9 @@ function useDragSideBar() {
} }
}); });
}; };
const expandSidebar = () => {
config.update((config) => (config.sidebarWidth = MAX_SIDEBAR_WIDTH));
};
const onDragStart = (e: MouseEvent) => { const onDragStart = (e: MouseEvent) => {
// Remembers the initial width each time the mouse is pressed // Remembers the initial width each time the mouse is pressed
@ -125,6 +130,7 @@ function useDragSideBar() {
return { return {
onDragStart, onDragStart,
shouldNarrow, shouldNarrow,
expandSidebar,
}; };
} }
@ -132,7 +138,7 @@ export function SideBar(props: { className?: string }) {
const chatStore = useChatStore(); const chatStore = useChatStore();
// drag side bar // drag side bar
const { onDragStart, shouldNarrow } = useDragSideBar(); const { expandSidebar, onDragStart, shouldNarrow } = useDragSideBar();
const navigate = useNavigate(); const navigate = useNavigate();
const config = useAppConfig(); const config = useAppConfig();
const isMobileScreen = useMobileScreen(); const isMobileScreen = useMobileScreen();
@ -141,6 +147,19 @@ export function SideBar(props: { className?: string }) {
[isMobileScreen], [isMobileScreen],
); );
// search bar
const searchBarRef = useRef<SearchInputRef>(null);
const [isSearching, setIsSearching] = useState(false);
useEffect(() => {
if (shouldNarrow) stopSearch();
}, [shouldNarrow]);
const stopSearch = () => {
setIsSearching(false);
searchBarRef.current?.clearInput();
};
useHotKey(); useHotKey();
return ( return (
@ -186,19 +205,47 @@ export function SideBar(props: { className?: string }) {
onClick={() => navigate(Path.Plugins, { state: { fromHome: true } })} onClick={() => navigate(Path.Plugins, { state: { fromHome: true } })}
shadow shadow
/> />
{shouldNarrow && (
<IconButton
icon={<SearchIcon />}
className={styles["sidebar-bar-button"]}
onClick={() => {
expandSidebar();
// use setTimeout to avoid the input element not ready
setTimeout(() => {
searchBarRef.current?.inputElement?.focus();
}, 0);
}}
shadow
/>
)}
</div> </div>
<div <div
className={styles["sidebar-body"]} className={
onClick={(e) => { styles["sidebar-search-bar"] +
if (e.target === e.currentTarget) { " " +
navigate(Path.Home); (isSearching ? styles["sidebar-search-bar-isSearching"] : "")
} }
}}
> >
<ChatList narrow={shouldNarrow} /> {!shouldNarrow && (
<SearchBar ref={searchBarRef} setIsSearching={setIsSearching} />
)}
</div> </div>
{!isSearching && (
<div
className={styles["sidebar-body"]}
onClick={(e) => {
if (e.target === e.currentTarget) {
navigate(Path.Home);
}
}}
>
<ChatList narrow={shouldNarrow} />
</div>
)}
<div className={styles["sidebar-tail"]}> <div className={styles["sidebar-tail"]}>
<div className={styles["sidebar-actions"]}> <div className={styles["sidebar-actions"]}>
<div className={styles["sidebar-action"] + " " + styles.mobile}> <div className={styles["sidebar-action"] + " " + styles.mobile}>
@ -233,6 +280,7 @@ export function SideBar(props: { className?: string }) {
} else { } else {
navigate(Path.NewChat); navigate(Path.NewChat);
} }
stopSearch();
}} }}
shadow shadow
/> />

4
app/icons/search.svg Normal file
View File

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg" fill="none">
<path fill="#000000" fill-rule="evenodd" d="M4 9a5 5 0 1110 0A5 5 0 014 9zm5-7a7 7 0 104.2 12.6.999.999 0 00.093.107l3 3a1 1 0 001.414-1.414l-3-3a.999.999 0 00-.107-.093A7 7 0 009 2z"/>
</svg>

After

Width:  |  Height:  |  Size: 418 B