"use client"; import { motion, AnimatePresence } from "framer-motion"; import { useState, useEffect, useRef } from "react"; import { ChevronDown } from "lucide-react"; import VariableReferencePicker from "./VariableReferencePicker"; import { toast } from "sonner"; import { useQuery } from "convex/react"; import { api } from "@/convex/_generated/api"; import { useUser } from "@clerk/nextjs"; import { Id } from "@/convex/_generated/dataModel"; // import FirecrawlLogo from "@/components/icons/FirecrawlLogo"; interface NodePanelProps { nodeData: { id: string; label: string; type: string; } | null; nodes?: any[]; // All nodes for variable reference onClose: () => void; onAddMCP: () => void; onDelete: (nodeId: string) => void; onUpdate: (nodeId: string, data: any) => void; onOpenSettings?: () => void; // To open Settings panel for MCP configuration } export default function NodePanel({ nodeData, nodes, onClose, onAddMCP, onDelete, onUpdate, onOpenSettings, }: NodePanelProps) { const { user } = useUser(); // MCP states - now store only server IDs, not full configs const [showMCPSelector, setShowMCPSelector] = useState(false); const [expandedMcpId, setExpandedMcpId] = useState(null); const [currentMCPServerIds, setCurrentMCPServerIds] = useState([]); const [showModelsDropdown, setShowModelsDropdown] = useState(false); // Fetch enabled MCP servers from central registry const mcpServers = useQuery(api.mcpServers.getEnabledMCPs, user?.id ? { userId: user.id } : "skip" ); // Fetch user's LLM API keys to determine available models const userLLMKeys = useQuery(api.userLLMKeys.getUserLLMKeys, user?.id ? { userId: user.id } : "skip" ); // Get available models based on active API keys const getAvailableModels = () => { if (!userLLMKeys) return []; const models: { provider: string; models: Array<{ id: string; name: string }> }[] = []; // Check for active keys and add corresponding models const activeKeys = userLLMKeys.filter(key => key.isActive); activeKeys.forEach(key => { if (key.provider === 'anthropic') { models.push({ provider: 'Anthropic', models: [ { id: 'anthropic/claude-sonnet-4-5-20250929', name: 'Claude Sonnet 4.5' }, { id: 'anthropic/claude-haiku-4-5-20251001', name: 'Claude Haiku 4.5' }, ] }); } else if (key.provider === 'openai') { models.push({ provider: 'OpenAI', models: [ { id: 'openai/gpt-4o', name: 'GPT-5' }, { id: 'openai/gpt-4o-mini', name: 'GPT-5 Mini' }, ] }); } else if (key.provider === 'groq') { models.push({ provider: 'Groq', models: [ { id: 'groq/openai/gpt-oss-120b', name: 'GPT OSS 120B' }, ] }); } }); return models; }; // Helper to update JSON schema from fields array const updateSchemaFromFields = ( fields: Array<{ name: string; type: string; required: boolean }>, ) => { const properties: any = {}; const required: string[] = []; fields.forEach((field) => { if (field.name) { properties[field.name] = { type: field.type }; if (field.required) { required.push(field.name); } } }); const schema: any = { type: "object", properties, }; if (required.length > 0) { schema.required = required; } setJsonOutputSchema(JSON.stringify(schema, null, 2)); }; // Track current node's MCP server IDs useEffect(() => { if (nodeData && nodes) { const actualNode = nodes.find((n) => n.id === nodeData.id); if (actualNode) { const data = actualNode.data as any; // If we already have server IDs, use them if (data.mcpServerIds && Array.isArray(data.mcpServerIds) && data.mcpServerIds.length > 0) { setCurrentMCPServerIds(data.mcpServerIds); } // If we have mcpTools but no server IDs, try to match them else if (data.mcpTools && Array.isArray(data.mcpTools) && mcpServers && mcpServers.length > 0) { console.log('πŸ”„ Re-matching mcpTools after servers loaded'); const mcpIds = data.mcpTools .map((tool: any) => { const normalizeUrl = (url: string) => url?.replace(/\{[^}]+\}/g, '').replace(/\/+$/, '').toLowerCase(); const matchingServer = mcpServers.find( (server: any) => { const toolUrlNormalized = normalizeUrl(tool.url || ''); const serverUrlNormalized = normalizeUrl(server.url || ''); const urlMatch = toolUrlNormalized === serverUrlNormalized || (toolUrlNormalized && serverUrlNormalized && (toolUrlNormalized.includes(serverUrlNormalized) || serverUrlNormalized.includes(toolUrlNormalized))); const nameMatch = server.name?.toLowerCase() === tool.name?.toLowerCase() || server.label?.toLowerCase() === tool.label?.toLowerCase(); return urlMatch || nameMatch; } ); return matchingServer?._id; }) .filter(Boolean); if (mcpIds.length > 0) { console.log('βœ… Matched server IDs:', mcpIds); setCurrentMCPServerIds(mcpIds); } } } } }, [nodeData?.id, nodes, mcpServers]); // Initialize from nodeData if available const [name, setName] = useState(nodeData?.label || "My agent"); const [instructions, setInstructions] = useState((nodeData as any)?.instructions || ""); const [includeChatHistory, setIncludeChatHistory] = useState(true); const [model, setModel] = useState("anthropic/claude-sonnet-4-5-20250929"); const [outputFormat, setOutputFormat] = useState("Text"); const [customModel, setCustomModel] = useState(""); const [showAdvanced, setShowAdvanced] = useState(false); const [showSearchSources, setShowSearchSources] = useState(false); const [jsonOutputSchema, setJsonOutputSchema] = useState(`{ "type": "object", "properties": { "result": { "type": "string" } } }`); const [schemaFields, setSchemaFields] = useState< Array<{ name: string; type: string; required: boolean }> >([{ name: "result", type: "string", required: false }]); const lastLoadedNodeId = useRef(null); const lastSyncedInstructionsRef = useRef(null); // Load actual node data when panel opens useEffect(() => { if (!nodeData || !nodes) return; const actualNode = nodes.find((n) => n.id === nodeData.id); if (!actualNode) return; const data = actualNode.data as any; const isNewNode = lastLoadedNodeId.current !== nodeData.id; const incomingInstructions = typeof data.instructions === "string" ? data.instructions : ""; if (isNewNode) { lastLoadedNodeId.current = nodeData.id; setName(data.name || data.nodeName || nodeData.label); setInstructions(incomingInstructions); setIncludeChatHistory(data.includeChatHistory ?? true); setModel(data.model || "anthropic/claude-sonnet-4-5-20250929"); setOutputFormat(data.outputFormat || "Text"); setShowSearchSources(data.showSearchSources ?? false); lastSyncedInstructionsRef.current = incomingInstructions; // Initialize MCP servers from node data if (data.mcpServerIds && Array.isArray(data.mcpServerIds)) { // If node already has server IDs, use them directly setCurrentMCPServerIds(data.mcpServerIds); } else if (data.mcpTools && Array.isArray(data.mcpTools)) { // Convert mcpTools to server IDs by matching against available MCP servers if (mcpServers && mcpServers.length > 0) { console.log('πŸ” Matching mcpTools from template:', data.mcpTools); console.log('πŸ” Available MCP servers:', mcpServers.map((s: any) => ({ name: s.name, url: s.url }))); const mcpIds = data.mcpTools .map((tool: any) => { // Try to find matching MCP server by URL or name const matchingServer = mcpServers.find( (server: any) => { // Normalize URLs by removing placeholders for comparison const normalizeUrl = (url: string) => url?.replace(/\{[^}]+\}/g, '').replace(/\/+$/, '').toLowerCase(); const toolUrlNormalized = normalizeUrl(tool.url || ''); const serverUrlNormalized = normalizeUrl(server.url || ''); // Match by URL (exact match after normalization) const urlMatch = toolUrlNormalized === serverUrlNormalized || (toolUrlNormalized && serverUrlNormalized && toolUrlNormalized.includes(serverUrlNormalized.split('/')[0]) || serverUrlNormalized.includes(toolUrlNormalized.split('/')[0])); // Match by name (case-insensitive) const nameMatch = server.name?.toLowerCase() === tool.name?.toLowerCase() || server.label?.toLowerCase() === tool.label?.toLowerCase() || server.name?.toLowerCase() === tool.label?.toLowerCase(); if (urlMatch || nameMatch) { console.log('βœ… Matched tool', tool.name, 'to server', server.name); } return urlMatch || nameMatch; } ); return matchingServer?._id; }) .filter(Boolean); console.log('🎯 Matched MCP server IDs:', mcpIds); if (mcpIds.length > 0) { setCurrentMCPServerIds(mcpIds); } } } if (data.jsonOutputSchema) { setJsonOutputSchema(data.jsonOutputSchema); try { const parsed = JSON.parse(data.jsonOutputSchema); if (parsed.properties) { const fields = Object.entries(parsed.properties).map( ([propName, prop]: [string, any]) => ({ name: propName, type: prop.type || "string", required: parsed.required?.includes(propName) || false, }), ); setSchemaFields(fields); } } catch (e) { // ignore parse errors } } return; } if (isNewNode) { lastLoadedNodeId.current = nodeData.id; } if (incomingInstructions !== lastSyncedInstructionsRef.current) { lastSyncedInstructionsRef.current = incomingInstructions; if (incomingInstructions !== instructions) { setInstructions(incomingInstructions); } } else if ( incomingInstructions === instructions && incomingInstructions !== lastSyncedInstructionsRef.current ) { lastSyncedInstructionsRef.current = incomingInstructions; } }, [nodeData, nodes, instructions, mcpServers]); // Auto-save changes with proper dependency tracking useEffect(() => { if (!nodeData?.id) return; const timeoutId = setTimeout(() => { try { // Build mcpTools from currentMCPServerIds const mcpTools = currentMCPServerIds .map((serverId: string) => { const server = mcpServers?.find((s: any) => s._id === serverId); if (server) { return { id: server._id, name: server.name, url: server.url, label: server.label || server.name, authType: server.authType, }; } return null; }) .filter(Boolean); onUpdate(nodeData.id, { name, nodeName: name, instructions, includeChatHistory, model, outputFormat, jsonOutputSchema: outputFormat === "JSON" ? jsonOutputSchema : undefined, showSearchSources, mcpTools: mcpTools.length > 0 ? mcpTools : undefined, mcpServerIds: currentMCPServerIds.length > 0 ? currentMCPServerIds : undefined, }); } catch (error) { console.error("Error updating node:", error); } }, 500); return () => clearTimeout(timeoutId); }, [ name, instructions, includeChatHistory, model, outputFormat, jsonOutputSchema, showSearchSources, currentMCPServerIds, mcpServers, ]); return ( {nodeData && ( {/* Header */}
setName(e.target.value)} className="text-label-large font-medium text-accent-black bg-transparent border-none outline-none focus:outline-none hover:bg-black-alpha-4 px-2 -ml-2 rounded-4 transition-colors" placeholder="Enter node name..." />

Call the model with your instructions and tools

{/* Form Fields */}
{/* Instructions Field */}
{nodes && ( setInstructions(instructions + ` {{${ref}}}`) } /> )}