diff --git a/README.md b/README.md index da1adb6a5..db62e0838 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,8 @@ One-Click to get a well-designed cross-platform ChatGPT web UI, with GPT3, GPT4 [](https://vercel.com/new/clone?repository-url=https%3A%2F%2Fgithub.com%2FChatGPTNextWeb%2FChatGPT-Next-Web&env=OPENAI_API_KEY&env=CODE&project-name=nextchat&repository-name=NextChat) [](https://zeabur.com/templates/ZBUEFA) [](https://gitpod.io/#https://github.com/Yidadaa/ChatGPT-Next-Web) +[](https://monica.im/?utm=nxcrp) + ## Enterprise Edition diff --git a/app/components/artifacts.tsx b/app/components/artifacts.tsx index 326891e73..ac0d713b3 100644 --- a/app/components/artifacts.tsx +++ b/app/components/artifacts.tsx @@ -1,4 +1,11 @@ -import { useEffect, useState, useRef, useMemo } from "react"; +import { + useEffect, + useState, + useRef, + useMemo, + forwardRef, + useImperativeHandle, +} from "react"; import { useParams } from "react-router"; import { useWindowSize } from "@/app/utils"; import { IconButton } from "./button"; @@ -8,6 +15,7 @@ import CopyIcon from "../icons/copy.svg"; import DownloadIcon from "../icons/download.svg"; import GithubIcon from "../icons/github.svg"; import LoadingButtonIcon from "../icons/loading.svg"; +import ReloadButtonIcon from "../icons/reload.svg"; import Locale from "../locales"; import { Modal, showToast } from "./ui-lib"; import { copyToClipboard, downloadAs } from "../utils"; @@ -15,73 +23,89 @@ import { Path, ApiPath, REPO_URL } from "@/app/constant"; import { Loading } from "./home"; import styles from "./artifacts.module.scss"; -export function HTMLPreview(props: { +type HTMLPreviewProps = { code: string; autoHeight?: boolean; height?: number | string; onLoad?: (title?: string) => void; -}) { - const ref = useRef(null); - const frameId = useRef(nanoid()); - const [iframeHeight, setIframeHeight] = useState(600); - const [title, setTitle] = useState(""); - /* - * https://stackoverflow.com/questions/19739001/what-is-the-difference-between-srcdoc-and-src-datatext-html-in-an - * 1. using srcdoc - * 2. using src with dataurl: - * easy to share - * length limit (Data URIs cannot be larger than 32,768 characters.) - */ +}; - useEffect(() => { - const handleMessage = (e: any) => { - const { id, height, title } = e.data; - setTitle(title); - if (id == frameId.current) { - setIframeHeight(height); +export type HTMLPreviewHander = { + reload: () => void; +}; + +export const HTMLPreview = forwardRef( + function HTMLPreview(props, ref) { + const iframeRef = useRef(null); + const [frameId, setFrameId] = useState(nanoid()); + const [iframeHeight, setIframeHeight] = useState(600); + const [title, setTitle] = useState(""); + /* + * https://stackoverflow.com/questions/19739001/what-is-the-difference-between-srcdoc-and-src-datatext-html-in-an + * 1. using srcdoc + * 2. using src with dataurl: + * easy to share + * length limit (Data URIs cannot be larger than 32,768 characters.) + */ + + useEffect(() => { + const handleMessage = (e: any) => { + const { id, height, title } = e.data; + setTitle(title); + if (id == frameId) { + setIframeHeight(height); + } + }; + window.addEventListener("message", handleMessage); + return () => { + window.removeEventListener("message", handleMessage); + }; + }, [frameId]); + + useImperativeHandle(ref, () => ({ + reload: () => { + setFrameId(nanoid()); + }, + })); + + const height = useMemo(() => { + if (!props.autoHeight) return props.height || 600; + if (typeof props.height === "string") { + return props.height; + } + const parentHeight = props.height || 600; + return iframeHeight + 40 > parentHeight + ? parentHeight + : iframeHeight + 40; + }, [props.autoHeight, props.height, iframeHeight]); + + const srcDoc = useMemo(() => { + const script = ``; + if (props.code.includes("")) { + props.code.replace("", "" + script); + } + return script + props.code; + }, [props.code, frameId]); + + const handleOnLoad = () => { + if (props?.onLoad) { + props.onLoad(title); } }; - window.addEventListener("message", handleMessage); - return () => { - window.removeEventListener("message", handleMessage); - }; - }, []); - const height = useMemo(() => { - if (!props.autoHeight) return props.height || 600; - if (typeof props.height === "string") { - return props.height; - } - const parentHeight = props.height || 600; - return iframeHeight + 40 > parentHeight ? parentHeight : iframeHeight + 40; - }, [props.autoHeight, props.height, iframeHeight]); - - const srcDoc = useMemo(() => { - const script = ``; - if (props.code.includes("")) { - props.code.replace("", "" + script); - } - return props.code + script; - }, [props.code]); - - const handleOnLoad = () => { - if (props?.onLoad) { - props.onLoad(title); - } - }; - - return ( - - ); -} + return ( + + ); + }, +); export function ArtifactsShareButton({ getCode, @@ -184,6 +208,7 @@ export function Artifacts() { const [code, setCode] = useState(""); const [loading, setLoading] = useState(true); const [fileName, setFileName] = useState(""); + const previewRef = useRef(null); useEffect(() => { if (id) { @@ -208,6 +233,13 @@ export function Artifacts() { } shadow /> + } + shadow + onClick={() => previewRef.current?.reload()} + /> NextChat Artifacts { diff --git a/app/components/home.tsx b/app/components/home.tsx index 11455028a..54436bff4 100644 --- a/app/components/home.tsx +++ b/app/components/home.tsx @@ -60,6 +60,13 @@ const MaskPage = dynamic(async () => (await import("./mask")).MaskPage, { loading: () => , }); +const SearchChat = dynamic( + async () => (await import("./search-chat")).SearchChatPage, + { + loading: () => , + }, +); + const Sd = dynamic(async () => (await import("./sd")).Sd, { loading: () => , }); @@ -175,6 +182,7 @@ function Screen() { } /> } /> } /> + } /> } /> } /> diff --git a/app/components/markdown.tsx b/app/components/markdown.tsx index 1531d2ff0..500af7175 100644 --- a/app/components/markdown.tsx +++ b/app/components/markdown.tsx @@ -8,14 +8,21 @@ import RehypeHighlight from "rehype-highlight"; import { useRef, useState, RefObject, useEffect, useMemo } from "react"; import { copyToClipboard, useWindowSize } from "../utils"; import mermaid from "mermaid"; - +import Locale from "../locales"; import LoadingIcon from "../icons/three-dots.svg"; +import ReloadButtonIcon from "../icons/reload.svg"; import React from "react"; import { useDebouncedCallback } from "use-debounce"; import { showImageModal, FullScreen } from "./ui-lib"; -import { ArtifactsShareButton, HTMLPreview } from "./artifacts"; +import { + ArtifactsShareButton, + HTMLPreview, + HTMLPreviewHander, +} from "./artifacts"; import { Plugin } from "../constant"; import { useChatStore } from "../store"; +import { IconButton } from "./button"; + export function Mermaid(props: { code: string }) { const ref = useRef(null); const [hasError, setHasError] = useState(false); @@ -64,7 +71,7 @@ export function Mermaid(props: { code: string }) { export function PreCode(props: { children: any }) { const ref = useRef(null); - const refText = ref.current?.innerText; + const previewRef = useRef(null); const [mermaidCode, setMermaidCode] = useState(""); const [htmlCode, setHtmlCode] = useState(""); const { height } = useWindowSize(); @@ -79,6 +86,7 @@ export function PreCode(props: { children: any }) { setMermaidCode((mermaidDom as HTMLElement).innerText); } const htmlDom = ref.current.querySelector("code.language-html"); + const refText = ref.current.querySelector("code")?.innerText; if (htmlDom) { setHtmlCode((htmlDom as HTMLElement).innerText); } else if (refText?.startsWith(" { - setTimeout(renderArtifacts, 1); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [refText]); - const enableArtifacts = useMemo( () => plugins?.includes(Plugin.Artifacts), [plugins], @@ -119,6 +122,7 @@ export function PreCode(props: { children: any }) { codeElement.style.whiteSpace = "pre-wrap"; } }); + setTimeout(renderArtifacts, 1); } }, []); @@ -145,7 +149,15 @@ export function PreCode(props: { children: any }) { style={{ position: "absolute", right: 20, top: 10 }} getCode={() => htmlCode} /> + } + shadow + onClick={() => previewRef.current?.reload()} + /> (null); + const [collapsed, setCollapsed] = useState(true); + const [showToggle, setShowToggle] = useState(false); + + useEffect(() => { + if (ref.current) { + const codeHeight = ref.current.scrollHeight; + setShowToggle(codeHeight > 400); + ref.current.scrollTop = ref.current.scrollHeight; + } + }, [props.children]); + + const toggleCollapsed = () => { + setCollapsed((collapsed) => !collapsed); + }; + return ( + <> + + {props.children} + + {showToggle && collapsed && ( + + {Locale.NewChat.More} + + )} + > + ); +} + function escapeDollarNumber(text: string) { let escapedText = ""; @@ -211,6 +261,7 @@ function _MarkDownContent(props: { content: string }) { ]} components={{ pre: PreCode, + code: CustomCode, p: (pProps) => , a: (aProps) => { const href = aProps.href || ""; diff --git a/app/components/search-chat.tsx b/app/components/search-chat.tsx new file mode 100644 index 000000000..7178865f5 --- /dev/null +++ b/app/components/search-chat.tsx @@ -0,0 +1,167 @@ +import { useState, useEffect, useRef, useCallback } from "react"; +import { ErrorBoundary } from "./error"; +import styles from "./mask.module.scss"; +import { useNavigate } from "react-router-dom"; +import { IconButton } from "./button"; +import CloseIcon from "../icons/close.svg"; +import EyeIcon from "../icons/eye.svg"; +import Locale from "../locales"; +import { Path } from "../constant"; + +import { useChatStore } from "../store"; + +type Item = { + id: number; + name: string; + content: string; +}; +export function SearchChatPage() { + const navigate = useNavigate(); + + const chatStore = useChatStore(); + + const sessions = chatStore.sessions; + const selectSession = chatStore.selectSession; + + const [searchResults, setSearchResults] = useState([]); + + const previousValueRef = useRef(""); + const searchInputRef = useRef(null); + const doSearch = useCallback((text: string) => { + const lowerCaseText = text.toLowerCase(); + const results: Item[] = []; + + sessions.forEach((session, index) => { + const fullTextContents: string[] = []; + + session.messages.forEach((message) => { + const content = message.content as string; + if (!content.toLowerCase || content === "") return; + const lowerCaseContent = content.toLowerCase(); + + // full text search + let pos = lowerCaseContent.indexOf(lowerCaseText); + while (pos !== -1) { + const start = Math.max(0, pos - 35); + const end = Math.min(content.length, pos + lowerCaseText.length + 35); + fullTextContents.push(content.substring(start, end)); + pos = lowerCaseContent.indexOf( + lowerCaseText, + pos + lowerCaseText.length, + ); + } + }); + + if (fullTextContents.length > 0) { + results.push({ + id: index, + name: session.topic, + content: fullTextContents.join("... "), // concat content with... + }); + } + }); + + // sort by length of matching content + results.sort((a, b) => b.content.length - a.content.length); + + return results; + }, []); + + useEffect(() => { + const intervalId = setInterval(() => { + if (searchInputRef.current) { + const currentValue = searchInputRef.current.value; + if (currentValue !== previousValueRef.current) { + if (currentValue.length > 0) { + const result = doSearch(currentValue); + setSearchResults(result); + } + previousValueRef.current = currentValue; + } + } + }, 1000); + + // Cleanup the interval on component unmount + return () => clearInterval(intervalId); + }, [doSearch]); + + return ( + + + {/* header */} + + + + {Locale.SearchChat.Page.Title} + + + {Locale.SearchChat.Page.SubTitle(searchResults.length)} + + + + + + } + bordered + onClick={() => navigate(-1)} + /> + + + + + + + {/**搜索输入框 */} + { + if (e.key === "Enter") { + e.preventDefault(); + const searchText = e.currentTarget.value; + if (searchText.length > 0) { + const result = doSearch(searchText); + setSearchResults(result); + } + } + }} + /> + + + + {searchResults.map((item) => ( + { + navigate(Path.Chat); + selectSession(item.id); + }} + style={{ cursor: "pointer" }} + > + {/** 搜索匹配的文本 */} + + + {item.name} + {item.content.slice(0, 70)} + + + {/** 操作按钮 */} + + } + text={Locale.SearchChat.Item.View} + /> + + + ))} + + + + + ); +} diff --git a/app/config/server.ts b/app/config/server.ts index 10c74f544..5ad8662bc 100644 --- a/app/config/server.ts +++ b/app/config/server.ts @@ -1,5 +1,5 @@ import md5 from "spark-md5"; -import { DEFAULT_MODELS } from "../constant"; +import { DEFAULT_MODELS, DEFAULT_GA_ID } from "../constant"; declare global { namespace NodeJS { @@ -211,6 +211,7 @@ export const getServerSideConfig = () => { cloudflareKVTTL: process.env.CLOUDFLARE_KV_TTL, gtmId: process.env.GTM_ID, + gaId: process.env.GA_ID || DEFAULT_GA_ID, needCode: ACCESS_CODES.size > 0, code: process.env.CODE, diff --git a/app/constant.ts b/app/constant.ts index 41b080b00..3c562450a 100644 --- a/app/constant.ts +++ b/app/constant.ts @@ -1,3 +1,5 @@ +import path from "path"; + export const OWNER = "ChatGPTNextWeb"; export const REPO = "ChatGPT-Next-Web"; export const REPO_URL = `https://github.com/${OWNER}/${REPO}`; @@ -41,6 +43,7 @@ export enum Path { Sd = "/sd", SdNew = "/sd-new", Artifacts = "/artifacts", + SearchChat = "/search-chat", } export enum ApiPath { @@ -482,4 +485,8 @@ export const internalAllowedWebDavEndpoints = [ "https://app.koofr.net/dav/Koofr", ]; -export const PLUGINS = [{ name: "Stable Diffusion", path: Path.Sd }]; +export const DEFAULT_GA_ID = "G-89WN60ZK2E"; +export const PLUGINS = [ + { name: "Stable Diffusion", path: Path.Sd }, + { name: "Search Chat", path: Path.SearchChat }, +]; diff --git a/app/icons/zoom.svg b/app/icons/zoom.svg new file mode 100644 index 000000000..507b4957f --- /dev/null +++ b/app/icons/zoom.svg @@ -0,0 +1 @@ + diff --git a/app/layout.tsx b/app/layout.tsx index 11681bcb1..162fe14a0 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -9,7 +9,7 @@ import { getClientConfig } from "./config/client"; import type { Metadata, Viewport } from "next"; import { SpeedInsights } from "@vercel/speed-insights/next"; import { getServerSideConfig } from "./config/server"; -import { GoogleTagManager } from "@next/third-parties/google"; +import { GoogleTagManager, GoogleAnalytics } from "@next/third-parties/google"; const serverConfig = getServerSideConfig(); export const metadata: Metadata = { @@ -61,6 +61,11 @@ export default async function RootLayout({ > )} + {serverConfig?.gaId && ( + <> + + > + )}
+ {props.children} +