diff --git a/ui/admin/app/components/composed/typography.tsx b/ui/admin/app/components/composed/typography.tsx index 9e4c336ab..97b8864fc 100644 --- a/ui/admin/app/components/composed/typography.tsx +++ b/ui/admin/app/components/composed/typography.tsx @@ -14,7 +14,7 @@ export function Truncate({ asChild, disableTooltip, tooltipContent = children, - clamp = false, + clamp = true, }: { children: React.ReactNode; className?: string; diff --git a/ui/admin/app/components/tools/ToolCatalog.tsx b/ui/admin/app/components/tools/ToolCatalog.tsx index 60f3cf0ac..3f692bbad 100644 --- a/ui/admin/app/components/tools/ToolCatalog.tsx +++ b/ui/admin/app/components/tools/ToolCatalog.tsx @@ -5,8 +5,9 @@ import useSWR from "swr"; import { OAuthProvider } from "~/lib/model/oauthApps/oauth-helpers"; import { ToolCategory, - ToolReferenceService, -} from "~/lib/service/api/toolreferenceService"; + convertToolReferencesToCategoryMap, +} from "~/lib/model/toolReferences"; +import { ToolReferenceService } from "~/lib/service/api/toolreferenceService"; import { cn } from "~/lib/utils"; import { ToolCatalogGroup } from "~/components/tools/ToolCatalogGroup"; @@ -41,11 +42,17 @@ export function ToolCatalog({ onUpdateTools, classNames, }: ToolCatalogProps) { - const { data: toolCategories, isLoading } = useSWR( - ToolReferenceService.getToolReferencesCategoryMap.key("tool"), - () => ToolReferenceService.getToolReferencesCategoryMap("tool"), - { fallbackData: {} } + const { data: toolList, isLoading } = useSWR( + ToolReferenceService.getToolReferences.key("tool"), + () => ToolReferenceService.getToolReferences("tool"), + { fallbackData: [] } ); + + const toolCategories = useMemo( + () => convertToolReferencesToCategoryMap(toolList), + [toolList] + ); + const [search, setSearch] = useState(""); const oauthApps = useOAuthAppList(); diff --git a/ui/admin/app/components/tools/ToolCatalogGroup.tsx b/ui/admin/app/components/tools/ToolCatalogGroup.tsx index 15457038f..ae8b0c4c9 100644 --- a/ui/admin/app/components/tools/ToolCatalogGroup.tsx +++ b/ui/admin/app/components/tools/ToolCatalogGroup.tsx @@ -1,6 +1,6 @@ import { useEffect, useState } from "react"; -import { ToolCategory } from "~/lib/service/api/toolreferenceService"; +import { ToolCategory } from "~/lib/model/toolReferences"; import { cn } from "~/lib/utils"; import { ToolItem } from "~/components/tools/ToolItem"; diff --git a/ui/admin/app/components/tools/toolGrid/ToolCard.tsx b/ui/admin/app/components/tools/toolGrid/ToolCard.tsx index a3602b7b5..d66ef4e24 100644 --- a/ui/admin/app/components/tools/toolGrid/ToolCard.tsx +++ b/ui/admin/app/components/tools/toolGrid/ToolCard.tsx @@ -6,6 +6,7 @@ import { cn, timeSince } from "~/lib/utils"; import { ConfirmationDialog } from "~/components/composed/ConfirmationDialog"; import { Truncate } from "~/components/composed/typography"; import { ToolIcon } from "~/components/tools/ToolIcon"; +import { ToolCardActions } from "~/components/tools/toolGrid/ToolCardActions"; import { Badge } from "~/components/ui/badge"; import { Button } from "~/components/ui/button"; import { @@ -33,15 +34,15 @@ export function ToolCard({ tool, onDelete }: ToolCardProps) { "border-2 border-error": tool.error, })} > - -

+ +

- {tool.name} + {tool.name}
{tool.error && ( @@ -59,6 +60,8 @@ export function ToolCard({ tool, onDelete }: ToolCardProps) { Bundle )}

+ +
{!tool.builtin && ( diff --git a/ui/admin/app/components/tools/toolGrid/ToolCardActions.tsx b/ui/admin/app/components/tools/toolGrid/ToolCardActions.tsx new file mode 100644 index 000000000..0175927a9 --- /dev/null +++ b/ui/admin/app/components/tools/toolGrid/ToolCardActions.tsx @@ -0,0 +1,50 @@ +import { EllipsisVerticalIcon } from "lucide-react"; +import { toast } from "sonner"; + +import { ToolReference } from "~/lib/model/toolReferences"; +import { ToolReferenceService } from "~/lib/service/api/toolreferenceService"; + +import { LoadingSpinner } from "~/components/ui/LoadingSpinner"; +import { Button } from "~/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "~/components/ui/dropdown-menu"; +import { useAsync } from "~/hooks/useAsync"; +import { usePollSingleTool } from "~/hooks/usePollSingleTool"; + +export function ToolCardActions({ tool }: { tool: ToolReference }) { + const { startPolling, isPolling } = usePollSingleTool(tool.id); + + const forceRefresh = useAsync( + ToolReferenceService.forceRefreshToolReference, + { + onSuccess: () => { + toast.success("Tool reference force refreshed"); + startPolling(); + }, + } + ); + + return ( +
+ {(forceRefresh.isLoading || isPolling) && } + + + + + + + + forceRefresh.execute(tool.id)}> + Refresh Tool + + + +
+ ); +} diff --git a/ui/admin/app/components/tools/toolGrid/ToolGrid.tsx b/ui/admin/app/components/tools/toolGrid/ToolGrid.tsx index 5648c8eac..fd0f17504 100644 --- a/ui/admin/app/components/tools/toolGrid/ToolGrid.tsx +++ b/ui/admin/app/components/tools/toolGrid/ToolGrid.tsx @@ -1,10 +1,10 @@ import { useCallback, useEffect, useState } from "react"; -import { ToolReference } from "~/lib/model/toolReferences"; import { CustomToolsToolCategory, ToolCategoryMap, -} from "~/lib/service/api/toolreferenceService"; + ToolReference, +} from "~/lib/model/toolReferences"; import { CategoryHeader } from "~/components/tools/toolGrid/CategoryHeader"; import { CategoryTools } from "~/components/tools/toolGrid/CategoryTools"; diff --git a/ui/admin/app/hooks/usePollSingleTool.ts b/ui/admin/app/hooks/usePollSingleTool.ts new file mode 100644 index 000000000..1de5e7c76 --- /dev/null +++ b/ui/admin/app/hooks/usePollSingleTool.ts @@ -0,0 +1,49 @@ +import { useEffect, useState } from "react"; +import useSWR from "swr"; + +import { ToolReferenceService } from "~/lib/service/api/toolreferenceService"; + +export function usePollSingleTool(toolId: string) { + const [isPolling, setIsPolling] = useState(false); + + const { mutate: updateTools } = useSWR( + isPolling ? ToolReferenceService.getToolReferences.key("tool") : null, + ({ type }) => ToolReferenceService.getToolReferences(type), + { fallbackData: [], revalidateIfStale: false } + ); + + const getTool = useSWR( + isPolling ? ToolReferenceService.getToolReferenceById.key(toolId) : null, + ({ toolReferenceId }) => + ToolReferenceService.getToolReferenceById(toolReferenceId), + { refreshInterval: 1000 } + ); + + useEffect(() => { + if (!getTool.data) return; + + setIsPolling(!getTool.data.resolved); + + // resolved means async update is complete + if (getTool.data.resolved) { + updateTools( + (tools) => { + if (!getTool.data) return tools; + if (!tools) return [getTool.data]; + + const index = tools.findIndex((tool) => tool.id === toolId); + + const copy = [...tools]; + copy[index] = getTool.data; + return copy; + }, + { revalidate: false } + ); + } + }, [getTool.data, updateTools, toolId]); + + return { + startPolling: () => setIsPolling(true), + isPolling, + }; +} diff --git a/ui/admin/app/lib/model/toolReferences.ts b/ui/admin/app/lib/model/toolReferences.ts index c6506552d..e6dedb318 100644 --- a/ui/admin/app/lib/model/toolReferences.ts +++ b/ui/admin/app/lib/model/toolReferences.ts @@ -7,6 +7,7 @@ export type ToolReferenceBase = { reference: string; resolved?: boolean; metadata?: Record; + revision: string; }; export type ToolReferenceType = "tool" | "stepTemplate" | "modelProvider"; @@ -28,3 +29,42 @@ export const toolReferenceToTemplate = (toolReference: ToolReference) => { args: toolReference.params, } as Template; }; + +export type ToolCategory = { + bundleTool?: ToolReference; + tools: ToolReference[]; +}; +export const UncategorizedToolCategory = "Uncategorized"; +export const CustomToolsToolCategory = "Custom Tools"; +export type ToolCategoryMap = Record; + +export function convertToolReferencesToCategoryMap( + toolReferences: ToolReference[] +) { + const result: ToolCategoryMap = {}; + + for (const toolReference of toolReferences) { + if (toolReference.deleted) { + // skip tools if marked with deleted + continue; + } + + const category = !toolReference.builtin + ? CustomToolsToolCategory + : toolReference.metadata?.category || UncategorizedToolCategory; + + if (!result[category]) { + result[category] = { + tools: [], + }; + } + + if (toolReference.metadata?.bundle === "true") { + result[category].bundleTool = toolReference; + } else { + result[category].tools.push(toolReference); + } + } + + return result; +} diff --git a/ui/admin/app/lib/routers/apiRoutes.ts b/ui/admin/app/lib/routers/apiRoutes.ts index 8332f3fc6..6b57b3251 100644 --- a/ui/admin/app/lib/routers/apiRoutes.ts +++ b/ui/admin/app/lib/routers/apiRoutes.ts @@ -223,6 +223,8 @@ export const ApiRoutes = { buildUrl("/tool-references", params), getById: (toolReferenceId: string) => buildUrl(`/tool-references/${toolReferenceId}`), + purgeCache: (toolReferenceId: string) => + buildUrl(`/tool-references/${toolReferenceId}/force-refresh`), }, users: { base: () => buildUrl("/users"), diff --git a/ui/admin/app/lib/service/api/toolreferenceService.ts b/ui/admin/app/lib/service/api/toolreferenceService.ts index f57922881..87e7ff054 100644 --- a/ui/admin/app/lib/service/api/toolreferenceService.ts +++ b/ui/admin/app/lib/service/api/toolreferenceService.ts @@ -18,54 +18,13 @@ async function getToolReferences(type?: ToolReferenceType) { getToolReferences.key = (type?: ToolReferenceType) => ({ url: ApiRoutes.toolReferences.base({ type }).path, + type, }) as const; - -export type ToolCategory = { - bundleTool?: ToolReference; - tools: ToolReference[]; +getToolReferences.revalidate = (type?: ToolReferenceType) => { + revalidateWhere((url) => + url.includes(ApiRoutes.toolReferences.base({ type }).path) + ); }; -export const UncategorizedToolCategory = "Uncategorized"; -export const CustomToolsToolCategory = "Custom Tools"; -export type ToolCategoryMap = Record; -async function getToolReferencesCategoryMap(type?: ToolReferenceType) { - const res = await request<{ items: ToolReference[] }>({ - url: ApiRoutes.toolReferences.base({ type }).url, - errorMessage: "Failed to fetch tool references category map", - }); - - const toolReferences = res.data.items; - const result: ToolCategoryMap = {}; - - for (const toolReference of toolReferences) { - if (toolReference.deleted) { - // skip tools if marked with deleted - continue; - } - - const category = !toolReference.builtin - ? CustomToolsToolCategory - : toolReference.metadata?.category || UncategorizedToolCategory; - - if (!result[category]) { - result[category] = { - tools: [], - }; - } - - if (toolReference.metadata?.bundle === "true") { - result[category].bundleTool = toolReference; - } else { - result[category].tools.push(toolReference); - } - } - - return result; -} -getToolReferencesCategoryMap.key = (type?: ToolReferenceType) => - ({ - url: ApiRoutes.toolReferences.base({ type }).path, - responseType: "map", - }) as const; const getToolReferenceById = async (toolReferenceId: string) => { const res = await request({ @@ -116,6 +75,16 @@ async function updateToolReference({ return res.data; } +async function forceRefreshToolReference(id: string) { + const res = await request({ + url: ApiRoutes.toolReferences.purgeCache(id).url, + method: "POST", + errorMessage: "Failed to force refresh tool reference", + }); + + return res.data; +} + async function deleteToolReference(id: string) { await request({ url: ApiRoutes.toolReferences.getById(id).url, @@ -124,15 +93,11 @@ async function deleteToolReference(id: string) { }); } -const revalidateToolReferences = () => - revalidateWhere((url) => url.includes(ApiRoutes.toolReferences.base().path)); - export const ToolReferenceService = { getToolReferences, - getToolReferencesCategoryMap, getToolReferenceById, createToolReference, updateToolReference, deleteToolReference, - revalidateToolReferences, + forceRefreshToolReference, }; diff --git a/ui/admin/app/routes/_auth.tools._index.tsx b/ui/admin/app/routes/_auth.tools._index.tsx index c6e84e1be..5069988f1 100644 --- a/ui/admin/app/routes/_auth.tools._index.tsx +++ b/ui/admin/app/routes/_auth.tools._index.tsx @@ -1,8 +1,9 @@ import { PlusIcon, SearchIcon } from "lucide-react"; -import { useState } from "react"; +import { useMemo, useState } from "react"; import { MetaFunction } from "react-router"; import useSWR, { preload } from "swr"; +import { convertToolReferencesToCategoryMap } from "~/lib/model/toolReferences"; import { ToolReferenceService } from "~/lib/service/api/toolreferenceService"; import { RouteHandle } from "~/lib/service/routeHandles"; @@ -23,18 +24,23 @@ import { ScrollArea } from "~/components/ui/scroll-area"; export async function clientLoader() { await Promise.all([ - preload(ToolReferenceService.getToolReferencesCategoryMap.key("tool"), () => - ToolReferenceService.getToolReferencesCategoryMap("tool") + preload(ToolReferenceService.getToolReferences.key("tool"), () => + ToolReferenceService.getToolReferences("tool") ), ]); return null; } export default function Tools() { - const { data: toolCategories, mutate } = useSWR( - ToolReferenceService.getToolReferencesCategoryMap.key("tool"), - () => ToolReferenceService.getToolReferencesCategoryMap("tool"), - { fallbackData: {} } + const getTools = useSWR( + ToolReferenceService.getToolReferences.key("tool"), + () => ToolReferenceService.getToolReferences("tool"), + { fallbackData: [] } + ); + + const toolCategories = useMemo( + () => convertToolReferencesToCategoryMap(getTools.data), + [getTools.data] ); const [isDialogOpen, setIsDialogOpen] = useState(false); @@ -42,17 +48,17 @@ export default function Tools() { const [errorDialogError, setErrorDialogError] = useState(""); const handleCreateSuccess = () => { - mutate(); + getTools.mutate(); setIsDialogOpen(false); }; const handleDelete = async (id: string) => { await ToolReferenceService.deleteToolReference(id); - mutate(); + getTools.mutate(); }; const handleErrorDialogError = (error: string) => { - mutate(); + getTools.mutate(); setErrorDialogError(error); setIsDialogOpen(false); };