diff --git a/src/interfaces/coral_web/package-lock.json b/src/interfaces/coral_web/package-lock.json index 52383f8981..8e83dc0e7f 100644 --- a/src/interfaces/coral_web/package-lock.json +++ b/src/interfaces/coral_web/package-lock.json @@ -52,6 +52,7 @@ "react-syntax-highlighter": "^15.5.0", "rehype-highlight": "^7.0.0", "rehype-katex": "^7.0.0", + "rehype-raw": "^7.0.0", "remark": "^15.0.1", "remark-directive": "^3.0.0", "remark-gfm": "^4.0.0", @@ -5852,6 +5853,30 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/hast-util-raw": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/hast-util-raw/-/hast-util-raw-9.0.3.tgz", + "integrity": "sha512-ICWvVOF2fq4+7CMmtCPD5CM4QKjPbHpPotE6+8tDooV0ZuyJVUzHsrNX+O5NaRbieTf0F7FfeBOMAwi6Td0+yQ==", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "@ungap/structured-clone": "^1.0.0", + "hast-util-from-parse5": "^8.0.0", + "hast-util-to-parse5": "^8.0.0", + "html-void-elements": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "parse5": "^7.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0", + "web-namespaces": "^2.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/hast-util-to-jsx-runtime": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.0.tgz", @@ -5878,6 +5903,24 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/hast-util-to-parse5": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/hast-util-to-parse5/-/hast-util-to-parse5-8.0.0.tgz", + "integrity": "sha512-3KKrV5ZVI8if87DVSi1vDeByYrkGzg4mEfeu4alwgmmIeARiBLKCZS2uw5Gb6nU9x9Yufyj3iudm6i7nl52PFw==", + "dependencies": { + "@types/hast": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "property-information": "^6.0.0", + "space-separated-tokens": "^2.0.0", + "web-namespaces": "^2.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/hast-util-to-text": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/hast-util-to-text/-/hast-util-to-text-4.0.2.tgz", @@ -5989,6 +6032,15 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/html-void-elements": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz", + "integrity": "sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, "node_modules/ignore": { "version": "5.3.1", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.1.tgz", @@ -9216,6 +9268,20 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/rehype-raw": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/rehype-raw/-/rehype-raw-7.0.0.tgz", + "integrity": "sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-raw": "^9.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, "node_modules/remark": { "version": "15.0.1", "resolved": "https://registry.npmjs.org/remark/-/remark-15.0.1.tgz", diff --git a/src/interfaces/coral_web/package.json b/src/interfaces/coral_web/package.json index 0849747a40..0610b129e6 100644 --- a/src/interfaces/coral_web/package.json +++ b/src/interfaces/coral_web/package.json @@ -59,6 +59,7 @@ "react-syntax-highlighter": "^15.5.0", "rehype-highlight": "^7.0.0", "rehype-katex": "^7.0.0", + "rehype-raw": "^7.0.0", "remark": "^15.0.1", "remark-directive": "^3.0.0", "remark-gfm": "^4.0.0", diff --git a/src/interfaces/coral_web/src/components/MessageContent.tsx b/src/interfaces/coral_web/src/components/MessageContent.tsx index 953858b9ca..433b1fefdf 100644 --- a/src/interfaces/coral_web/src/components/MessageContent.tsx +++ b/src/interfaces/coral_web/src/components/MessageContent.tsx @@ -12,10 +12,12 @@ import { MessageType, isAbortedMessage, isErroredMessage, + isFulfilledMessage, isFulfilledOrTypingMessage, isLoadingMessage, } from '@/types/message'; import { cn } from '@/utils'; +import { replaceCodeBlockWithIframe } from '@/utils/preview'; type Props = { isLast: boolean; @@ -100,13 +102,18 @@ export const MessageContent: React.FC = ({ isLast, message, onRetry }) => } else { const hasCitations = isTypingOrFulfilledMessage && message.citations && message.citations.length > 0; + let md = message.text; + if (isFulfilledMessage(message)) { + // replace the code block with an iframe + md = replaceCodeBlockWithIframe(message.originalText); + } content = ( <> diff --git a/src/interfaces/coral_web/src/components/Shared/Markdown/tags/Iframe.tsx b/src/interfaces/coral_web/src/components/Shared/Markdown/tags/Iframe.tsx new file mode 100644 index 0000000000..2ec1391b86 --- /dev/null +++ b/src/interfaces/coral_web/src/components/Shared/Markdown/tags/Iframe.tsx @@ -0,0 +1,99 @@ +import type { Component, ExtraProps } from 'hast-util-to-jsx-runtime/lib/components'; +import { useEffect } from 'react'; +import { useRef } from 'react'; +import { useState } from 'react'; +import { ComponentPropsWithoutRef } from 'react'; +import SyntaxHighlighter from 'react-syntax-highlighter/dist/cjs/prism-light'; +import theme from 'react-syntax-highlighter/dist/cjs/styles/prism/atom-dark'; + +import { Text } from '@/components/Shared/Text'; +import { cn } from '@/utils'; + +const MIN_HEIGHT = 600; + +/** + * Renders an iframe with a lazy loading mechanism. + * + * The iframe is initially rendered with a `data-src` attribute that points to a blob URL. + * When the iframe is loaded, the `data-src` attribute is replaced with the actual source URL. + * The height of the iframe is adjusted to fit the content. + */ +export const Iframe: Component & { 'data-src': string }> = ( + props +) => { + const iframeRef = useRef(null); + const [option, setOption] = useState<'live' | 'code'>('live'); + const [code, setCode] = useState(''); + const src = props[`data-src`]; + + const onload = (e: any) => { + const iframe = e.target; + const root = iframe.contentDocument.documentElement; + const height = (root.offsetHeight || 0) + 16 + 4; + iframe.style.height = Math.min(height, MIN_HEIGHT) + 'px'; + }; + + useEffect(() => { + const iframe = iframeRef.current; + if (iframe) { + iframe.addEventListener('load', onload); + return () => { + iframe.removeEventListener('load', onload); + }; + } + }, []); + + useEffect(() => { + // read the blob URL (src) and extract the text into the code state + if (src) { + fetch(src) + .then((res) => res.text()) + .then((text) => { + setCode(text.trim()); + }); + } + }, [src]); + + return ( +
+
+ `; + + content = content.replace(replacingRegex, iframe); + + return content; +};