mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
feat(custom-functions): completed UI
This commit is contained in:
committed by
Waleed Latif
parent
07469f1966
commit
4158dd33e7
@@ -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>
|
||||
)
|
||||
}
|
||||
+178
@@ -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>
|
||||
)
|
||||
}
|
||||
+404
@@ -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>
|
||||
)
|
||||
}
|
||||
+582
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user