mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-05-25 18:55:19 +02:00
Add hosted tools + revamp tools UX
Replace db storage with api calls to klavis for list servers and add filters to hosted tool views Add logging and simplify oauth Refactor klavis API calls to go via a proxy Add projectAuthCheck() to klavis actions Fix build error in stream-response route.ts PARTIAL: Revamp tools modal PARTIAL: Manage mcp servers at project level document PARTIAL: Fetch tools from MCP servers upon toggle ON PARTIAL: Propogate hosted MCP tools to entity_list in build view Show tool toggle banner Add sync explicitly to prevent long page load time for MCP server's tools PARTIAL: Fix auth flow DB writes PARTIAL: Add tools with isready flag for auth-related server handling PARTIAL: Bring back sync tools CTA Fix tool selection issues PARTIAL: Fix sync issues with enriched and available tools and log unenriched tool names Remove buggy log statement Refactor common components and refactor HostedServer PARTIAL: Add custom servers and standardize the UI PARTIAL: Add modal and small UI improvements to custom servers page Show clubbed MCP tools in entity_list Add tool filters in tools section of entity_list Revert text in add tool CTA Make entity_list sections collapsed when one is expanded Merge project level tools to workflow level tools when sending requests to agent service Restore original panel-common variants Reduce agentic workflow request by removing tools from mcp servers Merge project level tools to workflow level tools when sending requests to copilot service Fix padding issues in entity_list headers Update package-lock.json Revert package* files to devg Revert tsconfig to dev PARTIAL: Change tabs and switch to heroui pending switch issues Fix switch issues with heroui Pass projectTools via workflow/app to entity_list and do not write to DB Fix issue with tool_config rendering and @ mentions for project tools Include @ mentioned project tools in agent request Update copilot usage of project tools Read mcp server url directly from tool config in agents service Make entity_list panels resizable Update resize handlers across the board Change Hosted MCP servers ---> Tools Library Remove tools filter Remove filter tabs in hosted tools Move tools selected / tools available labels below card titles Remove tools from config / settings page Bring back old mcp servers handling in agents service for backward compatibility as fallback Remove web_search from project template Add icons for agents, tools and prompts in entity_list Enable agents reordering in entity_list Fix build errors Make entity_list icons more transparent Add logos for hosted tools and fix importsg Fix server card component sizes and overflow Add error handling in hosted servers pageg Add node_modules to gitignore remove root package json add project auth checks revert to project mcpServers being optional refactor tool merging and conversion revert stream route change Move authURL klavis logic to klavis_actions Fix tool enrichment for post-auth tools and make logging less verbose Expand tool schema to include comprehensive json schema fields Add enabled and ready filters to hosted tools Add needs auth warning above auth button Update tools icon Add github and google client ids to docker-compose Clean up MCP servers upon project deletion Remove klavis ai label Improve server loading on and off UX Fix bug that was not enriching un-auth servers Add tool testing capabilities Fix un-blurred strip in tool testing modal view Disable server card CTAs during toggling on or off transition Add beta tag to tools Add tool and server counts Truncate long tool descriptions Add separators between filters in servers view Support multiple format types in tool testing fields Fix menu position issue for @ mentions
This commit is contained in:
parent
b5ff40af8e
commit
9157f87dc7
64 changed files with 5625 additions and 1242 deletions
|
|
@ -0,0 +1,460 @@
|
|||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Info, Plus, Search } from 'lucide-react';
|
||||
import { clsx } from 'clsx';
|
||||
import { z } from 'zod';
|
||||
import { MCPServer } from '@/app/lib/types/types';
|
||||
import {
|
||||
ServerCard,
|
||||
ToolManagementPanel
|
||||
} from './MCPServersCommon';
|
||||
import { fetchMcpToolsForServer } from '@/app/actions/mcp_actions';
|
||||
import {
|
||||
fetchCustomServers,
|
||||
addCustomServer,
|
||||
removeCustomServer,
|
||||
toggleCustomServer,
|
||||
updateCustomServerTools
|
||||
} from '@/app/actions/custom_server_actions';
|
||||
import { Modal } from '@/components/ui/modal';
|
||||
|
||||
type McpServerType = z.infer<typeof MCPServer>;
|
||||
type McpToolType = z.infer<typeof MCPServer>['tools'][number];
|
||||
|
||||
export function CustomServers() {
|
||||
const params = useParams();
|
||||
const projectId = typeof params.projectId === 'string' ? params.projectId : params.projectId?.[0];
|
||||
if (!projectId) throw new Error('Project ID is required');
|
||||
|
||||
const [servers, setServers] = useState<McpServerType[]>([]);
|
||||
const [selectedServer, setSelectedServer] = useState<McpServerType | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [togglingServers, setTogglingServers] = useState<Set<string>>(new Set());
|
||||
const [serverOperations, setServerOperations] = useState<Map<string, 'setup' | 'delete'>>(new Map());
|
||||
const [selectedTools, setSelectedTools] = useState<Set<string>>(new Set());
|
||||
const [hasToolChanges, setHasToolChanges] = useState(false);
|
||||
const [savingTools, setSavingTools] = useState(false);
|
||||
const [syncingServers, setSyncingServers] = useState<Set<string>>(new Set());
|
||||
const [showAddServer, setShowAddServer] = useState(false);
|
||||
const [newServerName, setNewServerName] = useState('');
|
||||
const [newServerUrl, setNewServerUrl] = useState('');
|
||||
|
||||
const fetchServers = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const customServers = await fetchCustomServers(projectId);
|
||||
setServers(customServers);
|
||||
setError(null);
|
||||
} catch (err: any) {
|
||||
setError(err?.message || 'Failed to load custom MCP servers');
|
||||
console.error('Error fetching servers:', err);
|
||||
setServers([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchServers();
|
||||
}, [fetchServers]);
|
||||
|
||||
const handleToggleServer = async (server: McpServerType) => {
|
||||
try {
|
||||
const serverKey = server.name;
|
||||
setTogglingServers(prev => {
|
||||
const next = new Set(prev);
|
||||
next.add(serverKey);
|
||||
return next;
|
||||
});
|
||||
|
||||
setServerOperations(prev => {
|
||||
const next = new Map(prev);
|
||||
next.set(serverKey, server.isActive ? 'delete' : 'setup');
|
||||
return next;
|
||||
});
|
||||
|
||||
await toggleCustomServer(projectId, server.name, !server.isActive);
|
||||
|
||||
// Update local state
|
||||
setServers(prevServers => {
|
||||
return prevServers.map(s => {
|
||||
if (s.name === serverKey) {
|
||||
return {
|
||||
...s,
|
||||
isActive: !s.isActive
|
||||
};
|
||||
}
|
||||
return s;
|
||||
});
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Toggle failed:', { server: server.name, error: err });
|
||||
} finally {
|
||||
const serverKey = server.name;
|
||||
setTogglingServers(prev => {
|
||||
const next = new Set(prev);
|
||||
next.delete(serverKey);
|
||||
return next;
|
||||
});
|
||||
setServerOperations(prev => {
|
||||
const next = new Map(prev);
|
||||
next.delete(serverKey);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleSyncServer = async (server: McpServerType) => {
|
||||
if (!projectId || !server.isActive) return;
|
||||
|
||||
try {
|
||||
setSyncingServers(prev => {
|
||||
const next = new Set(prev);
|
||||
next.add(server.name);
|
||||
return next;
|
||||
});
|
||||
const enrichedTools = await fetchMcpToolsForServer(projectId, server.name);
|
||||
|
||||
const updatedAvailableTools = enrichedTools.map(tool => ({
|
||||
id: tool.name,
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
parameters: tool.parameters
|
||||
}));
|
||||
|
||||
await updateCustomServerTools(
|
||||
projectId,
|
||||
server.name,
|
||||
updatedAvailableTools, // Auto-select all tools for custom servers
|
||||
updatedAvailableTools
|
||||
);
|
||||
|
||||
// Update servers state
|
||||
setServers(prevServers => {
|
||||
return prevServers.map(s => {
|
||||
if (s.name === server.name) {
|
||||
return {
|
||||
...s,
|
||||
availableTools: updatedAvailableTools,
|
||||
tools: updatedAvailableTools
|
||||
};
|
||||
}
|
||||
return s;
|
||||
});
|
||||
});
|
||||
|
||||
// If this server is currently selected, update the selectedTools state
|
||||
if (selectedServer?.name === server.name) {
|
||||
setSelectedServer(prev => {
|
||||
if (!prev) return null;
|
||||
return {
|
||||
...prev,
|
||||
availableTools: updatedAvailableTools,
|
||||
tools: updatedAvailableTools
|
||||
};
|
||||
});
|
||||
// Update selectedTools to include all tools for the custom server
|
||||
setSelectedTools(new Set(updatedAvailableTools.map(tool => tool.id)));
|
||||
}
|
||||
} finally {
|
||||
setSyncingServers(prev => {
|
||||
const next = new Set(prev);
|
||||
next.delete(server.name);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Add effect to sync selectedTools when selectedServer changes
|
||||
useEffect(() => {
|
||||
if (selectedServer) {
|
||||
setSelectedTools(new Set(selectedServer.tools.map(tool => tool.id)));
|
||||
setHasToolChanges(false);
|
||||
}
|
||||
}, [selectedServer]);
|
||||
|
||||
const handleAddServer = async () => {
|
||||
if (!newServerName || !newServerUrl) return;
|
||||
|
||||
try {
|
||||
const newServer: McpServerType = {
|
||||
id: `custom-${Date.now()}`,
|
||||
name: newServerName,
|
||||
description: `Custom MCP server at ${newServerUrl}`,
|
||||
serverUrl: newServerUrl,
|
||||
tools: [],
|
||||
availableTools: [],
|
||||
isActive: true,
|
||||
isReady: true,
|
||||
serverType: 'custom',
|
||||
authNeeded: false,
|
||||
isAuthenticated: false
|
||||
};
|
||||
|
||||
// Add to MongoDB and get back the formatted server
|
||||
const formattedServer = await addCustomServer(projectId, newServer);
|
||||
|
||||
// Update local state with the formatted server
|
||||
setServers(prev => [...prev, formattedServer]);
|
||||
setShowAddServer(false);
|
||||
setNewServerName('');
|
||||
setNewServerUrl('');
|
||||
|
||||
// Fetch tools for the new server using the formatted URL
|
||||
await handleSyncServer(formattedServer);
|
||||
} catch (err) {
|
||||
console.error('Error adding server:', err);
|
||||
setError('Failed to add server. Please try again.');
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveServer = async (server: McpServerType) => {
|
||||
// Show confirmation dialog
|
||||
const shouldRemove = window.confirm(
|
||||
"Are you sure you want to delete this server? Alternatively, you can toggle it OFF if you'd like to retain the configuration but not make it available to agents."
|
||||
);
|
||||
|
||||
if (!shouldRemove) return;
|
||||
|
||||
try {
|
||||
await removeCustomServer(projectId, server.name);
|
||||
// Update local state
|
||||
setServers(prev => prev.filter(s => s.name !== server.name));
|
||||
// If this server was selected, close the tool management panel
|
||||
if (selectedServer?.name === server.name) {
|
||||
setSelectedServer(null);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Error removing server:', err);
|
||||
setError('Failed to remove server. Please try again.');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveToolSelection = async () => {
|
||||
if (!selectedServer || !projectId) return;
|
||||
|
||||
setSavingTools(true);
|
||||
try {
|
||||
const availableTools = selectedServer.availableTools || [];
|
||||
const selectedToolsList = availableTools.filter(tool => selectedTools.has(tool.id));
|
||||
|
||||
await updateCustomServerTools(
|
||||
projectId,
|
||||
selectedServer.name,
|
||||
selectedToolsList,
|
||||
availableTools
|
||||
);
|
||||
|
||||
setServers(prevServers => {
|
||||
return prevServers.map(s => {
|
||||
if (s.name === selectedServer.name) {
|
||||
return {
|
||||
...s,
|
||||
tools: selectedToolsList
|
||||
};
|
||||
}
|
||||
return s;
|
||||
});
|
||||
});
|
||||
|
||||
setSelectedServer(prev => {
|
||||
if (!prev) return null;
|
||||
return {
|
||||
...prev,
|
||||
tools: selectedToolsList
|
||||
};
|
||||
});
|
||||
|
||||
setHasToolChanges(false);
|
||||
} catch (error) {
|
||||
console.error('Error saving tool selection:', error);
|
||||
} finally {
|
||||
setSavingTools(false);
|
||||
}
|
||||
};
|
||||
|
||||
const filteredServers = servers.filter(server => {
|
||||
const searchLower = searchQuery.toLowerCase();
|
||||
const serverTools = server.tools || [];
|
||||
return (
|
||||
server.name.toLowerCase().includes(searchLower) ||
|
||||
server.description.toLowerCase().includes(searchLower) ||
|
||||
serverTools.some(tool =>
|
||||
tool.name.toLowerCase().includes(searchLower) ||
|
||||
tool.description.toLowerCase().includes(searchLower)
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-100 dark:border-blue-800 rounded-lg p-4">
|
||||
<div className="flex gap-3">
|
||||
<div className="flex-shrink-0">
|
||||
<Info className="h-5 w-5 text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<p className="text-sm text-blue-700 dark:text-blue-300">
|
||||
Add your own MCP servers here. These servers will be available to agents in the Build view once toggled ON.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="primary"
|
||||
onClick={() => setShowAddServer(true)}
|
||||
>
|
||||
<div className="inline-flex items-center">
|
||||
<Plus className="h-4 w-4" />
|
||||
<span className="ml-2">Add Server</span>
|
||||
</div>
|
||||
</Button>
|
||||
<div className="flex-1 flex items-center gap-4">
|
||||
<div className="relative flex-1">
|
||||
<div className="absolute inset-y-0 left-2 flex items-center pointer-events-none">
|
||||
<Search className="h-4 w-4 text-gray-400 dark:text-gray-500" />
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search servers or tools..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full pl-8 pr-4 py-2 text-sm border border-gray-200 dark:border-gray-700 rounded-md
|
||||
bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100
|
||||
placeholder-gray-400 dark:placeholder-gray-500
|
||||
focus:outline-none focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400
|
||||
hover:border-gray-300 dark:hover:border-gray-600 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400 whitespace-nowrap">
|
||||
{filteredServers.length} {filteredServers.length === 1 ? 'server' : 'servers'} • {
|
||||
filteredServers.reduce((total, server) => total + (server.availableTools?.length || 0), 0)
|
||||
} tools
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
isOpen={showAddServer}
|
||||
onClose={() => {
|
||||
setShowAddServer(false);
|
||||
setNewServerName('');
|
||||
setNewServerUrl('');
|
||||
}}
|
||||
title="Add Custom MCP Server"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Server Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newServerName}
|
||||
onChange={(e) => setNewServerName(e.target.value)}
|
||||
placeholder="e.g., My Custom Server"
|
||||
className="w-full px-3 py-2 text-sm border border-gray-200 dark:border-gray-700 rounded-md
|
||||
bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100
|
||||
focus:outline-none focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Server URL
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newServerUrl}
|
||||
onChange={(e) => setNewServerUrl(e.target.value)}
|
||||
placeholder="e.g., http://localhost:3000"
|
||||
className="w-full px-3 py-2 text-sm border border-gray-200 dark:border-gray-700 rounded-md
|
||||
bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100
|
||||
focus:outline-none focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 pt-4">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
setShowAddServer(false);
|
||||
setNewServerName('');
|
||||
setNewServerUrl('');
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="primary"
|
||||
onClick={handleAddServer}
|
||||
disabled={!newServerName || !newServerUrl}
|
||||
>
|
||||
Add Server
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
{loading ? (
|
||||
<div className="text-center py-8">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-gray-800 dark:border-gray-200 mx-auto"></div>
|
||||
<p className="mt-4 text-sm text-gray-600 dark:text-gray-400">Loading servers...</p>
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="text-center py-8 text-red-500 dark:text-red-400">{error}</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{filteredServers.map((server) => (
|
||||
<ServerCard
|
||||
key={server.id}
|
||||
server={server}
|
||||
onToggle={() => handleToggleServer(server)}
|
||||
onManageTools={() => setSelectedServer(server)}
|
||||
onSync={() => handleSyncServer(server)}
|
||||
onRemove={() => handleRemoveServer(server)}
|
||||
isToggling={togglingServers.has(server.name)}
|
||||
isSyncing={syncingServers.has(server.name)}
|
||||
operation={serverOperations.get(server.name)}
|
||||
error={error && error.includes(server.name) ? { message: error } : undefined}
|
||||
showAuth={false}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ToolManagementPanel
|
||||
server={selectedServer}
|
||||
onClose={() => {
|
||||
setSelectedServer(null);
|
||||
setSelectedTools(new Set());
|
||||
setHasToolChanges(false);
|
||||
}}
|
||||
selectedTools={selectedTools}
|
||||
onToolSelectionChange={(toolId, selected) => {
|
||||
setSelectedTools(prev => {
|
||||
const next = new Set(prev);
|
||||
if (selected) {
|
||||
next.add(toolId);
|
||||
} else {
|
||||
next.delete(toolId);
|
||||
}
|
||||
setHasToolChanges(true);
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
onSaveTools={handleSaveToolSelection}
|
||||
onSyncTools={selectedServer ? () => handleSyncServer(selectedServer) : undefined}
|
||||
hasChanges={hasToolChanges}
|
||||
isSaving={savingTools}
|
||||
isSyncing={selectedServer ? syncingServers.has(selectedServer.name) : false}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,663 @@
|
|||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Info, RefreshCw, Search, AlertTriangle } from 'lucide-react';
|
||||
import { clsx } from 'clsx';
|
||||
import {
|
||||
listAvailableMcpServers,
|
||||
enableServer,
|
||||
updateProjectServers,
|
||||
generateServerAuthUrl,
|
||||
syncServerTools
|
||||
} from '@/app/actions/klavis_actions';
|
||||
import { toggleMcpTool, fetchMcpToolsForServer } from '@/app/actions/mcp_actions';
|
||||
import { z } from 'zod';
|
||||
import { MCPServer } from '@/app/lib/types/types';
|
||||
import { Checkbox } from '@heroui/react';
|
||||
import {
|
||||
ServerCard,
|
||||
ToolManagementPanel,
|
||||
} from './MCPServersCommon';
|
||||
|
||||
type McpServerType = z.infer<typeof MCPServer>;
|
||||
type McpToolType = z.infer<typeof MCPServer>['tools'][number];
|
||||
|
||||
function sortServers(servers: McpServerType[]): McpServerType[] {
|
||||
return [...servers].sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
const fadeInAnimation = {
|
||||
'@keyframes fadeIn': {
|
||||
'0%': { opacity: 0, transform: 'translateY(-5px)' },
|
||||
'100%': { opacity: 1, transform: 'translateY(0)' }
|
||||
},
|
||||
'.animate-fadeIn': {
|
||||
animation: 'fadeIn 0.2s ease-out'
|
||||
}
|
||||
} as const;
|
||||
|
||||
const toolCardStyles = {
|
||||
base: clsx(
|
||||
"group p-4 rounded-lg transition-all duration-200",
|
||||
"bg-gray-50/50 dark:bg-gray-800/50",
|
||||
"hover:bg-gray-100/50 dark:hover:bg-gray-700/50",
|
||||
"border border-transparent",
|
||||
"hover:border-gray-200 dark:hover:border-gray-600"
|
||||
),
|
||||
};
|
||||
|
||||
const ToolCard = ({
|
||||
tool,
|
||||
server,
|
||||
isSelected,
|
||||
onSelect,
|
||||
showCheckbox = false
|
||||
}: {
|
||||
tool: McpToolType;
|
||||
server: McpServerType;
|
||||
isSelected?: boolean;
|
||||
onSelect?: (selected: boolean) => void;
|
||||
showCheckbox?: boolean;
|
||||
}) => {
|
||||
return (
|
||||
<div className={toolCardStyles.base}>
|
||||
<div className="flex items-start gap-3">
|
||||
{showCheckbox && (
|
||||
<Checkbox
|
||||
isSelected={isSelected}
|
||||
onValueChange={onSelect}
|
||||
size="sm"
|
||||
/>
|
||||
)}
|
||||
<div className="flex-1">
|
||||
<h4 className="text-sm font-medium text-gray-900 dark:text-gray-100 mb-1">
|
||||
{tool.name}
|
||||
</h4>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{tool.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ErrorBanner = ({ onRetry }: { onRetry: () => void }) => (
|
||||
<div className="mb-6 bg-red-50 dark:bg-red-900/20 border border-red-100 dark:border-red-800 rounded-lg p-4">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<AlertTriangle className="h-5 w-5 text-red-600 dark:text-red-400" />
|
||||
<p className="text-sm text-red-700 dark:text-red-300">
|
||||
Unable to load hosted tools. Please check your connection and try again. If the problem persists, contact us on Discord.
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={onRetry}
|
||||
className="shrink-0"
|
||||
>
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export function HostedServers() {
|
||||
const params = useParams();
|
||||
const projectId = typeof params.projectId === 'string' ? params.projectId : params.projectId?.[0];
|
||||
if (!projectId) throw new Error('Project ID is required');
|
||||
|
||||
const [servers, setServers] = useState<McpServerType[]>([]);
|
||||
const [selectedServer, setSelectedServer] = useState<McpServerType | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [showOnlyEnabled, setShowOnlyEnabled] = useState(false);
|
||||
const [showOnlyReady, setShowOnlyReady] = useState(false);
|
||||
const [toggleError, setToggleError] = useState<{serverId: string; message: string} | null>(null);
|
||||
const [enabledServers, setEnabledServers] = useState<Set<string>>(new Set());
|
||||
const [togglingServers, setTogglingServers] = useState<Set<string>>(new Set());
|
||||
const [serverOperations, setServerOperations] = useState<Map<string, 'setup' | 'delete' | 'checking-auth'>>(new Map());
|
||||
const [selectedTools, setSelectedTools] = useState<Set<string>>(new Set());
|
||||
const [hasToolChanges, setHasToolChanges] = useState(false);
|
||||
const [savingTools, setSavingTools] = useState(false);
|
||||
const [serverToolCounts, setServerToolCounts] = useState<Map<string, number>>(new Map());
|
||||
const [syncingServers, setSyncingServers] = useState<Set<string>>(new Set());
|
||||
|
||||
const fetchServers = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await listAvailableMcpServers(projectId || "");
|
||||
|
||||
if (response.error || !response.data) {
|
||||
setError('No hosted tools found. Make sure to set your Klavis API key. Contact us on discord if you\'re still unable to see hosted tools.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Mark all servers as hosted type
|
||||
const serversWithType = response.data.map(server => ({
|
||||
...server,
|
||||
serverType: 'hosted' as const
|
||||
}));
|
||||
|
||||
setServers(serversWithType);
|
||||
setError(null);
|
||||
} catch (err: any) {
|
||||
setError('No hosted tools found. Make sure to set your Klavis API key. Contact us on discord if you\'re still unable to see hosted tools.');
|
||||
console.error('Error fetching servers:', err);
|
||||
setServers([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchServers();
|
||||
}, [fetchServers]);
|
||||
|
||||
// Initialize enabled servers on load and keep it updated
|
||||
useEffect(() => {
|
||||
if (servers) {
|
||||
console.log('Updating enabled servers from server data:', servers);
|
||||
const enabled = new Set(
|
||||
servers
|
||||
.filter(server => server.isActive)
|
||||
.map(server => server.name)
|
||||
);
|
||||
console.log('New enabled servers state:', Array.from(enabled));
|
||||
setEnabledServers(enabled);
|
||||
}
|
||||
}, [servers]);
|
||||
|
||||
// Initialize tool counts when servers are loaded
|
||||
useEffect(() => {
|
||||
const newCounts = new Map<string, number>();
|
||||
servers.forEach(server => {
|
||||
if (isServerEligible(server)) {
|
||||
newCounts.set(server.name, server.tools.length);
|
||||
}
|
||||
});
|
||||
setServerToolCounts(newCounts);
|
||||
}, [servers]);
|
||||
|
||||
// Initialize selected tools when opening the panel
|
||||
useEffect(() => {
|
||||
if (selectedServer) {
|
||||
setSelectedTools(new Set(selectedServer.tools.map(t => t.id)));
|
||||
setHasToolChanges(false);
|
||||
}
|
||||
}, [selectedServer]);
|
||||
|
||||
const isServerEligible = (server: McpServerType) => {
|
||||
return server.isActive && (!server.authNeeded || server.isAuthenticated);
|
||||
};
|
||||
|
||||
const handleToggleTool = async (server: McpServerType) => {
|
||||
try {
|
||||
const serverKey = server.name;
|
||||
const isCurrentlyEnabled = enabledServers.has(serverKey);
|
||||
const newState = !isCurrentlyEnabled;
|
||||
|
||||
// Immediately update UI state
|
||||
setServers(prevServers => {
|
||||
return prevServers.map(s => {
|
||||
if (s.name === serverKey) {
|
||||
return {
|
||||
...s,
|
||||
isActive: newState,
|
||||
// If turning off, reset these states
|
||||
...(newState ? {} : {
|
||||
serverUrl: undefined,
|
||||
tools: [],
|
||||
isAuthenticated: false
|
||||
})
|
||||
};
|
||||
}
|
||||
return s;
|
||||
});
|
||||
});
|
||||
|
||||
setTogglingServers(prev => {
|
||||
const next = new Set(prev);
|
||||
next.add(serverKey);
|
||||
return next;
|
||||
});
|
||||
setToggleError(null);
|
||||
|
||||
setServerOperations(prev => {
|
||||
const next = new Map(prev);
|
||||
next.set(serverKey, newState ? 'setup' : 'delete');
|
||||
return next;
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await enableServer(server.name, projectId || "", newState);
|
||||
|
||||
setEnabledServers(prev => {
|
||||
const next = new Set(prev);
|
||||
if (!newState) {
|
||||
next.delete(serverKey);
|
||||
} else if ('instanceId' in result) {
|
||||
next.add(serverKey);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
|
||||
if (newState) {
|
||||
const response = await listAvailableMcpServers(projectId || "");
|
||||
if (response.data) {
|
||||
const updatedServer = response.data.find(s => s.name === serverKey);
|
||||
if (updatedServer) {
|
||||
setServers(prevServers => {
|
||||
return prevServers.map(s => {
|
||||
if (s.name === serverKey) {
|
||||
return { ...updatedServer, serverType: 'hosted' as const };
|
||||
}
|
||||
return s;
|
||||
});
|
||||
});
|
||||
|
||||
setServerToolCounts(prev => {
|
||||
const next = new Map(prev);
|
||||
next.set(serverKey, updatedServer.tools.length);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
setServerToolCounts(prev => {
|
||||
const next = new Map(prev);
|
||||
next.set(serverKey, 0);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Toggle failed:', { server: serverKey, error: err });
|
||||
// Revert the UI state on error
|
||||
setServers(prevServers => {
|
||||
return prevServers.map(s => {
|
||||
if (s.name === serverKey) {
|
||||
return {
|
||||
...s,
|
||||
isActive: isCurrentlyEnabled,
|
||||
// Restore previous state if the toggle failed
|
||||
...(isCurrentlyEnabled ? {} : {
|
||||
serverUrl: undefined,
|
||||
tools: [],
|
||||
isAuthenticated: false
|
||||
})
|
||||
};
|
||||
}
|
||||
return s;
|
||||
});
|
||||
});
|
||||
|
||||
setEnabledServers(prev => {
|
||||
const next = new Set(prev);
|
||||
if (newState) {
|
||||
next.delete(serverKey);
|
||||
} else {
|
||||
next.add(serverKey);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
setToggleError({
|
||||
serverId: serverKey,
|
||||
message: "We're having trouble setting up this server. Please reach out on discord."
|
||||
});
|
||||
}
|
||||
} finally {
|
||||
const serverKey = server.name;
|
||||
setTogglingServers(prev => {
|
||||
const next = new Set(prev);
|
||||
next.delete(serverKey);
|
||||
return next;
|
||||
});
|
||||
setServerOperations(prev => {
|
||||
const next = new Map(prev);
|
||||
next.delete(serverKey);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleAuthenticate = async (server: McpServerType) => {
|
||||
try {
|
||||
if (!server.instanceId) {
|
||||
throw new Error('Server instance ID not found');
|
||||
}
|
||||
const authUrl = await generateServerAuthUrl(server.name, projectId, server.instanceId);
|
||||
const authWindow = window.open(
|
||||
authUrl,
|
||||
'_blank',
|
||||
'width=600,height=700'
|
||||
);
|
||||
|
||||
if (authWindow) {
|
||||
const checkInterval = setInterval(async () => {
|
||||
if (authWindow.closed) {
|
||||
clearInterval(checkInterval);
|
||||
|
||||
try {
|
||||
setServerOperations(prev => {
|
||||
const next = new Map(prev);
|
||||
next.set(server.name, 'checking-auth');
|
||||
return next;
|
||||
});
|
||||
|
||||
await updateProjectServers(projectId, server.name);
|
||||
|
||||
const response = await listAvailableMcpServers(projectId);
|
||||
if (response.data) {
|
||||
const updatedServer = response.data.find(us => us.name === server.name);
|
||||
if (updatedServer) {
|
||||
setServers(prevServers => {
|
||||
return prevServers.map(s => {
|
||||
if (s.name === server.name) {
|
||||
return { ...updatedServer, serverType: 'hosted' as const };
|
||||
}
|
||||
return s;
|
||||
});
|
||||
});
|
||||
|
||||
if (selectedServer?.name === server.name) {
|
||||
setSelectedServer({ ...updatedServer, serverType: 'hosted' as const });
|
||||
}
|
||||
|
||||
if (!server.authNeeded || updatedServer.isAuthenticated) {
|
||||
await handleSyncServer(updatedServer);
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
setServerOperations(prev => {
|
||||
const next = new Map(prev);
|
||||
next.delete(server.name);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}
|
||||
}, 500);
|
||||
} else {
|
||||
window.alert('Failed to open authentication window. Please check your popup blocker settings.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[Auth] Error initiating OAuth:', error);
|
||||
window.alert('Failed to setup authentication');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveToolSelection = async () => {
|
||||
if (!selectedServer || !projectId) return;
|
||||
|
||||
setSavingTools(true);
|
||||
try {
|
||||
const availableTools = selectedServer.availableTools || [];
|
||||
const previousTools = new Set(selectedServer.tools.map(t => t.id));
|
||||
const updatedTools = new Set<string>();
|
||||
|
||||
for (const tool of availableTools) {
|
||||
const isSelected = selectedTools.has(tool.id);
|
||||
await toggleMcpTool(projectId, selectedServer.name, tool.id, isSelected);
|
||||
if (isSelected) {
|
||||
updatedTools.add(tool.id);
|
||||
}
|
||||
}
|
||||
|
||||
setServers(prevServers => {
|
||||
return prevServers.map(s => {
|
||||
if (s.name === selectedServer.name) {
|
||||
return {
|
||||
...s,
|
||||
tools: availableTools.filter(tool => selectedTools.has(tool.id))
|
||||
};
|
||||
}
|
||||
return s;
|
||||
});
|
||||
});
|
||||
|
||||
setSelectedServer(prev => {
|
||||
if (!prev) return null;
|
||||
return {
|
||||
...prev,
|
||||
tools: availableTools.filter(tool => selectedTools.has(tool.id))
|
||||
};
|
||||
});
|
||||
|
||||
setServerToolCounts(prev => {
|
||||
const next = new Map(prev);
|
||||
next.set(selectedServer.name, selectedTools.size);
|
||||
return next;
|
||||
});
|
||||
|
||||
setHasToolChanges(false);
|
||||
} catch (error) {
|
||||
console.error('Error saving tool selection:', error);
|
||||
} finally {
|
||||
setSavingTools(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSyncServer = async (server: McpServerType) => {
|
||||
if (!projectId || !isServerEligible(server)) return;
|
||||
|
||||
try {
|
||||
setSyncingServers(prev => {
|
||||
const next = new Set(prev);
|
||||
next.add(server.name);
|
||||
return next;
|
||||
});
|
||||
|
||||
// Call the server action to sync and update DB
|
||||
await syncServerTools(projectId, server.name);
|
||||
|
||||
// Refresh the server list to get updated data
|
||||
const response = await listAvailableMcpServers(projectId);
|
||||
if (response.data) {
|
||||
const updatedServer = response.data.find(s => s.name === server.name);
|
||||
if (updatedServer) {
|
||||
setServers(prevServers => {
|
||||
return prevServers.map(s => {
|
||||
if (s.name === server.name) {
|
||||
return { ...updatedServer, serverType: 'hosted' as const };
|
||||
}
|
||||
return s;
|
||||
});
|
||||
});
|
||||
|
||||
if (selectedServer?.name === server.name) {
|
||||
setSelectedServer({ ...updatedServer, serverType: 'hosted' as const });
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
setSyncingServers(prev => {
|
||||
const next = new Set(prev);
|
||||
next.delete(server.name);
|
||||
return next;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const filteredServers = sortServers(servers.filter(server => {
|
||||
const searchLower = searchQuery.toLowerCase();
|
||||
const serverTools = server.tools || [];
|
||||
|
||||
// Search text filter
|
||||
const matchesSearch =
|
||||
server.name.toLowerCase().includes(searchLower) ||
|
||||
server.description.toLowerCase().includes(searchLower) ||
|
||||
serverTools.some(tool =>
|
||||
tool.name.toLowerCase().includes(searchLower) ||
|
||||
tool.description.toLowerCase().includes(searchLower)
|
||||
);
|
||||
|
||||
// Enabled servers filter
|
||||
const matchesEnabled = !showOnlyEnabled || server.isActive;
|
||||
|
||||
// Ready to use filter (server is active and either doesn't need auth or is already authenticated)
|
||||
const isReady = server.isActive && (!server.authNeeded || server.isAuthenticated);
|
||||
const matchesReady = !showOnlyReady || isReady;
|
||||
|
||||
return matchesSearch && matchesEnabled && matchesReady;
|
||||
}));
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="text-center py-8">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-gray-800 dark:border-gray-200 mx-auto"></div>
|
||||
<p className="mt-4 text-sm text-gray-600 dark:text-gray-400">Loading tools...</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-[50vh]">
|
||||
<p className="text-center text-red-500 dark:text-red-400">
|
||||
{error}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="bg-blue-50 dark:bg-blue-900/20 border border-blue-100 dark:border-blue-800 rounded-lg p-4">
|
||||
<div className="flex gap-3">
|
||||
<div className="flex-shrink-0">
|
||||
<Info className="h-5 w-5 text-blue-600 dark:text-blue-400" />
|
||||
</div>
|
||||
<p className="text-sm text-blue-700 dark:text-blue-300">
|
||||
To make hosted MCP tools available to agents in the Build view, first toggle the servers ON here. Some tools may require authentication after enabling.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex-1 flex items-center gap-4">
|
||||
<div className="relative flex-1">
|
||||
<div className="absolute inset-y-0 left-2 flex items-center pointer-events-none">
|
||||
<Search className="h-4 w-4 text-gray-400 dark:text-gray-500" />
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search servers or tools..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full pl-8 pr-4 py-2 text-sm border border-gray-200 dark:border-gray-700 rounded-md
|
||||
bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100
|
||||
placeholder-gray-400 dark:placeholder-gray-500
|
||||
focus:outline-none focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400
|
||||
hover:border-gray-300 dark:hover:border-gray-600 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400 whitespace-nowrap">
|
||||
{filteredServers.length} {filteredServers.length === 1 ? 'server' : 'servers'} • {
|
||||
filteredServers.reduce((total, server) => total + (server.availableTools?.length || 0), 0)
|
||||
} tools
|
||||
</div>
|
||||
<div className="h-4 w-px bg-gray-200 dark:bg-gray-700" />
|
||||
<div className="flex items-center gap-8">
|
||||
<div className="group relative flex items-center gap-1">
|
||||
<label className="flex items-center gap-1 text-sm text-gray-700 dark:text-gray-300">
|
||||
<Checkbox
|
||||
isSelected={showOnlyEnabled}
|
||||
onValueChange={setShowOnlyEnabled}
|
||||
size="sm"
|
||||
/>
|
||||
Enabled Only
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Info className="h-3.5 w-3.5 text-gray-400 dark:text-gray-500 cursor-help ml-1" />
|
||||
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 px-3 py-2 bg-gray-900 dark:bg-gray-800 text-white text-xs rounded-lg opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-200 whitespace-nowrap shadow-lg">
|
||||
Shows only servers that are currently toggled ON
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="group relative flex items-center gap-1">
|
||||
<label className="flex items-center gap-1 text-sm text-gray-700 dark:text-gray-300">
|
||||
<Checkbox
|
||||
isSelected={showOnlyReady}
|
||||
onValueChange={setShowOnlyReady}
|
||||
size="sm"
|
||||
/>
|
||||
Ready to Use
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Info className="h-3.5 w-3.5 text-gray-400 dark:text-gray-500 cursor-help ml-1" />
|
||||
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 px-3 py-2 bg-gray-900 dark:bg-gray-800 text-white text-xs rounded-lg opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-200 whitespace-nowrap shadow-lg">
|
||||
Shows only servers that are enabled and fully authenticated
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-4 w-px bg-gray-200 dark:bg-gray-700" />
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={fetchServers}
|
||||
disabled={loading}
|
||||
>
|
||||
<div className="inline-flex items-center">
|
||||
<RefreshCw className={clsx("h-4 w-4", loading && "animate-spin")} />
|
||||
<span className="ml-2">Refresh</span>
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{filteredServers.map((server) => (
|
||||
<ServerCard
|
||||
key={server.instanceId}
|
||||
server={server}
|
||||
onToggle={() => handleToggleTool(server)}
|
||||
onManageTools={() => setSelectedServer(server)}
|
||||
onSync={() => handleSyncServer(server)}
|
||||
onAuth={() => handleAuthenticate(server)}
|
||||
isToggling={togglingServers.has(server.name)}
|
||||
isSyncing={syncingServers.has(server.name)}
|
||||
operation={serverOperations.get(server.name)}
|
||||
error={toggleError?.serverId === server.name ? toggleError : undefined}
|
||||
showAuth={true}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<ToolManagementPanel
|
||||
server={selectedServer}
|
||||
onClose={() => {
|
||||
setSelectedServer(null);
|
||||
setSelectedTools(new Set());
|
||||
setHasToolChanges(false);
|
||||
}}
|
||||
selectedTools={selectedTools}
|
||||
onToolSelectionChange={(toolId, selected) => {
|
||||
setSelectedTools(prev => {
|
||||
const next = new Set(prev);
|
||||
if (selected) {
|
||||
next.add(toolId);
|
||||
} else {
|
||||
next.delete(toolId);
|
||||
}
|
||||
setHasToolChanges(true);
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
onSaveTools={handleSaveToolSelection}
|
||||
onSyncTools={selectedServer ? () => handleSyncServer(selectedServer) : undefined}
|
||||
hasChanges={hasToolChanges}
|
||||
isSaving={savingTools}
|
||||
isSyncing={selectedServer ? syncingServers.has(selectedServer.name) : false}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,498 @@
|
|||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import Image from 'next/image';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { SlidePanel } from '@/components/ui/slide-panel';
|
||||
import { Info, RefreshCw, RefreshCcw, Lock, Wrench } from 'lucide-react';
|
||||
import { clsx } from 'clsx';
|
||||
import { MCPServer, McpTool } from '@/app/lib/types/types';
|
||||
import type { z } from 'zod';
|
||||
import { TestToolModal } from './TestToolModal';
|
||||
|
||||
type McpServerType = z.infer<typeof MCPServer>;
|
||||
type McpToolType = z.infer<typeof McpTool>;
|
||||
|
||||
interface ServerLogoProps {
|
||||
serverName: string;
|
||||
className?: string;
|
||||
fallback?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function ServerLogo({ serverName, className = "", fallback }: ServerLogoProps) {
|
||||
const logoMap: Record<string, string> = {
|
||||
'GitHub': '/mcp-server-images/github.svg',
|
||||
'Google Drive': '/mcp-server-images/gdrive.svg',
|
||||
'Google Docs': '/mcp-server-images/gdocs.svg',
|
||||
'Jira': '/mcp-server-images/jira.svg',
|
||||
'Notion': '/mcp-server-images/notion.svg',
|
||||
'Resend': '/mcp-server-images/resend.svg',
|
||||
'Slack': '/mcp-server-images/slack.svg',
|
||||
'WordPress': '/mcp-server-images/wordpress.svg',
|
||||
'Supabase': '/mcp-server-images/supabase.svg',
|
||||
'Postgres': '/mcp-server-images/postgres.svg',
|
||||
'Firecrawl Web Search': '/mcp-server-images/firecrawl.webp',
|
||||
'Firecrawl Deep Research': '/mcp-server-images/firecrawl.webp',
|
||||
'Discord': '/mcp-server-images/discord.svg',
|
||||
'YouTube': '/mcp-server-images/youtube.svg',
|
||||
'Google Sheets': '/mcp-server-images/gsheets.svg',
|
||||
'Google Calendar': '/mcp-server-images/gcalendar.svg',
|
||||
'Gmail': '/mcp-server-images/gmail.svg',
|
||||
};
|
||||
|
||||
const logoPath = logoMap[serverName];
|
||||
|
||||
if (!logoPath) return fallback || null;
|
||||
|
||||
return (
|
||||
<div className={`relative w-6 h-6 ${className}`}>
|
||||
<Image
|
||||
src={logoPath}
|
||||
alt={`${serverName} logo`}
|
||||
fill
|
||||
sizes="16px"
|
||||
className="object-contain"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ServerOperationBannerProps {
|
||||
serverName: string;
|
||||
operation: 'setup' | 'delete' | 'checking-auth';
|
||||
}
|
||||
|
||||
export function ServerOperationBanner({ serverName, operation }: ServerOperationBannerProps) {
|
||||
const getMessage = () => {
|
||||
switch (operation) {
|
||||
case 'setup':
|
||||
return 'Setting up server (~10s)';
|
||||
case 'delete':
|
||||
return 'Removing server (~10s)';
|
||||
case 'checking-auth':
|
||||
return 'Checking authentication';
|
||||
default:
|
||||
return 'Processing';
|
||||
}
|
||||
};
|
||||
|
||||
const getMessageColor = () => {
|
||||
switch (operation) {
|
||||
case 'setup':
|
||||
return 'text-emerald-600 dark:text-emerald-400';
|
||||
case 'delete':
|
||||
return 'text-red-600 dark:text-red-400';
|
||||
default:
|
||||
return 'text-gray-600 dark:text-gray-400';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mb-4 text-sm animate-fadeIn">
|
||||
<div className="flex flex-col gap-1 bg-gray-50 dark:bg-gray-800/50 rounded-md p-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="animate-spin rounded-full h-3 w-3 border-2 border-b-transparent border-current" />
|
||||
<span className={`font-medium ${getMessageColor()}`}>{getMessage()}</span>
|
||||
</div>
|
||||
<div className="text-gray-500 dark:text-gray-400 pl-5">
|
||||
You can safely navigate away from this page
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ToolCardProps {
|
||||
tool: McpToolType;
|
||||
server: McpServerType;
|
||||
isSelected?: boolean;
|
||||
onSelect?: (selected: boolean) => void;
|
||||
showCheckbox?: boolean;
|
||||
onTest?: (tool: McpToolType) => void;
|
||||
isServerReady?: boolean;
|
||||
}
|
||||
|
||||
export function ToolCard({
|
||||
tool,
|
||||
server,
|
||||
isSelected,
|
||||
onSelect,
|
||||
showCheckbox = false,
|
||||
onTest,
|
||||
isServerReady = false
|
||||
}: ToolCardProps) {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
const toolCardStyles = {
|
||||
base: clsx(
|
||||
"group p-4 rounded-lg transition-all duration-200",
|
||||
"bg-gray-50/50 dark:bg-gray-800/50",
|
||||
"hover:bg-gray-100/50 dark:hover:bg-gray-700/50",
|
||||
"border border-transparent",
|
||||
"hover:border-gray-200 dark:hover:border-gray-600"
|
||||
),
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={toolCardStyles.base}>
|
||||
<div className="flex items-start gap-3">
|
||||
{showCheckbox && (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSelected}
|
||||
onChange={(e) => onSelect?.(e.target.checked)}
|
||||
className="mt-1"
|
||||
/>
|
||||
)}
|
||||
<div className="flex-1">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-900 dark:text-gray-100 mb-1">
|
||||
{tool.name}
|
||||
</h4>
|
||||
<div>
|
||||
<p className={clsx(
|
||||
"text-sm text-gray-500 dark:text-gray-400",
|
||||
!isExpanded && "line-clamp-3"
|
||||
)}>
|
||||
{tool.description}
|
||||
</p>
|
||||
{tool.description.length > 150 && (
|
||||
<button
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className="text-xs text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 mt-1"
|
||||
>
|
||||
{isExpanded ? 'Show less' : 'Show more'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{onTest && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => onTest(tool)}
|
||||
disabled={!isServerReady}
|
||||
className="shrink-0 bg-blue-50 dark:bg-blue-900/20
|
||||
text-blue-700 dark:text-blue-300 hover:bg-blue-100 dark:hover:bg-blue-900/40
|
||||
hover:text-blue-800 dark:hover:text-blue-200"
|
||||
>
|
||||
Test
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ServerCardProps {
|
||||
server: McpServerType;
|
||||
onToggle: () => void;
|
||||
onManageTools: () => void;
|
||||
onSync?: () => void;
|
||||
onAuth?: () => void;
|
||||
onRemove?: () => void;
|
||||
isToggling: boolean;
|
||||
isSyncing?: boolean;
|
||||
operation?: 'setup' | 'delete' | 'checking-auth';
|
||||
error?: { message: string };
|
||||
showAuth?: boolean;
|
||||
}
|
||||
|
||||
export function ServerCard({
|
||||
server,
|
||||
onToggle,
|
||||
onManageTools,
|
||||
onSync,
|
||||
onAuth,
|
||||
onRemove,
|
||||
isToggling,
|
||||
isSyncing,
|
||||
operation,
|
||||
error,
|
||||
showAuth = false
|
||||
}: ServerCardProps) {
|
||||
const isEligible = server.serverType === 'custom' ||
|
||||
(server.isActive && (!server.authNeeded || server.isAuthenticated));
|
||||
|
||||
return (
|
||||
<div className="relative border-2 border-gray-200/80 dark:border-gray-700/80 rounded-xl p-6
|
||||
bg-white dark:bg-gray-900 shadow-sm dark:shadow-none
|
||||
backdrop-blur-sm hover:shadow-md dark:hover:shadow-none
|
||||
transition-all duration-200 min-h-[280px]
|
||||
hover:border-blue-200 dark:hover:border-blue-900">
|
||||
<div className="flex flex-col h-full">
|
||||
{operation && (
|
||||
<ServerOperationBanner
|
||||
serverName={server.name}
|
||||
operation={operation}
|
||||
/>
|
||||
)}
|
||||
<div className="flex justify-between items-start mb-6 flex-wrap gap-2">
|
||||
<div className="flex-1 min-w-[200px]">
|
||||
<div className="flex items-center justify-between flex-wrap gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<ServerLogo serverName={server.name} className="mr-2" />
|
||||
<h3 className="font-semibold text-lg text-gray-900 dark:text-gray-100">
|
||||
{server.name}
|
||||
</h3>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<Switch
|
||||
checked={server.isActive}
|
||||
onCheckedChange={onToggle}
|
||||
disabled={isToggling}
|
||||
className={clsx(
|
||||
"data-[state=checked]:bg-blue-500 dark:data-[state=checked]:bg-blue-600",
|
||||
"data-[state=unchecked]:bg-gray-200 dark:data-[state=unchecked]:bg-gray-700",
|
||||
isToggling && "opacity-50 cursor-not-allowed",
|
||||
"scale-75"
|
||||
)}
|
||||
/>
|
||||
{onRemove && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={onRemove}
|
||||
disabled={isToggling}
|
||||
className="ml-2 text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20"
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 mt-2 flex-wrap">
|
||||
{server.availableTools && server.availableTools.length > 0 && (
|
||||
<span className="px-1.5 py-0.5 rounded-full text-xs font-medium
|
||||
bg-blue-50 dark:bg-blue-900/20 text-blue-700 dark:text-blue-300">
|
||||
{server.availableTools.length} tools available
|
||||
</span>
|
||||
)}
|
||||
{isEligible && server.tools.length > 0 && (
|
||||
<span className="px-1.5 py-0.5 rounded-full text-xs font-medium
|
||||
bg-emerald-50 dark:bg-emerald-900/20 text-emerald-700 dark:text-emerald-300">
|
||||
{server.tools.length} tools selected
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{error && (
|
||||
<div className="text-xs text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-900/20
|
||||
py-1 px-2 rounded-md mt-2 animate-fadeIn">
|
||||
{error.message}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1">
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-6 line-clamp-2">
|
||||
{server.description}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-end gap-2 mt-auto flex-wrap">
|
||||
{showAuth && server.isActive && server.authNeeded && (
|
||||
<div className="flex flex-col items-start gap-1 mb-0">
|
||||
{!server.isAuthenticated && onAuth && (
|
||||
<>
|
||||
<span className="text-xs font-medium text-orange-600 dark:text-orange-400 mb-1">
|
||||
Needs authentication!
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="primary"
|
||||
onClick={onAuth}
|
||||
disabled={isToggling}
|
||||
className="text-xs shrink-0"
|
||||
>
|
||||
<div className="inline-flex items-center">
|
||||
<Lock className="h-3.5 w-3.5" />
|
||||
<span className="ml-1.5">Auth</span>
|
||||
</div>
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{server.isAuthenticated && (
|
||||
<div className="text-xs py-1 px-2 rounded-full shrink-0 text-emerald-600 dark:text-emerald-400 bg-emerald-50 dark:bg-emerald-900/20">
|
||||
Authenticated
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className="ml-auto flex items-center gap-2 flex-wrap">
|
||||
{isEligible && onSync && (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={onSync}
|
||||
disabled={isSyncing || isToggling}
|
||||
className="text-xs shrink-0"
|
||||
>
|
||||
<div className="inline-flex items-center">
|
||||
<RefreshCcw className={clsx(
|
||||
"h-3.5 w-3.5",
|
||||
isSyncing && "animate-spin"
|
||||
)} />
|
||||
<span className="ml-1.5">
|
||||
{isSyncing ? 'Syncing...' : 'Sync'}
|
||||
</span>
|
||||
</div>
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={onManageTools}
|
||||
disabled={isToggling}
|
||||
className="text-xs shrink-0"
|
||||
>
|
||||
<div className="inline-flex items-center">
|
||||
<Wrench className="h-3.5 w-3.5" />
|
||||
<span className="ml-1.5">{isEligible ? 'Tools' : 'Tools'}</span>
|
||||
</div>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ToolManagementPanelProps {
|
||||
server: McpServerType | null;
|
||||
onClose: () => void;
|
||||
selectedTools: Set<string>;
|
||||
onToolSelectionChange: (toolId: string, selected: boolean) => void;
|
||||
onSaveTools: () => void;
|
||||
onSyncTools?: () => void;
|
||||
hasChanges: boolean;
|
||||
isSaving: boolean;
|
||||
isSyncing?: boolean;
|
||||
}
|
||||
|
||||
export function ToolManagementPanel({
|
||||
server,
|
||||
onClose,
|
||||
selectedTools,
|
||||
onToolSelectionChange,
|
||||
onSaveTools,
|
||||
onSyncTools,
|
||||
hasChanges,
|
||||
isSaving,
|
||||
isSyncing
|
||||
}: ToolManagementPanelProps) {
|
||||
const [testingTool, setTestingTool] = useState<McpToolType | null>(null);
|
||||
|
||||
if (!server) return null;
|
||||
|
||||
const isEligible = server.serverType === 'custom' ||
|
||||
(server.isActive && (!server.authNeeded || server.isAuthenticated));
|
||||
|
||||
return (
|
||||
<>
|
||||
<SlidePanel
|
||||
isOpen={!!server}
|
||||
onClose={() => {
|
||||
if (hasChanges) {
|
||||
if (window.confirm('You have unsaved changes. Are you sure you want to close?')) {
|
||||
onClose();
|
||||
}
|
||||
} else {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
title={server.name}
|
||||
>
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<h4 className="text-lg font-semibold text-gray-900 dark:text-gray-100">Available Tools</h4>
|
||||
</div>
|
||||
{isEligible && (
|
||||
<div className="flex items-center gap-2">
|
||||
{onSyncTools && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={onSyncTools}
|
||||
disabled={isSyncing}
|
||||
>
|
||||
<div className="inline-flex items-center">
|
||||
<RefreshCcw className={clsx(
|
||||
"h-3.5 w-3.5",
|
||||
isSyncing && "animate-spin"
|
||||
)} />
|
||||
<span className="ml-1.5">
|
||||
{isSyncing ? 'Syncing...' : 'Sync'}
|
||||
</span>
|
||||
</div>
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const allTools = new Set<string>(server.availableTools?.map((t: McpToolType) => t.id) || []);
|
||||
const shouldSelectAll = selectedTools.size !== allTools.size;
|
||||
Array.from(allTools).forEach((toolId: string) => {
|
||||
onToolSelectionChange(toolId, shouldSelectAll);
|
||||
});
|
||||
}}
|
||||
>
|
||||
{selectedTools.size === (server.availableTools || []).length ? 'Deselect All' : 'Select All'}
|
||||
</Button>
|
||||
{hasChanges && (
|
||||
<Button
|
||||
variant="primary"
|
||||
size="sm"
|
||||
onClick={onSaveTools}
|
||||
disabled={isSaving}
|
||||
>
|
||||
{isSaving ? (
|
||||
<>
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-2 border-b-transparent border-white mr-2" />
|
||||
Saving...
|
||||
</>
|
||||
) : (
|
||||
'Save Changes'
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{(server.availableTools || []).map((tool: McpToolType) => (
|
||||
<ToolCard
|
||||
key={tool.id}
|
||||
tool={tool}
|
||||
server={server}
|
||||
isSelected={selectedTools.has(tool.id)}
|
||||
onSelect={(selected) => onToolSelectionChange(tool.id, selected)}
|
||||
showCheckbox={isEligible}
|
||||
onTest={(tool) => setTestingTool(tool)}
|
||||
isServerReady={isEligible}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</SlidePanel>
|
||||
|
||||
{testingTool && (
|
||||
<TestToolModal
|
||||
isOpen={!!testingTool}
|
||||
onClose={() => setTestingTool(null)}
|
||||
tool={testingTool}
|
||||
server={server}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,674 @@
|
|||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams } from 'next/navigation';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { MCPServer, McpTool } from '@/app/lib/types/types';
|
||||
import { testMcpTool } from '@/app/actions/mcp_actions';
|
||||
import { Copy, ChevronDown, ChevronRight, X, Trash2 } from 'lucide-react';
|
||||
import type { z } from 'zod';
|
||||
import clsx from 'clsx';
|
||||
|
||||
type McpServerType = z.infer<typeof MCPServer>;
|
||||
type McpToolType = z.infer<typeof McpTool>;
|
||||
|
||||
interface TestToolModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
tool: McpToolType;
|
||||
server: McpServerType;
|
||||
}
|
||||
|
||||
export function TestToolModal({ isOpen, onClose, tool, server }: TestToolModalProps) {
|
||||
const params = useParams();
|
||||
const projectId = typeof params.projectId === 'string' ? params.projectId : params.projectId?.[0];
|
||||
if (!projectId) throw new Error('Project ID is required');
|
||||
|
||||
// Prevent body scroll when modal is open
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
document.body.style.overflow = 'hidden';
|
||||
} else {
|
||||
document.body.style.overflow = 'unset';
|
||||
}
|
||||
return () => {
|
||||
document.body.style.overflow = 'unset';
|
||||
};
|
||||
}, [isOpen]);
|
||||
|
||||
// Handle escape key
|
||||
useEffect(() => {
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
window.addEventListener('keydown', handleEscape);
|
||||
return () => window.removeEventListener('keydown', handleEscape);
|
||||
}, [onClose]);
|
||||
|
||||
const [parameters, setParameters] = useState<Record<string, any>>({});
|
||||
const [response, setResponse] = useState<any>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [showRequest, setShowRequest] = useState(false);
|
||||
const [showInputs, setShowInputs] = useState(true);
|
||||
const [copySuccess, setCopySuccess] = useState<'request' | 'response' | null>(null);
|
||||
const [showOnlyRequired, setShowOnlyRequired] = useState(false);
|
||||
const [showDescriptions, setShowDescriptions] = useState(true);
|
||||
const [validationError, setValidationError] = useState<string | null>(null);
|
||||
const [showRawResponse, setShowRawResponse] = useState(false);
|
||||
|
||||
const handleReset = () => {
|
||||
setParameters({});
|
||||
setResponse(null);
|
||||
setError(null);
|
||||
setShowRequest(false);
|
||||
setShowInputs(true);
|
||||
setValidationError(null);
|
||||
setShowRawResponse(false);
|
||||
};
|
||||
|
||||
const handleParameterChange = (name: string, value: any) => {
|
||||
// Handle nested object updates
|
||||
if (name.includes('.')) {
|
||||
const parts = name.split('.');
|
||||
const topLevel = parts[0];
|
||||
const rest = parts.slice(1);
|
||||
|
||||
setParameters(prev => {
|
||||
const current = prev[topLevel] || {};
|
||||
let temp = current;
|
||||
for (let i = 0; i < rest.length - 1; i++) {
|
||||
temp[rest[i]] = temp[rest[i]] || {};
|
||||
temp = temp[rest[i]];
|
||||
}
|
||||
temp[rest[rest.length - 1]] = value;
|
||||
|
||||
return {
|
||||
...prev,
|
||||
[topLevel]: current
|
||||
};
|
||||
});
|
||||
}
|
||||
// Handle array index updates
|
||||
else if (name.includes('[') && name.includes(']')) {
|
||||
const matches = name.match(/^([^\[]+)\[(\d+)\]$/);
|
||||
if (matches) {
|
||||
const [_, arrayName, index] = matches;
|
||||
setParameters(prev => {
|
||||
const array = Array.isArray(prev[arrayName]) ? [...prev[arrayName]] : [];
|
||||
array[parseInt(index, 10)] = value;
|
||||
return {
|
||||
...prev,
|
||||
[arrayName]: array
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
// Handle regular updates
|
||||
else {
|
||||
setParameters(prev => ({
|
||||
...prev,
|
||||
[name]: value
|
||||
}));
|
||||
}
|
||||
setValidationError(null);
|
||||
};
|
||||
|
||||
const validateRequiredParameters = () => {
|
||||
const missingParams = tool.parameters?.required?.filter(param => {
|
||||
const value = parameters[param];
|
||||
return value === undefined || value === null || value === '';
|
||||
}) || [];
|
||||
|
||||
if (missingParams.length > 0) {
|
||||
setValidationError(`Please fill in all required parameters: ${missingParams.join(', ')}`);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
|
||||
const handleCopy = async (type: 'request' | 'response') => {
|
||||
const textToCopy = type === 'request'
|
||||
? JSON.stringify({ name: tool.id, arguments: parameters }, null, 2)
|
||||
: (typeof response === 'string' ? response : JSON.stringify(response, null, 2));
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(textToCopy);
|
||||
setCopySuccess(type);
|
||||
setTimeout(() => setCopySuccess(null), 2000);
|
||||
} catch (err) {
|
||||
console.error('Failed to copy:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTest = async () => {
|
||||
setValidationError(null);
|
||||
if (!validateRequiredParameters()) return;
|
||||
|
||||
// Collapse both sections
|
||||
setShowInputs(false);
|
||||
setShowRequest(false);
|
||||
|
||||
setResponse(null);
|
||||
setError(null);
|
||||
setIsLoading(true);
|
||||
|
||||
try {
|
||||
const result = await testMcpTool(projectId, server.name, tool.id, parameters);
|
||||
setResponse(result);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'An error occurred while testing the tool');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const renderParameterInput = (paramName: string, schema: any) => {
|
||||
const value = parameters[paramName] ?? (schema.type === 'array' ? [] : schema.type === 'object' ? {} : '');
|
||||
|
||||
switch (schema.type) {
|
||||
case 'array':
|
||||
const arrayValue = Array.isArray(value) ? value : value ? [value] : [];
|
||||
const itemSchema = schema.items || { type: 'string' };
|
||||
|
||||
const handleArrayItemChange = (index: number, itemValue: any) => {
|
||||
const newArray = [...arrayValue];
|
||||
newArray[index] = itemValue;
|
||||
handleParameterChange(paramName, newArray);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{arrayValue.map((item: any, index: number) => (
|
||||
<div key={index} className="flex gap-2 items-start">
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400 pt-2 min-w-[24px]">
|
||||
{index + 1}:
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
{itemSchema.type === 'string' ? (
|
||||
<Input
|
||||
type="text"
|
||||
value={item || ''}
|
||||
onChange={(e) => handleArrayItemChange(index, e.target.value)}
|
||||
placeholder="Enter value"
|
||||
className="focus:ring-0 focus:ring-offset-0 !ring-0 !ring-offset-0 focus:outline-none"
|
||||
/>
|
||||
) : itemSchema.type === 'number' || itemSchema.type === 'integer' ? (
|
||||
<Input
|
||||
type="number"
|
||||
value={item || ''}
|
||||
step={itemSchema.type === 'integer' ? '1' : 'any'}
|
||||
min={itemSchema.minimum}
|
||||
max={itemSchema.maximum}
|
||||
onChange={(e) => {
|
||||
const val = itemSchema.type === 'integer' ?
|
||||
parseInt(e.target.value, 10) :
|
||||
parseFloat(e.target.value);
|
||||
handleArrayItemChange(index, isNaN(val) ? '' : val);
|
||||
}}
|
||||
placeholder="Enter value"
|
||||
className="focus:ring-0 focus:ring-offset-0 !ring-0 !ring-offset-0 focus:outline-none"
|
||||
/>
|
||||
) : itemSchema.type === 'boolean' ? (
|
||||
<div className="scale-75 origin-left">
|
||||
<Switch
|
||||
checked={!!item}
|
||||
onCheckedChange={(checked) => handleArrayItemChange(index, checked)}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-full">
|
||||
{renderParameterInput(paramName, {
|
||||
...itemSchema,
|
||||
value: item,
|
||||
onChange: (newValue: any) => handleArrayItemChange(index, newValue)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
const newArray = arrayValue.filter((_, i) => i !== index);
|
||||
handleParameterChange(paramName, newArray);
|
||||
}}
|
||||
className="px-2 h-9 hover:bg-transparent border-transparent"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-gray-500 hover:text-red-500 transition-colors" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
const defaultValue = itemSchema.type === 'object' ? {} :
|
||||
itemSchema.type === 'array' ? [] :
|
||||
itemSchema.type === 'boolean' ? false : '';
|
||||
handleParameterChange(paramName, [...arrayValue, defaultValue]);
|
||||
}}
|
||||
className="text-xs text-gray-500 hover:text-gray-900 dark:hover:text-gray-100 hover:bg-transparent border-transparent"
|
||||
>
|
||||
Add item
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'object':
|
||||
if (!schema.properties) return null;
|
||||
const objectValue = typeof value === 'object' ? value : {};
|
||||
return (
|
||||
<div className="space-y-4 border-l-2 border-gray-200 dark:border-gray-700 pl-4 mt-2">
|
||||
{Object.entries(schema.properties).map(([key, propSchema]: [string, any]) => (
|
||||
<div key={key} className="space-y-2">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{key}
|
||||
{schema.required?.includes(key) && <span className="text-red-500 ml-1">*</span>}
|
||||
</label>
|
||||
{renderParameterInput(
|
||||
`${paramName}.${key}`,
|
||||
propSchema
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'string':
|
||||
if (schema.enum) {
|
||||
return (
|
||||
<select
|
||||
value={value}
|
||||
onChange={(e) => handleParameterChange(paramName, e.target.value)}
|
||||
className="w-full px-3 py-2 text-sm border border-gray-200 dark:border-gray-700 rounded-md
|
||||
bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100
|
||||
focus:outline-none hover:border-gray-300 dark:hover:border-gray-600
|
||||
focus:ring-0 focus:ring-offset-0 !ring-0 !ring-offset-0"
|
||||
>
|
||||
<option value="" disabled>Select {paramName}</option>
|
||||
{schema.enum.map((opt: string) => (
|
||||
<option key={opt} value={opt}>{opt}</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
if (schema.format === 'date-time') {
|
||||
return (
|
||||
<Input
|
||||
type="datetime-local"
|
||||
value={value}
|
||||
onChange={(e) => handleParameterChange(paramName, e.target.value)}
|
||||
className="focus:ring-0 focus:ring-offset-0 !ring-0 !ring-offset-0 focus:outline-none"
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (schema.format === 'date') {
|
||||
return (
|
||||
<Input
|
||||
type="date"
|
||||
value={value}
|
||||
onChange={(e) => handleParameterChange(paramName, e.target.value)}
|
||||
className="focus:ring-0 focus:ring-offset-0 !ring-0 !ring-offset-0 focus:outline-none"
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (schema.format === 'time') {
|
||||
return (
|
||||
<Input
|
||||
type="time"
|
||||
value={value}
|
||||
onChange={(e) => handleParameterChange(paramName, e.target.value)}
|
||||
className="focus:ring-0 focus:ring-offset-0 !ring-0 !ring-offset-0 focus:outline-none"
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Input
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => handleParameterChange(paramName, e.target.value)}
|
||||
placeholder={`Enter ${paramName}`}
|
||||
className="focus:ring-0 focus:ring-offset-0 !ring-0 !ring-offset-0 focus:outline-none"
|
||||
/>
|
||||
);
|
||||
|
||||
case 'number':
|
||||
case 'integer':
|
||||
return (
|
||||
<Input
|
||||
type="number"
|
||||
value={value}
|
||||
step={schema.type === 'integer' ? '1' : 'any'}
|
||||
min={schema.minimum}
|
||||
max={schema.maximum}
|
||||
onChange={(e) => {
|
||||
const val = schema.type === 'integer' ?
|
||||
parseInt(e.target.value, 10) :
|
||||
parseFloat(e.target.value);
|
||||
handleParameterChange(paramName, isNaN(val) ? '' : val);
|
||||
}}
|
||||
placeholder={`Enter ${paramName}`}
|
||||
className="focus:ring-0 focus:ring-offset-0 !ring-0 !ring-offset-0 focus:outline-none"
|
||||
/>
|
||||
);
|
||||
|
||||
case 'boolean':
|
||||
return (
|
||||
<div className="scale-75 origin-left">
|
||||
<Switch
|
||||
checked={!!value}
|
||||
onCheckedChange={(checked) => handleParameterChange(paramName, checked)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
case 'null':
|
||||
return (
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400 italic">
|
||||
Null value
|
||||
</div>
|
||||
);
|
||||
|
||||
default:
|
||||
return (
|
||||
<Input
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => handleParameterChange(paramName, e.target.value)}
|
||||
placeholder={`Enter ${paramName}`}
|
||||
className="focus:ring-0 focus:ring-offset-0 !ring-0 !ring-offset-0 focus:outline-none"
|
||||
/>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const getFilteredParameters = () => {
|
||||
if (!tool.parameters?.properties) return [];
|
||||
|
||||
return Object.entries(tool.parameters.properties).filter(([name]) => {
|
||||
if (showOnlyRequired) {
|
||||
return tool.parameters?.required?.includes(name);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
};
|
||||
|
||||
const formatResponse = (response: any): string => {
|
||||
try {
|
||||
if (showRawResponse) {
|
||||
return typeof response === 'string' ? response : JSON.stringify(response);
|
||||
}
|
||||
|
||||
// Convert to object if it's a string
|
||||
const obj = typeof response === 'string' ? JSON.parse(response) : response;
|
||||
|
||||
// Handle nested structures and attempt to parse JSON strings
|
||||
const processValue = (value: any): any => {
|
||||
if (typeof value === 'string') {
|
||||
try {
|
||||
// Try to parse string as JSON if it looks like JSON
|
||||
if ((value.startsWith('{') && value.endsWith('}')) ||
|
||||
(value.startsWith('[') && value.endsWith(']'))) {
|
||||
const parsed = JSON.parse(value);
|
||||
return processValue(parsed); // Recursively process the parsed JSON
|
||||
}
|
||||
} catch {
|
||||
// Not valid JSON, treat as regular string
|
||||
}
|
||||
// Preserve explicit newlines in regular strings
|
||||
return value;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(processValue);
|
||||
}
|
||||
if (value && typeof value === 'object') {
|
||||
const processed: any = {};
|
||||
for (const [k, v] of Object.entries(value)) {
|
||||
processed[k] = processValue(v);
|
||||
}
|
||||
return processed;
|
||||
}
|
||||
return value;
|
||||
};
|
||||
|
||||
// Process and stringify with proper indentation
|
||||
const processed = processValue(obj);
|
||||
const stringified = JSON.stringify(processed, null, 2);
|
||||
|
||||
// Replace escaped newlines and handle nested JSON formatting
|
||||
return stringified
|
||||
.replace(/\\n/g, '\n') // Convert escaped newlines to actual newlines
|
||||
.replace(/"\{/g, '{') // Remove quotes around nested JSON objects
|
||||
.replace(/\}"/g, '}') // Remove quotes around nested JSON objects
|
||||
.replace(/"\[/g, '[') // Remove quotes around nested JSON arrays
|
||||
.replace(/\]"/g, ']'); // Remove quotes around nested JSON arrays
|
||||
} catch (e) {
|
||||
// If JSON parsing fails, return as is
|
||||
return String(response);
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center overflow-hidden">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="fixed inset-0 bg-black/50 backdrop-blur-sm"
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* Modal */}
|
||||
<div className="relative bg-white dark:bg-gray-900 rounded-lg shadow-xl w-[900px] max-w-[90vw] max-h-[90vh] flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-800">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||
Test {tool.name}
|
||||
</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1 rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
|
||||
>
|
||||
<X className="h-5 w-5 text-gray-500 dark:text-gray-400" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto min-h-0 p-6 space-y-6">
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{tool.description}
|
||||
</div>
|
||||
|
||||
{validationError && (
|
||||
<div className="text-sm text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-900/20 p-3 rounded-md mb-6">
|
||||
{validationError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col flex-1 min-h-0">
|
||||
<div>
|
||||
<button
|
||||
onClick={() => setShowInputs(!showInputs)}
|
||||
className="flex items-center gap-1 text-sm font-medium text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100"
|
||||
>
|
||||
{showInputs ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
|
||||
Inputs
|
||||
</button>
|
||||
|
||||
{showInputs && (
|
||||
<div className="space-y-6 pl-5 mt-4">
|
||||
<div className="flex flex-col gap-2 pb-2 border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="scale-75 origin-left">
|
||||
<Switch
|
||||
checked={showOnlyRequired}
|
||||
onCheckedChange={setShowOnlyRequired}
|
||||
/>
|
||||
</div>
|
||||
<label className="text-sm text-gray-700 dark:text-gray-300">
|
||||
Show only required parameters
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="scale-75 origin-left">
|
||||
<Switch
|
||||
checked={showDescriptions}
|
||||
onCheckedChange={setShowDescriptions}
|
||||
/>
|
||||
</div>
|
||||
<label className="text-sm text-gray-700 dark:text-gray-300">
|
||||
Show parameter descriptions
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{getFilteredParameters().map(([name, schema]) => (
|
||||
<div key={name} className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{name}
|
||||
{tool.parameters?.required?.includes(name) && (
|
||||
<span className="text-red-500 ml-1">*</span>
|
||||
)}
|
||||
</label>
|
||||
{showDescriptions && schema.description && (
|
||||
<p className="text-xs text-gray-600 dark:text-gray-400">
|
||||
{schema.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{renderParameterInput(name, schema)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="text-sm text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-900/20 p-3 rounded-md mt-6">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<button
|
||||
onClick={() => setShowRequest(!showRequest)}
|
||||
className="flex items-center gap-1 text-sm font-medium text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-gray-100"
|
||||
>
|
||||
{showRequest ? <ChevronDown className="h-4 w-4" /> : <ChevronRight className="h-4 w-4" />}
|
||||
Request
|
||||
</button>
|
||||
{showRequest && (
|
||||
<button
|
||||
onClick={() => handleCopy('request')}
|
||||
className="p-1 rounded-md hover:bg-gray-200 dark:hover:bg-gray-700 flex items-center gap-1.5"
|
||||
title="Copy request"
|
||||
>
|
||||
<Copy className="h-4 w-4 text-gray-500 dark:text-gray-400" />
|
||||
{copySuccess === 'request' && (
|
||||
<span className="text-xs text-green-600 dark:text-green-400">
|
||||
Copied!
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showRequest && (
|
||||
<div className="pl-5 mt-4">
|
||||
<pre className="text-sm bg-gray-50 dark:bg-gray-800 p-3 rounded-md overflow-auto max-h-60">
|
||||
{JSON.stringify({ name: tool.id, arguments: parameters }, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Response section - shown when loading or when there's a response */}
|
||||
{(isLoading || response) && (
|
||||
<div className="flex flex-col flex-1 min-h-0 mt-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<h4 className="text-sm font-medium text-gray-900 dark:text-gray-100">Response</h4>
|
||||
{response && (
|
||||
<>
|
||||
<div className="h-4 w-px bg-gray-200 dark:bg-gray-700" />
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-sm text-gray-600 dark:text-gray-400">
|
||||
Raw
|
||||
</label>
|
||||
<div className="scale-75 origin-right">
|
||||
<Switch
|
||||
checked={showRawResponse}
|
||||
onCheckedChange={setShowRawResponse}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
{response && (
|
||||
<button
|
||||
onClick={() => handleCopy('response')}
|
||||
className="p-1 rounded-md hover:bg-gray-200 dark:hover:bg-gray-700 flex items-center gap-1.5"
|
||||
title="Copy response"
|
||||
>
|
||||
<Copy className="h-4 w-4 text-gray-500 dark:text-gray-400" />
|
||||
{copySuccess === 'response' && (
|
||||
<span className="text-xs text-green-600 dark:text-green-400">
|
||||
Copied!
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="pl-5 mt-4 flex-1 min-h-0">
|
||||
{isLoading ? (
|
||||
<div className="h-full bg-gray-50 dark:bg-gray-800 rounded-md p-3 flex items-start">
|
||||
<div className="flex items-center gap-3 text-sm text-gray-500 dark:text-gray-400">
|
||||
<div className="animate-spin rounded-full h-4 w-4 border-2 border-gray-300 dark:border-gray-600 border-t-blue-600 dark:border-t-blue-400" />
|
||||
Awaiting response...
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<pre
|
||||
className={clsx(
|
||||
"text-sm bg-gray-50 dark:bg-gray-800 p-3 rounded-md overflow-auto h-full",
|
||||
!showRawResponse && "whitespace-pre-wrap break-all"
|
||||
)}
|
||||
>
|
||||
{formatResponse(response)}
|
||||
</pre>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex justify-end gap-2 px-6 py-4 border-t border-gray-200 dark:border-gray-800">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={handleReset}
|
||||
disabled={isLoading}
|
||||
className="text-red-600 dark:text-red-400 hover:bg-red-50 dark:hover:bg-red-900/20
|
||||
border-red-200 dark:border-red-800 hover:border-red-300 dark:hover:border-red-700"
|
||||
>
|
||||
<span className="text-sm">Reset</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleTest}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<span className="text-sm">{isLoading ? 'Awaiting...' : 'Test'}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { Tabs, Tab } from '@/components/ui/tabs';
|
||||
import { HostedServers } from './HostedServers';
|
||||
import { CustomServers } from './CustomServers';
|
||||
import { WebhookConfig } from './WebhookConfig';
|
||||
import type { Key } from 'react';
|
||||
|
||||
export function ToolsConfig() {
|
||||
const [activeTab, setActiveTab] = useState('hosted');
|
||||
|
||||
const handleTabChange = (key: Key) => {
|
||||
setActiveTab(key.toString());
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
<Tabs
|
||||
selectedKey={activeTab}
|
||||
onSelectionChange={handleTabChange}
|
||||
aria-label="Tool configuration options"
|
||||
className="w-full"
|
||||
fullWidth
|
||||
>
|
||||
<Tab key="hosted" title={
|
||||
<div className="flex items-center gap-2">
|
||||
<span>Tools Library</span>
|
||||
<span className="leading-none px-1.5 py-[2px] text-[9px] font-medium bg-gradient-to-r from-pink-500 to-violet-500 text-white rounded-full">
|
||||
BETA
|
||||
</span>
|
||||
</div>
|
||||
}>
|
||||
<div className="mt-4 p-6">
|
||||
<HostedServers />
|
||||
</div>
|
||||
</Tab>
|
||||
<Tab key="custom" title="Custom MCP Servers">
|
||||
<div className="mt-4 p-6">
|
||||
<CustomServers />
|
||||
</div>
|
||||
</Tab>
|
||||
<Tab key="webhook" title="Webhook">
|
||||
<div className="mt-4 p-6">
|
||||
<WebhookConfig />
|
||||
</div>
|
||||
</Tab>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,127 @@
|
|||
'use client';
|
||||
|
||||
import { useState, useEffect } from "react";
|
||||
import { useParams } from 'next/navigation';
|
||||
import { Spinner } from "@heroui/react";
|
||||
import { getProjectConfig, updateWebhookUrl } from "@/app/actions/project_actions";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { clsx } from "clsx";
|
||||
|
||||
const sectionHeaderStyles = "text-sm font-semibold text-gray-900 dark:text-gray-100 mb-2";
|
||||
const sectionDescriptionStyles = "text-sm text-gray-500 dark:text-gray-400 mb-4";
|
||||
const textareaStyles = "rounded-lg p-3 border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-750 focus:shadow-inner focus:ring-2 focus:ring-indigo-500/20 dark:focus:ring-indigo-400/20 placeholder:text-gray-400 dark:placeholder:text-gray-500";
|
||||
const inputStyles = "rounded-lg px-3 py-2 border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 hover:bg-gray-50 dark:hover:bg-gray-750 focus:shadow-inner focus:ring-2 focus:ring-indigo-500/20 dark:focus:ring-indigo-400/20";
|
||||
|
||||
function Section({ title, children, description }: {
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
description?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-lg border border-gray-200 dark:border-gray-800 bg-white dark:bg-gray-900 overflow-hidden">
|
||||
<div className="px-6 pt-4">
|
||||
<h2 className={sectionHeaderStyles}>{title}</h2>
|
||||
{description && (
|
||||
<p className={sectionDescriptionStyles}>{description}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="px-6 pb-6">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function WebhookConfig() {
|
||||
const params = useParams();
|
||||
const projectId = typeof params.projectId === 'string' ? params.projectId : params.projectId[0];
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [webhookUrl, setWebhookUrl] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let mounted = true;
|
||||
|
||||
async function loadConfig() {
|
||||
try {
|
||||
const project = await getProjectConfig(projectId);
|
||||
if (mounted) {
|
||||
setWebhookUrl(project.webhookUrl || null);
|
||||
setError(null);
|
||||
}
|
||||
} catch (err) {
|
||||
if (mounted) {
|
||||
console.error('Failed to load webhook URL:', err);
|
||||
setError('Failed to load webhook URL');
|
||||
}
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
loadConfig();
|
||||
|
||||
return () => {
|
||||
mounted = false;
|
||||
};
|
||||
}, [projectId]);
|
||||
|
||||
function validate(url: string) {
|
||||
if (!url.trim()) {
|
||||
return { valid: true };
|
||||
}
|
||||
try {
|
||||
new URL(url);
|
||||
setError(null);
|
||||
return { valid: true };
|
||||
} catch {
|
||||
setError('Please enter a valid URL');
|
||||
return { valid: false, errorMessage: 'Please enter a valid URL' };
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<Section
|
||||
title="Webhook URL"
|
||||
description="In workflow editor, tool calls will be posted to this URL, unless they are mocked."
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<div className={clsx(
|
||||
"border rounded-lg focus-within:ring-2",
|
||||
error
|
||||
? "border-red-500 focus-within:ring-red-500/20"
|
||||
: "border-gray-200 dark:border-gray-700 focus-within:ring-indigo-500/20 dark:focus-within:ring-indigo-400/20"
|
||||
)}>
|
||||
<Textarea
|
||||
value={webhookUrl || ''}
|
||||
useValidation={true}
|
||||
updateOnBlur={true}
|
||||
validate={validate}
|
||||
onValidatedChange={(value) => {
|
||||
setWebhookUrl(value);
|
||||
updateWebhookUrl(projectId, value);
|
||||
}}
|
||||
placeholder="Enter webhook URL..."
|
||||
className="w-full text-sm bg-transparent focus:outline-none disabled:cursor-not-allowed disabled:opacity-50 transition-colors px-4 py-3"
|
||||
autoResize
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
{loading && (
|
||||
<div className="flex items-center gap-2 text-sm text-gray-500">
|
||||
<Spinner size="sm" />
|
||||
<span>Loading...</span>
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<p className="text-sm text-red-500">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
</Section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default WebhookConfig;
|
||||
Loading…
Add table
Add a link
Reference in a new issue