From 5a0a21fd60aebf3dc4f5c8fab78282f1404d0551 Mon Sep 17 00:00:00 2001 From: Ajax Davis Date: Sat, 3 Jan 2026 03:10:53 +1000 Subject: [PATCH] feat: add tools and collections management UI to agent detail page - Add Tools section with search/add/remove functionality - Add Collections section with search/add/remove functionality - Add GET endpoints to /api/agents/[id]/tools and /api/agents/[id]/collections - Use debounced search with dropdown for adding tools/collections - Include click-outside handling to close dropdowns --- .../app/api/agents/[id]/collections/route.ts | 62 +++ .../src/app/api/agents/[id]/tools/route.ts | 66 +++ .../src/app/dashboard/agents/[id]/page.tsx | 454 +++++++++++++++++- 3 files changed, 581 insertions(+), 1 deletion(-) diff --git a/apps/web/src/app/api/agents/[id]/collections/route.ts b/apps/web/src/app/api/agents/[id]/collections/route.ts index 9052729..ea9e67a 100644 --- a/apps/web/src/app/api/agents/[id]/collections/route.ts +++ b/apps/web/src/app/api/agents/[id]/collections/route.ts @@ -12,6 +12,68 @@ type RouteContext = { params: Promise<{ id: string }>; }; +/** + * GET /api/agents/[id]/collections + * List collections attached to an agent + */ +export async function GET(_request: NextRequest, context: RouteContext): Promise { + try { + const session = await auth.api.getSession({ headers: await headers() }); + const { id } = await context.params; + + const agent = await prisma.agent.findUnique({ + where: { id }, + select: { userId: true, isPublic: true }, + }); + + if (!agent) { + return NextResponse.json({ success: false, error: 'Agent not found' }, { status: 404 }); + } + + const isOwner = session?.user?.id === agent.userId; + if (!isOwner && !agent.isPublic) { + return NextResponse.json({ success: false, error: 'Access denied' }, { status: 403 }); + } + + const collections = await prisma.agentCollection.findMany({ + where: { agentId: id }, + include: { + collection: { + select: { + id: true, + name: true, + description: true, + _count: { select: { tools: true } }, + }, + }, + }, + orderBy: { position: 'asc' }, + }); + + return NextResponse.json({ + success: true, + data: collections.map((ac) => ({ + id: ac.id, + collectionId: ac.collectionId, + position: ac.position, + addedAt: ac.addedAt, + collection: { + id: ac.collection.id, + name: ac.collection.name, + description: ac.collection.description, + toolCount: ac.collection._count.tools, + }, + })), + }); + } catch (error) { + console.error('Failed to get agent collections:', error); + return NextResponse.json( + { success: false, error: 'Failed to get agent collections' }, + { status: 500 } + ); + } +} + /** * POST /api/agents/[id]/collections * Add a collection to an agent diff --git a/apps/web/src/app/api/agents/[id]/tools/route.ts b/apps/web/src/app/api/agents/[id]/tools/route.ts index d99d366..f27455c 100644 --- a/apps/web/src/app/api/agents/[id]/tools/route.ts +++ b/apps/web/src/app/api/agents/[id]/tools/route.ts @@ -12,6 +12,72 @@ type RouteContext = { params: Promise<{ id: string }>; }; +/** + * GET /api/agents/[id]/tools + * List tools attached to an agent + */ +export async function GET(_request: NextRequest, context: RouteContext): Promise { + try { + const session = await auth.api.getSession({ headers: await headers() }); + const { id } = await context.params; + + const agent = await prisma.agent.findUnique({ + where: { id }, + select: { userId: true, isPublic: true }, + }); + + if (!agent) { + return NextResponse.json({ success: false, error: 'Agent not found' }, { status: 404 }); + } + + const isOwner = session?.user?.id === agent.userId; + if (!isOwner && !agent.isPublic) { + return NextResponse.json({ success: false, error: 'Access denied' }, { status: 403 }); + } + + const tools = await prisma.agentTool.findMany({ + where: { agentId: id }, + include: { + tool: { + select: { + id: true, + name: true, + description: true, + package: { + select: { + npmPackageName: true, + }, + }, + }, + }, + }, + orderBy: { position: 'asc' }, + }); + + return NextResponse.json({ + success: true, + data: tools.map((at) => ({ + id: at.id, + toolId: at.toolId, + position: at.position, + addedAt: at.addedAt, + tool: { + id: at.tool.id, + name: at.tool.name, + description: at.tool.description, + npmPackageName: at.tool.package.npmPackageName, + }, + })), + }); + } catch (error) { + console.error('Failed to get agent tools:', error); + return NextResponse.json( + { success: false, error: 'Failed to get agent tools' }, + { status: 500 } + ); + } +} + /** * POST /api/agents/[id]/tools * Add an individual tool to an agent diff --git a/apps/web/src/app/dashboard/agents/[id]/page.tsx b/apps/web/src/app/dashboard/agents/[id]/page.tsx index d9031b4..bd6b3d8 100644 --- a/apps/web/src/app/dashboard/agents/[id]/page.tsx +++ b/apps/web/src/app/dashboard/agents/[id]/page.tsx @@ -6,7 +6,7 @@ import { Button } from '@tpmjs/ui/Button/Button'; import { Icon } from '@tpmjs/ui/Icon/Icon'; import Link from 'next/link'; import { useParams, useRouter } from 'next/navigation'; -import { useCallback, useEffect, useState } from 'react'; +import { useCallback, useEffect, useRef, useState } from 'react'; import { AppHeader } from '~/components/AppHeader'; interface Agent { @@ -27,6 +27,42 @@ interface Agent { updatedAt: string; } +interface AgentTool { + id: string; + toolId: string; + tool: { + id: string; + name: string; + npmPackageName: string; + description: string | null; + }; +} + +interface AgentCollection { + id: string; + collectionId: string; + collection: { + id: string; + name: string; + description: string | null; + toolCount: number; + }; +} + +interface SearchTool { + id: string; + name: string; + npmPackageName: string; + description: string | null; +} + +interface SearchCollection { + id: string; + name: string; + description: string | null; + toolCount: number; +} + const PROVIDER_DISPLAY_NAMES: Record = { OPENAI: 'OpenAI', ANTHROPIC: 'Anthropic', @@ -35,6 +71,7 @@ const PROVIDER_DISPLAY_NAMES: Record = { MISTRAL: 'Mistral', }; +// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Agent detail page has multiple interconnected features export default function AgentDetailPage(): React.ReactElement { const params = useParams(); const router = useRouter(); @@ -46,6 +83,22 @@ export default function AgentDetailPage(): React.ReactElement { const [isSaving, setIsSaving] = useState(false); const [error, setError] = useState(null); + // Tools state + const [agentTools, setAgentTools] = useState([]); + const [toolSearch, setToolSearch] = useState(''); + const [toolSearchResults, setToolSearchResults] = useState([]); + const [isSearchingTools, setIsSearchingTools] = useState(false); + const [showToolSearch, setShowToolSearch] = useState(false); + const toolSearchRef = useRef(null); + + // Collections state + const [agentCollections, setAgentCollections] = useState([]); + const [collectionSearch, setCollectionSearch] = useState(''); + const [collectionSearchResults, setCollectionSearchResults] = useState([]); + const [isSearchingCollections, setIsSearchingCollections] = useState(false); + const [showCollectionSearch, setShowCollectionSearch] = useState(false); + const collectionSearchRef = useRef(null); + const [formData, setFormData] = useState({ name: '', uid: '', @@ -91,10 +144,217 @@ export default function AgentDetailPage(): React.ReactElement { } }, [agentId, router]); + // Fetch tools attached to agent + const fetchAgentTools = useCallback(async () => { + try { + const response = await fetch(`/api/agents/${agentId}/tools`); + const data = await response.json(); + if (data.success) { + setAgentTools(data.data || []); + } + } catch (err) { + console.error('Failed to fetch agent tools:', err); + } + }, [agentId]); + + // Fetch collections attached to agent + const fetchAgentCollections = useCallback(async () => { + try { + const response = await fetch(`/api/agents/${agentId}/collections`); + const data = await response.json(); + if (data.success) { + setAgentCollections(data.data || []); + } + } catch (err) { + console.error('Failed to fetch agent collections:', err); + } + }, [agentId]); + useEffect(() => { fetchAgent(); }, [fetchAgent]); + useEffect(() => { + if (agent) { + fetchAgentTools(); + fetchAgentCollections(); + } + }, [agent, fetchAgentTools, fetchAgentCollections]); + + // Click outside handler for search dropdowns + useEffect(() => { + function handleClickOutside(event: MouseEvent) { + if (toolSearchRef.current && !toolSearchRef.current.contains(event.target as Node)) { + setShowToolSearch(false); + } + if ( + collectionSearchRef.current && + !collectionSearchRef.current.contains(event.target as Node) + ) { + setShowCollectionSearch(false); + } + } + document.addEventListener('mousedown', handleClickOutside); + return () => document.removeEventListener('mousedown', handleClickOutside); + }, []); + + // Search tools + const searchTools = useCallback( + async (query: string) => { + if (!query.trim()) { + setToolSearchResults([]); + return; + } + setIsSearchingTools(true); + try { + const response = await fetch(`/api/tools/search?q=${encodeURIComponent(query)}&limit=10`); + const data = await response.json(); + if (data.success) { + // Filter out tools already attached + const attachedToolIds = new Set(agentTools.map((t) => t.toolId)); + const filtered = (data.data || []).filter((t: SearchTool) => !attachedToolIds.has(t.id)); + setToolSearchResults(filtered); + } + } catch (err) { + console.error('Failed to search tools:', err); + } finally { + setIsSearchingTools(false); + } + }, + [agentTools] + ); + + // Search collections + const searchCollections = useCallback( + async (query: string) => { + if (!query.trim()) { + setCollectionSearchResults([]); + return; + } + setIsSearchingCollections(true); + try { + const response = await fetch( + `/api/collections?search=${encodeURIComponent(query)}&limit=10` + ); + const data = await response.json(); + if (data.success) { + // Filter out collections already attached + const attachedCollectionIds = new Set(agentCollections.map((c) => c.collectionId)); + const filtered = (data.data || []).filter( + (c: SearchCollection) => !attachedCollectionIds.has(c.id) + ); + setCollectionSearchResults(filtered); + } + } catch (err) { + console.error('Failed to search collections:', err); + } finally { + setIsSearchingCollections(false); + } + }, + [agentCollections] + ); + + // Debounced search effects + useEffect(() => { + const timer = setTimeout(() => { + if (toolSearch) searchTools(toolSearch); + }, 300); + return () => clearTimeout(timer); + }, [toolSearch, searchTools]); + + useEffect(() => { + const timer = setTimeout(() => { + if (collectionSearch) searchCollections(collectionSearch); + }, 300); + return () => clearTimeout(timer); + }, [collectionSearch, searchCollections]); + + // Add tool to agent + const addTool = async (toolId: string) => { + try { + const response = await fetch(`/api/agents/${agentId}/tools`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ toolId }), + }); + const data = await response.json(); + if (data.success) { + await fetchAgentTools(); + await fetchAgent(); + setToolSearch(''); + setToolSearchResults([]); + setShowToolSearch(false); + } else { + alert(data.error || 'Failed to add tool'); + } + } catch (err) { + console.error('Failed to add tool:', err); + alert('Failed to add tool'); + } + }; + + // Remove tool from agent + const removeTool = async (toolId: string) => { + try { + const response = await fetch(`/api/agents/${agentId}/tools/${toolId}`, { + method: 'DELETE', + }); + const data = await response.json(); + if (data.success) { + await fetchAgentTools(); + await fetchAgent(); + } else { + alert(data.error || 'Failed to remove tool'); + } + } catch (err) { + console.error('Failed to remove tool:', err); + alert('Failed to remove tool'); + } + }; + + // Add collection to agent + const addCollection = async (collectionId: string) => { + try { + const response = await fetch(`/api/agents/${agentId}/collections`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ collectionId }), + }); + const data = await response.json(); + if (data.success) { + await fetchAgentCollections(); + await fetchAgent(); + setCollectionSearch(''); + setCollectionSearchResults([]); + setShowCollectionSearch(false); + } else { + alert(data.error || 'Failed to add collection'); + } + } catch (err) { + console.error('Failed to add collection:', err); + alert('Failed to add collection'); + } + }; + + // Remove collection from agent + const removeCollection = async (collectionId: string) => { + try { + const response = await fetch(`/api/agents/${agentId}/collections/${collectionId}`, { + method: 'DELETE', + }); + const data = await response.json(); + if (data.success) { + await fetchAgentCollections(); + await fetchAgent(); + } else { + alert(data.error || 'Failed to remove collection'); + } + } catch (err) { + console.error('Failed to remove collection:', err); + alert('Failed to remove collection'); + } + }; + const handleChange = ( e: React.ChangeEvent ) => { @@ -285,6 +545,198 @@ export default function AgentDetailPage(): React.ReactElement { + {/* Tools Section */} +
+
+

Tools

+ {agentTools.length} attached +
+ + {/* Current Tools */} + {agentTools.length > 0 && ( +
+ {agentTools.map((at) => ( +
+
+ +
+

{at.tool.name}

+

+ {at.tool.npmPackageName} +

+
+
+ +
+ ))} +
+ )} + + {/* Add Tool Search */} +
+
+ + { + setToolSearch(e.target.value); + setShowToolSearch(true); + }} + onFocus={() => setShowToolSearch(true)} + placeholder="Search tools to add..." + className="w-full pl-9 pr-3 py-2 bg-background border border-border rounded-lg text-foreground text-sm placeholder:text-foreground-tertiary focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary" + /> + {isSearchingTools && ( +
+
+
+ )} +
+ + {/* Search Results Dropdown */} + {showToolSearch && toolSearch && ( +
+ {toolSearchResults.length > 0 ? ( + toolSearchResults.map((tool) => ( + + )) + ) : !isSearchingTools ? ( +
+ No tools found +
+ ) : null} +
+ )} +
+
+ + {/* Collections Section */} +
+
+

Collections

+ + {agentCollections.length} attached + +
+ + {/* Current Collections */} + {agentCollections.length > 0 && ( +
+ {agentCollections.map((ac) => ( +
+
+ +
+

+ {ac.collection.name} +

+

+ {ac.collection.toolCount} tools +

+
+
+ +
+ ))} +
+ )} + + {/* Add Collection Search */} +
+
+ + { + setCollectionSearch(e.target.value); + setShowCollectionSearch(true); + }} + onFocus={() => setShowCollectionSearch(true)} + placeholder="Search collections to add..." + className="w-full pl-9 pr-3 py-2 bg-background border border-border rounded-lg text-foreground text-sm placeholder:text-foreground-tertiary focus:outline-none focus:ring-2 focus:ring-primary/50 focus:border-primary" + /> + {isSearchingCollections && ( +
+
+
+ )} +
+ + {/* Search Results Dropdown */} + {showCollectionSearch && collectionSearch && ( +
+ {collectionSearchResults.length > 0 ? ( + collectionSearchResults.map((collection) => ( + + )) + ) : !isSearchingCollections ? ( +
+ No collections found +
+ ) : null} +
+ )} +
+
+ {/* Configuration */}