mirror of
				https://github.com/Yidadaa/ChatGPT-Next-Web.git
				synced 2025-11-04 16:57:27 +08:00 
			
		
		
		
	Merge branch 'main' of https://github.com/ChatGPTNextWeb/ChatGPT-Next-Web
This commit is contained in:
		
							
								
								
									
										73
									
								
								app/api/artifacts/route.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										73
									
								
								app/api/artifacts/route.ts
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,73 @@
 | 
			
		||||
import md5 from "spark-md5";
 | 
			
		||||
import { NextRequest, NextResponse } from "next/server";
 | 
			
		||||
import { getServerSideConfig } from "@/app/config/server";
 | 
			
		||||
 | 
			
		||||
async function handle(req: NextRequest, res: NextResponse) {
 | 
			
		||||
  const serverConfig = getServerSideConfig();
 | 
			
		||||
  const storeUrl = () =>
 | 
			
		||||
    `https://api.cloudflare.com/client/v4/accounts/${serverConfig.cloudflareAccountId}/storage/kv/namespaces/${serverConfig.cloudflareKVNamespaceId}`;
 | 
			
		||||
  const storeHeaders = () => ({
 | 
			
		||||
    Authorization: `Bearer ${serverConfig.cloudflareKVApiKey}`,
 | 
			
		||||
  });
 | 
			
		||||
  if (req.method === "POST") {
 | 
			
		||||
    const clonedBody = await req.text();
 | 
			
		||||
    const hashedCode = md5.hash(clonedBody).trim();
 | 
			
		||||
    const body: {
 | 
			
		||||
      key: string;
 | 
			
		||||
      value: string;
 | 
			
		||||
      expiration_ttl?: number;
 | 
			
		||||
    } = {
 | 
			
		||||
      key: hashedCode,
 | 
			
		||||
      value: clonedBody,
 | 
			
		||||
    };
 | 
			
		||||
    try {
 | 
			
		||||
      const ttl = parseInt(serverConfig.cloudflareKVTTL as string);
 | 
			
		||||
      if (ttl > 60) {
 | 
			
		||||
        body["expiration_ttl"] = ttl;
 | 
			
		||||
      }
 | 
			
		||||
    } catch (e) {
 | 
			
		||||
      console.error(e);
 | 
			
		||||
    }
 | 
			
		||||
    const res = await fetch(`${storeUrl()}/bulk`, {
 | 
			
		||||
      headers: {
 | 
			
		||||
        ...storeHeaders(),
 | 
			
		||||
        "Content-Type": "application/json",
 | 
			
		||||
      },
 | 
			
		||||
      method: "PUT",
 | 
			
		||||
      body: JSON.stringify([body]),
 | 
			
		||||
    });
 | 
			
		||||
    const result = await res.json();
 | 
			
		||||
    console.log("save data", result);
 | 
			
		||||
    if (result?.success) {
 | 
			
		||||
      return NextResponse.json(
 | 
			
		||||
        { code: 0, id: hashedCode, result },
 | 
			
		||||
        { status: res.status },
 | 
			
		||||
      );
 | 
			
		||||
    }
 | 
			
		||||
    return NextResponse.json(
 | 
			
		||||
      { error: true, msg: "Save data error" },
 | 
			
		||||
      { status: 400 },
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
  if (req.method === "GET") {
 | 
			
		||||
    const id = req?.nextUrl?.searchParams?.get("id");
 | 
			
		||||
    const res = await fetch(`${storeUrl()}/values/${id}`, {
 | 
			
		||||
      headers: storeHeaders(),
 | 
			
		||||
      method: "GET",
 | 
			
		||||
    });
 | 
			
		||||
    return new Response(res.body, {
 | 
			
		||||
      status: res.status,
 | 
			
		||||
      statusText: res.statusText,
 | 
			
		||||
      headers: res.headers,
 | 
			
		||||
    });
 | 
			
		||||
  }
 | 
			
		||||
  return NextResponse.json(
 | 
			
		||||
    { error: true, msg: "Invalid request" },
 | 
			
		||||
    { status: 400 },
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export const POST = handle;
 | 
			
		||||
export const GET = handle;
 | 
			
		||||
 | 
			
		||||
export const runtime = "edge";
 | 
			
		||||
							
								
								
									
										31
									
								
								app/components/artifacts.module.scss
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										31
									
								
								app/components/artifacts.module.scss
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,31 @@
 | 
			
		||||
.artifacts {
 | 
			
		||||
  display: flex;
 | 
			
		||||
  width: 100%;
 | 
			
		||||
  height: 100%;
 | 
			
		||||
  flex-direction: column;
 | 
			
		||||
  &-header {
 | 
			
		||||
    display: flex;
 | 
			
		||||
    align-items: center;
 | 
			
		||||
    height: 36px;
 | 
			
		||||
    padding: 20px;
 | 
			
		||||
    background: var(--second);
 | 
			
		||||
  }
 | 
			
		||||
  &-title {
 | 
			
		||||
    flex: 1;
 | 
			
		||||
    text-align: center;
 | 
			
		||||
    font-weight: bold;
 | 
			
		||||
    font-size: 24px;
 | 
			
		||||
  }
 | 
			
		||||
  &-content {
 | 
			
		||||
    flex-grow: 1;
 | 
			
		||||
    padding: 0 20px 20px 20px;
 | 
			
		||||
    background-color: var(--second);
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
.artifacts-iframe {
 | 
			
		||||
  width: 100%;
 | 
			
		||||
  border: var(--border-in-light);
 | 
			
		||||
  border-radius: 6px;
 | 
			
		||||
  background-color: var(--gray);
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										234
									
								
								app/components/artifacts.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										234
									
								
								app/components/artifacts.tsx
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,234 @@
 | 
			
		||||
import { useEffect, useState, useRef, useMemo } from "react";
 | 
			
		||||
import { useParams } from "react-router";
 | 
			
		||||
import { useWindowSize } from "@/app/utils";
 | 
			
		||||
import { IconButton } from "./button";
 | 
			
		||||
import { nanoid } from "nanoid";
 | 
			
		||||
import ExportIcon from "../icons/share.svg";
 | 
			
		||||
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 Locale from "../locales";
 | 
			
		||||
import { Modal, showToast } from "./ui-lib";
 | 
			
		||||
import { copyToClipboard, downloadAs } from "../utils";
 | 
			
		||||
import { Path, ApiPath, REPO_URL } from "@/app/constant";
 | 
			
		||||
import { Loading } from "./home";
 | 
			
		||||
import styles from "./artifacts.module.scss";
 | 
			
		||||
 | 
			
		||||
export function HTMLPreview(props: {
 | 
			
		||||
  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);
 | 
			
		||||
      }
 | 
			
		||||
    };
 | 
			
		||||
    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}
 | 
			
		||||
    />
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function ArtifactsShareButton({
 | 
			
		||||
  getCode,
 | 
			
		||||
  id,
 | 
			
		||||
  style,
 | 
			
		||||
  fileName,
 | 
			
		||||
}: {
 | 
			
		||||
  getCode: () => string;
 | 
			
		||||
  id?: string;
 | 
			
		||||
  style?: any;
 | 
			
		||||
  fileName?: string;
 | 
			
		||||
}) {
 | 
			
		||||
  const [loading, setLoading] = useState(false);
 | 
			
		||||
  const [name, setName] = useState(id);
 | 
			
		||||
  const [show, setShow] = useState(false);
 | 
			
		||||
  const shareUrl = useMemo(
 | 
			
		||||
    () => [location.origin, "#", Path.Artifacts, "/", name].join(""),
 | 
			
		||||
    [name],
 | 
			
		||||
  );
 | 
			
		||||
  const upload = (code: string) =>
 | 
			
		||||
    id
 | 
			
		||||
      ? Promise.resolve({ id })
 | 
			
		||||
      : fetch(ApiPath.Artifacts, {
 | 
			
		||||
          method: "POST",
 | 
			
		||||
          body: code,
 | 
			
		||||
        })
 | 
			
		||||
          .then((res) => res.json())
 | 
			
		||||
          .then(({ id }) => {
 | 
			
		||||
            if (id) {
 | 
			
		||||
              return { id };
 | 
			
		||||
            }
 | 
			
		||||
            throw Error();
 | 
			
		||||
          })
 | 
			
		||||
          .catch((e) => {
 | 
			
		||||
            showToast(Locale.Export.Artifacts.Error);
 | 
			
		||||
          });
 | 
			
		||||
  return (
 | 
			
		||||
    <>
 | 
			
		||||
      <div className="window-action-button" style={style}>
 | 
			
		||||
        <IconButton
 | 
			
		||||
          icon={loading ? <LoadingButtonIcon /> : <ExportIcon />}
 | 
			
		||||
          bordered
 | 
			
		||||
          title={Locale.Export.Artifacts.Title}
 | 
			
		||||
          onClick={() => {
 | 
			
		||||
            if (loading) return;
 | 
			
		||||
            setLoading(true);
 | 
			
		||||
            upload(getCode())
 | 
			
		||||
              .then((res) => {
 | 
			
		||||
                if (res?.id) {
 | 
			
		||||
                  setShow(true);
 | 
			
		||||
                  setName(res?.id);
 | 
			
		||||
                }
 | 
			
		||||
              })
 | 
			
		||||
              .finally(() => setLoading(false));
 | 
			
		||||
          }}
 | 
			
		||||
        />
 | 
			
		||||
      </div>
 | 
			
		||||
      {show && (
 | 
			
		||||
        <div className="modal-mask">
 | 
			
		||||
          <Modal
 | 
			
		||||
            title={Locale.Export.Artifacts.Title}
 | 
			
		||||
            onClose={() => setShow(false)}
 | 
			
		||||
            actions={[
 | 
			
		||||
              <IconButton
 | 
			
		||||
                key="download"
 | 
			
		||||
                icon={<DownloadIcon />}
 | 
			
		||||
                bordered
 | 
			
		||||
                text={Locale.Export.Download}
 | 
			
		||||
                onClick={() => {
 | 
			
		||||
                  downloadAs(getCode(), `${fileName || name}.html`).then(() =>
 | 
			
		||||
                    setShow(false),
 | 
			
		||||
                  );
 | 
			
		||||
                }}
 | 
			
		||||
              />,
 | 
			
		||||
              <IconButton
 | 
			
		||||
                key="copy"
 | 
			
		||||
                icon={<CopyIcon />}
 | 
			
		||||
                bordered
 | 
			
		||||
                text={Locale.Chat.Actions.Copy}
 | 
			
		||||
                onClick={() => {
 | 
			
		||||
                  copyToClipboard(shareUrl).then(() => setShow(false));
 | 
			
		||||
                }}
 | 
			
		||||
              />,
 | 
			
		||||
            ]}
 | 
			
		||||
          >
 | 
			
		||||
            <div>
 | 
			
		||||
              <a target="_blank" href={shareUrl}>
 | 
			
		||||
                {shareUrl}
 | 
			
		||||
              </a>
 | 
			
		||||
            </div>
 | 
			
		||||
          </Modal>
 | 
			
		||||
        </div>
 | 
			
		||||
      )}
 | 
			
		||||
    </>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function Artifacts() {
 | 
			
		||||
  const { id } = useParams();
 | 
			
		||||
  const [code, setCode] = useState("");
 | 
			
		||||
  const [loading, setLoading] = useState(true);
 | 
			
		||||
  const [fileName, setFileName] = useState("");
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    if (id) {
 | 
			
		||||
      fetch(`${ApiPath.Artifacts}?id=${id}`)
 | 
			
		||||
        .then((res) => {
 | 
			
		||||
          if (res.status > 300) {
 | 
			
		||||
            throw Error("can not get content");
 | 
			
		||||
          }
 | 
			
		||||
          return res;
 | 
			
		||||
        })
 | 
			
		||||
        .then((res) => res.text())
 | 
			
		||||
        .then(setCode)
 | 
			
		||||
        .catch((e) => {
 | 
			
		||||
          showToast(Locale.Export.Artifacts.Error);
 | 
			
		||||
        });
 | 
			
		||||
    }
 | 
			
		||||
  }, [id]);
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <div className={styles["artifacts"]}>
 | 
			
		||||
      <div className={styles["artifacts-header"]}>
 | 
			
		||||
        <a href={REPO_URL} target="_blank" rel="noopener noreferrer">
 | 
			
		||||
          <IconButton bordered icon={<GithubIcon />} shadow />
 | 
			
		||||
        </a>
 | 
			
		||||
        <div className={styles["artifacts-title"]}>NextChat Artifacts</div>
 | 
			
		||||
        <ArtifactsShareButton
 | 
			
		||||
          id={id}
 | 
			
		||||
          getCode={() => code}
 | 
			
		||||
          fileName={fileName}
 | 
			
		||||
        />
 | 
			
		||||
      </div>
 | 
			
		||||
      <div className={styles["artifacts-content"]}>
 | 
			
		||||
        {loading && <Loading />}
 | 
			
		||||
        {code && (
 | 
			
		||||
          <HTMLPreview
 | 
			
		||||
            code={code}
 | 
			
		||||
            autoHeight={false}
 | 
			
		||||
            height={"100%"}
 | 
			
		||||
            onLoad={(title) => {
 | 
			
		||||
              setFileName(title as string);
 | 
			
		||||
              setLoading(false);
 | 
			
		||||
            }}
 | 
			
		||||
          />
 | 
			
		||||
        )}
 | 
			
		||||
      </div>
 | 
			
		||||
    </div>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
@@ -90,6 +90,7 @@ import {
 | 
			
		||||
  REQUEST_TIMEOUT_MS,
 | 
			
		||||
  UNFINISHED_INPUT,
 | 
			
		||||
  ServiceProvider,
 | 
			
		||||
  Plugin,
 | 
			
		||||
} from "../constant";
 | 
			
		||||
import { Avatar } from "./emoji";
 | 
			
		||||
import { ContextPrompts, MaskAvatar, MaskConfig } from "./mask";
 | 
			
		||||
@@ -477,6 +478,7 @@ export function ChatActions(props: {
 | 
			
		||||
    return model?.displayName ?? "";
 | 
			
		||||
  }, [models, currentModel, currentProviderName]);
 | 
			
		||||
  const [showModelSelector, setShowModelSelector] = useState(false);
 | 
			
		||||
  const [showPluginSelector, setShowPluginSelector] = useState(false);
 | 
			
		||||
  const [showUploadImage, setShowUploadImage] = useState(false);
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
@@ -588,12 +590,6 @@ export function ChatActions(props: {
 | 
			
		||||
        icon={<RobotIcon />}
 | 
			
		||||
      />
 | 
			
		||||
 | 
			
		||||
      <ChatAction
 | 
			
		||||
        onClick={() => showToast(Locale.WIP)}
 | 
			
		||||
        text={Locale.Plugin.Name}
 | 
			
		||||
        icon={<PluginIcon />}
 | 
			
		||||
      />
 | 
			
		||||
 | 
			
		||||
      {showModelSelector && (
 | 
			
		||||
        <Selector
 | 
			
		||||
          defaultSelectedValue={`${currentModel}@${currentProviderName}`}
 | 
			
		||||
@@ -627,6 +623,34 @@ export function ChatActions(props: {
 | 
			
		||||
          }}
 | 
			
		||||
        />
 | 
			
		||||
      )}
 | 
			
		||||
 | 
			
		||||
      <ChatAction
 | 
			
		||||
        onClick={() => setShowPluginSelector(true)}
 | 
			
		||||
        text={Locale.Plugin.Name}
 | 
			
		||||
        icon={<PluginIcon />}
 | 
			
		||||
      />
 | 
			
		||||
      {showPluginSelector && (
 | 
			
		||||
        <Selector
 | 
			
		||||
          multiple
 | 
			
		||||
          defaultSelectedValue={chatStore.currentSession().mask?.plugin}
 | 
			
		||||
          items={[
 | 
			
		||||
            {
 | 
			
		||||
              title: Locale.Plugin.Artifacts,
 | 
			
		||||
              value: Plugin.Artifacts,
 | 
			
		||||
            },
 | 
			
		||||
          ]}
 | 
			
		||||
          onClose={() => setShowPluginSelector(false)}
 | 
			
		||||
          onSelection={(s) => {
 | 
			
		||||
            const plugin = s[0];
 | 
			
		||||
            chatStore.updateCurrentSession((session) => {
 | 
			
		||||
              session.mask.plugin = s;
 | 
			
		||||
            });
 | 
			
		||||
            if (plugin) {
 | 
			
		||||
              showToast(plugin);
 | 
			
		||||
            }
 | 
			
		||||
          }}
 | 
			
		||||
        />
 | 
			
		||||
      )}
 | 
			
		||||
    </div>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -541,7 +541,7 @@ export function ImagePreviewer(props: {
 | 
			
		||||
          <div>
 | 
			
		||||
            <div className={styles["main-title"]}>NextChat</div>
 | 
			
		||||
            <div className={styles["sub-title"]}>
 | 
			
		||||
              github.com/Yidadaa/ChatGPT-Next-Web
 | 
			
		||||
              github.com/ChatGPTNextWeb/ChatGPT-Next-Web
 | 
			
		||||
            </div>
 | 
			
		||||
            <div className={styles["icons"]}>
 | 
			
		||||
              <ExportAvatar avatar={config.avatar} />
 | 
			
		||||
 
 | 
			
		||||
@@ -39,6 +39,10 @@ export function Loading(props: { noLogo?: boolean }) {
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
const Artifacts = dynamic(async () => (await import("./artifacts")).Artifacts, {
 | 
			
		||||
  loading: () => <Loading noLogo />,
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
const Settings = dynamic(async () => (await import("./settings")).Settings, {
 | 
			
		||||
  loading: () => <Loading noLogo />,
 | 
			
		||||
});
 | 
			
		||||
@@ -137,6 +141,7 @@ export function WindowContent(props: { children: React.ReactNode }) {
 | 
			
		||||
function Screen() {
 | 
			
		||||
  const config = useAppConfig();
 | 
			
		||||
  const location = useLocation();
 | 
			
		||||
  const isArtifact = location.pathname.includes(Path.Artifacts);
 | 
			
		||||
  const isHome = location.pathname === Path.Home;
 | 
			
		||||
  const isAuth = location.pathname === Path.Auth;
 | 
			
		||||
  const isSd = location.pathname === Path.Sd;
 | 
			
		||||
@@ -150,6 +155,13 @@ function Screen() {
 | 
			
		||||
    loadAsyncGoogleFont();
 | 
			
		||||
  }, []);
 | 
			
		||||
 | 
			
		||||
  if (isArtifact) {
 | 
			
		||||
    return (
 | 
			
		||||
      <Routes>
 | 
			
		||||
        <Route path="/artifacts/:id" element={<Artifacts />} />
 | 
			
		||||
      </Routes>
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
  const renderContent = () => {
 | 
			
		||||
    if (isAuth) return <AuthPage />;
 | 
			
		||||
    if (isSd) return <Sd />;
 | 
			
		||||
 
 | 
			
		||||
@@ -6,14 +6,16 @@ import RehypeKatex from "rehype-katex";
 | 
			
		||||
import RemarkGfm from "remark-gfm";
 | 
			
		||||
import RehypeHighlight from "rehype-highlight";
 | 
			
		||||
import { useRef, useState, RefObject, useEffect, useMemo } from "react";
 | 
			
		||||
import { copyToClipboard } from "../utils";
 | 
			
		||||
import { copyToClipboard, useWindowSize } from "../utils";
 | 
			
		||||
import mermaid from "mermaid";
 | 
			
		||||
 | 
			
		||||
import LoadingIcon from "../icons/three-dots.svg";
 | 
			
		||||
import React from "react";
 | 
			
		||||
import { useDebouncedCallback } from "use-debounce";
 | 
			
		||||
import { showImageModal } from "./ui-lib";
 | 
			
		||||
 | 
			
		||||
import { showImageModal, FullScreen } from "./ui-lib";
 | 
			
		||||
import { ArtifactsShareButton, HTMLPreview } from "./artifacts";
 | 
			
		||||
import { Plugin } from "../constant";
 | 
			
		||||
import { useChatStore } from "../store";
 | 
			
		||||
export function Mermaid(props: { code: string }) {
 | 
			
		||||
  const ref = useRef<HTMLDivElement>(null);
 | 
			
		||||
  const [hasError, setHasError] = useState(false);
 | 
			
		||||
@@ -64,25 +66,38 @@ export function PreCode(props: { children: any }) {
 | 
			
		||||
  const ref = useRef<HTMLPreElement>(null);
 | 
			
		||||
  const refText = ref.current?.innerText;
 | 
			
		||||
  const [mermaidCode, setMermaidCode] = useState("");
 | 
			
		||||
  const [htmlCode, setHtmlCode] = useState("");
 | 
			
		||||
  const { height } = useWindowSize();
 | 
			
		||||
  const chatStore = useChatStore();
 | 
			
		||||
  const session = chatStore.currentSession();
 | 
			
		||||
  const plugins = session.mask?.plugin;
 | 
			
		||||
 | 
			
		||||
  const renderMermaid = useDebouncedCallback(() => {
 | 
			
		||||
  const renderArtifacts = useDebouncedCallback(() => {
 | 
			
		||||
    if (!ref.current) return;
 | 
			
		||||
    const mermaidDom = ref.current.querySelector("code.language-mermaid");
 | 
			
		||||
    if (mermaidDom) {
 | 
			
		||||
      setMermaidCode((mermaidDom as HTMLElement).innerText);
 | 
			
		||||
    }
 | 
			
		||||
    const htmlDom = ref.current.querySelector("code.language-html");
 | 
			
		||||
    if (htmlDom) {
 | 
			
		||||
      setHtmlCode((htmlDom as HTMLElement).innerText);
 | 
			
		||||
    } else if (refText?.startsWith("<!DOCTYPE")) {
 | 
			
		||||
      setHtmlCode(refText);
 | 
			
		||||
    }
 | 
			
		||||
  }, 600);
 | 
			
		||||
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    setTimeout(renderMermaid, 1);
 | 
			
		||||
    setTimeout(renderArtifacts, 1);
 | 
			
		||||
    // eslint-disable-next-line react-hooks/exhaustive-deps
 | 
			
		||||
  }, [refText]);
 | 
			
		||||
 | 
			
		||||
  const enableArtifacts = useMemo(
 | 
			
		||||
    () => plugins?.includes(Plugin.Artifacts),
 | 
			
		||||
    [plugins],
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <>
 | 
			
		||||
      {mermaidCode.length > 0 && (
 | 
			
		||||
        <Mermaid code={mermaidCode} key={mermaidCode} />
 | 
			
		||||
      )}
 | 
			
		||||
      <pre ref={ref}>
 | 
			
		||||
        <span
 | 
			
		||||
          className="copy-code-button"
 | 
			
		||||
@@ -95,6 +110,22 @@ export function PreCode(props: { children: any }) {
 | 
			
		||||
        ></span>
 | 
			
		||||
        {props.children}
 | 
			
		||||
      </pre>
 | 
			
		||||
      {mermaidCode.length > 0 && (
 | 
			
		||||
        <Mermaid code={mermaidCode} key={mermaidCode} />
 | 
			
		||||
      )}
 | 
			
		||||
      {htmlCode.length > 0 && enableArtifacts && (
 | 
			
		||||
        <FullScreen className="no-dark html" right={70}>
 | 
			
		||||
          <ArtifactsShareButton
 | 
			
		||||
            style={{ position: "absolute", right: 20, top: 10 }}
 | 
			
		||||
            getCode={() => htmlCode}
 | 
			
		||||
          />
 | 
			
		||||
          <HTMLPreview
 | 
			
		||||
            code={htmlCode}
 | 
			
		||||
            autoHeight={!document.fullscreenElement}
 | 
			
		||||
            height={!document.fullscreenElement ? 600 : height}
 | 
			
		||||
          />
 | 
			
		||||
        </FullScreen>
 | 
			
		||||
      )}
 | 
			
		||||
    </>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -309,6 +309,7 @@
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  &-content {
 | 
			
		||||
    min-width: 300px;
 | 
			
		||||
    .list {
 | 
			
		||||
      max-height: 90vh;
 | 
			
		||||
      overflow-x: hidden;
 | 
			
		||||
 
 | 
			
		||||
@@ -19,6 +19,8 @@ import React, {
 | 
			
		||||
  MouseEvent,
 | 
			
		||||
  useEffect,
 | 
			
		||||
  useState,
 | 
			
		||||
  useCallback,
 | 
			
		||||
  useRef,
 | 
			
		||||
} from "react";
 | 
			
		||||
import { IconButton } from "./button";
 | 
			
		||||
 | 
			
		||||
@@ -53,7 +55,7 @@ export function ListItem(props: {
 | 
			
		||||
  children?: JSX.Element | JSX.Element[];
 | 
			
		||||
  icon?: JSX.Element;
 | 
			
		||||
  className?: string;
 | 
			
		||||
  onClick?: (event: MouseEvent) => void;
 | 
			
		||||
  onClick?: (e: MouseEvent) => void;
 | 
			
		||||
  vertical?: boolean;
 | 
			
		||||
}) {
 | 
			
		||||
  return (
 | 
			
		||||
@@ -463,17 +465,40 @@ export function Selector<T>(props: {
 | 
			
		||||
    value: T;
 | 
			
		||||
    disable?: boolean;
 | 
			
		||||
  }>;
 | 
			
		||||
  defaultSelectedValue?: T;
 | 
			
		||||
  defaultSelectedValue?: T[] | T;
 | 
			
		||||
  onSelection?: (selection: T[]) => void;
 | 
			
		||||
  onClose?: () => void;
 | 
			
		||||
  multiple?: boolean;
 | 
			
		||||
}) {
 | 
			
		||||
  const [selectedValues, setSelectedValues] = useState<T[]>(
 | 
			
		||||
    Array.isArray(props.defaultSelectedValue)
 | 
			
		||||
      ? props.defaultSelectedValue
 | 
			
		||||
      : props.defaultSelectedValue !== undefined
 | 
			
		||||
      ? [props.defaultSelectedValue]
 | 
			
		||||
      : [],
 | 
			
		||||
  );
 | 
			
		||||
 | 
			
		||||
  const handleSelection = (e: MouseEvent, value: T) => {
 | 
			
		||||
    if (props.multiple) {
 | 
			
		||||
      e.stopPropagation();
 | 
			
		||||
      const newSelectedValues = selectedValues.includes(value)
 | 
			
		||||
        ? selectedValues.filter((v) => v !== value)
 | 
			
		||||
        : [...selectedValues, value];
 | 
			
		||||
      setSelectedValues(newSelectedValues);
 | 
			
		||||
      props.onSelection?.(newSelectedValues);
 | 
			
		||||
    } else {
 | 
			
		||||
      setSelectedValues([value]);
 | 
			
		||||
      props.onSelection?.([value]);
 | 
			
		||||
      props.onClose?.();
 | 
			
		||||
    }
 | 
			
		||||
  };
 | 
			
		||||
 | 
			
		||||
  return (
 | 
			
		||||
    <div className={styles["selector"]} onClick={() => props.onClose?.()}>
 | 
			
		||||
      <div className={styles["selector-content"]}>
 | 
			
		||||
        <List>
 | 
			
		||||
          {props.items.map((item, i) => {
 | 
			
		||||
            const selected = props.defaultSelectedValue === item.value;
 | 
			
		||||
            const selected = selectedValues.includes(item.value);
 | 
			
		||||
            return (
 | 
			
		||||
              <ListItem
 | 
			
		||||
                className={`${styles["selector-item"]} ${
 | 
			
		||||
@@ -482,11 +507,11 @@ export function Selector<T>(props: {
 | 
			
		||||
                key={i}
 | 
			
		||||
                title={item.title}
 | 
			
		||||
                subTitle={item.subTitle}
 | 
			
		||||
                onClick={(event) => {
 | 
			
		||||
                  event.stopPropagation();
 | 
			
		||||
                  if (!item.disable) {
 | 
			
		||||
                    props.onSelection?.([item.value]);
 | 
			
		||||
                    props.onClose?.();
 | 
			
		||||
                onClick={(e) => {
 | 
			
		||||
                  if (item.disable) {
 | 
			
		||||
                    e.stopPropagation();
 | 
			
		||||
                  } else {
 | 
			
		||||
                    handleSelection(e, item.value);
 | 
			
		||||
                  }
 | 
			
		||||
                }}
 | 
			
		||||
              >
 | 
			
		||||
@@ -510,3 +535,38 @@ export function Selector<T>(props: {
 | 
			
		||||
    </div>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
export function FullScreen(props: any) {
 | 
			
		||||
  const { children, right = 10, top = 10, ...rest } = props;
 | 
			
		||||
  const ref = useRef<HTMLDivElement>();
 | 
			
		||||
  const [fullScreen, setFullScreen] = useState(false);
 | 
			
		||||
  const toggleFullscreen = useCallback(() => {
 | 
			
		||||
    if (!document.fullscreenElement) {
 | 
			
		||||
      ref.current?.requestFullscreen();
 | 
			
		||||
    } else {
 | 
			
		||||
      document.exitFullscreen();
 | 
			
		||||
    }
 | 
			
		||||
  }, []);
 | 
			
		||||
  useEffect(() => {
 | 
			
		||||
    const handleScreenChange = (e: any) => {
 | 
			
		||||
      if (e.target === ref.current) {
 | 
			
		||||
        setFullScreen(!!document.fullscreenElement);
 | 
			
		||||
      }
 | 
			
		||||
    };
 | 
			
		||||
    document.addEventListener("fullscreenchange", handleScreenChange);
 | 
			
		||||
    return () => {
 | 
			
		||||
      document.removeEventListener("fullscreenchange", handleScreenChange);
 | 
			
		||||
    };
 | 
			
		||||
  }, []);
 | 
			
		||||
  return (
 | 
			
		||||
    <div ref={ref} style={{ position: "relative" }} {...rest}>
 | 
			
		||||
      <div style={{ position: "absolute", right, top }}>
 | 
			
		||||
        <IconButton
 | 
			
		||||
          icon={fullScreen ? <MinIcon /> : <MaxIcon />}
 | 
			
		||||
          onClick={toggleFullscreen}
 | 
			
		||||
          bordered
 | 
			
		||||
        />
 | 
			
		||||
      </div>
 | 
			
		||||
      {children}
 | 
			
		||||
    </div>
 | 
			
		||||
  );
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -168,6 +168,11 @@ export const getServerSideConfig = () => {
 | 
			
		||||
    alibabaUrl: process.env.ALIBABA_URL,
 | 
			
		||||
    alibabaApiKey: getApiKey(process.env.ALIBABA_API_KEY),
 | 
			
		||||
 | 
			
		||||
    cloudflareAccountId: process.env.CLOUDFLARE_ACCOUNT_ID,
 | 
			
		||||
    cloudflareKVNamespaceId: process.env.CLOUDFLARE_KV_NAMESPACE_ID,
 | 
			
		||||
    cloudflareKVApiKey: getApiKey(process.env.CLOUDFLARE_KV_API_KEY),
 | 
			
		||||
    cloudflareKVTTL: process.env.CLOUDFLARE_KV_TTL,
 | 
			
		||||
 | 
			
		||||
    gtmId: process.env.GTM_ID,
 | 
			
		||||
 | 
			
		||||
    needCode: ACCESS_CODES.size > 0,
 | 
			
		||||
 
 | 
			
		||||
@@ -1,4 +1,4 @@
 | 
			
		||||
export const OWNER = "Yidadaa";
 | 
			
		||||
export const OWNER = "ChatGPTNextWeb";
 | 
			
		||||
export const REPO = "ChatGPT-Next-Web";
 | 
			
		||||
export const REPO_URL = `https://github.com/${OWNER}/${REPO}`;
 | 
			
		||||
export const ISSUE_URL = `https://github.com/${OWNER}/${REPO}/issues`;
 | 
			
		||||
@@ -35,6 +35,7 @@ export enum Path {
 | 
			
		||||
  Auth = "/auth",
 | 
			
		||||
  Sd = "/sd",
 | 
			
		||||
  SdNew = "/sd-new",
 | 
			
		||||
  Artifacts = "/artifacts",
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export enum ApiPath {
 | 
			
		||||
@@ -47,6 +48,7 @@ export enum ApiPath {
 | 
			
		||||
  ByteDance = "/api/bytedance",
 | 
			
		||||
  Alibaba = "/api/alibaba",
 | 
			
		||||
  Stability = "/api/stability",
 | 
			
		||||
  Artifacts = "/api/artifacts",
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export enum SlotID {
 | 
			
		||||
@@ -59,6 +61,10 @@ export enum FileName {
 | 
			
		||||
  Prompts = "prompts.json",
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export enum Plugin {
 | 
			
		||||
  Artifacts = "artifacts",
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export enum StoreKey {
 | 
			
		||||
  Chat = "chat-next-web-store",
 | 
			
		||||
  Access = "access-control",
 | 
			
		||||
 
 | 
			
		||||
@@ -104,6 +104,10 @@ const cn = {
 | 
			
		||||
      Toast: "正在生成截图",
 | 
			
		||||
      Modal: "长按或右键保存图片",
 | 
			
		||||
    },
 | 
			
		||||
    Artifacts: {
 | 
			
		||||
      Title: "分享页面",
 | 
			
		||||
      Error: "分享失败",
 | 
			
		||||
    },
 | 
			
		||||
  },
 | 
			
		||||
  Select: {
 | 
			
		||||
    Search: "搜索消息",
 | 
			
		||||
@@ -457,6 +461,7 @@ const cn = {
 | 
			
		||||
  },
 | 
			
		||||
  Plugin: {
 | 
			
		||||
    Name: "插件",
 | 
			
		||||
    Artifacts: "Artifacts",
 | 
			
		||||
  },
 | 
			
		||||
  Discovery: {
 | 
			
		||||
    Name: "发现",
 | 
			
		||||
 
 | 
			
		||||
@@ -106,6 +106,10 @@ const en: LocaleType = {
 | 
			
		||||
      Toast: "Capturing Image...",
 | 
			
		||||
      Modal: "Long press or right click to save image",
 | 
			
		||||
    },
 | 
			
		||||
    Artifacts: {
 | 
			
		||||
      Title: "Share Artifacts",
 | 
			
		||||
      Error: "Share Error",
 | 
			
		||||
    },
 | 
			
		||||
  },
 | 
			
		||||
  Select: {
 | 
			
		||||
    Search: "Search",
 | 
			
		||||
@@ -464,6 +468,7 @@ const en: LocaleType = {
 | 
			
		||||
  },
 | 
			
		||||
  Plugin: {
 | 
			
		||||
    Name: "Plugin",
 | 
			
		||||
    Artifacts: "Artifacts",
 | 
			
		||||
  },
 | 
			
		||||
  Discovery: {
 | 
			
		||||
    Name: "Discovery",
 | 
			
		||||
 
 | 
			
		||||
@@ -2,7 +2,7 @@ import { BUILTIN_MASKS } from "../masks";
 | 
			
		||||
import { getLang, Lang } from "../locales";
 | 
			
		||||
import { DEFAULT_TOPIC, ChatMessage } from "./chat";
 | 
			
		||||
import { ModelConfig, useAppConfig } from "./config";
 | 
			
		||||
import { StoreKey } from "../constant";
 | 
			
		||||
import { StoreKey, Plugin } from "../constant";
 | 
			
		||||
import { nanoid } from "nanoid";
 | 
			
		||||
import { createPersistStore } from "../utils/store";
 | 
			
		||||
 | 
			
		||||
@@ -17,6 +17,7 @@ export type Mask = {
 | 
			
		||||
  modelConfig: ModelConfig;
 | 
			
		||||
  lang: Lang;
 | 
			
		||||
  builtin: boolean;
 | 
			
		||||
  plugin?: Plugin[];
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
export const DEFAULT_MASK_STATE = {
 | 
			
		||||
@@ -37,6 +38,7 @@ export const createEmptyMask = () =>
 | 
			
		||||
    lang: getLang(),
 | 
			
		||||
    builtin: false,
 | 
			
		||||
    createdAt: Date.now(),
 | 
			
		||||
    plugin: [Plugin.Artifacts],
 | 
			
		||||
  }) as Mask;
 | 
			
		||||
 | 
			
		||||
export const useMaskStore = createPersistStore(
 | 
			
		||||
 
 | 
			
		||||
@@ -9,7 +9,7 @@
 | 
			
		||||
  },
 | 
			
		||||
  "package": {
 | 
			
		||||
    "productName": "NextChat",
 | 
			
		||||
    "version": "2.13.1"
 | 
			
		||||
    "version": "2.14.0"
 | 
			
		||||
  },
 | 
			
		||||
  "tauri": {
 | 
			
		||||
    "allowlist": {
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user