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 all 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
73 changes: 73 additions & 0 deletions app/api/artifacts/route.ts
Original file line number Diff line number Diff line change
@@ -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";
31 changes: 31 additions & 0 deletions app/components/artifacts.module.scss
Original file line number Diff line number Diff line change
@@ -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 changes: 234 additions & 0 deletions app/components/artifacts.tsx
Original file line number Diff line number Diff line change
@@ -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}
/>
);
}
Comment on lines +18 to +84
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider using useCallback and handling potential XSS vulnerabilities.

  1. Use useCallback to memoize the handleOnLoad function.
  2. Ensure that the HTML code passed to the iframe is sanitized to prevent XSS attacks.
+ import { useCallback } from "react";

-  const handleOnLoad = () => {
+  const handleOnLoad = useCallback(() => {
     if (props?.onLoad) {
       props.onLoad(title);
     }
-  };
+  }, [props?.onLoad, title]);

+  const sanitizedCode = sanitizeHTML(props.code); // Implement sanitizeHTML function to sanitize the HTML code

-  return props.code + script;
+  return sanitizedCode + script;
Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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}
/>
);
}
import { useCallback } from "react";
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);
}
const sanitizedCode = sanitizeHTML(props.code); // Implement sanitizeHTML function to sanitize the HTML code
return sanitizedCode + script;
}, [props.code]);
const handleOnLoad = useCallback(() => {
if (props?.onLoad) {
props.onLoad(title);
}
}, [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>
)}
</>
);
Comment on lines +86 to +179
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Improve error handling and use useCallback.

  1. Improve error handling in the upload function by providing more specific error messages.
  2. Use useCallback to memoize the upload function.
+ import { useCallback } from "react";

-  const upload = (code: string) =>
+  const upload = useCallback((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("Failed to upload code");
           })
           .catch((e) => {
             showToast(Locale.Export.Artifacts.Error);
+            console.error("Upload error:", e);
           });
+  }, [id]);
Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
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>
)}
</>
);
import { useCallback } from "react";
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 = useCallback((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("Failed to upload code");
})
.catch((e) => {
showToast(Locale.Export.Artifacts.Error);
console.error("Upload error:", e);
});
}, [id]);
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>
);
}
Comment on lines +182 to +234
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Improve error handling and use useCallback.

  1. Improve error handling in the useEffect hook by providing more specific error messages.
  2. Use useCallback to memoize the setCode function.
+ import { useCallback } from "react";

-  useEffect(() => {
+  useEffect(() => {
     if (id) {
       fetch(`${ApiPath.Artifacts}?id=${id}`)
         .then((res) => {
           if (res.status > 300) {
             throw Error("Cannot get content");
           }
           return res;
         })
         .then((res) => res.text())
         .then(setCode)
         .catch((e) => {
           showToast(Locale.Export.Artifacts.Error);
+          console.error("Fetch error:", e);
         });
     }
-  }, [id]);
+  }, [id, setCode]);

+  const memoizedSetCode = useCallback((code: string) => {
+    setCode(code);
+  }, [setCode]);

Committable suggestion was skipped due to low confidence.

Loading
Loading