mirror of
https://github.com/hanzoai/shadcn.git
synced 2026-10-30 18:22:59 +00:00
395 lines
12 KiB
TypeScript
395 lines
12 KiB
TypeScript
"use client"
|
|
|
|
import * as React from "react"
|
|
import { Grid } from "@hanzo/ui/grid"
|
|
import {
|
|
Calculator,
|
|
ChevronRight,
|
|
Code,
|
|
FileText,
|
|
Globe,
|
|
Play,
|
|
Search,
|
|
Settings,
|
|
Wrench,
|
|
Zap,
|
|
} from "lucide-react"
|
|
|
|
import { cn } from "@/lib/utils"
|
|
import { Badge } from "@/registry/default/ui/badge"
|
|
import { Button } from "@/registry/default/ui/button"
|
|
import {
|
|
Card,
|
|
CardContent,
|
|
CardHeader,
|
|
CardTitle,
|
|
} from "@/registry/default/ui/card"
|
|
import { Input } from "@/registry/default/ui/input"
|
|
import { ScrollArea } from "@/registry/default/ui/scroll-area"
|
|
import { Separator } from "@/registry/default/ui/separator"
|
|
import { Textarea } from "@/registry/default/ui/textarea"
|
|
|
|
export interface AITool {
|
|
name: string
|
|
description: string
|
|
icon?: React.ComponentType<{ className?: string }>
|
|
category?: string
|
|
parameters?: Record<string, any>
|
|
execute?: (params: any) => Promise<any>
|
|
enabled?: boolean
|
|
}
|
|
|
|
export interface AIToolsProps extends React.HTMLAttributes<HTMLDivElement> {
|
|
tools?: AITool[]
|
|
onToolExecute?: (toolName: string, params: any) => void
|
|
onToolResult?: (toolName: string, result: any) => void
|
|
onToolToggle?: (toolName: string, enabled: boolean) => void
|
|
showCategories?: boolean
|
|
searchable?: boolean
|
|
}
|
|
|
|
// Default tools
|
|
const defaultTools: AITool[] = [
|
|
{
|
|
name: "web_search",
|
|
description: "Search the web for current information",
|
|
icon: Search,
|
|
category: "Information",
|
|
enabled: true,
|
|
parameters: { query: "" },
|
|
},
|
|
{
|
|
name: "code_execution",
|
|
description: "Execute code snippets safely",
|
|
icon: Code,
|
|
category: "Development",
|
|
enabled: true,
|
|
parameters: { code: "", language: "javascript" },
|
|
},
|
|
{
|
|
name: "calculator",
|
|
description: "Perform mathematical calculations",
|
|
icon: Calculator,
|
|
category: "Utilities",
|
|
enabled: true,
|
|
parameters: { expression: "" },
|
|
},
|
|
{
|
|
name: "file_reader",
|
|
description: "Read and analyze file contents",
|
|
icon: FileText,
|
|
category: "Files",
|
|
enabled: true,
|
|
parameters: { file_path: "" },
|
|
},
|
|
{
|
|
name: "api_call",
|
|
description: "Make HTTP requests to external APIs",
|
|
icon: Globe,
|
|
category: "Network",
|
|
enabled: false,
|
|
parameters: { url: "", method: "GET", headers: {}, body: "" },
|
|
},
|
|
]
|
|
|
|
const categoryIcons = {
|
|
Information: Search,
|
|
Development: Code,
|
|
Utilities: Calculator,
|
|
Files: FileText,
|
|
Network: Globe,
|
|
default: Wrench,
|
|
}
|
|
|
|
const AITools = React.forwardRef<HTMLDivElement, AIToolsProps>(
|
|
(
|
|
{
|
|
className,
|
|
children,
|
|
tools = defaultTools,
|
|
onToolExecute,
|
|
onToolResult,
|
|
onToolToggle,
|
|
showCategories = true,
|
|
searchable = true,
|
|
...props
|
|
},
|
|
ref
|
|
) => {
|
|
const [searchQuery, setSearchQuery] = React.useState("")
|
|
const [selectedTool, setSelectedTool] = React.useState<string | null>(null)
|
|
const [toolParameters, setToolParameters] = React.useState<
|
|
Record<string, any>
|
|
>({})
|
|
const [isExecuting, setIsExecuting] = React.useState(false)
|
|
|
|
// Filter tools based on search query
|
|
const filteredTools = React.useMemo(() => {
|
|
if (!searchQuery) return tools
|
|
return tools.filter(
|
|
(tool) =>
|
|
tool.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
|
tool.description.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
|
tool.category?.toLowerCase().includes(searchQuery.toLowerCase())
|
|
)
|
|
}, [tools, searchQuery])
|
|
|
|
// Group tools by category
|
|
const categorizedTools = React.useMemo(() => {
|
|
const categories: Record<string, AITool[]> = {}
|
|
filteredTools.forEach((tool) => {
|
|
const category = tool.category || "Other"
|
|
if (!categories[category]) categories[category] = []
|
|
categories[category].push(tool)
|
|
})
|
|
return categories
|
|
}, [filteredTools])
|
|
|
|
const handleToolExecute = async (tool: AITool) => {
|
|
if (!tool.enabled) return
|
|
|
|
setIsExecuting(true)
|
|
try {
|
|
const params = toolParameters[tool.name] || tool.parameters || {}
|
|
|
|
if (tool.execute) {
|
|
const result = await tool.execute(params)
|
|
onToolResult?.(tool.name, result)
|
|
} else {
|
|
onToolExecute?.(tool.name, params)
|
|
}
|
|
} catch (error) {
|
|
console.error(`Tool execution failed: ${tool.name}`, error)
|
|
} finally {
|
|
setIsExecuting(false)
|
|
}
|
|
}
|
|
|
|
const handleParameterChange = (
|
|
toolName: string,
|
|
paramName: string,
|
|
value: any
|
|
) => {
|
|
setToolParameters((prev) => ({
|
|
...prev,
|
|
[toolName]: {
|
|
...prev[toolName],
|
|
[paramName]: value,
|
|
},
|
|
}))
|
|
}
|
|
|
|
const ToolCard = ({ tool }: { tool: AITool }) => {
|
|
const Icon = tool.icon || Wrench
|
|
const isSelected = selectedTool === tool.name
|
|
const params = toolParameters[tool.name] || tool.parameters || {}
|
|
|
|
return (
|
|
<Card
|
|
className={cn(
|
|
"transition-all cursor-pointer",
|
|
isSelected && "ring-2 ring-primary",
|
|
!tool.enabled && "opacity-50"
|
|
)}
|
|
onClick={() => setSelectedTool(isSelected ? null : tool.name)}
|
|
>
|
|
<CardHeader className="pb-2">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-2">
|
|
<div
|
|
className={cn(
|
|
"flex h-8 w-8 items-center justify-center rounded-lg",
|
|
tool.enabled
|
|
? "bg-primary/10 text-primary"
|
|
: "bg-muted text-muted-foreground"
|
|
)}
|
|
>
|
|
<Icon className="h-4 w-4" />
|
|
</div>
|
|
<div>
|
|
<CardTitle className="text-sm font-medium">
|
|
{tool.name}
|
|
</CardTitle>
|
|
{tool.category && (
|
|
<Badge variant="outline" className="text-xs mt-1">
|
|
{tool.category}
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
{onToolToggle && (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={(e) => {
|
|
e.stopPropagation()
|
|
onToolToggle(tool.name, !tool.enabled)
|
|
}}
|
|
>
|
|
<Settings className="h-3 w-3" />
|
|
</Button>
|
|
)}
|
|
<ChevronRight
|
|
className={cn(
|
|
"h-4 w-4 transition-transform",
|
|
isSelected && "rotate-90"
|
|
)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent className="pt-0">
|
|
<p className="text-sm text-muted-foreground mb-3">
|
|
{tool.description}
|
|
</p>
|
|
|
|
{isSelected && Object.keys(params).length > 0 && (
|
|
<Grid columns={1} gap={12} className="border-t pt-3">
|
|
<h4 className="text-sm font-medium">Parameters</h4>
|
|
{/* One field per parameter, two-up as soon as the card is wide
|
|
enough to hold two 220px fields. */}
|
|
<Grid columns={{ min: 220, max: 2 }} gap={12}>
|
|
{Object.entries(params).map(([paramName, paramValue]) => (
|
|
<Grid key={paramName} columns={1} gap={4}>
|
|
<label className="text-xs font-medium capitalize">
|
|
{paramName.replace(/_/g, " ")}
|
|
</label>
|
|
{typeof paramValue === "string" &&
|
|
paramValue.length > 50 ? (
|
|
<Textarea
|
|
value={params[paramName] || ""}
|
|
onChange={(e) =>
|
|
handleParameterChange(
|
|
tool.name,
|
|
paramName,
|
|
e.target.value
|
|
)
|
|
}
|
|
placeholder={`Enter ${paramName}`}
|
|
className="text-xs"
|
|
rows={3}
|
|
/>
|
|
) : (
|
|
<Input
|
|
value={params[paramName] || ""}
|
|
onChange={(e) =>
|
|
handleParameterChange(
|
|
tool.name,
|
|
paramName,
|
|
e.target.value
|
|
)
|
|
}
|
|
placeholder={`Enter ${paramName}`}
|
|
className="text-xs"
|
|
/>
|
|
)}
|
|
</Grid>
|
|
))}
|
|
</Grid>
|
|
<Button
|
|
onClick={(e) => {
|
|
e.stopPropagation()
|
|
handleToolExecute(tool)
|
|
}}
|
|
disabled={!tool.enabled || isExecuting}
|
|
size="sm"
|
|
className="w-full"
|
|
>
|
|
{isExecuting ? (
|
|
<>
|
|
<Zap className="h-3 w-3 mr-2 animate-spin" />
|
|
Executing...
|
|
</>
|
|
) : (
|
|
<>
|
|
<Play className="h-3 w-3 mr-2" />
|
|
Execute
|
|
</>
|
|
)}
|
|
</Button>
|
|
</Grid>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<Grid ref={ref} columns={1} gap={16} className={className} {...props}>
|
|
{/* Header */}
|
|
<div className="flex items-center gap-2">
|
|
<Wrench className="h-5 w-5" />
|
|
<h3 className="font-semibold">AI Tools</h3>
|
|
<Badge variant="secondary">
|
|
{tools.filter((t) => t.enabled).length} enabled
|
|
</Badge>
|
|
</div>
|
|
|
|
{/* Search */}
|
|
{searchable && (
|
|
<Input
|
|
placeholder="Search tools..."
|
|
value={searchQuery}
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
className="w-full"
|
|
/>
|
|
)}
|
|
|
|
{/* Tools */}
|
|
<ScrollArea className="h-[400px]">
|
|
<Grid columns={1} gap={16}>
|
|
{showCategories
|
|
? Object.entries(categorizedTools).map(
|
|
([category, categoryTools]) => {
|
|
const CategoryIcon =
|
|
categoryIcons[category as keyof typeof categoryIcons] ||
|
|
categoryIcons.default
|
|
return (
|
|
<Grid key={category} columns={1} gap={8}>
|
|
<div className="flex items-center gap-2">
|
|
<CategoryIcon className="h-4 w-4 text-muted-foreground" />
|
|
<h4 className="font-medium text-sm">{category}</h4>
|
|
<Separator className="flex-1" />
|
|
</div>
|
|
<Grid columns={1} gap={8} className="pl-6">
|
|
{categoryTools.map((tool) => (
|
|
<ToolCard key={tool.name} tool={tool} />
|
|
))}
|
|
</Grid>
|
|
</Grid>
|
|
)
|
|
}
|
|
)
|
|
: filteredTools.map((tool) => (
|
|
<ToolCard key={tool.name} tool={tool} />
|
|
))}
|
|
|
|
{filteredTools.length === 0 && (
|
|
<Grid
|
|
columns={1}
|
|
gap={4}
|
|
className="py-8 text-center"
|
|
style={{ justifyItems: "center" }}
|
|
>
|
|
<Wrench className="h-12 w-12 text-muted-foreground/50" />
|
|
<h4 className="font-medium text-muted-foreground">
|
|
No tools found
|
|
</h4>
|
|
<p className="text-sm text-muted-foreground">
|
|
Try adjusting your search criteria
|
|
</p>
|
|
</Grid>
|
|
)}
|
|
</Grid>
|
|
</ScrollArea>
|
|
|
|
{children}
|
|
</Grid>
|
|
)
|
|
}
|
|
)
|
|
AITools.displayName = "AITools"
|
|
|
|
export { AITools }
|