You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by ju...@apache.org on 2025/05/21 17:58:45 UTC
(superset) 01/01: [DRAFT] chart assistant using LLM
This is an automated email from the ASF dual-hosted git repository.
justinpark pushed a commit to branch chart-assistant--airbnb
in repository https://gitbox.apache.org/repos/asf/superset.git
commit 037be49185dbf982d549f0bb97d971b2246c1347
Author: justinpark <ju...@gmail.com>
AuthorDate: Wed May 21 10:58:23 2025 -0700
[DRAFT] chart assistant using LLM
---
.../src/shared-controls/sharedControls.tsx | 6 +
.../src/chart/components/SuperChartCore.tsx | 51 +++-
.../src/BoxPlot/EchartsBoxPlot.tsx | 10 +-
.../src/BoxPlot/controlPanel.ts | 1 +
.../src/BoxPlot/transformProps.ts | 10 +-
.../src/Timeseries/EchartsTimeseries.tsx | 2 +
.../src/Timeseries/Regular/Line/controlPanel.tsx | 1 +
.../src/Timeseries/transformProps.ts | 26 ++-
.../plugin-chart-echarts/src/components/Echart.tsx | 18 +-
.../plugins/plugin-chart-echarts/src/types.ts | 2 +
.../SqlLab/components/AIAssistantModal/Content.tsx | 259 +++++++++++++++++++++
.../components/AIAssistantModal/LiveText.tsx | 124 ++++++++++
.../components/AIAssistantModal/PromptInput.tsx | 81 +++++++
.../components/AIAssistantModal/ResultCard.tsx | 138 +++++++++++
.../SqlLab/components/AIAssistantModal/index.tsx | 35 +++
.../src/components/AsyncAceEditor/index.tsx | 4 +-
.../src/components/Chart/ChartRenderer.jsx | 5 +-
.../components/Drawer/index.tsx} | 39 ++--
.../src/components/Icons/AntdEnhanced.tsx | 4 +
.../explore/components/ControlPanelsContainer.tsx | 8 +
.../explore/components/ExploreChartPanel/index.jsx | 69 +++++-
21 files changed, 853 insertions(+), 40 deletions(-)
diff --git a/superset-frontend/packages/superset-ui-chart-controls/src/shared-controls/sharedControls.tsx b/superset-frontend/packages/superset-ui-chart-controls/src/shared-controls/sharedControls.tsx
index 5baa8c688c..c77ce9b5ec 100644
--- a/superset-frontend/packages/superset-ui-chart-controls/src/shared-controls/sharedControls.tsx
+++ b/superset-frontend/packages/superset-ui-chart-controls/src/shared-controls/sharedControls.tsx
@@ -381,6 +381,11 @@ const sort_by_metric: SharedControlConfig<'CheckboxControl'> = {
),
};
+const echart_options: SharedControlConfig<'HiddenControl'> = {
+ type: 'HiddenControl',
+ renderTrigger: true,
+};
+
export default {
metrics: dndAdhocMetricsControl,
metric: dndAdhocMetricControl,
@@ -421,4 +426,5 @@ export default {
temporal_columns_lookup,
currency_format,
sort_by_metric,
+ echart_options,
};
diff --git a/superset-frontend/packages/superset-ui-core/src/chart/components/SuperChartCore.tsx b/superset-frontend/packages/superset-ui-core/src/chart/components/SuperChartCore.tsx
index f980ed13cf..1050af75ba 100644
--- a/superset-frontend/packages/superset-ui-core/src/chart/components/SuperChartCore.tsx
+++ b/superset-frontend/packages/superset-ui-core/src/chart/components/SuperChartCore.tsx
@@ -84,6 +84,48 @@ export default class SuperChartCore extends PureComponent<Props, {}> {
*/
container?: HTMLElement | null;
+ preSelector = createSelector(
+ [
+ (input: {
+ chartProps: ChartProps;
+ preTransformProps?: PreTransformProps;
+ }) => input.chartProps,
+ input => input.preTransformProps,
+ ],
+ (chartProps, pre = IDENTITY) => {
+ console.log('preselector');
+ return { chartProps: pre(chartProps) };
+ },
+ );
+
+ transformSelector = createSelector(
+ [
+ (input: { chartProps: ChartProps; transformProps?: TransformProps }) =>
+ input.chartProps,
+ input => input.transformProps,
+ ],
+ (chartProps, transform = IDENTITY) => {
+ console.log('transformselector');
+ return {
+ chartProps: transform(chartProps),
+ };
+ },
+ );
+
+ postSelector = createSelector(
+ [
+ (input: {
+ chartProps: ChartProps;
+ postTransformProps?: PostTransformProps;
+ }) => input.chartProps,
+ input => input.postTransformProps,
+ ],
+ (chartProps, post = IDENTITY) => {
+ console.log('postselector');
+ return post(chartProps);
+ },
+ );
+
/**
* memoized function so it will not recompute
* and return previous value
@@ -156,10 +198,11 @@ export default class SuperChartCore extends PureComponent<Props, {}> {
return (
<Chart
- {...this.processChartProps({
- chartProps,
- preTransformProps,
- transformProps,
+ {...this.postSelector({
+ ...this.transformSelector({
+ ...this.preSelector({ chartProps, preTransformProps }),
+ transformProps,
+ }),
postTransformProps,
})}
/>
diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/BoxPlot/EchartsBoxPlot.tsx b/superset-frontend/plugins/plugin-chart-echarts/src/BoxPlot/EchartsBoxPlot.tsx
index d293361f1d..078f08c94c 100644
--- a/superset-frontend/plugins/plugin-chart-echarts/src/BoxPlot/EchartsBoxPlot.tsx
+++ b/superset-frontend/plugins/plugin-chart-echarts/src/BoxPlot/EchartsBoxPlot.tsx
@@ -21,7 +21,14 @@ import { allEventHandlers } from '../utils/eventHandlers';
import { BoxPlotChartTransformedProps } from './types';
export default function EchartsBoxPlot(props: BoxPlotChartTransformedProps) {
- const { height, width, echartOptions, selectedValues, refs } = props;
+ const {
+ height,
+ width,
+ echartOptions,
+ customEchartOptions,
+ selectedValues,
+ refs,
+ } = props;
const eventHandlers = allEventHandlers(props);
@@ -31,6 +38,7 @@ export default function EchartsBoxPlot(props: BoxPlotChartTransformedProps) {
height={height}
width={width}
echartOptions={echartOptions}
+ customEchartOptions={customEchartOptions}
eventHandlers={eventHandlers}
selectedValues={selectedValues}
/>
diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/BoxPlot/controlPanel.ts b/superset-frontend/plugins/plugin-chart-echarts/src/BoxPlot/controlPanel.ts
index e2208892f7..6dba063710 100644
--- a/superset-frontend/plugins/plugin-chart-echarts/src/BoxPlot/controlPanel.ts
+++ b/superset-frontend/plugins/plugin-chart-echarts/src/BoxPlot/controlPanel.ts
@@ -155,6 +155,7 @@ const config: ControlPanelConfig = {
},
},
],
+ ['echart_options'],
],
},
],
diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/BoxPlot/transformProps.ts b/superset-frontend/plugins/plugin-chart-echarts/src/BoxPlot/transformProps.ts
index 511d083d47..c19d83cdac 100644
--- a/superset-frontend/plugins/plugin-chart-echarts/src/BoxPlot/transformProps.ts
+++ b/superset-frontend/plugins/plugin-chart-echarts/src/BoxPlot/transformProps.ts
@@ -49,7 +49,7 @@ export default function transformProps(
const {
width,
height,
- formData,
+ formData: { echartOptions: _echartOptions, ...formData },
hooks,
filterState,
queriesData,
@@ -286,11 +286,19 @@ export default function transformProps(
series,
};
+ let customEchartOptions;
+ try {
+ customEchartOptions = JSON.parse(_echartOptions);
+ } catch (e) {
+ // skip
+ }
+
return {
formData,
width,
height,
echartOptions,
+ customEchartOptions,
setDataMask,
emitCrossFilters,
labelMap,
diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/EchartsTimeseries.tsx b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/EchartsTimeseries.tsx
index b91db0b4c1..a5e9a6f53d 100644
--- a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/EchartsTimeseries.tsx
+++ b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/EchartsTimeseries.tsx
@@ -43,6 +43,7 @@ export default function EchartsTimeseries({
height,
width,
echartOptions,
+ customEchartOptions,
groupby,
labelMap,
selectedValues,
@@ -269,6 +270,7 @@ export default function EchartsTimeseries({
height={height - extraControlHeight}
width={width}
echartOptions={echartOptions}
+ customEchartOptions={customEchartOptions}
eventHandlers={eventHandlers}
zrEventHandlers={zrEventHandlers}
selectedValues={selectedValues}
diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Regular/Line/controlPanel.tsx b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Regular/Line/controlPanel.tsx
index 8bf40c06c8..f31f05c5e4 100644
--- a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Regular/Line/controlPanel.tsx
+++ b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/Regular/Line/controlPanel.tsx
@@ -247,6 +247,7 @@ const config: ControlPanelConfig = {
},
},
],
+ ['echart_options'],
],
},
],
diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformProps.ts b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformProps.ts
index 0fb392a12d..346278976e 100644
--- a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformProps.ts
+++ b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformProps.ts
@@ -17,7 +17,7 @@
* under the License.
*/
/* eslint-disable camelcase */
-import { invert } from 'lodash';
+import { invert, assignIn } from 'lodash';
import {
AnnotationLayer,
AxisType,
@@ -115,7 +115,7 @@ export default function transformProps(
height,
filterState,
legendState,
- formData,
+ formData: { echartOptions: _echartOptions, ...formData },
hooks,
queriesData,
datasource,
@@ -670,8 +670,30 @@ export default function transformProps(
focusedSeries = seriesName;
};
+ let customEchartOptions;
+ try {
+ customEchartOptions = new Function('return ' + _echartOptions)();
+ // JSON.parse(_echartOptions, function (key, value) {
+ // if (
+ // typeof value === 'string' &&
+ // (value.indexOf('function') === 0 || value.match(/^\([^)]*\) =>/))
+ // ) {
+ // // For both traditional functions and arrow functions
+ // return new Function('return ' + value)();
+ // }
+ // return value;
+ // });
+ // delete customEchartOptions?.series;
+ } catch (e) {
+ // skip
+ console.log(e);
+ }
+
+ console.log('customEchartOptions', _echartOptions, customEchartOptions);
+
return {
echartOptions,
+ customEchartOptions,
emitCrossFilters,
formData,
groupby: groupBy,
diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/components/Echart.tsx b/superset-frontend/plugins/plugin-chart-echarts/src/components/Echart.tsx
index e93327a696..2dd4914f25 100644
--- a/superset-frontend/plugins/plugin-chart-echarts/src/components/Echart.tsx
+++ b/superset-frontend/plugins/plugin-chart-echarts/src/components/Echart.tsx
@@ -122,6 +122,7 @@ function Echart(
width,
height,
echartOptions,
+ customEchartOptions,
eventHandlers,
zrEventHandlers,
selectedValues = {},
@@ -168,6 +169,8 @@ function Echart(
if (!chartRef.current) {
chartRef.current = init(divRef.current, null, { locale });
}
+ // did mount
+ handleSizeChange({ width, height });
setDidMount(true);
});
}, [locale]);
@@ -185,13 +188,20 @@ function Echart(
});
chartRef.current?.setOption(echartOptions, true);
+ }
+ }, [didMount, echartOptions]);
- // did mount
- handleSizeChange({ width, height });
+ useEffect(() => {
+ if (didMount && customEchartOptions) {
+ console.log('useEffect for rendering with custom', customEchartOptions);
+ chartRef.current?.setOption(echartOptions, true);
+ chartRef.current?.setOption(customEchartOptions, false);
}
- }, [didMount, echartOptions, eventHandlers, zrEventHandlers]);
+ }, [didMount, customEchartOptions]);
- useEffect(() => () => chartRef.current?.dispose(), []);
+ useEffect(() => {
+ return () => chartRef.current?.dispose();
+ }, []);
// highlighting
useEffect(() => {
diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/types.ts b/superset-frontend/plugins/plugin-chart-echarts/src/types.ts
index 294437c745..301846ab30 100644
--- a/superset-frontend/plugins/plugin-chart-echarts/src/types.ts
+++ b/superset-frontend/plugins/plugin-chart-echarts/src/types.ts
@@ -50,6 +50,7 @@ export interface EchartsProps {
height: number;
width: number;
echartOptions: EChartsCoreOption;
+ customEchartOptions: EChartsCoreOption;
eventHandlers?: EventHandlers;
zrEventHandlers?: EventHandlers;
selectedValues?: Record<number, string>;
@@ -124,6 +125,7 @@ export interface BaseChartProps<T extends PlainObject> extends ChartProps<T> {
export interface BaseTransformedProps<F> {
echartOptions: EChartsCoreOption;
+ customEchartOptions: EChartsCoreOption;
formData: F;
height: number;
onContextMenu?: (
diff --git a/superset-frontend/src/SqlLab/components/AIAssistantModal/Content.tsx b/superset-frontend/src/SqlLab/components/AIAssistantModal/Content.tsx
new file mode 100644
index 0000000000..d1e2cead64
--- /dev/null
+++ b/superset-frontend/src/SqlLab/components/AIAssistantModal/Content.tsx
@@ -0,0 +1,259 @@
+import {
+ styled,
+ SuperChart,
+ SupersetClient,
+ useTheme,
+} from '@superset-ui/core';
+import {
+ FC,
+ useCallback,
+ useEffect,
+ useMemo,
+ useReducer,
+ useRef,
+ useState,
+} from 'react';
+import IconButton from 'src/dashboard/components/IconButton';
+import Icons from 'src/components/Icons';
+import { Input } from 'src/components/Input';
+
+import StyledModal from 'src/components/Modal';
+import { Button, Skeleton } from 'src/components';
+import { JsonEditor } from 'src/components/AsyncAceEditor';
+import ChartRenderer from 'src/components/Chart/ChartRenderer';
+import Popover from 'src/components/Popover';
+import { TimeseriesChartTransformedProps } from '@superset-ui/plugin-chart-echarts';
+import LiveText from './LiveText';
+import PromptInput from './PromptInput';
+import ResultCard from './ResultCard';
+
+const StyledInputBar = styled.div`
+ position: relative;
+ transition: height 0.25s ease-in;
+ height: 55px;
+
+ &.expanded {
+ height: 550px;
+ }
+`;
+
+const StyledIconButtonWrapper = styled.div`
+ position: absolute;
+ right: 8px;
+ top: 4px;
+`;
+
+const Flex = styled.div`
+ display: flex;
+ flex-direction: column;
+ row-gap: 8px;
+`;
+
+const FlexContent = styled.div`
+ flex: 1 1 auto;
+`;
+
+const ChartContainer = styled.div`
+ border: 1px ${({ theme }) => theme.colors.grayscale.light2} solid;
+ border-radius: 8px;
+ position: relative;
+`;
+
+const StyledLoading = styled.div`
+ padding: 20px;
+ position: absolute;
+ top: 0px;
+ left: 0px;
+ width: 100%;
+ height: 100%;
+`;
+
+type Action =
+ | {
+ type: 'request';
+ payload: {
+ prompt: string;
+ };
+ }
+ | {
+ type: 'success';
+ payload: {
+ optionResult: string;
+ };
+ };
+
+type State = {
+ id: number;
+ prompt: string;
+ loading: boolean;
+ optionResult: string;
+}[];
+
+const reducer = (state: State = [], action: Action) => {
+ switch (action.type) {
+ case 'request': {
+ return [
+ ...state,
+ {
+ ...action.payload,
+ id: Date.now(),
+ loading: true,
+ optionResult: '',
+ },
+ ];
+ }
+ case 'success': {
+ const index = state.length - 1;
+ return [
+ ...state.slice(0, index),
+ {
+ ...state[index],
+ loading: false,
+ ...action.payload,
+ },
+ ];
+ }
+ default: {
+ return state;
+ }
+ }
+};
+
+const Content = ({ formData, queriesResponse }) => {
+ const [prompts, dispatch] = useReducer(reducer, []);
+ const chartOptionRef = useRef();
+ const onSubmit = useCallback((prompt: string) => {
+ dispatch({ type: 'request', payload: { prompt } });
+ // setTimeout(() => {
+ // const result =
+ // demoRef.current === 0
+ // ? `{
+ // "series": [
+ // {
+ // "itemStyle": {
+ // "color": "red"
+ // },
+ // "id": "Michael"
+ // },
+ // {
+ // "itemStyle": {
+ // "color": "red"
+ // },
+ // "id": "David"
+ // }
+ // ]
+ // }`
+ // : `{
+ // "grid": { "containLabel": true, "left": 50, "right": 20, "top": 40, "bottom": 20 },
+ // "xAxis": { "type": "time", "nameGap": 15, "nameLocation": "middle", "axisLabel": { "hideOverlap": true }, "minorTick": {}, "minInterval": 86400000 },
+ // "yAxis": { "scale": true, "yAxisLabelRotation": 0, "type": "value", "minorTick": {}, "minorSplitLine": {}, "axisLabel": {}, "nameGap": 30, "nameLocation": "middle" },
+ // "legend": { "orient": "horizontal", "show": true, "type": "scroll", "selector": [ "all", "inverse" ], "selectorLabel": { "fontFamily": "'Inter', Helvetica, Arial", "fontSize": 12, "color": "#666666", "borderColor": "#666666" }, "top": 0, "right": 0, "data": [ "Michael", "David", "Christopher", "Daniel", "Jennifer", "Matthew", "Robert", "Jose", "Anthony", "Jessica" ] },
+ // "series": [
+ // { "id": "Michael", "name": "Michael", "itemStyle": { "color": "red", "opacity": 1, "borderWidth": 0 }, "type": "line", "showSymbol": false, "symbolSize": 6, "label": { "show": false, "position": "top" },
+ // "markLine": {
+ // "data": [
+ // {
+ // "xAxis": "2000-01-01",
+ // "name": "Airbnb launched",
+ // "label": { "formatter": "Airbnb launched" }
+ // }
+ // ]
+ // }
+ // },
+ // { "id": "David", "name": "David", "itemStyle": { "color": "red", "opacity": 1, "borderWidth": 0 }, "type": "line", "showSymbol": false, "symbolSize": 6, "label": { "show": false, "position": "top" } },
+ // { "id": "Christopher", "name": "Christopher", "itemStyle": { "color": "#5AC189", "opacity": 1, "borderWidth": 0 }, "type": "line", "showSymbol": false, "symbolSize": 6, "label": { "show": false, "position": "top" } },
+ // { "id": "Daniel", "name": "Daniel", "itemStyle": { "color": "#FF7F44", "opacity": 1, "borderWidth": 0 }, "type": "line", "showSymbol": false, "symbolSize": 6, "label": { "show": false, "position": "top" } },
+ // { "id": "Jennifer", "name": "Jennifer", "itemStyle": { "color": "#666666", "opacity": 1, "borderWidth": 0 }, "type": "line", "showSymbol": false, "symbolSize": 6, "label": { "show": false, "position": "top" } },
+ // { "id": "Matthew", "name": "Matthew", "itemStyle": { "color": "#E04355", "opacity": 1, "borderWidth": 0 }, "type": "line", "showSymbol": false, "symbolSize": 6, "label": { "show": false, "position": "top" } },
+ // { "id": "Robert", "name": "Robert", "itemStyle": { "color": "#FCC700", "opacity": 1, "borderWidth": 0 }, "type": "line", "showSymbol": false, "symbolSize": 6, "label": { "show": false, "position": "top" } },
+ // { "id": "Jose", "name": "Jose", "itemStyle": { "color": "#A868B7", "opacity": 1, "borderWidth": 0 }, "type": "line", "showSymbol": false, "symbolSize": 6, "label": { "show": false, "position": "top" } },
+ // { "id": "Anthony", "name": "Anthony", "itemStyle": { "color": "#3CCCCB", "opacity": 1, "borderWidth": 0 }, "type": "line", "showSymbol": false, "symbolSize": 6, "label": { "show": false, "position": "top" } },
+ // { "id": "Jessica", "name": "Jessica", "itemStyle": { "color": "#A38F79", "opacity": 1, "borderWidth": 0 }, "type": "line", "showSymbol": false, "symbolSize": 6, "label": { "show": false, "position": "top" } }
+ // ],
+ // "toolbox": { "show": false, "top": 0, "right": 5, "feature": { "dataZoom": { "title": { "zoom": "zoom area", "back": "restore zoom" } } } },
+ // "dataZoom": []
+ // }`;
+ // demoRef.current += 1;
+ // dispatch({ type: 'success', payload: { optionResult: result } });
+ // }, 4000);
+ // SupersetClient.get({
+ // url: `https://supersetai.sandcastle.musta.ch/echarts?prompt=${prompt}`,
+ // mode: 'cors',
+ // credentials: 'include',
+ // // jsonPayload: {
+ // // prompt,
+ // // },
+ // }).then(({ json }) => {
+ // console.log('result', json);
+ // });
+ SupersetClient.post({
+ url: '/chat/',
+ jsonPayload: {
+ echartOptions: JSON.stringify(chartOptionRef.current),
+ userInput: prompt,
+ },
+ }).then(({ json }) => {
+ const optionResult = json.result.startsWith('```json\n')
+ ? json.result.slice(8, -3)
+ : json.result;
+ dispatch({ type: 'success', payload: { optionResult } });
+ });
+ }, []);
+
+ const [step, setStep] = useState(0);
+
+ useEffect(() => {
+ if (prompts[prompts.length - 1]?.loading === true) {
+ document?.getElementById('prompt')?.scrollIntoView({
+ behavior: 'smooth',
+ });
+ } else {
+ document?.getElementById('prompt')?.focus();
+ }
+ }, [prompts, step]);
+
+ const postProcess = useCallback(
+ ({ echartOptions, ...options }: TimeseriesChartTransformedProps) => {
+ chartOptionRef.current = echartOptions;
+ return { echartOptions, ...options };
+ },
+ [],
+ );
+
+ return (
+ <Flex>
+ <ChartContainer>
+ <ChartRenderer
+ formData={formData}
+ queriesResponse={queriesResponse}
+ actions={{
+ chartRenderingSucceeded: () => {},
+ }}
+ chartStatus="rendered"
+ postTransformProps={postProcess}
+ />
+ </ChartContainer>
+ {prompts.map(({ id, ...props }, i) => (
+ <ResultCard
+ key={`${id}`}
+ {...props}
+ formData={formData}
+ queriesResponse={queriesResponse}
+ ></ResultCard>
+ ))}
+ {prompts.length === 0 && (
+ <LiveText
+ as="h4"
+ text="How would you like to change?"
+ onCompleted={() => setStep(1)}
+ />
+ )}
+ <PromptInput
+ onSubmit={onSubmit}
+ disabled={prompts[prompts.length - 1]?.loading === true}
+ />
+ </Flex>
+ );
+};
+
+export default Content;
diff --git a/superset-frontend/src/SqlLab/components/AIAssistantModal/LiveText.tsx b/superset-frontend/src/SqlLab/components/AIAssistantModal/LiveText.tsx
new file mode 100644
index 0000000000..0c6ddc93db
--- /dev/null
+++ b/superset-frontend/src/SqlLab/components/AIAssistantModal/LiveText.tsx
@@ -0,0 +1,124 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { styled } from '@superset-ui/core';
+import { useEffect, useMemo, useRef, useState, forwardRef } from 'react';
+import useEffectEvent from 'src/hooks/useEffectEvent';
+
+type Props = {
+ as?: keyof HTMLElementTagNameMap;
+ text: string;
+ onCompleted?: () => void;
+};
+
+function chunkText(text: string, chunkSize: number) {
+ const chunks = [];
+
+ for (let i = 0; i < text.length; i += chunkSize) {
+ chunks.push(text.slice(i, i + chunkSize));
+ }
+
+ return chunks;
+}
+
+function useAnimatedText(
+ text: string,
+ options: {
+ chunkSize?: number;
+ onCompleted?: () => void;
+ } = {},
+) {
+ const [resolvedText, setText] = useState('');
+ const chunkSize = options.chunkSize ?? 3;
+ const index = useRef(0);
+ const chunks = useMemo(() => chunkText(text, chunkSize), [text]);
+ const onCompleted = useEffectEvent(() => options.onCompleted?.());
+
+ useEffect(() => {
+ if (index.current >= chunks.length) {
+ onCompleted();
+ return;
+ }
+
+ const timer = setTimeout(
+ () => {
+ index.current += 1;
+ setText(chunks.slice(0, index.current).join(''));
+ },
+ Math.random() * 50 + 50,
+ );
+
+ return () => {
+ clearTimeout(timer);
+ };
+ }, [chunks, resolvedText]);
+
+ useEffect(() => {
+ index.current = 0;
+ }, [text]);
+
+ return resolvedText;
+}
+
+const DynamicElementComponent = forwardRef((props: any, ref) => {
+ const { as: As } = props;
+
+ return <As ref={ref} {...props} />;
+});
+
+const StyledElement = styled(DynamicElementComponent)<{ completed: boolean }>`
+ ${({ completed, theme }) =>
+ !completed &&
+ `
+ ::after {
+ content: '';
+ width: 5px;
+ height: 20px;
+ background: ${theme.colors.grayscale.light1};
+ display: inline-block;
+ animation: cursor-blink 1.5s;
+ }
+ `}
+
+ @keyframes cursor-blink {
+ 0% {
+ opacity: 0;
+ }
+ }
+`;
+
+const LiveText = ({
+ as = 'span',
+ text,
+ onCompleted,
+ ...remainingProps
+}: Props) => {
+ const children = useAnimatedText(text, { onCompleted });
+
+ return (
+ <StyledElement
+ as={as}
+ {...remainingProps}
+ children={children}
+ completed={text === children}
+ />
+ );
+};
+
+export default LiveText;
diff --git a/superset-frontend/src/SqlLab/components/AIAssistantModal/PromptInput.tsx b/superset-frontend/src/SqlLab/components/AIAssistantModal/PromptInput.tsx
new file mode 100644
index 0000000000..f5447fcbdc
--- /dev/null
+++ b/superset-frontend/src/SqlLab/components/AIAssistantModal/PromptInput.tsx
@@ -0,0 +1,81 @@
+/**
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+import { useState } from 'react';
+import { useTheme } from '@superset-ui/core';
+
+import Icons from 'src/components/Icons';
+import { Input } from 'src/components/Input';
+import { Button } from 'src/components';
+
+import useEffectEvent from 'src/hooks/useEffectEvent';
+
+type Props = {
+ disabled: boolean;
+ onSubmit: (prompt: string) => void;
+};
+
+const PromptInput = ({ disabled, onSubmit }: Props) => {
+ const theme = useTheme();
+ const [prompt, setPrompt] = useState('');
+ const [hasError, setHasError] = useState(false);
+
+ const showError = hasError && prompt.length === 0;
+ const handleSubmit = useEffectEvent(() => {
+ if (!prompt) {
+ setHasError(true);
+ } else {
+ onSubmit(prompt);
+ setPrompt('');
+ setHasError(false);
+ }
+ });
+
+ return (
+ <Input
+ id="prompt"
+ variant="outlined"
+ size="large"
+ disabled={disabled}
+ onChange={({ target }) => setPrompt(target.value)}
+ onKeyDown={({ code }) => {
+ if (code === 'Enter') handleSubmit();
+ }}
+ value={prompt}
+ {...(showError && {
+ status: 'error',
+ placeholder: 'Please type a prompt to configure your chart',
+ })}
+ autoFocus
+ suffix={
+ showError ? (
+ <Icons.InfoCircleOutlined />
+ ) : (
+ <Button buttonStyle="primary" shape="circle" onClick={handleSubmit}>
+ <Icons.SendOutlined
+ iconSize="l"
+ iconColor={theme.colors.grayscale.light5}
+ />
+ </Button>
+ )
+ }
+ />
+ );
+};
+
+export default PromptInput;
diff --git a/superset-frontend/src/SqlLab/components/AIAssistantModal/ResultCard.tsx b/superset-frontend/src/SqlLab/components/AIAssistantModal/ResultCard.tsx
new file mode 100644
index 0000000000..c65c55c036
--- /dev/null
+++ b/superset-frontend/src/SqlLab/components/AIAssistantModal/ResultCard.tsx
@@ -0,0 +1,138 @@
+import { styled } from '@superset-ui/core';
+import {
+ FC,
+ useCallback,
+ useEffect,
+ useMemo,
+ useReducer,
+ useRef,
+ useState,
+} from 'react';
+import AutoSizer from 'react-virtualized-auto-sizer';
+import { Button, Skeleton } from 'src/components';
+import { TextAreaEditor } from 'src/components/AsyncAceEditor';
+import ChartRenderer from 'src/components/Chart/ChartRenderer';
+import { TimeseriesChartTransformedProps } from '@superset-ui/plugin-chart-echarts';
+import LiveText from './LiveText';
+
+const ChartContainer = styled.div`
+ border: 1px ${({ theme }) => theme.colors.grayscale.light2} solid;
+ border-radius: 8px;
+ position: relative;
+ height: 300px;
+`;
+
+const StyledLoading = styled.div`
+ padding: 20px;
+`;
+
+const Flex = styled.div`
+ display: flex;
+ flex-direction: row;
+ justify-content: space-between;
+`;
+
+const ResultCard = ({
+ formData,
+ queriesResponse,
+ loading,
+ prompt,
+ optionResult,
+}) => {
+ const editorRef = useRef(null);
+ const [overrideEchartOpt, setOverrideEchartOpt] = useState(optionResult);
+ const [showEditor, setShowEditor] = useState(false);
+ const postProcess = useCallback(
+ ({ echartOptions, ...options }: TimeseriesChartTransformedProps) => {
+ let compiled;
+ try {
+ compiled = new Function('return ' + overrideEchartOpt)();
+ } catch (e) {
+ // skip
+ console.log(e);
+ }
+ try {
+ if (!compiled) {
+ compiled = new Function('return ' + optionResult)();
+ }
+ } catch (e) {
+ // skip
+ console.log(e);
+ }
+ return {
+ ...options,
+ echartOptions,
+ customEchartOptions: compiled,
+ };
+ },
+ [optionResult, overrideEchartOpt],
+ );
+
+ const onEditorLoad = useCallback(editor => {
+ // editorRef.current = editor;
+ editorRef.current?.scrollIntoView({
+ behavior: 'smooth',
+ block: 'end',
+ });
+ }, []);
+
+ const handleEditClick = useCallback(() => {
+ if (!showEditor) {
+ setShowEditor(true);
+ } else {
+ editorRef.current?.scrollIntoView({
+ behavior: 'smooth',
+ block: 'end',
+ });
+ }
+ }, [showEditor]);
+
+ return (
+ <>
+ {loading ? <LiveText as="h4" text={prompt} /> : <h4>{prompt}</h4>}
+ <ChartContainer>
+ {loading ? (
+ <StyledLoading>
+ <Skeleton active paragraph />
+ </StyledLoading>
+ ) : (
+ <ChartRenderer
+ formData={formData}
+ postTransformProps={postProcess}
+ queriesResponse={queriesResponse}
+ actions={{
+ chartRenderingSucceeded: () => {},
+ }}
+ chartStatus="rendered"
+ width={329}
+ height={300}
+ />
+ )}
+ </ChartContainer>
+
+ {showEditor && (
+ <AutoSizer disableHeight>
+ {({ width }) => (
+ <TextAreaEditor
+ value={overrideEchartOpt || optionResult}
+ width={width}
+ height={300}
+ onChange={(val: string) => {
+ setOverrideEchartOpt(val);
+ }}
+ onLoad={onEditorLoad}
+ />
+ )}
+ </AutoSizer>
+ )}
+ {!loading && (
+ <Flex ref={editorRef}>
+ <Button onClick={handleEditClick}>Edit Chart Config</Button>
+ <Button buttonStyle="primary">Apply change</Button>
+ </Flex>
+ )}
+ </>
+ );
+};
+
+export default ResultCard;
diff --git a/superset-frontend/src/SqlLab/components/AIAssistantModal/index.tsx b/superset-frontend/src/SqlLab/components/AIAssistantModal/index.tsx
new file mode 100644
index 0000000000..29e485af35
--- /dev/null
+++ b/superset-frontend/src/SqlLab/components/AIAssistantModal/index.tsx
@@ -0,0 +1,35 @@
+import { useTheme } from '@superset-ui/core';
+import { OutPortal } from 'react-reverse-portal';
+import { FC } from 'react';
+import Icons from 'src/components/Icons';
+import { Button, Skeleton } from 'src/components';
+import Popover from 'src/components/Popover';
+
+type Props = {
+ show: boolean;
+};
+
+const AIAssistantModal: FC<Props> = ({ portalNode }) => {
+ const theme = useTheme();
+ return (
+ <Popover
+ title="AI Assistant"
+ trigger="click"
+ placement="bottomLeft"
+ content={<OutPortal node={portalNode} />}
+ >
+ <Button
+ icon={
+ <Icons.BulbOutlined
+ iconSize="s"
+ iconColor={theme.colors.primary.dark1}
+ />
+ }
+ >
+ AI Assistant
+ </Button>
+ </Popover>
+ );
+};
+
+export default AIAssistantModal;
diff --git a/superset-frontend/src/components/AsyncAceEditor/index.tsx b/superset-frontend/src/components/AsyncAceEditor/index.tsx
index c656f40086..a5ac2142cb 100644
--- a/superset-frontend/src/components/AsyncAceEditor/index.tsx
+++ b/superset-frontend/src/components/AsyncAceEditor/index.tsx
@@ -295,13 +295,15 @@ export const TextAreaEditor = AsyncAceEditor([
'mode/json',
'mode/html',
'mode/javascript',
- 'theme/textmate',
+ 'theme/github',
]);
export const CssEditor = AsyncAceEditor(['mode/css', 'theme/github']);
export const JsonEditor = AsyncAceEditor(['mode/json', 'theme/github']);
+export const JSEditor = AsyncAceEditor(['mode/javascript', 'theme/github']);
+
/**
* JSON or Yaml config editor.
*/
diff --git a/superset-frontend/src/components/Chart/ChartRenderer.jsx b/superset-frontend/src/components/Chart/ChartRenderer.jsx
index 25fc045d21..ef6e36eaa6 100644
--- a/superset-frontend/src/components/Chart/ChartRenderer.jsx
+++ b/superset-frontend/src/components/Chart/ChartRenderer.jsx
@@ -162,7 +162,8 @@ class ChartRenderer extends Component {
nextProps.formData.color_scheme !== this.props.formData.color_scheme ||
nextProps.formData.stack !== this.props.formData.stack ||
nextProps.cacheBusterProp !== this.props.cacheBusterProp ||
- nextProps.emitCrossFilters !== this.props.emitCrossFilters
+ nextProps.emitCrossFilters !== this.props.emitCrossFilters ||
+ nextProps.postTransformProps !== this.props.postTransformProps
);
}
return false;
@@ -327,7 +328,7 @@ class ChartRenderer extends Component {
?.behaviors.find(behavior => behavior === Behavior.DrillToDetail)
? { inContextMenu: this.state.inContextMenu }
: {};
-
+ console.log('chartrenderer render()');
return (
<>
{this.state.showContextMenu && (
diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/BoxPlot/EchartsBoxPlot.tsx b/superset-frontend/src/components/Drawer/index.tsx
similarity index 59%
copy from superset-frontend/plugins/plugin-chart-echarts/src/BoxPlot/EchartsBoxPlot.tsx
copy to superset-frontend/src/components/Drawer/index.tsx
index d293361f1d..8075bbe54a 100644
--- a/superset-frontend/plugins/plugin-chart-echarts/src/BoxPlot/EchartsBoxPlot.tsx
+++ b/superset-frontend/src/components/Drawer/index.tsx
@@ -16,23 +16,28 @@
* specific language governing permissions and limitations
* under the License.
*/
-import Echart from '../components/Echart';
-import { allEventHandlers } from '../utils/eventHandlers';
-import { BoxPlotChartTransformedProps } from './types';
+import { Drawer as AntdDrawer } from 'antd-v5';
+import { DrawerProps } from 'antd-v5/lib/drawer';
-export default function EchartsBoxPlot(props: BoxPlotChartTransformedProps) {
- const { height, width, echartOptions, selectedValues, refs } = props;
+const drawerStyles = {
+ mask: {
+ background: 'rgba(0,0,0,0.2)',
+ },
+ content: {
+ background: 'white',
+ },
+ body: {
+ padding: 16,
+ },
+ header: {
+ padding: 16,
+ },
+};
- const eventHandlers = allEventHandlers(props);
+const Drawer = (props: DrawerProps) => (
+ <AntdDrawer {...props} styles={drawerStyles} />
+);
- return (
- <Echart
- refs={refs}
- height={height}
- width={width}
- echartOptions={echartOptions}
- eventHandlers={eventHandlers}
- selectedValues={selectedValues}
- />
- );
-}
+export type { DrawerProps };
+
+export default Drawer;
diff --git a/superset-frontend/src/components/Icons/AntdEnhanced.tsx b/superset-frontend/src/components/Icons/AntdEnhanced.tsx
index 423e2b39ee..622fee354c 100644
--- a/superset-frontend/src/components/Icons/AntdEnhanced.tsx
+++ b/superset-frontend/src/components/Icons/AntdEnhanced.tsx
@@ -27,6 +27,7 @@ import {
BarChartOutlined,
BellOutlined,
BookOutlined,
+ BulbOutlined,
CaretDownOutlined,
CalendarOutlined,
CaretUpOutlined,
@@ -66,6 +67,7 @@ import {
RightOutlined,
SaveOutlined,
SearchOutlined,
+ SendOutlined,
SettingOutlined,
StopOutlined,
SyncOutlined,
@@ -88,6 +90,7 @@ const AntdIcons = {
BarChartOutlined,
BellOutlined,
BookOutlined,
+ BulbOutlined,
CaretDownOutlined,
CalendarOutlined,
CaretUpOutlined,
@@ -127,6 +130,7 @@ const AntdIcons = {
RightOutlined,
SaveOutlined,
SearchOutlined,
+ SendOutlined,
SettingOutlined,
StopOutlined,
SyncOutlined,
diff --git a/superset-frontend/src/explore/components/ControlPanelsContainer.tsx b/superset-frontend/src/explore/components/ControlPanelsContainer.tsx
index 77e5580fd7..efc6189902 100644
--- a/superset-frontend/src/explore/components/ControlPanelsContainer.tsx
+++ b/superset-frontend/src/explore/components/ControlPanelsContainer.tsx
@@ -67,6 +67,7 @@ import { ExploreActions } from 'src/explore/actions/exploreActions';
import { ChartState, ExplorePageState } from 'src/explore/types';
import { Tooltip } from 'src/components/Tooltip';
import Icons from 'src/components/Icons';
+import { JSEditor } from 'src/components/AsyncAceEditor';
import ControlRow from './ControlRow';
import Control from './Control';
import { ExploreAlert } from './ExploreAlert';
@@ -818,6 +819,13 @@ export const ControlPanelsContainer = (props: ControlPanelsContainerProps) => {
</Collapse>
</Tabs.TabPane>
)}
+ <Tabs.TabPane key="echart" tab={t('Editor')}>
+ <JSEditor
+ onChange={(val: string) => {
+ props.actions.setControlValue('echart_options', val);
+ }}
+ />
+ </Tabs.TabPane>
</ControlPanelsTabs>
<div css={actionButtonsContainerStyles}>
<RunQueryButton
diff --git a/superset-frontend/src/explore/components/ExploreChartPanel/index.jsx b/superset-frontend/src/explore/components/ExploreChartPanel/index.jsx
index 189177019c..431d941d07 100644
--- a/superset-frontend/src/explore/components/ExploreChartPanel/index.jsx
+++ b/superset-frontend/src/explore/components/ExploreChartPanel/index.jsx
@@ -18,6 +18,11 @@
*/
import { useState, useEffect, useCallback, useMemo } from 'react';
import PropTypes from 'prop-types';
+import {
+ createHtmlPortalNode,
+ InPortal,
+ OutPortal,
+} from 'react-reverse-portal';
import Split from 'react-split';
import {
css,
@@ -43,6 +48,11 @@ import { SaveDatasetModal } from 'src/SqlLab/components/SaveDatasetModal';
import { getDatasourceAsSaveableDataset } from 'src/utils/datasourceUtils';
import { buildV1ChartDataPayload } from 'src/explore/exploreUtils';
import { getChartRequiredFieldsMissingMessage } from 'src/utils/getChartRequiredFieldsMissingMessage';
+import { Button } from 'src/components';
+import Drawer from 'src/components/Drawer';
+import AIAssistantModal from 'src/SqlLab/components/AIAssistantModal';
+import AIAssistantContent from 'src/SqlLab/components/AIAssistantModal/Content';
+import Icons from 'src/components/Icons';
import { DataTablesPane } from '../DataTablesPane';
import { ChartPills } from '../ChartPills';
import { ExploreAlert } from '../ExploreAlert';
@@ -121,6 +131,13 @@ const Styles = styled.div`
}
`;
+const FlexHeader = styled.div`
+ display: flex;
+ flex-direction: row;
+ justify-content: space-between;
+ align-items: baseline;
+`;
+
const ExploreChartPanel = ({
chart,
slice,
@@ -140,6 +157,7 @@ const ExploreChartPanel = ({
can_download: canDownload,
}) => {
const theme = useTheme();
+ const portalNode = useMemo(() => createHtmlPortalNode(), []);
const gutterMargin = theme.gridUnit * GUTTER_SIZE_FACTOR;
const gutterHeight = theme.gridUnit * GUTTER_SIZE_FACTOR;
const {
@@ -158,6 +176,7 @@ const ExploreChartPanel = ({
? false
: getItem(LocalStorageKeys.IsDatapanelOpen, false),
);
+ const [showAIAssistant, setShowAIAssistant] = useState(false);
const [showDatasetModal, setShowDatasetModal] = useState(false);
@@ -363,18 +382,33 @@ const ExploreChartPanel = ({
`}
/>
)}
- <ChartPills
- queriesResponse={chart.queriesResponse}
- chartStatus={chart.chartStatus}
- chartUpdateStartTime={chart.chartUpdateStartTime}
- chartUpdateEndTime={chart.chartUpdateEndTime}
- refreshCachedQuery={refreshCachedQuery}
- rowLimit={formData?.row_limit}
- />
+ <FlexHeader>
+ {/* <AIAssistantModal portalNode={portalNode} /> */}
+ <Button
+ icon={
+ <Icons.BulbOutlined
+ iconSize="s"
+ iconColor={theme.colors.primary.dark1}
+ />
+ }
+ onClick={() => setShowAIAssistant(true)}
+ >
+ AI Assistant
+ </Button>
+ <ChartPills
+ queriesResponse={chart.queriesResponse}
+ chartStatus={chart.chartStatus}
+ chartUpdateStartTime={chart.chartUpdateStartTime}
+ chartUpdateEndTime={chart.chartUpdateEndTime}
+ refreshCachedQuery={refreshCachedQuery}
+ rowLimit={formData?.row_limit}
+ />
+ </FlexHeader>
{renderChart()}
</div>
),
[
+ portalNode,
resizeObserverRef,
showAlertBanner,
errorMessage,
@@ -464,6 +498,25 @@ const ExploreChartPanel = ({
formData={formData}
/>
)}
+ <Drawer
+ title="AI Assistant"
+ open={showAIAssistant}
+ onClose={() => setShowAIAssistant(false)}
+ >
+ <AIAssistantContent
+ formData={formData}
+ queriesResponse={chart.queriesResponse}
+ onClose={() => setShowAIAssistant(false)}
+ />
+ </Drawer>
+
+ {/* <InPortal node={portalNode}>
+ <AIAssistantContent
+ formData={formData}
+ queriesResponse={chart.queriesResponse}
+ onClose={() => setShowAIAssistant(false)}
+ />
+ </InPortal> */}
</Styles>
);
};