Skip to content

Commit

Permalink
Add foundations for viz
Browse files Browse the repository at this point in the history
  • Loading branch information
flvndvd committed Jul 24, 2024
1 parent 81e856d commit 5c4fdb0
Show file tree
Hide file tree
Showing 9 changed files with 3,308 additions and 910 deletions.
12 changes: 11 additions & 1 deletion viz/.eslintrc.json
Original file line number Diff line number Diff line change
@@ -1,3 +1,13 @@
{
"extends": "next/core-web-vitals"
"extends": [
"next/core-web-vitals",
"eslint:recommended",
"plugin:@typescript-eslint/recommended"
],
"rules": {
"eqeqeq": "error",
"no-unused-vars": "error",
"no-console": "warn",
"@typescript-eslint/no-explicit-any": "error"
}
}
257 changes: 257 additions & 0 deletions viz/app/components/VisualizationWrapper.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,257 @@
"use client";

import { Button, Collapsible, ContentMessage, Spinner } from "@dust-tt/sparkle";
import {
VisualizationRPCCommand,
VisualizationRPCRequest,
} from "@dust-tt/types";
import * as papaparseAll from "papaparse";
import * as reactAll from "react";
import React from "react";
import { useEffect, useMemo, useState } from "react";
import { importCode, Runner } from "react-runner";
import {} from "react-runner";
import * as rechartsAll from "recharts";

function isFileResult(res: unknown): res is { file: File } {
return (
typeof res === "object" &&
res !== null &&
"file" in res &&
res.file instanceof File
);
}

// This is a hook provided to the code generator model to fetch a file from the conversation.
function useFile(actionId: string, fileId: string) {
const [file, setFile] = useState<File | null>(null);
const actionIdParsed = useMemo(() => parseInt(actionId, 10), [actionId]);

useEffect(() => {
if (!fileId) {
return;
}

const getFileContent = async () => {
const getFile = makeIframeMessagePassingFunction<
{ fileId: string },
{ code: string }
>("getFile", actionIdParsed);

const res = await getFile({ fileId });
if (!isFileResult(res)) {
return;
}

const { file } = res;

setFile(file);
};

getFileContent();
}, [actionIdParsed, fileId]);

return file;
}

// This function creates a function that sends a command to the host window with templated Input and Output types.
function makeIframeMessagePassingFunction<Params, Answer>(
methodName: VisualizationRPCCommand,
actionId: number
) {
return (params?: Params) => {
return new Promise<Answer>((resolve, reject) => {
const messageUniqueId = Math.random().toString();
const listener = (event: MessageEvent) => {
if (event.data.messageUniqueId === messageUniqueId) {
if (event.data.error) {
reject(event.data.error);
} else {
resolve(event.data.result);
}
window.removeEventListener("message", listener);
}
};
window.addEventListener("message", listener);
window.top?.postMessage(
{
command: methodName,
messageUniqueId,
actionId,
params,
} satisfies VisualizationRPCRequest,
"*"
);
});
};
}

// This component renders the generated code.
// It gets the generated code via message passing to the host window.
export function VisualizationWrapper({ actionId }: { actionId: string }) {
const [code, setCode] = useState<string | null>(null);
const [errored, setErrored] = useState<Error | null>(null);
const useFileWrapped = (fileId: string) => useFile(actionId, fileId);

useEffect(() => {
// Get the code to execute.
const getCodeToExecute = makeIframeMessagePassingFunction<
{ actionId: string },
{ code: string }
>("getCodeToExecute", parseInt(actionId, 10));

const fetchCode = async () => {
try {
const result = await getCodeToExecute({ actionId });
const regex = /<visualization[^>]*>\s*([\s\S]*?)\s*<\/visualization>/;
let extractedCode: string | null = null;
const match = result.code.match(regex);
if (match && match[1]) {
extractedCode = match[1];
setCode(extractedCode);
} else {
setErrored(new Error("No visualization code found"));
}
} catch (error) {
console.error(error);
}
};

fetchCode();
}, [actionId]);

// This retry function sends the "retry" instruction to the host window, to retry an agent message
// in case the generated code does not work or is not satisfying.
const retry = useMemo(() => {
return makeIframeMessagePassingFunction("retry", parseInt(actionId, 10));
}, [actionId]);

if (errored) {
return <VisualizationError error={errored} retry={() => retry()} />;
}

if (!code) {
return <Spinner variant="color" size="xxl" />;
}

const generatedCodeScope = {
recharts: rechartsAll,
react: reactAll,
papaparse: papaparseAll,
"@dust/react-hooks": { useFile: useFileWrapped },
};

const scope = {
import: {
recharts: rechartsAll,
react: reactAll,
// Here we expose the code generated as a module to be imported by the wrapper code below.
"@dust/generated-code": importCode(code, { import: generatedCodeScope }),
},
};

// This code imports and renders the generated code.
const wrapperCode = `
() => {
import Comp from '@dust/generated-code';
return (<Comp />);
}
`;

return (
<Runner
code={wrapperCode}
scope={scope}
onRendered={(error) => {
if (error) {
setErrored(error);
}
}}
/>
);
}

// This is the component to render when an error occurs.
function VisualizationError({
error,
retry,
}: {
error: Error;
retry: () => void;
}) {
return (
<>
<div className="flex w-full flex-col items-center justify-center gap-4">
<div>
<ContentMessage title="Error" variant="pink">
We encountered an error while running the code generated above. You
can try again by clicking the button below.
<Collapsible>
<Collapsible.Button label="Show details" />
<Collapsible.Panel>
<div className="s-flex s-h-16 s-w-full s-items-center s-justify-center s-bg-slate-200">
Error messsage:
{error.message}
</div>
</Collapsible.Panel>
</Collapsible>
</ContentMessage>
</div>
<div>
<Button label="Retry" onClick={retry} />
</div>
</div>
</>
);
}

type ErrorBoundaryProps = {
actionId: string;
};

type ErrorBoundaryState = {
hasError: boolean;
error: unknown;
activeTab: "code" | "runtime";
};

// This is the error boundary component that wraps the VisualizationWrapper component.
// It needs to be a class component for error handling to work.
export class VisualizationWrapperWithErrorHandling extends React.Component<
ErrorBoundaryProps,
ErrorBoundaryState
> {
constructor(props: ErrorBoundaryProps) {
super(props);
this.state = { hasError: false, error: null, activeTab: "code" };
}

static getDerivedStateFromError() {
// Update state so the next render will show the fallback UI.
return { hasError: true };
}

componentDidCatch(error: unknown) {
this.setState({ hasError: true, error });
}

render() {
if (this.state.hasError) {
// You can render any custom fallback UI
let error: Error;
if (this.state.error instanceof Error) {
error = this.state.error;
} else {
error = new Error("Unknown error");
}
const retry = makeIframeMessagePassingFunction(
"retry",
parseInt(this.props.actionId, 10)
);
return <VisualizationError error={error} retry={() => retry} />;
}

return <VisualizationWrapper actionId={this.props.actionId} />;
}
}
22 changes: 22 additions & 0 deletions viz/app/content/layout.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "../globals.css";

const inter = Inter({ subsets: ["latin"] });

export const metadata: Metadata = {
title: "Dust Visualization",
description: "Dust Visualization",
};

export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body className={inter.className}>{children}</body>
</html>
);
}
16 changes: 16 additions & 0 deletions viz/app/content/pages.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { VisualizationIframeContentWithErrorHandling } from "@viz/components/VisualizationIframeContent";

type IframeProps = {
wId: string;
aId: string;
};

export default function Iframe({
searchParams,
}: {
searchParams: IframeProps;
}) {
return (
<VisualizationIframeContentWithErrorHandling actionId={searchParams.aId} />
);
}
2 changes: 2 additions & 0 deletions viz/app/globals.css
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
/* purgecss start ignore */
@tailwind base;
@tailwind components;
@tailwind utilities;
/* purgecss end ignore */

:root {
--foreground-rgb: 0, 0, 0;
Expand Down
23 changes: 22 additions & 1 deletion viz/next.config.mjs
Original file line number Diff line number Diff line change
@@ -1,4 +1,25 @@
/** @type {import('next').NextConfig} */
const nextConfig = {};
const isDev = process.env.NODE_ENV === "development";

const nextConfig = {
async headers() {
return [
{
source: "/:path*",
headers: [
{
key: "Access-Control-Allow-Origin",
value: isDev ? "http://localhost:3000" : "https://dust.tt",
},
],
},
// Allow CORS for static files.
{
source: "/_next/static/:path*",
headers: [{ key: "Access-Control-Allow-Origin", value: "*" }],
},
];
},
};

export default nextConfig;
Loading

0 comments on commit 5c4fdb0

Please sign in to comment.