Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Artifacts] add preview html code #5092

Merged
merged 30 commits into from
Jul 26, 2024
Merged
Show file tree
Hide file tree
Changes from 9 commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
3a10f58
add preview html code
lloydzhou Jul 23, 2024
dfd0891
add preview html code
lloydzhou Jul 23, 2024
4199e17
auto height for html preview
lloydzhou Jul 23, 2024
fb60fbb
auto height for html preview
lloydzhou Jul 23, 2024
2e9e20c
auto height for html preview
lloydzhou Jul 23, 2024
1ecefd8
hotfix
lloydzhou Jul 23, 2024
421bf33
save artifact content to cloudflare workers kv
lloydzhou Jul 24, 2024
e31bec3
save artifact content to cloudflare workers kv
lloydzhou Jul 24, 2024
ab9f538
fix typescript
lloydzhou Jul 24, 2024
b4bf11d
add loading icon when upload artifact content
lloydzhou Jul 25, 2024
044116c
add plugin selector on chat
lloydzhou Jul 25, 2024
2efedb1
update
lloydzhou Jul 25, 2024
9f0e16b
hotfix: ts check
lloydzhou Jul 25, 2024
47b33f2
hotfix: auto set height
lloydzhou Jul 25, 2024
763fc89
add fullscreen button on artifact component
lloydzhou Jul 25, 2024
7c1bc1f
hotfix: auto set height
lloydzhou Jul 25, 2024
d8afd1a
add expiration_ttl for kv storage
lloydzhou Jul 25, 2024
21ef9a4
feat: artifacts style
Dogtiti Jul 25, 2024
825929f
merge main
lloydzhou Jul 25, 2024
6a083b2
fix typescript error
lloydzhou Jul 25, 2024
556d563
update
lloydzhou Jul 25, 2024
5ec0311
fix typescript error
lloydzhou Jul 25, 2024
51e8f04
Merge branch 'feature-artifacts' of https://github.com/ConnectAI-E/Ch…
Dogtiti Jul 25, 2024
c27ef6f
feat: artifacts style
Dogtiti Jul 25, 2024
a0f0b4f
Merge pull request #5 from ConnectAI-E/feature/artifacts-style
lloydzhou Jul 25, 2024
72d6f97
fix: ts error
Dogtiti Jul 26, 2024
f2d2622
fix: uploading loading
Dogtiti Jul 26, 2024
6737f01
chore: artifact => artifacts
Dogtiti Jul 26, 2024
715d1dc
fix: default enable artifacts
Dogtiti Jul 26, 2024
3f9f556
fix: iframe bg
Dogtiti Jul 26, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions app/api/artifact/route.ts
Original file line number Diff line number Diff line change
@@ -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: string) =>
`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 as string), {
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";
218 changes: 218 additions & 0 deletions app/components/artifact.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,218 @@
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;
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(() => {
window.addEventListener("message", (e) => {
const { id, height, title } = e.data;
setTitle(title);
if (id == frameId.current) {
setIframeHeight(height);
}
});
}, []);
lloydzhou marked this conversation as resolved.
Show resolved Hide resolved

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]);
lloydzhou marked this conversation as resolved.
Show resolved Hide resolved

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}
onLoad={(e) => props?.onLoad && props?.onLoad(title)}
lloydzhou marked this conversation as resolved.
Show resolved Hide resolved
></iframe>
);
}

export function ArtifactShareButton({
getCode,
id,
style,
fileName,
}: {
getCode: () => string;
id?: string;
style?: any;
fileName?: string;
}) {
const [name, setName] = useState(id);
const [show, setShow] = useState(false);
const shareUrl = useMemo(
() => [location.origin, "#", Path.Artifact, "/", name].join(""),
[name],
);
const upload = (code: string) =>
id
? Promise.resolve({ id })
: fetch(ApiPath.Artifact, {
method: "POST",
body: code,
})
.then((res) => res.json())
.then(({ id }) => {
if (id) {
return { id };
}
throw Error();
})
.catch((e) => {
showToast(Locale.Export.Artifact.Error);
});
lloydzhou marked this conversation as resolved.
Show resolved Hide resolved
return (
<>
<div className="window-action-button" style={style}>
<IconButton
icon={<ExportIcon />}
bordered
title={Locale.Export.Artifact.Title}
onClick={() => {
upload(getCode()).then((res) => {
if (res?.id) {
setShow(true);
setName(res?.id);
}
});
lloydzhou marked this conversation as resolved.
Show resolved Hide resolved
}}
/>
</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(), `${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 Artifact() {
const { id } = useParams();
const [code, setCode] = useState("");
const [loading, setLoading] = useState(true);
const [fileName, setFileName] = useState("");
const { height } = useWindowSize();

useEffect(() => {
if (id) {
fetch(`${ApiPath.Artifact}?id=${id}`)
.then((res) => res.text())
.then(setCode);
}
}, [id]);
lloydzhou marked this conversation as resolved.
Show resolved Hide resolved

return (
<div
style={{
display: "block",
width: "100%",
height: "100%",
position: "relative",
}}
>
<div
style={{
height: 36,
display: "flex",
alignItems: "center",
padding: 12,
}}
>
<a href={REPO_URL} target="_blank" rel="noopener noreferrer">
<IconButton bordered icon={<GithubIcon />} shadow />
</a>
<div style={{ flex: 1, textAlign: "center" }}>NextChat Artifact</div>
<ArtifactShareButton id={id} getCode={() => code} fileName={fileName} />
</div>
{loading && <Loading />}
{code && (
<HTMLPreview
code={code}
autoHeight={false}
height={height - 36}
onLoad={(title) => {
setFileName(title as string);
setLoading(false);
}}
/>
)}
</div>
);
}
13 changes: 13 additions & 0 deletions app/components/home.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 />,
});
Expand Down Expand Up @@ -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();
Expand All @@ -135,6 +140,14 @@ function Screen() {
loadAsyncGoogleFont();
}, []);

if (isArtifact) {
return (
<Routes>
<Route path="/artifact/:id" element={<Artifact />} />
</Routes>
);
}

return (
<div
className={
Expand Down
32 changes: 27 additions & 5 deletions app/components/markdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import LoadingIcon from "../icons/three-dots.svg";
import React from "react";
import { useDebouncedCallback } from "use-debounce";
import { showImageModal } from "./ui-lib";
import { ArtifactShareButton, HTMLPreview } from "./artifact";

export function Mermaid(props: { code: string }) {
const ref = useRef<HTMLDivElement>(null);
Expand Down Expand Up @@ -64,25 +65,27 @@ 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 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);
}
lloydzhou marked this conversation as resolved.
Show resolved Hide resolved
}, 600);

useEffect(() => {
setTimeout(renderMermaid, 1);
setTimeout(renderArtifacts, 1);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [refText]);

return (
<>
{mermaidCode.length > 0 && (
<Mermaid code={mermaidCode} key={mermaidCode} />
)}
<pre ref={ref}>
<span
className="copy-code-button"
Expand All @@ -95,6 +98,25 @@ export function PreCode(props: { children: any }) {
></span>
{props.children}
</pre>
{mermaidCode.length > 0 && (
<Mermaid code={mermaidCode} key={mermaidCode} />
)}
{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>
)}
</>
);
}
Expand Down
4 changes: 4 additions & 0 deletions app/config/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading
Loading