diff --git a/apiclient/types/toolreference.go b/apiclient/types/toolreference.go index 7c4510ecc..79160c179 100644 --- a/apiclient/types/toolreference.go +++ b/apiclient/types/toolreference.go @@ -15,6 +15,7 @@ const ( type ToolReferenceManifest struct { Name string `json:"name"` ToolType ToolReferenceType `json:"toolType"` + Commit string `json:"commit,omitempty"` Reference string `json:"reference,omitempty"` Active bool `json:"active,omitempty"` } @@ -22,12 +23,14 @@ type ToolReferenceManifest struct { type ToolReference struct { Metadata ToolReferenceManifest - Resolved bool `json:"resolved,omitempty"` - Error string `json:"error,omitempty"` - Builtin bool `json:"builtin,omitempty"` - Description string `json:"description,omitempty"` - Credentials []string `json:"credentials,omitempty"` - Params map[string]string `json:"params,omitempty"` + Resolved bool `json:"resolved,omitempty"` + Error string `json:"error,omitempty"` + Builtin bool `json:"builtin,omitempty"` + Description string `json:"description,omitempty"` + Credentials []string `json:"credentials,omitempty"` + Params map[string]string `json:"params,omitempty"` + Bundle bool `json:"bundle,omitempty"` + BundleToolName string `json:"bundleToolName,omitempty"` } type ToolReferenceList List[ToolReference] diff --git a/pkg/api/handlers/toolreferences.go b/pkg/api/handlers/toolreferences.go index 4dc0e2c24..10d30e306 100644 --- a/pkg/api/handlers/toolreferences.go +++ b/pkg/api/handlers/toolreferences.go @@ -12,6 +12,7 @@ import ( "github.com/obot-platform/obot/apiclient/types" "github.com/obot-platform/obot/pkg/api" v1 "github.com/obot-platform/obot/pkg/storage/apis/obot.obot.ai/v1" + "github.com/obot-platform/obot/pkg/tools" apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" ) @@ -33,10 +34,13 @@ func convertToolReference(toolRef v1.ToolReference) types.ToolReference { Name: toolRef.Name, ToolType: toolRef.Spec.Type, Reference: toolRef.Spec.Reference, + Commit: toolRef.Status.Commit, }, - Builtin: toolRef.Spec.Builtin, - Error: toolRef.Status.Error, - Resolved: toolRef.Generation == toolRef.Status.ObservedGeneration, + Builtin: toolRef.Spec.Builtin, + Bundle: toolRef.Spec.Bundle, + BundleToolName: toolRef.Spec.BundleToolName, + Error: toolRef.Status.Error, + Resolved: toolRef.Generation == toolRef.Status.ObservedGeneration, } if toolRef.Spec.Active == nil { tf.Active = true @@ -123,22 +127,22 @@ func (a *ToolReferenceHandler) Create(req api.Context) (err error) { return apierrors.NewBadRequest(fmt.Sprintf("invalid tool type %s", newToolReference.ToolType)) } - toolRef := &v1.ToolReference{ - ObjectMeta: metav1.ObjectMeta{ - Name: newToolReference.Name, - Namespace: req.Namespace(), - }, - Spec: v1.ToolReferenceSpec{ - Type: newToolReference.ToolType, - Reference: newToolReference.Reference, - }, + toolRefs, err := tools.ResolveToolReferences(req.Context(), a.gptscript, newToolReference.Name, newToolReference.Reference, false, newToolReference.ToolType) + if err != nil { + return apierrors.NewBadRequest(fmt.Sprintf("failed to resolve tool references for %s: %v", newToolReference.Reference, err)) } - if err = req.Create(toolRef); err != nil { - return err + if len(toolRefs) == 0 { + return apierrors.NewBadRequest(fmt.Sprintf("no tool references found for %s", newToolReference.Reference)) } - return req.Write(convertToolReference(*toolRef)) + for _, toolRef := range toolRefs { + if err := req.Create(toolRef); err != nil && !apierrors.IsAlreadyExists(err) { + return apierrors.NewInternalError(fmt.Errorf("failed to create tool reference %s: %w", toolRef.GetName(), err)) + } + } + + return req.Write(convertToolReference(*toolRefs[0])) } func (a *ToolReferenceHandler) Delete(req api.Context) error { @@ -166,6 +170,10 @@ func (a *ToolReferenceHandler) Delete(req api.Context) error { return types.NewErrBadRequest("cannot delete builtin tool reference %s", id) } + if !toolRef.Spec.Bundle && toolRef.Spec.BundleToolName != "" { + return types.NewErrBadRequest("cannot delete child tool that belongs to a bundle tool") + } + return req.Delete(&v1.ToolReference{ ObjectMeta: metav1.ObjectMeta{ Name: id, diff --git a/pkg/controller/controller.go b/pkg/controller/controller.go index d630c0fca..6331b7fd1 100644 --- a/pkg/controller/controller.go +++ b/pkg/controller/controller.go @@ -40,6 +40,9 @@ func (c *Controller) PreStart(ctx context.Context) error { if err := data.Data(ctx, c.services.StorageClient, c.services.AgentsDir); err != nil { return fmt.Errorf("failed to apply data: %w", err) } + if err := toolreference.MigrateToolNames(ctx, c.services.StorageClient); err != nil { + return fmt.Errorf("failed to migrate tool names: %w", err) + } return nil } diff --git a/pkg/controller/handlers/toolreference/migrate.go b/pkg/controller/handlers/toolreference/migrate.go new file mode 100644 index 000000000..8460c8331 --- /dev/null +++ b/pkg/controller/handlers/toolreference/migrate.go @@ -0,0 +1,83 @@ +package toolreference + +import ( + "context" + "errors" + + v1 "github.com/obot-platform/obot/pkg/storage/apis/obot.obot.ai/v1" + kclient "sigs.k8s.io/controller-runtime/pkg/client" +) + +var toolMigrations = map[string]string{ + "file-summarizer-file-summarizer": "file-summarizer", +} + +func MigrateToolNames(ctx context.Context, client kclient.Client) error { + if len(toolMigrations) == 0 { + return nil + } + + var agents v1.AgentList + if err := client.List(ctx, &agents); err != nil { + return err + } + + var workflows v1.WorkflowList + if err := client.List(ctx, &workflows); err != nil { + return err + } + + var threads v1.ThreadList + if err := client.List(ctx, &threads); err != nil { + return err + } + + var workflowSteps v1.WorkflowStepList + if err := client.List(ctx, &workflowSteps); err != nil { + return err + } + + var objs []kclient.Object + for _, agent := range agents.Items { + objs = append(objs, &agent) + } + for _, workflow := range workflows.Items { + objs = append(objs, &workflow) + } + for _, thread := range threads.Items { + objs = append(objs, &thread) + } + for _, step := range workflowSteps.Items { + objs = append(objs, &step) + } + + var tools []string + var errs []error + for _, obj := range objs { + switch o := obj.(type) { + case *v1.Agent: + tools = o.Spec.Manifest.Tools + case *v1.Workflow: + tools = o.Spec.Manifest.Tools + case *v1.Thread: + tools = o.Spec.Manifest.Tools + case *v1.WorkflowStep: + tools = o.Spec.Step.Tools + } + modified := false + for i, tool := range tools { + if newName, shouldMigrate := toolMigrations[tool]; shouldMigrate { + tools[i] = newName + modified = true + } + } + + if !modified { + continue + } + + errs = append(errs, client.Update(ctx, obj)) + } + + return errors.Join(errs...) +} diff --git a/pkg/controller/handlers/toolreference/toolreference.go b/pkg/controller/handlers/toolreference/toolreference.go index 0d2761880..c4a12fc98 100644 --- a/pkg/controller/handlers/toolreference/toolreference.go +++ b/pkg/controller/handlers/toolreference/toolreference.go @@ -23,6 +23,7 @@ import ( "github.com/obot-platform/obot/pkg/gateway/server/dispatcher" v1 "github.com/obot-platform/obot/pkg/storage/apis/obot.obot.ai/v1" "github.com/obot-platform/obot/pkg/system" + "github.com/obot-platform/obot/pkg/tools" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "k8s.io/apimachinery/pkg/fields" "sigs.k8s.io/controller-runtime/pkg/client" @@ -35,7 +36,6 @@ var jsonErrRegexp = regexp.MustCompile(`\{.*"error":.*}`) type indexEntry struct { Reference string `json:"reference,omitempty"` - All bool `json:"all,omitempty"` } type index struct { @@ -68,93 +68,23 @@ func New(gptClient *gptscript.GPTScript, } } -func isValidTool(tool gptscript.Tool) bool { - if tool.MetaData["index"] == "false" { - return false - } - return tool.Name != "" && (tool.Type == "" || tool.Type == "tool") -} - func (h *Handler) toolsToToolReferences(ctx context.Context, toolType types.ToolReferenceType, registryURL string, entries map[string]indexEntry) (result []client.Object) { - annotations := map[string]string{ - "obot.obot.ai/timestamp": time.Now().String(), - } for name, entry := range entries { if ref, ok := strings.CutPrefix(entry.Reference, "./"); ok { entry.Reference = registryURL + "/" + ref } + if !h.supportDocker && name == system.ShellTool { + continue + } - if entry.All { - prg, err := h.gptClient.LoadFile(ctx, "* from "+entry.Reference) - if err != nil { - log.Errorf("Failed to load tool %s: %v", entry.Reference, err) - continue - } + toolRefs, err := tools.ResolveToolReferences(ctx, h.gptClient, name, entry.Reference, true, toolType) + if err != nil { + log.Errorf("Failed to resolve tool references for %s: %v", entry.Reference, err) + continue + } - tool := prg.ToolSet[prg.EntryToolID] - if isValidTool(tool) { - toolName := tool.Name - if tool.MetaData["bundle"] == "true" { - toolName = "bundle" - } - result = append(result, &v1.ToolReference{ - ObjectMeta: metav1.ObjectMeta{ - Name: normalize(name, toolName), - Namespace: system.DefaultNamespace, - Finalizers: []string{v1.ToolReferenceFinalizer}, - Annotations: annotations, - }, - Spec: v1.ToolReferenceSpec{ - Type: toolType, - Reference: entry.Reference, - Builtin: true, - }, - }) - } - for _, peerToolID := range tool.LocalTools { - // If this is the entry tool, then we already added it or skipped it above. - if peerToolID == prg.EntryToolID { - continue - } - - peerTool := prg.ToolSet[peerToolID] - if isValidTool(peerTool) { - toolName := peerTool.Name - if peerTool.MetaData["bundle"] == "true" { - toolName += "-bundle" - } - result = append(result, &v1.ToolReference{ - ObjectMeta: metav1.ObjectMeta{ - Name: normalize(name, toolName), - Namespace: system.DefaultNamespace, - Finalizers: []string{v1.ToolReferenceFinalizer}, - Annotations: annotations, - }, - Spec: v1.ToolReferenceSpec{ - Type: toolType, - Reference: fmt.Sprintf("%s from %s", peerTool.Name, entry.Reference), - Builtin: true, - }, - }) - } - } - } else { - if !h.supportDocker && name == system.ShellTool { - continue - } - result = append(result, &v1.ToolReference{ - ObjectMeta: metav1.ObjectMeta{ - Name: name, - Namespace: system.DefaultNamespace, - Finalizers: []string{v1.ToolReferenceFinalizer}, - Annotations: annotations, - }, - Spec: v1.ToolReferenceSpec{ - Type: toolType, - Reference: entry.Reference, - Builtin: true, - }, - }) + for _, toolRef := range toolRefs { + result = append(result, toolRef) } } @@ -215,10 +145,6 @@ func (h *Handler) readFromRegistry(ctx context.Context, c client.Client) error { return apply.New(c).WithOwnerSubContext("toolreferences").Apply(ctx, nil, toAdd...) } -func normalize(names ...string) string { - return strings.ToLower(strings.ReplaceAll(strings.ReplaceAll(strings.Join(names, "-"), " ", "-"), "_", "-")) -} - func (h *Handler) PollRegistries(ctx context.Context, c client.Client) { if len(h.registryURLs) < 1 { return @@ -251,6 +177,7 @@ func (h *Handler) Populate(req router.Request, resp router.Response) error { toolRef.Status.LastReferenceCheck = metav1.Now() toolRef.Status.ObservedGeneration = toolRef.Generation toolRef.Status.Reference = toolRef.Spec.Reference + toolRef.Status.Commit = "" toolRef.Status.Tool = nil toolRef.Status.Error = "" @@ -269,6 +196,9 @@ func (h *Handler) Populate(req router.Request, resp router.Response) error { Metadata: tool.MetaData, Params: map[string]string{}, } + if tool.Source.Repo != nil { + toolRef.Status.Commit = tool.Source.Repo.Revision + } if tool.Arguments != nil { for name, param := range tool.Arguments.Properties { if param.Value != nil { diff --git a/pkg/controller/routes.go b/pkg/controller/routes.go index a0a0bc5d1..0b6d4a86e 100644 --- a/pkg/controller/routes.go +++ b/pkg/controller/routes.go @@ -98,6 +98,7 @@ func (c *Controller) setupRoutes() error { root.Type(&v1.KnowledgeSource{}).HandlerFunc(knowledgesource.Sync) // ToolReferences + root.Type(&v1.ToolReference{}).HandlerFunc(cleanup.Cleanup) root.Type(&v1.ToolReference{}).HandlerFunc(toolRef.Populate) root.Type(&v1.ToolReference{}).HandlerFunc(toolRef.BackPopulateModels) root.Type(&v1.ToolReference{}).IncludeFinalizing().HandlerFunc(removeOldFinalizers) diff --git a/pkg/storage/apis/obot.obot.ai/v1/toolreference.go b/pkg/storage/apis/obot.obot.ai/v1/toolreference.go index 8118f4bb5..070daa719 100644 --- a/pkg/storage/apis/obot.obot.ai/v1/toolreference.go +++ b/pkg/storage/apis/obot.obot.ai/v1/toolreference.go @@ -48,12 +48,20 @@ func (in *ToolReference) GetColumns() [][]string { } } +func (in *ToolReference) DeleteRefs() []Ref { + return []Ref{ + {ObjType: new(ToolReference), Name: in.Spec.BundleToolName}, + } +} + type ToolReferenceSpec struct { - Type types.ToolReferenceType `json:"type,omitempty"` - Builtin bool `json:"builtin,omitempty"` - Reference string `json:"reference,omitempty"` - Active *bool `json:"active,omitempty"` - ForceRefresh metav1.Time `json:"forceRefresh,omitempty"` + Type types.ToolReferenceType `json:"type,omitempty"` + Builtin bool `json:"builtin,omitempty"` + Reference string `json:"reference,omitempty"` + Active *bool `json:"active,omitempty"` + Bundle bool `json:"bundle,omitempty"` + BundleToolName string `json:"bundleToolName,omitempty"` + ForceRefresh metav1.Time `json:"forceRefresh,omitempty"` } type ToolShortDescription struct { @@ -70,6 +78,7 @@ type ToolShortDescription struct { type ToolReferenceStatus struct { Reference string `json:"reference,omitempty"` + Commit string `json:"commit,omitempty"` ObservedGeneration int64 `json:"observedGeneration,omitempty"` LastReferenceCheck metav1.Time `json:"lastReferenceCheck,omitempty"` Tool *ToolShortDescription `json:"tool,omitempty"` diff --git a/pkg/storage/openapi/generated/openapi_generated.go b/pkg/storage/openapi/generated/openapi_generated.go index 14fd37fca..4b5fbe338 100644 --- a/pkg/storage/openapi/generated/openapi_generated.go +++ b/pkg/storage/openapi/generated/openapi_generated.go @@ -4318,6 +4318,18 @@ func schema_obot_platform_obot_apiclient_types_ToolReference(ref common.Referenc }, }, }, + "bundle": { + SchemaProps: spec.SchemaProps{ + Type: []string{"boolean"}, + Format: "", + }, + }, + "bundleToolName": { + SchemaProps: spec.SchemaProps{ + Type: []string{"string"}, + Format: "", + }, + }, }, Required: []string{"Metadata", "ToolReferenceManifest"}, }, @@ -4375,6 +4387,12 @@ func schema_obot_platform_obot_apiclient_types_ToolReferenceManifest(ref common. Format: "", }, }, + "commit": { + SchemaProps: spec.SchemaProps{ + Type: []string{"string"}, + Format: "", + }, + }, "reference": { SchemaProps: spec.SchemaProps{ Type: []string{"string"}, @@ -8534,6 +8552,18 @@ func schema_storage_apis_obotobotai_v1_ToolReferenceSpec(ref common.ReferenceCal Format: "", }, }, + "bundle": { + SchemaProps: spec.SchemaProps{ + Type: []string{"boolean"}, + Format: "", + }, + }, + "bundleToolName": { + SchemaProps: spec.SchemaProps{ + Type: []string{"string"}, + Format: "", + }, + }, "forceRefresh": { SchemaProps: spec.SchemaProps{ Ref: ref("k8s.io/apimachinery/pkg/apis/meta/v1.Time"), @@ -8559,6 +8589,12 @@ func schema_storage_apis_obotobotai_v1_ToolReferenceStatus(ref common.ReferenceC Format: "", }, }, + "commit": { + SchemaProps: spec.SchemaProps{ + Type: []string{"string"}, + Format: "", + }, + }, "observedGeneration": { SchemaProps: spec.SchemaProps{ Type: []string{"integer"}, diff --git a/pkg/tools/resolve.go b/pkg/tools/resolve.go new file mode 100644 index 000000000..5342640f2 --- /dev/null +++ b/pkg/tools/resolve.go @@ -0,0 +1,109 @@ +package tools + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/gptscript-ai/go-gptscript" + "github.com/obot-platform/obot/apiclient/types" + v1 "github.com/obot-platform/obot/pkg/storage/apis/obot.obot.ai/v1" + "github.com/obot-platform/obot/pkg/system" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" +) + +func ResolveToolReferences(ctx context.Context, gptClient *gptscript.GPTScript, name, reference string, builtin bool, toolType types.ToolReferenceType) ([]*v1.ToolReference, error) { + annotations := map[string]string{ + "obot.obot.ai/timestamp": time.Now().String(), + } + + var result []*v1.ToolReference + + prg, err := gptClient.LoadFile(ctx, reference) + if err != nil { + return nil, err + } + + tool := prg.ToolSet[prg.EntryToolID] + isCapability := tool.MetaData["category"] == "Capability" + isBundleTool := tool.MetaData["bundle"] == "true" + + toolName := resolveToolReferenceName(toolType, isBundleTool, isCapability, name, "") + + entryTool := v1.ToolReference{ + ObjectMeta: metav1.ObjectMeta{ + Name: toolName, + Namespace: system.DefaultNamespace, + Finalizers: []string{v1.ToolReferenceFinalizer}, + Annotations: annotations, + }, + Spec: v1.ToolReferenceSpec{ + Type: toolType, + Reference: reference, + Builtin: builtin, + Bundle: isBundleTool, + }, + } + result = append(result, &entryTool) + + if isCapability || !isBundleTool { + return result, nil + } + + for _, peerToolID := range tool.LocalTools { + if peerToolID == prg.EntryToolID { + continue + } + + peerTool := prg.ToolSet[peerToolID] + if isValidTool(peerTool) { + toolName := resolveToolReferenceName(toolType, false, peerTool.MetaData["category"] == "Capability", name, peerTool.Name) + result = append(result, &v1.ToolReference{ + ObjectMeta: metav1.ObjectMeta{ + Name: toolName, + Namespace: system.DefaultNamespace, + Finalizers: []string{v1.ToolReferenceFinalizer}, + Annotations: annotations, + }, + Spec: v1.ToolReferenceSpec{ + Type: toolType, + Reference: fmt.Sprintf("%s from %s", peerTool.Name, reference), + Builtin: builtin, + BundleToolName: entryTool.Name, + }, + }) + } + } + + return result, nil +} + +func resolveToolReferenceName(toolType types.ToolReferenceType, isBundle bool, isCapability bool, toolName, subToolName string) string { + if toolType == types.ToolReferenceTypeTool { + if isBundle { + if isCapability { + return toolName + } + return toolName + "-bundle" + } + + if subToolName == "" { + return toolName + } + return normalize(toolName, subToolName) + } + + return toolName +} + +func normalize(names ...string) string { + return strings.ToLower(strings.ReplaceAll(strings.ReplaceAll(strings.Join(names, "-"), " ", "-"), "_", "-")) +} + +func isValidTool(tool gptscript.Tool) bool { + if tool.MetaData["index"] == "false" { + return false + } + return tool.Name != "" && (tool.Type == "" || tool.Type == "tool") +} diff --git a/ui/admin/app/components/tools/ToolCatalog.tsx b/ui/admin/app/components/tools/ToolCatalog.tsx index f6afea9ee..7f99825e6 100644 --- a/ui/admin/app/components/tools/ToolCatalog.tsx +++ b/ui/admin/app/components/tools/ToolCatalog.tsx @@ -3,8 +3,10 @@ import { useMemo, useState } from "react"; import useSWR from "swr"; import { - ToolCategory, - convertToolReferencesToCategoryMap, + CustomToolsToolCategory, + ToolReference, + UncategorizedToolCategory, + convertToolReferencesToMap, } from "~/lib/model/toolReferences"; import { ToolReferenceService } from "~/lib/service/api/toolreferenceService"; import { cn } from "~/lib/utils"; @@ -49,8 +51,8 @@ export function ToolCatalog({ { fallbackData: [] } ); - const toolCategories = useMemo( - () => convertToolReferencesToCategoryMap(toolList), + const toolMap = useMemo( + () => convertToolReferencesToMap(toolList), [toolList] ); @@ -73,17 +75,10 @@ export function ToolCatalog({ }, [toolList, configuredOauthApps]); const sortedValidCategories = useMemo(() => { - return Object.entries(toolCategories).sort( - ([nameA, categoryA], [nameB, categoryB]): number => { - const aHasBundle = categoryA.bundleTool ? 1 : 0; - const bHasBundle = categoryB.bundleTool ? 1 : 0; - - if (aHasBundle !== bHasBundle) return bHasBundle - aHasBundle; - - return nameA.localeCompare(nameB); - } - ); - }, [toolCategories]); + return Object.entries(toolMap).sort(([nameA], [nameB]): number => { + return nameA.localeCompare(nameB); + }); + }, [toolMap]); if (isLoading) return ; @@ -143,15 +138,28 @@ export function ToolCatalog({ No results found. - {results.map(([category, categoryTools]) => ( + {Object.entries( + results.reduce>((acc, [_, tool]) => { + const category = tool.metadata?.category + ? tool.metadata?.category + : tool.builtin + ? UncategorizedToolCategory + : CustomToolsToolCategory; + + if (!acc[category]) { + acc[category] = []; + } + acc[category].push(tool); + return acc; + }, {}) + ).map(([category, tools]) => ( @@ -180,42 +188,30 @@ export function ToolCatalogDialog(props: ToolCatalogProps) { } export function filterToolCatalogBySearch( - toolCategories: [string, ToolCategory][], + toolMap: [string, ToolReference][], query: string ) { - return toolCategories.reduce<[string, ToolCategory][]>( - (acc, [category, categoryData]) => { - const matchesSearch = (str: string) => - str.toLowerCase().includes(query.toLowerCase()); - - // Check if category name matches - if (matchesSearch(category)) { - acc.push([category, categoryData]); - return acc; - } - - // Check if bundle tool matches - if ( - categoryData.bundleTool && - matchesSearch(categoryData.bundleTool.name) - ) { - acc.push([category, categoryData]); - return acc; - } - - // Filter tools and only include category if it has matching tools - const filteredTools = categoryData.tools.filter( - (tool) => - matchesSearch(tool.name ?? "") || - matchesSearch(tool.description ?? "") - ); - - if (filteredTools.length > 0) { - acc.push([category, { ...categoryData, tools: filteredTools }]); - } + return toolMap.reduce<[string, ToolReference][]>((acc, [toolName, tool]) => { + const matchesSearch = (str: string) => + str.toLowerCase().includes(query.toLowerCase()); + // Check if category name matches + if (matchesSearch(tool?.metadata?.category ?? "")) { + acc.push([toolName, tool]); return acc; - }, - [] - ); + } + + // Check if bundle tool matches + if (matchesSearch(tool?.name ?? "")) { + acc.push([toolName, tool]); + return acc; + } + + if (matchesSearch(tool?.description ?? "")) { + acc.push([toolName, tool]); + return acc; + } + + return acc; + }, []); } diff --git a/ui/admin/app/components/tools/ToolCatalogGroup.tsx b/ui/admin/app/components/tools/ToolCatalogGroup.tsx index e07fa8e7b..8cb79d9c0 100644 --- a/ui/admin/app/components/tools/ToolCatalogGroup.tsx +++ b/ui/admin/app/components/tools/ToolCatalogGroup.tsx @@ -1,7 +1,9 @@ -import { useEffect, useState } from "react"; +import { useState } from "react"; -import { ToolCategory } from "~/lib/model/toolReferences"; -import { cn } from "~/lib/utils"; +import { + ToolReference, + UncategorizedToolCategory, +} from "~/lib/model/toolReferences"; import { ToolItem } from "~/components/tools/ToolItem"; import { CommandGroup } from "~/components/ui/command"; @@ -13,11 +15,10 @@ export function ToolCatalogGroup({ selectedTools, onAddTool, onRemoveTool, - expandFor, }: { category: string; configuredTools: Set; - tools: ToolCategory; + tools: ToolReference[]; selectedTools: string[]; onAddTool: ( toolId: string, @@ -26,89 +27,82 @@ export function ToolCatalogGroup({ ) => void; onRemoveTool: (toolId: string, oauthToRemove?: string) => void; oauths: string[]; - expandFor?: string; }) { - const handleSelect = (toolId: string, toolOauth?: string) => { + const handleSelect = ( + toolId: string, + bundleToolId: string, + toolOauth?: string + ) => { if (selectedTools.includes(toolId)) { onRemoveTool(toolId, toolOauth); } else { - onAddTool( - toolId, - tools.bundleTool?.id ? [tools.bundleTool.id] : [], - toolOauth - ); + onAddTool(toolId, [bundleToolId], toolOauth); } }; - const handleSelectBundle = (bundleToolId: string, toolOauth?: string) => { + const handleSelectBundle = ( + bundleToolId: string, + bundleTool: ToolReference, + toolOauth?: string + ) => { if (selectedTools.includes(bundleToolId)) { onRemoveTool(bundleToolId, toolOauth); } else { onAddTool( bundleToolId, - tools.tools.map((tool) => tool.id), + bundleTool.tools?.map((tool) => tool.id) ?? [], toolOauth ); } }; - const [expanded, setExpanded] = useState(() => { - const set = new Set(tools.tools.map((tool) => tool.id)); - return selectedTools.some((tool) => set.has(tool)); - }); - - useEffect(() => { - const containsMatchingTool = - expandFor?.length && - tools.tools.some( - (tool) => - tool.description?.toLowerCase().includes(expandFor) || - tool.name?.toLowerCase().includes(expandFor) - ); - setExpanded(containsMatchingTool || false); - }, [expandFor, tools]); + const [expanded, setExpanded] = useState>({}); return ( - {tools.bundleTool && ( - - handleSelectBundle(tools.bundleTool!.id, toolOauthToAdd) - } - expanded={expanded} - onExpand={setExpanded} - isBundle - /> - )} + {tools.map((tool) => { + const configured = configuredTools.has(tool.id); - {(expanded || !tools.bundleTool) && - tools.tools.map((categoryTool) => ( - - handleSelect(categoryTool.id, toolOauthToAdd) - } - /> - ))} + return ( + <> + + handleSelectBundle(tool.id, tool, toolOauthToAdd) + } + expanded={expanded[tool.id]} + onExpand={(expanded) => { + setExpanded((prev) => ({ + ...prev, + [tool.id]: expanded, + })); + }} + isBundle + /> + + {expanded[tool.id] && + tool.tools?.map((categoryTool) => ( + + handleSelect(categoryTool.id, tool.id, toolOauthToAdd) + } + /> + ))} + > + ); + })} ); } diff --git a/ui/admin/app/components/tools/ToolItem.tsx b/ui/admin/app/components/tools/ToolItem.tsx index e61319808..aa9469296 100644 --- a/ui/admin/app/components/tools/ToolItem.tsx +++ b/ui/admin/app/components/tools/ToolItem.tsx @@ -95,10 +95,10 @@ export function ToolItem({ - {tool.name} + {tool.name || normalizeToolID(tool.id)} @@ -138,3 +138,7 @@ export function ToolItem({ > ); } + +function normalizeToolID(toolId: string) { + return toolId.replace(/-/g, " "); +} diff --git a/ui/admin/app/components/tools/toolGrid/ToolCard.tsx b/ui/admin/app/components/tools/toolGrid/ToolCard.tsx index e01b80814..08b5f79ce 100644 --- a/ui/admin/app/components/tools/toolGrid/ToolCard.tsx +++ b/ui/admin/app/components/tools/toolGrid/ToolCard.tsx @@ -1,3 +1,5 @@ +import { GitCommitIcon } from "lucide-react"; + import { ToolReference } from "~/lib/model/toolReferences"; import { cn } from "~/lib/utils/cn"; @@ -5,12 +7,22 @@ import { Truncate } from "~/components/composed/typography"; import { ToolIcon } from "~/components/tools/ToolIcon"; import { ToolCardActions } from "~/components/tools/toolGrid/ToolCardActions"; import { Button } from "~/components/ui/button"; -import { Card, CardContent, CardHeader } from "~/components/ui/card"; +import { + Card, + CardContent, + CardFooter, + CardHeader, +} from "~/components/ui/card"; import { Popover, PopoverContent, PopoverTrigger, } from "~/components/ui/popover"; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from "~/components/ui/tooltip"; export function ToolCard({ tool, @@ -19,6 +31,17 @@ export function ToolCard({ tool: ToolReference; HeaderRightContent?: React.ReactNode; }) { + const getToolCommitURL = (tool: ToolReference) => { + if (tool.reference?.startsWith("github.com")) { + const parts = tool.reference.split("/"); + const [org, repo, ...rest] = parts.slice(1); + const path = rest.join("/"); + const pathWithGpt = path.endsWith(".gpt") ? path : `${path}/tool.gpt`; + return `https://github.com/${org}/${repo}/blob/${tool.commit}/${pathWithGpt}`; + } + return tool.reference; + }; + return ( )} + {tool.commit && ( + + + + { + window.open( + getToolCommitURL(tool), + "_blank", + "noopener,noreferrer" + ); + }} + > + + + + + View Commit + + + + )} ); } diff --git a/ui/admin/app/components/tools/toolGrid/ToolGrid.tsx b/ui/admin/app/components/tools/toolGrid/ToolGrid.tsx index 4ad09876e..62b92a7a2 100644 --- a/ui/admin/app/components/tools/toolGrid/ToolGrid.tsx +++ b/ui/admin/app/components/tools/toolGrid/ToolGrid.tsx @@ -2,33 +2,22 @@ import { useMemo } from "react"; import { CustomToolsToolCategory, - ToolCategory, ToolReference, } from "~/lib/model/toolReferences"; import { BundleToolList } from "~/components/tools/toolGrid/BundleToolList"; import { ToolCard } from "~/components/tools/toolGrid/ToolCard"; -export function ToolGrid({ - toolCategories, -}: { - toolCategories: [string, ToolCategory][]; -}) { +export function ToolGrid({ toolMap }: { toolMap: [string, ToolReference][] }) { const { customTools, builtinTools } = useMemo(() => { - return separateCustomAndBuiltinTools(toolCategories); - }, [toolCategories]); + return separateCustomAndBuiltinTools(toolMap); + }, [toolMap]); const sortedCustomTools = customTools.sort((a, b) => { // Sort by created descending for custom tools - const aCreatedAt = - "bundleTool" in a - ? a.bundleTool?.created - : (a as ToolReference).created; - const bCreatedAt = - "bundleTool" in b - ? b.bundleTool?.created - : (b as ToolReference).created; + const aCreatedAt = a.created; + const bCreatedAt = b.created; return ( new Date(bCreatedAt ?? "").getTime() - @@ -37,10 +26,8 @@ export function ToolGrid({ }) ?? []; const sortedBuiltinTools = builtinTools.sort((a, b) => { - const aName = - "bundleTool" in a ? a.bundleTool?.name : (a as ToolReference).name; - const bName = - "bundleTool" in b ? b.bundleTool?.name : (b as ToolReference).name; + const aName = a.name; + const bName = b.name; return (aName ?? "").localeCompare(bName ?? ""); }); @@ -50,7 +37,7 @@ export function ToolGrid({ {CustomToolsToolCategory} - {sortedCustomTools.map(renderToolCard)} + {sortedCustomTools.map((tool) => renderToolCard(tool))} )} @@ -59,52 +46,37 @@ export function ToolGrid({ Built-in Tools - {sortedBuiltinTools.map(renderToolCard)} + {sortedBuiltinTools.map((tool) => renderToolCard(tool))} )} ); - function renderToolCard(item: ToolCategory | ToolReference) { - if ("bundleTool" in item && item.bundleTool) { - return ( - 0 ? ( - - ) : null - } - tool={item.bundleTool} - /> - ); - } - - if ("name" in item) return ; - - return null; + function renderToolCard(item: ToolReference) { + return ( + 0 ? ( + + ) : null + } + tool={item} + /> + ); } - function separateCustomAndBuiltinTools( - toolCategories: [string, ToolCategory][] - ) { - return toolCategories.reduce<{ - customTools: (ToolCategory | ToolReference)[]; - builtinTools: (ToolCategory | ToolReference)[]; + function separateCustomAndBuiltinTools(toolMap: [string, ToolReference][]) { + return toolMap.reduce<{ + customTools: ToolReference[]; + builtinTools: ToolReference[]; }>( - (acc, [, { bundleTool, tools }]) => { - if (bundleTool) { - const key = bundleTool.builtin ? "builtinTools" : "customTools"; - acc[key].push({ bundleTool, tools }); + (acc, [_, tool]) => { + if (tool.builtin) { + acc.builtinTools.push(tool); } else { - tools.forEach((tool) => { - if (tool.builtin) { - acc.builtinTools.push(tool); - } else { - acc.customTools.push(tool); - } - }); + acc.customTools.push(tool); } return acc; }, diff --git a/ui/admin/app/lib/model/toolReferences.ts b/ui/admin/app/lib/model/toolReferences.ts index 769866742..8fea80092 100644 --- a/ui/admin/app/lib/model/toolReferences.ts +++ b/ui/admin/app/lib/model/toolReferences.ts @@ -13,12 +13,16 @@ export type ToolReferenceBase = { export type ToolReferenceType = "tool" | "stepTemplate" | "modelProvider"; export type ToolReference = { + bundle: boolean; + bundleToolName?: string; + tools?: ToolReference[]; description: string; builtin: boolean; active: boolean; credentials?: string[]; error?: string; params?: Record; + commit?: string; } & EntityMeta & ToolReferenceBase; @@ -32,15 +36,11 @@ export const toolReferenceToTemplate = (toolReference: ToolReference) => { } as Template; }; -export type ToolCategory = { - bundleTool?: ToolReference; - tools: ToolReference[]; -}; export const UncategorizedToolCategory = "Uncategorized"; export const CustomToolsToolCategory = "Custom Tools"; export const CapabilitiesToolCategory = "Capability"; -export type ToolCategoryMap = Record; +export type ToolMap = Record; export const CapabilityTool = { Knowledge: "knowledge", @@ -55,36 +55,47 @@ export function isCapabilityTool(toolReference: ToolReference) { return toolReference.metadata?.category === CapabilitiesToolCategory; } -export function convertToolReferencesToCategoryMap( - toolReferences: ToolReference[] -) { - const result: ToolCategoryMap = {}; +export function convertToolReferencesToMap(toolReferences: ToolReference[]) { + // Convert array of tools to a map keyed by tool name + const toolMap = new Map(toolReferences.map((tool) => [tool.id, tool])); + const result: ToolMap = {}; for (const toolReference of toolReferences) { - if (toolReference.deleted) { - // skip tools if marked with deleted + if (toolReference.deleted || isCapabilityTool(toolReference)) { continue; } - // skip capabilities - if (isCapabilityTool(toolReference)) { - continue; - } - - const category = - toolReference.metadata?.category || UncategorizedToolCategory; - - if (!result[category]) { - result[category] = { + if (toolReference.bundle) { + // Handle bundle tool + if (!result[toolReference.id]) { + result[toolReference.id] = { + ...toolReference, + tools: [], + }; + } + } else if (toolReference.bundleToolName) { + // Handle tool that belongs to a bundle + const bundleTool = toolMap.get(toolReference.bundleToolName); + if (bundleTool && !isCapabilityTool(bundleTool)) { + if (!result[toolReference.bundleToolName]) { + result[toolReference.bundleToolName] = { + ...bundleTool, + tools: [toolReference], + }; + } else { + if (!result[toolReference.bundleToolName].tools) { + throw new Error("This should never happen"); + } + result[toolReference.bundleToolName].tools?.push(toolReference); + } + } + } else { + // Handle standalone tool + result[toolReference.id] = { + ...toolReference, tools: [], }; } - - if (toolReference.metadata?.bundle === "true") { - result[category].bundleTool = toolReference; - } else { - result[category].tools.push(toolReference); - } } return result; diff --git a/ui/admin/app/routes/_auth.tools._index.tsx b/ui/admin/app/routes/_auth.tools._index.tsx index 485daa614..5a71326c3 100644 --- a/ui/admin/app/routes/_auth.tools._index.tsx +++ b/ui/admin/app/routes/_auth.tools._index.tsx @@ -3,7 +3,7 @@ import { useMemo, useState } from "react"; import { MetaFunction } from "react-router"; import useSWR, { preload } from "swr"; -import { convertToolReferencesToCategoryMap } from "~/lib/model/toolReferences"; +import { convertToolReferencesToMap } from "~/lib/model/toolReferences"; import { OauthAppService } from "~/lib/service/api/oauthAppService"; import { ToolReferenceService } from "~/lib/service/api/toolreferenceService"; import { RouteHandle } from "~/lib/service/routeHandles"; @@ -33,8 +33,8 @@ export default function Tools() { { fallbackData: [] } ); - const toolCategories = useMemo( - () => Object.entries(convertToolReferencesToCategoryMap(getTools.data)), + const toolMap = useMemo( + () => convertToolReferencesToMap(getTools.data), [getTools.data] ); @@ -42,8 +42,8 @@ export default function Tools() { const results = searchQuery.length > 0 - ? filterToolCatalogBySearch(toolCategories, searchQuery) - : toolCategories; + ? filterToolCatalogBySearch(Object.entries(toolMap), searchQuery) + : Object.entries(toolMap); return ( @@ -65,7 +65,7 @@ export default function Tools() { - + ); diff --git a/ui/admin/test/mocks/models/toolReferences.ts b/ui/admin/test/mocks/models/toolReferences.ts index a6bda2ac1..a1f1c4461 100644 --- a/ui/admin/test/mocks/models/toolReferences.ts +++ b/ui/admin/test/mocks/models/toolReferences.ts @@ -16,6 +16,7 @@ export const mockedDatabaseToolReference: ToolReference = { resolved: true, builtin: true, description: "Tools for interacting with a database", + bundle: false, }; export const mockedKnowledgeToolReference: ToolReference = { @@ -39,6 +40,7 @@ export const mockedKnowledgeToolReference: ToolReference = { params: { Query: "A search query that will be evaluated against the knowledge set", }, + bundle: false, }; export const mockedTasksToolReference: ToolReference = { @@ -57,6 +59,7 @@ export const mockedTasksToolReference: ToolReference = { resolved: true, builtin: true, description: "Manage and execute tasks", + bundle: false, }; export const mockedWorkspaceFilesToolReference: ToolReference = { @@ -76,6 +79,7 @@ export const mockedWorkspaceFilesToolReference: ToolReference = { builtin: true, description: "Adds the capability for users to read and write workspace files", + bundle: false, }; export const mockedImageToolBundle: ToolReference[] = [ @@ -85,7 +89,6 @@ export const mockedImageToolBundle: ToolReference[] = [ revision: "1", metadata: { bundle: "true", - category: "Images", icon: "https://www.mock.com/assets/images_icon.svg", }, type: "toolreference", @@ -97,13 +100,13 @@ export const mockedImageToolBundle: ToolReference[] = [ builtin: true, description: "Tools for analyzing and generating images", credentials: ["github.com/gptscript-ai/credentials/model-provider"], + bundle: true, }, { id: "images-analyze-images", created: "2025-01-29T11:10:12-05:00", revision: "1", metadata: { - category: "Images", icon: "https://www.mock.com/assets/images_icon.svg", noUserAuth: "sys.model.provider.credential", }, @@ -123,6 +126,7 @@ export const mockedImageToolBundle: ToolReference[] = [ prompt: '(optional) A prompt to analyze the images with (defaults "Provide a brief description of each image")', }, + bundle: false, }, ]; @@ -133,7 +137,6 @@ export const mockedBrowserToolBundle: ToolReference[] = [ revision: "1", metadata: { bundle: "true", - category: "Browser", icon: "https://www.mock.com/assets/browser_icon.svg", noUserAuth: "sys.model.provider.credential", }, @@ -146,13 +149,13 @@ export const mockedBrowserToolBundle: ToolReference[] = [ builtin: true, description: "Tools to navigate websites using a browser.", credentials: ["github.com/gptscript-ai/credentials/model-provider"], + bundle: true, }, { id: "browser-download-file-from-url", created: "2025-01-29T11:10:12-05:00", revision: "1", metadata: { - category: "Browser", icon: "https://www.mock.com/assets/browser_icon.svg", }, type: "toolreference", @@ -170,6 +173,8 @@ export const mockedBrowserToolBundle: ToolReference[] = [ "(required) The name of the workspace file to save the content to.", url: "(required) The URL of the file to download.", }, + bundle: false, + bundleToolName: "browser-bundle", }, ]; @@ -180,7 +185,6 @@ export const mockedBundleWithOauthReference: ToolReference[] = [ revision: "1", metadata: { bundle: "true", - category: "Gmail", icon: "gmail_icon_small.png", oauth: "google", }, @@ -193,13 +197,13 @@ export const mockedBundleWithOauthReference: ToolReference[] = [ builtin: true, description: "Tools for interacting with a user's Gmail account", credentials: ["github.com/obot-platform/tools/google/credential"], + bundle: true, }, { id: "google-gmail-list-drafts", created: "2025-02-05T13:54:26-05:00", revision: "1", metadata: { - category: "Gmail", icon: "gmail_icon_small.png", oauth: "google", }, @@ -218,6 +222,8 @@ export const mockedBundleWithOauthReference: ToolReference[] = [ max_results: "Maximum number of drafts to list (Optional: Default will list 100 drafts)", }, + bundle: false, + bundleToolName: "google-gmail-bundle", }, ];
View Commit