diff --git a/app/api/artifact/route.ts b/app/api/artifact/route.ts new file mode 100644 index 00000000000..4accf7c09a0 --- /dev/null +++ b/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"; diff --git a/app/components/artifact.tsx b/app/components/artifact.tsx new file mode 100644 index 00000000000..e08a713dc46 --- /dev/null +++ b/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(null); + const frameId = useRef(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 = ``; + if (props.code.includes("")) { + props.code.replace("", "" + script); + } + return props.code + script; + }, [props.code]); + + return ( + + ); +} + +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 ( + <> +
+ } + bordered + title={Locale.Export.Artifact.Title} + onClick={() => { + upload(getCode()); + }} + /> +
+ {show && ( +
+ setShow(false)} + actions={[ + } + bordered + text={Locale.Export.Download} + onClick={() => { + downloadAs(getCode(), `${id}.html`).then(() => + setShow(false), + ); + }} + />, + } + bordered + text={Locale.Chat.Actions.Copy} + onClick={() => { + copyToClipboard(shareUrl).then(() => setShow(false)); + }} + />, + ]} + > + + +
+ )} + + ); +} + +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 ( +
+
+
+ + } shadow /> + +
+ code} /> +
+ {code ? ( + + ) : ( + + )} +
+ ); +} diff --git a/app/components/home.tsx b/app/components/home.tsx index e127c65f8fd..11000f98533 100644 --- a/app/components/home.tsx +++ b/app/components/home.tsx @@ -39,6 +39,10 @@ export function Loading(props: { noLogo?: boolean }) { ); } +const Artifact = dynamic(async () => (await import("./artifact")).Artifact, { + loading: () => , +}); + const Settings = dynamic(async () => (await import("./settings")).Settings, { loading: () => , }); @@ -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 ( + + } /> + + ); + } + return (
(null); @@ -61,56 +61,6 @@ export function Mermaid(props: { code: string }) { ); } -export function HTMLPreview(props: { code: string }) { - const ref = useRef(null); - const frameId = useRef(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( - ``, - ); - - return ( -
e.stopPropagation()} - > - -
- ); -} - export function PreCode(props: { children: any }) { const ref = useRef(null); const refText = ref.current?.innerText; @@ -151,7 +101,22 @@ export function PreCode(props: { children: any }) { {mermaidCode.length > 0 && ( )} - {htmlCode.length > 0 && } + {htmlCode.length > 0 && ( +
e.stopPropagation()} + > + htmlCode} + /> + +
+ )} ); } diff --git a/app/config/server.ts b/app/config/server.ts index d3ff9651d74..b87cba2fff1 100644 --- a/app/config/server.ts +++ b/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, diff --git a/app/constant.ts b/app/constant.ts index 2f4acb10439..3546cc9be9c 100644 --- a/app/constant.ts +++ b/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 { diff --git a/app/locales/cn.ts b/app/locales/cn.ts index 43ea6763332..f08480f7cff 100644 --- a/app/locales/cn.ts +++ b/app/locales/cn.ts @@ -104,6 +104,10 @@ const cn = { Toast: "正在生成截图", Modal: "长按或右键保存图片", }, + Artifact: { + Title: "分享页面", + Error: "分享失败", + }, }, Select: { Search: "搜索消息", diff --git a/app/locales/en.ts b/app/locales/en.ts index 94c737550ec..dd3b9e733f8 100644 --- a/app/locales/en.ts +++ b/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",