Skip to content

Commit

Permalink
feat(prompts): Add "Clone Prompt" flow to prompts UI (#5993)
Browse files Browse the repository at this point in the history
* feat(prompts): Implement ClonePromptMutation

* feat(prompts): Add Clone prompt flow to prompts UI

* chore(prompts): Fix storybook build

* Add prompt clone tests

* Revalidate clone dialog on change

* Fix invocation parameter list display

* Reduce number of db writes during prompt clone

* Improve prompt clone test readability

* Fix prompt clone error handling

* Tweak error utils to ignore single quotes

* Properly display error messages for prompt save and clone mutations

* Fix prompt clone error message display

* Fix test

* Fix test
  • Loading branch information
cephalization authored Jan 14, 2025
1 parent 8869cbd commit 4609752
Show file tree
Hide file tree
Showing 13 changed files with 634 additions and 34 deletions.
15 changes: 15 additions & 0 deletions app/.storybook/main.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { StorybookConfig } from "@storybook/react-vite";
import { mergeConfig } from "vite";
import { resolve } from "path";

const config: StorybookConfig = {
Expand All @@ -16,5 +17,19 @@ const config: StorybookConfig = {
typescript: {
reactDocgen: "react-docgen-typescript",
},
async viteFinal(config, { configType }) {
// return the customized config
return mergeConfig(config, {
// customize the Vite config here
optimizeDeps: {
include: ["@storybook/addon-interactions"],
},
resolve: {
alias: {
"@phoenix": resolve(__dirname, "../src"),
},
},
});
},
};
export default config;
7 changes: 7 additions & 0 deletions app/schema.graphql
Original file line number Diff line number Diff line change
Expand Up @@ -215,6 +215,12 @@ input ClearProjectInput {
endTime: DateTime
}

input ClonePromptInput {
name: String!
description: String = null
promptId: GlobalID!
}

type Cluster {
"""The ID of the cluster"""
id: ID!
Expand Down Expand Up @@ -1207,6 +1213,7 @@ type Mutation {
createChatPrompt(input: CreateChatPromptInput!): Prompt!
createChatPromptVersion(input: CreateChatPromptVersionInput!): Prompt!
deletePrompt(input: DeletePromptInput!): Query!
clonePrompt(input: ClonePromptInput!): Prompt!
deletePromptVersionTag(input: DeletePromptVersionTagInput!): PromptVersionTagMutationPayload!
setPromptVersionTag(input: SetPromptVersionTagInput!): PromptVersionTagMutationPayload!
createSpanAnnotations(input: [CreateSpanAnnotationInput!]!): SpanAnnotationMutationPayload!
Expand Down
9 changes: 5 additions & 4 deletions app/src/pages/playground/UpsertPromptFromTemplateDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
SavePromptForm,
SavePromptSubmitHandler,
} from "@phoenix/pages/playground/SavePromptForm";
import { getErrorMessagesFromRelayMutationError } from "@phoenix/utils/errorUtils";

type UpsertPromptFromTemplateProps = {
instanceId: number;
Expand Down Expand Up @@ -109,10 +110,10 @@ export const UpsertPromptFromTemplateDialog = ({
setDialog(null);
},
onError: (error) => {
// eslint-disable-next-line no-console
console.error(error);
const message = getErrorMessagesFromRelayMutationError(error);
notifyError({
title: "Error creating prompt",
message: message?.[0],
});
setDialog(null);
},
Expand Down Expand Up @@ -161,10 +162,10 @@ export const UpsertPromptFromTemplateDialog = ({
setDialog(null);
},
onError: (error) => {
// eslint-disable-next-line no-console
console.error(error);
const message = getErrorMessagesFromRelayMutationError(error);
notifyError({
title: "Error updating prompt",
message: message?.[0],
});
setDialog(null);
},
Expand Down
188 changes: 188 additions & 0 deletions app/src/pages/prompt/ClonePromptDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
import React, { Suspense } from "react";
import { Controller, useForm } from "react-hook-form";
import { graphql, useMutation } from "react-relay";
import { useNavigate } from "react-router";

import { Dialog, TextArea } from "@arizeai/components";

import {
Button,
FieldError,
Flex,
Input,
Label,
Loading,
Text,
TextField,
View,
} from "@phoenix/components";
import { useNotifySuccess } from "@phoenix/contexts/NotificationContext";
import { ClonePromptDialogMutation } from "@phoenix/pages/prompt/__generated__/ClonePromptDialogMutation.graphql";
import { getErrorMessagesFromRelayMutationError } from "@phoenix/utils/errorUtils";

export const ClonePromptDialog = ({
promptId,
promptName,
promptDescription,
setDialog,
}: {
promptId: string;
promptName: string;
promptDescription?: string;
setDialog: (dialog: React.ReactNode | null) => void;
}) => {
const notifySuccess = useNotifySuccess();
const navigate = useNavigate();
const [clonePrompt, isClonePending] = useMutation<ClonePromptDialogMutation>(
graphql`
mutation ClonePromptDialogMutation($input: ClonePromptInput!) {
clonePrompt(input: $input) {
id
}
}
`
);
const form = useForm({
disabled: isClonePending,
reValidateMode: "onBlur",
mode: "onChange",
defaultValues: {
name: `${promptName} (Clone)`,
description: promptDescription,
},
});
const {
control,
handleSubmit,
formState: { isValid, errors },
setError,
} = form;
const onSubmit = handleSubmit((data) => {
clonePrompt({
variables: {
input: {
promptId,
name: data.name,
description: data.description,
},
},
onCompleted: (data) => {
setDialog(null);
notifySuccess({
title: "Prompt cloned successfully",
action: {
text: "View Prompt",
onClick: () => {
navigate(`/prompts/${data.clonePrompt.id}`);
},
},
});
},
onError: (error) => {
const message = getErrorMessagesFromRelayMutationError(error);
if (message?.[0]?.includes("already exists")) {
setError("name", {
message: message?.[0],
});
} else {
setError("root", {
message: message?.[0],
});
}
},
});
});
return (
<Dialog title="Clone Prompt">
<Suspense fallback={<Loading />}>
<View padding="size-200">
<form onSubmit={onSubmit}>
<Controller
control={control}
name="name"
rules={{
required: { message: "Name is required", value: true },
validate: {
unique: (value) => {
if (value.trim() === promptName.trim()) {
return "Name must be different from the original prompt name";
}
return true;
},
},
}}
render={({
field: { onChange, onBlur, value, disabled },
fieldState: { error },
}) => (
<TextField isInvalid={!!error?.message}>
<Label>Name</Label>
<Input
name="name"
type="text"
onChange={onChange}
onBlur={onBlur}
value={value}
disabled={disabled}
/>
{!error && (
<Text slot="description">
A name for the cloned prompt.
</Text>
)}
<FieldError>{error?.message}</FieldError>
</TextField>
)}
/>
<Controller
control={control}
name="description"
render={({
field: { onChange, onBlur, value, disabled },
fieldState: { error },
}) => (
<TextField isInvalid={!!error?.message}>
<Label>Description</Label>
<TextArea
name="description"
onChange={onChange}
onBlur={onBlur}
value={value}
height={100}
isDisabled={disabled}
/>
{!error && (
<Text slot="description">
A description for the cloned prompt.
</Text>
)}
<FieldError>{error?.message}</FieldError>
</TextField>
)}
/>
{errors?.root && (
<Text color="danger">{errors?.root?.message}</Text>
)}
<Flex direction="row" justifyContent="end" gap="size-100">
<Button
variant="default"
onPress={() => setDialog(null)}
isDisabled={isClonePending}
>
Cancel
</Button>
<Button
type="submit"
variant="primary"
isDisabled={!isValid}
isPending={isClonePending}
>
Clone
</Button>
</Flex>
</form>
</View>
</Suspense>
</Dialog>
);
};
7 changes: 6 additions & 1 deletion app/src/pages/prompt/PromptInvocationParameters.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import isObject from "lodash/isObject";
import { List, ListItem } from "@arizeai/components";

import { Flex, Text, View } from "@phoenix/components";
import { safelyStringifyJSON } from "@phoenix/utils/jsonUtils";

import { PromptInvocationParameters__main$key } from "./__generated__/PromptInvocationParameters__main.graphql";

Expand All @@ -15,11 +16,15 @@ function PromptInvocationParameterItem({
keyName: string;
value: unknown;
}) {
const { json, stringifyError } = safelyStringifyJSON(value);
if (stringifyError) {
return null;
}
return (
<View paddingStart="size-100" paddingEnd="size-100">
<Flex direction="row" justifyContent="space-between">
<Text weight="heavy">{keyName}</Text>
<Text>{String(value)}</Text>
<Text>{json}</Text>
</Flex>
</View>
);
Expand Down
48 changes: 37 additions & 11 deletions app/src/pages/prompt/PromptLayout.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import React from "react";
import React, { useState } from "react";
import { useFragment } from "react-relay";
import { Outlet, useLocation, useNavigate } from "react-router";
import { graphql } from "relay-runtime";
import { css } from "@emotion/react";

import { Counter, TabPane, Tabs } from "@arizeai/components";
import { Counter, DialogContainer, TabPane, Tabs } from "@arizeai/components";

import { Button, Flex, Heading, Icon, Icons, View } from "@phoenix/components";
import { ClonePromptDialog } from "@phoenix/pages/prompt/ClonePromptDialog";

import { PromptLayout__main$key } from "./__generated__/PromptLayout__main.graphql";
import { usePromptIdLoader } from "./usePromptIdLoader";
Expand Down Expand Up @@ -40,6 +41,7 @@ const mainCSS = css`
`;

export function PromptLayout() {
const [dialog, setDialog] = useState<React.ReactNode | null>(null);
const loaderData = usePromptIdLoader();
const navigate = useNavigate();
const { pathname } = useLocation();
Expand All @@ -53,6 +55,9 @@ export function PromptLayout() {
const data = useFragment<PromptLayout__main$key>(
graphql`
fragment PromptLayout__main on Prompt {
id
name
description
promptVersions {
edges {
node {
Expand Down Expand Up @@ -80,15 +85,33 @@ export function PromptLayout() {
alignItems="center"
>
<Heading level={1}>{loaderData.prompt.name}</Heading>
<Button
size="S"
icon={<Icon svg={<Icons.Edit2Outline />} />}
onPress={() => {
navigate(`/prompts/${loaderData.prompt.id}/playground`);
}}
>
Edit in Playground
</Button>
<Flex direction="row" gap="size-100">
<Button
size="S"
icon={<Icon svg={<Icons.DuplicateIcon />} />}
onPress={() => {
setDialog(
<ClonePromptDialog
promptId={data.id}
promptName={data.name}
promptDescription={data.description ?? undefined}
setDialog={setDialog}
/>
);
}}
>
Clone
</Button>
<Button
size="S"
icon={<Icon svg={<Icons.Edit2Outline />} />}
onPress={() => {
navigate(`/prompts/${loaderData.prompt.id}/playground`);
}}
>
Edit in Playground
</Button>
</Flex>
</Flex>
</View>
<Tabs
Expand Down Expand Up @@ -118,6 +141,9 @@ export function PromptLayout() {
<Outlet />
</TabPane>
</Tabs>
<DialogContainer onDismiss={() => setDialog(null)}>
{dialog}
</DialogContainer>
</main>
);
}
Loading

0 comments on commit 4609752

Please sign in to comment.