mirror of
https://github.com/ChatGPTNextWeb/ChatGPT-Next-Web.git
synced 2025-11-17 06:23:44 +08:00
Merge remote-tracking branch 'up/website' into website
# Conflicts: # app/components/chat.tsx # app/utils.ts
This commit is contained in:
@@ -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<HTMLIFrameElement>(null);
|
||||
const frameId = useRef<string>(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<HTMLPreviewHander, HTMLPreviewProps>(
|
||||
function HTMLPreview(props, ref) {
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
const [frameId, setFrameId] = useState<string>(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 = `<script>new ResizeObserver((entries) => parent.postMessage({id: '${frameId}', height: entries[0].target.clientHeight}, '*')).observe(document.body)</script>`;
|
||||
if (props.code.includes("<!DOCTYPE html>")) {
|
||||
props.code.replace("<!DOCTYPE html>", "<!DOCTYPE html>" + 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 = `<script>new ResizeObserver((entries) => parent.postMessage({id: '${frameId.current}', height: entries[0].target.clientHeight}, '*')).observe(document.body)</script>`;
|
||||
if (props.code.includes("</head>")) {
|
||||
props.code.replace("</head>", "</head>" + script);
|
||||
}
|
||||
return props.code + script;
|
||||
}, [props.code]);
|
||||
|
||||
const handleOnLoad = () => {
|
||||
if (props?.onLoad) {
|
||||
props.onLoad(title);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<iframe
|
||||
className={styles["artifacts-iframe"]}
|
||||
id={frameId.current}
|
||||
ref={ref}
|
||||
sandbox="allow-forms allow-modals allow-scripts"
|
||||
style={{ height }}
|
||||
srcDoc={srcDoc}
|
||||
onLoad={handleOnLoad}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<iframe
|
||||
className={styles["artifacts-iframe"]}
|
||||
key={frameId}
|
||||
ref={iframeRef}
|
||||
sandbox="allow-forms allow-modals allow-scripts"
|
||||
style={{ height }}
|
||||
srcDoc={srcDoc}
|
||||
onLoad={handleOnLoad}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
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<HTMLPreviewHander>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (id) {
|
||||
@@ -208,6 +233,13 @@ export function Artifacts() {
|
||||
<a href={REPO_URL} target="_blank" rel="noopener noreferrer">
|
||||
<IconButton bordered icon={<GithubIcon />} shadow />
|
||||
</a>
|
||||
<IconButton
|
||||
bordered
|
||||
style={{ marginLeft: 20 }}
|
||||
icon={<ReloadButtonIcon />}
|
||||
shadow
|
||||
onClick={() => previewRef.current?.reload()}
|
||||
/>
|
||||
<div className={styles["artifacts-title"]}>NextChat Artifacts</div>
|
||||
<ArtifactsShareButton
|
||||
id={id}
|
||||
@@ -220,6 +252,7 @@ export function Artifacts() {
|
||||
{code && (
|
||||
<HTMLPreview
|
||||
code={code}
|
||||
ref={previewRef}
|
||||
autoHeight={false}
|
||||
height={"100%"}
|
||||
onLoad={(title) => {
|
||||
|
||||
@@ -413,6 +413,21 @@
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.chat-message-tools {
|
||||
font-size: 12px;
|
||||
color: #aaa;
|
||||
line-height: 1.5;
|
||||
margin-top: 5px;
|
||||
.chat-message-tool {
|
||||
display: flex;
|
||||
align-items: end;
|
||||
svg {
|
||||
margin-left: 5px;
|
||||
margin-right: 5px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.chat-message-item {
|
||||
box-sizing: border-box;
|
||||
max-width: 100%;
|
||||
@@ -630,4 +645,4 @@
|
||||
.chat-input-send {
|
||||
bottom: 30px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ import DeleteIcon from "../icons/clear.svg";
|
||||
import PinIcon from "../icons/pin.svg";
|
||||
import EditIcon from "../icons/rename.svg";
|
||||
import ConfirmIcon from "../icons/confirm.svg";
|
||||
import CloseIcon from "../icons/close.svg";
|
||||
import CancelIcon from "../icons/cancel.svg";
|
||||
import ImageIcon from "../icons/image.svg";
|
||||
|
||||
@@ -53,6 +54,7 @@ import {
|
||||
useAppConfig,
|
||||
DEFAULT_TOPIC,
|
||||
ModelType,
|
||||
usePluginStore,
|
||||
} from "../store";
|
||||
|
||||
import {
|
||||
@@ -65,6 +67,7 @@ import {
|
||||
isVisionModel,
|
||||
isDalle3,
|
||||
removeOutdatedEntries,
|
||||
showPlugins,
|
||||
} from "../utils";
|
||||
|
||||
import { uploadImage as uploadImageRemote } from "@/app/utils/chat";
|
||||
@@ -96,7 +99,6 @@ import {
|
||||
REQUEST_TIMEOUT_MS,
|
||||
UNFINISHED_INPUT,
|
||||
ServiceProvider,
|
||||
Plugin,
|
||||
} from "../constant";
|
||||
import { Avatar } from "./emoji";
|
||||
import { ContextPrompts, MaskAvatar, MaskConfig } from "./mask";
|
||||
@@ -440,6 +442,7 @@ export function ChatActions(props: {
|
||||
const config = useAppConfig();
|
||||
const navigate = useNavigate();
|
||||
const chatStore = useChatStore();
|
||||
const pluginStore = usePluginStore();
|
||||
|
||||
// switch themes
|
||||
const theme = config.theme;
|
||||
@@ -724,30 +727,32 @@ export function ChatActions(props: {
|
||||
/>
|
||||
)}
|
||||
|
||||
<ChatAction
|
||||
onClick={() => setShowPluginSelector(true)}
|
||||
text={Locale.Plugin.Name}
|
||||
icon={<PluginIcon />}
|
||||
/>
|
||||
{showPlugins(currentProviderName, currentModel) && (
|
||||
<ChatAction
|
||||
onClick={() => {
|
||||
if (pluginStore.getAll().length == 0) {
|
||||
navigate(Path.Plugins);
|
||||
} else {
|
||||
setShowPluginSelector(true);
|
||||
}
|
||||
}}
|
||||
text={Locale.Plugin.Name}
|
||||
icon={<PluginIcon />}
|
||||
/>
|
||||
)}
|
||||
{showPluginSelector && (
|
||||
<Selector
|
||||
multiple
|
||||
defaultSelectedValue={chatStore.currentSession().mask?.plugin}
|
||||
items={[
|
||||
{
|
||||
title: Locale.Plugin.Artifacts,
|
||||
value: Plugin.Artifacts,
|
||||
},
|
||||
]}
|
||||
items={pluginStore.getAll().map((item) => ({
|
||||
title: `${item?.title}@${item?.version}`,
|
||||
value: item?.id,
|
||||
}))}
|
||||
onClose={() => setShowPluginSelector(false)}
|
||||
onSelection={(s) => {
|
||||
const plugin = s[0];
|
||||
chatStore.updateCurrentSession((session) => {
|
||||
session.mask.plugin = s;
|
||||
session.mask.plugin = s as string[];
|
||||
});
|
||||
if (plugin) {
|
||||
showToast(plugin);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
@@ -1585,11 +1590,31 @@ function _Chat() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{showTyping && (
|
||||
{message?.tools?.length == 0 && showTyping && (
|
||||
<div className={styles["chat-message-status"]}>
|
||||
{Locale.Chat.Typing}
|
||||
</div>
|
||||
)}
|
||||
{/*@ts-ignore*/}
|
||||
{message?.tools?.length > 0 && (
|
||||
<div className={styles["chat-message-tools"]}>
|
||||
{message?.tools?.map((tool) => (
|
||||
<div
|
||||
key={tool.id}
|
||||
className={styles["chat-message-tool"]}
|
||||
>
|
||||
{tool.isError === false ? (
|
||||
<ConfirmIcon />
|
||||
) : tool.isError === true ? (
|
||||
<CloseIcon />
|
||||
) : (
|
||||
<LoadingButtonIcon />
|
||||
)}
|
||||
<span>{tool?.function?.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className={styles["chat-message-item"]}>
|
||||
<Markdown
|
||||
key={message.streaming ? "loading" : "done"}
|
||||
@@ -1599,7 +1624,7 @@ function _Chat() {
|
||||
message.content.length === 0 &&
|
||||
!isUser
|
||||
}
|
||||
onContextMenu={(e) => onRightClick(e, message)}
|
||||
// onContextMenu={(e) => onRightClick(e, message)} // hard to use
|
||||
onDoubleClickCapture={() => {
|
||||
if (!isMobileScreen) return;
|
||||
setUserInput(getMessageTextContent(message));
|
||||
|
||||
@@ -59,6 +59,17 @@ const MaskPage = dynamic(async () => (await import("./mask")).MaskPage, {
|
||||
loading: () => <Loading noLogo />,
|
||||
});
|
||||
|
||||
const PluginPage = dynamic(async () => (await import("./plugin")).PluginPage, {
|
||||
loading: () => <Loading noLogo />,
|
||||
});
|
||||
|
||||
const SearchChat = dynamic(
|
||||
async () => (await import("./search-chat")).SearchChatPage,
|
||||
{
|
||||
loading: () => <Loading noLogo />,
|
||||
},
|
||||
);
|
||||
|
||||
const Sd = dynamic(async () => (await import("./sd")).Sd, {
|
||||
loading: () => <Loading noLogo />,
|
||||
});
|
||||
@@ -174,6 +185,8 @@ function Screen() {
|
||||
<Route path={Path.Home} element={<Chat />} />
|
||||
<Route path={Path.NewChat} element={<NewChat />} />
|
||||
<Route path={Path.Masks} element={<MaskPage />} />
|
||||
<Route path={Path.Plugins} element={<PluginPage />} />
|
||||
<Route path={Path.SearchChat} element={<SearchChat />} />
|
||||
<Route path={Path.Chat} element={<Chat />} />
|
||||
<Route path={Path.Settings} element={<Settings />} />
|
||||
</Routes>
|
||||
|
||||
@@ -8,14 +8,20 @@ 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 { Plugin } from "../constant";
|
||||
import {
|
||||
ArtifactsShareButton,
|
||||
HTMLPreview,
|
||||
HTMLPreviewHander,
|
||||
} from "./artifacts";
|
||||
import { useChatStore } from "../store";
|
||||
import { IconButton } from "./button";
|
||||
|
||||
export function Mermaid(props: { code: string }) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [hasError, setHasError] = useState(false);
|
||||
@@ -64,13 +70,12 @@ export function Mermaid(props: { code: string }) {
|
||||
|
||||
export function PreCode(props: { children: any }) {
|
||||
const ref = useRef<HTMLPreElement>(null);
|
||||
const refText = ref.current?.innerText;
|
||||
const previewRef = useRef<HTMLPreviewHander>(null);
|
||||
const [mermaidCode, setMermaidCode] = useState("");
|
||||
const [htmlCode, setHtmlCode] = useState("");
|
||||
const { height } = useWindowSize();
|
||||
const chatStore = useChatStore();
|
||||
const session = chatStore.currentSession();
|
||||
const plugins = session.mask?.plugin;
|
||||
|
||||
const renderArtifacts = useDebouncedCallback(() => {
|
||||
if (!ref.current) return;
|
||||
@@ -79,6 +84,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("<!DOCTYPE")) {
|
||||
@@ -86,15 +92,7 @@ export function PreCode(props: { children: any }) {
|
||||
}
|
||||
}, 600);
|
||||
|
||||
useEffect(() => {
|
||||
setTimeout(renderArtifacts, 1);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [refText]);
|
||||
|
||||
const enableArtifacts = useMemo(
|
||||
() => plugins?.includes(Plugin.Artifacts),
|
||||
[plugins],
|
||||
);
|
||||
const enableArtifacts = session.mask?.enableArtifacts !== false;
|
||||
|
||||
//Wrap the paragraph for plain-text
|
||||
useEffect(() => {
|
||||
@@ -119,6 +117,7 @@ export function PreCode(props: { children: any }) {
|
||||
codeElement.style.whiteSpace = "pre-wrap";
|
||||
}
|
||||
});
|
||||
setTimeout(renderArtifacts, 1);
|
||||
}
|
||||
}, []);
|
||||
|
||||
@@ -145,7 +144,15 @@ export function PreCode(props: { children: any }) {
|
||||
style={{ position: "absolute", right: 20, top: 10 }}
|
||||
getCode={() => htmlCode}
|
||||
/>
|
||||
<IconButton
|
||||
style={{ position: "absolute", right: 120, top: 10 }}
|
||||
bordered
|
||||
icon={<ReloadButtonIcon />}
|
||||
shadow
|
||||
onClick={() => previewRef.current?.reload()}
|
||||
/>
|
||||
<HTMLPreview
|
||||
ref={previewRef}
|
||||
code={htmlCode}
|
||||
autoHeight={!document.fullscreenElement}
|
||||
height={!document.fullscreenElement ? 600 : height}
|
||||
@@ -182,16 +189,14 @@ function CustomCode(props: { children: any }) {
|
||||
}}
|
||||
>
|
||||
{props.children}
|
||||
{showToggle && collapsed && (
|
||||
<div
|
||||
className={`show-hide-button ${
|
||||
collapsed ? "collapsed" : "expanded"
|
||||
}`}
|
||||
>
|
||||
<button onClick={toggleCollapsed}>查看全部</button>
|
||||
</div>
|
||||
)}
|
||||
</code>
|
||||
{showToggle && collapsed && (
|
||||
<div
|
||||
className={`show-hide-button ${collapsed ? "collapsed" : "expanded"}`}
|
||||
>
|
||||
<button onClick={toggleCollapsed}>{Locale.NewChat.More}</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -167,6 +167,22 @@ export function MaskConfig(props: {
|
||||
></input>
|
||||
</ListItem>
|
||||
|
||||
<ListItem
|
||||
title={Locale.Mask.Config.Artifacts.Title}
|
||||
subTitle={Locale.Mask.Config.Artifacts.SubTitle}
|
||||
>
|
||||
<input
|
||||
aria-label={Locale.Mask.Config.Artifacts.Title}
|
||||
type="checkbox"
|
||||
checked={props.mask.enableArtifacts !== false}
|
||||
onChange={(e) => {
|
||||
props.updateMask((mask) => {
|
||||
mask.enableArtifacts = e.currentTarget.checked;
|
||||
});
|
||||
}}
|
||||
></input>
|
||||
</ListItem>
|
||||
|
||||
{!props.shouldSyncFromGlobal ? (
|
||||
<ListItem
|
||||
title={Locale.Mask.Config.Share.Title}
|
||||
|
||||
16
app/components/plugin.module.scss
Normal file
16
app/components/plugin.module.scss
Normal file
@@ -0,0 +1,16 @@
|
||||
.plugin-title {
|
||||
font-weight: bolder;
|
||||
font-size: 16px;
|
||||
margin: 10px 0;
|
||||
}
|
||||
.plugin-content {
|
||||
font-size: 14px;
|
||||
font-family: inherit;
|
||||
pre code {
|
||||
max-height: 240px;
|
||||
overflow-y: auto;
|
||||
white-space: pre-wrap;
|
||||
min-width: 300px;
|
||||
}
|
||||
}
|
||||
|
||||
393
app/components/plugin.tsx
Normal file
393
app/components/plugin.tsx
Normal file
@@ -0,0 +1,393 @@
|
||||
import { useDebouncedCallback } from "use-debounce";
|
||||
import OpenAPIClientAxios from "openapi-client-axios";
|
||||
import yaml from "js-yaml";
|
||||
import { PLUGINS_REPO_URL } from "../constant";
|
||||
import { IconButton } from "./button";
|
||||
import { ErrorBoundary } from "./error";
|
||||
|
||||
import styles from "./mask.module.scss";
|
||||
import pluginStyles from "./plugin.module.scss";
|
||||
|
||||
import EditIcon from "../icons/edit.svg";
|
||||
import AddIcon from "../icons/add.svg";
|
||||
import CloseIcon from "../icons/close.svg";
|
||||
import DeleteIcon from "../icons/delete.svg";
|
||||
import EyeIcon from "../icons/eye.svg";
|
||||
import ConfirmIcon from "../icons/confirm.svg";
|
||||
import ReloadIcon from "../icons/reload.svg";
|
||||
import GithubIcon from "../icons/github.svg";
|
||||
|
||||
import { Plugin, usePluginStore, FunctionToolService } from "../store/plugin";
|
||||
import {
|
||||
PasswordInput,
|
||||
List,
|
||||
ListItem,
|
||||
Modal,
|
||||
showConfirm,
|
||||
showToast,
|
||||
} from "./ui-lib";
|
||||
import Locale from "../locales";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { useEffect, useState } from "react";
|
||||
import { getClientConfig } from "../config/client";
|
||||
|
||||
export function PluginPage() {
|
||||
const navigate = useNavigate();
|
||||
const pluginStore = usePluginStore();
|
||||
|
||||
const allPlugins = pluginStore.getAll();
|
||||
const [searchPlugins, setSearchPlugins] = useState<Plugin[]>([]);
|
||||
const [searchText, setSearchText] = useState("");
|
||||
const plugins = searchText.length > 0 ? searchPlugins : allPlugins;
|
||||
|
||||
// refactored already, now it accurate
|
||||
const onSearch = (text: string) => {
|
||||
setSearchText(text);
|
||||
if (text.length > 0) {
|
||||
const result = allPlugins.filter(
|
||||
(m) => m?.title.toLowerCase().includes(text.toLowerCase()),
|
||||
);
|
||||
setSearchPlugins(result);
|
||||
} else {
|
||||
setSearchPlugins(allPlugins);
|
||||
}
|
||||
};
|
||||
|
||||
const [editingPluginId, setEditingPluginId] = useState<string | undefined>();
|
||||
const editingPlugin = pluginStore.get(editingPluginId);
|
||||
const editingPluginTool = FunctionToolService.get(editingPlugin?.id);
|
||||
const closePluginModal = () => setEditingPluginId(undefined);
|
||||
|
||||
const onChangePlugin = useDebouncedCallback((editingPlugin, e) => {
|
||||
const content = e.target.innerText;
|
||||
try {
|
||||
const api = new OpenAPIClientAxios({
|
||||
definition: yaml.load(content) as any,
|
||||
});
|
||||
api
|
||||
.init()
|
||||
.then(() => {
|
||||
if (content != editingPlugin.content) {
|
||||
pluginStore.updatePlugin(editingPlugin.id, (plugin) => {
|
||||
plugin.content = content;
|
||||
const tool = FunctionToolService.add(plugin, true);
|
||||
plugin.title = tool.api.definition.info.title;
|
||||
plugin.version = tool.api.definition.info.version;
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch((e) => {
|
||||
console.error(e);
|
||||
showToast(Locale.Plugin.EditModal.Error);
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
showToast(Locale.Plugin.EditModal.Error);
|
||||
}
|
||||
}, 100).bind(null, editingPlugin);
|
||||
|
||||
const [loadUrl, setLoadUrl] = useState<string>("");
|
||||
const loadFromUrl = (loadUrl: string) =>
|
||||
fetch(loadUrl)
|
||||
.catch((e) => {
|
||||
const p = new URL(loadUrl);
|
||||
return fetch(`/api/proxy/${p.pathname}?${p.search}`, {
|
||||
headers: {
|
||||
"X-Base-URL": p.origin,
|
||||
},
|
||||
});
|
||||
})
|
||||
.then((res) => res.text())
|
||||
.then((content) => {
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(content), null, " ");
|
||||
} catch (e) {
|
||||
return content;
|
||||
}
|
||||
})
|
||||
.then((content) => {
|
||||
pluginStore.updatePlugin(editingPlugin.id, (plugin) => {
|
||||
plugin.content = content;
|
||||
const tool = FunctionToolService.add(plugin, true);
|
||||
plugin.title = tool.api.definition.info.title;
|
||||
plugin.version = tool.api.definition.info.version;
|
||||
});
|
||||
})
|
||||
.catch((e) => {
|
||||
showToast(Locale.Plugin.EditModal.Error);
|
||||
});
|
||||
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<div className={styles["mask-page"]}>
|
||||
<div className="window-header">
|
||||
<div className="window-header-title">
|
||||
<div className="window-header-main-title">
|
||||
{Locale.Plugin.Page.Title}
|
||||
</div>
|
||||
<div className="window-header-submai-title">
|
||||
{Locale.Plugin.Page.SubTitle(plugins.length)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="window-actions">
|
||||
<div className="window-action-button">
|
||||
<a
|
||||
href={PLUGINS_REPO_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<IconButton icon={<GithubIcon />} bordered />
|
||||
</a>
|
||||
</div>
|
||||
<div className="window-action-button">
|
||||
<IconButton
|
||||
icon={<CloseIcon />}
|
||||
bordered
|
||||
onClick={() => navigate(-1)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles["mask-page-body"]}>
|
||||
<div className={styles["mask-filter"]}>
|
||||
<input
|
||||
type="text"
|
||||
className={styles["search-bar"]}
|
||||
placeholder={Locale.Plugin.Page.Search}
|
||||
autoFocus
|
||||
onInput={(e) => onSearch(e.currentTarget.value)}
|
||||
/>
|
||||
|
||||
<IconButton
|
||||
className={styles["mask-create"]}
|
||||
icon={<AddIcon />}
|
||||
text={Locale.Plugin.Page.Create}
|
||||
bordered
|
||||
onClick={() => {
|
||||
const createdPlugin = pluginStore.create();
|
||||
setEditingPluginId(createdPlugin.id);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{plugins.length == 0 && (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
margin: "60px auto",
|
||||
alignItems: "center",
|
||||
justifyContent: "center",
|
||||
}}
|
||||
>
|
||||
{Locale.Plugin.Page.Find}
|
||||
<a
|
||||
href={PLUGINS_REPO_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ marginLeft: 16 }}
|
||||
>
|
||||
<IconButton icon={<GithubIcon />} bordered />
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
{plugins.map((m) => (
|
||||
<div className={styles["mask-item"]} key={m.id}>
|
||||
<div className={styles["mask-header"]}>
|
||||
<div className={styles["mask-icon"]}></div>
|
||||
<div className={styles["mask-title"]}>
|
||||
<div className={styles["mask-name"]}>
|
||||
{m.title}@<small>{m.version}</small>
|
||||
</div>
|
||||
<div className={styles["mask-info"] + " one-line"}>
|
||||
{Locale.Plugin.Item.Info(
|
||||
FunctionToolService.add(m).length,
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles["mask-actions"]}>
|
||||
{m.builtin ? (
|
||||
<IconButton
|
||||
icon={<EyeIcon />}
|
||||
text={Locale.Plugin.Item.View}
|
||||
onClick={() => setEditingPluginId(m.id)}
|
||||
/>
|
||||
) : (
|
||||
<IconButton
|
||||
icon={<EditIcon />}
|
||||
text={Locale.Plugin.Item.Edit}
|
||||
onClick={() => setEditingPluginId(m.id)}
|
||||
/>
|
||||
)}
|
||||
{!m.builtin && (
|
||||
<IconButton
|
||||
icon={<DeleteIcon />}
|
||||
text={Locale.Plugin.Item.Delete}
|
||||
onClick={async () => {
|
||||
if (
|
||||
await showConfirm(Locale.Plugin.Item.DeleteConfirm)
|
||||
) {
|
||||
pluginStore.delete(m.id);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{editingPlugin && (
|
||||
<div className="modal-mask">
|
||||
<Modal
|
||||
title={Locale.Plugin.EditModal.Title(editingPlugin?.builtin)}
|
||||
onClose={closePluginModal}
|
||||
actions={[
|
||||
<IconButton
|
||||
icon={<ConfirmIcon />}
|
||||
text={Locale.UI.Confirm}
|
||||
key="export"
|
||||
bordered
|
||||
onClick={() => setEditingPluginId("")}
|
||||
/>,
|
||||
]}
|
||||
>
|
||||
<List>
|
||||
<ListItem title={Locale.Plugin.EditModal.Auth}>
|
||||
<select
|
||||
value={editingPlugin?.authType}
|
||||
onChange={(e) => {
|
||||
pluginStore.updatePlugin(editingPlugin.id, (plugin) => {
|
||||
plugin.authType = e.target.value;
|
||||
});
|
||||
}}
|
||||
>
|
||||
<option value="">{Locale.Plugin.Auth.None}</option>
|
||||
<option value="bearer">{Locale.Plugin.Auth.Bearer}</option>
|
||||
<option value="basic">{Locale.Plugin.Auth.Basic}</option>
|
||||
<option value="custom">{Locale.Plugin.Auth.Custom}</option>
|
||||
</select>
|
||||
</ListItem>
|
||||
{["bearer", "basic", "custom"].includes(
|
||||
editingPlugin.authType as string,
|
||||
) && (
|
||||
<ListItem title={Locale.Plugin.Auth.Location}>
|
||||
<select
|
||||
value={editingPlugin?.authLocation}
|
||||
onChange={(e) => {
|
||||
pluginStore.updatePlugin(editingPlugin.id, (plugin) => {
|
||||
plugin.authLocation = e.target.value;
|
||||
});
|
||||
}}
|
||||
>
|
||||
<option value="header">
|
||||
{Locale.Plugin.Auth.LocationHeader}
|
||||
</option>
|
||||
<option value="query">
|
||||
{Locale.Plugin.Auth.LocationQuery}
|
||||
</option>
|
||||
<option value="body">
|
||||
{Locale.Plugin.Auth.LocationBody}
|
||||
</option>
|
||||
</select>
|
||||
</ListItem>
|
||||
)}
|
||||
{editingPlugin.authType == "custom" && (
|
||||
<ListItem title={Locale.Plugin.Auth.CustomHeader}>
|
||||
<input
|
||||
type="text"
|
||||
value={editingPlugin?.authHeader}
|
||||
onChange={(e) => {
|
||||
pluginStore.updatePlugin(editingPlugin.id, (plugin) => {
|
||||
plugin.authHeader = e.target.value;
|
||||
});
|
||||
}}
|
||||
></input>
|
||||
</ListItem>
|
||||
)}
|
||||
{["bearer", "basic", "custom"].includes(
|
||||
editingPlugin.authType as string,
|
||||
) && (
|
||||
<ListItem title={Locale.Plugin.Auth.Token}>
|
||||
<PasswordInput
|
||||
type="text"
|
||||
value={editingPlugin?.authToken}
|
||||
onChange={(e) => {
|
||||
pluginStore.updatePlugin(editingPlugin.id, (plugin) => {
|
||||
plugin.authToken = e.currentTarget.value;
|
||||
});
|
||||
}}
|
||||
></PasswordInput>
|
||||
</ListItem>
|
||||
)}
|
||||
{!getClientConfig()?.isApp && (
|
||||
<ListItem
|
||||
title={Locale.Plugin.Auth.Proxy}
|
||||
subTitle={Locale.Plugin.Auth.ProxyDescription}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={editingPlugin?.usingProxy}
|
||||
style={{ minWidth: 16 }}
|
||||
onChange={(e) => {
|
||||
pluginStore.updatePlugin(editingPlugin.id, (plugin) => {
|
||||
plugin.usingProxy = e.currentTarget.checked;
|
||||
});
|
||||
}}
|
||||
></input>
|
||||
</ListItem>
|
||||
)}
|
||||
</List>
|
||||
<List>
|
||||
<ListItem title={Locale.Plugin.EditModal.Content}>
|
||||
<div style={{ display: "flex", justifyContent: "flex-end" }}>
|
||||
<input
|
||||
type="text"
|
||||
style={{ minWidth: 200, marginRight: 20 }}
|
||||
onInput={(e) => setLoadUrl(e.currentTarget.value)}
|
||||
></input>
|
||||
<IconButton
|
||||
icon={<ReloadIcon />}
|
||||
text={Locale.Plugin.EditModal.Load}
|
||||
bordered
|
||||
onClick={() => loadFromUrl(loadUrl)}
|
||||
/>
|
||||
</div>
|
||||
</ListItem>
|
||||
<ListItem
|
||||
subTitle={
|
||||
<div
|
||||
className={`markdown-body ${pluginStyles["plugin-content"]}`}
|
||||
dir="auto"
|
||||
>
|
||||
<pre>
|
||||
<code
|
||||
contentEditable={true}
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: editingPlugin.content,
|
||||
}}
|
||||
onBlur={onChangePlugin}
|
||||
></code>
|
||||
</pre>
|
||||
</div>
|
||||
}
|
||||
></ListItem>
|
||||
{editingPluginTool?.tools.map((tool, index) => (
|
||||
<ListItem
|
||||
key={index}
|
||||
title={tool?.function?.name}
|
||||
subTitle={tool?.function?.description}
|
||||
/>
|
||||
))}
|
||||
</List>
|
||||
</Modal>
|
||||
</div>
|
||||
)}
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
167
app/components/search-chat.tsx
Normal file
167
app/components/search-chat.tsx
Normal file
@@ -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<Item[]>([]);
|
||||
|
||||
const previousValueRef = useRef<string>("");
|
||||
const searchInputRef = useRef<HTMLInputElement>(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 (
|
||||
<ErrorBoundary>
|
||||
<div className={styles["mask-page"]}>
|
||||
{/* header */}
|
||||
<div className="window-header">
|
||||
<div className="window-header-title">
|
||||
<div className="window-header-main-title">
|
||||
{Locale.SearchChat.Page.Title}
|
||||
</div>
|
||||
<div className="window-header-submai-title">
|
||||
{Locale.SearchChat.Page.SubTitle(searchResults.length)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="window-actions">
|
||||
<div className="window-action-button">
|
||||
<IconButton
|
||||
icon={<CloseIcon />}
|
||||
bordered
|
||||
onClick={() => navigate(-1)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles["mask-page-body"]}>
|
||||
<div className={styles["mask-filter"]}>
|
||||
{/**搜索输入框 */}
|
||||
<input
|
||||
type="text"
|
||||
className={styles["search-bar"]}
|
||||
placeholder={Locale.SearchChat.Page.Search}
|
||||
autoFocus
|
||||
ref={searchInputRef}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
const searchText = e.currentTarget.value;
|
||||
if (searchText.length > 0) {
|
||||
const result = doSearch(searchText);
|
||||
setSearchResults(result);
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
{searchResults.map((item) => (
|
||||
<div
|
||||
className={styles["mask-item"]}
|
||||
key={item.id}
|
||||
onClick={() => {
|
||||
navigate(Path.Chat);
|
||||
selectSession(item.id);
|
||||
}}
|
||||
style={{ cursor: "pointer" }}
|
||||
>
|
||||
{/** 搜索匹配的文本 */}
|
||||
<div className={styles["mask-header"]}>
|
||||
<div className={styles["mask-title"]}>
|
||||
<div className={styles["mask-name"]}>{item.name}</div>
|
||||
{item.content.slice(0, 70)}
|
||||
</div>
|
||||
</div>
|
||||
{/** 操作按钮 */}
|
||||
<div className={styles["mask-actions"]}>
|
||||
<IconButton
|
||||
icon={<EyeIcon />}
|
||||
text={Locale.SearchChat.Item.View}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ErrorBoundary>
|
||||
);
|
||||
}
|
||||
@@ -50,8 +50,8 @@ export function Card(props: { children: JSX.Element[]; className?: string }) {
|
||||
}
|
||||
|
||||
export function ListItem(props: {
|
||||
title: string;
|
||||
subTitle?: string;
|
||||
title?: string;
|
||||
subTitle?: string | JSX.Element;
|
||||
children?: JSX.Element | JSX.Element[];
|
||||
icon?: JSX.Element;
|
||||
className?: string;
|
||||
|
||||
Reference in New Issue
Block a user