Переглянути джерело

save artifact content to cloudflare workers kv

lloydzhou 1 рік тому
батько
коміт
421bf33c0e

+ 54 - 0
app/api/artifact/route.ts

@@ -0,0 +1,54 @@
+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 = (key) =>
+    `https://api.cloudflare.com/client/v4/accounts/${serverConfig.cloudflareAccountId}/storage/kv/namespaces/${serverConfig.cloudflareKVNamespaceId}/values/${key}`;
+  const storeHeaders = () => ({
+    Authorization: `Bearer ${serverConfig.cloudflareKVApiKey}`,
+  });
+  if (req.method === "POST") {
+    const clonedBody = await req.text();
+    const hashedCode = md5.hash(clonedBody).trim();
+    const res = await fetch(storeUrl(hashedCode), {
+      headers: storeHeaders(),
+      method: "PUT",
+      body: clonedBody,
+    });
+    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(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";

+ 190 - 0
app/components/artifact.tsx

@@ -0,0 +1,190 @@
+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 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";
+
+export function HTMLPreview(props: {
+  code: string;
+  autoHeight?: boolean;
+  height?: number;
+}) {
+  const ref = useRef<HTMLIFrameElement>(null);
+  const frameId = useRef<string>(nanoid());
+  const [iframeHeight, setIframeHeight] = useState(600);
+  /*
+   * 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(() => {
+    window.addEventListener("message", (e) => {
+      const { id, height } = e.data;
+      if (id == frameId.current) {
+        console.log("setHeight", height);
+        setIframeHeight(height);
+      }
+    });
+  }, []);
+
+  const height = useMemo(() => {
+    const parentHeight = props.height || 600;
+    if (props.autoHeight !== false) {
+      return iframeHeight > parentHeight ? parentHeight : iframeHeight + 40;
+    } else {
+      return parentHeight;
+    }
+  }, [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]);
+
+  return (
+    <iframe
+      id={frameId.current}
+      ref={ref}
+      frameBorder={0}
+      sandbox="allow-forms allow-modals allow-scripts"
+      style={{ width: "100%", height }}
+      // src={`data:text/html,${encodeURIComponent(srcDoc)}`}
+      srcDoc={srcDoc}
+    ></iframe>
+  );
+}
+
+export function ArtifactShareButton({ getCode, id, style }) {
+  const [name, setName] = useState(id);
+  const [show, setShow] = useState(false);
+  const shareUrl = useMemo(() =>
+    [location.origin, "#", Path.Artifact, "/", name].join(""),
+  );
+  const upload = (code) =>
+    fetch(ApiPath.Artifact, {
+      method: "POST",
+      body: getCode(),
+    })
+      .then((res) => res.json())
+      .then(({ id }) => {
+        if (id) {
+          setShow(true);
+          return setName(id);
+        }
+        throw Error();
+      })
+      .catch((e) => {
+        showToast(Locale.Export.Artifact.Error);
+      });
+  return (
+    <>
+      <div className="window-action-button" style={style}>
+        <IconButton
+          icon={<ExportIcon />}
+          bordered
+          title={Locale.Export.Artifact.Title}
+          onClick={() => {
+            upload(getCode());
+          }}
+        />
+      </div>
+      {show && (
+        <div className="modal-mask">
+          <Modal
+            title={Locale.Export.Artifact.Title}
+            onClose={() => setShow(false)}
+            actions={[
+              <IconButton
+                key="download"
+                icon={<DownloadIcon />}
+                bordered
+                text={Locale.Export.Download}
+                onClick={() => {
+                  downloadAs(getCode(), `${id}.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 Artifact() {
+  const { id } = useParams();
+  const [code, setCode] = useState("");
+  const { height } = useWindowSize();
+
+  useEffect(() => {
+    if (id) {
+      fetch(`${ApiPath.Artifact}?id=${id}`)
+        .then((res) => res.text())
+        .then(setCode);
+    }
+  }, [id]);
+
+  return (
+    <div
+      style={{
+        disply: "block",
+        width: "100%",
+        height: "100%",
+        position: "relative",
+      }}
+    >
+      <div
+        style={{
+          height: 40,
+          display: "flex",
+          alignItems: "center",
+          padding: 12,
+        }}
+      >
+        <div style={{ flex: 1 }}>
+          <a href={REPO_URL} target="_blank" rel="noopener noreferrer">
+            <IconButton bordered icon={<GithubIcon />} shadow />
+          </a>
+        </div>
+        <ArtifactShareButton id={id} getCode={() => code} />
+      </div>
+      {code ? (
+        <HTMLPreview code={code} autoHeight={false} height={height - 40} />
+      ) : (
+        <Loading />
+      )}
+    </div>
+  );
+}

+ 13 - 0
app/components/home.tsx

@@ -39,6 +39,10 @@ export function Loading(props: { noLogo?: boolean }) {
   );
 }
 
+const Artifact = dynamic(async () => (await import("./artifact")).Artifact, {
+  loading: () => <Loading noLogo />,
+});
+
 const Settings = dynamic(async () => (await import("./settings")).Settings, {
   loading: () => <Loading noLogo />,
 });
@@ -125,6 +129,7 @@ const loadAsyncGoogleFont = () => {
 function Screen() {
   const config = useAppConfig();
   const location = useLocation();
+  const isArtifact = location.pathname.includes(Path.Artifact);
   const isHome = location.pathname === Path.Home;
   const isAuth = location.pathname === Path.Auth;
   const isMobileScreen = useMobileScreen();
@@ -135,6 +140,14 @@ function Screen() {
     loadAsyncGoogleFont();
   }, []);
 
+  if (isArtifact) {
+    return (
+      <Routes>
+        <Route exact path="/artifact/:id" element={<Artifact />} />
+      </Routes>
+    );
+  }
+
   return (
     <div
       className={

+ 17 - 52
app/components/markdown.tsx

@@ -13,7 +13,7 @@ import LoadingIcon from "../icons/three-dots.svg";
 import React from "react";
 import { useDebouncedCallback } from "use-debounce";
 import { showImageModal } from "./ui-lib";
-import { nanoid } from "nanoid";
+import { ArtifactShareButton, HTMLPreview } from "./artifact";
 
 export function Mermaid(props: { code: string }) {
   const ref = useRef<HTMLDivElement>(null);
@@ -61,56 +61,6 @@ export function Mermaid(props: { code: string }) {
   );
 }
 
-export function HTMLPreview(props: { code: string }) {
-  const ref = useRef<HTMLIFrameElement>(null);
-  const frameId = useRef<string>(nanoid());
-  const [height, setHeight] = useState(600);
-  /*
-   * 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(() => {
-    window.addEventListener("message", (e) => {
-      const { id, height } = e.data;
-      if (id == frameId.current) {
-        console.log("setHeight", height);
-        if (height < 600) {
-          setHeight(height + 40);
-        }
-      }
-    });
-  }, []);
-
-  const script = encodeURIComponent(
-    `<script>new ResizeObserver((entries) => parent.postMessage({id: '${frameId.current}', height: entries[0].target.clientHeight}, '*')).observe(document.body)</script>`,
-  );
-
-  return (
-    <div
-      className="no-dark html"
-      style={{
-        cursor: "pointer",
-        overflow: "auto",
-      }}
-      onClick={(e) => e.stopPropagation()}
-    >
-      <iframe
-        id={frameId.current}
-        ref={ref}
-        frameBorder={0}
-        sandbox="allow-forms allow-modals allow-scripts"
-        style={{ width: "100%", height }}
-        src={`data:text/html,${encodeURIComponent(props.code)}${script}`}
-        // srcDoc={props.code + script}
-      ></iframe>
-    </div>
-  );
-}
-
 export function PreCode(props: { children: any }) {
   const ref = useRef<HTMLPreElement>(null);
   const refText = ref.current?.innerText;
@@ -151,7 +101,22 @@ export function PreCode(props: { children: any }) {
       {mermaidCode.length > 0 && (
         <Mermaid code={mermaidCode} key={mermaidCode} />
       )}
-      {htmlCode.length > 0 && <HTMLPreview code={htmlCode} key={htmlCode} />}
+      {htmlCode.length > 0 && (
+        <div
+          className="no-dark html"
+          style={{
+            overflow: "auto",
+            position: "relative",
+          }}
+          onClick={(e) => e.stopPropagation()}
+        >
+          <ArtifactShareButton
+            style={{ position: "absolute", right: 10, top: 10 }}
+            getCode={() => htmlCode}
+          />
+          <HTMLPreview code={htmlCode} />
+        </div>
+      )}
     </>
   );
 }

+ 4 - 0
app/config/server.ts

@@ -158,6 +158,10 @@ 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),
+
     gtmId: process.env.GTM_ID,
 
     needCode: ACCESS_CODES.size > 0,

+ 3 - 0
app/constant.ts

@@ -8,6 +8,7 @@ export const FETCH_COMMIT_URL = `https://api.github.com/repos/${OWNER}/${REPO}/c
 export const FETCH_TAG_URL = `https://api.github.com/repos/${OWNER}/${REPO}/tags?per_page=1`;
 export const RUNTIME_CONFIG_DOM = "danger-runtime-config";
 
+export const PREVIEW_URL = "https://app.nextchat.dev";
 export const DEFAULT_API_HOST = "https://api.nextchat.dev";
 export const OPENAI_BASE_URL = "https://api.openai.com";
 export const ANTHROPIC_BASE_URL = "https://api.anthropic.com";
@@ -31,6 +32,7 @@ export enum Path {
   NewChat = "/new-chat",
   Masks = "/masks",
   Auth = "/auth",
+  Artifact = "/artifact",
 }
 
 export enum ApiPath {
@@ -42,6 +44,7 @@ export enum ApiPath {
   Baidu = "/api/baidu",
   ByteDance = "/api/bytedance",
   Alibaba = "/api/alibaba",
+  Artifact = "/api/artifact",
 }
 
 export enum SlotID {

+ 4 - 0
app/locales/cn.ts

@@ -104,6 +104,10 @@ const cn = {
       Toast: "正在生成截图",
       Modal: "长按或右键保存图片",
     },
+    Artifact: {
+      Title: "分享页面",
+      Error: "分享失败",
+    },
   },
   Select: {
     Search: "搜索消息",

+ 4 - 0
app/locales/en.ts

@@ -106,6 +106,10 @@ const en: LocaleType = {
       Toast: "Capturing Image...",
       Modal: "Long press or right click to save image",
     },
+    Artifact: {
+      Title: "Share Artifact",
+      Error: "Share Error",
+    },
   },
   Select: {
     Search: "Search",