Added tool UI to agent

This commit is contained in:
Emir Karabeg
2025-02-03 21:56:27 -08:00
parent 838fee32bc
commit 08fe6f2d39
16 changed files with 562 additions and 15 deletions

View File

@@ -0,0 +1,304 @@
import { useState } from 'react'
import { Button } from '@/components/ui/button'
import { PlusIcon, XIcon } from 'lucide-react'
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command'
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover'
import { useSubBlockValue } from '../hooks/use-sub-block-value'
import { getAllBlocks } from '@/blocks'
import { cn } from '@/lib/utils'
import { getTool } from '@/tools'
import { ShortInput } from './short-input'
interface ToolInputProps {
blockId: string
subBlockId: string
}
// State interface - only what we need to store
interface StoredTool {
type: string
title: string
params: Record<string, string>
}
// UI interface - includes display properties
interface ToolDisplay {
type: string
icon: any
title: string
bgColor: string
requiredParams: ToolParam[]
}
interface SelectedTool {
type: string
icon: any
title: string
bgColor: string
requiredParams?: ToolParam[]
params?: Record<string, string>
}
interface ToolParam {
id: string
type: string
description?: string
requiredForToolCall: boolean
}
// Assumes the first tool in the access array is the tool to be used
// TODO: Switch to getting tools instead of tool blocks once we switch to providers
const getToolIdFromBlock = (blockType: string): string | undefined => {
const block = getAllBlocks().find((block) => block.type === blockType)
return block?.tools.access[0]
}
const getRequiredToolParams = (toolId: string): ToolParam[] => {
const tool = getTool(toolId)
if (!tool) return []
return Object.entries(tool.params)
.filter(([_, param]) => param.requiredForToolCall)
.map(([paramId, param]) => ({
id: paramId,
type: param.type,
description: param.description,
requiredForToolCall: param.requiredForToolCall ?? false,
}))
}
export function ToolInput({ blockId, subBlockId }: ToolInputProps) {
const [value, setValue] = useSubBlockValue(blockId, subBlockId)
const [open, setOpen] = useState(false)
const toolBlocks = getAllBlocks().filter(
(block) => block.toolbar.category === 'tools'
)
const selectedTools: StoredTool[] =
Array.isArray(value) && value.length > 0 && typeof value[0] === 'object'
? (value as unknown as StoredTool[])
: []
const handleSelectTool = (toolBlock: (typeof toolBlocks)[0]) => {
const toolId = getToolIdFromBlock(toolBlock.type)
// Only store essential data
const newTool: StoredTool = {
type: toolBlock.type,
title: toolBlock.toolbar.title,
params: {},
}
if (!selectedTools.some((tool) => tool.type === newTool.type)) {
setValue([...selectedTools, newTool])
}
setOpen(false)
}
const handleRemoveTool = (toolType: string) => {
setValue(selectedTools.filter((tool) => tool.type !== toolType))
}
const handleParamChange = (
toolType: string,
paramId: string,
paramValue: string
) => {
setValue(
selectedTools.map((tool) => {
if (tool.type === toolType) {
return {
...tool,
params: {
...tool.params,
[paramId]: paramValue,
},
}
}
return tool
})
)
}
const IconComponent = ({
icon: Icon,
className,
}: {
icon: any
className?: string
}) => {
if (!Icon) return null
return <Icon className={className} />
}
// Helper function to get the icon component for a tool type
const getToolIcon = (type: string) => {
const toolBlock = toolBlocks.find((block) => block.type === type)
return toolBlock?.toolbar.icon
}
return (
<div className="w-full">
{selectedTools.length === 0 ? (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<div className="flex h-10 w-full items-center justify-center rounded-md border border-input bg-transparent px-3 py-2 text-sm ring-offset-background transition-colors hover:bg-accent hover:text-accent-foreground cursor-pointer">
<div className="flex items-center text-base text-muted-foreground/50 md:text-sm">
<PlusIcon className="w-4 h-4 mr-2" />
Add Tool
</div>
</div>
</PopoverTrigger>
<PopoverContent className="p-0 w-[200px]" align="start">
<Command>
<CommandInput placeholder="Search tools..." />
<CommandList>
<CommandEmpty>No tools found.</CommandEmpty>
<CommandGroup>
{toolBlocks.map((block) => (
<CommandItem
key={block.type}
onSelect={() => handleSelectTool(block)}
className="flex items-center gap-2 cursor-pointer"
>
<div
className="flex items-center justify-center w-6 h-6 rounded"
style={{ backgroundColor: block.toolbar.bgColor }}
>
<IconComponent
icon={block.toolbar.icon}
className="w-4 h-4 text-white"
/>
</div>
<span>{block.toolbar.title}</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
) : (
<div className="flex flex-wrap gap-2 min-h-[2.5rem] w-full rounded-md border border-input bg-transparent px-3 py-2 text-sm ring-offset-background">
{selectedTools.map((tool) => {
// Get UI properties from toolBlocks for display
const toolBlock = toolBlocks.find(
(block) => block.type === tool.type
)
const toolId = getToolIdFromBlock(tool.type)
const requiredParams = toolId ? getRequiredToolParams(toolId) : []
return (
<div key={tool.type} className="group flex flex-col w-full">
<div className="flex flex-col rounded-md border bg-card overflow-visible">
<div className="flex items-center justify-between p-2 bg-accent/50">
<div className="flex items-center gap-2">
<div
className="flex items-center justify-center w-5 h-5 rounded"
style={{ backgroundColor: toolBlock?.toolbar.bgColor }}
>
<IconComponent
icon={toolBlock?.toolbar.icon}
className="w-3 h-3 text-white"
/>
</div>
<span className="text-sm font-medium">{tool.title}</span>
</div>
<button
onClick={() => handleRemoveTool(tool.type)}
className="text-muted-foreground hover:text-foreground"
>
<XIcon className="w-4 h-4" />
</button>
</div>
{requiredParams.length > 0 && (
<div className="p-3 space-y-3">
{requiredParams.map((param) => (
<div key={param.id} className="space-y-1.5 relative">
<div className="text-xs font-medium text-muted-foreground">
{param.id}
</div>
<div className="relative">
<input
type={
param.type === 'password' ? 'password' : 'text'
}
value={tool.params?.[param.id] || ''}
onChange={(e) =>
handleParamChange(
tool.type,
param.id,
e.target.value
)
}
placeholder={param.description}
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
/>
</div>
</div>
))}
</div>
)}
</div>
</div>
)
})}
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="sm"
className="h-6 px-2 text-xs text-muted-foreground hover:text-foreground"
>
<PlusIcon className="w-3 h-3" />
Add Tool
</Button>
</PopoverTrigger>
<PopoverContent className="p-0 w-[200px]" align="start">
<Command>
<CommandInput placeholder="Search tools..." />
<CommandList>
<CommandEmpty>No tools found.</CommandEmpty>
<CommandGroup>
{toolBlocks.map((block) => (
<CommandItem
key={block.type}
onSelect={() => handleSelectTool(block)}
className="flex items-center gap-2 cursor-pointer"
>
<div
className="flex items-center justify-center w-6 h-6 rounded"
style={{ backgroundColor: block.toolbar.bgColor }}
>
<IconComponent
icon={block.toolbar.icon}
className="w-4 h-4 text-white"
/>
</div>
<span>{block.toolbar.title}</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
)}
</div>
)
}

View File

@@ -7,6 +7,7 @@ import { SliderInput } from './components/slider-input'
import { Table } from './components/table'
import { Code } from './components/code'
import { Switch } from './components/switch'
import { ToolInput } from './components/tool-input'
interface SubBlockProps {
blockId: string
@@ -92,6 +93,8 @@ export function SubBlock({ blockId, config, isConnecting }: SubBlockProps) {
title={config.title}
/>
)
case 'tool-input':
return <ToolInput blockId={blockId} subBlockId={config.id} />
default:
return null
}

View File

@@ -38,7 +38,8 @@ export const AgentBlock: BlockConfig<ChatResponse> = {
context: { type: 'string', required: false },
apiKey: { type: 'string', required: true },
responseFormat: { type: 'json', required: false },
temperature: { type: 'number', required: false }
temperature: { type: 'number', required: false },
tools: { type: 'json', required: false }
},
outputs: {
response: {
@@ -89,12 +90,18 @@ export const AgentBlock: BlockConfig<ChatResponse> = {
password: true,
connectionDroppable: false
},
{
id: 'tools',
title: 'Tools',
type: 'tool-input',
layout: 'full'
},
{
id: 'responseFormat',
title: 'Response Format',
type: 'code',
layout: 'full'
}
},
]
}
}

View File

@@ -49,7 +49,6 @@ const toolToBlockType = Object.entries(blocks).reduce((acc, [blockType, config])
export const getBlock = (type: string): BlockConfig | undefined =>
blocks[type]
export const getBlocksByCategory = (category: 'blocks' | 'tools'): BlockConfig[] =>
Object.values(blocks).filter(block => block.toolbar.category === category)

View File

@@ -15,7 +15,7 @@ export type BlockOutput =
export type ParamType = 'string' | 'number' | 'boolean' | 'json'
export type SubBlockType = 'short-input' | 'long-input' | 'dropdown' | 'slider' | 'table' | 'code' | 'switch'
export type SubBlockType = 'short-input' | 'long-input' | 'dropdown' | 'slider' | 'table' | 'code' | 'switch' | 'tool-input'
export type SubBlockLayout = 'full' | 'half'
export interface OutputConfig {

169
components/ui/command.tsx Normal file
View File

@@ -0,0 +1,169 @@
// This file is not typed correctly from shadcn, so we're disabling the type checker
// @ts-nocheck
'use client'
import * as React from 'react'
import { type DialogProps } from '@radix-ui/react-dialog'
import { Command as CommandPrimitive } from 'cmdk'
import { Search } from 'lucide-react'
import { cn } from '@/lib/utils'
import { Dialog, DialogContent } from '@/components/ui/dialog'
const Command = React.forwardRef<
React.ElementRef<typeof CommandPrimitive>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive> & {
children?: React.ReactNode
}
>(({ className, ...props }, ref) => (
<CommandPrimitive
ref={ref}
className={cn(
'flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground',
className
)}
{...props}
/>
))
Command.displayName = CommandPrimitive.displayName
const CommandDialog = ({ children, ...props }: DialogProps) => {
return (
<Dialog {...props}>
<DialogContent className="overflow-hidden p-0 shadow-lg">
<Command className="[&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-group]]:px-2 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
</Dialog>
)
}
const CommandInput = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Input>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input> & {
placeholder?: string
}
>(({ className, ...props }, ref) => (
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
ref={ref}
className={cn(
'flex h-11 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50',
className
)}
{...props}
/>
</div>
))
CommandInput.displayName = CommandPrimitive.Input.displayName
const CommandList = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.List>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List> & {
children?: React.ReactNode
}
>(({ className, ...props }, ref) => (
<CommandPrimitive.List
ref={ref}
className={cn('max-h-[300px] overflow-y-auto overflow-x-hidden', className)}
{...props}
/>
))
CommandList.displayName = CommandPrimitive.List.displayName
const CommandEmpty = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Empty>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty> & {
children?: React.ReactNode
}
>((props, ref) => (
<CommandPrimitive.Empty
ref={ref}
className="py-6 text-center text-sm"
{...props}
/>
))
CommandEmpty.displayName = CommandPrimitive.Empty.displayName
const CommandGroup = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Group>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group> & {
children?: React.ReactNode
}
>(({ className, ...props }, ref) => (
<CommandPrimitive.Group
ref={ref}
className={cn(
'overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground',
className
)}
{...props}
/>
))
CommandGroup.displayName = CommandPrimitive.Group.displayName
const CommandSeparator = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
>(({ className, ...props }, ref) => (
<CommandPrimitive.Separator
ref={ref}
className={cn('-mx-1 h-px bg-border', className)}
{...props}
/>
))
CommandSeparator.displayName = CommandPrimitive.Separator.displayName
const CommandItem = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item> & {
children?: React.ReactNode
onSelect?: () => void
className?: string
}
>(({ className, ...props }, ref) => (
<CommandPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default gap-2 select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none data-[disabled=true]:pointer-events-none data-[selected='true']:bg-accent data-[selected=true]:text-accent-foreground data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
className
)}
{...props}
/>
))
CommandItem.displayName = CommandPrimitive.Item.displayName
const CommandShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn(
'ml-auto text-xs tracking-widest text-muted-foreground',
className
)}
{...props}
/>
)
}
CommandShortcut.displayName = 'CommandShortcut'
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
}

31
components/ui/popover.tsx Normal file
View File

@@ -0,0 +1,31 @@
"use client"
import * as React from "react"
import * as PopoverPrimitive from "@radix-ui/react-popover"
import { cn } from "@/lib/utils"
const Popover = PopoverPrimitive.Root
const PopoverTrigger = PopoverPrimitive.Trigger
const PopoverContent = React.forwardRef<
React.ElementRef<typeof PopoverPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof PopoverPrimitive.Content>
>(({ className, align = "center", sideOffset = 4, ...props }, ref) => (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
ref={ref}
align={align}
sideOffset={sideOffset}
className={cn(
"z-50 w-72 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
))
PopoverContent.displayName = PopoverPrimitive.Content.displayName
export { Popover, PopoverTrigger, PopoverContent }

43
package-lock.json generated
View File

@@ -9,10 +9,10 @@
"version": "0.1.0",
"dependencies": {
"@radix-ui/react-alert-dialog": "^1.1.5",
"@radix-ui/react-dialog": "^1.1.4",
"@radix-ui/react-dialog": "^1.1.5",
"@radix-ui/react-dropdown-menu": "^2.1.4",
"@radix-ui/react-label": "^2.1.1",
"@radix-ui/react-popover": "^1.1.4",
"@radix-ui/react-popover": "^1.1.5",
"@radix-ui/react-scroll-area": "^1.2.2",
"@radix-ui/react-select": "^2.1.4",
"@radix-ui/react-slider": "^1.2.2",
@@ -2362,15 +2362,15 @@
}
},
"node_modules/@radix-ui/react-popover": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.4.tgz",
"integrity": "sha512-aUACAkXx8LaFymDma+HQVji7WhvEhpFJ7+qPz17Nf4lLZqtreGOFRiNQWQmhzp7kEWg9cOyyQJpdIMUMPc/CPw==",
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.5.tgz",
"integrity": "sha512-YXkTAftOIW2Bt3qKH8vYr6n9gCkVrvyvfiTObVjoHVTHnNj26rmvO87IKa3VgtgCjb8FAQ6qOjNViwl+9iIzlg==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.1",
"@radix-ui/react-compose-refs": "1.1.1",
"@radix-ui/react-context": "1.1.1",
"@radix-ui/react-dismissable-layer": "1.1.3",
"@radix-ui/react-dismissable-layer": "1.1.4",
"@radix-ui/react-focus-guards": "1.1.1",
"@radix-ui/react-focus-scope": "1.1.1",
"@radix-ui/react-id": "1.1.0",
@@ -2380,8 +2380,35 @@
"@radix-ui/react-primitive": "2.0.1",
"@radix-ui/react-slot": "1.1.1",
"@radix-ui/react-use-controllable-state": "1.1.0",
"aria-hidden": "^1.1.1",
"react-remove-scroll": "^2.6.1"
"aria-hidden": "^1.2.4",
"react-remove-scroll": "^2.6.2"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-dismissable-layer": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.4.tgz",
"integrity": "sha512-XDUI0IVYVSwjMXxM6P4Dfti7AH+Y4oS/TB+sglZ/EXc7cqLwGAmp1NlMrcUjj7ks6R5WTZuWKv44FBbLpwU3sA==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.1",
"@radix-ui/react-compose-refs": "1.1.1",
"@radix-ui/react-primitive": "2.0.1",
"@radix-ui/react-use-callback-ref": "1.1.0",
"@radix-ui/react-use-escape-keydown": "1.1.0"
},
"peerDependencies": {
"@types/react": "*",

View File

@@ -12,10 +12,10 @@
},
"dependencies": {
"@radix-ui/react-alert-dialog": "^1.1.5",
"@radix-ui/react-dialog": "^1.1.4",
"@radix-ui/react-dialog": "^1.1.5",
"@radix-ui/react-dropdown-menu": "^2.1.4",
"@radix-ui/react-label": "^2.1.1",
"@radix-ui/react-popover": "^1.1.4",
"@radix-ui/react-popover": "^1.1.5",
"@radix-ui/react-scroll-area": "^1.2.2",
"@radix-ui/react-select": "^2.1.4",
"@radix-ui/react-slider": "^1.2.2",

View File

@@ -25,6 +25,7 @@ export const visionTool: ToolConfig<VisionParams, VisionResponse> = {
apiKey: {
type: 'string',
required: true,
requiredForToolCall: true,
description: 'API key for the selected model provider'
},
imageUrl: {

View File

@@ -41,6 +41,7 @@ export const scrapeTool: ToolConfig<ScrapeParams, ScrapeResponse> = {
apiKey: {
type: 'string',
required: true,
requiredForToolCall: true,
description: 'Firecrawl API key'
},
url: {

View File

@@ -36,6 +36,7 @@ export const repoInfoTool: ToolConfig<RepoInfoParams, RepoInfoResponse> = {
},
apiKey: {
type: 'string',
requiredForToolCall: true,
description: 'GitHub Personal Access Token'
}
},

View File

@@ -36,6 +36,7 @@ export const contactsTool: ToolConfig<ContactsParams, ContactsResponse> = {
apiKey: {
type: 'string',
required: true,
requiredForToolCall: true,
description: 'HubSpot API key'
},
email: {

View File

@@ -37,6 +37,7 @@ export const opportunitiesTool: ToolConfig<OpportunityParams, OpportunityRespons
apiKey: {
type: 'string',
required: true,
requiredForToolCall: true,
description: 'Salesforce API key'
},
action: {

View File

@@ -23,6 +23,7 @@ export const slackMessageTool: ToolConfig<SlackMessageParams, SlackMessageRespon
apiKey: {
type: 'string',
required: true,
requiredForToolCall: true,
description: 'Your Slack API token'
},
channel: {

View File

@@ -9,7 +9,7 @@ export interface ToolResponse {
export interface ToolConfig<P = any, R extends ToolResponse = ToolResponse> {
// Basic tool identification
id: string
name: string
name: string
description: string
version: string
@@ -17,6 +17,7 @@ export interface ToolConfig<P = any, R extends ToolResponse = ToolResponse> {
params: Record<string, {
type: string
required?: boolean
requiredForToolCall?: boolean
default?: any
description?: string
}>