Skip to content

Commit

Permalink
Rill Developer: generate dashboards with AI (#4064)
Browse files Browse the repository at this point in the history
* Scaffolding

* Add OpenAI client

* Implement initial prompt

* Fix lint

* Add use_ai config option

* Add fallback generator

* Re-generate runtime client

* Extend overlay with arbitrary component

* Create UI action to generate a metrics view with AI

* Use "Autogenerate dashboard" action in 4 places

* Remove old code

* Convey AI in CTAs

* Only generate metrics view AI

* Uncomment API call

* Fix lint

* Add request cancellation

* Remove generated measures that fail validation

* Use new action in Metrics Editor

* Fix test util

* Use new measure name in test

* Clean up overlay code

* Take `toggleMenu` out of action

* Code cleanups

* Nits

* Bugfix

* Remove old code

* Avoid flash of disabled button

* Fix metrics editor CTA with dedicated function

* Fix trimming of invalid measures

---------

Co-authored-by: Benjamin Egelund-Müller <[email protected]>
Co-authored-by: Nishant Bangarwa <[email protected]>
  • Loading branch information
3 people authored Feb 19, 2024
1 parent be2a101 commit c5c9e63
Show file tree
Hide file tree
Showing 14 changed files with 329 additions and 582 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<script lang="ts">
export let onCancel: () => void;
</script>

<div class="flex flex-col gap-y-1">
<span> This could take up to 20 seconds. </span>
<span>
Prefer not to wait?
<button on:click={onCancel} class="font-semibold text-primary-400"
>Start simple</button
>
</span>
</div>
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
import { goto } from "$app/navigation";
import { get } from "svelte/store";
import { notifications } from "../../../components/notifications";
import { appScreen } from "../../../layout/app-store";
import { overlay } from "../../../layout/overlay-store";
import { behaviourEvent } from "../../../metrics/initMetrics";
import type { BehaviourEventMedium } from "../../../metrics/service/BehaviourEventTypes";
import {
MetricsEventScreenName,
type MetricsEventSpace,
} from "../../../metrics/service/MetricsTypes";
import {
RuntimeServiceGenerateMetricsViewFileBody,
V1GenerateMetricsViewFileResponse,
runtimeServiceGenerateMetricsViewFile,
runtimeServiceGetFile,
} from "../../../runtime-client";
import httpClient from "../../../runtime-client/http-client";
import { useDashboardFileNames } from "../../dashboards/selectors";
import { getFilePathFromNameAndType } from "../../entity-management/entity-mappers";
import { getName } from "../../entity-management/name-utils";
import { EntityType } from "../../entity-management/types";
import OptionToCancelAIGeneration from "./OptionToCancelAIGeneration.svelte";

/**
* TanStack Query does not support mutation cancellation (at least as of v4).
* Here, we create our own version of `runtimeServiceGenerateMetricsViewFile` that accepts an
* AbortSignal, which we can use to cancel the request.
*/
const runtimeServiceGenerateMetricsViewFileWithSignal = (
instanceId: string,
runtimeServiceGenerateMetricsViewFileBody: RuntimeServiceGenerateMetricsViewFileBody,
signal: AbortSignal,
) => {
return httpClient<V1GenerateMetricsViewFileResponse>({
url: `/v1/instances/${instanceId}/files/generate-metrics-view`,
method: "post",
headers: { "Content-Type": "application/json" },
data: runtimeServiceGenerateMetricsViewFileBody,
signal,
});
};

/**
* Wrapper function that takes care of common UI side effects on top of creating a dashboard from a table.
*
* This function is to be called from all `Generate dashboard with AI` CTAs *outside* of the Metrics Editor.
*/
export function useCreateDashboardFromTableUIAction(
instanceId: string,
tableName: string,
behaviourEventMedium: BehaviourEventMedium,
metricsEventSpace: MetricsEventSpace,
) {
// Get the list of existing dashboards to generate a unique name
// We call here to avoid: `Error: Function called outside component initialization`
const dashboardNames = useDashboardFileNames(instanceId);

// Return a function that can be called to create a dashboard from a table
return async () => {
let isAICancelled = false;
const abortController = new AbortController();

overlay.set({
title: "Hang tight! AI is personalizing your dashboard",
detail: {
component: OptionToCancelAIGeneration,
props: {
onCancel: () => {
abortController.abort();
isAICancelled = true;
},
},
},
});

// Get a unique name
const newDashboardName = getName(
`${tableName}_dashboard`,
get(dashboardNames).data ?? [],
);
const newFilePath = getFilePathFromNameAndType(
newDashboardName,
EntityType.MetricsDefinition,
);

try {
// First, request an AI-generated dashboard
void runtimeServiceGenerateMetricsViewFileWithSignal(
instanceId,
{
table: tableName,
path: newFilePath,
useAi: true,
},
abortController.signal,
);

// Poll every second until the AI generation is complete or canceled
while (!isAICancelled) {
await new Promise((resolve) => setTimeout(resolve, 1000));

try {
await runtimeServiceGetFile(instanceId, newFilePath);
// success, AI is done
break;
} catch (err) {
// 404 error, AI is not done
}
}

// If the user canceled the AI request, submit another request with `useAi=false`
if (isAICancelled) {
await runtimeServiceGenerateMetricsViewFile(instanceId, {
table: tableName,
path: newFilePath,
useAi: false,
});
}

// Go to dashboard
await goto(`/dashboard/${newDashboardName}`);
void behaviourEvent.fireNavigationEvent(
newDashboardName,
behaviourEventMedium,
metricsEventSpace,
get(appScreen)?.type,
MetricsEventScreenName.Dashboard,
);
} catch (err) {
notifications.send({
message: "Failed to create a dashboard for " + tableName,
detail: err.response?.data?.message ?? err.message,
});
}

// Done, remove the overlay
overlay.set(null);
};
}

/**
* Wrapper function that takes care of UI side effects on top of creating a dashboard from a model.
*
* This function is to be called from the `Generate dashboard with AI` CTA *inside* of the Metrics Editor.
*/
export async function createDashboardFromTableInMetricsEditor(
instanceId: string,
modelName: string,
metricsViewName: string,
) {
const tableName = modelName;
let isAICancelled = false;
const abortController = new AbortController();

overlay.set({
title: "Hang tight! AI is personalizing your dashboard",
detail: {
component: OptionToCancelAIGeneration,
props: {
onCancel: () => {
abortController.abort();
isAICancelled = true;
},
},
},
});

const filePath = getFilePathFromNameAndType(
metricsViewName,
EntityType.MetricsDefinition,
);
try {
// First, request an AI-generated dashboard
void runtimeServiceGenerateMetricsViewFileWithSignal(
instanceId,
{
table: tableName,
path: filePath,
useAi: true,
},
abortController.signal,
);

// Poll every second until the AI generation is complete or canceled
while (!isAICancelled) {
await new Promise((resolve) => setTimeout(resolve, 1000));

try {
const file = await runtimeServiceGetFile(instanceId, filePath);
if (file.blob !== "") {
// success, AI is done
break;
}
} catch (err) {
// 404 error, AI is not done
}
}

// If the user canceled the AI request, submit another request with `useAi=false`
if (isAICancelled) {
await runtimeServiceGenerateMetricsViewFile(instanceId, {
table: tableName,
path: filePath,
useAi: false,
});
}
} catch (err) {
notifications.send({
message: "Failed to create a dashboard for " + tableName,
detail: err.response?.data?.message ?? err.message,
});
}

// Done, remove the overlay
overlay.set(null);
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,29 +3,14 @@
import { skipDebounceAnnotation } from "@rilldata/web-common/components/editor/annotations";
import WithTogglableFloatingElement from "@rilldata/web-common/components/floating-element/WithTogglableFloatingElement.svelte";
import { Menu, MenuItem } from "@rilldata/web-common/components/menu";
import {
getFileAPIPathFromNameAndType,
getFilePathFromNameAndType,
} from "@rilldata/web-common/features/entity-management/entity-mappers";
import { ResourceKind } from "@rilldata/web-common/features/entity-management/resource-selectors";
import { waitForResource } from "@rilldata/web-common/features/entity-management/resource-status-utils";
import { getFileAPIPathFromNameAndType } from "@rilldata/web-common/features/entity-management/entity-mappers";
import { EntityType } from "@rilldata/web-common/features/entity-management/types";
import {
generateDashboardYAMLForTable,
initBlankDashboardYAML,
} from "@rilldata/web-common/features/metrics-views/metrics-internal-store";
import { initBlankDashboardYAML } from "@rilldata/web-common/features/metrics-views/metrics-internal-store";
import { useModelFileNames } from "@rilldata/web-common/features/models/selectors";
import {
V1GetResourceResponse,
connectorServiceOLAPGetTable,
getConnectorServiceOLAPGetTableQueryKey,
getRuntimeServiceGetResourceQueryKey,
runtimeServiceGetResource,
runtimeServicePutFile,
} from "@rilldata/web-common/runtime-client";
import { runtimeServicePutFile } from "@rilldata/web-common/runtime-client";
import { runtime } from "@rilldata/web-common/runtime-client/runtime-store";
import { useQueryClient } from "@tanstack/svelte-query";
import { useIsModelingSupportedForCurrentOlapDriver } from "../../../tables/selectors";
import { createDashboardFromTableInMetricsEditor } from "../../ai-generation/generateMetricsView";
export let metricsName: string;
export let view: EditorView | undefined = undefined;
Expand All @@ -34,72 +19,15 @@
useIsModelingSupportedForCurrentOlapDriver($runtime.instanceId);
$: models = useModelFileNames($runtime.instanceId);
const queryClient = useQueryClient();
const buttonClasses =
"inline hover:font-semibold underline underline-offset-2";
// FIXME: shouldn't these be generalized and used everywhere?
async function onAutogenerateConfigFromModel(modelName: string) {
const instanceId = $runtime?.instanceId;
const model = await queryClient.fetchQuery<V1GetResourceResponse>({
queryKey: getRuntimeServiceGetResourceQueryKey(instanceId, {
"name.name": modelName,
"name.kind": ResourceKind.Model,
}),
queryFn: () =>
runtimeServiceGetResource(instanceId, {
"name.name": modelName,
"name.kind": ResourceKind.Model,
}),
});
const schemaResp = await queryClient.fetchQuery({
queryKey: getConnectorServiceOLAPGetTableQueryKey({
instanceId,
table: model?.resource?.model?.state?.table,
connector: model?.resource?.model?.state?.connector,
}),
queryFn: () =>
connectorServiceOLAPGetTable({
instanceId,
table: model?.resource?.model?.state?.table,
connector: model?.resource?.model?.state?.connector,
}),
});
const isModel = true;
const dashboardYAML = schemaResp?.schema
? generateDashboardYAMLForTable(modelName, isModel, schemaResp?.schema)
: "";
await runtimeServicePutFile(
$runtime.instanceId,
getFileAPIPathFromNameAndType(metricsName, EntityType.MetricsDefinition),
{
blob: dashboardYAML,
create: true,
createOnly: true,
},
);
await waitForResource(
queryClient,
await createDashboardFromTableInMetricsEditor(
$runtime.instanceId,
getFilePathFromNameAndType(metricsName, EntityType.MetricsDefinition),
modelName,
metricsName,
);
/**
* go ahead and optimistically update the editor view.
*/
view?.dispatch({
changes: {
from: 0,
to: view.state.doc.length,
insert: dashboardYAML,
},
// tell the editor that this is a transaction that should _not_ be
// debounced. This tells the binder to delay dispatching out of the editor component
// any reconciliation update.
annotations: skipDebounceAnnotation.of(true),
});
}
// FIXME: shouldn't these be generalized and used everywhere?
Expand Down Expand Up @@ -154,7 +82,7 @@
{#each $models?.data ?? [] as model}
<MenuItem
on:select={() => {
onAutogenerateConfigFromModel(model);
void onAutogenerateConfigFromModel(model);
toggleFloatingElement();
}}
>
Expand Down
Loading

1 comment on commit c5c9e63

@github-actions
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please sign in to comment.