mirror of
https://github.com/ChatGPTNextWeb/ChatGPT-Next-Web.git
synced 2025-05-29 00:50:22 +09:00
feat: support-search-in-chats
This commit is contained in:
parent
ecdfb6c753
commit
341161037e
@ -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;
|
||||||
|
250
app/components/search-bar.tsx
Normal file
250
app/components/search-bar.tsx
Normal 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,
|
||||||
|
);
|
@ -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
4
app/icons/search.svg
Normal 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 |
Loading…
Reference in New Issue
Block a user