mirror of
https://github.com/ChatGPTNextWeb/ChatGPT-Next-Web.git
synced 2025-05-30 01:20:22 +09:00
save artifact content to cloudflare workers kv
This commit is contained in:
parent
1ecefd88f7
commit
421bf33c0e
54
app/api/artifact/route.ts
Normal file
54
app/api/artifact/route.ts
Normal file
@ -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
app/components/artifact.tsx
Normal file
190
app/components/artifact.tsx
Normal file
@ -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>
|
||||||
|
);
|
||||||
|
}
|
@ -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, {
|
const Settings = dynamic(async () => (await import("./settings")).Settings, {
|
||||||
loading: () => <Loading noLogo />,
|
loading: () => <Loading noLogo />,
|
||||||
});
|
});
|
||||||
@ -125,6 +129,7 @@ const loadAsyncGoogleFont = () => {
|
|||||||
function Screen() {
|
function Screen() {
|
||||||
const config = useAppConfig();
|
const config = useAppConfig();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
const isArtifact = location.pathname.includes(Path.Artifact);
|
||||||
const isHome = location.pathname === Path.Home;
|
const isHome = location.pathname === Path.Home;
|
||||||
const isAuth = location.pathname === Path.Auth;
|
const isAuth = location.pathname === Path.Auth;
|
||||||
const isMobileScreen = useMobileScreen();
|
const isMobileScreen = useMobileScreen();
|
||||||
@ -135,6 +140,14 @@ function Screen() {
|
|||||||
loadAsyncGoogleFont();
|
loadAsyncGoogleFont();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
if (isArtifact) {
|
||||||
|
return (
|
||||||
|
<Routes>
|
||||||
|
<Route exact path="/artifact/:id" element={<Artifact />} />
|
||||||
|
</Routes>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={
|
className={
|
||||||
|
@ -13,7 +13,7 @@ import LoadingIcon from "../icons/three-dots.svg";
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import { useDebouncedCallback } from "use-debounce";
|
import { useDebouncedCallback } from "use-debounce";
|
||||||
import { showImageModal } from "./ui-lib";
|
import { showImageModal } from "./ui-lib";
|
||||||
import { nanoid } from "nanoid";
|
import { ArtifactShareButton, HTMLPreview } from "./artifact";
|
||||||
|
|
||||||
export function Mermaid(props: { code: string }) {
|
export function Mermaid(props: { code: string }) {
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
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 }) {
|
export function PreCode(props: { children: any }) {
|
||||||
const ref = useRef<HTMLPreElement>(null);
|
const ref = useRef<HTMLPreElement>(null);
|
||||||
const refText = ref.current?.innerText;
|
const refText = ref.current?.innerText;
|
||||||
@ -151,7 +101,22 @@ export function PreCode(props: { children: any }) {
|
|||||||
{mermaidCode.length > 0 && (
|
{mermaidCode.length > 0 && (
|
||||||
<Mermaid code={mermaidCode} key={mermaidCode} />
|
<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>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -158,6 +158,10 @@ export const getServerSideConfig = () => {
|
|||||||
alibabaUrl: process.env.ALIBABA_URL,
|
alibabaUrl: process.env.ALIBABA_URL,
|
||||||
alibabaApiKey: getApiKey(process.env.ALIBABA_API_KEY),
|
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,
|
gtmId: process.env.GTM_ID,
|
||||||
|
|
||||||
needCode: ACCESS_CODES.size > 0,
|
needCode: ACCESS_CODES.size > 0,
|
||||||
|
@ -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 FETCH_TAG_URL = `https://api.github.com/repos/${OWNER}/${REPO}/tags?per_page=1`;
|
||||||
export const RUNTIME_CONFIG_DOM = "danger-runtime-config";
|
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 DEFAULT_API_HOST = "https://api.nextchat.dev";
|
||||||
export const OPENAI_BASE_URL = "https://api.openai.com";
|
export const OPENAI_BASE_URL = "https://api.openai.com";
|
||||||
export const ANTHROPIC_BASE_URL = "https://api.anthropic.com";
|
export const ANTHROPIC_BASE_URL = "https://api.anthropic.com";
|
||||||
@ -31,6 +32,7 @@ export enum Path {
|
|||||||
NewChat = "/new-chat",
|
NewChat = "/new-chat",
|
||||||
Masks = "/masks",
|
Masks = "/masks",
|
||||||
Auth = "/auth",
|
Auth = "/auth",
|
||||||
|
Artifact = "/artifact",
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum ApiPath {
|
export enum ApiPath {
|
||||||
@ -42,6 +44,7 @@ export enum ApiPath {
|
|||||||
Baidu = "/api/baidu",
|
Baidu = "/api/baidu",
|
||||||
ByteDance = "/api/bytedance",
|
ByteDance = "/api/bytedance",
|
||||||
Alibaba = "/api/alibaba",
|
Alibaba = "/api/alibaba",
|
||||||
|
Artifact = "/api/artifact",
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum SlotID {
|
export enum SlotID {
|
||||||
|
@ -104,6 +104,10 @@ const cn = {
|
|||||||
Toast: "正在生成截图",
|
Toast: "正在生成截图",
|
||||||
Modal: "长按或右键保存图片",
|
Modal: "长按或右键保存图片",
|
||||||
},
|
},
|
||||||
|
Artifact: {
|
||||||
|
Title: "分享页面",
|
||||||
|
Error: "分享失败",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
Select: {
|
Select: {
|
||||||
Search: "搜索消息",
|
Search: "搜索消息",
|
||||||
|
@ -106,6 +106,10 @@ const en: LocaleType = {
|
|||||||
Toast: "Capturing Image...",
|
Toast: "Capturing Image...",
|
||||||
Modal: "Long press or right click to save image",
|
Modal: "Long press or right click to save image",
|
||||||
},
|
},
|
||||||
|
Artifact: {
|
||||||
|
Title: "Share Artifact",
|
||||||
|
Error: "Share Error",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
Select: {
|
Select: {
|
||||||
Search: "Search",
|
Search: "Search",
|
||||||
|
Loading…
Reference in New Issue
Block a user