Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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
6 changes: 6 additions & 0 deletions app/components/artifacts/preview.module.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
.preview-iframe {
width: 100%;
border: none;
background-color: white;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
125 changes: 125 additions & 0 deletions app/components/artifacts/preview.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import { useEffect, useRef, useMemo, forwardRef, useImperativeHandle, useState } from "react";
import { nanoid } from "nanoid";
import styles from "./preview.module.scss";

type PreviewProps = {
code: string;
viewMode: "html" | "svg" | "react";
height?: number | string;
};

export type PreviewHandler = {
reload: () => void;
};

export const Preview = forwardRef<PreviewHandler, PreviewProps>(
function Preview(props, ref) {
const iframeRef = useRef<HTMLIFrameElement>(null);
const [frameId, setFrameId] = useState<string>(nanoid());

useImperativeHandle(ref, () => ({
reload: () => {
setFrameId(nanoid());
},
}));

const srcDoc = useMemo(() => {
// 添加消息监听器,用于处理iframe内部的事件
const script = `<script>
window.addEventListener("DOMContentLoaded", () => {
// 发送iframe加载完成的消息
parent.postMessage({ type: "iframe-loaded", id: '${frameId}' }, '*');
});
</script>`;

let fullCode = props.code;

// 根据viewMode处理代码
if (props.viewMode === "svg") {
// 为SVG代码添加HTML包装
fullCode = `<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>SVG Preview</title>
<style>
body {
margin: 0;
padding: 20px;
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
background-color: #f5f5f5;
}
</style>
</head>
<body>
${props.code}
</body>
</html>`;
} else if (props.viewMode === "react") {
// 为React代码添加HTML包装和React依赖
fullCode = `<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>React Preview</title>
<script crossorigin src="https://unpkg.com/react@18/umd/react.development.js"></script>
<script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.development.js"></script>
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
<style>
body {
margin: 0;
padding: 20px;
min-height: 100vh;
background-color: #f5f5f5;
}
</style>
</head>
<body>
<div id="root"></div>
<script type="text/babel">
${props.code}
ReactDOM.render(<App />, document.getElementById('root'));
</script>
</body>
</html>`;
} else {
// HTML代码直接使用
if (!fullCode.includes("<!DOCTYPE html>")) {
fullCode = `<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>HTML Preview</title>
</head>
<body>
${fullCode}
</body>
</html>`;
}
}

// 将脚本添加到HTML代码中
if (fullCode.includes("</head>")) {
fullCode = fullCode.replace("</head>", `${script}</head>`);
} else {
fullCode = script + fullCode;
}

return fullCode;
}, [props.code, props.viewMode, frameId]);

return (
<iframe
className={styles["preview-iframe"]}
key={frameId}
ref={iframeRef}
sandbox="allow-forms allow-modals allow-scripts allow-same-origin"
style={{ height: props.height || "100%" }}
srcDoc={srcDoc}
/>
);
},
);
61 changes: 49 additions & 12 deletions app/components/chat.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,9 @@
import { useDebouncedCallback } from "use-debounce";
import React, {
Fragment,
RefObject,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import React, { Fragment, RefObject, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useArtifactsStore } from "../store/artifacts";
import { Preview } from "./artifacts/preview";
import { IconButton } from "./button";
import CloseIcon from "../icons/close.svg";

import SendWhiteIcon from "../icons/send-white.svg";
import BrainIcon from "../icons/brain.svg";
Expand Down Expand Up @@ -994,6 +990,11 @@ function _Chat() {
const config = useAppConfig();
const fontSize = config.fontSize;
const fontFamily = config.fontFamily;
const [isMounted, setIsMounted] = useState(false);

useEffect(() => {
setIsMounted(true);
}, []);

const [showExport, setShowExport] = useState(false);

Expand Down Expand Up @@ -1769,7 +1770,9 @@ function _Chat() {
/>
</div>
<div className={styles["chat-main"]}>
<div className={styles["chat-body-container"]}>
{/* 左边聊天内容 */}
<div style={{ flex: artifactsStore.isOpen ? 1 : 2, overflow: "auto" }}>
<div className={styles["chat-body-container"]} style={{ height: "100%" }}>
<div
className={styles["chat-body"]}
ref={scrollRef}
Expand Down Expand Up @@ -2126,12 +2129,46 @@ function _Chat() {
</label>
</div>
</div>
{/* 右边预览内容 */}
{isMounted && artifactsStore.isOpen && (
<div style={{
flex: 1,
borderLeft: "1px solid #e5e7eb",
overflow: "auto",
position: "relative",
backgroundColor: "white"
}}>
{/* 预览头部 */}
<div style={{
padding: "10px 20px",
borderBottom: "1px solid #e5e7eb",
display: "flex",
justifyContent: "space-between",
alignItems: "center"
}}>
<h3 style={{ margin: 0, fontSize: "16px", fontWeight: 600 }}>👁️ 预览</h3>
<IconButton
icon={<CloseIcon />}
bordered
onClick={() => artifactsStore.close()}
/>
</div>
{/* 预览内容 */}
<div style={{ padding: "20px", height: "calc(100% - 50px)" }}>
<Preview
code={artifactsStore.codeContent}
viewMode={artifactsStore.viewMode}
height="100%"
/>
</div>
</div>
)}
<div
className={clsx(styles["chat-side-panel"], {
[styles["mobile"]]: isMobileScreen,
[styles["chat-side-panel-show"]]: showChatSidePanel,
})}
>
})
}>
{showChatSidePanel && (
<RealtimeChat
onClose={() => {
Expand Down
58 changes: 42 additions & 16 deletions app/components/markdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
HTMLPreviewHandler,
} from "./artifacts";
import { useChatStore } from "../store";
import { useArtifactsStore } from "../store/artifacts";
import { IconButton } from "./button";

import { useAppConfig } from "../store/config";
Expand Down Expand Up @@ -79,6 +80,7 @@ export function PreCode(props: { children: any }) {
const { height } = useWindowSize();
const chatStore = useChatStore();
const session = chatStore.currentSession();
const artifactsStore = useArtifactsStore();

const renderArtifacts = useDebouncedCallback(() => {
if (!ref.current) return;
Expand All @@ -87,9 +89,15 @@ export function PreCode(props: { children: any }) {
setMermaidCode((mermaidDom as HTMLElement).innerText);
}
const htmlDom = ref.current.querySelector("code.language-html");
const svgDom = ref.current.querySelector("code.language-svg");
const reactDom = ref.current.querySelector("code.language-react");
const refText = ref.current.querySelector("code")?.innerText;
if (htmlDom) {
setHtmlCode((htmlDom as HTMLElement).innerText);
} else if (svgDom) {
setHtmlCode((svgDom as HTMLElement).innerText);
} else if (reactDom) {
setHtmlCode((reactDom as HTMLElement).innerText);
} else if (
refText?.startsWith("<!DOCTYPE") ||
refText?.startsWith("<svg") ||
Expand Down Expand Up @@ -149,25 +157,43 @@ export function PreCode(props: { children: any }) {
<Mermaid code={mermaidCode} key={mermaidCode} />
)}
{htmlCode.length > 0 && enableArtifacts && (
<FullScreen className="no-dark html" right={70}>
<ArtifactsShareButton
style={{ position: "absolute", right: 20, top: 10 }}
getCode={() => htmlCode}
/>
<>
<IconButton
style={{ position: "absolute", right: 120, top: 10 }}
style={{ marginLeft: "10px", marginTop: "10px" }}
bordered
icon={<ReloadButtonIcon />}
shadow
onClick={() => previewRef.current?.reload()}
/>
<HTMLPreview
ref={previewRef}
code={htmlCode}
autoHeight={!document.fullscreenElement}
height={!document.fullscreenElement ? 600 : height}
text="👁️ 预览"
onClick={() => {
// 检测代码类型
let viewMode: "html" | "svg" | "react" = "html";
const codeElement = ref.current?.querySelector("code");
if (codeElement?.className.includes("language-svg")) {
viewMode = "svg";
} else if (codeElement?.className.includes("language-react")) {
viewMode = "react";
}
artifactsStore.open(htmlCode, viewMode);
}}
/>
</FullScreen>
<FullScreen className="no-dark html" right={70}>
<ArtifactsShareButton
style={{ position: "absolute", right: 20, top: 10 }}
getCode={() => htmlCode}
/>
<IconButton
style={{ position: "absolute", right: 120, top: 10 }}
bordered
icon={<ReloadButtonIcon />}
shadow
onClick={() => previewRef.current?.reload()}
/>
<HTMLPreview
ref={previewRef}
code={htmlCode}
autoHeight={!document.fullscreenElement}
height={!document.fullscreenElement ? 600 : height}
/>
</FullScreen>
</>
)}
</>
);
Expand Down
1 change: 1 addition & 0 deletions app/constant.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ export enum StoreKey {
Sync = "sync",
SdList = "sd-list",
Mcp = "mcp-store",
Artifacts = "artifacts-store",
}

export const DEFAULT_SIDEBAR_WIDTH = 300;
Expand Down
30 changes: 30 additions & 0 deletions app/store/artifacts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { createPersistStore } from "../utils/store";
import { StoreKey } from "../constant";

export type ViewMode = "html" | "svg" | "react";

export interface ArtifactsState {
isOpen: boolean;
codeContent: string;
viewMode: ViewMode;
}

const DEFAULT_STATE: ArtifactsState = {
isOpen: false,
codeContent: "",
viewMode: "html",
};

export const useArtifactsStore = createPersistStore(
DEFAULT_STATE,
(set) => ({
open: (codeContent: string, viewMode: ViewMode) =>
set({ isOpen: true, codeContent, viewMode }),
close: () => set({ isOpen: false }),
setCodeContent: (codeContent: string) => set({ codeContent }),
setViewMode: (viewMode: ViewMode) => set({ viewMode }),
}),
{
name: StoreKey.Artifacts,
},
);
Loading