diff --git a/apps/opik-frontend/src/api/api.ts b/apps/opik-frontend/src/api/api.ts index 10ad7e277..486d296aa 100644 --- a/apps/opik-frontend/src/api/api.ts +++ b/apps/opik-frontend/src/api/api.ts @@ -15,6 +15,7 @@ export const FEEDBACK_DEFINITIONS_REST_ENDPOINT = "/v1/private/feedback-definitions/"; export const TRACES_REST_ENDPOINT = "/v1/private/traces/"; export const SPANS_REST_ENDPOINT = "/v1/private/spans/"; +export const PROMPTS_REST_ENDPOINT = "/v1/private/prompts/"; export type QueryConfig = Omit< UseQueryOptions< diff --git a/apps/opik-frontend/src/api/prompts/useCreatePromptVersionMutation.ts b/apps/opik-frontend/src/api/prompts/useCreatePromptVersionMutation.ts new file mode 100644 index 000000000..9bc7017e6 --- /dev/null +++ b/apps/opik-frontend/src/api/prompts/useCreatePromptVersionMutation.ts @@ -0,0 +1,56 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { AxiosError } from "axios"; +import get from "lodash/get"; + +import api, { PROMPTS_REST_ENDPOINT } from "@/api/api"; +import { useToast } from "@/components/ui/use-toast"; +import { PromptVersion } from "@/types/prompts"; + +type UseCreatePromptVersionMutationParams = { + name: string; + template: string; + onSetActiveVersionId: (versionId: string) => void; +}; + +const useCreatePromptVersionMutation = () => { + const queryClient = useQueryClient(); + const { toast } = useToast(); + + return useMutation({ + mutationFn: async ({ + name, + template, + }: UseCreatePromptVersionMutationParams) => { + const { data } = await api.post(`${PROMPTS_REST_ENDPOINT}versions`, { + name, + version: { + template, + }, + }); + + return data; + }, + onError: (error: AxiosError) => { + const message = get( + error, + ["response", "data", "message"], + error.message, + ); + + toast({ + title: "Error", + description: message, + variant: "destructive", + }); + }, + onSuccess: async (data: PromptVersion, { onSetActiveVersionId }) => { + onSetActiveVersionId(data.id); + }, + onSettled: () => { + queryClient.invalidateQueries({ queryKey: ["prompt-versions"] }); + queryClient.invalidateQueries({ queryKey: ["prompt"] }); + }, + }); +}; + +export default useCreatePromptVersionMutation; diff --git a/apps/opik-frontend/src/api/prompts/usePromptById.ts b/apps/opik-frontend/src/api/prompts/usePromptById.ts new file mode 100644 index 000000000..dcd8e8f2b --- /dev/null +++ b/apps/opik-frontend/src/api/prompts/usePromptById.ts @@ -0,0 +1,29 @@ +import { QueryFunctionContext, useQuery } from "@tanstack/react-query"; +import api, { PROMPTS_REST_ENDPOINT, QueryConfig } from "@/api/api"; +import { PromptWithLatestVersion } from "@/types/prompts"; + +const getPromptById = async ( + { signal }: QueryFunctionContext, + { promptId }: UsePromptByIdParams, +) => { + const { data } = await api.get(`${PROMPTS_REST_ENDPOINT}${promptId}`, { + signal, + }); + + return data; +}; + +type UsePromptByIdParams = { + promptId: string; +}; + +export default function usePromptById( + params: UsePromptByIdParams, + options?: QueryConfig, +) { + return useQuery({ + queryKey: ["prompt", params], + queryFn: (context) => getPromptById(context, params), + ...options, + }); +} diff --git a/apps/opik-frontend/src/api/prompts/usePromptCreateMutation.ts b/apps/opik-frontend/src/api/prompts/usePromptCreateMutation.ts new file mode 100644 index 000000000..f953bae01 --- /dev/null +++ b/apps/opik-frontend/src/api/prompts/usePromptCreateMutation.ts @@ -0,0 +1,49 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { AxiosError } from "axios"; +import get from "lodash/get"; + +import api, { PROMPTS_REST_ENDPOINT } from "@/api/api"; +import { useToast } from "@/components/ui/use-toast"; +import { Prompt } from "@/types/prompts"; + +interface CreatePromptTemplate { + template: string; +} + +type UsePromptCreateMutationParams = { + prompt: Partial & CreatePromptTemplate; +}; + +const usePromptCreateMutation = () => { + const queryClient = useQueryClient(); + const { toast } = useToast(); + + return useMutation({ + mutationFn: async ({ prompt }: UsePromptCreateMutationParams) => { + const { data } = await api.post(PROMPTS_REST_ENDPOINT, { + ...prompt, + }); + + return data; + }, + + onError: (error: AxiosError) => { + const message = get( + error, + ["response", "data", "message"], + error.message, + ); + + toast({ + title: "Error", + description: message, + variant: "destructive", + }); + }, + onSettled: () => { + return queryClient.invalidateQueries({ queryKey: ["prompts"] }); + }, + }); +}; + +export default usePromptCreateMutation; diff --git a/apps/opik-frontend/src/api/prompts/usePromptDeleteMutation.ts b/apps/opik-frontend/src/api/prompts/usePromptDeleteMutation.ts new file mode 100644 index 000000000..b60bc6428 --- /dev/null +++ b/apps/opik-frontend/src/api/prompts/usePromptDeleteMutation.ts @@ -0,0 +1,38 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import get from "lodash/get"; +import { useToast } from "@/components/ui/use-toast"; +import api, { PROMPTS_REST_ENDPOINT } from "@/api/api"; + +type UsePromptDeleteMutationParams = { + promptId: string; +}; + +const usePromptDeleteMutation = () => { + const queryClient = useQueryClient(); + const { toast } = useToast(); + + return useMutation({ + mutationFn: async ({ promptId }: UsePromptDeleteMutationParams) => { + const { data } = await api.delete(PROMPTS_REST_ENDPOINT + promptId); + return data; + }, + onError: (error) => { + const message = get( + error, + ["response", "data", "message"], + error.message, + ); + + toast({ + title: "Error", + description: message, + variant: "destructive", + }); + }, + onSettled: () => { + return queryClient.invalidateQueries({ queryKey: ["prompts"] }); + }, + }); +}; + +export default usePromptDeleteMutation; diff --git a/apps/opik-frontend/src/api/prompts/usePromptUpdateMutation.ts b/apps/opik-frontend/src/api/prompts/usePromptUpdateMutation.ts new file mode 100644 index 000000000..84ed4cc1e --- /dev/null +++ b/apps/opik-frontend/src/api/prompts/usePromptUpdateMutation.ts @@ -0,0 +1,48 @@ +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { AxiosError } from "axios"; +import get from "lodash/get"; + +import api, { PROMPTS_REST_ENDPOINT } from "@/api/api"; +import { useToast } from "@/components/ui/use-toast"; +import { Prompt } from "@/types/prompts"; + +type UsePromptUpdateMutationParams = { + prompt: Partial; +}; + +const usePromptUpdateMutation = () => { + const queryClient = useQueryClient(); + const { toast } = useToast(); + + return useMutation({ + mutationFn: async ({ prompt }: UsePromptUpdateMutationParams) => { + const { id: promptId, ...restPrompt } = prompt; + + const { data } = await api.put( + `${PROMPTS_REST_ENDPOINT}${promptId}`, + restPrompt, + ); + + return data; + }, + + onError: (error: AxiosError) => { + const message = get( + error, + ["response", "data", "message"], + error.message, + ); + + toast({ + title: "Error", + description: message, + variant: "destructive", + }); + }, + onSettled: () => { + return queryClient.invalidateQueries({ queryKey: ["prompts"] }); + }, + }); +}; + +export default usePromptUpdateMutation; diff --git a/apps/opik-frontend/src/api/prompts/usePromptVersionById.ts b/apps/opik-frontend/src/api/prompts/usePromptVersionById.ts new file mode 100644 index 000000000..80eb9baa8 --- /dev/null +++ b/apps/opik-frontend/src/api/prompts/usePromptVersionById.ts @@ -0,0 +1,32 @@ +import { QueryFunctionContext, useQuery } from "@tanstack/react-query"; +import api, { PROMPTS_REST_ENDPOINT, QueryConfig } from "@/api/api"; +import { PromptVersion } from "@/types/prompts"; + +type UsePromptVersionByIdParams = { + versionId: string; +}; + +const getPromptVersionById = async ( + { signal }: QueryFunctionContext, + { versionId }: UsePromptVersionByIdParams, +) => { + const { data } = await api.get( + `${PROMPTS_REST_ENDPOINT}versions/${versionId}`, + { + signal, + }, + ); + + return data; +}; + +export default function usePromptVersionById( + params: UsePromptVersionByIdParams, + options?: QueryConfig, +) { + return useQuery({ + queryKey: ["prompt-version", params], + queryFn: (context) => getPromptVersionById(context, params), + ...options, + }); +} diff --git a/apps/opik-frontend/src/api/prompts/usePromptVersionsById.ts b/apps/opik-frontend/src/api/prompts/usePromptVersionsById.ts new file mode 100644 index 000000000..e60150a5b --- /dev/null +++ b/apps/opik-frontend/src/api/prompts/usePromptVersionsById.ts @@ -0,0 +1,45 @@ +import { QueryFunctionContext, useQuery } from "@tanstack/react-query"; +import api, { PROMPTS_REST_ENDPOINT, QueryConfig } from "@/api/api"; +import { PromptVersion } from "@/types/prompts"; + +type UsePromptVersionsByIdParams = { + promptId: string; + search?: string; + page: number; + size: number; +}; + +type UsePromptsVersionsByIdResponse = { + content: PromptVersion[]; + total: number; +}; + +const getPromptVersionsById = async ( + { signal }: QueryFunctionContext, + { promptId, size, page, search }: UsePromptVersionsByIdParams, +) => { + const { data } = await api.get( + `${PROMPTS_REST_ENDPOINT}${promptId}/versions`, + { + signal, + params: { + ...(search && { name: search }), + size, + page, + }, + }, + ); + + return data; +}; + +export default function usePromptVersionsById( + params: UsePromptVersionsByIdParams, + options?: QueryConfig, +) { + return useQuery({ + queryKey: ["prompt-versions", params], + queryFn: (context) => getPromptVersionsById(context, params), + ...options, + }); +} diff --git a/apps/opik-frontend/src/api/prompts/usePromptsList.ts b/apps/opik-frontend/src/api/prompts/usePromptsList.ts new file mode 100644 index 000000000..2c5c2b56a --- /dev/null +++ b/apps/opik-frontend/src/api/prompts/usePromptsList.ts @@ -0,0 +1,42 @@ +import { QueryFunctionContext, useQuery } from "@tanstack/react-query"; +import api, { PROMPTS_REST_ENDPOINT, QueryConfig } from "@/api/api"; +import { Prompt } from "@/types/prompts"; + +type UsePromptsListParams = { + workspaceName: string; + search?: string; + page: number; + size: number; +}; + +type UsePromptsListResponse = { + content: Prompt[]; + total: number; +}; + +const getPromptsList = async ( + { signal }: QueryFunctionContext, + { search, size, page }: UsePromptsListParams, +) => { + const { data } = await api.get(PROMPTS_REST_ENDPOINT, { + signal, + params: { + ...(search && { name: search }), + size, + page, + }, + }); + + return data; +}; + +export default function usePromptsList( + params: UsePromptsListParams, + options?: QueryConfig, +) { + return useQuery({ + queryKey: ["prompts", params], + queryFn: (context) => getPromptsList(context, params), + ...options, + }); +} diff --git a/apps/opik-frontend/src/components/layout/SideBar/SideBar.tsx b/apps/opik-frontend/src/components/layout/SideBar/SideBar.tsx index e3effc3cd..dae8a1e4b 100644 --- a/apps/opik-frontend/src/components/layout/SideBar/SideBar.tsx +++ b/apps/opik-frontend/src/components/layout/SideBar/SideBar.tsx @@ -11,6 +11,7 @@ import { MessageSquare, PanelLeftClose, MessageCircleQuestion, + FileTerminal, } from "lucide-react"; import { keepPreviousData } from "@tanstack/react-query"; @@ -27,6 +28,7 @@ import { buildDocsUrl, cn } from "@/lib/utils"; import Logo from "@/components/layout/Logo/Logo"; import usePluginsStore from "@/store/PluginsStore"; import ProvideFeedbackDialog from "@/components/layout/SideBar/FeedbackDialog/ProvideFeedbackDialog"; +import usePromptsList from "@/api/prompts/usePromptsList"; enum MENU_ITEM_TYPE { link = "link", @@ -53,6 +55,14 @@ const MAIN_MENU_ITEMS: MenuItem[] = [ label: "Projects", count: "projects", }, + { + id: "prompts", + path: "/$workspaceName/prompts", + type: MENU_ITEM_TYPE.router, + icon: FileTerminal, + label: "Prompt library", + count: "prompts", + }, { id: "datasets", path: "/$workspaceName/datasets", @@ -204,11 +214,24 @@ const SideBar: React.FunctionComponent = ({ }, ); + const { data: promptsData } = usePromptsList( + { + workspaceName, + page: 1, + size: 1, + }, + { + placeholderData: keepPreviousData, + enabled: expanded, + }, + ); + const countDataMap: Record = { projects: projectData?.total, datasets: datasetsData?.total, experiments: experimentsData?.total, feedbackDefinitions: feedbackDefinitions?.total, + prompts: promptsData?.total, }; const bottomMenuItems: MenuItem[] = [ @@ -292,7 +315,7 @@ const SideBar: React.FunctionComponent = ({ } return ( - + {itemElement} ); diff --git a/apps/opik-frontend/src/components/pages/CompareExperimentsPage/CompareExperimentsDetails.tsx b/apps/opik-frontend/src/components/pages/CompareExperimentsPage/CompareExperimentsDetails.tsx index 2d3f87aad..c175d0cea 100644 --- a/apps/opik-frontend/src/components/pages/CompareExperimentsPage/CompareExperimentsDetails.tsx +++ b/apps/opik-frontend/src/components/pages/CompareExperimentsPage/CompareExperimentsDetails.tsx @@ -3,24 +3,18 @@ import sortBy from "lodash/sortBy"; import uniq from "lodash/uniq"; import isUndefined from "lodash/isUndefined"; import { BooleanParam, useQueryParam } from "use-query-params"; -import { - Clock, - FlaskConical, - Maximize2, - Minimize2, - PenLine, -} from "lucide-react"; +import { FlaskConical, Maximize2, Minimize2, PenLine } from "lucide-react"; import useBreadcrumbsStore from "@/store/BreadcrumbsStore"; import FeedbackScoreTag from "@/components/shared/FeedbackScoreTag/FeedbackScoreTag"; import { Experiment } from "@/types/datasets"; import { TableBody, TableCell, TableRow } from "@/components/ui/table"; import { Tag } from "@/components/ui/tag"; -import { formatDate } from "@/lib/date"; import { Button } from "@/components/ui/button"; import ResourceLink, { RESOURCE_TYPE, } from "@/components/shared/ResourceLink/ResourceLink"; +import DateTag from "@/components/shared/DateTag/DateTag"; type CompareExperimentsDetailsProps = { experimentsIds: string[]; @@ -201,16 +195,7 @@ const CompareExperimentsDetails: React.FunctionComponent< {renderCompareFeedbackScoresButton()}
- {!isCompare && ( - - -
{formatDate(experiment?.created_at)}
-
- )} + {!isCompare && } { + const [tab, setTab] = useQueryParam("tab", StringParam); + + const promptId = usePromptIdFromURL(); + + const { data: prompt } = usePromptById({ promptId }, { enabled: !!promptId }); + const promptName = prompt?.name || ""; + const setBreadcrumbParam = useBreadcrumbsStore((state) => state.setParam); + + useEffect(() => { + if (promptId && promptName) { + setBreadcrumbParam("promptId", promptId, promptName); + } + }, [promptId, promptName, setBreadcrumbParam]); + + useEffect(() => { + if (!tab) { + setTab("prompt", "replaceIn"); + } + }, [tab, setTab]); + + return ( +
+
+
+

{promptName}

+
+ + {prompt?.created_at && ( +
+ +
+ )} +
+ + + + + Prompt + + + Commits + + + + + + + + + +
+ ); +}; + +export default PromptPage; diff --git a/apps/opik-frontend/src/components/pages/PromptPage/PromptTab/CommitHistory.tsx b/apps/opik-frontend/src/components/pages/PromptPage/PromptTab/CommitHistory.tsx new file mode 100644 index 000000000..d0c2998d9 --- /dev/null +++ b/apps/opik-frontend/src/components/pages/PromptPage/PromptTab/CommitHistory.tsx @@ -0,0 +1,75 @@ +import { Copy, GitCommitVertical } from "lucide-react"; +import copy from "clipboard-copy"; +import { cn } from "@/lib/utils"; + +import { formatDate } from "@/lib/date"; +import React, { useState } from "react"; +import TooltipWrapper from "@/components/shared/TooltipWrapper/TooltipWrapper"; +import { Button } from "@/components/ui/button"; +import { useToast } from "@/components/ui/use-toast"; +import { PromptVersion } from "@/types/prompts"; + +interface CommitHistoryProps { + versions: PromptVersion[]; + onVersionClick: (version: PromptVersion) => void; + activeVersionId: string; +} + +const CommitHistory = ({ + versions, + onVersionClick, + activeVersionId, +}: CommitHistoryProps) => { + const { toast } = useToast(); + const [hoveredVersionId, setHoveredVersionId] = useState(null); + + const handleCopyClick = async (versionId: string) => { + await copy(versionId); + + toast({ + description: "ID successfully copied to clipboard", + }); + }; + + return ( +
    + {versions?.map((version) => { + return ( +
  • setHoveredVersionId(version.id)} + onMouseLeave={() => setHoveredVersionId(null)} + onClick={() => onVersionClick(version)} + > +
    + + {version.id} + {hoveredVersionId == version.id && ( + + + + )} +
    +

    + {formatDate(version.created_at)} +

    +
  • + ); + })} +
+ ); +}; + +export default CommitHistory; diff --git a/apps/opik-frontend/src/components/pages/PromptPage/PromptTab/CommitsTab.tsx b/apps/opik-frontend/src/components/pages/PromptPage/PromptTab/CommitsTab.tsx new file mode 100644 index 000000000..1a7b26fc7 --- /dev/null +++ b/apps/opik-frontend/src/components/pages/PromptPage/PromptTab/CommitsTab.tsx @@ -0,0 +1,117 @@ +import React, { useCallback, useState } from "react"; + +import { PromptWithLatestVersion, PromptVersion } from "@/types/prompts"; +import Loader from "@/components/shared/Loader/Loader"; +import usePromptVersionsById from "@/api/prompts/usePromptVersionsById"; + +import { useNavigate } from "@tanstack/react-router"; +import useAppStore from "@/store/AppStore"; +import DataTable from "@/components/shared/DataTable/DataTable"; +import DataTableNoData from "@/components/shared/DataTableNoData/DataTableNoData"; +import DataTablePagination from "@/components/shared/DataTablePagination/DataTablePagination"; + +import { COLUMN_TYPE } from "@/types/shared"; +import IdCell from "@/components/shared/DataTableCells/IdCell"; +import { formatDate } from "@/lib/date"; +import { convertColumnDataToColumn } from "@/lib/table"; +import CodeCell from "@/components/shared/DataTableCells/CodeCell"; + +interface CommitsTabInterface { + prompt?: PromptWithLatestVersion; +} + +export const COMMITS_DEFAULT_COLUMNS = convertColumnDataToColumn< + PromptVersion, + PromptVersion +>( + [ + { + id: "id", + label: "Prompt commit", + type: COLUMN_TYPE.string, + cell: IdCell as never, + }, + { + id: "template", + label: "Prompt", + type: COLUMN_TYPE.dictionary, + cell: CodeCell as never, + }, + + { + id: "created_at", + label: "Created at", + type: COLUMN_TYPE.time, + accessorFn: (row) => formatDate(row.created_at), + }, + ], + {}, +); + +const CommitsTab = ({ prompt }: CommitsTabInterface) => { + const navigate = useNavigate(); + + const workspaceName = useAppStore((state) => state.activeWorkspaceName); + + const [page, setPage] = useState(1); + const [size, setSize] = useState(10); + + const { data, isPending } = usePromptVersionsById( + { + promptId: prompt?.id || "", + page: page, + size: size, + }, + { + enabled: !!prompt?.id, + }, + ); + + const versions = data?.content ?? []; + const total = data?.total ?? 0; + const noDataText = "There are no commits yet"; + + const handleRowClick = useCallback( + (version: PromptVersion) => { + if (prompt?.id) { + navigate({ + to: `/$workspaceName/prompts/$promptId`, + params: { + promptId: prompt.id, + workspaceName, + }, + search: { + activeVersionId: version.id, + }, + }); + } + }, + [prompt?.id, navigate, workspaceName], + ); + + if (isPending) { + return ; + } + + return ( +
+ } + /> +
+ +
+
+ ); +}; + +export default CommitsTab; diff --git a/apps/opik-frontend/src/components/pages/PromptPage/PromptTab/EditPromptDialog.tsx b/apps/opik-frontend/src/components/pages/PromptPage/PromptTab/EditPromptDialog.tsx new file mode 100644 index 000000000..4ba38a382 --- /dev/null +++ b/apps/opik-frontend/src/components/pages/PromptPage/PromptTab/EditPromptDialog.tsx @@ -0,0 +1,93 @@ +import React, { useState } from "react"; + +import { + Dialog, + DialogClose, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Textarea } from "@/components/ui/textarea"; +import { Label } from "@/components/ui/label"; +import { Button } from "@/components/ui/button"; +import useCreatePromptVersionMutation from "@/api/prompts/useCreatePromptVersionMutation"; + +type EditPromptDialogProps = { + open: boolean; + setOpen: (open: boolean) => void; + + promptTemplate: string; + promptName: string; + onSetActiveVersionId: (versionId: string) => void; +}; + +const EditPromptDialog: React.FunctionComponent = ({ + open, + setOpen, + promptTemplate: parentPromptTemplate, + promptName, + onSetActiveVersionId, +}) => { + const [promptTemplate, setPromptTemplate] = useState(parentPromptTemplate); + + const createPromptVersionMutation = useCreatePromptVersionMutation(); + + const handleClickEditPrompt = () => { + createPromptVersionMutation.mutate({ + name: promptName, + template: promptTemplate, + onSetActiveVersionId, + }); + }; + + const isValid = + promptTemplate?.length && promptTemplate !== parentPromptTemplate; + + return ( + + + + Edit prompt + +
+

+ By editing a prompt, a new commit will be created automatically. You + can access older versions of the prompt from the Commits tab. +

+ +
+ +