mirror of
https://github.com/ChatGPTNextWeb/ChatGPT-Next-Web.git
synced 2025-05-29 09:00:18 +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,
|
||||
&: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;
|
||||
|
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";
|
||||
|
||||
@ -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<SearchInputRef>(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 && (
|
||||
<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
|
||||
className={styles["sidebar-body"]}
|
||||
onClick={(e) => {
|
||||
if (e.target === e.currentTarget) {
|
||||
navigate(Path.Home);
|
||||
}
|
||||
}}
|
||||
className={
|
||||
styles["sidebar-search-bar"] +
|
||||
" " +
|
||||
(isSearching ? styles["sidebar-search-bar-isSearching"] : "")
|
||||
}
|
||||
>
|
||||
<ChatList narrow={shouldNarrow} />
|
||||
{!shouldNarrow && (
|
||||
<SearchBar ref={searchBarRef} setIsSearching={setIsSearching} />
|
||||
)}
|
||||
</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-actions"]}>
|
||||
<div className={styles["sidebar-action"] + " " + styles.mobile}>
|
||||
@ -233,6 +280,7 @@ export function SideBar(props: { className?: string }) {
|
||||
} else {
|
||||
navigate(Path.NewChat);
|
||||
}
|
||||
stopSearch();
|
||||
}}
|
||||
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