feat(custom-functions): completed UI

This commit is contained in:
Emir Karabeg
2025-02-27 15:03:30 -08:00
committed by Waleed Latif
parent 07469f1966
commit 4158dd33e7
8 changed files with 1282 additions and 298 deletions
@@ -1,294 +0,0 @@
import { useState } from 'react'
import { PlusIcon, XIcon } from 'lucide-react'
import { Button } from '@/components/ui/button'
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
} from '@/components/ui/command'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { cn } from '@/lib/utils'
import { useWorkflowStore } from '@/stores/workflow/store'
import { getAllBlocks } from '@/blocks'
import { getTool } from '@/tools'
import { useSubBlockValue } from '../hooks/use-sub-block-value'
import { ShortInput } from './short-input'
interface ToolInputProps {
blockId: string
subBlockId: string
}
interface StoredTool {
type: string
title: string
params: Record<string, string>
isExpanded?: boolean
}
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 isWide = useWorkflowStore((state) => state.blocks[blockId]?.isWide)
const toolBlocks = getAllBlocks().filter((block) => block.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]) => {
// Check if tool already exists
if (selectedTools.some((tool) => tool.type === toolBlock.type)) {
setOpen(false)
return
}
const toolId = getToolIdFromBlock(toolBlock.type)
const newTool: StoredTool = {
type: toolBlock.type,
title: toolBlock.name,
params: {},
isExpanded: true,
}
// If isWide, keep tools in the same row expanded
if (isWide) {
setValue([
...selectedTools.map((tool, index) => ({
...tool,
// Keep expanded if it's in the same row as the new tool
isExpanded: Math.floor(selectedTools.length / 2) === Math.floor(index / 2),
})),
newTool,
])
} else {
// Original behavior for non-wide mode
setValue([...selectedTools.map((tool) => ({ ...tool, isExpanded: false })), 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) =>
tool.type === toolType
? {
...tool,
params: {
...tool.params,
[paramId]: paramValue,
},
}
: tool
)
)
}
const toggleToolExpansion = (toolType: string) => {
setValue(
selectedTools.map((tool) =>
tool.type === toolType ? { ...tool, isExpanded: !tool.isExpanded } : tool
)
)
}
const IconComponent = ({ icon: Icon, className }: { icon: any; className?: string }) => {
if (!Icon) return null
return <Icon className={className} />
}
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.bgColor }}
>
<IconComponent icon={block.icon} className="w-4 h-4 text-white" />
</div>
<span>{block.name}</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) => {
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={cn('group flex flex-col', isWide ? 'w-[calc(50%-0.25rem)]' : '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 cursor-pointer"
onClick={() => toggleToolExpansion(tool.type)}
>
<div className="flex items-center gap-2">
<div
className="flex items-center justify-center w-5 h-5 rounded"
style={{ backgroundColor: toolBlock?.bgColor }}
>
<IconComponent icon={toolBlock?.icon} className="w-3 h-3 text-white" />
</div>
<span className="text-sm font-medium">{tool.title}</span>
</div>
<div className="flex items-center gap-1">
<button
onClick={(e) => {
e.stopPropagation()
handleRemoveTool(tool.type)
}}
className="text-muted-foreground hover:text-foreground"
>
<XIcon className="w-4 h-4" />
</button>
</div>
</div>
{tool.isExpanded && requiredParams.length > 0 && (
<div
className="p-3 space-y-3"
onClick={(e) => {
if (e.target === e.currentTarget) {
toggleToolExpansion(tool.type)
}
}}
>
{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">
<ShortInput
blockId={blockId}
subBlockId={`${subBlockId}-param`}
placeholder={param.description}
password={param.id.toLowerCase().replace(/\s+/g, '') === 'apikey'}
isConnecting={false}
config={{
id: `${subBlockId}-param`,
type: 'short-input',
title: param.id,
}}
value={tool.params[param.id] || ''}
onChange={(value) => handleParamChange(tool.type, param.id, value)}
/>
</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.bgColor }}
>
<IconComponent icon={block.icon} className="w-4 h-4 text-white" />
</div>
<span>{block.name}</span>
</CommandItem>
))}
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
)}
</div>
)
}
@@ -0,0 +1,178 @@
import { useEffect, useRef, useState } from 'react'
import type { ReactElement } from 'react'
import { highlight, languages } from 'prismjs'
import 'prismjs/components/prism-javascript'
import 'prismjs/components/prism-json'
import 'prismjs/themes/prism.css'
import Editor from 'react-simple-code-editor'
import { cn } from '@/lib/utils'
interface CodeEditorProps {
value: string
onChange: (value: string) => void
language: 'javascript' | 'json'
placeholder?: string
className?: string
minHeight?: string
}
export function CodeEditor({
value,
onChange,
language,
placeholder = '',
className = '',
minHeight = '360px',
}: CodeEditorProps) {
const [code, setCode] = useState(value)
const [visualLineHeights, setVisualLineHeights] = useState<number[]>([])
const [isCollapsed, setIsCollapsed] = useState(false)
const editorRef = useRef<HTMLDivElement>(null)
useEffect(() => {
setCode(value)
}, [value])
useEffect(() => {
if (!editorRef.current) return
const calculateVisualLines = () => {
const preElement = editorRef.current?.querySelector('pre')
if (!preElement) return
const lines = code.split('\n')
const newVisualLineHeights: number[] = []
const container = document.createElement('div')
container.style.cssText = `
position: absolute;
visibility: hidden;
width: ${preElement.clientWidth}px;
font-family: ${window.getComputedStyle(preElement).fontFamily};
font-size: ${window.getComputedStyle(preElement).fontSize};
padding: 12px;
white-space: pre-wrap;
word-break: break-word;
`
document.body.appendChild(container)
lines.forEach((line) => {
const lineDiv = document.createElement('div')
lineDiv.textContent = line || ' '
container.appendChild(lineDiv)
const actualHeight = lineDiv.getBoundingClientRect().height
const lineUnits = Math.ceil(actualHeight / 21)
newVisualLineHeights.push(lineUnits)
container.removeChild(lineDiv)
})
document.body.removeChild(container)
setVisualLineHeights(newVisualLineHeights)
}
const resizeObserver = new ResizeObserver(calculateVisualLines)
resizeObserver.observe(editorRef.current)
return () => resizeObserver.disconnect()
}, [code])
// Calculate the number of lines to determine gutter width
const lineCount = code.split('\n').length
const gutterWidth = lineCount >= 100 ? '40px' : lineCount >= 10 ? '35px' : '30px'
// Render helpers
const renderLineNumbers = () => {
const numbers: ReactElement[] = []
let lineNumber = 1
visualLineHeights.forEach((height) => {
for (let i = 0; i < height; i++) {
numbers.push(
<div
key={`${lineNumber}-${i}`}
className={cn('text-xs text-muted-foreground leading-[21px]', i > 0 && 'invisible')}
>
{lineNumber}
</div>
)
}
lineNumber++
})
return numbers
}
return (
<div
className={cn(
'relative min-h-[100px] rounded-md border bg-background font-mono text-sm group',
className
)}
>
{code.split('\n').length > 5 && (
<button
onClick={() => setIsCollapsed(!isCollapsed)}
className={cn(
'absolute right-2 top-2 z-10 p-1.5 rounded-md',
'bg-accent/50 hover:bg-accent text-muted-foreground hover:text-foreground',
'opacity-0 group-hover:opacity-100 transition-opacity',
'text-xs font-medium'
)}
>
{isCollapsed ? 'Expand' : 'Collapse'}
</button>
)}
<div
className="absolute left-0 top-0 bottom-0 bg-muted/30 flex flex-col items-end pr-3 pt-3 select-none overflow-hidden"
aria-hidden="true"
style={{
width: gutterWidth,
}}
>
{renderLineNumbers()}
</div>
<div
className={cn('pt-0 mt-0 relative', isCollapsed && 'max-h-[126px] overflow-hidden')}
ref={editorRef}
style={{
minHeight,
paddingLeft: gutterWidth,
}}
>
{code.length === 0 && placeholder && (
<div
className="absolute top-[12px] text-muted-foreground/50 select-none pointer-events-none"
style={{ left: `calc(${gutterWidth} + 12px)` }}
>
{placeholder}
</div>
)}
<Editor
value={code}
onValueChange={(newCode) => {
if (!isCollapsed) {
setCode(newCode)
onChange(newCode)
}
}}
highlight={(code) => highlight(code, languages[language], language)}
padding={12}
style={{
fontFamily: 'inherit',
minHeight: '46px',
lineHeight: '21px',
}}
className={cn('focus:outline-none', isCollapsed && 'pointer-events-none select-none')}
textareaClassName={cn(
'focus:outline-none focus:ring-0 bg-transparent',
isCollapsed && 'pointer-events-none'
)}
/>
</div>
</div>
)
}
@@ -0,0 +1,404 @@
import { useEffect, useMemo, useState } from 'react'
import { Code, FileJson, X } from 'lucide-react'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Label } from '@/components/ui/label'
import { cn } from '@/lib/utils'
import { useCustomToolsStore } from '@/stores/custom-tools/store'
import { CodeEditor } from './code-editor'
interface CustomToolModalProps {
open: boolean
onOpenChange: (open: boolean) => void
onSave: (tool: CustomTool) => void
initialValues?: {
id?: string
schema: any
code: string
}
}
export interface CustomTool {
type: 'custom-tool'
title: string
name: string
description: string
schema: any
code: string
params: Record<string, string>
isExpanded?: boolean
}
type ToolSection = 'schema' | 'code'
export function CustomToolModal({
open,
onOpenChange,
onSave,
initialValues,
}: CustomToolModalProps) {
const [activeSection, setActiveSection] = useState<ToolSection>('schema')
const [jsonSchema, setJsonSchema] = useState('')
const [functionCode, setFunctionCode] = useState('')
const [schemaError, setSchemaError] = useState<string | null>(null)
const [codeError, setCodeError] = useState<string | null>(null)
const [isEditing, setIsEditing] = useState(false)
const [toolId, setToolId] = useState<string | undefined>(undefined)
const addTool = useCustomToolsStore((state) => state.addTool)
const updateTool = useCustomToolsStore((state) => state.updateTool)
// Initialize form with initial values if provided
useEffect(() => {
if (open && initialValues) {
try {
setJsonSchema(
typeof initialValues.schema === 'string'
? initialValues.schema
: JSON.stringify(initialValues.schema, null, 2)
)
setFunctionCode(initialValues.code || '')
setIsEditing(true)
setToolId(initialValues.id)
} catch (error) {
console.error('Error initializing form with initial values:', error)
setSchemaError('Failed to load tool data. Please try again.')
}
} else if (open) {
// Reset form when opening without initial values
resetForm()
}
}, [open, initialValues])
const resetForm = () => {
setJsonSchema('')
setFunctionCode('')
setSchemaError(null)
setCodeError(null)
setActiveSection('schema')
setIsEditing(false)
setToolId(undefined)
}
const handleClose = () => {
resetForm()
onOpenChange(false)
}
// Pure validation function that doesn't update state
const validateJsonSchema = (schema: string): boolean => {
if (!schema) return false
try {
const parsed = JSON.parse(schema)
// Basic validation for function schema
if (!parsed.type || parsed.type !== 'function') {
return false
}
if (!parsed.function || !parsed.function.name) {
return false
}
return true
} catch (error) {
return false
}
}
// Pure validation function that doesn't update state
const validateFunctionCode = (code: string): boolean => {
return true // Allow empty code
}
// Memoize validation results to prevent unnecessary recalculations
const isSchemaValid = useMemo(() => validateJsonSchema(jsonSchema), [jsonSchema])
const isCodeValid = useMemo(() => validateFunctionCode(functionCode), [functionCode])
const handleSave = () => {
setSchemaError(null)
setCodeError(null)
// Validation with error messages
if (!jsonSchema) {
setSchemaError('Schema cannot be empty')
setActiveSection('schema')
return
}
try {
const parsed = JSON.parse(jsonSchema)
if (!parsed.type || parsed.type !== 'function') {
setSchemaError('Schema must have a "type" field set to "function"')
setActiveSection('schema')
return
}
if (!parsed.function || !parsed.function.name) {
setSchemaError('Schema must have a "function" object with a "name" field')
setActiveSection('schema')
return
}
// Check for duplicate tool name
const toolName = parsed.function.name
const existingTools = useCustomToolsStore.getState().getAllTools()
// If editing, we need to find the original tool to get its ID
let originalToolId = toolId
if (isEditing && !originalToolId) {
// If we're editing but don't have an ID, try to find the tool by its original name
const originalSchema = initialValues?.schema
const originalName = originalSchema?.function?.name
if (originalName) {
const originalTool = existingTools.find(
(tool) => tool.schema.function.name === originalName
)
if (originalTool) {
originalToolId = originalTool.id
}
}
}
// Check for duplicates, excluding the current tool if editing
const isDuplicate = existingTools.some((tool) => {
// Skip the current tool when checking for duplicates
if (isEditing && tool.id === originalToolId) {
return false
}
return tool.schema.function.name === toolName
})
if (isDuplicate) {
setSchemaError(`A tool with the name "${toolName}" already exists`)
setActiveSection('schema')
return
}
// Save to custom tools store
const schema = JSON.parse(jsonSchema)
const name = schema.function.name
const description = schema.function.description || ''
if (isEditing && originalToolId) {
// Update existing tool
updateTool(originalToolId, {
title: name,
schema,
code: functionCode || '',
})
} else {
// Add new tool
originalToolId = addTool({
title: name,
schema,
code: functionCode || '',
})
}
const customTool: CustomTool = {
type: 'custom-tool',
title: name,
name,
description,
schema,
code: functionCode || '',
params: {},
isExpanded: true,
}
onSave(customTool)
handleClose()
} catch (error) {
console.error('Error saving custom tool:', error)
setSchemaError('Failed to save custom tool. Please check your inputs and try again.')
console.error('Error saving custom tool:', error)
setSchemaError('Failed to save custom tool. Please check your inputs and try again.')
}
}
const handleJsonSchemaChange = (value: string) => {
setJsonSchema(value)
if (schemaError) {
setSchemaError(null)
}
}
const handleFunctionCodeChange = (value: string) => {
setFunctionCode(value)
if (codeError) {
setCodeError(null)
}
}
const navigationItems = [
{
id: 'schema' as const,
label: 'Schema',
icon: FileJson,
complete: isSchemaValid,
},
{
id: 'code' as const,
label: 'Code',
icon: Code,
complete: isCodeValid,
},
]
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="sm:max-w-[700px] h-[80vh] flex flex-col p-0 gap-0" hideCloseButton>
<DialogHeader className="px-6 py-4 border-b">
<div className="flex items-center justify-between">
<DialogTitle className="text-lg font-medium">
{isEditing ? 'Edit AgentTool' : 'Create Agent Tool'}
</DialogTitle>
<Button variant="ghost" size="icon" className="h-8 w-8 p-0" onClick={handleClose}>
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</Button>
</div>
<DialogDescription className="mt-1.5">
Step {activeSection === 'schema' ? '1' : '2'} of 2:{' '}
{activeSection === 'schema' ? 'Define schema' : 'Implement code'}
</DialogDescription>
</DialogHeader>
<div className="flex-1 flex flex-col min-h-0 overflow-hidden">
<div className="flex border-b">
{navigationItems.map((item) => (
<button
key={item.id}
onClick={() => setActiveSection(item.id)}
className={cn(
'flex items-center gap-2 px-6 py-3 text-sm transition-colors border-b-2',
'hover:bg-muted/50',
activeSection === item.id
? 'border-primary text-foreground font-medium'
: 'border-transparent text-muted-foreground hover:text-foreground'
)}
>
<item.icon className="h-4 w-4" />
<span>{item.label}</span>
</button>
))}
</div>
<div className="flex-1 px-6 pt-6 pb-12 overflow-auto">
<div
className={cn(
'flex-1 flex flex-col h-full',
activeSection === 'schema' ? 'block' : 'hidden'
)}
>
<div className="flex items-center mb-1 min-h-6">
<div className="flex items-center gap-2">
<FileJson className="h-4 w-4" />
<Label htmlFor="json-schema" className="font-medium">
JSON Schema
</Label>
</div>
{schemaError && (
<span className="text-sm text-red-600 ml-4 flex-shrink-0">{schemaError}</span>
)}
</div>
<CodeEditor
value={jsonSchema}
onChange={handleJsonSchemaChange}
language="json"
placeholder={`{
"type": "function",
"function": {
"name": "addItemToOrder",
"description": "Add one quantity of a food item to the order.",
"parameters": {
"type": "object",
"properties": {
"itemName": {
"type": "string",
"description": "The name of the food item to add to order"
}
},
"required": ["itemName"]
}
}
}`}
minHeight="340px"
className={cn(schemaError ? 'border-red-500' : '')}
/>
<div className="h-6"></div>
</div>
<div
className={cn(
'flex-1 flex flex-col h-full pb-6',
activeSection === 'code' ? 'block' : 'hidden'
)}
>
<div className="flex items-center mb-1 min-h-6">
<div className="flex items-center gap-2">
<Code className="h-4 w-4" />
<Label htmlFor="function-code" className="font-medium">
Code (optional)
</Label>
</div>
{codeError && (
<span className="text-sm text-red-600 ml-4 flex-shrink-0">{codeError}</span>
)}
</div>
<CodeEditor
value={functionCode}
onChange={handleFunctionCodeChange}
language="javascript"
placeholder={`// This code will be executed when the tool is called`}
minHeight="340px"
className={cn(codeError ? 'border-red-500' : '')}
/>
<div className="h-6"></div>
</div>
</div>
</div>
<DialogFooter className="px-6 py-4 border-t mt-auto">
<div className="flex justify-between w-full">
<Button
variant="outline"
onClick={() => {
if (activeSection === 'code') {
setActiveSection('schema')
}
}}
disabled={activeSection === 'schema'}
>
Back
</Button>
<div className="flex space-x-2">
<Button variant="outline" onClick={handleClose}>
Cancel
</Button>
{activeSection === 'schema' ? (
<Button onClick={() => setActiveSection('code')} disabled={!isSchemaValid}>
Next
</Button>
) : (
<Button onClick={handleSave}>{isEditing ? 'Update Tool' : 'Save Tool'}</Button>
)}
</div>
</div>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
@@ -0,0 +1,582 @@
import { useState } from 'react'
import { PencilIcon, PlusIcon, WrenchIcon, XIcon } from 'lucide-react'
import { Button } from '@/components/ui/button'
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
} from '@/components/ui/command'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { cn } from '@/lib/utils'
import { useCustomToolsStore } from '@/stores/custom-tools/store'
import { useWorkflowStore } from '@/stores/workflow/store'
import { getAllBlocks } from '@/blocks'
import { getTool } from '@/tools'
import { useSubBlockValue } from '../../hooks/use-sub-block-value'
import { ShortInput } from '../short-input'
import { CustomTool, CustomToolModal } from './components/custom-tool-modal'
interface ToolInputProps {
blockId: string
subBlockId: string
}
interface StoredTool {
type: string
title: string
params: Record<string, string>
isExpanded?: boolean
schema?: any // For custom tools
code?: string // For custom tools implementation
}
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,
}))
}
// For custom tools, extract parameters from the schema
const getCustomToolParams = (schema: any): ToolParam[] => {
if (!schema?.function?.parameters?.properties) return []
const properties = schema.function.parameters.properties
const required = schema.function.parameters.required || []
return Object.entries(properties).map(([paramId, param]: [string, any]) => ({
id: paramId,
type: param.type || 'string',
description: param.description || '',
requiredForToolCall: required.includes(paramId),
}))
}
export function ToolInput({ blockId, subBlockId }: ToolInputProps) {
const [value, setValue] = useSubBlockValue(blockId, subBlockId)
const [open, setOpen] = useState(false)
const [customToolModalOpen, setCustomToolModalOpen] = useState(false)
const [editingToolIndex, setEditingToolIndex] = useState<number | null>(null)
const isWide = useWorkflowStore((state) => state.blocks[blockId]?.isWide)
const customTools = useCustomToolsStore((state) => state.getAllTools())
const toolBlocks = getAllBlocks().filter((block) => block.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]) => {
// Check if tool already exists
if (selectedTools.some((tool) => tool.type === toolBlock.type)) {
setOpen(false)
return
}
const toolId = getToolIdFromBlock(toolBlock.type)
const newTool: StoredTool = {
type: toolBlock.type,
title: toolBlock.name,
params: {},
isExpanded: true,
}
// If isWide, keep tools in the same row expanded
if (isWide) {
setValue([
...selectedTools.map((tool, index) => ({
...tool,
// Keep expanded if it's in the same row as the new tool
isExpanded: Math.floor(selectedTools.length / 2) === Math.floor(index / 2),
})),
newTool,
])
} else {
// Original behavior for non-wide mode
setValue([...selectedTools.map((tool) => ({ ...tool, isExpanded: false })), newTool])
}
setOpen(false)
}
const handleAddCustomTool = (customTool: CustomTool) => {
// Check if a tool with the same name already exists
if (
selectedTools.some(
(tool) =>
tool.type === 'custom-tool' &&
tool.schema?.function?.name === customTool.schema.function.name
)
) {
return
}
const newTool: StoredTool = {
type: 'custom-tool',
title: customTool.title,
params: {},
isExpanded: true,
schema: customTool.schema,
code: customTool.code || '',
}
// If isWide, keep tools in the same row expanded
if (isWide) {
setValue([
...selectedTools.map((tool, index) => ({
...tool,
// Keep expanded if it's in the same row as the new tool
isExpanded: Math.floor(selectedTools.length / 2) === Math.floor(index / 2),
})),
newTool,
])
} else {
// Original behavior for non-wide mode
setValue([...selectedTools.map((tool) => ({ ...tool, isExpanded: false })), newTool])
}
}
const handleEditCustomTool = (toolIndex: number) => {
const tool = selectedTools[toolIndex]
if (tool.type !== 'custom-tool' || !tool.schema) return
// Find the tool ID from the custom tools store based on the function name
const customToolsList = useCustomToolsStore.getState().getAllTools()
const existingTool = customToolsList.find(
(customTool) => customTool.schema.function.name === tool.schema.function.name
)
setEditingToolIndex(toolIndex)
setCustomToolModalOpen(true)
}
const handleSaveCustomTool = (customTool: CustomTool) => {
if (editingToolIndex !== null) {
// Update existing tool
setValue(
selectedTools.map((tool, index) =>
index === editingToolIndex
? {
...tool,
title: customTool.title,
schema: customTool.schema,
code: customTool.code || '',
}
: tool
)
)
setEditingToolIndex(null)
} else {
// Add new tool
handleAddCustomTool(customTool)
}
}
const handleRemoveTool = (toolType: string, toolIndex: number) => {
setValue(selectedTools.filter((_, index) => index !== toolIndex))
}
const handleParamChange = (toolIndex: number, paramId: string, paramValue: string) => {
setValue(
selectedTools.map((tool, index) =>
index === toolIndex
? {
...tool,
params: {
...tool.params,
[paramId]: paramValue,
},
}
: tool
)
)
}
const toggleToolExpansion = (toolIndex: number) => {
setValue(
selectedTools.map((tool, index) =>
index === toolIndex ? { ...tool, isExpanded: !tool.isExpanded } : tool
)
)
}
const IconComponent = ({ icon: Icon, className }: { icon: any; className?: string }) => {
if (!Icon) return null
return <Icon className={className} />
}
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>
<CommandItem
onSelect={() => {
setOpen(false)
setCustomToolModalOpen(true)
}}
className="flex items-center gap-2 cursor-pointer mb-1"
>
<div className="flex items-center justify-center w-6 h-6 rounded border border-dashed border-muted-foreground/50 bg-transparent">
<WrenchIcon className="w-4 h-4 text-muted-foreground" />
</div>
<span>Create Tool</span>
</CommandItem>
{/* Display saved custom tools at the top */}
{customTools.length > 0 && (
<>
<CommandSeparator />
<div className="px-2 pt-2.5 pb-0.5 text-xs font-medium text-muted-foreground">
Custom Tools
</div>
<CommandGroup className="-mx-1 -px-1">
{customTools.map((customTool) => (
<CommandItem
key={customTool.id}
onSelect={() => {
const newTool: StoredTool = {
type: 'custom-tool',
title: customTool.title,
params: {},
isExpanded: true,
schema: customTool.schema,
code: customTool.code,
}
if (isWide) {
setValue([
...selectedTools.map((tool, index) => ({
...tool,
isExpanded:
Math.floor(selectedTools.length / 2) ===
Math.floor(index / 2),
})),
newTool,
])
} else {
setValue([
...selectedTools.map((tool) => ({ ...tool, isExpanded: false })),
newTool,
])
}
setOpen(false)
}}
className="flex items-center gap-2 cursor-pointer"
>
<div className="flex items-center justify-center w-6 h-6 rounded bg-blue-500">
<WrenchIcon className="w-4 h-4 text-white" />
</div>
<span className="truncate max-w-[140px]">{customTool.title}</span>
</CommandItem>
))}
</CommandGroup>
<CommandSeparator />
</>
)}
{/* Display built-in tools */}
<div className="px-2 pt-2.5 pb-0.5 text-xs font-medium text-muted-foreground">
Built-in Tools
</div>
<CommandGroup className="-mx-1 -px-1">
{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.bgColor }}
>
<IconComponent icon={block.icon} className="w-4 h-4 text-white" />
</div>
<span className="truncate max-w-[140px]">{block.name}</span>
</CommandItem>
))}
</CommandGroup>
</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, toolIndex) => {
// Handle custom tools differently
const isCustomTool = tool.type === 'custom-tool'
const toolBlock = !isCustomTool
? toolBlocks.find((block) => block.type === tool.type)
: null
const toolId = !isCustomTool ? getToolIdFromBlock(tool.type) : null
// Get parameters based on tool type
const requiredParams = isCustomTool
? getCustomToolParams(tool.schema)
: toolId
? getRequiredToolParams(toolId)
: []
return (
<div
key={`${tool.type}-${toolIndex}`}
className={cn('group flex flex-col', isWide ? 'w-[calc(50%-0.25rem)]' : '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 cursor-pointer"
onClick={() => {
if (isCustomTool) {
handleEditCustomTool(toolIndex)
} else {
toggleToolExpansion(toolIndex)
}
}}
>
<div className="flex items-center gap-2">
<div
className="flex items-center justify-center w-5 h-5 rounded"
style={{
backgroundColor: isCustomTool
? '#3B82F6' // blue-500 for custom tools
: toolBlock?.bgColor,
}}
>
{isCustomTool ? (
<WrenchIcon className="w-3 h-3 text-white" />
) : (
<IconComponent icon={toolBlock?.icon} className="w-3 h-3 text-white" />
)}
</div>
<span
className={`text-sm font-medium truncate ${
isWide ? 'max-w-[134px]' : 'max-w-[180px]'
}`}
>
{tool.title}
</span>
</div>
<div className="flex items-center gap-1">
<button
onClick={(e) => {
e.stopPropagation()
handleRemoveTool(tool.type, toolIndex)
}}
className="text-muted-foreground hover:text-foreground"
>
<XIcon className="w-4 h-4" />
</button>
</div>
</div>
{tool.isExpanded && !isCustomTool && requiredParams.length > 0 && (
<div
className="p-3 space-y-3"
onClick={(e) => {
if (e.target === e.currentTarget) {
toggleToolExpansion(toolIndex)
}
}}
>
{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">
<ShortInput
blockId={blockId}
subBlockId={`${subBlockId}-param`}
placeholder={param.description}
password={param.id.toLowerCase().replace(/\s+/g, '') === 'apikey'}
isConnecting={false}
config={{
id: `${subBlockId}-param`,
type: 'short-input',
title: param.id,
}}
value={tool.params[param.id] || ''}
onChange={(value) => handleParamChange(toolIndex, param.id, value)}
/>
</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>
<CommandItem
onSelect={() => {
setOpen(false)
setCustomToolModalOpen(true)
}}
className="flex items-center gap-2 cursor-pointer mb-1"
>
<div className="flex items-center justify-center w-6 h-6 rounded border border-dashed border-muted-foreground/50 bg-transparent">
<WrenchIcon className="w-4 h-4 text-muted-foreground" />
</div>
<span>Create Tool</span>
</CommandItem>
{/* Display saved custom tools at the top */}
{customTools.length > 0 && (
<>
<CommandSeparator />
<div className="px-2 pt-2.5 pb-0.5 text-xs font-medium text-muted-foreground">
Custom Tools
</div>
<CommandGroup className="-mx-1 -px-1">
{customTools.map((customTool) => (
<CommandItem
key={customTool.id}
onSelect={() => {
const newTool: StoredTool = {
type: 'custom-tool',
title: customTool.title,
params: {},
isExpanded: true,
schema: customTool.schema,
code: customTool.code,
}
if (isWide) {
setValue([
...selectedTools.map((tool, index) => ({
...tool,
isExpanded:
Math.floor(selectedTools.length / 2) ===
Math.floor(index / 2),
})),
newTool,
])
} else {
setValue([
...selectedTools.map((tool) => ({
...tool,
isExpanded: false,
})),
newTool,
])
}
setOpen(false)
}}
className="flex items-center gap-2 cursor-pointer"
>
<div className="flex items-center justify-center w-6 h-6 rounded bg-blue-500">
<WrenchIcon className="w-4 h-4 text-white" />
</div>
<span className="truncate max-w-[140px]">{customTool.title}</span>
</CommandItem>
))}
</CommandGroup>
<CommandSeparator />
</>
)}
{/* Display built-in tools */}
<div className="px-2 pt-2.5 pb-0.5 text-xs font-medium text-muted-foreground">
Built-in Tools
</div>
<CommandGroup className="-mx-1 -px-1">
{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.bgColor }}
>
<IconComponent icon={block.icon} className="w-4 h-4 text-white" />
</div>
<span className="truncate max-w-[140px]">{block.name}</span>
</CommandItem>
))}
</CommandGroup>
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
</div>
)}
<CustomToolModal
open={customToolModalOpen}
onOpenChange={(open) => {
setCustomToolModalOpen(open)
if (!open) setEditingToolIndex(null)
}}
onSave={handleSaveCustomTool}
initialValues={
editingToolIndex !== null && selectedTools[editingToolIndex]?.type === 'custom-tool'
? {
schema: selectedTools[editingToolIndex].schema,
code: selectedTools[editingToolIndex].code || '',
}
: undefined
}
/>
</div>
)
}
@@ -12,7 +12,7 @@ import { SliderInput } from './components/slider-input'
import { Switch } from './components/switch'
import { Table } from './components/table'
import { TimeInput } from './components/time-input'
import { ToolInput } from './components/tool-input'
import { ToolInput } from './components/tool-input/tool-input'
interface SubBlockProps {
blockId: string
+7
View File
@@ -39,6 +39,12 @@ import { cn } from '@/lib/utils'
// This file is not typed correctly from shadcn, so we're disabling the type checker
// @ts-nocheck
// This file is not typed correctly from shadcn, so we're disabling the type checker
// @ts-nocheck
// This file is not typed correctly from shadcn, so we're disabling the type checker
// @ts-nocheck
const Command = React.forwardRef<
React.ElementRef<typeof CommandPrimitive>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive> & {
@@ -119,6 +125,7 @@ const CommandGroup = React.forwardRef<
React.ElementRef<typeof CommandPrimitive.Group>,
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group> & {
children?: React.ReactNode
className?: string
}
>(({ className, ...props }, ref) => (
<CommandPrimitive.Group
+103
View File
@@ -0,0 +1,103 @@
import { create } from 'zustand'
import { devtools, persist } from 'zustand/middleware'
export interface CustomToolSchema {
type: string
function: {
name: string
description?: string
parameters: {
type: string
properties: Record<string, any>
required?: string[]
}
}
}
export interface CustomToolDefinition {
id: string
title: string
schema: CustomToolSchema
code: string
createdAt: string
updatedAt?: string
}
interface CustomToolsStore {
tools: Record<string, CustomToolDefinition>
addTool: (tool: Omit<CustomToolDefinition, 'id' | 'createdAt' | 'updatedAt'>) => string
updateTool: (
id: string,
updates: Partial<Omit<CustomToolDefinition, 'id' | 'createdAt' | 'updatedAt'>>
) => boolean
removeTool: (id: string) => void
getTool: (id: string) => CustomToolDefinition | undefined
getAllTools: () => CustomToolDefinition[]
}
export const useCustomToolsStore = create<CustomToolsStore>()(
devtools(
persist(
(set, get) => ({
tools: {},
addTool: (tool) => {
const id = crypto.randomUUID()
const newTool = {
...tool,
id,
createdAt: new Date().toISOString(),
}
set((state) => ({
tools: {
...state.tools,
[id]: newTool,
},
}))
return id
},
updateTool: (id, updates) => {
const tool = get().tools[id]
if (!tool) return false
const updatedTool = {
...tool,
...updates,
updatedAt: new Date().toISOString(),
}
set((state) => ({
tools: {
...state.tools,
[id]: updatedTool,
},
}))
return true
},
removeTool: (id) => {
set((state) => {
const newTools = { ...state.tools }
delete newTools[id]
return { tools: newTools }
})
},
getTool: (id) => {
return get().tools[id]
},
getAllTools: () => {
return Object.values(get().tools)
},
}),
{
name: 'custom-tools-store',
}
)
)
)
+7 -3
View File
@@ -1,5 +1,6 @@
import { useChatStore } from './chat/store'
import { useConsoleStore } from './console/store'
import { useCustomToolsStore } from './custom-tools/store'
import { useExecutionStore } from './execution/store'
import { useNotificationStore } from './notifications/store'
import { useEnvironmentStore } from './settings/environment/store'
@@ -9,9 +10,9 @@ import { useWorkflowRegistry } from './workflow/registry/store'
import { useWorkflowStore } from './workflow/store'
// Initialize sync manager when the store is first imported
if (typeof window !== 'undefined') {
initializeSyncManager()
}
// if (typeof window !== 'undefined') {
// initializeSyncManager()
// }
// Reset all application stores to their initial state
export const resetAllStores = () => {
@@ -43,6 +44,7 @@ export const resetAllStores = () => {
useConsoleStore.setState({ entries: [], isOpen: false })
useGeneralStore.setState({ isAutoConnectEnabled: true, isDebugModeEnabled: false })
useChatStore.setState({ messages: [], isProcessing: false, error: null })
useCustomToolsStore.setState({ tools: {} })
}
// Log the current state of all stores
@@ -55,6 +57,7 @@ export const logAllStores = () => {
execution: useExecutionStore.getState(),
console: useConsoleStore.getState(),
chat: useChatStore.getState(),
customTools: useCustomToolsStore.getState(),
}
console.group('Application State')
@@ -77,4 +80,5 @@ export {
useExecutionStore,
useConsoleStore,
useChatStore,
useCustomToolsStore,
}