Skip to content

Commit

Permalink
feat(prompts): Add prompt combobox to playground page + deeplink to p…
Browse files Browse the repository at this point in the history
…rompt specific playground (#5748)

* feat(prompts): Add prompt selection combobox to playground instances

* Implement prompt/:id/playground route and add link from prompt page

* Rename prompt combobox components for clarity
  • Loading branch information
cephalization authored Dec 17, 2024
1 parent 96bb771 commit 8fbd55c
Show file tree
Hide file tree
Showing 12 changed files with 426 additions and 35 deletions.
14 changes: 12 additions & 2 deletions app/src/Routes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { spanPlaygroundPageLoaderQuery$data } from "./pages/playground/__generat
import { PlaygroundExamplePage } from "./pages/playground/PlaygroundExamplePage";
import { projectLoaderQuery$data } from "./pages/project/__generated__/projectLoaderQuery.graphql";
import { promptLoaderQuery$data } from "./pages/prompt/__generated__/promptLoaderQuery.graphql";
import { PromptPlaygroundPage } from "./pages/prompt/PromptPlaygroundPage";
import { sessionLoader } from "./pages/trace/sessionLoader";
import { SessionPage } from "./pages/trace/SessionPage";
import {
Expand Down Expand Up @@ -208,7 +209,6 @@ const router = createBrowserRouter(
<Route
path=":promptId"
loader={promptLoader}
element={<PromptPage />}
handle={{
crumb: (data: promptLoaderQuery$data) => {
if (data.prompt.__typename === "Prompt") {
Expand All @@ -217,7 +217,17 @@ const router = createBrowserRouter(
return "unknown";
},
}}
/>
>
<Route index element={<PromptPage />} loader={promptLoader} />
<Route
path="playground"
element={<PromptPlaygroundPage />}
loader={promptLoader}
handle={{
crumb: () => "Playground",
}}
/>
</Route>
</Route>
<Route
path="/apis"
Expand Down
31 changes: 28 additions & 3 deletions app/src/components/combobox/ComboBox.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export interface ComboBoxProps<T extends object>
extends Omit<AriaComboBoxProps<T>, "children">,
SizingProps {
label?: string;
placeholder?: string;
description?: string | null;
errorMessage?: string | ((validation: ValidationResult) => string);
children: React.ReactNode | ((item: T) => React.ReactNode);
Expand All @@ -36,16 +37,33 @@ export interface ComboBoxProps<T extends object>
* The width of the combobox. If not provided, the combobox will be sized to fit its contents. with a minimum width of 200px.
*/
width?: string;
/**
* If true, click and keypress events will not propagate to the parent element.
*
* This is useful when nesting the combobox within containers that have onClick handlers,
* but should be used sparingly.
*/
stopPropagation?: boolean;
}

/**
* Prevents the event from propagating to the parent element.
*/
const stopPropagationHandler = (e: React.MouseEvent | React.KeyboardEvent) => {
e.preventDefault();
e.stopPropagation();
};

export function ComboBox<T extends object>({
label,
placeholder,
description,
errorMessage,
children,
container,
size = "M",
width,
stopPropagation,
...props
}: ComboBoxProps<T>) {
return (
Expand All @@ -57,9 +75,16 @@ export function ComboBox<T extends object>({
width,
}}
>
<Label>{label}</Label>
<div className="px-combobox-container">
<Input />
{label && <Label>{label}</Label>}
<div
className="px-combobox-container"
// Prevent interactions with the combobox components from propagating above this element
// This allows us to nest the combobox within containers that have onClick handlers
onClick={stopPropagation ? stopPropagationHandler : undefined}
onKeyDown={stopPropagation ? stopPropagationHandler : undefined}
onKeyUp={stopPropagation ? stopPropagationHandler : undefined}
>
<Input placeholder={placeholder} />
<Button>
<Icon svg={<Icons.ArrowIosDownwardOutline />} />
</Button>
Expand Down
2 changes: 1 addition & 1 deletion app/src/pages/playground/Playground.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -214,7 +214,7 @@ const playgroundInputOutputPanelContentCSS = css`
* This width accomodates the model config button min-width, as well as chat message accordion
* header contents such as the chat message mode radio group for AI messages
*/
const PLAYGROUND_PROMPT_PANEL_MIN_WIDTH = 475;
const PLAYGROUND_PROMPT_PANEL_MIN_WIDTH = 570;

function PlaygroundContent() {
const instances = usePlaygroundContext((state) => state.instances);
Expand Down
55 changes: 32 additions & 23 deletions app/src/pages/playground/PlaygroundChatTemplate.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -377,30 +377,39 @@ function SortableMessageItem({
bodyStyle={{ padding: 0 }}
{...messageCardStyles}
title={
<MessageRolePicker
includeLabel={false}
role={message.role}
onChange={(role) => {
let toolCalls = message.toolCalls;
// Tool calls should only be attached to ai messages
// Clear tools from the message and reset the message mode when switching away form ai
if (role !== "ai") {
toolCalls = undefined;
setAIMessageMode("text");
}
updateInstance({
instanceId: playgroundInstanceId,
patch: {
template: {
__type: "chat",
messages: template.messages.map((msg) =>
msg.id === message.id ? { ...msg, role, toolCalls } : msg
),
<div
css={css`
// Align the role picker with the prompt picker in PlaygroundTemplate header
margin-left: var(--ac-global-dimension-size-150);
`}
>
<MessageRolePicker
includeLabel={false}
role={message.role}
onChange={(role) => {
let toolCalls = message.toolCalls;
// Tool calls should only be attached to ai messages
// Clear tools from the message and reset the message mode when switching away form ai
if (role !== "ai") {
toolCalls = undefined;
setAIMessageMode("text");
}
updateInstance({
instanceId: playgroundInstanceId,
patch: {
template: {
__type: "chat",
messages: template.messages.map((msg) =>
msg.id === message.id
? { ...msg, role, toolCalls }
: msg
),
},
},
},
});
}}
/>
});
}}
/>
</div>
}
extra={
<Flex direction="row" gap="size-100">
Expand Down
27 changes: 25 additions & 2 deletions app/src/pages/playground/PlaygroundTemplate.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { usePlaygroundContext } from "@phoenix/contexts/PlaygroundContext";
import { ModelConfigButton } from "./ModelConfigButton";
import { ModelSupportedParamsFetcher } from "./ModelSupportedParamsFetcher";
import { PlaygroundChatTemplate } from "./PlaygroundChatTemplate";
import { PromptComboBox } from "./PromptComboBox";
import { PlaygroundInstanceProps } from "./types";

interface PlaygroundTemplateProps extends PlaygroundInstanceProps {}
Expand All @@ -28,6 +29,15 @@ export function PlaygroundTemplate(props: PlaygroundTemplateProps) {
const instances = usePlaygroundContext((state) => state.instances);
const instance = instances.find((instance) => instance.id === instanceId);
const index = instances.findIndex((instance) => instance.id === instanceId);
const prompt = instance?.prompt;
const promptId = prompt?.id;
const updateInstancePrompt = usePlaygroundContext(
(state) => state.updateInstancePrompt
);

// TODO(apowell): Sync instance state with promptId + version (or latest if unset)
// If it exists, and we can fetch it from gql, replace the instance with it
// If it doesn't exist, or we can't fetch it from gql, set the promptId to null

if (!instance) {
throw new Error(`Playground instance ${instanceId} not found`);
Expand All @@ -37,9 +47,22 @@ export function PlaygroundTemplate(props: PlaygroundTemplateProps) {
return (
<Card
title={
<Flex direction="row" gap="size-100" alignItems="center">
<Flex
direction="row"
gap="size-100"
alignItems="center"
marginEnd="size-100"
>
<AlphabeticIndexIcon index={index} />
<span>Prompt</span>
<PromptComboBox
promptId={promptId}
onChange={(nextPromptId) => {
updateInstancePrompt({
instanceId,
patch: nextPromptId ? { id: nextPromptId } : null,
});
}}
/>
</Flex>
}
collapsible
Expand Down
110 changes: 110 additions & 0 deletions app/src/pages/playground/PromptComboBox.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
import React, { useEffect, useMemo } from "react";
import {
graphql,
PreloadedQuery,
usePreloadedQuery,
useQueryLoader,
} from "react-relay";

import { ComboBox, ComboBoxItem, ComboBoxProps } from "@phoenix/components";

import promptComboBoxQuery, {
PromptComboBoxQuery,
} from "./__generated__/PromptComboBoxQuery.graphql";

export type PromptItem =
PromptComboBoxQuery["response"]["prompts"]["edges"][number]["prompt"];

type PromptComboBoxProps = {
onChange: (promptId: string | null) => void;
promptId?: string | null;
container?: HTMLElement;
} & Omit<
ComboBoxProps<PromptItem>,
"children" | "onSelectionChange" | "defaultSelectedKey"
>;

function PromptComboBoxComponent({
onChange,
container,
promptId,
queryReference,
...comboBoxProps
}: PromptComboBoxProps & {
queryReference: PreloadedQuery<PromptComboBoxQuery>;
}) {
const data = usePreloadedQuery(
graphql`
query PromptComboBoxQuery($first: Int = 100) {
prompts(first: $first) {
edges {
prompt: node {
__typename
... on Prompt {
id
name
}
}
}
}
}
`,
queryReference
);
const items = useMemo((): PromptItem[] => {
return data.prompts.edges.map((edge) => edge.prompt);
}, [data.prompts.edges]);

return (
<ComboBox
size="M"
data-testid="prompt-picker"
selectedKey={promptId ?? null}
aria-label="prompt picker"
width="100%"
menuTrigger="focus"
stopPropagation
container={container}
defaultItems={items}
placeholder="Select a prompt..."
onSelectionChange={(key) => {
if (typeof key !== "string" && key != null) {
return;
}
if (key === promptId) {
onChange(null);
return;
}
onChange(key);
}}
{...comboBoxProps}
>
{(item) => {
return (
<ComboBoxItem key={item.id} textValue={item.name} id={item.id}>
{item.name}
</ComboBoxItem>
);
}}
</ComboBox>
);
}

function PromptComboBoxLoader(props: PromptComboBoxProps) {
const [queryReference, loadQuery, disposeQuery] =
useQueryLoader<PromptComboBoxQuery>(promptComboBoxQuery);

useEffect(() => {
// TODO(apowell): Paginate and filter in query
loadQuery({ first: 100 });
return () => disposeQuery();
}, [disposeQuery, loadQuery]);

return queryReference != null ? (
<PromptComboBoxComponent queryReference={queryReference} {...props} />
) : null;
}

export function PromptComboBox(props: PromptComboBoxProps) {
return <PromptComboBoxLoader {...props} />;
}
Loading

0 comments on commit 8fbd55c

Please sign in to comment.