Skip to content

Commit

Permalink
feat: allow users to force-refresh their custom tools (#1240)
Browse files Browse the repository at this point in the history
* feat: allow users to force-refresh their custom tools

Signed-off-by: Ryan Hopper-Lowe <ryan@acorn.io>

* fix: implement polling while asynchronous tool refresh is unresolved

- force refresh happens asynchronously from response. Must implement polling to ensure data displayed is up to date
- removes the redundant `ToolReferenceService.getToolReferencesCategoryMap` method by extracting unique code and applying it as needed in components

* chore: extract action code for easier updates

---------

Signed-off-by: Ryan Hopper-Lowe <ryan@acorn.io>
  • Loading branch information
ryanhopperlowe authored Jan 14, 2025
1 parent d7e5abb commit d569be4
Show file tree
Hide file tree
Showing 11 changed files with 196 additions and 74 deletions.
2 changes: 1 addition & 1 deletion ui/admin/app/components/composed/typography.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ export function Truncate({
asChild,
disableTooltip,
tooltipContent = children,
clamp = false,
clamp = true,
}: {
children: React.ReactNode;
className?: string;
Expand Down
19 changes: 13 additions & 6 deletions ui/admin/app/components/tools/ToolCatalog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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();
Expand Down
2 changes: 1 addition & 1 deletion ui/admin/app/components/tools/ToolCatalogGroup.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down
9 changes: 6 additions & 3 deletions ui/admin/app/components/tools/toolGrid/ToolCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -33,15 +34,15 @@ export function ToolCard({ tool, onDelete }: ToolCardProps) {
"border-2 border-error": tool.error,
})}
>
<CardHeader className="pb-2">
<h4 className="flex flex-wrap items-center gap-x-2 truncate">
<CardHeader className="flex flex-row justify-between space-y-0 pb-2">
<h4 className="flex flex-wrap items-center gap-x-2">
<div className="flex flex-nowrap gap-x-2">
<ToolIcon
className="h-5 w-5 min-w-5"
name={tool.name}
icon={tool.metadata?.icon}
/>
{tool.name}
<Truncate>{tool.name}</Truncate>
</div>
{tool.error && (
<Tooltip>
Expand All @@ -59,6 +60,8 @@ export function ToolCard({ tool, onDelete }: ToolCardProps) {
<Badge className="pointer-events-none">Bundle</Badge>
)}
</h4>

<ToolCardActions tool={tool} />
</CardHeader>
<CardContent className="flex-grow">
{!tool.builtin && (
Expand Down
50 changes: 50 additions & 0 deletions ui/admin/app/components/tools/toolGrid/ToolCardActions.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="flex items-center gap-2">
{(forceRefresh.isLoading || isPolling) && <LoadingSpinner />}

<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="m-0">
<EllipsisVerticalIcon />
</Button>
</DropdownMenuTrigger>

<DropdownMenuContent side="top" align="start">
<DropdownMenuItem onClick={() => forceRefresh.execute(tool.id)}>
Refresh Tool
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
}
4 changes: 2 additions & 2 deletions ui/admin/app/components/tools/toolGrid/ToolGrid.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down
49 changes: 49 additions & 0 deletions ui/admin/app/hooks/usePollSingleTool.ts
Original file line number Diff line number Diff line change
@@ -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,
};
}
40 changes: 40 additions & 0 deletions ui/admin/app/lib/model/toolReferences.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ export type ToolReferenceBase = {
reference: string;
resolved?: boolean;
metadata?: Record<string, string>;
revision: string;
};

export type ToolReferenceType = "tool" | "stepTemplate" | "modelProvider";
Expand All @@ -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<string, ToolCategory>;

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;
}
2 changes: 2 additions & 0 deletions ui/admin/app/lib/routers/apiRoutes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
67 changes: 16 additions & 51 deletions ui/admin/app/lib/service/api/toolreferenceService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, ToolCategory>;
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<ToolReference>({
Expand Down Expand Up @@ -116,6 +75,16 @@ async function updateToolReference({
return res.data;
}

async function forceRefreshToolReference(id: string) {
const res = await request<ToolReference>({
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,
Expand All @@ -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,
};
Loading

0 comments on commit d569be4

Please sign in to comment.