lloydzhou 1 жил өмнө
parent
commit
b8bbc37b8e

+ 14 - 5
README.md

@@ -88,10 +88,14 @@ For enterprise inquiries, please contact: **business@nextchat.dev**
 - [x] Share as image, share to ShareGPT [#1741](https://github.com/Yidadaa/ChatGPT-Next-Web/pull/1741)
 - [x] Desktop App with tauri
 - [x] Self-host Model: Fully compatible with [RWKV-Runner](https://github.com/josStorer/RWKV-Runner), as well as server deployment of [LocalAI](https://github.com/go-skynet/LocalAI): llama/gpt4all/rwkv/vicuna/koala/gpt4all-j/cerebras/falcon/dolly etc.
-- [ ] Plugins: support network search, calculator, any other apis etc. [#165](https://github.com/Yidadaa/ChatGPT-Next-Web/issues/165)
+- [x] Artifacts: Easily preview, copy and share generated content/webpages through a separate window [#5092](https://github.com/ChatGPTNextWeb/ChatGPT-Next-Web/pull/5092)
+- [x] Plugins: support artifacts, network search, calculator, any other apis etc. [#165](https://github.com/Yidadaa/ChatGPT-Next-Web/issues/165)
+  - [x] artifacts
+  - [ ] network search, network search, calculator, any other apis etc. [#165](https://github.com/Yidadaa/ChatGPT-Next-Web/issues/165)
 
 ## What's New
 
+- 🚀 v2.14.0 Now supports  Artifacts & SD 
 - 🚀 v2.10.1 support Google Gemini Pro model.
 - 🚀 v2.9.11 you can use azure endpoint now.
 - 🚀 v2.8 now we have a client that runs across all platforms!
@@ -120,15 +124,20 @@ For enterprise inquiries, please contact: **business@nextchat.dev**
 - [x] 分享为图片,分享到 ShareGPT 链接 [#1741](https://github.com/Yidadaa/ChatGPT-Next-Web/pull/1741)
 - [x] 使用 tauri 打包桌面应用
 - [x] 支持自部署的大语言模型:开箱即用 [RWKV-Runner](https://github.com/josStorer/RWKV-Runner) ,服务端部署 [LocalAI 项目](https://github.com/go-skynet/LocalAI) llama / gpt4all / rwkv / vicuna / koala / gpt4all-j / cerebras / falcon / dolly 等等,或者使用 [api-for-open-llm](https://github.com/xusenlinzy/api-for-open-llm)
-- [ ] 插件机制,支持联网搜索、计算器、调用其他平台 api [#165](https://github.com/Yidadaa/ChatGPT-Next-Web/issues/165)
+- [x] Artifacts: 通过独立窗口,轻松预览、复制和分享生成的内容/可交互网页 [#5092](https://github.com/ChatGPTNextWeb/ChatGPT-Next-Web/pull/5092)
+- [x] 插件机制,支持 artifacts,联网搜索、计算器、调用其他平台 api [#165](https://github.com/Yidadaa/ChatGPT-Next-Web/issues/165)
+   - [x] artifacts
+   - [ ] 支持联网搜索、计算器、调用其他平台 api [#165](https://github.com/Yidadaa/ChatGPT-Next-Web/issues/165)
 
 ## 最新动态
 
+- 🚀 v2.14.0 现在支持 Artifacts & SD 了。
+- 🚀 v2.10.1 现在支持 Gemini Pro 模型。
+- 🚀 v2.9.11 现在可以使用自定义 Azure 服务了。
+- 🚀 v2.8 发布了横跨 Linux/Windows/MacOS 的体积极小的客户端。
+- 🚀 v2.7 现在可以将会话分享为图片了,也可以分享到 ShareGPT 的在线链接。
 - 🚀 v2.0 已经发布,现在你可以使用面具功能快速创建预制对话了! 了解更多: [ChatGPT 提示词高阶技能:零次、一次和少样本提示](https://github.com/Yidadaa/ChatGPT-Next-Web/issues/138)。
 - 💡 想要更方便地随时随地使用本项目?可以试下这款桌面插件:https://github.com/mushan0x0/AI0x0.com
-- 🚀 v2.7 现在可以将会话分享为图片了,也可以分享到 ShareGPT 的在线链接。
-- 🚀 v2.8 发布了横跨 Linux/Windows/MacOS 的体积极小的客户端。
-- 🚀 v2.9.11 现在可以使用自定义 Azure 服务了。
 
 ## Get Started
 

+ 73 - 0
app/api/artifacts/route.ts

@@ -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";

+ 6 - 4
app/client/platforms/google.ts

@@ -25,11 +25,9 @@ export class GeminiProApi implements LLMApi {
       baseUrl = accessStore.googleUrl;
     }
 
+    const isApp = !!getClientConfig()?.isApp;
     if (baseUrl.length === 0) {
-      const isApp = !!getClientConfig()?.isApp;
-      baseUrl = isApp
-        ? DEFAULT_API_HOST + `/api/proxy/google?key=${accessStore.googleApiKey}`
-        : ApiPath.Google;
+      baseUrl = isApp ? DEFAULT_API_HOST + `/api/proxy/google` : ApiPath.Google;
     }
     if (baseUrl.endsWith("/")) {
       baseUrl = baseUrl.slice(0, baseUrl.length - 1);
@@ -43,6 +41,10 @@ export class GeminiProApi implements LLMApi {
     let chatPath = [baseUrl, path].join("/");
 
     chatPath += chatPath.includes("?") ? "&alt=sse" : "?alt=sse";
+    // if chatPath.startsWith('http') then add key in query string
+    if (chatPath.startsWith("http") && accessStore.googleApiKey) {
+      chatPath += `&key=${accessStore.googleApiKey}`;
+    }
     return chatPath;
   }
   extractMessage(res: any) {

+ 31 - 0
app/components/artifacts.module.scss

@@ -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 - 0
app/components/artifacts.tsx

@@ -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>
+  );
+}

+ 30 - 6
app/components/chat.tsx

@@ -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>
   );
 }

+ 1 - 1
app/components/exporter.tsx

@@ -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} />

+ 12 - 0
app/components/home.tsx

@@ -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 />;

+ 39 - 8
app/components/markdown.tsx

@@ -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>
+      )}
     </>
   );
 }

+ 1 - 0
app/components/ui-lib.module.scss

@@ -309,6 +309,7 @@
   }
 
   &-content {
+    min-width: 300px;
     .list {
       max-height: 90vh;
       overflow-x: hidden;

+ 68 - 8
app/components/ui-lib.tsx

@@ -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>
+  );
+}

+ 5 - 0
app/config/server.ts

@@ -177,6 +177,11 @@ export const getServerSideConfig = () => {
     moonshotUrl: process.env.MOONSHOT_URL,
     moonshotApiKey: getApiKey(process.env.MOONSHOT_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,

+ 7 - 1
app/constant.ts

@@ -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`;
@@ -36,6 +36,7 @@ export enum Path {
   Auth = "/auth",
   Sd = "/sd",
   SdNew = "/sd-new",
+  Artifacts = "/artifacts",
 }
 
 export enum ApiPath {
@@ -49,6 +50,7 @@ export enum ApiPath {
   Alibaba = "/api/alibaba",
   Moonshot = "/api/moonshot",
   Stability = "/api/stability",
+  Artifacts = "/api/artifacts",
 }
 
 export enum SlotID {
@@ -61,6 +63,10 @@ export enum FileName {
   Prompts = "prompts.json",
 }
 
+export enum Plugin {
+  Artifacts = "artifacts",
+}
+
 export enum StoreKey {
   Chat = "chat-next-web-store",
   Access = "access-control",

+ 5 - 0
app/locales/cn.ts

@@ -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: "发现",

+ 5 - 0
app/locales/en.ts

@@ -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",

+ 3 - 1
app/store/mask.ts

@@ -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(

+ 1 - 1
src-tauri/tauri.conf.json

@@ -9,7 +9,7 @@
   },
   "package": {
     "productName": "NextChat",
-    "version": "2.13.1"
+    "version": "2.14.0"
   },
   "tauri": {
     "allowlist": {