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 27 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/artifact/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";
30 changes: 30 additions & 0 deletions app/components/artifact.module.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
.artifact {
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);
}
}

.artifact-iframe {
width: 100%;
border: var(--border-in-light);
border-radius: 6px;
}
230 changes: 230 additions & 0 deletions app/components/artifact.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,230 @@
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 "./artifact.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]);
lloydzhou marked this conversation as resolved.
Show resolved Hide resolved

const handleOnLoad = () => {
if (props?.onLoad) {
props.onLoad(title);
}
};

return (
<iframe
className={styles["artifact-iframe"]}
id={frameId.current}
ref={ref}
sandbox="allow-forms allow-modals allow-scripts"
style={{ height }}
srcDoc={srcDoc}
onLoad={handleOnLoad}
/>
);
}

export function ArtifactShareButton({
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.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={loading ? <LoadingButtonIcon /> : <ExportIcon />}
bordered
title={Locale.Export.Artifact.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.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("");

useEffect(() => {
if (id) {
fetch(`${ApiPath.Artifact}?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.Artifact.Error);
});
lloydzhou marked this conversation as resolved.
Show resolved Hide resolved
}
}, [id]);

return (
<div className={styles["artifact"]}>
<div className={styles["artifact-header"]}>
<a href={REPO_URL} target="_blank" rel="noopener noreferrer">
<IconButton bordered icon={<GithubIcon />} shadow />
</a>
<div className={styles["artifact-title"]}>NextChat Artifact</div>
<ArtifactShareButton id={id} getCode={() => code} fileName={fileName} />
</div>
<div className={styles["artifact-content"]}>
{loading && <Loading />}
{code && (
<HTMLPreview
code={code}
autoHeight={false}
height={"100%"}
onLoad={(title) => {
setFileName(title as string);
setLoading(false);
}}
/>
)}
</div>
</div>
);
}
36 changes: 30 additions & 6 deletions app/components/chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ import {
REQUEST_TIMEOUT_MS,
UNFINISHED_INPUT,
ServiceProvider,
Plugin,
} from "../constant";
import { Avatar } from "./emoji";
import { ContextPrompts, MaskAvatar, MaskConfig } from "./mask";
Expand Down Expand Up @@ -477,6 +478,7 @@ export function ChatActions(props: {
return model?.displayName ?? "";
}, [models, currentModel, currentProviderName]);
const [showModelSelector, setShowModelSelector] = useState(false);
const [showPluginSelector, setShowPluginSelector] = useState(false);
const [showUploadImage, setShowUploadImage] = useState(false);

useEffect(() => {
Expand Down Expand Up @@ -588,12 +590,6 @@ export function ChatActions(props: {
icon={<RobotIcon />}
/>

<ChatAction
onClick={() => showToast(Locale.WIP)}
text={Locale.Plugin.Name}
icon={<PluginIcon />}
/>

{showModelSelector && (
<Selector
defaultSelectedValue={`${currentModel}@${currentProviderName}`}
Expand Down Expand Up @@ -627,6 +623,34 @@ export function ChatActions(props: {
}}
/>
)}

<ChatAction
onClick={() => setShowPluginSelector(true)}
text={Locale.Plugin.Name}
icon={<PluginIcon />}
/>
{showPluginSelector && (
<Selector
multiple
defaultSelectedValue={chatStore.currentSession().mask?.plugin}
items={[
{
title: Locale.Plugin.Artifact,
value: Plugin.Artifact,
},
]}
onClose={() => setShowPluginSelector(false)}
onSelection={(s) => {
const plugin = s[0];
chatStore.updateCurrentSession((session) => {
session.mask.plugin = s;
});
if (plugin) {
showToast(plugin);
}
}}
/>
)}
</div>
);
}
Expand Down
Loading
Loading