Skip to content
Open
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
112 changes: 112 additions & 0 deletions packages/plotly/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
# @openuidev/plotly

Plotly.js component library for OpenUI's generative UI runtime. An LLM speaking openui-lang gets **47 typed chart components** — bar, line, heatmap, violin, sankey, sunburst, candlestick, scatter3d, choropleth, parallel coordinates, the rest of Plotly's catalog — plus a `<PlotlyChat />` wrapper that drops onto any "use client" page.

## Install

```bash
pnpm add @openuidev/plotly @openuidev/react-ui @openuidev/react-headless @openuidev/react-lang react react-dom zod
```

## Quick start

```tsx
"use client";
import "@openuidev/react-ui/components.css";
import "@openuidev/react-ui/styles/index.css";
import "@openuidev/plotly/styles.css";

import { PlotlyChat } from "@openuidev/plotly";

export default function Home() {
return (
<div className="h-screen w-screen overflow-hidden">
<PlotlyChat />
</div>
);
}
```

Pair with an `/api/chat` proxy that streams from your LLM provider (see `examples/` for a working OpenAI implementation).

## Two-tier API

Following Plotly's own dual API:

**Tier 1** — Typed Plotly-Express-style components. Small zod schemas, ergonomic for the LLM:

```
Bar(rows, "month", "revenue", "product") # Express style
Bar(null, ["Jan", "Feb", "Mar"], [1.2, 1.5, 1.8]) # Graph-Objects style
```

**Tier 2** — `Figure(data, layout)` accepts the full Plotly Graph-Objects schema. Use this for charts Tier 1 doesn't cover (multi-trace overlays, dual axes, animation frames, custom polar/ternary/carpet, advanced 3D scenes).

**Tier 0** — `PlotlyJSON({ figure })` renders a fully-formed Plotly figure JSON verbatim. Bridge for backend-produced figures (Python `fig.to_json()`).

## Component catalog

| Family | Components |
|---|---|
| Layout | `Stack`, `Card`, `CardHeader`, `Heading`, `Text`, `Callout`, `KPI` |
| Cartesian | `Bar`, `Line`, `Scatter`, `Area`, `Histogram` |
| Distributions | `Violin`, `Box` |
| Matrix & 2D-density | `Heatmap`, `Histogram2D`, `Histogram2DContour`, `Contour` |
| Hierarchical | `Sunburst`, `Treemap`, `Icicle` |
| Categorical | `Pie`, `Donut`, `Funnel`, `FunnelArea`, `Waterfall` |
| Flow | `Sankey` |
| Multivariate | `ScatterMatrix`, `ParCoords`, `ParCats` |
| Financial | `Candlestick`, `OHLC` |
| Polar | `ScatterPolar`, `BarPolar` |
| Specialty coords | `ScatterTernary`, `ScatterSmith` |
| Geo | `Choropleth`, `ScatterGeo` |
| Data display | `Indicator`, `Table` |
| Escape hatches | `Figure`, `PlotlyJSON` |

The LLM sees these via the auto-generated system prompt — no manual Plotly schema authoring required.

## Bidirectional reactivity

Plotly events flow into OpenUI's action system:

```
Bar(rows, "month", "revenue",
onClick=Action([@Set($selectedMonth, event.x), @Run(monthlyDetail)]))
```

Supported events: `onClick`, `onSelected` (lasso/box select), `onRelayout` (zoom/pan).

## Bundle & loading

- The Plotly bundle (~3.5 MB minified, ~1 MB gzipped) is loaded **lazily via `React.lazy`** on first chart render. The chat shell first paint is unaffected.
- Chart instances mount only after the assistant message finishes streaming — avoids token-by-token flicker as the LLM populates trace shape.

## Custom chat shell

If `<PlotlyChat />` is too restrictive, drop down to the lower-level pieces:

```tsx
import { Renderer, useTriggerAction } from "@openuidev/react-lang";
import { FullScreen } from "@openuidev/react-ui";
import {
plotlyLibrary,
plotlyPromptOptions,
PlotlyAssistantMessage,
} from "@openuidev/plotly";

const systemPrompt = plotlyLibrary.prompt(plotlyPromptOptions);
// …compose your own FullScreen / processMessage / assistantMessage…
```

## Theming

`lightTemplate` + `darkTemplate` are exported as plain Plotly `Layout` objects matching OpenUI's design language. Apply them to your own (non-OpenUI) Plotly charts elsewhere if you want consistent visuals.

## Limitations

- **`image` trace** is not registered in the default bundle. Plotly's image source does `require('buffer/').Buffer` (with the trailing slash, deliberately), which Turbopack can't resolve. Use `Figure` with custom Plotly registration to enable it in non-Turbopack environments.
- **Carpet family** (`Carpet`, `ScatterCarpet`, `ContourCarpet`) is figure-only — they only render when composed in a single figure, which separate `defineComponent` calls can't express. Use `<Figure />`.

## License

MIT
96 changes: 96 additions & 0 deletions packages/plotly/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
{
"type": "module",
"name": "@openuidev/plotly",
"license": "MIT",
"version": "0.1.0",
"description": "Plotly.js component library for OpenUI generative UI — 47 typed chart components an LLM can compose via openui-lang, plus a <PlotlyChat /> wrapper.",
"main": "dist/index.cjs",
"module": "dist/index.mjs",
"types": "dist/index.d.cts",
"exports": {
".": {
"import": {
"types": "./dist/index.d.mts",
"default": "./dist/index.mjs"
},
"require": {
"types": "./dist/index.d.cts",
"default": "./dist/index.cjs"
}
},
"./styles.css": {
"default": "./styles/plotly.css"
}
},
"files": [
"dist",
"styles",
"README.md"
],
"sideEffects": [
"*.css",
"./dist/index.mjs",
"./dist/index.cjs"
],
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"build": "rm -rf dist && pnpm build:tsc && pnpm build:cjs",
"build:tsc": "tsc -p . || true",
"build:cjs": "tsdown",
"typecheck": "tsc --noEmit",
"lint:check": "eslint ./src",
"lint:fix": "eslint ./src --fix",
"format:fix": "prettier --write ./src",
"format:check": "prettier --check ./src",
"check:publint": "publint",
"check:attw": "attw --pack . --ignore-rules no-resolution",
"prepare": "pnpm run build",
"prepublishOnly": "pnpm run check:publint && pnpm run check:attw",
"ci": "pnpm run lint:check && pnpm run format:check"
},
"peerDependencies": {
"@openuidev/react-headless": "workspace:^",
"@openuidev/react-lang": "workspace:^",
"@openuidev/react-ui": "workspace:^",
"react": "^18.3.1 || ^19.0.0",
"react-dom": "^18.0.0 || ^19.0.0",
"zod": "^3.25.0 || ^4.0.0"
},
"dependencies": {
"plotly.js-dist-min": "^3.0.0",
"react-plotly.js": "^2.6.0"
},
"devDependencies": {
"@types/node": "^22.15.32",
"@types/plotly.js": "^3.0.0",
"@types/plotly.js-dist-min": "^2.3.4",
"@types/react": ">=18.3.1",
"@types/react-dom": ">=18.3.1",
"@types/react-plotly.js": "^2.6.3"
},
"keywords": [
"openui",
"plotly",
"plotly.js",
"generative-ui",
"ai",
"llm",
"react",
"charts",
"data-visualization",
"scientific"
],
"homepage": "https://openui.com",
"author": "engineering@openui.com",
"repository": {
"type": "git",
"url": "https://github.com/thesysdev/openui.git",
"directory": "packages/plotly"
},
"bugs": {
"url": "https://github.com/thesysdev/openui/issues"
},
"publishConfig": {
"access": "public"
}
}
94 changes: 94 additions & 0 deletions packages/plotly/src/AssistantMessage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
"use client";
import type { AssistantMessage as AssistantMessageType } from "@openuidev/react-headless";
import type { Library } from "@openuidev/react-lang";
import { Renderer } from "@openuidev/react-lang";
import React from "react";

interface Props {
message: AssistantMessageType;
isStreaming: boolean;
library: Library;
}

interface ErrorBoundaryState {
err: Error | null;
}

class MessageErrorBoundary extends React.Component<
{ children: React.ReactNode },
ErrorBoundaryState
> {
override state: ErrorBoundaryState = { err: null };
static getDerivedStateFromError(err: Error): ErrorBoundaryState {
return { err };
}
override componentDidCatch(err: Error) {
if (typeof window !== "undefined") {
console.error("[plotly] message render crashed:", err);
}
}
override render() {
if (this.state.err) {
return (
<div
style={{
background: "rgba(239,68,68,0.06)",
border: "1px dashed rgba(239,68,68,0.3)",
borderRadius: 8,
padding: "10px 12px",
fontSize: 12.5,
color: "rgba(127,29,29,0.95)",
lineHeight: 1.45,
}}
>
<div style={{ fontWeight: 600, marginBottom: 2 }}>Render error</div>
<div style={{ color: "rgba(127,29,29,0.7)", fontSize: 11.5 }}>
{this.state.err.message}
</div>
</div>
);
}
return this.props.children;
}
}

export function PlotlyAssistantMessage({ message, isStreaming, library }: Props) {
const content = typeof message.content === "string" ? message.content : "";

React.useEffect(() => {
if (!isStreaming && content) {
// eslint-disable-next-line no-console
console.groupCollapsed(
`%c[plotly] openui-lang (${content.length} chars)`,
"color:#4c78a8;font-weight:600",
);
// eslint-disable-next-line no-console
console.log(content);
// eslint-disable-next-line no-console
console.groupEnd();
}
}, [isStreaming, content]);

return (
<div style={{ width: "100%" }}>
<MessageErrorBoundary>
<Renderer
response={content}
library={library}
isStreaming={isStreaming}
onError={(errors) => {
if (errors.length === 0) return;
console.warn(
`%c[plotly] ${errors.length} render error(s)`,
"color:#dc2626;font-weight:600",
errors,
);
}}
// onParseResult intentionally omitted — it fires on every reactive
// state change which spams the console with N×M log lines on multi-
// chart messages. Re-enable behind a debug flag if needed.
/>
</MessageErrorBoundary>
</div>
);
}
86 changes: 86 additions & 0 deletions packages/plotly/src/PlotlyChat.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
"use client";
// One-line chat shell: <PlotlyChat /> wires up FullScreen, the plotlyLibrary,
// the OpenAI adapter, and PlotlyAssistantMessage with sensible defaults.
// Use the lower-level pieces (plotlyLibrary, PlotlyAssistantMessage, etc.)
// directly if you need custom message processing.

import {
openAIMessageFormat,
openAIReadableStreamAdapter,
type Message,
} from "@openuidev/react-headless";
import { FullScreen } from "@openuidev/react-ui";
import React from "react";
import { PlotlyAssistantMessage } from "./AssistantMessage";
import { plotlyLibrary, plotlyPromptOptions } from "./library";

export interface PlotlyChatProps {
/** API endpoint that proxies to your LLM. Receives `{ systemPrompt, messages }`
* and returns a streaming response. Defaults to `/api/chat`. */
apiUrl?: string;
/** Or supply your own message processor (overrides `apiUrl`). */
processMessage?: (params: {
threadId: string;
messages: Message[];
abortController: AbortController;
}) => Promise<Response>;
/** Override the system prompt fed to the LLM. Defaults to the catalog of
* all 47 components. */
systemPrompt?: string;
/** Display name shown in the chat shell header. */
agentName?: string;
/** Inline class on the wrapping div. */
className?: string;
}

export function PlotlyChat({
apiUrl = "/api/chat",
processMessage,
systemPrompt,
agentName = "OpenUI × Plotly",
className,
}: PlotlyChatProps) {
const finalSystemPrompt = React.useMemo(
() => systemPrompt ?? plotlyLibrary.prompt(plotlyPromptOptions),
[systemPrompt],
);

const finalProcessMessage = React.useMemo(() => {
if (processMessage) return processMessage;
return async ({
messages,
abortController,
}: {
threadId: string;
messages: Message[];
abortController: AbortController;
}) =>
fetch(apiUrl, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
systemPrompt: finalSystemPrompt,
messages: openAIMessageFormat.toApi(messages),
}),
signal: abortController.signal,
});
}, [processMessage, apiUrl, finalSystemPrompt]);

return (
<div className={className ?? "openui-plotly-chat"} style={{ width: "100%", height: "100%" }}>
<FullScreen
processMessage={finalProcessMessage}
streamProtocol={openAIReadableStreamAdapter()}
componentLibrary={plotlyLibrary}
agentName={agentName}
assistantMessage={({ message, isStreaming }) => (
<PlotlyAssistantMessage
message={message}
isStreaming={isStreaming}
library={plotlyLibrary}
/>
)}
/>
</div>
);
}
Loading
Loading