diff --git a/apps/agents/src/open-canvas/nodes/generate-artifact/utils.ts b/apps/agents/src/open-canvas/nodes/generate-artifact/utils.ts index 0595dc01..9216ac35 100644 --- a/apps/agents/src/open-canvas/nodes/generate-artifact/utils.ts +++ b/apps/agents/src/open-canvas/nodes/generate-artifact/utils.ts @@ -31,6 +31,7 @@ export const createArtifactContent = ( title: toolCall?.title, code: toolCall?.artifact, language: toolCall?.language as ProgrammingLanguageOptions, + isValidReact: toolCall?.isValidReact }; } diff --git a/apps/agents/src/open-canvas/nodes/rewrite-artifact/schemas.ts b/apps/agents/src/open-canvas/nodes/rewrite-artifact/schemas.ts index 19279d3a..a0747171 100644 --- a/apps/agents/src/open-canvas/nodes/rewrite-artifact/schemas.ts +++ b/apps/agents/src/open-canvas/nodes/rewrite-artifact/schemas.ts @@ -22,5 +22,11 @@ export const OPTIONALLY_UPDATE_ARTIFACT_META_SCHEMA = z .describe( "The language of the code artifact. This should be populated with the programming language if the user is requesting code to be written, or 'other', in all other cases." ), + isValidReact: z + .boolean() + .optional() + .describe( + "Whether or not the generated code is valid React code. Only populate this field if generating code." + ), }) .describe("Update the artifact meta information, if necessary."); diff --git a/apps/agents/src/open-canvas/nodes/rewrite-artifact/utils.ts b/apps/agents/src/open-canvas/nodes/rewrite-artifact/utils.ts index 736ac0bb..90ceea1e 100644 --- a/apps/agents/src/open-canvas/nodes/rewrite-artifact/utils.ts +++ b/apps/agents/src/open-canvas/nodes/rewrite-artifact/utils.ts @@ -110,6 +110,7 @@ export const createNewArtifactContent = ({ currentArtifactContent ) as ProgrammingLanguageOptions, code: newContent, + isValidReact: artifactMetaToolCall.isValidReact }; } diff --git a/apps/agents/src/open-canvas/nodes/rewriteCodeArtifactTheme.ts b/apps/agents/src/open-canvas/nodes/rewriteCodeArtifactTheme.ts index ef412dca..6048208c 100644 --- a/apps/agents/src/open-canvas/nodes/rewriteCodeArtifactTheme.ts +++ b/apps/agents/src/open-canvas/nodes/rewriteCodeArtifactTheme.ts @@ -112,6 +112,7 @@ export const rewriteCodeArtifactTheme = async ( // Ensure the new artifact's language is updated, if necessary language: state.portLanguage || currentArtifactContent.language, code: artifactContentText, + isValidReact: currentArtifactContent.isValidReact }; const newArtifact: ArtifactV3 = { diff --git a/apps/agents/src/open-canvas/prompts.ts b/apps/agents/src/open-canvas/prompts.ts index f594506b..a0d7eec1 100644 --- a/apps/agents/src/open-canvas/prompts.ts +++ b/apps/agents/src/open-canvas/prompts.ts @@ -1,4 +1,7 @@ -const DEFAULT_CODE_PROMPT_RULES = `- Do NOT include triple backticks when generating code. The code should be in plain text.`; +const DEFAULT_CODE_PROMPT_RULES = ` +- If writing React code with style information, remember to put all CSS in a style element. +- Do NOT include triple backticks when generating code. The code should be in plain text. +`; const APP_CONTEXT = ` diff --git a/apps/web/package.json b/apps/web/package.json index 97c7699a..f8d5d493 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -91,6 +91,7 @@ "react-dom": "^18", "react-icons": "^5.3.0", "react-json-view": "^1.21.3", + "react-live": "^4.1.8", "react-markdown": "^9.0.1", "react-resizable-panels": "^2.1.7", "react-syntax-highlighter": "^15.5.0", diff --git a/apps/web/src/components/artifacts/CodePreviewer.tsx b/apps/web/src/components/artifacts/CodePreviewer.tsx new file mode 100644 index 00000000..a605fd8b --- /dev/null +++ b/apps/web/src/components/artifacts/CodePreviewer.tsx @@ -0,0 +1,39 @@ +import { ArtifactCodeV3 } from "@opencanvas/shared/types"; +import { LiveProvider, LivePreview, LiveError } from "react-live"; +import { cn } from "@/lib/utils"; +import { getPreviewCode } from "@/lib/get_preview_code"; +import { motion } from "framer-motion"; + +export interface CodePreviewerProps { + isExpanded: boolean; + artifact: ArtifactCodeV3; +} + +export function CodePreviewer({ isExpanded, artifact }: CodePreviewerProps) { + const cleanedCode = getPreviewCode(artifact.code); + + return ( + +
+ {isExpanded && ( + + + + + )} +
+
+ ); +} diff --git a/apps/web/src/components/artifacts/CodeRenderer.tsx b/apps/web/src/components/artifacts/CodeRenderer.tsx index f0220d33..2e21f61b 100644 --- a/apps/web/src/components/artifacts/CodeRenderer.tsx +++ b/apps/web/src/components/artifacts/CodeRenderer.tsx @@ -1,5 +1,11 @@ import { ArtifactCodeV3 } from "@opencanvas/shared/types"; -import React, { MutableRefObject, useEffect } from "react"; +import React, { + Dispatch, + MutableRefObject, + SetStateAction, + useEffect, + useState, +} from "react"; import CodeMirror, { EditorView } from "@uiw/react-codemirror"; import { javascript } from "@codemirror/lang-javascript"; import { cpp } from "@codemirror/lang-cpp"; @@ -19,7 +25,10 @@ import { cn } from "@/lib/utils"; import { CopyText } from "./components/CopyText"; import { getArtifactContent } from "@opencanvas/shared/utils/artifacts"; import { useGraphContext } from "@/contexts/GraphContext"; - +import { motion } from "framer-motion"; +import { TooltipIconButton } from "../ui/assistant-ui/tooltip-icon-button"; +import { PanelRightOpen, PanelRightClose } from "lucide-react"; +import { CodePreviewer } from "./CodePreviewer"; export interface CodeRendererProps { editorRef: MutableRefObject; isHovering: boolean; @@ -58,6 +67,46 @@ const getLanguageExtension = (language: string) => { } }; +export interface ToggleCodePreviewProps { + isCodePreviewVisible: boolean; + setIsCodePreviewVisible: Dispatch>; + codePreviewDisabled: boolean; + isStreaming: boolean; +} + +function ToggleCodePreview({ + isCodePreviewVisible, + setIsCodePreviewVisible, + codePreviewDisabled, +}: ToggleCodePreviewProps) { + const tooltipContent = codePreviewDisabled + ? "Code preview is only supported for valid React code" + : `${isCodePreviewVisible ? "Hide" : "Show"} code preview`; + + return ( + + setIsCodePreviewVisible((p) => !p)} + > + {isCodePreviewVisible ? ( + + ) : ( + + )} + + + ); +} + export function CodeRendererComponent(props: Readonly) { const { graphData } = useGraphContext(); const { @@ -68,6 +117,7 @@ export function CodeRendererComponent(props: Readonly) { setArtifactContent, setUpdateRenderedArtifactRequired, } = graphData; + const [isCodePreviewVisible, setIsCodePreviewVisible] = useState(false); useEffect(() => { if (updateRenderedArtifactRequired) { @@ -75,6 +125,12 @@ export function CodeRendererComponent(props: Readonly) { } }, [updateRenderedArtifactRequired]); + useEffect(() => { + if (isStreaming) { + setIsCodePreviewVisible(false); + } + }, [isStreaming]); + if (!artifact) { return null; } @@ -89,7 +145,7 @@ export function CodeRendererComponent(props: Readonly) { const isEditable = !isStreaming; return ( -
+ - {props.isHovering && ( -
- -
- )} - setArtifactContent(artifactContent.index, c)} - onCreateEditor={(view) => { - props.editorRef.current = view; + -
+ transition={{ type: "spring", stiffness: 300, damping: 30 }} + > + {props.isHovering && ( +
+ + {!isStreaming && } +
+ )} + setArtifactContent(artifactContent.index, c)} + onCreateEditor={(view) => { + props.editorRef.current = view; + }} + /> + + {!isStreaming && artifactContent.isValidReact && ( + + )} + ); } diff --git a/apps/web/src/components/ui/assistant-ui/tooltip-icon-button.tsx b/apps/web/src/components/ui/assistant-ui/tooltip-icon-button.tsx index b2c41cd8..3fdf62da 100644 --- a/apps/web/src/components/ui/assistant-ui/tooltip-icon-button.tsx +++ b/apps/web/src/components/ui/assistant-ui/tooltip-icon-button.tsx @@ -32,16 +32,18 @@ export const TooltipIconButton = forwardRef< - + + + {tooltip} diff --git a/apps/web/src/contexts/GraphContext.tsx b/apps/web/src/contexts/GraphContext.tsx index e08fa6fc..65a1667a 100644 --- a/apps/web/src/contexts/GraphContext.tsx +++ b/apps/web/src/contexts/GraphContext.tsx @@ -831,6 +831,7 @@ export function GraphProvider({ children }: { children: ReactNode }) { type: artifactType, title: prevCurrentContent.title, language: artifactLanguage, + isValidReact: isArtifactCodeContent(artifact) ? artifact.isValidReact : undefined }, prevCurrentContent, newArtifactIndex, @@ -1154,6 +1155,7 @@ export function GraphProvider({ children }: { children: ReactNode }) { type: artifactType, title: prevCurrentContent.title, language: artifactLanguage, + isValidReact: isArtifactCodeContent(artifact) ? artifact.isValidReact : undefined }, prevCurrentContent, newArtifactIndex, diff --git a/apps/web/src/contexts/utils.ts b/apps/web/src/contexts/utils.ts index 17c28a86..3c183b65 100644 --- a/apps/web/src/contexts/utils.ts +++ b/apps/web/src/contexts/utils.ts @@ -94,6 +94,7 @@ export const createNewGeneratedArtifactFromTool = ( title: artifactTool.title || "", code: artifactTool.artifact || "", language: artifactTool.language as ProgrammingLanguageOptions, + isValidReact: artifactTool.isValidReact }; } }; @@ -272,6 +273,7 @@ export const updateRewrittenArtifact = ({ index: currentIndex, language: artifactLanguage as ProgrammingLanguageOptions, code: newArtifactContent, + isValidReact: rewriteArtifactMeta.isValidReact }, ]; } else { diff --git a/apps/web/src/lib/get_preview_code.ts b/apps/web/src/lib/get_preview_code.ts new file mode 100644 index 00000000..7174d0fc --- /dev/null +++ b/apps/web/src/lib/get_preview_code.ts @@ -0,0 +1,37 @@ +const reactImportRegex = + /^import\s+(React(?:,\s*\{[^}]+\})?|\{[^}]+\})\s+from\s+['"]react['"];?/gm; + +export const getPreviewCode = (code: string): string => { + const matches = Array.from(code.matchAll(reactImportRegex)); + const namedBindings = new Set(); + + // Strip any import statements from the generated code + for (const match of matches) { + const imported = match[1]; + + if (imported.includes("{")) { + const names = imported + .replace(/React,?/, "") + .replace(/[{}]/g, "") + .split(",") + .map((name) => name.trim()); + + names.forEach((n) => namedBindings.add(n)); + } + } + + let transformed = code.replace(reactImportRegex, "").trim(); + + namedBindings.forEach((name) => { + const usageRegex = new RegExp(`\\b${name}\\b`, "g"); + transformed = transformed.replace(usageRegex, `React.${name}`); + }); + + // Replace "export default X" with "render(X)" to display + transformed = transformed.replace( + /export\s+default\s+([a-zA-Z_$][\w$]*)\s*;/, + "render($1);" + ); + + return transformed; +}; diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index ed58463d..64659249 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -54,6 +54,7 @@ export interface ArtifactToolResponse { title?: string; language?: string; type?: string; + isValidReact?: boolean; } export type RewriteArtifactMetaToolResponse = @@ -66,6 +67,7 @@ export type RewriteArtifactMetaToolResponse = type: "code"; title: string; language: ProgrammingLanguageOptions; + isValidReact?: boolean }; export type LanguageOptions = @@ -116,6 +118,7 @@ export interface ArtifactCodeV3 { title: string; language: ProgrammingLanguageOptions; code: string; + isValidReact?: boolean; } export interface ArtifactV3 { diff --git a/yarn.lock b/yarn.lock index f5653816..ee2c2e1c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2345,7 +2345,7 @@ resolved "https://registry.yarnpkg.com/@types/phoenix/-/phoenix-1.6.6.tgz#3c1ab53fd5a23634b8e37ea72ccacbf07fbc7816" integrity sha512-PIzZZlEppgrpoT2QgbnDU+MMzuR6BbCjllj0bM70lWoejMeNJAxCchxnv7J3XFkI8MpygtRpzXrIlmWUBclP5A== -"@types/prismjs@^1.0.0": +"@types/prismjs@^1.0.0", "@types/prismjs@^1.26.0": version "1.26.5" resolved "https://registry.yarnpkg.com/@types/prismjs/-/prismjs-1.26.5.tgz#72499abbb4c4ec9982446509d2f14fb8483869d6" integrity sha512-AUZTa7hQ2KY5L7AmtSiqxlhWxb4ina0yd8hNbl4TWuqnv/pFP0nDMb3YrfSBf4hJVGLh2YEIBfKaBW/9UEl6IQ== @@ -3369,7 +3369,7 @@ client-only@0.0.1: resolved "https://registry.yarnpkg.com/client-only/-/client-only-0.0.1.tgz#38bba5d403c41ab150bff64a95c85013cf73bca1" integrity sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA== -clsx@^2.1.1: +clsx@^2.0.0, clsx@^2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/clsx/-/clsx-2.1.1.tgz#eed397c9fd8bd882bfb18deab7102049a2f32999" integrity sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA== @@ -7194,6 +7194,14 @@ prettier@^3.3.3: resolved "https://registry.yarnpkg.com/prettier/-/prettier-3.4.2.tgz#a5ce1fb522a588bf2b78ca44c6e6fe5aa5a2b13f" integrity sha512-e9MewbtFo+Fevyuxn/4rrcDAaq0IYxPGLvObpQjiZBMAzB9IGmzlnG9RZy3FFas+eBMu2vA0CszMeduow5dIuQ== +prism-react-renderer@^2.4.0: + version "2.4.1" + resolved "https://registry.yarnpkg.com/prism-react-renderer/-/prism-react-renderer-2.4.1.tgz#ac63b7f78e56c8f2b5e76e823a976d5ede77e35f" + integrity sha512-ey8Ls/+Di31eqzUxC46h8MksNuGx/n0AAC8uKpwFau4RPDYLuE3EXTp8N8G2vX2N7UC/+IXeNUnlWBGGcAG+Ig== + dependencies: + "@types/prismjs" "^1.26.0" + clsx "^2.0.0" + prismjs@^1.27.0: version "1.29.0" resolved "https://registry.yarnpkg.com/prismjs/-/prismjs-1.29.0.tgz#f113555a8fa9b57c35e637bba27509dcf802dd12" @@ -7464,6 +7472,15 @@ react-lifecycles-compat@^3.0.4: resolved "https://registry.yarnpkg.com/react-lifecycles-compat/-/react-lifecycles-compat-3.0.4.tgz#4f1a273afdfc8f3488a8c516bfda78f872352362" integrity sha512-fBASbA6LnOU9dOU2eW7aQ8xmYBSXUIWr+UmF9b1efZBazGNO+rcXT/icdKnYm2pTwcRylVUYwW7H1PHfLekVzA== +react-live@^4.1.8: + version "4.1.8" + resolved "https://registry.yarnpkg.com/react-live/-/react-live-4.1.8.tgz#287fb6c5127c2d89a6fe39380278d95cc8e661b6" + integrity sha512-B2SgNqwPuS2ekqj4lcxi5TibEcjWkdVyYykBEUBshPAPDQ527x2zPEZg560n8egNtAjUpwXFQm7pcXV65aAYmg== + dependencies: + prism-react-renderer "^2.4.0" + sucrase "^3.35.0" + use-editable "^2.3.3" + react-markdown@^9.0.1, react-markdown@^9.0.3, react-markdown@~9.0.1: version "9.0.3" resolved "https://registry.yarnpkg.com/react-markdown/-/react-markdown-9.0.3.tgz#c12bf60dad05e9bf650b86bcc612d80636e8456e" @@ -8872,6 +8889,11 @@ use-composed-ref@^1.3.0: resolved "https://registry.yarnpkg.com/use-composed-ref/-/use-composed-ref-1.4.0.tgz#09e023bf798d005286ad85cd20674bdf5770653b" integrity sha512-djviaxuOOh7wkj0paeO1Q/4wMZ8Zrnag5H6yBvzN7AKKe8beOaED9SF5/ByLqsku8NP4zQqsvM2u3ew/tJK8/w== +use-editable@^2.3.3: + version "2.3.3" + resolved "https://registry.yarnpkg.com/use-editable/-/use-editable-2.3.3.tgz#a292fe9ba4c291cd28d1cc2728c75a5fc8d9a33f" + integrity sha512-7wVD2JbfAFJ3DK0vITvXBdpd9JAz5BcKAAolsnLBuBn6UDDwBGuCIAGvR3yA2BNKm578vAMVHFCWaOcA+BhhiA== + use-isomorphic-layout-effect@^1.1.1: version "1.2.0" resolved "https://registry.yarnpkg.com/use-isomorphic-layout-effect/-/use-isomorphic-layout-effect-1.2.0.tgz#afb292eb284c39219e8cb8d3d62d71999361a21d"