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
|
|
@ -15,6 +15,8 @@ import { check_query_limit } from "../lib/rate_limiting";
|
|||
import { QueryLimitError, validateConfigChanges } from "../lib/client_utils";
|
||||
import { projectAuthCheck } from "./project_actions";
|
||||
import { redisClient } from "../lib/redis";
|
||||
import { fetchProjectMcpTools } from "../lib/project_tools";
|
||||
import { mergeProjectTools } from "../lib/types/project_types";
|
||||
|
||||
export async function getCopilotResponse(
|
||||
projectId: string,
|
||||
|
|
@ -32,11 +34,20 @@ export async function getCopilotResponse(
|
|||
throw new QueryLimitError();
|
||||
}
|
||||
|
||||
// Get MCP tools from project and merge with workflow tools
|
||||
const mcpTools = await fetchProjectMcpTools(projectId);
|
||||
|
||||
// Convert workflow to copilot format with both workflow and project tools
|
||||
const copilotWorkflow = convertToCopilotWorkflow({
|
||||
...current_workflow_config,
|
||||
tools: await mergeProjectTools(current_workflow_config.tools, mcpTools)
|
||||
});
|
||||
|
||||
// prepare request
|
||||
const request: z.infer<typeof CopilotAPIRequest> = {
|
||||
messages: messages.map(convertToCopilotApiMessage),
|
||||
workflow_schema: JSON.stringify(zodToJsonSchema(CopilotWorkflow)),
|
||||
current_workflow_config: JSON.stringify(convertToCopilotWorkflow(current_workflow_config)),
|
||||
current_workflow_config: JSON.stringify(copilotWorkflow),
|
||||
context: context ? convertToCopilotApiChatContext(context) : null,
|
||||
dataSources: dataSources ? dataSources.map(ds => {
|
||||
console.log('Original data source:', JSON.stringify(ds));
|
||||
|
|
@ -127,11 +138,20 @@ export async function getCopilotResponseStream(
|
|||
throw new QueryLimitError();
|
||||
}
|
||||
|
||||
// Get MCP tools from project and merge with workflow tools
|
||||
const mcpTools = await fetchProjectMcpTools(projectId);
|
||||
|
||||
// Convert workflow to copilot format with both workflow and project tools
|
||||
const copilotWorkflow = convertToCopilotWorkflow({
|
||||
...current_workflow_config,
|
||||
tools: await mergeProjectTools(current_workflow_config.tools, mcpTools)
|
||||
});
|
||||
|
||||
// prepare request
|
||||
const request: z.infer<typeof CopilotAPIRequest> = {
|
||||
messages: messages.map(convertToCopilotApiMessage),
|
||||
workflow_schema: JSON.stringify(zodToJsonSchema(CopilotWorkflow)),
|
||||
current_workflow_config: JSON.stringify(convertToCopilotWorkflow(current_workflow_config)),
|
||||
current_workflow_config: JSON.stringify(copilotWorkflow),
|
||||
context: context ? convertToCopilotApiChatContext(context) : null,
|
||||
dataSources: dataSources ? dataSources.map(ds => CopilotDataSource.parse(ds)) : undefined,
|
||||
};
|
||||
|
|
@ -163,11 +183,20 @@ export async function getCopilotAgentInstructions(
|
|||
throw new QueryLimitError();
|
||||
}
|
||||
|
||||
// Get MCP tools from project and merge with workflow tools
|
||||
const mcpTools = await fetchProjectMcpTools(projectId);
|
||||
|
||||
// Convert workflow to copilot format with both workflow and project tools
|
||||
const copilotWorkflow = convertToCopilotWorkflow({
|
||||
...current_workflow_config,
|
||||
tools: await mergeProjectTools(current_workflow_config.tools, mcpTools)
|
||||
});
|
||||
|
||||
// prepare request
|
||||
const request: z.infer<typeof CopilotAPIRequest> = {
|
||||
messages: messages.map(convertToCopilotApiMessage),
|
||||
workflow_schema: JSON.stringify(zodToJsonSchema(CopilotWorkflow)),
|
||||
current_workflow_config: JSON.stringify(convertToCopilotWorkflow(current_workflow_config)),
|
||||
current_workflow_config: JSON.stringify(copilotWorkflow),
|
||||
context: {
|
||||
type: 'agent',
|
||||
agentName: agentName,
|
||||
|
|
|
|||
93
apps/rowboat/app/actions/custom_server_actions.ts
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
'use server';
|
||||
|
||||
import { projectsCollection } from '../lib/mongodb';
|
||||
import { MCPServer } from '../lib/types/types';
|
||||
import { z } from 'zod';
|
||||
import { projectAuthCheck } from './project_actions';
|
||||
|
||||
type McpServerType = z.infer<typeof MCPServer>;
|
||||
|
||||
function formatServerUrl(url: string): string {
|
||||
// Ensure URL starts with http:// or https://
|
||||
if (!url.startsWith('http://') && !url.startsWith('https://')) {
|
||||
url = 'http://' + url;
|
||||
}
|
||||
// Remove trailing slash if present
|
||||
return url.replace(/\/$/, '');
|
||||
}
|
||||
|
||||
export async function fetchCustomServers(projectId: string) {
|
||||
await projectAuthCheck(projectId);
|
||||
|
||||
const project = await projectsCollection.findOne({ _id: projectId });
|
||||
return (project?.mcpServers || [])
|
||||
.filter(server => server.serverType === 'custom')
|
||||
.map(server => ({
|
||||
...server,
|
||||
serverType: 'custom' as const,
|
||||
isReady: true // Custom servers are always ready
|
||||
}));
|
||||
}
|
||||
|
||||
export async function addCustomServer(projectId: string, server: McpServerType) {
|
||||
await projectAuthCheck(projectId);
|
||||
|
||||
// Format the server URL and ensure isReady is true for custom servers
|
||||
const formattedServer = {
|
||||
...server,
|
||||
serverUrl: formatServerUrl(server.serverUrl || ''),
|
||||
isReady: true // Custom servers are always ready
|
||||
};
|
||||
|
||||
await projectsCollection.updateOne(
|
||||
{ _id: projectId },
|
||||
{ $push: { mcpServers: formattedServer } }
|
||||
);
|
||||
|
||||
return formattedServer;
|
||||
}
|
||||
|
||||
export async function removeCustomServer(projectId: string, serverName: string) {
|
||||
await projectAuthCheck(projectId);
|
||||
|
||||
await projectsCollection.updateOne(
|
||||
{ _id: projectId },
|
||||
{ $pull: { mcpServers: { name: serverName } } }
|
||||
);
|
||||
}
|
||||
|
||||
export async function toggleCustomServer(projectId: string, serverName: string, isActive: boolean) {
|
||||
await projectAuthCheck(projectId);
|
||||
|
||||
await projectsCollection.updateOne(
|
||||
{ _id: projectId, "mcpServers.name": serverName },
|
||||
{
|
||||
$set: {
|
||||
"mcpServers.$.isActive": isActive,
|
||||
"mcpServers.$.isReady": isActive // Update isReady along with isActive
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export async function updateCustomServerTools(
|
||||
projectId: string,
|
||||
serverName: string,
|
||||
tools: McpServerType['tools'],
|
||||
availableTools?: McpServerType['availableTools']
|
||||
) {
|
||||
await projectAuthCheck(projectId);
|
||||
|
||||
const update: Record<string, any> = {
|
||||
"mcpServers.$.tools": tools
|
||||
};
|
||||
|
||||
if (availableTools) {
|
||||
update["mcpServers.$.availableTools"] = availableTools;
|
||||
}
|
||||
|
||||
await projectsCollection.updateOne(
|
||||
{ _id: projectId, "mcpServers.name": serverName },
|
||||
{ $set: update }
|
||||
);
|
||||
}
|
||||
842
apps/rowboat/app/actions/klavis_actions.ts
Normal file
|
|
@ -0,0 +1,842 @@
|
|||
'use server';
|
||||
|
||||
import { projectAuthCheck } from './project_actions';
|
||||
import { z } from 'zod';
|
||||
import { MCPServer, McpTool, McpServerResponse, McpServerTool } from '../lib/types/types';
|
||||
import { projectsCollection } from '../lib/mongodb';
|
||||
import { fetchMcpTools, toggleMcpTool } from './mcp_actions';
|
||||
import { fetchMcpToolsForServer } from './mcp_actions';
|
||||
import { headers } from 'next/headers';
|
||||
|
||||
type McpServerType = z.infer<typeof MCPServer>;
|
||||
type McpToolType = z.infer<typeof McpTool>;
|
||||
type McpServerResponseType = z.infer<typeof McpServerResponse>;
|
||||
|
||||
// Internal API Response Types
|
||||
interface KlavisServerMetadata {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
tools: {
|
||||
name: string;
|
||||
description: string;
|
||||
}[];
|
||||
authNeeded: boolean;
|
||||
}
|
||||
|
||||
interface GetAllServersResponse {
|
||||
servers: KlavisServerMetadata[];
|
||||
}
|
||||
|
||||
interface CreateServerInstanceResponse {
|
||||
serverUrl: string;
|
||||
instanceId: string;
|
||||
}
|
||||
|
||||
interface DeleteServerInstanceResponse {
|
||||
success: boolean;
|
||||
message: string;
|
||||
}
|
||||
|
||||
interface UserInstance {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
tools: {
|
||||
name: string;
|
||||
description: string;
|
||||
authNeeded: boolean;
|
||||
isAuthenticated: boolean;
|
||||
}[] | null;
|
||||
authNeeded: boolean;
|
||||
isAuthenticated: boolean;
|
||||
}
|
||||
|
||||
interface GetUserInstancesResponse {
|
||||
instances: UserInstance[];
|
||||
}
|
||||
|
||||
// Add type for raw MCP tool response at the top with other types
|
||||
interface RawMcpTool {
|
||||
name: string;
|
||||
description: string;
|
||||
inputSchema: string | {
|
||||
type: string;
|
||||
properties: Record<string, any>;
|
||||
required?: string[];
|
||||
};
|
||||
}
|
||||
|
||||
const KLAVIS_BASE_URL = 'https://api.klavis.ai';
|
||||
|
||||
interface KlavisApiCallOptions {
|
||||
method?: 'GET' | 'POST' | 'PUT' | 'DELETE';
|
||||
body?: Record<string, any>;
|
||||
additionalHeaders?: Record<string, string>;
|
||||
}
|
||||
|
||||
async function klavisApiCall<T>(
|
||||
endpoint: string,
|
||||
options: KlavisApiCallOptions = {}
|
||||
): Promise<T> {
|
||||
const { method = 'GET', body, additionalHeaders = {} } = options;
|
||||
const startTime = performance.now();
|
||||
const url = `${KLAVIS_BASE_URL}${endpoint}`;
|
||||
|
||||
try {
|
||||
const headers = {
|
||||
'Authorization': `Bearer ${process.env.KLAVIS_API_KEY}`,
|
||||
'Content-Type': 'application/json',
|
||||
...additionalHeaders
|
||||
};
|
||||
|
||||
const fetchOptions: RequestInit = {
|
||||
method,
|
||||
headers,
|
||||
...(body ? { body: JSON.stringify(body) } : {})
|
||||
};
|
||||
|
||||
const response = await fetch(url, fetchOptions);
|
||||
const endTime = performance.now();
|
||||
|
||||
console.log('[Klavis API] Response time:', {
|
||||
url,
|
||||
method,
|
||||
durationMs: Math.round(endTime - startTime)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.text();
|
||||
throw new Error(error);
|
||||
}
|
||||
|
||||
return await response.json() as T;
|
||||
} catch (error) {
|
||||
const endTime = performance.now();
|
||||
console.error('[Klavis API] Failed call:', {
|
||||
url,
|
||||
method,
|
||||
durationMs: Math.round(endTime - startTime),
|
||||
error
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Lists all active server instances for a given project
|
||||
export async function listActiveServerInstances(projectId: string): Promise<UserInstance[]> {
|
||||
try {
|
||||
await projectAuthCheck(projectId);
|
||||
|
||||
const queryParams = new URLSearchParams({
|
||||
user_id: projectId,
|
||||
platform_name: 'Rowboat'
|
||||
});
|
||||
|
||||
console.log('[Klavis API] Fetching active instances:', { projectId, platformName: 'Rowboat' });
|
||||
|
||||
const endpoint = `/user/instances?${queryParams}`;
|
||||
const data = await klavisApiCall<GetUserInstancesResponse>(endpoint);
|
||||
|
||||
// Only show instances that are authenticated or need auth
|
||||
const relevantInstances = data.instances.filter(i => i.isAuthenticated || i.authNeeded);
|
||||
console.log('[Klavis API] Active instances:', {
|
||||
count: relevantInstances.length,
|
||||
authenticated: relevantInstances.filter(i => i.isAuthenticated).map(i => i.name).join(', '),
|
||||
needsAuth: relevantInstances.filter(i => i.authNeeded && !i.isAuthenticated).map(i => i.name).join(', ')
|
||||
});
|
||||
|
||||
return data.instances;
|
||||
} catch (error) {
|
||||
console.error('[Klavis API] Error listing active instances:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function enrichToolsWithParameters(
|
||||
projectId: string,
|
||||
serverName: string,
|
||||
basicTools: { name: string; description: string }[],
|
||||
isNewlyEnabled: boolean = false
|
||||
): Promise<McpToolType[]> {
|
||||
try {
|
||||
console.log(`[Klavis API] Starting tool enrichment for ${serverName}`);
|
||||
const enrichedTools = await fetchMcpToolsForServer(projectId, serverName);
|
||||
|
||||
if (enrichedTools.length === 0) {
|
||||
console.log(`[Klavis API] No tools enriched for ${serverName}`);
|
||||
return basicTools.map(tool => ({
|
||||
id: tool.name,
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {},
|
||||
required: []
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
console.log(`[Klavis API] Processing ${enrichedTools.length} tools for ${serverName}`);
|
||||
|
||||
// Create a map of enriched tools for this server
|
||||
const enrichedToolMap = new Map(
|
||||
enrichedTools.map(tool => [tool.name, {
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
parameters: {
|
||||
type: 'object' as const,
|
||||
properties: tool.parameters?.properties || {},
|
||||
required: tool.parameters?.required || []
|
||||
}
|
||||
}])
|
||||
);
|
||||
|
||||
// Find tools that couldn't be enriched
|
||||
const unenrichedTools = basicTools
|
||||
.filter(tool => !enrichedToolMap.has(tool.name))
|
||||
.map(tool => tool.name);
|
||||
|
||||
if (unenrichedTools.length > 0) {
|
||||
console.log('[Klavis API] Tools that could not be enriched:', {
|
||||
serverName,
|
||||
unenrichedTools: unenrichedTools.join(', ')
|
||||
});
|
||||
}
|
||||
|
||||
// Enrich the basic tools with parameters and descriptions
|
||||
const result = basicTools.map(basicTool => {
|
||||
const enrichedTool = enrichedToolMap.get(basicTool.name);
|
||||
|
||||
const tool: McpToolType = {
|
||||
id: basicTool.name,
|
||||
name: basicTool.name,
|
||||
description: enrichedTool?.description || basicTool.description || '',
|
||||
parameters: enrichedTool?.parameters || {
|
||||
type: 'object',
|
||||
properties: {},
|
||||
required: []
|
||||
}
|
||||
};
|
||||
|
||||
return tool;
|
||||
});
|
||||
|
||||
console.log('[Klavis API] Tools processed:', {
|
||||
serverName,
|
||||
toolCount: result.length,
|
||||
tools: result.map(t => ({
|
||||
name: t.name,
|
||||
paramCount: Object.keys(t.parameters?.properties || {}).length,
|
||||
hasParams: t.parameters && Object.keys(t.parameters.properties).length > 0
|
||||
}))
|
||||
});
|
||||
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('[Klavis API] Error enriching tools with parameters:', {
|
||||
serverName,
|
||||
error: error instanceof Error ? error.message : 'Unknown error',
|
||||
basicToolCount: basicTools.length
|
||||
});
|
||||
// Return basic tools with empty parameters if enrichment fails
|
||||
return basicTools.map(tool => ({
|
||||
id: tool.name,
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {},
|
||||
required: []
|
||||
}
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
// Modify listAvailableMcpServers to use enriched tools
|
||||
export async function listAvailableMcpServers(projectId: string): Promise<McpServerResponseType> {
|
||||
try {
|
||||
await projectAuthCheck(projectId);
|
||||
|
||||
console.log('[Klavis API] Starting server list fetch:', { projectId });
|
||||
|
||||
// Get MongoDB project data first
|
||||
const project = await projectsCollection.findOne({ _id: projectId });
|
||||
const mongodbServers = project?.mcpServers || [];
|
||||
const mongodbServerMap = new Map(mongodbServers.map(server => [server.name, server]));
|
||||
|
||||
console.log('[Klavis API] Found ', mongodbServers.length, ' MongoDB servers');
|
||||
|
||||
const serversEndpoint = '/mcp-server/servers';
|
||||
const rawData = await klavisApiCall<GetAllServersResponse>(serversEndpoint, {
|
||||
additionalHeaders: { 'Accept': 'application/json' }
|
||||
});
|
||||
|
||||
console.log('[Klavis API] Raw server response:', {
|
||||
serverCount: rawData.servers.length,
|
||||
servers: rawData.servers.map(s => s.name).join(', ')
|
||||
});
|
||||
|
||||
if (!rawData || !rawData.servers || !Array.isArray(rawData.servers)) {
|
||||
console.error('[Klavis API] Invalid response format:', rawData);
|
||||
return { data: null, error: 'Invalid response format from server' };
|
||||
}
|
||||
|
||||
// Get active instances for comparison
|
||||
const queryParams = new URLSearchParams({
|
||||
user_id: projectId,
|
||||
platform_name: 'Rowboat'
|
||||
});
|
||||
|
||||
const instancesEndpoint = `/user/instances?${queryParams}`;
|
||||
let activeInstances: UserInstance[] = [];
|
||||
|
||||
try {
|
||||
const instancesData = await klavisApiCall<GetUserInstancesResponse>(instancesEndpoint);
|
||||
activeInstances = instancesData.instances;
|
||||
console.log('[Klavis API] Active instances:', {
|
||||
count: activeInstances.length,
|
||||
authenticated: activeInstances.filter(i => i.isAuthenticated).map(i => i.name).join(', '),
|
||||
needsAuth: activeInstances.filter(i => i.authNeeded && !i.isAuthenticated).map(i => i.name).join(', ')
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[Klavis API] Failed to fetch user instances:', error);
|
||||
}
|
||||
|
||||
const activeInstanceMap = new Map(activeInstances.map(instance => [instance.name, instance]));
|
||||
|
||||
// Transform and enrich the data
|
||||
const transformedServers = [];
|
||||
let eligibleCount = 0;
|
||||
let serversWithToolsCount = 0;
|
||||
|
||||
for (const server of rawData.servers) {
|
||||
const activeInstance = activeInstanceMap.get(server.name);
|
||||
const mongodbServer = mongodbServerMap.get(server.name);
|
||||
|
||||
// Determine server eligibility
|
||||
const isActive = !!activeInstance;
|
||||
const authNeeded = activeInstance ? activeInstance.authNeeded : (server.authNeeded || false);
|
||||
const isAuthenticated = activeInstance ? activeInstance.isAuthenticated : false;
|
||||
const isEligible = isActive && (!authNeeded || isAuthenticated);
|
||||
|
||||
// Get basic tools data first
|
||||
const basicTools = (server.tools || []).map(tool => ({
|
||||
id: tool.name || '',
|
||||
name: tool.name || '',
|
||||
description: tool.description || '',
|
||||
}));
|
||||
|
||||
let availableTools: McpToolType[];
|
||||
let selectedTools: McpToolType[];
|
||||
|
||||
// Only use MongoDB data for eligible servers
|
||||
if (isEligible) {
|
||||
eligibleCount++;
|
||||
console.log('[Klavis API] Processing server:', server.name);
|
||||
|
||||
// Use MongoDB data if available
|
||||
availableTools = mongodbServer?.availableTools || basicTools;
|
||||
selectedTools = mongodbServer?.tools || [];
|
||||
|
||||
if (selectedTools.length > 0) {
|
||||
serversWithToolsCount++;
|
||||
}
|
||||
} else {
|
||||
// For non-eligible servers, just use basic data
|
||||
availableTools = basicTools;
|
||||
selectedTools = [];
|
||||
}
|
||||
|
||||
transformedServers.push({
|
||||
...server,
|
||||
instanceId: activeInstance?.id || server.id,
|
||||
serverName: server.name,
|
||||
tools: selectedTools,
|
||||
availableTools,
|
||||
isActive,
|
||||
authNeeded,
|
||||
isAuthenticated,
|
||||
requiresAuth: server.authNeeded || false,
|
||||
serverUrl: mongodbServer?.serverUrl
|
||||
});
|
||||
}
|
||||
|
||||
console.log('[Klavis API] Server processing complete:', {
|
||||
totalServers: transformedServers.length,
|
||||
eligibleServers: eligibleCount,
|
||||
serversWithTools: serversWithToolsCount
|
||||
});
|
||||
|
||||
return { data: transformedServers, error: null };
|
||||
} catch (error: any) {
|
||||
console.error('[Klavis API] Server list error:', error.message);
|
||||
return { data: null, error: error.message || 'An unexpected error occurred' };
|
||||
}
|
||||
}
|
||||
|
||||
export async function createMcpServerInstance(
|
||||
serverName: string,
|
||||
projectId: string,
|
||||
platformName: string,
|
||||
): Promise<CreateServerInstanceResponse> {
|
||||
try {
|
||||
await projectAuthCheck(projectId);
|
||||
|
||||
const requestBody = {
|
||||
serverName,
|
||||
userId: projectId,
|
||||
platformName
|
||||
};
|
||||
console.log('[Klavis API] Creating server instance:', requestBody);
|
||||
|
||||
const endpoint = '/mcp-server/instance/create';
|
||||
const result = await klavisApiCall<CreateServerInstanceResponse>(endpoint, {
|
||||
method: 'POST',
|
||||
body: requestBody
|
||||
});
|
||||
|
||||
console.log('[Klavis API] Created server instance:', result);
|
||||
return result;
|
||||
} catch (error: any) {
|
||||
console.error('[Klavis API] Error creating instance:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to filter eligible servers
|
||||
function getEligibleServers(servers: McpServerType[]): McpServerType[] {
|
||||
return servers.filter(server =>
|
||||
server.isActive && (!server.authNeeded || server.isAuthenticated)
|
||||
);
|
||||
}
|
||||
|
||||
async function getServerInstance(instanceId: string): Promise<{
|
||||
instanceId: string;
|
||||
authNeeded: boolean;
|
||||
isAuthenticated: boolean;
|
||||
serverName: string;
|
||||
serverUrl?: string;
|
||||
}> {
|
||||
const endpoint = `/mcp-server/instance/get/${instanceId}`;
|
||||
return await klavisApiCall(endpoint);
|
||||
}
|
||||
|
||||
export async function updateProjectServers(projectId: string, targetServerName?: string): Promise<void> {
|
||||
try {
|
||||
await projectAuthCheck(projectId);
|
||||
|
||||
console.log('[Auth] Starting server data update:', { projectId, targetServerName });
|
||||
|
||||
// Get current MongoDB data
|
||||
const project = await projectsCollection.findOne({ _id: projectId });
|
||||
if (!project) {
|
||||
console.error('[Auth] Project not found in MongoDB:', { projectId });
|
||||
throw new Error("Project not found");
|
||||
}
|
||||
|
||||
const mcpServers = project.mcpServers || [];
|
||||
|
||||
// Get active instances to find auth status
|
||||
const instances = await listActiveServerInstances(projectId);
|
||||
|
||||
// If targetServerName is provided, only process that server
|
||||
const instancesToProcess = targetServerName
|
||||
? instances.filter(i => i.name === targetServerName)
|
||||
: instances;
|
||||
|
||||
// For each active instance, get its current status
|
||||
for (const instance of instancesToProcess) {
|
||||
if (!instance.id) continue;
|
||||
|
||||
try {
|
||||
// Get fresh instance data
|
||||
const serverInstance = await getServerInstance(instance.id);
|
||||
|
||||
// Find this server in MongoDB
|
||||
const serverIndex = mcpServers.findIndex(s => s.name === instance.name);
|
||||
if (serverIndex === -1) continue;
|
||||
|
||||
// Update server readiness based on auth status
|
||||
const isReady = !serverInstance.authNeeded || serverInstance.isAuthenticated;
|
||||
|
||||
// Update existing server
|
||||
const updatedServer = {
|
||||
...mcpServers[serverIndex],
|
||||
isAuthenticated: serverInstance.isAuthenticated,
|
||||
isReady
|
||||
};
|
||||
mcpServers[serverIndex] = updatedServer;
|
||||
|
||||
// If server is now ready and has no tools, try to enrich them
|
||||
if (isReady && (!updatedServer.tools || updatedServer.tools.length === 0)) {
|
||||
try {
|
||||
console.log(`[Auth] Enriching tools for ${instance.name}`);
|
||||
const enrichedTools = await enrichToolsWithParameters(
|
||||
projectId,
|
||||
instance.name,
|
||||
updatedServer.availableTools || [],
|
||||
true
|
||||
);
|
||||
|
||||
if (enrichedTools.length > 0) {
|
||||
console.log(`[Auth] Writing ${enrichedTools.length} tools to DB for ${instance.name}`);
|
||||
updatedServer.availableTools = enrichedTools;
|
||||
await batchAddTools(projectId, instance.name, enrichedTools);
|
||||
}
|
||||
} catch (enrichError) {
|
||||
console.error(`[Auth] Tool enrichment failed for ${instance.name}:`, enrichError);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`[Auth] Error updating ${instance.name}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
// Update MongoDB with new server data
|
||||
await projectsCollection.updateOne(
|
||||
{ _id: projectId },
|
||||
{ $set: { mcpServers } }
|
||||
);
|
||||
console.log('[Auth] MongoDB update completed');
|
||||
} catch (error) {
|
||||
console.error('[Auth] Error updating server data:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function batchAddTools(projectId: string, serverName: string, tools: McpToolType[]): Promise<void> {
|
||||
console.log(`[Klavis API] Writing ${tools.length} tools to ${serverName}`);
|
||||
|
||||
const toolsToWrite = tools.map(tool => ({
|
||||
id: tool.id,
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
parameters: tool.parameters || {
|
||||
type: 'object',
|
||||
properties: {},
|
||||
required: []
|
||||
}
|
||||
}));
|
||||
|
||||
console.log('[Klavis API] DB Write - batchAddTools:', {
|
||||
serverName,
|
||||
toolCount: tools.length,
|
||||
tools: tools.map(t => t.name).join(', ')
|
||||
});
|
||||
|
||||
// Update MongoDB in a single operation
|
||||
await projectsCollection.updateOne(
|
||||
{ _id: projectId, "mcpServers.name": serverName },
|
||||
{
|
||||
$set: {
|
||||
"mcpServers.$.tools": toolsToWrite
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
console.log(`[Klavis API] Tools written to ${serverName}`);
|
||||
}
|
||||
|
||||
export async function enableServer(
|
||||
serverName: string,
|
||||
projectId: string,
|
||||
enabled: boolean
|
||||
): Promise<CreateServerInstanceResponse | {}> {
|
||||
try {
|
||||
await projectAuthCheck(projectId);
|
||||
|
||||
console.log('[Klavis API] Toggle server request:', { serverName, projectId, enabled });
|
||||
|
||||
if (enabled) {
|
||||
console.log(`[Klavis API] Creating server instance for ${serverName}...`);
|
||||
const result = await createMcpServerInstance(serverName, projectId, "Rowboat");
|
||||
console.log('[Klavis API] Server instance created:', {
|
||||
serverName,
|
||||
instanceId: result.instanceId,
|
||||
serverUrl: result.serverUrl
|
||||
});
|
||||
|
||||
// Get the current server list from MongoDB
|
||||
const project = await projectsCollection.findOne({ _id: projectId });
|
||||
if (!project) throw new Error("Project not found");
|
||||
|
||||
const mcpServers = project.mcpServers || [];
|
||||
|
||||
// Find the server we're enabling
|
||||
const serverIndex = mcpServers.findIndex(s => s.name === serverName);
|
||||
const rawServerData = (await klavisApiCall<GetAllServersResponse>('/mcp-server/servers')).servers
|
||||
.find(s => s.name === serverName);
|
||||
|
||||
if (!rawServerData) throw new Error("Server data not found");
|
||||
|
||||
// Get basic tools data
|
||||
const basicTools = (rawServerData.tools || []).map(tool => ({
|
||||
id: tool.name || '',
|
||||
name: tool.name || '',
|
||||
description: tool.description || '',
|
||||
}));
|
||||
|
||||
// Update server status in MongoDB
|
||||
const updatedServer = {
|
||||
...rawServerData,
|
||||
instanceId: result.instanceId,
|
||||
serverName: serverName,
|
||||
serverUrl: result.serverUrl,
|
||||
tools: basicTools, // Select all tools by default
|
||||
availableTools: basicTools, // Use basic tools initially
|
||||
isActive: true, // Keep isActive true to indicate server is enabled
|
||||
isReady: !rawServerData.authNeeded, // Use isReady to indicate eligibility
|
||||
authNeeded: rawServerData.authNeeded || false,
|
||||
isAuthenticated: false,
|
||||
requiresAuth: rawServerData.authNeeded || false
|
||||
};
|
||||
|
||||
if (serverIndex === -1) {
|
||||
mcpServers.push(updatedServer);
|
||||
} else {
|
||||
mcpServers[serverIndex] = updatedServer;
|
||||
}
|
||||
|
||||
// Update MongoDB with server status
|
||||
await projectsCollection.updateOne(
|
||||
{ _id: projectId },
|
||||
{ $set: { mcpServers } }
|
||||
);
|
||||
|
||||
// Wait for server warm-up (increased from 2s to 5s)
|
||||
console.log(`[Klavis API] New server detected, waiting 5s for ${serverName} to initialize...`);
|
||||
await new Promise(resolve => setTimeout(resolve, 5000));
|
||||
console.log(`[Klavis API] Warm-up period complete for ${serverName}`);
|
||||
|
||||
// Try to enrich tools regardless of auth status
|
||||
try {
|
||||
console.log(`[Klavis API] Enriching tools for ${serverName}`);
|
||||
const enrichedTools = await enrichToolsWithParameters(
|
||||
projectId,
|
||||
serverName,
|
||||
basicTools,
|
||||
true
|
||||
);
|
||||
|
||||
if (enrichedTools.length > 0) {
|
||||
console.log(`[Klavis API] Writing ${enrichedTools.length} tools to DB for ${serverName}`);
|
||||
// First update availableTools
|
||||
await projectsCollection.updateOne(
|
||||
{ _id: projectId, "mcpServers.name": serverName },
|
||||
{
|
||||
$set: {
|
||||
"mcpServers.$.availableTools": enrichedTools,
|
||||
"mcpServers.$.isReady": true // Mark server as ready after successful enrichment
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// Then write the tools
|
||||
await batchAddTools(projectId, serverName, enrichedTools);
|
||||
console.log(`[Klavis API] Successfully wrote tools for ${serverName}`);
|
||||
}
|
||||
} catch (enrichError) {
|
||||
console.error(`[Klavis API] Tool enrichment failed for ${serverName}:`, enrichError);
|
||||
}
|
||||
|
||||
return result;
|
||||
} else {
|
||||
// Get active instances to find the one to delete
|
||||
const instances = await listActiveServerInstances(projectId);
|
||||
const instance = instances.find(i => i.name === serverName);
|
||||
|
||||
if (instance?.id) {
|
||||
await deleteMcpServerInstance(instance.id, projectId);
|
||||
console.log('[Klavis API] Disabled server:', { serverName, instanceId: instance.id });
|
||||
|
||||
// Remove from MongoDB
|
||||
await projectsCollection.updateOne(
|
||||
{ _id: projectId },
|
||||
{ $pull: { mcpServers: { name: serverName } } }
|
||||
);
|
||||
} else {
|
||||
console.log('[Klavis API] No instance found to disable:', { serverName });
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('[Klavis API] Toggle error:', {
|
||||
server: serverName,
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
timestamp: new Date().toISOString()
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function deleteMcpServerInstance(
|
||||
instanceId: string,
|
||||
projectId: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await projectAuthCheck(projectId);
|
||||
|
||||
console.log('[Klavis API] Deleting instance:', { instanceId });
|
||||
|
||||
const endpoint = `/mcp-server/instance/delete/${instanceId}`;
|
||||
try {
|
||||
await klavisApiCall<DeleteServerInstanceResponse>(endpoint, {
|
||||
method: 'DELETE'
|
||||
});
|
||||
console.log('[Klavis API] Instance deleted successfully:', { instanceId });
|
||||
|
||||
// Get the server info from MongoDB to find its name
|
||||
const project = await projectsCollection.findOne({ _id: projectId });
|
||||
const server = project?.mcpServers?.find(s => s.instanceId === instanceId);
|
||||
|
||||
if (server) {
|
||||
// Update just this server's status in MongoDB
|
||||
await projectsCollection.updateOne(
|
||||
{ _id: projectId, "mcpServers.name": server.name },
|
||||
{
|
||||
$set: {
|
||||
"mcpServers.$.isActive": false,
|
||||
"mcpServers.$.serverUrl": null,
|
||||
"mcpServers.$.tools": [],
|
||||
"mcpServers.$.availableTools": [],
|
||||
"mcpServers.$.instanceId": null
|
||||
}
|
||||
}
|
||||
);
|
||||
console.log('[MongoDB] Server status updated:', { serverName: server.name });
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error.message.includes('404')) {
|
||||
console.log('[Klavis API] Instance already deleted:', { instanceId });
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error('[Klavis API] Error deleting instance:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Server name to URL parameter mapping
|
||||
const SERVER_URL_PARAMS: Record<string, string> = {
|
||||
'Google Calendar': 'gcalendar',
|
||||
'Google Drive': 'gdrive',
|
||||
'Google Docs': 'gdocs',
|
||||
'Google Sheets': 'gsheets',
|
||||
};
|
||||
|
||||
// Server name to environment variable mapping for client IDs
|
||||
const SERVER_CLIENT_ID_MAP: Record<string, string | undefined> = {
|
||||
'GitHub': process.env.KLAVIS_GITHUB_CLIENT_ID,
|
||||
'Google Calendar': process.env.KLAVIS_GOOGLE_CLIENT_ID,
|
||||
'Google Drive': process.env.KLAVIS_GOOGLE_CLIENT_ID,
|
||||
'Google Docs': process.env.KLAVIS_GOOGLE_CLIENT_ID,
|
||||
'Google Sheets': process.env.KLAVIS_GOOGLE_CLIENT_ID,
|
||||
'Slack': process.env.KLAVIS_SLACK_ID,
|
||||
};
|
||||
|
||||
export async function generateServerAuthUrl(
|
||||
serverName: string,
|
||||
projectId: string,
|
||||
instanceId: string,
|
||||
): Promise<string> {
|
||||
try {
|
||||
await projectAuthCheck(projectId);
|
||||
|
||||
// Get the origin from request headers
|
||||
const headersList = headers();
|
||||
const host = headersList.get('host') || '';
|
||||
const protocol = headersList.get('x-forwarded-proto') || 'http';
|
||||
const origin = `${protocol}://${host}`;
|
||||
|
||||
// Get the URL parameter for this server
|
||||
const serverUrlParam = SERVER_URL_PARAMS[serverName] || serverName.toLowerCase();
|
||||
|
||||
// Build base params
|
||||
const params: Record<string, string> = {
|
||||
instance_id: instanceId,
|
||||
redirect_url: `${origin}/projects/${projectId}/tools/oauth/callback`
|
||||
};
|
||||
|
||||
// Add client_id if available for this server
|
||||
const clientId = SERVER_CLIENT_ID_MAP[serverName];
|
||||
if (clientId) {
|
||||
params.client_id = clientId;
|
||||
}
|
||||
|
||||
let authUrl = `${KLAVIS_BASE_URL}/oauth/${serverUrlParam}/authorize?${new URLSearchParams(params).toString()}`
|
||||
console.log('authUrl', authUrl);
|
||||
|
||||
return authUrl;
|
||||
} catch (error) {
|
||||
console.error('[Klavis API] Error generating auth URL:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export async function syncServerTools(projectId: string, serverName: string): Promise<void> {
|
||||
try {
|
||||
await projectAuthCheck(projectId);
|
||||
|
||||
console.log('[Klavis API] Starting server tool sync:', { projectId, serverName });
|
||||
|
||||
// Get enriched tools from MCP
|
||||
const enrichedTools = await fetchMcpToolsForServer(projectId, serverName);
|
||||
console.log('[Klavis API] Received enriched tools:', {
|
||||
serverName,
|
||||
toolCount: enrichedTools.length
|
||||
});
|
||||
|
||||
// Convert enriched tools to the correct format
|
||||
const formattedTools = enrichedTools.map(tool => {
|
||||
return {
|
||||
id: tool.name,
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
parameters: {
|
||||
type: 'object' as const,
|
||||
properties: tool.parameters?.properties || {},
|
||||
required: tool.parameters?.required || []
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
// First verify the server exists
|
||||
const project = await projectsCollection.findOne({ _id: projectId });
|
||||
if (!project) {
|
||||
throw new Error(`Project ${projectId} not found`);
|
||||
}
|
||||
const server = project.mcpServers?.find(s => s.name === serverName);
|
||||
if (!server) {
|
||||
throw new Error(`Server ${serverName} not found in project ${projectId}`);
|
||||
}
|
||||
|
||||
// Update MongoDB with enriched tools
|
||||
const updateResult = await projectsCollection.updateOne(
|
||||
{ _id: projectId, "mcpServers.name": serverName },
|
||||
{
|
||||
$set: {
|
||||
"mcpServers.$.availableTools": formattedTools,
|
||||
"mcpServers.$.tools": formattedTools // Also update selected tools to match
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
console.log('[Klavis API] Tools synced:', {
|
||||
serverName,
|
||||
toolCount: formattedTools.length,
|
||||
success: updateResult.modifiedCount > 0
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('[Klavis API] Error syncing server tools:', {
|
||||
serverName,
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
|
@ -4,9 +4,10 @@ import { WorkflowTool } from "../lib/types/workflow_types";
|
|||
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
|
||||
import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js";
|
||||
import { projectAuthCheck } from "./project_actions";
|
||||
import { projectsCollection } from "../lib/mongodb";
|
||||
import { projectsCollection, agentWorkflowsCollection } from "../lib/mongodb";
|
||||
import { Project } from "../lib/types/project_types";
|
||||
import { MCPServer } from "../lib/types/types";
|
||||
import { MCPServer, McpTool, McpServerTool, convertMcpServerToolToWorkflowTool } from "../lib/types/types";
|
||||
import { ObjectId } from "mongodb";
|
||||
|
||||
export async function fetchMcpTools(projectId: string): Promise<z.infer<typeof WorkflowTool>[]> {
|
||||
await projectAuthCheck(projectId);
|
||||
|
|
@ -16,12 +17,13 @@ export async function fetchMcpTools(projectId: string): Promise<z.infer<typeof W
|
|||
});
|
||||
|
||||
const mcpServers = project?.mcpServers ?? [];
|
||||
|
||||
const tools: z.infer<typeof WorkflowTool>[] = [];
|
||||
|
||||
for (const mcpServer of mcpServers) {
|
||||
if (!mcpServer.isActive) continue;
|
||||
|
||||
try {
|
||||
const transport = new SSEClientTransport(new URL(mcpServer.url));
|
||||
const transport = new SSEClientTransport(new URL(mcpServer.serverUrl!));
|
||||
|
||||
const client = new Client(
|
||||
{
|
||||
|
|
@ -42,31 +44,158 @@ export async function fetchMcpTools(projectId: string): Promise<z.infer<typeof W
|
|||
// List tools
|
||||
const result = await client.listTools();
|
||||
|
||||
await client.close();
|
||||
// Validate and parse each tool
|
||||
const validTools = await Promise.all(
|
||||
result.tools.map(async (tool) => {
|
||||
try {
|
||||
return McpServerTool.parse(tool);
|
||||
} catch (error) {
|
||||
console.error(`Invalid tool response from ${mcpServer.name}:`, {
|
||||
tool: tool.name,
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
});
|
||||
return null;
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
tools.push(...result.tools.map((mcpTool) => {
|
||||
let props = mcpTool.inputSchema.properties as Record<string, { description: string; type: string }>;
|
||||
const tool: z.infer<typeof WorkflowTool> = {
|
||||
name: mcpTool.name,
|
||||
description: mcpTool.description ?? "",
|
||||
parameters: {
|
||||
type: "object",
|
||||
properties: props ?? {},
|
||||
required: mcpTool.inputSchema.required as string[] ?? [],
|
||||
},
|
||||
isMcp: true,
|
||||
mcpServerName: mcpServer.name,
|
||||
}
|
||||
return tool;
|
||||
}));
|
||||
// Filter out invalid tools and convert valid ones
|
||||
tools.push(...validTools
|
||||
.filter((tool): tool is z.infer<typeof McpServerTool> =>
|
||||
tool !== null &&
|
||||
mcpServer.tools.some(t => t.id === tool.name)
|
||||
)
|
||||
.map(mcpTool => convertMcpServerToolToWorkflowTool(mcpTool, mcpServer))
|
||||
);
|
||||
} catch (e) {
|
||||
console.error(`Error fetching MCP tools from ${mcpServer.name}: ${e}`);
|
||||
console.error(`Error fetching MCP tools from ${mcpServer.name}:`, {
|
||||
error: e instanceof Error ? e.message : 'Unknown error',
|
||||
serverUrl: mcpServer.serverUrl
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return tools;
|
||||
}
|
||||
|
||||
export async function fetchMcpToolsForServer(projectId: string, serverName: string): Promise<z.infer<typeof WorkflowTool>[]> {
|
||||
await projectAuthCheck(projectId);
|
||||
|
||||
console.log('[Klavis API] Fetching tools for specific server:', { projectId, serverName });
|
||||
|
||||
const project = await projectsCollection.findOne({
|
||||
_id: projectId,
|
||||
});
|
||||
|
||||
const mcpServer = project?.mcpServers?.find(server => server.name === serverName);
|
||||
if (!mcpServer) {
|
||||
console.error('[Klavis API] Server not found:', { serverName });
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!mcpServer.isActive || !mcpServer.serverUrl) {
|
||||
console.log('[Klavis API] Server is not active or missing URL:', {
|
||||
serverName,
|
||||
isActive: mcpServer.isActive,
|
||||
hasUrl: !!mcpServer.serverUrl
|
||||
});
|
||||
return [];
|
||||
}
|
||||
|
||||
const tools: z.infer<typeof WorkflowTool>[] = [];
|
||||
|
||||
try {
|
||||
console.log('[Klavis API] Attempting MCP connection:', {
|
||||
serverName,
|
||||
url: mcpServer.serverUrl
|
||||
});
|
||||
|
||||
const transport = new SSEClientTransport(new URL(mcpServer.serverUrl));
|
||||
const client = new Client(
|
||||
{
|
||||
name: "rowboat-client",
|
||||
version: "1.0.0"
|
||||
},
|
||||
{
|
||||
capabilities: {
|
||||
prompts: {},
|
||||
resources: {},
|
||||
tools: {}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
await client.connect(transport);
|
||||
console.log('[Klavis API] MCP connection established:', { serverName });
|
||||
|
||||
// List tools
|
||||
const result = await client.listTools();
|
||||
|
||||
// Log just essential info about tools
|
||||
console.log('[Klavis API] Received tools from server:', {
|
||||
serverName,
|
||||
toolCount: result.tools.length,
|
||||
tools: result.tools.map(tool => tool.name).join(', ')
|
||||
});
|
||||
|
||||
// Get all available tools from the server
|
||||
const availableToolNames = new Set(mcpServer.availableTools?.map(t => t.name) || []);
|
||||
|
||||
// Validate and parse each tool
|
||||
const validTools = await Promise.all(
|
||||
result.tools.map(async (tool) => {
|
||||
try {
|
||||
const parsedTool = McpServerTool.parse(tool);
|
||||
return parsedTool;
|
||||
} catch (error) {
|
||||
console.error(`Invalid tool response from ${mcpServer.name}:`, {
|
||||
tool: tool.name,
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
});
|
||||
return null;
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// Filter out invalid tools and convert valid ones
|
||||
const convertedTools = validTools
|
||||
.filter((tool): tool is z.infer<typeof McpServerTool> => tool !== null)
|
||||
.map(mcpTool => {
|
||||
const converted = convertMcpServerToolToWorkflowTool(mcpTool, mcpServer);
|
||||
return converted;
|
||||
});
|
||||
|
||||
tools.push(...convertedTools);
|
||||
|
||||
// Find tools that weren't enriched
|
||||
const enrichedToolNames = new Set(convertedTools.map(t => t.name));
|
||||
const unenrichedTools = Array.from(availableToolNames).filter(name => !enrichedToolNames.has(name));
|
||||
|
||||
if (unenrichedTools.length > 0) {
|
||||
console.log('[Klavis API] Tools that could not be enriched:', {
|
||||
serverName,
|
||||
unenrichedTools,
|
||||
totalAvailable: availableToolNames.size,
|
||||
totalEnriched: enrichedToolNames.size
|
||||
});
|
||||
}
|
||||
|
||||
console.log('[Klavis API] Successfully fetched tools for server:', {
|
||||
serverName,
|
||||
toolCount: tools.length,
|
||||
availableToolCount: availableToolNames.size,
|
||||
tools: tools.map(t => t.name).join(', ')
|
||||
});
|
||||
} catch (e) {
|
||||
console.error(`[Klavis API] Error fetching MCP tools from ${mcpServer.name}:`, {
|
||||
error: e instanceof Error ? e.message : 'Unknown error',
|
||||
serverUrl: mcpServer.serverUrl
|
||||
});
|
||||
}
|
||||
|
||||
return tools;
|
||||
}
|
||||
|
||||
export async function updateMcpServers(projectId: string, mcpServers: z.infer<typeof Project>['mcpServers']): Promise<void> {
|
||||
await projectAuthCheck(projectId);
|
||||
await projectsCollection.updateOne({
|
||||
|
|
@ -80,4 +209,233 @@ export async function listMcpServers(projectId: string): Promise<z.infer<typeof
|
|||
_id: projectId,
|
||||
});
|
||||
return project?.mcpServers ?? [];
|
||||
}
|
||||
|
||||
export async function updateToolInAllWorkflows(
|
||||
projectId: string,
|
||||
mcpServer: z.infer<typeof MCPServer>,
|
||||
toolId: string,
|
||||
shouldAdd: boolean
|
||||
): Promise<void> {
|
||||
await projectAuthCheck(projectId);
|
||||
|
||||
// 1. Get all workflows in the project
|
||||
const workflows = await agentWorkflowsCollection.find({ projectId }).toArray();
|
||||
|
||||
// 2. For each workflow
|
||||
for (const workflow of workflows) {
|
||||
// 3. Find if the tool already exists in this workflow
|
||||
const existingTool = workflow.tools.find(t =>
|
||||
t.isMcp &&
|
||||
t.mcpServerName === mcpServer.name &&
|
||||
t.name === toolId
|
||||
);
|
||||
|
||||
if (shouldAdd && !existingTool) {
|
||||
// 4a. If adding and tool doesn't exist, add it
|
||||
const tool = mcpServer.tools.find(t => t.id === toolId);
|
||||
if (tool) {
|
||||
const workflowTool = convertMcpServerToolToWorkflowTool(
|
||||
{
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: tool.parameters?.properties ?? {},
|
||||
required: tool.parameters?.required ?? [],
|
||||
},
|
||||
},
|
||||
mcpServer
|
||||
);
|
||||
workflow.tools.push(workflowTool);
|
||||
}
|
||||
} else if (!shouldAdd && existingTool) {
|
||||
// 4b. If removing and tool exists, remove it
|
||||
workflow.tools = workflow.tools.filter(t =>
|
||||
!(t.isMcp && t.mcpServerName === mcpServer.name && t.name === toolId)
|
||||
);
|
||||
}
|
||||
|
||||
// 5. Update the workflow
|
||||
await agentWorkflowsCollection.updateOne(
|
||||
{ _id: workflow._id },
|
||||
{
|
||||
$set: {
|
||||
tools: workflow.tools,
|
||||
lastUpdatedAt: new Date().toISOString()
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function toggleMcpTool(
|
||||
projectId: string,
|
||||
serverName: string,
|
||||
toolId: string,
|
||||
shouldAdd: boolean
|
||||
): Promise<void> {
|
||||
await projectAuthCheck(projectId);
|
||||
|
||||
// 1. Get the project and find the server
|
||||
const project = await projectsCollection.findOne({ _id: projectId });
|
||||
if (!project) throw new Error("Project not found");
|
||||
|
||||
const mcpServers = project.mcpServers || [];
|
||||
const serverIndex = mcpServers.findIndex(s => s.serverName === serverName);
|
||||
if (serverIndex === -1) throw new Error("Server not found");
|
||||
|
||||
const server = mcpServers[serverIndex];
|
||||
|
||||
if (shouldAdd) {
|
||||
// Add tool if it doesn't exist
|
||||
const toolExists = server.tools.some(t => t.id === toolId);
|
||||
if (!toolExists) {
|
||||
// Find the tool in availableTools to get its parameters
|
||||
const availableTool = server.availableTools?.find(t => t.name === toolId);
|
||||
|
||||
// Create a new tool with the parameters from availableTools
|
||||
const newTool = {
|
||||
id: toolId,
|
||||
name: toolId,
|
||||
description: availableTool?.description || '',
|
||||
parameters: availableTool?.parameters || {
|
||||
type: 'object' as const,
|
||||
properties: {},
|
||||
required: []
|
||||
}
|
||||
};
|
||||
server.tools.push(newTool);
|
||||
}
|
||||
} else {
|
||||
// Remove tool if it exists
|
||||
server.tools = server.tools.filter(t => t.id !== toolId);
|
||||
}
|
||||
|
||||
// Update the project
|
||||
await projectsCollection.updateOne(
|
||||
{ _id: projectId },
|
||||
{ $set: { mcpServers } }
|
||||
);
|
||||
}
|
||||
|
||||
export async function getSelectedMcpTools(projectId: string, serverName: string): Promise<string[]> {
|
||||
await projectAuthCheck(projectId);
|
||||
const project = await projectsCollection.findOne({ _id: projectId });
|
||||
if (!project) return [];
|
||||
|
||||
const server = project.mcpServers?.find(s => s.serverName === serverName);
|
||||
if (!server) return [];
|
||||
|
||||
return server.tools.map(t => t.id);
|
||||
}
|
||||
|
||||
export async function listProjectMcpTools(projectId: string): Promise<z.infer<typeof WorkflowTool>[]> {
|
||||
await projectAuthCheck(projectId);
|
||||
|
||||
try {
|
||||
// Get project's MCP servers and their tools
|
||||
const project = await projectsCollection.findOne({ _id: projectId });
|
||||
if (!project?.mcpServers) return [];
|
||||
|
||||
// Convert MCP tools to workflow tools format, but only from ready servers
|
||||
return project.mcpServers
|
||||
.filter(server => server.isReady) // Only include tools from ready servers
|
||||
.flatMap(server => {
|
||||
return server.tools.map(tool => ({
|
||||
name: tool.name,
|
||||
description: tool.description || "",
|
||||
parameters: {
|
||||
type: 'object' as const,
|
||||
properties: tool.parameters?.properties || {},
|
||||
required: tool.parameters?.required || []
|
||||
},
|
||||
isMcp: true,
|
||||
mcpServerName: server.name,
|
||||
mcpServerURL: server.serverUrl,
|
||||
}));
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error fetching project tools:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function testMcpTool(
|
||||
projectId: string,
|
||||
serverName: string,
|
||||
toolId: string,
|
||||
parameters: Record<string, any>
|
||||
): Promise<any> {
|
||||
await projectAuthCheck(projectId);
|
||||
|
||||
const project = await projectsCollection.findOne({
|
||||
_id: projectId,
|
||||
});
|
||||
|
||||
// Find the server by name in mcpServers array
|
||||
const mcpServer = project?.mcpServers?.find(server => server.name === serverName);
|
||||
if (!mcpServer) {
|
||||
throw new Error(`Server ${serverName} not found`);
|
||||
}
|
||||
|
||||
if (!mcpServer.isActive) {
|
||||
throw new Error(`Server ${serverName} is not active`);
|
||||
}
|
||||
|
||||
if (!mcpServer.serverUrl) {
|
||||
throw new Error(`Server ${serverName} has no URL configured`);
|
||||
}
|
||||
|
||||
try {
|
||||
console.log('[MCP Test] Attempting to test tool:', {
|
||||
serverName,
|
||||
serverUrl: mcpServer.serverUrl,
|
||||
toolId
|
||||
});
|
||||
|
||||
const transport = new SSEClientTransport(new URL(mcpServer.serverUrl));
|
||||
const client = new Client(
|
||||
{
|
||||
name: "rowboat-client",
|
||||
version: "1.0.0"
|
||||
},
|
||||
{
|
||||
capabilities: {
|
||||
prompts: {},
|
||||
resources: {},
|
||||
tools: {}
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
await client.connect(transport);
|
||||
|
||||
console.log('[MCP Test] Connected to server, calling tool:', {
|
||||
toolId,
|
||||
parameters
|
||||
});
|
||||
|
||||
// Execute the tool with the correct parameter format
|
||||
const result = await client.callTool({
|
||||
name: toolId,
|
||||
arguments: parameters
|
||||
});
|
||||
|
||||
console.log('[MCP Test] Tool execution completed:', {
|
||||
toolId,
|
||||
success: true
|
||||
});
|
||||
|
||||
return result;
|
||||
|
||||
} catch (e) {
|
||||
console.error(`[MCP Test] Error testing tool from ${mcpServer.name}:`, {
|
||||
error: e instanceof Error ? e.message : 'Unknown error',
|
||||
serverUrl: mcpServer.serverUrl,
|
||||
toolId,
|
||||
parameters
|
||||
});
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
|
@ -11,6 +11,7 @@ import { WithStringId } from "../lib/types/types";
|
|||
import { ApiKey } from "../lib/types/project_types";
|
||||
import { Project } from "../lib/types/project_types";
|
||||
import { USE_AUTH } from "../lib/feature_flags";
|
||||
import { deleteMcpServerInstance, listActiveServerInstances } from "./klavis_actions";
|
||||
|
||||
export async function projectAuthCheck(projectId: string) {
|
||||
if (!USE_AUTH) {
|
||||
|
|
@ -171,9 +172,56 @@ export async function updateProjectName(projectId: string, name: string) {
|
|||
revalidatePath(`/projects/${projectId}`, 'layout');
|
||||
}
|
||||
|
||||
interface McpServerDeletionError {
|
||||
serverName: string;
|
||||
error: string;
|
||||
}
|
||||
|
||||
async function cleanupMcpServers(projectId: string): Promise<McpServerDeletionError[]> {
|
||||
// Get all active instances directly from Klavis
|
||||
const activeInstances = await listActiveServerInstances(projectId);
|
||||
if (activeInstances.length === 0) return [];
|
||||
|
||||
console.log(`[Project Cleanup] Found ${activeInstances.length} active Klavis instances`);
|
||||
|
||||
// Track deletion errors
|
||||
const deletionErrors: McpServerDeletionError[] = [];
|
||||
|
||||
// Delete each instance
|
||||
const deletionPromises = activeInstances.map(async (instance) => {
|
||||
if (!instance.id) return; // Skip if no instance ID
|
||||
|
||||
try {
|
||||
await deleteMcpServerInstance(instance.id, projectId);
|
||||
console.log(`[Project Cleanup] Deleted Klavis instance: ${instance.name} (${instance.id})`);
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
|
||||
console.error(`[Project Cleanup] Failed to delete Klavis instance: ${instance.name}`, error);
|
||||
deletionErrors.push({
|
||||
serverName: instance.name,
|
||||
error: errorMessage
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Wait for all deletions to complete
|
||||
await Promise.all(deletionPromises);
|
||||
|
||||
return deletionErrors;
|
||||
}
|
||||
|
||||
export async function deleteProject(projectId: string) {
|
||||
await projectAuthCheck(projectId);
|
||||
|
||||
// First cleanup any Klavis instances
|
||||
const deletionErrors = await cleanupMcpServers(projectId);
|
||||
|
||||
// If there were any errors deleting instances, throw an error
|
||||
if (deletionErrors.length > 0) {
|
||||
const failedServers = deletionErrors.map(e => `${e.serverName} (${e.error})`).join(', ');
|
||||
throw new Error(`Cannot delete project because the following Klavis instances could not be deleted: ${failedServers}. Please try again or contact support if the issue persists.`);
|
||||
}
|
||||
|
||||
// delete api keys
|
||||
await apiKeysCollection.deleteMany({
|
||||
projectId,
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import { getAgenticApiResponse } from "../../../../lib/utils";
|
|||
import { check_query_limit } from "../../../../lib/rate_limiting";
|
||||
import { PrefixLogger } from "../../../../lib/utils";
|
||||
import { TestProfile } from "@/app/lib/types/testing_types";
|
||||
import { fetchProjectMcpTools } from "@/app/lib/project_tools";
|
||||
|
||||
// get next turn / agent response
|
||||
export async function POST(
|
||||
|
|
@ -54,6 +55,9 @@ export async function POST(
|
|||
return Response.json({ error: "Project not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
// fetch project tools
|
||||
const projectTools = await fetchProjectMcpTools(projectId);
|
||||
|
||||
// if workflow id is provided in the request, use it, else use the published workflow id
|
||||
let workflowId = result.data.workflowId ?? project.publishedWorkflowId;
|
||||
if (!workflowId) {
|
||||
|
|
@ -86,7 +90,7 @@ export async function POST(
|
|||
let currentState: unknown = reqState ?? { last_agent_name: workflow.agents[0].name };
|
||||
|
||||
// get assistant response
|
||||
const { agents, tools, prompts, startAgent } = convertWorkflowToAgenticAPI(workflow);
|
||||
const { agents, tools, prompts, startAgent } = convertWorkflowToAgenticAPI(workflow, projectTools);
|
||||
const request: z.infer<typeof AgenticAPIChatRequest> = {
|
||||
projectId,
|
||||
messages: convertFromApiToAgenticApiMessages(reqMessages),
|
||||
|
|
@ -96,7 +100,11 @@ export async function POST(
|
|||
prompts,
|
||||
startAgent,
|
||||
testProfile: testProfile ?? undefined,
|
||||
mcpServers: project.mcpServers ?? [],
|
||||
mcpServers: (project.mcpServers ?? []).map(server => ({
|
||||
name: server.name,
|
||||
serverUrl: server.serverUrl ?? '',
|
||||
isReady: server.isReady ?? false
|
||||
})),
|
||||
toolWebhookUrl: project.webhookUrl ?? '',
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import { AgenticAPIChatRequest } from "../../../../../../lib/types/agents_api_ty
|
|||
import { getAgenticApiResponse } from "../../../../../../lib/utils";
|
||||
import { check_query_limit } from "../../../../../../lib/rate_limiting";
|
||||
import { PrefixLogger } from "../../../../../../lib/utils";
|
||||
import { fetchProjectMcpTools } from "@/app/lib/project_tools";
|
||||
|
||||
// get next turn / agent response
|
||||
export async function POST(
|
||||
|
|
@ -80,6 +81,9 @@ export async function POST(
|
|||
throw new Error("Project settings not found");
|
||||
}
|
||||
|
||||
// fetch project tools
|
||||
const projectTools = await fetchProjectMcpTools(session.projectId);
|
||||
|
||||
// fetch workflow
|
||||
const workflow = await agentWorkflowsCollection.findOne({
|
||||
projectId: session.projectId,
|
||||
|
|
@ -90,7 +94,7 @@ export async function POST(
|
|||
}
|
||||
|
||||
// get assistant response
|
||||
const { agents, tools, prompts, startAgent } = convertWorkflowToAgenticAPI(workflow);
|
||||
const { agents, tools, prompts, startAgent } = convertWorkflowToAgenticAPI(workflow, projectTools);
|
||||
const unsavedMessages: z.infer<typeof apiV1.ChatMessage>[] = [userMessage];
|
||||
let state: unknown = chat.agenticState ?? { last_agent_name: startAgent };
|
||||
|
||||
|
|
@ -102,7 +106,11 @@ export async function POST(
|
|||
tools,
|
||||
prompts,
|
||||
startAgent,
|
||||
mcpServers: projectSettings.mcpServers ?? [],
|
||||
mcpServers: (projectSettings.mcpServers ?? []).map(server => ({
|
||||
name: server.name,
|
||||
serverUrl: server.serverUrl || '',
|
||||
isReady: server.isReady
|
||||
})),
|
||||
toolWebhookUrl: projectSettings.webhookUrl ?? '',
|
||||
testProfile: undefined,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -25,15 +25,6 @@ export const templates: { [key: string]: z.infer<typeof WorkflowTemplate> } = {
|
|||
],
|
||||
prompts: [],
|
||||
tools: [
|
||||
{
|
||||
"name": "web_search",
|
||||
"description": "Fetch information from the web based on chat context",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
},
|
||||
"isLibrary": true
|
||||
},
|
||||
{
|
||||
"name": "rag_search",
|
||||
"description": "Fetch articles with knowledge relevant to the query",
|
||||
|
|
|
|||
51
apps/rowboat/app/lib/project_tools.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import { z } from "zod";
|
||||
import { projectsCollection } from "./mongodb";
|
||||
import { WorkflowTool } from "./types/workflow_types";
|
||||
|
||||
export async function fetchProjectMcpTools(projectId: string): Promise<z.infer<typeof WorkflowTool>[]> {
|
||||
// Get project's MCP servers and their tools
|
||||
const project = await projectsCollection.findOne({ _id: projectId });
|
||||
if (!project?.mcpServers) return [];
|
||||
|
||||
console.log('[MCP] Getting tools from project:', {
|
||||
serverCount: project.mcpServers.length,
|
||||
servers: project.mcpServers.map(s => ({
|
||||
name: s.name,
|
||||
isReady: s.isReady,
|
||||
toolCount: s.tools.length,
|
||||
tools: s.tools.map(t => ({
|
||||
name: t.name,
|
||||
hasParams: !!t.parameters,
|
||||
paramCount: t.parameters ? Object.keys(t.parameters.properties).length : 0,
|
||||
required: t.parameters?.required || []
|
||||
}))
|
||||
}))
|
||||
});
|
||||
|
||||
// Convert MCP tools to workflow tools format, but only from ready servers
|
||||
const mcpTools = project.mcpServers
|
||||
.filter(server => server.isReady) // Only include tools from ready servers
|
||||
.flatMap(server => {
|
||||
return server.tools.map(tool => ({
|
||||
name: tool.name,
|
||||
description: tool.description || "",
|
||||
parameters: {
|
||||
type: 'object' as const,
|
||||
properties: tool.parameters?.properties || {},
|
||||
required: tool.parameters?.required || []
|
||||
},
|
||||
isMcp: true,
|
||||
mcpServerName: server.name,
|
||||
mcpServerURL: server.serverUrl,
|
||||
}));
|
||||
});
|
||||
|
||||
console.log('[MCP] Converted tools from ready servers:', mcpTools.map(t => ({
|
||||
name: t.name,
|
||||
hasParams: !!t.parameters,
|
||||
paramCount: t.parameters ? Object.keys(t.parameters.properties).length : 0,
|
||||
required: t.parameters?.required || []
|
||||
})));
|
||||
|
||||
return mcpTools;
|
||||
}
|
||||
|
|
@ -3,7 +3,8 @@ import { sanitizeTextWithMentions, Workflow, WorkflowAgent, WorkflowPrompt, Work
|
|||
import { apiV1 } from "rowboat-shared";
|
||||
import { ApiMessage } from "./types";
|
||||
import { TestProfile } from "./testing_types";
|
||||
import { MCPServer } from "./types";
|
||||
import { MCPServer, MCPServerMinimal } from "./types";
|
||||
import { mergeProjectTools } from "./project_types";
|
||||
|
||||
export const AgenticAPIChatMessage = z.object({
|
||||
role: z.union([z.literal('user'), z.literal('assistant'), z.literal('tool'), z.literal('system')]),
|
||||
|
|
@ -55,7 +56,7 @@ export const AgenticAPIChatRequest = z.object({
|
|||
prompts: z.array(WorkflowPrompt),
|
||||
startAgent: z.string(),
|
||||
testProfile: TestProfile.optional(),
|
||||
mcpServers: z.array(MCPServer),
|
||||
mcpServers: z.array(MCPServerMinimal),
|
||||
toolWebhookUrl: z.string(),
|
||||
});
|
||||
|
||||
|
|
@ -68,19 +69,21 @@ export const AgenticAPIInitStreamResponse = z.object({
|
|||
streamId: z.string(),
|
||||
});
|
||||
|
||||
export function convertWorkflowToAgenticAPI(workflow: z.infer<typeof Workflow>): {
|
||||
export function convertWorkflowToAgenticAPI(workflow: z.infer<typeof Workflow>, projectTools: z.infer<typeof WorkflowTool>[]): {
|
||||
agents: z.infer<typeof AgenticAPIAgent>[];
|
||||
tools: z.infer<typeof AgenticAPITool>[];
|
||||
prompts: z.infer<typeof AgenticAPIPrompt>[];
|
||||
startAgent: string;
|
||||
} {
|
||||
const mergedTools = mergeProjectTools(workflow.tools, projectTools);
|
||||
|
||||
return {
|
||||
agents: workflow.agents
|
||||
.filter(agent => !agent.disabled)
|
||||
.map(agent => {
|
||||
const compiledInstructions = agent.instructions +
|
||||
(agent.examples ? '\n\n# Examples\n' + agent.examples : '');
|
||||
const { sanitized, entities } = sanitizeTextWithMentions(compiledInstructions, workflow);
|
||||
const { sanitized, entities } = sanitizeTextWithMentions(compiledInstructions, workflow, mergedTools);
|
||||
|
||||
const agenticAgent: z.infer<typeof AgenticAPIAgent> = {
|
||||
name: agent.name,
|
||||
|
|
@ -100,13 +103,10 @@ export function convertWorkflowToAgenticAPI(workflow: z.infer<typeof Workflow>):
|
|||
};
|
||||
return agenticAgent;
|
||||
}),
|
||||
tools: workflow.tools.map(tool => {
|
||||
const { autoSubmitMockedResponse, ...rest } = tool;
|
||||
return rest;
|
||||
}),
|
||||
tools: mergedTools,
|
||||
prompts: workflow.prompts
|
||||
.map(p => {
|
||||
const { sanitized } = sanitizeTextWithMentions(p.prompt, workflow);
|
||||
const { sanitized } = sanitizeTextWithMentions(p.prompt, workflow, mergedTools);
|
||||
return {
|
||||
...p,
|
||||
prompt: sanitized,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import { z } from "zod";
|
||||
import { MCPServer } from "./types";
|
||||
import { WorkflowTool } from "./workflow_types";
|
||||
|
||||
export const Project = z.object({
|
||||
_id: z.string().uuid(),
|
||||
|
|
@ -28,4 +29,41 @@ export const ApiKey = z.object({
|
|||
key: z.string(),
|
||||
createdAt: z.string().datetime(),
|
||||
lastUsedAt: z.string().datetime().optional(),
|
||||
});
|
||||
});
|
||||
|
||||
export function mergeProjectTools(
|
||||
workflowTools: z.infer<typeof WorkflowTool>[],
|
||||
projectTools: z.infer<typeof WorkflowTool>[]
|
||||
): z.infer<typeof WorkflowTool>[] {
|
||||
// Filter out any existing MCP tools from workflow tools
|
||||
const nonMcpTools = workflowTools.filter(t => !t.isMcp);
|
||||
|
||||
// Merge with MCP tools
|
||||
const merged = [
|
||||
...nonMcpTools,
|
||||
...projectTools.map(tool => ({
|
||||
...tool,
|
||||
isMcp: true as const, // Ensure isMcp is set
|
||||
parameters: {
|
||||
type: 'object' as const,
|
||||
properties: tool.parameters?.properties || {},
|
||||
required: tool.parameters?.required || []
|
||||
}
|
||||
}))
|
||||
];
|
||||
|
||||
console.log('[mergeMcpTools] Merged tools:', {
|
||||
totalCount: merged.length,
|
||||
nonMcpCount: nonMcpTools.length,
|
||||
mcpCount: projectTools.length,
|
||||
tools: merged.map(t => ({
|
||||
name: t.name,
|
||||
isMcp: t.isMcp,
|
||||
hasParams: !!t.parameters,
|
||||
paramCount: t.parameters ? Object.keys(t.parameters.properties).length : 0,
|
||||
parameters: t.parameters
|
||||
}))
|
||||
});
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,79 @@
|
|||
import { CoreMessage, ToolCallPart } from "ai";
|
||||
import { z } from "zod";
|
||||
import { apiV1 } from "rowboat-shared";
|
||||
import { WorkflowTool } from "./workflow_types";
|
||||
|
||||
export const McpToolInputSchema = z.object({
|
||||
type: z.literal('object'),
|
||||
properties: z.record(z.object({
|
||||
type: z.string(),
|
||||
description: z.string(),
|
||||
enum: z.array(z.any()).optional(),
|
||||
default: z.any().optional(),
|
||||
minimum: z.number().optional(),
|
||||
maximum: z.number().optional(),
|
||||
items: z.any().optional(), // For array types
|
||||
format: z.string().optional(),
|
||||
pattern: z.string().optional(),
|
||||
minLength: z.number().optional(),
|
||||
maxLength: z.number().optional(),
|
||||
minItems: z.number().optional(),
|
||||
maxItems: z.number().optional(),
|
||||
uniqueItems: z.boolean().optional(),
|
||||
multipleOf: z.number().optional(),
|
||||
examples: z.array(z.any()).optional(),
|
||||
})).default({}),
|
||||
required: z.array(z.string()).default([]),
|
||||
});
|
||||
|
||||
export const McpServerTool = z.object({
|
||||
name: z.string(),
|
||||
description: z.string().optional(),
|
||||
inputSchema: McpToolInputSchema.optional(),
|
||||
});
|
||||
|
||||
export const McpTool = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
description: z.string(),
|
||||
parameters: z.object({
|
||||
type: z.literal('object'),
|
||||
properties: z.record(z.object({
|
||||
type: z.string(),
|
||||
description: z.string(),
|
||||
})),
|
||||
required: z.array(z.string()).optional(),
|
||||
}).optional(),
|
||||
});
|
||||
|
||||
export const MCPServer = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
url: z.string(),
|
||||
description: z.string(),
|
||||
tools: z.array(McpTool), // Selected tools from MongoDB
|
||||
availableTools: z.array(McpTool).optional(), // Available tools from Klavis
|
||||
isActive: z.boolean().optional(),
|
||||
isReady: z.boolean().optional(),
|
||||
authNeeded: z.boolean().optional(),
|
||||
isAuthenticated: z.boolean().optional(),
|
||||
requiresAuth: z.boolean().optional(),
|
||||
serverUrl: z.string().optional(),
|
||||
instanceId: z.string().optional(),
|
||||
serverName: z.string().optional(),
|
||||
serverType: z.enum(['hosted', 'custom']).optional(),
|
||||
});
|
||||
|
||||
// Minimal MCP server info needed by agents service
|
||||
export const MCPServerMinimal = z.object({
|
||||
name: z.string(),
|
||||
serverUrl: z.string(),
|
||||
isReady: z.boolean().optional(),
|
||||
});
|
||||
|
||||
// Response types for Klavis API
|
||||
export const McpServerResponse = z.object({
|
||||
data: z.array(z.lazy(() => MCPServer)).nullable(),
|
||||
error: z.string().nullable(),
|
||||
});
|
||||
|
||||
export const PlaygroundChat = z.object({
|
||||
|
|
@ -119,3 +188,44 @@ export const ApiResponse = z.object({
|
|||
messages: z.array(ApiMessage),
|
||||
state: z.unknown(),
|
||||
});
|
||||
|
||||
// Helper function to convert MCP server tool to WorkflowTool
|
||||
export function convertMcpServerToolToWorkflowTool(
|
||||
mcpTool: z.infer<typeof McpServerTool>,
|
||||
mcpServer: z.infer<typeof MCPServer>
|
||||
): z.infer<typeof WorkflowTool> {
|
||||
// Parse the input schema, handling both string and object formats
|
||||
let parsedSchema;
|
||||
if (typeof mcpTool.inputSchema === 'string') {
|
||||
try {
|
||||
parsedSchema = JSON.parse(mcpTool.inputSchema);
|
||||
} catch (e) {
|
||||
console.error('Failed to parse inputSchema string:', e);
|
||||
parsedSchema = {
|
||||
type: 'object',
|
||||
properties: {},
|
||||
required: []
|
||||
};
|
||||
}
|
||||
} else {
|
||||
parsedSchema = mcpTool.inputSchema ?? {
|
||||
type: 'object',
|
||||
properties: {},
|
||||
required: []
|
||||
};
|
||||
}
|
||||
|
||||
// Ensure the schema is valid
|
||||
const inputSchema = McpToolInputSchema.parse(parsedSchema);
|
||||
|
||||
const converted = {
|
||||
name: mcpTool.name,
|
||||
description: mcpTool.description ?? "",
|
||||
parameters: inputSchema,
|
||||
isMcp: true,
|
||||
mcpServerName: mcpServer.name,
|
||||
mcpServerURL: mcpServer.serverUrl,
|
||||
};
|
||||
|
||||
return converted;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { z } from "zod";
|
||||
export const WorkflowAgent = z.object({
|
||||
name: z.string(),
|
||||
order: z.number().int().optional(),
|
||||
type: z.union([
|
||||
z.literal('conversation'),
|
||||
z.literal('post_process'),
|
||||
|
|
@ -41,12 +42,31 @@ export const WorkflowTool = z.object({
|
|||
properties: z.record(z.object({
|
||||
type: z.string(),
|
||||
description: z.string(),
|
||||
enum: z.array(z.any()).optional(),
|
||||
default: z.any().optional(),
|
||||
minimum: z.number().optional(),
|
||||
maximum: z.number().optional(),
|
||||
items: z.any().optional(), // For array types
|
||||
format: z.string().optional(),
|
||||
pattern: z.string().optional(),
|
||||
minLength: z.number().optional(),
|
||||
maxLength: z.number().optional(),
|
||||
minItems: z.number().optional(),
|
||||
maxItems: z.number().optional(),
|
||||
uniqueItems: z.boolean().optional(),
|
||||
multipleOf: z.number().optional(),
|
||||
examples: z.array(z.any()).optional(),
|
||||
})),
|
||||
required: z.array(z.string()).optional(),
|
||||
required: z.array(z.string()).default([]),
|
||||
}).default({
|
||||
type: 'object',
|
||||
properties: {},
|
||||
required: [],
|
||||
}),
|
||||
isMcp: z.boolean().default(false).optional(),
|
||||
isLibrary: z.boolean().default(false).optional(),
|
||||
mcpServerName: z.string().optional(),
|
||||
mcpServerURL: z.string().optional(),
|
||||
});
|
||||
export const Workflow = z.object({
|
||||
name: z.string().optional(),
|
||||
|
|
@ -81,6 +101,7 @@ export function sanitizeTextWithMentions(
|
|||
tools: z.infer<typeof WorkflowTool>[],
|
||||
prompts: z.infer<typeof WorkflowPrompt>[],
|
||||
},
|
||||
projectTools: z.infer<typeof WorkflowTool>[] = []
|
||||
): {
|
||||
sanitized: string;
|
||||
entities: z.infer<typeof ConnectedEntity>[];
|
||||
|
|
@ -110,7 +131,8 @@ export function sanitizeTextWithMentions(
|
|||
if (entity.type === 'agent') {
|
||||
return workflow.agents.some(a => a.name === entity.name);
|
||||
} else if (entity.type === 'tool') {
|
||||
return workflow.tools.some(t => t.name === entity.name);
|
||||
return workflow.tools.some(t => t.name === entity.name) ||
|
||||
projectTools.some(t => t.name === entity.name);
|
||||
} else if (entity.type === 'prompt') {
|
||||
return workflow.prompts.some(p => p.name === entity.name);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,32 +2,19 @@
|
|||
|
||||
import { Metadata } from "next";
|
||||
import { Spinner, Textarea, Button, Dropdown, DropdownMenu, DropdownItem, DropdownTrigger, Modal, ModalContent, ModalHeader, ModalBody, ModalFooter, Input, useDisclosure, Divider } from "@heroui/react";
|
||||
import { ReactNode, useEffect, useState, useCallback, useMemo } from "react";
|
||||
import { ReactNode, useEffect, useState } from "react";
|
||||
import { getProjectConfig, updateProjectName, updateWebhookUrl, createApiKey, deleteApiKey, listApiKeys, deleteProject, rotateSecret } from "../../../actions/project_actions";
|
||||
import { updateMcpServers } from "../../../actions/mcp_actions";
|
||||
import { CopyButton } from "../../../../components/common/copy-button";
|
||||
import { EditableField } from "../../../lib/components/editable-field";
|
||||
import { EyeIcon, EyeOffIcon, CopyIcon, MoreVerticalIcon, PlusIcon, EllipsisVerticalIcon, CheckCircleIcon, XCircleIcon } from "lucide-react";
|
||||
import { EyeIcon, EyeOffIcon, Settings, Plus, MoreVertical } from "lucide-react";
|
||||
import { WithStringId } from "../../../lib/types/types";
|
||||
import { ApiKey } from "../../../lib/types/project_types";
|
||||
import { z } from "zod";
|
||||
import { RelativeTime } from "@primer/react";
|
||||
import { Label } from "../../../lib/components/label";
|
||||
import { ListItem } from "../../../lib/components/structured-list";
|
||||
import { FormSection } from "../../../lib/components/form-section";
|
||||
import { StructuredPanel } from "../../../lib/components/structured-panel";
|
||||
import {
|
||||
ResizableHandle,
|
||||
ResizablePanel,
|
||||
ResizablePanelGroup,
|
||||
} from "../../../../components/ui/resizable"
|
||||
import { VoiceSection } from './components/voice';
|
||||
import { ProjectSection } from './components/project';
|
||||
import { ToolsSection } from './components/tools';
|
||||
import { Panel } from "@/components/common/panel-common";
|
||||
import { Settings, Wrench, Phone } from "lucide-react";
|
||||
import { clsx } from "clsx";
|
||||
import { USE_VOICE_FEATURE } from "@/app/lib/feature_flags";
|
||||
import { ProjectSection } from './components/project';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Project config",
|
||||
|
|
@ -120,258 +107,6 @@ export function BasicSettingsSection({
|
|||
</Section>;
|
||||
}
|
||||
|
||||
function McpServersSection({
|
||||
projectId,
|
||||
}: {
|
||||
projectId: string;
|
||||
}) {
|
||||
const [servers, setServers] = useState<Array<{ name: string; url: string }>>([]);
|
||||
const [originalServers, setOriginalServers] = useState<Array<{ name: string; url: string }>>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [message, setMessage] = useState<{ type: 'success' | 'error', text: string } | null>(null);
|
||||
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||
const [newServer, setNewServer] = useState({ name: '', url: '' });
|
||||
const [validationErrors, setValidationErrors] = useState<{
|
||||
name?: string;
|
||||
url?: string;
|
||||
}>({});
|
||||
|
||||
// Load initial servers
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
getProjectConfig(projectId).then((project) => {
|
||||
const initialServers = project.mcpServers || [];
|
||||
setServers(JSON.parse(JSON.stringify(initialServers))); // Deep copy
|
||||
setOriginalServers(JSON.parse(JSON.stringify(initialServers))); // Deep copy
|
||||
setLoading(false);
|
||||
});
|
||||
}, [projectId]);
|
||||
|
||||
// Check if there are unsaved changes by comparing the arrays
|
||||
const hasChanges = useMemo(() => {
|
||||
if (servers.length !== originalServers.length) return true;
|
||||
return servers.some((server, index) => {
|
||||
return server.name !== originalServers[index]?.name ||
|
||||
server.url !== originalServers[index]?.url;
|
||||
});
|
||||
}, [servers, originalServers]);
|
||||
|
||||
const handleAddServer = () => {
|
||||
setNewServer({ name: '', url: '' });
|
||||
setValidationErrors({});
|
||||
onOpen();
|
||||
};
|
||||
|
||||
const handleRemoveServer = (index: number) => {
|
||||
setServers(servers.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const handleCreateServer = () => {
|
||||
// Clear previous validation errors
|
||||
setValidationErrors({});
|
||||
|
||||
const errors: typeof validationErrors = {};
|
||||
|
||||
// Validate name uniqueness
|
||||
if (!newServer.name.trim()) {
|
||||
errors.name = 'Server name is required';
|
||||
} else if (servers.some(s => s.name === newServer.name)) {
|
||||
errors.name = 'Server name must be unique';
|
||||
}
|
||||
|
||||
// Validate URL
|
||||
if (!newServer.url.trim()) {
|
||||
errors.url = 'Server URL is required';
|
||||
} else {
|
||||
try {
|
||||
new URL(newServer.url);
|
||||
} catch {
|
||||
errors.url = 'Invalid URL format';
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(errors).length > 0) {
|
||||
setValidationErrors(errors);
|
||||
return;
|
||||
}
|
||||
|
||||
setServers([...servers, newServer]);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
await updateMcpServers(projectId, servers);
|
||||
setOriginalServers(JSON.parse(JSON.stringify(servers))); // Update original servers after successful save
|
||||
setMessage({ type: 'success', text: 'Servers updated successfully' });
|
||||
setTimeout(() => setMessage(null), 3000);
|
||||
} catch (error) {
|
||||
setMessage({ type: 'error', text: 'Failed to update servers' });
|
||||
}
|
||||
setSaving(false);
|
||||
};
|
||||
|
||||
return <Section title="MCP servers">
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between items-center">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
MCP servers are used to execute MCP tools.
|
||||
</p>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="flat"
|
||||
startContent={<PlusIcon className="w-4 h-4" />}
|
||||
onPress={handleAddServer}
|
||||
>
|
||||
Add Server
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<Spinner size="sm" />
|
||||
) : (
|
||||
<>
|
||||
<div className="space-y-3">
|
||||
{servers.map((server, index) => (
|
||||
<div key={index} className="flex gap-3 items-center p-3 border border-border rounded-md">
|
||||
<div className="flex-1">
|
||||
<div className="font-medium">{server.name}</div>
|
||||
<div className="text-sm text-muted-foreground">{server.url}</div>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
color="danger"
|
||||
variant="light"
|
||||
onPress={() => handleRemoveServer(index)}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
{servers.length === 0 && (
|
||||
<div className="text-center text-muted-foreground p-4">
|
||||
No servers configured
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{hasChanges && (
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
size="sm"
|
||||
color="primary"
|
||||
onPress={handleSave}
|
||||
isLoading={saving}
|
||||
>
|
||||
Save Changes
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{message && (
|
||||
<div className={`text-sm p-2 rounded-md ${
|
||||
message.type === 'success' ? 'bg-green-50 text-green-500' : 'bg-red-50 text-red-500'
|
||||
}`}>
|
||||
{message.text}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<Modal isOpen={isOpen} onClose={onClose}>
|
||||
<ModalContent>
|
||||
<ModalHeader>Add MCP Server</ModalHeader>
|
||||
<ModalBody>
|
||||
<div className="flex flex-col gap-4">
|
||||
<Input
|
||||
label="Server Name"
|
||||
placeholder="Enter server name"
|
||||
value={newServer.name}
|
||||
onChange={(e) => {
|
||||
setNewServer({ ...newServer, name: e.target.value });
|
||||
// Clear name error when user types
|
||||
if (validationErrors.name) {
|
||||
setValidationErrors(prev => ({
|
||||
...prev,
|
||||
name: undefined
|
||||
}));
|
||||
}
|
||||
}}
|
||||
errorMessage={validationErrors.name}
|
||||
isInvalid={!!validationErrors.name}
|
||||
isRequired
|
||||
/>
|
||||
<Input
|
||||
label="SSE URL"
|
||||
placeholder="http://host.docker.internal:8000/sse"
|
||||
value={newServer.url}
|
||||
onChange={(e) => {
|
||||
setNewServer({ ...newServer, url: e.target.value });
|
||||
// Clear URL error when user types
|
||||
if (validationErrors.url) {
|
||||
setValidationErrors(prev => ({
|
||||
...prev,
|
||||
url: undefined
|
||||
}));
|
||||
}
|
||||
}}
|
||||
errorMessage={validationErrors.url}
|
||||
isInvalid={!!validationErrors.url}
|
||||
isRequired
|
||||
/>
|
||||
</div>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button variant="light" onPress={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
color="primary"
|
||||
onPress={handleCreateServer}
|
||||
isDisabled={!newServer.name || !newServer.url}
|
||||
>
|
||||
Add Server
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</div>
|
||||
</Section>;
|
||||
}
|
||||
|
||||
function ApiKeyDisplay({ apiKey }: { apiKey: string }) {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
|
||||
const formattedKey = isVisible ? apiKey : `${apiKey.slice(0, 2)}${'•'.repeat(5)}${apiKey.slice(-2)}`;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="text-sm font-mono break-all">{formattedKey}</div>
|
||||
<div className="flex flex-row gap-2 items-center">
|
||||
<button
|
||||
onClick={() => setIsVisible(!isVisible)}
|
||||
className="text-gray-300 hover:text-gray-700"
|
||||
>
|
||||
{isVisible ? (
|
||||
<EyeOffIcon className="w-4 h-4" />
|
||||
) : (
|
||||
<EyeIcon className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
<CopyButton
|
||||
onCopy={() => {
|
||||
navigator.clipboard.writeText(apiKey);
|
||||
}}
|
||||
label="Copy"
|
||||
successLabel="Copied"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ApiKeysSection({
|
||||
projectId,
|
||||
}: {
|
||||
|
|
@ -453,7 +188,7 @@ export function ApiKeysSection({
|
|||
<Button
|
||||
onPress={handleCreateKey}
|
||||
size="sm"
|
||||
startContent={<PlusIcon className="w-4 h-4" />}
|
||||
startContent={<Plus className="h-4 w-4" />}
|
||||
variant="flat"
|
||||
isDisabled={loading}
|
||||
>
|
||||
|
|
@ -495,7 +230,7 @@ export function ApiKeysSection({
|
|||
<Dropdown>
|
||||
<DropdownTrigger>
|
||||
<button className="text-muted-foreground hover:text-foreground">
|
||||
<EllipsisVerticalIcon size={16} />
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
</button>
|
||||
</DropdownTrigger>
|
||||
<DropdownMenu>
|
||||
|
|
@ -790,53 +525,34 @@ export function DeleteProjectSection({
|
|||
);
|
||||
}
|
||||
|
||||
function NavigationMenu({
|
||||
selected,
|
||||
onSelect
|
||||
}: {
|
||||
selected: string;
|
||||
onSelect: (page: string) => void;
|
||||
}) {
|
||||
const items = [
|
||||
{ id: 'Project', icon: <Settings className="w-4 h-4" /> },
|
||||
{ id: 'Tools', icon: <Wrench className="w-4 h-4" /> },
|
||||
...(USE_VOICE_FEATURE ? [{ id: 'Voice', icon: <Phone className="w-4 h-4" /> }] : [])
|
||||
];
|
||||
|
||||
function ApiKeyDisplay({ apiKey }: { apiKey: string }) {
|
||||
const [isVisible, setIsVisible] = useState(false);
|
||||
|
||||
const formattedKey = isVisible ? apiKey : `${apiKey.slice(0, 2)}${'•'.repeat(5)}${apiKey.slice(-2)}`;
|
||||
|
||||
return (
|
||||
<Panel
|
||||
variant="projects"
|
||||
title={
|
||||
<div className="font-semibold text-zinc-700 dark:text-zinc-300 flex items-center gap-2">
|
||||
<Settings className="w-4 h-4" />
|
||||
<span>Settings</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="flex flex-col">
|
||||
<div className="space-y-1 pb-2">
|
||||
{items.map((item) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className="group flex items-center gap-2 px-2 py-1.5 rounded-md hover:bg-zinc-50 dark:hover:bg-zinc-800"
|
||||
>
|
||||
<button
|
||||
className={clsx(
|
||||
"flex-1 flex items-center gap-2 text-sm text-left",
|
||||
selected === item.id
|
||||
? "text-zinc-900 dark:text-zinc-100"
|
||||
: "text-zinc-600 dark:text-zinc-400"
|
||||
)}
|
||||
onClick={() => onSelect(item.id)}
|
||||
>
|
||||
{item.icon}
|
||||
{item.id}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="text-sm font-mono break-all">{formattedKey}</div>
|
||||
<div className="flex flex-row gap-2 items-center">
|
||||
<button
|
||||
onClick={() => setIsVisible(!isVisible)}
|
||||
className="text-gray-300 hover:text-gray-700"
|
||||
>
|
||||
{isVisible ? (
|
||||
<EyeOffIcon className="w-4 h-4" />
|
||||
) : (
|
||||
<EyeIcon className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
<CopyButton
|
||||
onCopy={() => {
|
||||
navigator.clipboard.writeText(apiKey);
|
||||
}}
|
||||
label="Copy"
|
||||
successLabel="Copied"
|
||||
/>
|
||||
</div>
|
||||
</Panel>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -849,86 +565,26 @@ export function ConfigApp({
|
|||
useChatWidget: boolean;
|
||||
chatWidgetHost: string;
|
||||
}) {
|
||||
const [selectedPage, setSelectedPage] = useState('Project');
|
||||
|
||||
const renderContent = () => {
|
||||
switch (selectedPage) {
|
||||
case 'Project':
|
||||
return (
|
||||
<div className="h-full overflow-auto p-6">
|
||||
<Panel
|
||||
variant="projects"
|
||||
title={
|
||||
<div className="font-semibold text-zinc-700 dark:text-zinc-300 flex items-center gap-2">
|
||||
<Settings className="w-4 h-4" />
|
||||
<span>Project Settings</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="space-y-6">
|
||||
<ProjectSection
|
||||
projectId={projectId}
|
||||
useChatWidget={useChatWidget}
|
||||
chatWidgetHost={chatWidgetHost}
|
||||
/>
|
||||
</div>
|
||||
</Panel>
|
||||
</div>
|
||||
);
|
||||
case 'Tools':
|
||||
return (
|
||||
<div className="h-full overflow-auto p-6">
|
||||
<Panel
|
||||
variant="projects"
|
||||
title={
|
||||
<div className="font-semibold text-zinc-700 dark:text-zinc-300 flex items-center gap-2">
|
||||
<Wrench className="w-4 h-4" />
|
||||
<span>Tools Configuration</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="space-y-6">
|
||||
<ToolsSection projectId={projectId} />
|
||||
</div>
|
||||
</Panel>
|
||||
</div>
|
||||
);
|
||||
case 'Voice':
|
||||
return (
|
||||
<div className="h-full overflow-auto p-6">
|
||||
<Panel
|
||||
variant="projects"
|
||||
title={
|
||||
<div className="font-semibold text-zinc-700 dark:text-zinc-300 flex items-center gap-2">
|
||||
<Phone className="w-4 h-4" />
|
||||
<span>Voice Configuration</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="space-y-6">
|
||||
<VoiceSection projectId={projectId} />
|
||||
</div>
|
||||
</Panel>
|
||||
</div>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<ResizablePanelGroup direction="horizontal" className="h-screen gap-1">
|
||||
<ResizablePanel minSize={10} defaultSize={15} className="p-6">
|
||||
<NavigationMenu
|
||||
selected={selectedPage}
|
||||
onSelect={setSelectedPage}
|
||||
/>
|
||||
</ResizablePanel>
|
||||
<ResizableHandle className="w-[3px] bg-transparent" />
|
||||
<ResizablePanel minSize={20} defaultSize={85}>
|
||||
{renderContent()}
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
<div className="h-full overflow-auto p-6">
|
||||
<Panel
|
||||
variant="projects"
|
||||
title={
|
||||
<div className="font-semibold text-zinc-700 dark:text-zinc-300 flex items-center gap-2">
|
||||
<Settings className="w-4 h-4" />
|
||||
<span>Project Settings</span>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="space-y-6">
|
||||
<ProjectSection
|
||||
projectId={projectId}
|
||||
useChatWidget={useChatWidget}
|
||||
chatWidgetHost={chatWidgetHost}
|
||||
/>
|
||||
</div>
|
||||
</Panel>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -416,27 +416,36 @@ function ChatWidgetSection({ projectId, chatWidgetHost }: { projectId: string, c
|
|||
}
|
||||
|
||||
function DeleteProjectSection({ projectId }: { projectId: string }) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [loadingInitial, setLoadingInitial] = useState(false);
|
||||
const [deletingProject, setDeletingProject] = useState(false);
|
||||
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||
const [projectName, setProjectName] = useState("");
|
||||
const [projectNameInput, setProjectNameInput] = useState("");
|
||||
const [confirmationInput, setConfirmationInput] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const isValid = projectNameInput === projectName && confirmationInput === "delete project";
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
setLoadingInitial(true);
|
||||
getProjectConfig(projectId).then((project) => {
|
||||
setProjectName(project.name);
|
||||
setLoading(false);
|
||||
setLoadingInitial(false);
|
||||
});
|
||||
}, [projectId]);
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!isValid) return;
|
||||
setLoading(true);
|
||||
await deleteProject(projectId);
|
||||
setLoading(false);
|
||||
setError(null);
|
||||
setDeletingProject(true);
|
||||
try {
|
||||
await deleteProject(projectId);
|
||||
} catch (error) {
|
||||
setError(error instanceof Error ? error.message : "Failed to delete project");
|
||||
setDeletingProject(false);
|
||||
return;
|
||||
}
|
||||
setDeletingProject(false);
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
@ -456,8 +465,7 @@ function DeleteProjectSection({ projectId }: { projectId: string }) {
|
|||
variant="primary"
|
||||
size="sm"
|
||||
onClick={onOpen}
|
||||
disabled={loading}
|
||||
isLoading={loading}
|
||||
disabled={loadingInitial}
|
||||
color="red"
|
||||
>
|
||||
Delete project
|
||||
|
|
@ -483,17 +491,27 @@ function DeleteProjectSection({ projectId }: { projectId: string }) {
|
|||
value={confirmationInput}
|
||||
onChange={(e) => setConfirmationInput(e.target.value)}
|
||||
/>
|
||||
{error && (
|
||||
<div className="p-4 text-sm text-red-700 bg-red-50 dark:bg-red-900/10 dark:text-red-400 rounded-lg">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button variant="secondary" onClick={onClose}>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={onClose}
|
||||
disabled={deletingProject}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
color="danger"
|
||||
onClick={handleDelete}
|
||||
disabled={!isValid}
|
||||
disabled={!isValid || deletingProject}
|
||||
isLoading={deletingProject}
|
||||
>
|
||||
Delete Project
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -1,316 +0,0 @@
|
|||
'use client';
|
||||
|
||||
import { useState, useEffect, useMemo } from "react";
|
||||
import { Spinner, Modal, ModalContent, ModalHeader, ModalBody, ModalFooter, useDisclosure } from "@heroui/react";
|
||||
import { getProjectConfig, updateWebhookUrl } from "../../../../actions/project_actions";
|
||||
import { updateMcpServers } from "../../../../actions/mcp_actions";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { PlusIcon } from "lucide-react";
|
||||
import { sectionHeaderStyles, sectionDescriptionStyles, inputStyles } from './shared-styles';
|
||||
import { clsx } from "clsx";
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
function McpServersSection({ projectId }: { projectId: string }) {
|
||||
const [servers, setServers] = useState<Array<{ name: string; url: string }>>([]);
|
||||
const [originalServers, setOriginalServers] = useState<Array<{ name: string; url: string }>>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [message, setMessage] = useState<{ type: 'success' | 'error', text: string } | null>(null);
|
||||
const { isOpen, onOpen, onClose } = useDisclosure();
|
||||
const [newServer, setNewServer] = useState({ name: '', url: '' });
|
||||
const [validationErrors, setValidationErrors] = useState<{
|
||||
name?: string;
|
||||
url?: string;
|
||||
}>({});
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
getProjectConfig(projectId).then((project) => {
|
||||
const initialServers = project.mcpServers || [];
|
||||
setServers(JSON.parse(JSON.stringify(initialServers)));
|
||||
setOriginalServers(JSON.parse(JSON.stringify(initialServers)));
|
||||
setLoading(false);
|
||||
});
|
||||
}, [projectId]);
|
||||
|
||||
const hasChanges = useMemo(() => {
|
||||
if (servers.length !== originalServers.length) return true;
|
||||
return servers.some((server, index) => {
|
||||
return server.name !== originalServers[index]?.name ||
|
||||
server.url !== originalServers[index]?.url;
|
||||
});
|
||||
}, [servers, originalServers]);
|
||||
|
||||
const handleAddServer = () => {
|
||||
setNewServer({ name: '', url: '' });
|
||||
setValidationErrors({});
|
||||
onOpen();
|
||||
};
|
||||
|
||||
const handleRemoveServer = (index: number) => {
|
||||
setServers(servers.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const handleCreateServer = () => {
|
||||
setValidationErrors({});
|
||||
|
||||
const errors: typeof validationErrors = {};
|
||||
|
||||
if (!newServer.name.trim()) {
|
||||
errors.name = 'Server name is required';
|
||||
} else if (servers.some(s => s.name === newServer.name)) {
|
||||
errors.name = 'Server name must be unique';
|
||||
}
|
||||
|
||||
if (!newServer.url.trim()) {
|
||||
errors.url = 'Server URL is required';
|
||||
} else {
|
||||
try {
|
||||
new URL(newServer.url);
|
||||
} catch {
|
||||
errors.url = 'Invalid URL format';
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.keys(errors).length > 0) {
|
||||
setValidationErrors(errors);
|
||||
return;
|
||||
}
|
||||
|
||||
setServers([...servers, newServer]);
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
await updateMcpServers(projectId, servers);
|
||||
setOriginalServers(JSON.parse(JSON.stringify(servers)));
|
||||
setMessage({ type: 'success', text: 'Servers updated successfully' });
|
||||
setTimeout(() => setMessage(null), 3000);
|
||||
} catch (error) {
|
||||
setMessage({ type: 'error', text: 'Failed to update servers' });
|
||||
}
|
||||
setSaving(false);
|
||||
};
|
||||
|
||||
return <Section
|
||||
title="MCP Servers"
|
||||
description="MCP servers are used to execute MCP tools."
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-start">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="primary"
|
||||
onClick={handleAddServer}
|
||||
>
|
||||
+ Add Server
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<Spinner size="sm" />
|
||||
) : (
|
||||
<>
|
||||
<div className="space-y-3">
|
||||
{servers.map((server, index) => (
|
||||
<div key={index} className="flex gap-3 items-center p-4 border border-gray-200 dark:border-gray-700 rounded-lg bg-gray-50 dark:bg-gray-800">
|
||||
<div className="flex-1">
|
||||
<div className="font-medium">{server.name}</div>
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400">{server.url}</div>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => handleRemoveServer(index)}
|
||||
>
|
||||
Remove
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
{servers.length === 0 && (
|
||||
<div className="text-center text-gray-500 dark:text-gray-400 p-6 bg-gray-50 dark:bg-gray-800 rounded-lg border border-dashed border-gray-200 dark:border-gray-700">
|
||||
No servers configured
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{hasChanges && (
|
||||
<div className="flex justify-end pt-4">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="primary"
|
||||
onClick={handleSave}
|
||||
isLoading={saving}
|
||||
>
|
||||
Save Changes
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{message && (
|
||||
<div className={clsx(
|
||||
"mt-4 text-sm p-4 rounded-lg",
|
||||
message.type === 'success'
|
||||
? "bg-green-50 text-green-700 border border-green-200"
|
||||
: "bg-red-50 text-red-700 border border-red-200"
|
||||
)}>
|
||||
{message.text}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<Modal isOpen={isOpen} onClose={onClose}>
|
||||
<ModalContent>
|
||||
<ModalHeader>Add MCP Server</ModalHeader>
|
||||
<ModalBody>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Server Name</label>
|
||||
<Input
|
||||
placeholder="Enter server name"
|
||||
value={newServer.name}
|
||||
onChange={(e) => {
|
||||
setNewServer({ ...newServer, name: e.target.value });
|
||||
if (validationErrors.name) {
|
||||
setValidationErrors(prev => ({
|
||||
...prev,
|
||||
name: undefined
|
||||
}));
|
||||
}
|
||||
}}
|
||||
className={inputStyles}
|
||||
required
|
||||
/>
|
||||
{validationErrors.name && (
|
||||
<p className="text-sm text-red-500">{validationErrors.name}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">SSE URL</label>
|
||||
<Input
|
||||
placeholder="http://host.docker.internal:8000/sse"
|
||||
value={newServer.url}
|
||||
onChange={(e) => {
|
||||
setNewServer({ ...newServer, url: e.target.value });
|
||||
if (validationErrors.url) {
|
||||
setValidationErrors(prev => ({
|
||||
...prev,
|
||||
url: undefined
|
||||
}));
|
||||
}
|
||||
}}
|
||||
className={inputStyles}
|
||||
required
|
||||
/>
|
||||
{validationErrors.url && (
|
||||
<p className="text-sm text-red-500">{validationErrors.url}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button variant="secondary" onClick={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={handleCreateServer}
|
||||
disabled={!newServer.name || !newServer.url}
|
||||
>
|
||||
Add Server
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
</div>
|
||||
</Section>;
|
||||
}
|
||||
|
||||
export function WebhookUrlSection({ projectId }: { projectId: string }) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [webhookUrl, setWebhookUrl] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
getProjectConfig(projectId).then((project) => {
|
||||
setWebhookUrl(project.webhookUrl || null);
|
||||
setLoading(false);
|
||||
});
|
||||
}, [projectId]);
|
||||
|
||||
function validate(url: string) {
|
||||
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 <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
|
||||
/>
|
||||
</div>
|
||||
{error && (
|
||||
<p className="text-sm text-red-500">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
</Section>;
|
||||
}
|
||||
|
||||
export function ToolsSection({ projectId }: { projectId: string }) {
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<McpServersSection projectId={projectId} />
|
||||
<WebhookUrlSection projectId={projectId} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ToolsSection;
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
"use client";
|
||||
import { WithStringId } from "../../../lib/types/types";
|
||||
import { AgenticAPITool } from "../../../lib/types/agents_api_types";
|
||||
import { WorkflowPrompt, WorkflowAgent, Workflow } from "../../../lib/types/workflow_types";
|
||||
import { WorkflowPrompt, WorkflowAgent, Workflow, WorkflowTool } from "../../../lib/types/workflow_types";
|
||||
import { DataSource } from "../../../lib/types/datasource_types";
|
||||
import { z } from "zod";
|
||||
import { PlusIcon, Sparkles, X as XIcon, ChevronDown, ChevronRight, Trash2, Maximize2, Minimize2 } from "lucide-react";
|
||||
|
|
@ -40,6 +40,7 @@ export function AgentConfig({
|
|||
usedAgentNames,
|
||||
agents,
|
||||
tools,
|
||||
projectTools,
|
||||
prompts,
|
||||
dataSources,
|
||||
handleUpdate,
|
||||
|
|
@ -53,6 +54,7 @@ export function AgentConfig({
|
|||
usedAgentNames: Set<string>,
|
||||
agents: z.infer<typeof WorkflowAgent>[],
|
||||
tools: z.infer<typeof AgenticAPITool>[],
|
||||
projectTools: z.infer<typeof WorkflowTool>[],
|
||||
prompts: z.infer<typeof WorkflowPrompt>[],
|
||||
dataSources: WithStringId<z.infer<typeof DataSource>>[],
|
||||
handleUpdate: (agent: z.infer<typeof WorkflowAgent>) => void,
|
||||
|
|
@ -160,7 +162,7 @@ export function AgentConfig({
|
|||
const atMentions = createAtMentions({
|
||||
agents,
|
||||
prompts,
|
||||
tools,
|
||||
tools: [...tools, ...projectTools],
|
||||
currentAgentName: agent.name
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -160,10 +160,38 @@ export function ToolConfig({
|
|||
handleUpdate: (tool: z.infer<typeof WorkflowTool>) => void,
|
||||
handleClose: () => void
|
||||
}) {
|
||||
console.log('[ToolConfig] Received tool data:', {
|
||||
name: tool.name,
|
||||
isMcp: tool.isMcp,
|
||||
fullTool: tool,
|
||||
parameters: tool.parameters,
|
||||
parameterKeys: tool.parameters ? Object.keys(tool.parameters.properties) : [],
|
||||
required: tool.parameters?.required || []
|
||||
});
|
||||
|
||||
const [selectedParams, setSelectedParams] = useState(new Set([]));
|
||||
const isReadOnly = tool.isMcp || tool.isLibrary;
|
||||
const [nameError, setNameError] = useState<string | null>(null);
|
||||
|
||||
// Log when parameters are being rendered
|
||||
useEffect(() => {
|
||||
console.log('[ToolConfig] Processing parameters for render:', {
|
||||
toolName: tool.name,
|
||||
hasParameters: !!tool.parameters,
|
||||
parameterDetails: tool.parameters ? {
|
||||
type: tool.parameters.type,
|
||||
propertyCount: Object.keys(tool.parameters.properties).length,
|
||||
properties: Object.entries(tool.parameters.properties).map(([name, param]) => ({
|
||||
name,
|
||||
type: param.type,
|
||||
description: param.description,
|
||||
isRequired: tool.parameters?.required?.includes(name)
|
||||
})),
|
||||
required: tool.parameters.required
|
||||
} : null
|
||||
});
|
||||
}, [tool.name, tool.parameters]);
|
||||
|
||||
function handleParamRename(oldName: string, newName: string) {
|
||||
const newProperties = { ...tool.parameters!.properties };
|
||||
newProperties[newName] = newProperties[oldName];
|
||||
|
|
@ -231,6 +259,43 @@ export function ToolConfig({
|
|||
return null;
|
||||
}
|
||||
|
||||
// Log parameter rendering in the actual parameter section
|
||||
const renderParameters = () => {
|
||||
if (!tool.parameters?.properties) {
|
||||
console.log('[ToolConfig] No parameters to render');
|
||||
return null;
|
||||
}
|
||||
|
||||
console.log('[ToolConfig] Rendering parameters:', {
|
||||
count: Object.keys(tool.parameters.properties).length,
|
||||
parameters: Object.keys(tool.parameters.properties)
|
||||
});
|
||||
|
||||
return Object.entries(tool.parameters.properties).map(([paramName, param], index) => {
|
||||
console.log('[ToolConfig] Rendering parameter:', {
|
||||
name: paramName,
|
||||
param,
|
||||
isRequired: tool.parameters?.required?.includes(paramName)
|
||||
});
|
||||
|
||||
return (
|
||||
<ParameterConfig
|
||||
key={paramName}
|
||||
param={{
|
||||
name: paramName,
|
||||
description: param.description,
|
||||
type: param.type,
|
||||
required: tool.parameters?.required?.includes(paramName) ?? false
|
||||
}}
|
||||
handleUpdate={handleParamUpdate}
|
||||
handleDelete={handleParamDelete}
|
||||
handleRename={handleParamRename}
|
||||
readOnly={isReadOnly}
|
||||
/>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Panel
|
||||
title={
|
||||
|
|
@ -405,21 +470,7 @@ export function ToolConfig({
|
|||
Parameters
|
||||
</label>
|
||||
<div className="pl-3 space-y-3">
|
||||
{Object.entries(tool.parameters?.properties || {}).map(([paramName, param], index) => (
|
||||
<ParameterConfig
|
||||
key={paramName}
|
||||
param={{
|
||||
name: paramName,
|
||||
description: param.description,
|
||||
type: param.type,
|
||||
required: tool.parameters?.required?.includes(paramName) ?? false
|
||||
}}
|
||||
handleUpdate={handleParamUpdate}
|
||||
handleDelete={handleParamDelete}
|
||||
handleRename={handleParamRename}
|
||||
readOnly={isReadOnly}
|
||||
/>
|
||||
))}
|
||||
{renderParameters()}
|
||||
</div>
|
||||
|
||||
{!isReadOnly && (
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
import { useState, useCallback, useRef } from "react";
|
||||
import { z } from "zod";
|
||||
import { MCPServer, PlaygroundChat } from "@/app/lib/types/types";
|
||||
import { Workflow } from "@/app/lib/types/workflow_types";
|
||||
import { Workflow, WorkflowTool } from "@/app/lib/types/workflow_types";
|
||||
import { Chat } from "./components/chat";
|
||||
import { Panel } from "@/components/common/panel-common";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
|
@ -26,6 +26,7 @@ export function App({
|
|||
toolWebhookUrl,
|
||||
isInitialState = false,
|
||||
onPanelClick,
|
||||
projectTools,
|
||||
}: {
|
||||
hidden?: boolean;
|
||||
projectId: string;
|
||||
|
|
@ -35,6 +36,7 @@ export function App({
|
|||
toolWebhookUrl: string;
|
||||
isInitialState?: boolean;
|
||||
onPanelClick?: () => void;
|
||||
projectTools: z.infer<typeof WorkflowTool>[];
|
||||
}) {
|
||||
const [counter, setCounter] = useState<number>(0);
|
||||
const [testProfile, setTestProfile] = useState<WithStringId<z.infer<typeof TestProfile>> | null>(null);
|
||||
|
|
@ -185,6 +187,7 @@ export function App({
|
|||
toolWebhookUrl={toolWebhookUrl}
|
||||
onCopyClick={(fn) => { getCopyContentRef.current = fn; }}
|
||||
showDebugMessages={showDebugMessages}
|
||||
projectTools={projectTools}
|
||||
/>
|
||||
</div>
|
||||
</Panel>
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import { MCPServer, PlaygroundChat } from "@/app/lib/types/types";
|
|||
import { AgenticAPIChatMessage, convertFromAgenticAPIChatMessages, convertToAgenticAPIChatMessages } from "@/app/lib/types/agents_api_types";
|
||||
import { convertWorkflowToAgenticAPI } from "@/app/lib/types/agents_api_types";
|
||||
import { AgenticAPIChatRequest } from "@/app/lib/types/agents_api_types";
|
||||
import { Workflow } from "@/app/lib/types/workflow_types";
|
||||
import { Workflow, WorkflowTool } from "@/app/lib/types/workflow_types";
|
||||
import { ComposeBoxPlayground } from "@/components/common/compose-box-playground";
|
||||
import { Button } from "@heroui/react";
|
||||
import { apiV1 } from "rowboat-shared";
|
||||
|
|
@ -29,6 +29,7 @@ export function Chat({
|
|||
toolWebhookUrl,
|
||||
onCopyClick,
|
||||
showDebugMessages = true,
|
||||
projectTools,
|
||||
}: {
|
||||
chat: z.infer<typeof PlaygroundChat>;
|
||||
projectId: string;
|
||||
|
|
@ -42,6 +43,7 @@ export function Chat({
|
|||
toolWebhookUrl: string;
|
||||
onCopyClick: (fn: () => string) => void;
|
||||
showDebugMessages?: boolean;
|
||||
projectTools: z.infer<typeof WorkflowTool>[];
|
||||
}) {
|
||||
const [messages, setMessages] = useState<z.infer<typeof apiV1.ChatMessage>[]>(chat.messages);
|
||||
const [loadingAssistantResponse, setLoadingAssistantResponse] = useState<boolean>(false);
|
||||
|
|
@ -125,7 +127,7 @@ export function Chat({
|
|||
setLastAgenticRequest(null);
|
||||
setLastAgenticResponse(null);
|
||||
|
||||
const { agents, tools, prompts, startAgent } = convertWorkflowToAgenticAPI(workflow);
|
||||
const { agents, tools, prompts, startAgent } = convertWorkflowToAgenticAPI(workflow, projectTools);
|
||||
const request: z.infer<typeof AgenticAPIChatRequest> = {
|
||||
projectId,
|
||||
messages: convertToAgenticAPIChatMessages([{
|
||||
|
|
@ -140,7 +142,11 @@ export function Chat({
|
|||
tools,
|
||||
prompts,
|
||||
startAgent,
|
||||
mcpServers: mcpServerUrls,
|
||||
mcpServers: mcpServerUrls.map(server => ({
|
||||
name: server.name,
|
||||
serverUrl: server.serverUrl || '',
|
||||
isReady: server.isReady
|
||||
})),
|
||||
toolWebhookUrl: toolWebhookUrl,
|
||||
testProfile: testProfile ?? undefined,
|
||||
};
|
||||
|
|
@ -258,6 +264,7 @@ export function Chat({
|
|||
toolWebhookUrl,
|
||||
testProfile,
|
||||
fetchResponseError,
|
||||
projectTools,
|
||||
]);
|
||||
|
||||
return <div className="relative max-w-3xl mx-auto h-full flex flex-col">
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
|
||||
export default function OAuthCallback() {
|
||||
useEffect(() => {
|
||||
// Simply close the window - parent will refresh server status
|
||||
if (window.opener) {
|
||||
window.close();
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center">
|
||||
<div className="text-center">
|
||||
<h1 className="text-xl font-semibold mb-4">Completing Authentication</h1>
|
||||
<p className="text-gray-600">Please wait while we complete the authentication process...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
19
apps/rowboat/app/projects/[projectId]/tools/page.tsx
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import { Suspense } from 'react';
|
||||
import { ToolsConfig } from './components/ToolsConfig';
|
||||
import { PageHeader } from '@/components/ui/page-header';
|
||||
|
||||
export default function ToolsPage() {
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<PageHeader
|
||||
title="Tools"
|
||||
description="Configure and manage your project's tool integrations"
|
||||
/>
|
||||
<div className="flex-1 p-6">
|
||||
<Suspense fallback={<div>Loading...</div>}>
|
||||
<ToolsConfig />
|
||||
</Suspense>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -9,8 +9,9 @@ import { WorkflowSelector } from "./workflow_selector";
|
|||
import { Spinner } from "@heroui/react";
|
||||
import { cloneWorkflow, createWorkflow, fetchPublishedWorkflowId, fetchWorkflow } from "../../../actions/workflow_actions";
|
||||
import { listDataSources } from "../../../actions/datasource_actions";
|
||||
import { listMcpServers } from "@/app/actions/mcp_actions";
|
||||
import { listMcpServers, listProjectMcpTools } from "@/app/actions/mcp_actions";
|
||||
import { getProjectConfig } from "@/app/actions/project_actions";
|
||||
import { WorkflowTool } from "@/app/lib/types/workflow_types";
|
||||
|
||||
export function App({
|
||||
projectId,
|
||||
|
|
@ -25,6 +26,7 @@ export function App({
|
|||
const [workflow, setWorkflow] = useState<WithStringId<z.infer<typeof Workflow>> | null>(null);
|
||||
const [publishedWorkflowId, setPublishedWorkflowId] = useState<string | null>(null);
|
||||
const [dataSources, setDataSources] = useState<WithStringId<z.infer<typeof DataSource>>[] | null>(null);
|
||||
const [projectTools, setProjectTools] = useState<z.infer<typeof WorkflowTool>[] | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [autoSelectIfOnlyOneWorkflow, setAutoSelectIfOnlyOneWorkflow] = useState(true);
|
||||
const [mcpServerUrls, setMcpServerUrls] = useState<Array<z.infer<typeof MCPServer>>>([]);
|
||||
|
|
@ -37,6 +39,7 @@ export function App({
|
|||
const dataSources = await listDataSources(projectId);
|
||||
const mcpServers = await listMcpServers(projectId);
|
||||
const projectConfig = await getProjectConfig(projectId);
|
||||
const projectTools = await listProjectMcpTools(projectId);
|
||||
// Store the selected workflow ID in local storage
|
||||
localStorage.setItem(`lastWorkflowId_${projectId}`, workflowId);
|
||||
setWorkflow(workflow);
|
||||
|
|
@ -44,6 +47,7 @@ export function App({
|
|||
setDataSources(dataSources);
|
||||
setMcpServerUrls(mcpServers);
|
||||
setToolWebhookUrl(projectConfig.webhookUrl ?? '');
|
||||
setProjectTools(projectTools);
|
||||
setLoading(false);
|
||||
}, [projectId]);
|
||||
|
||||
|
|
@ -110,10 +114,11 @@ export function App({
|
|||
handleCreateNewVersion={handleCreateNewVersion}
|
||||
autoSelectIfOnlyOneWorkflow={autoSelectIfOnlyOneWorkflow}
|
||||
/>}
|
||||
{!loading && workflow && (dataSources !== null) && <WorkflowEditor
|
||||
{!loading && workflow && (dataSources !== null) && (projectTools !== null) && <WorkflowEditor
|
||||
key={workflow._id}
|
||||
workflow={workflow}
|
||||
dataSources={dataSources}
|
||||
projectTools={projectTools}
|
||||
publishedWorkflowId={publishedWorkflowId}
|
||||
handleShowSelector={handleShowSelector}
|
||||
handleCloneVersion={handleCloneVersion}
|
||||
|
|
|
|||
|
|
@ -1,25 +1,38 @@
|
|||
import { z } from "zod";
|
||||
import { AgenticAPITool } from "../../../lib/types/agents_api_types";
|
||||
import { WorkflowPrompt } from "../../../lib/types/workflow_types";
|
||||
import { WorkflowAgent } from "../../../lib/types/workflow_types";
|
||||
import { WorkflowPrompt, WorkflowAgent, WorkflowTool } from "../../../lib/types/workflow_types";
|
||||
import { Dropdown, DropdownItem, DropdownTrigger, DropdownMenu } from "@heroui/react";
|
||||
import { useRef, useEffect, useState } from "react";
|
||||
import { EllipsisVerticalIcon, ImportIcon, PlusIcon, Brain, Wrench, PenLine } from "lucide-react";
|
||||
import { EllipsisVerticalIcon, ImportIcon, PlusIcon, Brain, Boxes, Wrench, PenLine, Library, ChevronDown, ChevronRight, ServerIcon, Component, ScrollText, GripVertical } from "lucide-react";
|
||||
import { DndContext, DragEndEvent, closestCenter, KeyboardSensor, PointerSensor, useSensor, useSensors } from '@dnd-kit/core';
|
||||
import { SortableContext, sortableKeyboardCoordinates, useSortable, verticalListSortingStrategy } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { Panel } from "@/components/common/panel-common";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { clsx } from "clsx";
|
||||
import { ResizablePanelGroup, ResizablePanel, ResizableHandle } from "@/components/ui/resizable";
|
||||
import { ServerLogo } from '../tools/components/MCPServersCommon';
|
||||
|
||||
const SECTION_HEIGHT_PERCENTAGES = {
|
||||
AGENTS: 40, // 50% of available height
|
||||
TOOLS: 30, // 30% of available height
|
||||
PROMPTS: 30, // 20% of available height
|
||||
// Reduced gap size to match Cursor's UI
|
||||
const GAP_SIZE = 4; // 1 unit * 4px (tailwind's default spacing unit)
|
||||
|
||||
// Panel height ratios
|
||||
const PANEL_RATIOS = {
|
||||
expanded: {
|
||||
agents: 50,
|
||||
tools: 50,
|
||||
prompts: 20
|
||||
}
|
||||
} as const;
|
||||
|
||||
const GAP_SIZE = 24; // 6 units * 4px (tailwind's default spacing unit)
|
||||
// Common classes
|
||||
const headerClasses = "font-semibold text-zinc-700 dark:text-zinc-300 flex items-center justify-between w-full";
|
||||
const buttonClasses = "text-sm px-3 py-1.5 bg-indigo-50 hover:bg-indigo-100 text-indigo-700 dark:bg-indigo-950 dark:hover:bg-indigo-900 dark:text-indigo-400";
|
||||
|
||||
interface EntityListProps {
|
||||
agents: z.infer<typeof WorkflowAgent>[];
|
||||
tools: z.infer<typeof AgenticAPITool>[];
|
||||
tools: z.infer<typeof WorkflowTool>[];
|
||||
projectTools: z.infer<typeof WorkflowTool>[];
|
||||
prompts: z.infer<typeof WorkflowPrompt>[];
|
||||
selectedEntity: {
|
||||
type: "agent" | "tool" | "prompt";
|
||||
|
|
@ -30,23 +43,23 @@ interface EntityListProps {
|
|||
onSelectTool: (name: string) => void;
|
||||
onSelectPrompt: (name: string) => void;
|
||||
onAddAgent: (agent: Partial<z.infer<typeof WorkflowAgent>>) => void;
|
||||
onAddTool: (tool: Partial<z.infer<typeof AgenticAPITool>>) => void;
|
||||
onAddTool: (tool: Partial<z.infer<typeof WorkflowTool>>) => void;
|
||||
onAddPrompt: (prompt: Partial<z.infer<typeof WorkflowPrompt>>) => void;
|
||||
onToggleAgent: (name: string) => void;
|
||||
onSetMainAgent: (name: string) => void;
|
||||
onDeleteAgent: (name: string) => void;
|
||||
onDeleteTool: (name: string) => void;
|
||||
onDeletePrompt: (name: string) => void;
|
||||
triggerMcpImport: () => void;
|
||||
}
|
||||
|
||||
interface EmptyStateProps {
|
||||
entity: string;
|
||||
hasFilteredItems: boolean;
|
||||
}
|
||||
|
||||
const EmptyState: React.FC<EmptyStateProps> = ({ entity }) => (
|
||||
const EmptyState: React.FC<EmptyStateProps> = ({ entity, hasFilteredItems }) => (
|
||||
<div className="flex items-center justify-center h-24 text-sm text-zinc-400 dark:text-zinc-500">
|
||||
No {entity} created
|
||||
{hasFilteredItems ? "No tools to show" : `No ${entity} created`}
|
||||
</div>
|
||||
);
|
||||
|
||||
|
|
@ -59,6 +72,9 @@ const ListItemWithMenu = ({
|
|||
menuContent,
|
||||
statusLabel,
|
||||
icon,
|
||||
iconClassName,
|
||||
mcpServerName,
|
||||
dragHandle,
|
||||
}: {
|
||||
name: string;
|
||||
isSelected?: boolean;
|
||||
|
|
@ -68,37 +84,53 @@ const ListItemWithMenu = ({
|
|||
menuContent: React.ReactNode;
|
||||
statusLabel?: React.ReactNode;
|
||||
icon?: React.ReactNode;
|
||||
}) => (
|
||||
<div className={clsx(
|
||||
"group flex items-center gap-2 px-2 py-1.5 rounded-md",
|
||||
{
|
||||
"bg-indigo-50 dark:bg-indigo-950/30": isSelected,
|
||||
"hover:bg-zinc-50 dark:hover:bg-zinc-800": !isSelected
|
||||
}
|
||||
)}>
|
||||
<button
|
||||
ref={selectedRef}
|
||||
className={clsx(
|
||||
"flex-1 flex items-center gap-2 text-sm text-left",
|
||||
{
|
||||
"text-zinc-900 dark:text-zinc-100": !disabled,
|
||||
"text-zinc-400 dark:text-zinc-600": disabled,
|
||||
}
|
||||
)}
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
>
|
||||
{icon}
|
||||
{name}
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
{statusLabel}
|
||||
<div className="opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
{menuContent}
|
||||
iconClassName?: string;
|
||||
mcpServerName?: string;
|
||||
dragHandle?: React.ReactNode;
|
||||
}) => {
|
||||
return (
|
||||
<div className={clsx(
|
||||
"group flex items-center gap-2 px-2 py-1.5 rounded-md",
|
||||
{
|
||||
"bg-indigo-50 dark:bg-indigo-950/30": isSelected,
|
||||
"hover:bg-zinc-50 dark:hover:bg-zinc-800": !isSelected
|
||||
}
|
||||
)}>
|
||||
{dragHandle}
|
||||
<button
|
||||
ref={selectedRef}
|
||||
className={clsx(
|
||||
"flex-1 flex items-center gap-2 text-sm text-left",
|
||||
{
|
||||
"text-zinc-900 dark:text-zinc-100": !disabled,
|
||||
"text-zinc-400 dark:text-zinc-600": disabled,
|
||||
}
|
||||
)}
|
||||
onClick={() => {
|
||||
onClick?.();
|
||||
}}
|
||||
disabled={disabled}
|
||||
>
|
||||
<div className={clsx("flex-shrink-0 flex items-center justify-center w-4 h-4", iconClassName)}>
|
||||
{mcpServerName ? (
|
||||
<ServerLogo
|
||||
serverName={mcpServerName}
|
||||
className="h-4 w-4"
|
||||
fallback={<ImportIcon className="w-4 h-4 text-blue-600 dark:text-blue-500" />}
|
||||
/>
|
||||
) : icon}
|
||||
</div>
|
||||
{name}
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
{statusLabel}
|
||||
<div className="opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
{menuContent}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
);
|
||||
};
|
||||
|
||||
const StartLabel = () => (
|
||||
<div className="text-xs text-indigo-500 dark:text-indigo-400 bg-indigo-50/50 dark:bg-indigo-950/30 px-1.5 py-0.5 rounded">
|
||||
|
|
@ -106,9 +138,77 @@ const StartLabel = () => (
|
|||
</div>
|
||||
);
|
||||
|
||||
interface ServerCardProps {
|
||||
serverName: string;
|
||||
tools: z.infer<typeof WorkflowTool>[];
|
||||
selectedEntity: {
|
||||
type: "agent" | "tool" | "prompt";
|
||||
name: string;
|
||||
} | null;
|
||||
onSelectTool: (name: string) => void;
|
||||
onDeleteTool: (name: string) => void;
|
||||
selectedRef: React.RefObject<HTMLButtonElement>;
|
||||
}
|
||||
|
||||
const ServerCard = ({
|
||||
serverName,
|
||||
tools,
|
||||
selectedEntity,
|
||||
onSelectTool,
|
||||
onDeleteTool,
|
||||
selectedRef,
|
||||
}: ServerCardProps) => {
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
return (
|
||||
<div className="mb-2">
|
||||
<button
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
className="w-full flex items-center gap-2 px-2 py-1.5 hover:bg-zinc-50 dark:hover:bg-zinc-800 rounded-md text-sm text-left"
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="w-4 h-4 text-gray-500" />
|
||||
) : (
|
||||
<ChevronRight className="w-4 h-4 text-gray-500" />
|
||||
)}
|
||||
<div className="flex items-center gap-1">
|
||||
<ServerLogo
|
||||
serverName={serverName}
|
||||
className="h-4 w-4"
|
||||
fallback={<ImportIcon className="w-4 h-4 text-blue-600 dark:text-blue-500" />}
|
||||
/>
|
||||
<span>{serverName}</span>
|
||||
</div>
|
||||
</button>
|
||||
{isExpanded && (
|
||||
<div className="ml-6 mt-1 space-y-1">
|
||||
{tools.map((tool, index) => (
|
||||
<ListItemWithMenu
|
||||
key={`tool-${index}`}
|
||||
name={tool.name}
|
||||
isSelected={selectedEntity?.type === "tool" && selectedEntity.name === tool.name}
|
||||
onClick={() => onSelectTool(tool.name)}
|
||||
selectedRef={selectedEntity?.type === "tool" && selectedEntity.name === tool.name ? selectedRef : undefined}
|
||||
mcpServerName={serverName}
|
||||
menuContent={
|
||||
<EntityDropdown
|
||||
name={tool.name}
|
||||
onDelete={onDeleteTool}
|
||||
isLocked={tool.isMcp || tool.isLibrary}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export function EntityList({
|
||||
agents,
|
||||
tools,
|
||||
projectTools,
|
||||
prompts,
|
||||
selectedEntity,
|
||||
startAgentName,
|
||||
|
|
@ -123,13 +223,57 @@ export function EntityList({
|
|||
onDeleteAgent,
|
||||
onDeleteTool,
|
||||
onDeletePrompt,
|
||||
triggerMcpImport,
|
||||
}: EntityListProps) {
|
||||
projectId,
|
||||
onReorderAgents,
|
||||
}: EntityListProps & {
|
||||
projectId: string,
|
||||
onReorderAgents: (agents: z.infer<typeof WorkflowAgent>[]) => void
|
||||
}) {
|
||||
// Merge workflow tools with project tools
|
||||
const mergedTools = [...tools, ...projectTools];
|
||||
const selectedRef = useRef<HTMLButtonElement | null>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [containerHeight, setContainerHeight] = useState<number>(0);
|
||||
const headerClasses = "font-semibold text-zinc-700 dark:text-zinc-300 flex items-center justify-between w-full";
|
||||
const buttonClasses = "text-sm px-3 py-1.5 bg-indigo-50 hover:bg-indigo-100 text-indigo-700 dark:bg-indigo-950 dark:hover:bg-indigo-900 dark:text-indigo-400";
|
||||
|
||||
// Panel expansion states
|
||||
const [expandedPanels, setExpandedPanels] = useState({
|
||||
agents: true,
|
||||
tools: true,
|
||||
prompts: false
|
||||
});
|
||||
|
||||
// Default sizes when panels are expanded
|
||||
const DEFAULT_SIZES = {
|
||||
agents: 40,
|
||||
tools: 40,
|
||||
prompts: 20
|
||||
};
|
||||
|
||||
// Calculate panel sizes based on expanded state
|
||||
const getPanelSize = (panelName: 'agents' | 'tools' | 'prompts') => {
|
||||
if (!expandedPanels[panelName]) {
|
||||
return 8; // Collapsed height (53px equivalent)
|
||||
}
|
||||
|
||||
// Base size when expanded
|
||||
let size = DEFAULT_SIZES[panelName];
|
||||
|
||||
// Redistribute space from collapsed panels to the panel above
|
||||
if (panelName === 'agents') {
|
||||
if (!expandedPanels.tools) {
|
||||
size += DEFAULT_SIZES.tools;
|
||||
}
|
||||
if (!expandedPanels.prompts) {
|
||||
size += DEFAULT_SIZES.prompts;
|
||||
}
|
||||
} else if (panelName === 'tools') {
|
||||
if (!expandedPanels.prompts && expandedPanels.agents) {
|
||||
size += DEFAULT_SIZES.prompts;
|
||||
}
|
||||
}
|
||||
|
||||
return size;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const updateHeight = () => {
|
||||
|
|
@ -149,187 +293,326 @@ export function EntityList({
|
|||
}
|
||||
}, [selectedEntity]);
|
||||
|
||||
const calculateSectionHeight = (percentage: number) => {
|
||||
// Total gaps = 2 gaps between 3 sections
|
||||
const totalGaps = GAP_SIZE * 2;
|
||||
const availableHeight = containerHeight - totalGaps;
|
||||
return `${(availableHeight * percentage) / 100}px`;
|
||||
function handleToolSelection(name: string) {
|
||||
onSelectTool(name);
|
||||
}
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor),
|
||||
useSensor(KeyboardSensor, {
|
||||
coordinateGetter: sortableKeyboardCoordinates,
|
||||
})
|
||||
);
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent) => {
|
||||
const { active, over } = event;
|
||||
|
||||
if (over && active.id !== over.id) {
|
||||
const oldIndex = agents.findIndex(agent => agent.name === active.id);
|
||||
const newIndex = agents.findIndex(agent => agent.name === over.id);
|
||||
|
||||
const newAgents = [...agents];
|
||||
const [movedAgent] = newAgents.splice(oldIndex, 1);
|
||||
newAgents.splice(newIndex, 0, movedAgent);
|
||||
|
||||
// Update order numbers
|
||||
const updatedAgents = newAgents.map((agent, index) => ({
|
||||
...agent,
|
||||
order: index * 100
|
||||
}));
|
||||
|
||||
onReorderAgents(updatedAgents);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={containerRef} className="flex flex-col h-full">
|
||||
<div className="flex flex-col gap-6 h-full flex-1">
|
||||
<ResizablePanelGroup
|
||||
direction="vertical"
|
||||
className="h-full"
|
||||
style={{ gap: `${GAP_SIZE}px` }}
|
||||
>
|
||||
{/* Agents Panel */}
|
||||
<Panel variant="projects"
|
||||
tourTarget="entity-agents"
|
||||
title={
|
||||
<div className={headerClasses}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Brain className="w-4 h-4" />
|
||||
<span>Agents</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => onAddAgent({})}
|
||||
className={`group ${buttonClasses}`}
|
||||
showHoverContent={true}
|
||||
hoverContent="Add Agent"
|
||||
>
|
||||
<PlusIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
maxHeight={calculateSectionHeight(SECTION_HEIGHT_PERCENTAGES.AGENTS)}
|
||||
className="overflow-hidden flex-[50]"
|
||||
<ResizablePanel
|
||||
defaultSize={getPanelSize('agents')}
|
||||
minSize={expandedPanels.agents ? 20 : 8}
|
||||
maxSize={100}
|
||||
>
|
||||
<div className="flex flex-col h-full overflow-y-auto">
|
||||
{agents.length > 0 ? (
|
||||
<div className="space-y-1 pb-2">
|
||||
{agents.map((agent, index) => (
|
||||
<ListItemWithMenu
|
||||
key={`agent-${index}`}
|
||||
name={agent.name}
|
||||
isSelected={selectedEntity?.type === "agent" && selectedEntity.name === agent.name}
|
||||
onClick={() => onSelectAgent(agent.name)}
|
||||
disabled={agent.disabled}
|
||||
selectedRef={selectedEntity?.type === "agent" && selectedEntity.name === agent.name ? selectedRef : undefined}
|
||||
statusLabel={startAgentName === agent.name ? <StartLabel /> : null}
|
||||
menuContent={
|
||||
<AgentDropdown
|
||||
agent={agent}
|
||||
isStartAgent={startAgentName === agent.name}
|
||||
onToggle={onToggleAgent}
|
||||
onSetMainAgent={onSetMainAgent}
|
||||
onDelete={onDeleteAgent}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState entity="agents" />
|
||||
<Panel
|
||||
variant="entity-list"
|
||||
tourTarget="entity-agents"
|
||||
className={clsx(
|
||||
"h-full overflow-hidden",
|
||||
!expandedPanels.agents && "!h-[53px]"
|
||||
)}
|
||||
</div>
|
||||
</Panel>
|
||||
title={
|
||||
<button
|
||||
onClick={() => setExpandedPanels(prev => ({ ...prev, agents: !prev.agents }))}
|
||||
className={`${headerClasses} hover:bg-zinc-50 dark:hover:bg-zinc-800 rounded-md transition-colors h-full`}
|
||||
>
|
||||
<div className="flex items-center gap-2 h-full">
|
||||
{expandedPanels.agents ? (
|
||||
<ChevronDown className="w-4 h-4" />
|
||||
) : (
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
)}
|
||||
<Brain className="w-4 h-4" />
|
||||
<span>Agents</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setExpandedPanels(prev => ({ ...prev, agents: true }));
|
||||
onAddAgent({});
|
||||
}}
|
||||
className={`group ${buttonClasses}`}
|
||||
showHoverContent={true}
|
||||
hoverContent="Add Agent"
|
||||
>
|
||||
<PlusIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
</button>
|
||||
}
|
||||
>
|
||||
{expandedPanels.agents && (
|
||||
<div className="h-[calc(100%-53px)] overflow-y-auto">
|
||||
<div className="p-2">
|
||||
{agents.length > 0 ? (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext
|
||||
items={agents.map(a => a.name)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<div className="space-y-1">
|
||||
{agents.map((agent) => (
|
||||
<SortableAgentItem
|
||||
key={agent.name}
|
||||
agent={agent}
|
||||
isSelected={selectedEntity?.type === "agent" && selectedEntity.name === agent.name}
|
||||
onClick={() => onSelectAgent(agent.name)}
|
||||
selectedRef={selectedEntity?.type === "agent" && selectedEntity.name === agent.name ? selectedRef : undefined}
|
||||
statusLabel={startAgentName === agent.name ? <StartLabel /> : null}
|
||||
onToggle={onToggleAgent}
|
||||
onSetMainAgent={onSetMainAgent}
|
||||
onDelete={onDeleteAgent}
|
||||
isStartAgent={startAgentName === agent.name}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
) : (
|
||||
<EmptyState entity="agents" hasFilteredItems={false} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Panel>
|
||||
</ResizablePanel>
|
||||
|
||||
<ResizableHandle withHandle className="w-[3px] bg-transparent" />
|
||||
|
||||
{/* Tools Panel */}
|
||||
<Panel variant="projects"
|
||||
tourTarget="entity-tools"
|
||||
title={
|
||||
<div className={headerClasses}>
|
||||
<div className="flex items-center gap-2">
|
||||
<Wrench className="w-4 h-4" />
|
||||
<span>Tools</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<ResizablePanel
|
||||
defaultSize={getPanelSize('tools')}
|
||||
minSize={expandedPanels.tools ? 20 : 8}
|
||||
maxSize={100}
|
||||
>
|
||||
<Panel
|
||||
variant="entity-list"
|
||||
tourTarget="entity-tools"
|
||||
className={clsx(
|
||||
"h-full overflow-hidden",
|
||||
!expandedPanels.tools && "!h-[53px]"
|
||||
)}
|
||||
title={
|
||||
<button
|
||||
onClick={() => setExpandedPanels(prev => ({ ...prev, tools: !prev.tools }))}
|
||||
className={`${headerClasses} hover:bg-zinc-50 dark:hover:bg-zinc-800 rounded-md transition-colors h-full`}
|
||||
>
|
||||
<div className="flex items-center gap-2 h-full">
|
||||
{expandedPanels.tools ? (
|
||||
<ChevronDown className="w-4 h-4" />
|
||||
) : (
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
)}
|
||||
<Wrench className="w-4 h-4" />
|
||||
<span>Tools</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={triggerMcpImport}
|
||||
className={buttonClasses}
|
||||
showHoverContent={true}
|
||||
hoverContent="Import from MCP"
|
||||
>
|
||||
<ImportIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => onAddTool({
|
||||
mockTool: true,
|
||||
parameters: {
|
||||
type: 'object',
|
||||
properties: {}
|
||||
}
|
||||
})}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setExpandedPanels(prev => ({ ...prev, tools: true }));
|
||||
onAddTool({});
|
||||
}}
|
||||
className={`group ${buttonClasses}`}
|
||||
showHoverContent={true}
|
||||
hoverContent="Add Tool"
|
||||
>
|
||||
<PlusIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
</button>
|
||||
}
|
||||
>
|
||||
{expandedPanels.tools && (
|
||||
<div className="h-full overflow-y-auto">
|
||||
<div className="p-2">
|
||||
{mergedTools.length > 0 ? (
|
||||
<div className="space-y-1">
|
||||
{/* Group tools by server */}
|
||||
{(() => {
|
||||
// Get custom tools (non-MCP tools)
|
||||
const customTools = mergedTools.filter(tool => !tool.isMcp);
|
||||
|
||||
// Group MCP tools by server
|
||||
const serverTools = mergedTools.reduce((acc, tool) => {
|
||||
if (tool.isMcp && tool.mcpServerName) {
|
||||
if (!acc[tool.mcpServerName]) {
|
||||
acc[tool.mcpServerName] = [];
|
||||
}
|
||||
acc[tool.mcpServerName].push(tool);
|
||||
}
|
||||
return acc;
|
||||
}, {} as Record<string, typeof mergedTools>);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Show MCP server cards first */}
|
||||
{Object.entries(serverTools).map(([serverName, tools]) => (
|
||||
<ServerCard
|
||||
key={serverName}
|
||||
serverName={serverName}
|
||||
tools={tools}
|
||||
selectedEntity={selectedEntity}
|
||||
onSelectTool={handleToolSelection}
|
||||
onDeleteTool={onDeleteTool}
|
||||
selectedRef={selectedRef}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Show custom tools */}
|
||||
{customTools.length > 0 && (
|
||||
<div className="mt-2">
|
||||
{customTools.map((tool, index) => (
|
||||
<ListItemWithMenu
|
||||
key={`custom-tool-${index}`}
|
||||
name={tool.name}
|
||||
isSelected={selectedEntity?.type === "tool" && selectedEntity.name === tool.name}
|
||||
onClick={() => handleToolSelection(tool.name)}
|
||||
selectedRef={selectedEntity?.type === "tool" && selectedEntity.name === tool.name ? selectedRef : undefined}
|
||||
icon={<Boxes className="w-4 h-4 text-blue-600/70 dark:text-blue-500/70" />}
|
||||
menuContent={
|
||||
<EntityDropdown
|
||||
name={tool.name}
|
||||
onDelete={onDeleteTool}
|
||||
isLocked={tool.isLibrary}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState
|
||||
entity="tools"
|
||||
hasFilteredItems={false}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
maxHeight={calculateSectionHeight(SECTION_HEIGHT_PERCENTAGES.TOOLS)}
|
||||
className="overflow-hidden flex-[30]"
|
||||
>
|
||||
<div className="flex flex-col h-full overflow-y-auto">
|
||||
{tools.length > 0 ? (
|
||||
<div className="space-y-1 pb-2">
|
||||
{tools.map((tool, index) => (
|
||||
<ListItemWithMenu
|
||||
key={`tool-${index}`}
|
||||
name={tool.name}
|
||||
isSelected={selectedEntity?.type === "tool" && selectedEntity.name === tool.name}
|
||||
onClick={() => onSelectTool(tool.name)}
|
||||
selectedRef={selectedEntity?.type === "tool" && selectedEntity.name === tool.name ? selectedRef : undefined}
|
||||
icon={tool.isMcp ? <ImportIcon className="w-4 h-4 text-blue-700" /> : undefined}
|
||||
menuContent={
|
||||
<EntityDropdown
|
||||
name={tool.name}
|
||||
onDelete={onDeleteTool}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState entity="tools" />
|
||||
)}
|
||||
</div>
|
||||
</Panel>
|
||||
</Panel>
|
||||
</ResizablePanel>
|
||||
|
||||
<ResizableHandle withHandle className="w-[3px] bg-transparent" />
|
||||
|
||||
{/* Prompts Panel */}
|
||||
<Panel variant="projects"
|
||||
tourTarget="entity-prompts"
|
||||
title={
|
||||
<div className={headerClasses}>
|
||||
<div className="flex items-center gap-2">
|
||||
<PenLine className="w-4 h-4" />
|
||||
<span>Prompts</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => onAddPrompt({})}
|
||||
className={`group ${buttonClasses}`}
|
||||
showHoverContent={true}
|
||||
hoverContent="Add Prompt"
|
||||
>
|
||||
<PlusIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
}
|
||||
maxHeight={calculateSectionHeight(SECTION_HEIGHT_PERCENTAGES.PROMPTS)}
|
||||
className="overflow-hidden flex-[20]"
|
||||
<ResizablePanel
|
||||
defaultSize={getPanelSize('prompts')}
|
||||
minSize={expandedPanels.prompts ? 20 : 8}
|
||||
maxSize={100}
|
||||
>
|
||||
<div className="flex flex-col h-full overflow-y-auto">
|
||||
{prompts.length > 0 ? (
|
||||
<div className="space-y-1 pb-2">
|
||||
{prompts.map((prompt, index) => (
|
||||
<ListItemWithMenu
|
||||
key={`prompt-${index}`}
|
||||
name={prompt.name}
|
||||
isSelected={selectedEntity?.type === "prompt" && selectedEntity.name === prompt.name}
|
||||
onClick={() => onSelectPrompt(prompt.name)}
|
||||
selectedRef={selectedEntity?.type === "prompt" && selectedEntity.name === prompt.name ? selectedRef : undefined}
|
||||
menuContent={
|
||||
<EntityDropdown
|
||||
name={prompt.name}
|
||||
onDelete={onDeletePrompt}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState entity="prompts" />
|
||||
<Panel
|
||||
variant="entity-list"
|
||||
tourTarget="entity-prompts"
|
||||
className={clsx(
|
||||
"h-full overflow-hidden",
|
||||
!expandedPanels.prompts && "!h-[53px]"
|
||||
)}
|
||||
</div>
|
||||
</Panel>
|
||||
</div>
|
||||
title={
|
||||
<button
|
||||
onClick={() => setExpandedPanels(prev => ({ ...prev, prompts: !prev.prompts }))}
|
||||
className={`${headerClasses} hover:bg-zinc-50 dark:hover:bg-zinc-800 rounded-md transition-colors h-full`}
|
||||
>
|
||||
<div className="flex items-center gap-2 h-full">
|
||||
{expandedPanels.prompts ? (
|
||||
<ChevronDown className="w-4 h-4" />
|
||||
) : (
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
)}
|
||||
<PenLine className="w-4 h-4" />
|
||||
<span>Prompts</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setExpandedPanels(prev => ({ ...prev, prompts: true }));
|
||||
onAddPrompt({});
|
||||
}}
|
||||
className={`group ${buttonClasses}`}
|
||||
showHoverContent={true}
|
||||
hoverContent="Add Prompt"
|
||||
>
|
||||
<PlusIcon className="w-4 h-4" />
|
||||
</Button>
|
||||
</button>
|
||||
}
|
||||
>
|
||||
{expandedPanels.prompts && (
|
||||
<div className="h-[calc(100%-53px)] overflow-y-auto">
|
||||
<div className="p-2">
|
||||
{prompts.length > 0 ? (
|
||||
<div className="space-y-1">
|
||||
{prompts.map((prompt, index) => (
|
||||
<ListItemWithMenu
|
||||
key={`prompt-${index}`}
|
||||
name={prompt.name}
|
||||
isSelected={selectedEntity?.type === "prompt" && selectedEntity.name === prompt.name}
|
||||
onClick={() => onSelectPrompt(prompt.name)}
|
||||
selectedRef={selectedEntity?.type === "prompt" && selectedEntity.name === prompt.name ? selectedRef : undefined}
|
||||
icon={<ScrollText className="w-4 h-4 text-blue-600/70 dark:text-blue-500/70" />}
|
||||
menuContent={
|
||||
<EntityDropdown
|
||||
name={prompt.name}
|
||||
onDelete={onDeletePrompt}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<EmptyState entity="prompts" hasFilteredItems={false} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Panel>
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -382,10 +665,12 @@ function AgentDropdown({
|
|||
|
||||
function EntityDropdown({
|
||||
name,
|
||||
onDelete
|
||||
onDelete,
|
||||
isLocked,
|
||||
}: {
|
||||
name: string;
|
||||
onDelete: (name: string) => void;
|
||||
isLocked?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<Dropdown>
|
||||
|
|
@ -393,6 +678,7 @@ function EntityDropdown({
|
|||
<EllipsisVerticalIcon size={16} />
|
||||
</DropdownTrigger>
|
||||
<DropdownMenu
|
||||
disabledKeys={isLocked ? ['delete'] : []}
|
||||
onAction={(key) => {
|
||||
if (key === 'delete') {
|
||||
onDelete(name);
|
||||
|
|
@ -403,4 +689,60 @@ function EntityDropdown({
|
|||
</DropdownMenu>
|
||||
</Dropdown>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Add SortableItem component for agents
|
||||
const SortableAgentItem = ({ agent, isSelected, onClick, selectedRef, statusLabel, onToggle, onSetMainAgent, onDelete, isStartAgent }: {
|
||||
agent: z.infer<typeof WorkflowAgent>;
|
||||
isSelected?: boolean;
|
||||
onClick?: () => void;
|
||||
selectedRef?: React.RefObject<HTMLButtonElement>;
|
||||
statusLabel?: React.ReactNode;
|
||||
onToggle: (name: string) => void;
|
||||
onSetMainAgent: (name: string) => void;
|
||||
onDelete: (name: string) => void;
|
||||
isStartAgent: boolean;
|
||||
}) => {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging
|
||||
} = useSortable({ id: agent.name });
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.5 : 1,
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={setNodeRef} style={style} {...attributes}>
|
||||
<ListItemWithMenu
|
||||
name={agent.name}
|
||||
isSelected={isSelected}
|
||||
onClick={onClick}
|
||||
disabled={agent.disabled}
|
||||
selectedRef={selectedRef}
|
||||
statusLabel={statusLabel}
|
||||
icon={<Component className="w-4 h-4 text-blue-600/70 dark:text-blue-500/70" />}
|
||||
dragHandle={
|
||||
<button className="cursor-grab" {...listeners}>
|
||||
<GripVertical className="w-4 h-4 text-gray-400" />
|
||||
</button>
|
||||
}
|
||||
menuContent={
|
||||
<AgentDropdown
|
||||
agent={agent}
|
||||
isStartAgent={isStartAgent}
|
||||
onToggle={onToggle}
|
||||
onSetMainAgent={onSetMainAgent}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
@ -1,151 +0,0 @@
|
|||
"use client";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Button, Spinner, Modal, ModalContent, ModalHeader, ModalBody, ModalFooter, Checkbox } from "@heroui/react";
|
||||
import { z } from "zod";
|
||||
import { WorkflowTool } from "@/app/lib/types/workflow_types";
|
||||
import { RefreshCwIcon } from "lucide-react";
|
||||
import { fetchMcpTools } from "@/app/actions/mcp_actions";
|
||||
|
||||
interface McpImportToolsProps {
|
||||
projectId: string;
|
||||
isOpen: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onImport: (tools: z.infer<typeof WorkflowTool>[]) => void;
|
||||
}
|
||||
|
||||
export function McpImportTools({ projectId, isOpen, onOpenChange, onImport }: McpImportToolsProps) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [tools, setTools] = useState<z.infer<typeof WorkflowTool>[]>([]);
|
||||
const [selectedTools, setSelectedTools] = useState<Set<number>>(new Set());
|
||||
|
||||
const process = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setSelectedTools(new Set());
|
||||
try {
|
||||
const result = await fetchMcpTools(projectId);
|
||||
setTools(result);
|
||||
// Select all tools by default
|
||||
setSelectedTools(new Set(result.map((_, index) => index)));
|
||||
} catch (error) {
|
||||
setError(`Unable to fetch tools: ${error}`);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [projectId]);
|
||||
|
||||
useEffect(() => {
|
||||
console.log("mcp import tools useEffect", isOpen);
|
||||
if (isOpen) {
|
||||
process();
|
||||
}
|
||||
}, [isOpen, process]);
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onOpenChange={onOpenChange} size="xl">
|
||||
<ModalContent>
|
||||
{(onClose) => (
|
||||
<>
|
||||
<ModalHeader>Import from MCP servers</ModalHeader>
|
||||
<ModalBody>
|
||||
{loading && <div className="flex gap-2 items-center">
|
||||
<Spinner size="sm" />
|
||||
Fetching tools...
|
||||
</div>}
|
||||
{error && <div className="bg-red-100 p-2 rounded-md text-red-800 flex items-center gap-2 text-sm">
|
||||
{error}
|
||||
<Button size="sm" color="danger" onPress={() => process()}>Retry</Button>
|
||||
</div>}
|
||||
{!loading && !error && <>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="text-gray-600">
|
||||
{tools.length === 0 ? "No tools found" : `Found ${tools.length} tools:`}
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="flat"
|
||||
onPress={() => {
|
||||
setTools([]);
|
||||
process();
|
||||
}}
|
||||
startContent={<RefreshCwIcon className="w-4 h-4" />}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
{tools.length > 0 && <div className="flex flex-col w-full mt-4">
|
||||
<div className="flex items-center gap-4 px-4 py-2 bg-gray-50 rounded-t-lg border-b text-sm text-gray-700 font-medium">
|
||||
<div className="w-8">
|
||||
<Checkbox
|
||||
size="sm"
|
||||
isSelected={selectedTools.size === tools.length}
|
||||
isIndeterminate={selectedTools.size > 0 && selectedTools.size < tools.length}
|
||||
onValueChange={(checked) => {
|
||||
if (checked) {
|
||||
setSelectedTools(new Set(tools.map((_, i) => i)));
|
||||
} else {
|
||||
setSelectedTools(new Set());
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-36">Server</div>
|
||||
<div className="flex-1">Tool Name</div>
|
||||
</div>
|
||||
<div className="border rounded-b-lg divide-y overflow-y-auto max-h-[300px]">
|
||||
{tools.map((t, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center gap-4 px-4 py-2 hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<div className="w-8">
|
||||
<Checkbox
|
||||
size="sm"
|
||||
isSelected={selectedTools.has(index)}
|
||||
onValueChange={(checked) => {
|
||||
const newSelected = new Set(selectedTools);
|
||||
if (checked) {
|
||||
newSelected.add(index);
|
||||
} else {
|
||||
newSelected.delete(index);
|
||||
}
|
||||
setSelectedTools(newSelected);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="w-36">
|
||||
<div className="bg-blue-50 px-2 py-1 rounded text-blue-700 text-sm font-medium border border-blue-100">
|
||||
{t.mcpServerName}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 truncate text-gray-700">{t.name}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>}
|
||||
{tools.length > 0 && (
|
||||
<div className="mt-4 text-sm text-gray-600">
|
||||
{selectedTools.size} of {tools.length} tools selected
|
||||
</div>
|
||||
)}
|
||||
</>}
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button size="sm" variant="flat" onPress={onClose}>
|
||||
Cancel
|
||||
</Button>
|
||||
{tools.length > 0 && <Button size="sm" onPress={() => {
|
||||
const selectedToolsList = tools.filter((_, index) => selectedTools.has(index));
|
||||
onImport(selectedToolsList);
|
||||
onClose();
|
||||
}}>
|
||||
Import
|
||||
</Button>}
|
||||
</ModalFooter>
|
||||
</>
|
||||
)}
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,10 +1,7 @@
|
|||
"use client";
|
||||
import React, { useReducer, Reducer, useState, useCallback, useEffect, useRef, createContext, useContext } from "react";
|
||||
import { MCPServer, WithStringId } from "../../../lib/types/types";
|
||||
import { Workflow } from "../../../lib/types/workflow_types";
|
||||
import { WorkflowTool } from "../../../lib/types/workflow_types";
|
||||
import { WorkflowPrompt } from "../../../lib/types/workflow_types";
|
||||
import { WorkflowAgent } from "../../../lib/types/workflow_types";
|
||||
import { Workflow, WorkflowTool, WorkflowPrompt, WorkflowAgent } from "../../../lib/types/workflow_types";
|
||||
import { DataSource } from "../../../lib/types/datasource_types";
|
||||
import { produce, applyPatches, enablePatches, produceWithPatches, Patch } from 'immer';
|
||||
import { AgentConfig } from "../entities/agent_config";
|
||||
|
|
@ -29,7 +26,6 @@ import { PublishedBadge } from "./published_badge";
|
|||
import { BackIcon, HamburgerIcon, WorkflowIcon } from "../../../lib/components/icons";
|
||||
import { CopyIcon, ImportIcon, Layers2Icon, RadioIcon, RedoIcon, ServerIcon, Sparkles, UndoIcon, RocketIcon, PenLine, AlertTriangle } from "lucide-react";
|
||||
import { EntityList } from "./entity_list";
|
||||
import { McpImportTools } from "./mcp_imports";
|
||||
import { ProductTour } from "@/components/common/product-tour";
|
||||
|
||||
enablePatches();
|
||||
|
|
@ -140,12 +136,11 @@ export type Action = {
|
|||
type: "restore_state";
|
||||
state: StateItem;
|
||||
} | {
|
||||
type: "import_mcp_tools";
|
||||
tools: z.infer<typeof WorkflowTool>[];
|
||||
type: "reorder_agents";
|
||||
agents: z.infer<typeof WorkflowAgent>[];
|
||||
};
|
||||
|
||||
function reducer(state: State, action: Action): State {
|
||||
console.log('running reducer', action);
|
||||
let newState: State;
|
||||
|
||||
if (action.type === "restore_state") {
|
||||
|
|
@ -220,6 +215,23 @@ function reducer(state: State, action: Action): State {
|
|||
});
|
||||
break;
|
||||
}
|
||||
case "reorder_agents": {
|
||||
const newState = produce(state.present, draft => {
|
||||
draft.workflow.agents = action.agents;
|
||||
draft.lastUpdatedAt = new Date().toISOString();
|
||||
});
|
||||
const [nextState, patches, inversePatches] = produceWithPatches(state.present, draft => {
|
||||
draft.workflow.agents = action.agents;
|
||||
draft.lastUpdatedAt = new Date().toISOString();
|
||||
});
|
||||
return {
|
||||
...state,
|
||||
present: nextState,
|
||||
patches: [...state.patches.slice(0, state.currentIndex), patches],
|
||||
inversePatches: [...state.inversePatches.slice(0, state.currentIndex), inversePatches],
|
||||
currentIndex: state.currentIndex + 1,
|
||||
};
|
||||
}
|
||||
default: {
|
||||
const [nextState, patches, inversePatches] = produceWithPatches(
|
||||
state.present,
|
||||
|
|
@ -296,6 +308,7 @@ function reducer(state: State, action: Action): State {
|
|||
parameters: {
|
||||
type: 'object',
|
||||
properties: {},
|
||||
required: []
|
||||
},
|
||||
mockTool: true,
|
||||
autoSubmitMockedResponse: true,
|
||||
|
|
@ -521,27 +534,6 @@ function reducer(state: State, action: Action): State {
|
|||
draft.workflow.startAgent = action.name;
|
||||
draft.chatKey++;
|
||||
break;
|
||||
case "import_mcp_tools":
|
||||
if (isLive) {
|
||||
break;
|
||||
}
|
||||
// Process each tool one by one
|
||||
action.tools.forEach(newTool => {
|
||||
const existingToolIndex = draft.workflow.tools.findIndex(
|
||||
tool => tool.name === newTool.name
|
||||
);
|
||||
|
||||
if (existingToolIndex !== -1) {
|
||||
// Replace existing tool
|
||||
draft.workflow.tools[existingToolIndex] = newTool;
|
||||
} else {
|
||||
// Add new tool
|
||||
draft.workflow.tools.push(newTool);
|
||||
}
|
||||
});
|
||||
draft.pendingChanges = true;
|
||||
draft.chatKey++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
|
@ -570,6 +562,7 @@ export function WorkflowEditor({
|
|||
mcpServerUrls,
|
||||
toolWebhookUrl,
|
||||
defaultModel,
|
||||
projectTools,
|
||||
}: {
|
||||
dataSources: WithStringId<z.infer<typeof DataSource>>[];
|
||||
workflow: WithStringId<z.infer<typeof Workflow>>;
|
||||
|
|
@ -580,7 +573,9 @@ export function WorkflowEditor({
|
|||
mcpServerUrls: Array<z.infer<typeof MCPServer>>;
|
||||
toolWebhookUrl: string;
|
||||
defaultModel: string;
|
||||
projectTools: z.infer<typeof WorkflowTool>[];
|
||||
}) {
|
||||
|
||||
const [state, dispatch] = useReducer<Reducer<State, Action>>(reducer, {
|
||||
patches: [],
|
||||
inversePatches: [],
|
||||
|
|
@ -608,11 +603,30 @@ export function WorkflowEditor({
|
|||
const [showCopySuccess, setShowCopySuccess] = useState(false);
|
||||
const [showCopilot, setShowCopilot] = useState(true);
|
||||
const [copilotWidth, setCopilotWidth] = useState<number>(PANEL_RATIOS.copilot);
|
||||
const [isMcpImportModalOpen, setIsMcpImportModalOpen] = useState(false);
|
||||
const [isInitialState, setIsInitialState] = useState(true);
|
||||
const [showTour, setShowTour] = useState(true);
|
||||
const copilotRef = useRef<{ handleUserMessage: (message: string) => void }>(null);
|
||||
|
||||
// Load agent order from localStorage on mount
|
||||
useEffect(() => {
|
||||
const storedOrder = localStorage.getItem(`workflow_${workflow._id}_agent_order`);
|
||||
if (storedOrder) {
|
||||
try {
|
||||
const orderMap = JSON.parse(storedOrder);
|
||||
const orderedAgents = [...workflow.agents].sort((a, b) => {
|
||||
const orderA = orderMap[a.name] ?? Number.MAX_SAFE_INTEGER;
|
||||
const orderB = orderMap[b.name] ?? Number.MAX_SAFE_INTEGER;
|
||||
return orderA - orderB;
|
||||
});
|
||||
if (JSON.stringify(orderedAgents) !== JSON.stringify(workflow.agents)) {
|
||||
dispatch({ type: "reorder_agents", agents: orderedAgents });
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Error loading agent order:", e);
|
||||
}
|
||||
}
|
||||
}, [workflow._id, workflow.agents]);
|
||||
|
||||
// Function to trigger copilot chat
|
||||
const triggerCopilotChat = useCallback((message: string) => {
|
||||
setShowCopilot(true);
|
||||
|
|
@ -725,6 +739,17 @@ export function WorkflowEditor({
|
|||
dispatch({ type: "set_main_agent", name });
|
||||
}
|
||||
|
||||
function handleReorderAgents(agents: z.infer<typeof WorkflowAgent>[]) {
|
||||
// Save order to localStorage
|
||||
const orderMap = agents.reduce((acc, agent, index) => {
|
||||
acc[agent.name] = index;
|
||||
return acc;
|
||||
}, {} as Record<string, number>);
|
||||
localStorage.setItem(`workflow_${workflow._id}_agent_order`, JSON.stringify(orderMap));
|
||||
|
||||
dispatch({ type: "reorder_agents", agents });
|
||||
}
|
||||
|
||||
async function handleRenameWorkflow(name: string) {
|
||||
await renameWorkflow(state.present.workflow.projectId, state.present.workflow._id, name);
|
||||
dispatch({ type: "update_workflow_name", name });
|
||||
|
|
@ -747,10 +772,6 @@ export function WorkflowEditor({
|
|||
}, 1500);
|
||||
}
|
||||
|
||||
function triggerMcpImport() {
|
||||
setIsMcpImportModalOpen(true);
|
||||
}
|
||||
|
||||
const processQueue = useCallback(async (state: State, dispatch: React.Dispatch<Action>) => {
|
||||
if (saving.current || saveQueue.current.length === 0) return;
|
||||
|
||||
|
|
@ -774,10 +795,6 @@ export function WorkflowEditor({
|
|||
}
|
||||
}, [isLive]);
|
||||
|
||||
function handleImportMcpTools(tools: z.infer<typeof WorkflowTool>[]) {
|
||||
dispatch({ type: "import_mcp_tools", tools });
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (state.present.pendingChanges && state.present.workflow) {
|
||||
saveQueue.current.push(state.present.workflow);
|
||||
|
|
@ -952,27 +969,31 @@ export function WorkflowEditor({
|
|||
</div>
|
||||
<ResizablePanelGroup direction="horizontal" className="grow flex overflow-auto gap-1">
|
||||
<ResizablePanel minSize={10} defaultSize={PANEL_RATIOS.entityList}>
|
||||
<EntityList
|
||||
agents={state.present.workflow.agents}
|
||||
tools={state.present.workflow.tools}
|
||||
prompts={state.present.workflow.prompts}
|
||||
selectedEntity={state.present.selection}
|
||||
startAgentName={state.present.workflow.startAgent}
|
||||
onSelectAgent={handleSelectAgent}
|
||||
onSelectTool={handleSelectTool}
|
||||
onSelectPrompt={handleSelectPrompt}
|
||||
onAddAgent={handleAddAgent}
|
||||
onAddTool={handleAddTool}
|
||||
onAddPrompt={handleAddPrompt}
|
||||
onToggleAgent={handleToggleAgent}
|
||||
onSetMainAgent={handleSetMainAgent}
|
||||
onDeleteAgent={handleDeleteAgent}
|
||||
onDeleteTool={handleDeleteTool}
|
||||
onDeletePrompt={handleDeletePrompt}
|
||||
triggerMcpImport={triggerMcpImport}
|
||||
/>
|
||||
<div className="flex flex-col h-full">
|
||||
<EntityList
|
||||
agents={state.present.workflow.agents}
|
||||
tools={state.present.workflow.tools}
|
||||
projectTools={projectTools}
|
||||
prompts={state.present.workflow.prompts}
|
||||
selectedEntity={state.present.selection}
|
||||
startAgentName={state.present.workflow.startAgent}
|
||||
onSelectAgent={handleSelectAgent}
|
||||
onSelectTool={handleSelectTool}
|
||||
onSelectPrompt={handleSelectPrompt}
|
||||
onAddAgent={handleAddAgent}
|
||||
onAddTool={handleAddTool}
|
||||
onAddPrompt={handleAddPrompt}
|
||||
onToggleAgent={handleToggleAgent}
|
||||
onSetMainAgent={handleSetMainAgent}
|
||||
onDeleteAgent={handleDeleteAgent}
|
||||
onDeleteTool={handleDeleteTool}
|
||||
onDeletePrompt={handleDeletePrompt}
|
||||
projectId={state.present.workflow.projectId}
|
||||
onReorderAgents={handleReorderAgents}
|
||||
/>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
<ResizableHandle className="w-[3px] bg-transparent" />
|
||||
<ResizableHandle withHandle className="w-[3px] bg-transparent" />
|
||||
<ResizablePanel
|
||||
minSize={20}
|
||||
defaultSize={showCopilot ? PANEL_RATIOS.chatApp : PANEL_RATIOS.chatApp + PANEL_RATIOS.copilot}
|
||||
|
|
@ -988,6 +1009,7 @@ export function WorkflowEditor({
|
|||
toolWebhookUrl={toolWebhookUrl}
|
||||
isInitialState={isInitialState}
|
||||
onPanelClick={handlePlaygroundClick}
|
||||
projectTools={projectTools}
|
||||
/>
|
||||
{state.present.selection?.type === "agent" && <AgentConfig
|
||||
key={state.present.selection.name}
|
||||
|
|
@ -997,6 +1019,7 @@ export function WorkflowEditor({
|
|||
usedAgentNames={new Set(state.present.workflow.agents.filter((agent) => agent.name !== state.present.selection!.name).map((agent) => agent.name))}
|
||||
agents={state.present.workflow.agents}
|
||||
tools={state.present.workflow.tools}
|
||||
projectTools={projectTools}
|
||||
prompts={state.present.workflow.prompts}
|
||||
dataSources={dataSources}
|
||||
handleUpdate={handleUpdateAgent.bind(null, state.present.selection.name)}
|
||||
|
|
@ -1004,13 +1027,23 @@ export function WorkflowEditor({
|
|||
useRag={useRag}
|
||||
triggerCopilotChat={triggerCopilotChat}
|
||||
/>}
|
||||
{state.present.selection?.type === "tool" && <ToolConfig
|
||||
key={state.present.selection.name}
|
||||
tool={state.present.workflow.tools.find((tool) => tool.name === state.present.selection!.name)!}
|
||||
usedToolNames={new Set(state.present.workflow.tools.filter((tool) => tool.name !== state.present.selection!.name).map((tool) => tool.name))}
|
||||
handleUpdate={handleUpdateTool.bind(null, state.present.selection.name)}
|
||||
handleClose={handleUnselectTool}
|
||||
/>}
|
||||
{state.present.selection?.type === "tool" && (() => {
|
||||
const selectedTool = state.present.workflow.tools.find(
|
||||
(tool) => tool.name === state.present.selection!.name
|
||||
) || projectTools.find(
|
||||
(tool) => tool.name === state.present.selection!.name
|
||||
);
|
||||
return <ToolConfig
|
||||
key={state.present.selection.name}
|
||||
tool={selectedTool!}
|
||||
usedToolNames={new Set([
|
||||
...state.present.workflow.tools.filter((tool) => tool.name !== state.present.selection!.name).map((tool) => tool.name),
|
||||
...projectTools.filter((tool) => tool.name !== state.present.selection!.name).map((tool) => tool.name)
|
||||
])}
|
||||
handleUpdate={handleUpdateTool.bind(null, state.present.selection.name)}
|
||||
handleClose={handleUnselectTool}
|
||||
/>;
|
||||
})()}
|
||||
{state.present.selection?.type === "prompt" && <PromptConfig
|
||||
key={state.present.selection.name}
|
||||
prompt={state.present.workflow.prompts.find((prompt) => prompt.name === state.present.selection!.name)!}
|
||||
|
|
@ -1024,7 +1057,7 @@ export function WorkflowEditor({
|
|||
</ResizablePanel>
|
||||
{showCopilot && (
|
||||
<>
|
||||
<ResizableHandle className="w-[3px] bg-transparent" />
|
||||
<ResizableHandle withHandle className="w-[3px] bg-transparent" />
|
||||
<ResizablePanel
|
||||
minSize={10}
|
||||
defaultSize={PANEL_RATIOS.copilot}
|
||||
|
|
@ -1057,11 +1090,5 @@ export function WorkflowEditor({
|
|||
onComplete={() => setShowTour(false)}
|
||||
/>
|
||||
)}
|
||||
<McpImportTools
|
||||
projectId={state.present.workflow.projectId}
|
||||
isOpen={isMcpImportModalOpen}
|
||||
onOpenChange={setIsMcpImportModalOpen}
|
||||
onImport={handleImportMcpTools}
|
||||
/>
|
||||
</div>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,7 +14,8 @@ import {
|
|||
ChevronRightIcon,
|
||||
Moon,
|
||||
Sun,
|
||||
HelpCircle
|
||||
HelpCircle,
|
||||
Wrench
|
||||
} from "lucide-react";
|
||||
import { getProjectConfig } from "@/app/actions/project_actions";
|
||||
import { useTheme } from "@/app/providers/theme-provider";
|
||||
|
|
@ -73,6 +74,13 @@ export default function Sidebar({ projectId, useRag, useAuth, collapsed = false,
|
|||
icon: DatabaseIcon,
|
||||
requiresProject: true
|
||||
}] : []),
|
||||
{
|
||||
href: 'tools',
|
||||
label: 'Tools',
|
||||
icon: Wrench,
|
||||
requiresProject: true,
|
||||
beta: true
|
||||
},
|
||||
{
|
||||
href: 'config',
|
||||
label: 'Settings',
|
||||
|
|
@ -138,7 +146,7 @@ export default function Sidebar({ projectId, useRag, useAuth, collapsed = false,
|
|||
className={`
|
||||
relative w-full rounded-md flex items-center
|
||||
text-[15px] font-medium transition-all duration-200
|
||||
${collapsed ? 'justify-center py-4' : 'px-4 py-4 gap-3'}
|
||||
${collapsed ? 'justify-center py-4' : 'px-2.5 py-3 gap-2.5'}
|
||||
${isActive
|
||||
? 'bg-indigo-50 dark:bg-indigo-500/10 text-indigo-600 dark:text-indigo-400 border-l-2 border-indigo-600 dark:border-indigo-400'
|
||||
: isDisabled
|
||||
|
|
@ -161,7 +169,16 @@ export default function Sidebar({ projectId, useRag, useAuth, collapsed = false,
|
|||
}
|
||||
`}
|
||||
/>
|
||||
{!collapsed && <span>{item.label}</span>}
|
||||
{!collapsed && (
|
||||
<>
|
||||
<span>{item.label}</span>
|
||||
{item.beta && (
|
||||
<span className="ml-1.5 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>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</Link>
|
||||
</Tooltip>
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@
|
|||
box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1);
|
||||
background-color: white;
|
||||
z-index: 1000;
|
||||
top: 100% !important; /* Force dropdown below the cursor */
|
||||
margin-top: 4px; /* Add some spacing between cursor and dropdown */
|
||||
}
|
||||
|
||||
/* Dark mode styles */
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ interface PanelProps {
|
|||
actions?: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
maxHeight?: string;
|
||||
variant?: 'default' | 'copilot' | 'playground' | 'projects';
|
||||
variant?: 'default' | 'copilot' | 'playground' | 'projects' | 'entity-list';
|
||||
showWelcome?: boolean;
|
||||
className?: string;
|
||||
onClick?: () => void;
|
||||
|
|
@ -54,6 +54,8 @@ export function Panel({
|
|||
onClick,
|
||||
tourTarget,
|
||||
}: PanelProps) {
|
||||
const isEntityList = variant === 'entity-list';
|
||||
|
||||
return <div
|
||||
className={clsx(
|
||||
"flex flex-col overflow-hidden rounded-xl border relative",
|
||||
|
|
@ -84,7 +86,11 @@ export function Panel({
|
|||
<div
|
||||
className={clsx(
|
||||
"shrink-0 border-b border-zinc-100 dark:border-zinc-800 relative",
|
||||
variant === 'projects' ? "flex flex-col gap-3 px-4 py-3" : "flex items-center justify-between px-4 py-3"
|
||||
{
|
||||
"flex flex-col gap-3 px-4 py-3": variant === 'projects',
|
||||
"flex items-center justify-between h-[53px] p-3": isEntityList,
|
||||
"flex items-center justify-between px-6 py-3": !isEntityList && variant !== 'projects'
|
||||
}
|
||||
)}
|
||||
>
|
||||
{variant === 'projects' ? (
|
||||
|
|
@ -103,6 +109,13 @@ export function Panel({
|
|||
</div>
|
||||
{rightActions}
|
||||
</>
|
||||
) : isEntityList ? (
|
||||
<div className="flex items-center justify-between w-full">
|
||||
{title}
|
||||
{actions && <div className="flex items-center gap-2">
|
||||
{actions}
|
||||
</div>}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{title}
|
||||
|
|
@ -112,10 +125,10 @@ export function Panel({
|
|||
</div>
|
||||
<div className={clsx(
|
||||
"min-h-0 flex-1 overflow-y-auto",
|
||||
variant === 'projects' && "custom-scrollbar"
|
||||
(variant === 'projects' || isEntityList) && "custom-scrollbar"
|
||||
)}>
|
||||
{variant === 'projects' ? (
|
||||
<div className="px-3 py-2 pb-4">
|
||||
{(variant === 'projects' || isEntityList) ? (
|
||||
<div className="px-4 py-3">
|
||||
{children}
|
||||
</div>
|
||||
) : children}
|
||||
|
|
|
|||
37
apps/rowboat/components/ui/badge.tsx
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const badgeVariants = cva(
|
||||
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
|
||||
secondary:
|
||||
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
||||
destructive:
|
||||
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
|
||||
outline: "text-foreground",
|
||||
success: "border-transparent bg-green-500 text-white hover:bg-green-600",
|
||||
warning: "border-transparent bg-yellow-500 text-white hover:bg-yellow-600",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
export interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof badgeVariants> {}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return (
|
||||
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
68
apps/rowboat/components/ui/modal.tsx
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
import { Button } from './button';
|
||||
|
||||
interface ModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
export function Modal({ isOpen, onClose, title, children }: ModalProps) {
|
||||
// Close on escape key
|
||||
useEffect(() => {
|
||||
const handleEscape = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
};
|
||||
|
||||
if (isOpen) {
|
||||
document.addEventListener('keydown', handleEscape);
|
||||
// Prevent scrolling when modal is open
|
||||
document.body.style.overflow = 'hidden';
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('keydown', handleEscape);
|
||||
document.body.style.overflow = 'unset';
|
||||
};
|
||||
}, [isOpen, onClose]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="absolute 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-full max-w-md mx-4 p-6 space-y-4 animate-in fade-in zoom-in duration-200">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||
{title}
|
||||
</h3>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={onClose}
|
||||
className="p-1.5"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div>
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
27
apps/rowboat/components/ui/page-header.tsx
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
interface PageHeaderProps {
|
||||
title: string;
|
||||
description?: string;
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function PageHeader({ title, description, children }: PageHeaderProps) {
|
||||
return (
|
||||
<div className="border-b border-zinc-100 dark:border-zinc-800 bg-white dark:bg-zinc-900">
|
||||
<div className="px-6 py-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold text-zinc-900 dark:text-zinc-50">
|
||||
{title}
|
||||
</h1>
|
||||
{description && (
|
||||
<p className="mt-1 text-sm text-zinc-500 dark:text-zinc-400">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{children && <div>{children}</div>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
78
apps/rowboat/components/ui/slide-panel.tsx
Normal file
|
|
@ -0,0 +1,78 @@
|
|||
'use client';
|
||||
|
||||
import * as React from "react";
|
||||
import { XIcon } from 'lucide-react';
|
||||
import { Button } from './button';
|
||||
import { clsx } from 'clsx';
|
||||
|
||||
interface SlidePanelProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
title: React.ReactNode;
|
||||
children: React.ReactNode;
|
||||
width?: string;
|
||||
}
|
||||
|
||||
export function SlidePanel({
|
||||
isOpen,
|
||||
onClose,
|
||||
title,
|
||||
children,
|
||||
width = '500px'
|
||||
}: SlidePanelProps) {
|
||||
const [mounted, setMounted] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isOpen) {
|
||||
setMounted(true);
|
||||
} else {
|
||||
const timer = setTimeout(() => setMounted(false), 300); // Match transition duration
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
if (!mounted) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className={clsx(
|
||||
"fixed inset-0 bg-black/50 transition-opacity duration-300",
|
||||
isOpen ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* Panel */}
|
||||
<div
|
||||
className={clsx(
|
||||
"fixed right-0 top-0 h-full bg-white dark:bg-zinc-900 shadow-xl transition-transform duration-300 transform",
|
||||
"border-l border-zinc-200 dark:border-zinc-800",
|
||||
isOpen ? "translate-x-0" : "translate-x-full"
|
||||
)}
|
||||
style={{ width }}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-zinc-200 dark:border-zinc-800">
|
||||
<div className="text-lg font-semibold text-zinc-900 dark:text-zinc-100">
|
||||
{title}
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={onClose}
|
||||
className="text-zinc-500 hover:text-zinc-900 dark:text-zinc-400 dark:hover:text-zinc-100 p-2"
|
||||
>
|
||||
<XIcon className="h-5 w-5" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-6 h-[calc(100vh-73px)] overflow-y-auto">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
31
apps/rowboat/components/ui/switch.tsx
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import * as React from "react"
|
||||
import { Switch as HeroSwitch } from "@heroui/react"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
interface SwitchProps {
|
||||
checked?: boolean
|
||||
defaultChecked?: boolean
|
||||
onCheckedChange?: (checked: boolean) => void
|
||||
className?: string
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
const Switch = React.forwardRef<HTMLInputElement, SwitchProps>(
|
||||
({ checked, defaultChecked, onCheckedChange, disabled, className }, ref) => {
|
||||
return (
|
||||
<HeroSwitch
|
||||
ref={ref}
|
||||
isSelected={checked}
|
||||
defaultSelected={defaultChecked}
|
||||
onValueChange={onCheckedChange}
|
||||
isDisabled={disabled}
|
||||
color="primary"
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
Switch.displayName = "Switch";
|
||||
|
||||
export { Switch }
|
||||
17
apps/rowboat/components/ui/tabs.tsx
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import * as React from "react"
|
||||
import { Tabs as HeroTabs, Tab as HeroTab } from "@heroui/react"
|
||||
import { cn } from "../../lib/utils"
|
||||
|
||||
const Tabs = React.forwardRef<HTMLDivElement, React.ComponentPropsWithoutRef<typeof HeroTabs>>(
|
||||
({ className, ...props }, ref) => (
|
||||
<HeroTabs
|
||||
ref={ref}
|
||||
className={cn("w-full", className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
);
|
||||
|
||||
Tabs.displayName = "Tabs";
|
||||
|
||||
export { Tabs, HeroTab as Tab }
|
||||
56
apps/rowboat/package-lock.json
generated
|
|
@ -12,6 +12,9 @@
|
|||
"@auth0/nextjs-auth0": "^3.5.0",
|
||||
"@aws-sdk/client-s3": "^3.743.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.743.0",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@floating-ui/react": "^0.27.7",
|
||||
"@google/generative-ai": "^0.21.0",
|
||||
"@heroicons/react": "^2.2.0",
|
||||
|
|
@ -1340,6 +1343,59 @@
|
|||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/accessibility": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
|
||||
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/core": {
|
||||
"version": "6.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
|
||||
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@dnd-kit/accessibility": "^3.1.1",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/sortable": {
|
||||
"version": "10.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/sortable/-/sortable-10.0.0.tgz",
|
||||
"integrity": "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@dnd-kit/core": "^6.3.0",
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/utilities": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
|
||||
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/runtime": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.2.0.tgz",
|
||||
|
|
|
|||
|
|
@ -19,6 +19,9 @@
|
|||
"@auth0/nextjs-auth0": "^3.5.0",
|
||||
"@aws-sdk/client-s3": "^3.743.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.743.0",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@floating-ui/react": "^0.27.7",
|
||||
"@google/generative-ai": "^0.21.0",
|
||||
"@heroicons/react": "^2.2.0",
|
||||
|
|
|
|||
8
apps/rowboat/public/mcp-server-images/discord.svg
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg width="800px" height="800px" viewBox="0 -28.5 256 256" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" preserveAspectRatio="xMidYMid">
|
||||
<g>
|
||||
<path d="M216.856339,16.5966031 C200.285002,8.84328665 182.566144,3.2084988 164.041564,0 C161.766523,4.11318106 159.108624,9.64549908 157.276099,14.0464379 C137.583995,11.0849896 118.072967,11.0849896 98.7430163,14.0464379 C96.9108417,9.64549908 94.1925838,4.11318106 91.8971895,0 C73.3526068,3.2084988 55.6133949,8.86399117 39.0420583,16.6376612 C5.61752293,67.146514 -3.4433191,116.400813 1.08711069,164.955721 C23.2560196,181.510915 44.7403634,191.567697 65.8621325,198.148576 C71.0772151,190.971126 75.7283628,183.341335 79.7352139,175.300261 C72.104019,172.400575 64.7949724,168.822202 57.8887866,164.667963 C59.7209612,163.310589 61.5131304,161.891452 63.2445898,160.431257 C105.36741,180.133187 151.134928,180.133187 192.754523,160.431257 C194.506336,161.891452 196.298154,163.310589 198.110326,164.667963 C191.183787,168.842556 183.854737,172.420929 176.223542,175.320965 C180.230393,183.341335 184.861538,190.991831 190.096624,198.16893 C211.238746,191.588051 232.743023,181.531619 254.911949,164.955721 C260.227747,108.668201 245.831087,59.8662432 216.856339,16.5966031 Z M85.4738752,135.09489 C72.8290281,135.09489 62.4592217,123.290155 62.4592217,108.914901 C62.4592217,94.5396472 72.607595,82.7145587 85.4738752,82.7145587 C98.3405064,82.7145587 108.709962,94.5189427 108.488529,108.914901 C108.508531,123.290155 98.3405064,135.09489 85.4738752,135.09489 Z M170.525237,135.09489 C157.88039,135.09489 147.510584,123.290155 147.510584,108.914901 C147.510584,94.5396472 157.658606,82.7145587 170.525237,82.7145587 C183.391518,82.7145587 193.761324,94.5189427 193.539891,108.914901 C193.539891,123.290155 183.391518,135.09489 170.525237,135.09489 Z" fill="#5865F2" fill-rule="nonzero">
|
||||
</path>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2 KiB |
1
apps/rowboat/public/mcp-server-images/doc2markdown.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="lucide lucide-file-text-icon lucide-file-text"><path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/><path d="M10 9H8"/><path d="M16 13H8"/><path d="M16 17H8"/></svg>
|
||||
|
After Width: | Height: | Size: 405 B |
BIN
apps/rowboat/public/mcp-server-images/firecrawl.webp
Normal file
|
After Width: | Height: | Size: 322 B |
28
apps/rowboat/public/mcp-server-images/gcalendar.svg
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!-- Generator: Adobe Illustrator 23.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
||||
<svg version="1.1" id="Livello_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
|
||||
viewBox="0 0 200 200" enable-background="new 0 0 200 200" xml:space="preserve">
|
||||
<g>
|
||||
<g transform="translate(3.75 3.75)">
|
||||
<path fill="#FFFFFF" d="M148.882,43.618l-47.368-5.263l-57.895,5.263L38.355,96.25l5.263,52.632l52.632,6.579l52.632-6.579
|
||||
l5.263-53.947L148.882,43.618z"/>
|
||||
<path fill="#1A73E8" d="M65.211,125.276c-3.934-2.658-6.658-6.539-8.145-11.671l9.132-3.763c0.829,3.158,2.276,5.605,4.342,7.342
|
||||
c2.053,1.737,4.553,2.592,7.474,2.592c2.987,0,5.553-0.908,7.697-2.724s3.224-4.132,3.224-6.934c0-2.868-1.132-5.211-3.395-7.026
|
||||
s-5.105-2.724-8.5-2.724h-5.276v-9.039H76.5c2.921,0,5.382-0.789,7.382-2.368c2-1.579,3-3.737,3-6.487
|
||||
c0-2.447-0.895-4.395-2.684-5.855s-4.053-2.197-6.803-2.197c-2.684,0-4.816,0.711-6.395,2.145s-2.724,3.197-3.447,5.276
|
||||
l-9.039-3.763c1.197-3.395,3.395-6.395,6.618-8.987c3.224-2.592,7.342-3.895,12.342-3.895c3.697,0,7.026,0.711,9.974,2.145
|
||||
c2.947,1.434,5.263,3.421,6.934,5.947c1.671,2.539,2.5,5.382,2.5,8.539c0,3.224-0.776,5.947-2.329,8.184
|
||||
c-1.553,2.237-3.461,3.947-5.724,5.145v0.539c2.987,1.25,5.421,3.158,7.342,5.724c1.908,2.566,2.868,5.632,2.868,9.211
|
||||
s-0.908,6.776-2.724,9.579c-1.816,2.803-4.329,5.013-7.513,6.618c-3.197,1.605-6.789,2.421-10.776,2.421
|
||||
C73.408,129.263,69.145,127.934,65.211,125.276z"/>
|
||||
<path fill="#1A73E8" d="M121.25,79.961l-9.974,7.25l-5.013-7.605l17.987-12.974h6.895v61.197h-9.895L121.25,79.961z"/>
|
||||
<path fill="#EA4335" d="M148.882,196.25l47.368-47.368l-23.684-10.526l-23.684,10.526l-10.526,23.684L148.882,196.25z"/>
|
||||
<path fill="#34A853" d="M33.092,172.566l10.526,23.684h105.263v-47.368H43.618L33.092,172.566z"/>
|
||||
<path fill="#4285F4" d="M12.039-3.75C3.316-3.75-3.75,3.316-3.75,12.039v136.842l23.684,10.526l23.684-10.526V43.618h105.263
|
||||
l10.526-23.684L148.882-3.75H12.039z"/>
|
||||
<path fill="#188038" d="M-3.75,148.882v31.579c0,8.724,7.066,15.789,15.789,15.789h31.579v-47.368H-3.75z"/>
|
||||
<path fill="#FBBC04" d="M148.882,43.618v105.263h47.368V43.618l-23.684-10.526L148.882,43.618z"/>
|
||||
<path fill="#1967D2" d="M196.25,43.618V12.039c0-8.724-7.066-15.789-15.789-15.789h-31.579v47.368H196.25z"/>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.3 KiB |
60
apps/rowboat/public/mcp-server-images/gdocs.svg
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
<svg width="73" height="100" viewBox="0 0 73 100" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_1:149)">
|
||||
<mask id="mask0_1:149" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="73" height="100">
|
||||
<path d="M45.1923 0H6.77885C3.05048 0 0 3.06818 0 6.81818V93.1818C0 96.9318 3.05048 100 6.77885 100H65.5288C69.2572 100 72.3077 96.9318 72.3077 93.1818V27.2727L45.1923 0Z" fill="white"/>
|
||||
</mask>
|
||||
<g mask="url(#mask0_1:149)">
|
||||
<path d="M45.1923 0H6.77885C3.05048 0 0 3.06818 0 6.81818V93.1818C0 96.9318 3.05048 100 6.77885 100H65.5288C69.2572 100 72.3077 96.9318 72.3077 93.1818V27.2727L56.4904 15.9091L45.1923 0Z" fill="#4285F4"/>
|
||||
</g>
|
||||
<mask id="mask1_1:149" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="73" height="100">
|
||||
<path d="M45.1923 0H6.77885C3.05048 0 0 3.06818 0 6.81818V93.1818C0 96.9318 3.05048 100 6.77885 100H65.5288C69.2572 100 72.3077 96.9318 72.3077 93.1818V27.2727L45.1923 0Z" fill="white"/>
|
||||
</mask>
|
||||
<g mask="url(#mask1_1:149)">
|
||||
<path d="M47.1751 25.2784L72.3077 50.5511V27.2727L47.1751 25.2784Z" fill="url(#paint0_linear_1:149)"/>
|
||||
</g>
|
||||
<mask id="mask2_1:149" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="73" height="100">
|
||||
<path d="M45.1923 0H6.77885C3.05048 0 0 3.06818 0 6.81818V93.1818C0 96.9318 3.05048 100 6.77885 100H65.5288C69.2572 100 72.3077 96.9318 72.3077 93.1818V27.2727L45.1923 0Z" fill="white"/>
|
||||
</mask>
|
||||
<g mask="url(#mask2_1:149)">
|
||||
<path d="M18.0769 72.7273H54.2308V68.1818H18.0769V72.7273ZM18.0769 81.8182H45.1923V77.2727H18.0769V81.8182ZM18.0769 50V54.5455H54.2308V50H18.0769ZM18.0769 63.6364H54.2308V59.0909H18.0769V63.6364Z" fill="#F1F1F1"/>
|
||||
</g>
|
||||
<mask id="mask3_1:149" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="73" height="100">
|
||||
<path d="M45.1923 0H6.77885C3.05048 0 0 3.06818 0 6.81818V93.1818C0 96.9318 3.05048 100 6.77885 100H65.5288C69.2572 100 72.3077 96.9318 72.3077 93.1818V27.2727L45.1923 0Z" fill="white"/>
|
||||
</mask>
|
||||
<g mask="url(#mask3_1:149)">
|
||||
<path d="M45.1923 0V20.4545C45.1923 24.2216 48.2258 27.2727 51.9712 27.2727H72.3077L45.1923 0Z" fill="#A1C2FA"/>
|
||||
</g>
|
||||
<mask id="mask4_1:149" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="73" height="100">
|
||||
<path d="M45.1923 0H6.77885C3.05048 0 0 3.06818 0 6.81818V93.1818C0 96.9318 3.05048 100 6.77885 100H65.5288C69.2572 100 72.3077 96.9318 72.3077 93.1818V27.2727L45.1923 0Z" fill="white"/>
|
||||
</mask>
|
||||
<g mask="url(#mask4_1:149)">
|
||||
<path d="M6.77885 0C3.05048 0 0 3.06818 0 6.81818V7.38636C0 3.63636 3.05048 0.568182 6.77885 0.568182H45.1923V0H6.77885Z" fill="white" fill-opacity="0.2"/>
|
||||
</g>
|
||||
<mask id="mask5_1:149" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="73" height="100">
|
||||
<path d="M45.1923 0H6.77885C3.05048 0 0 3.06818 0 6.81818V93.1818C0 96.9318 3.05048 100 6.77885 100H65.5288C69.2572 100 72.3077 96.9318 72.3077 93.1818V27.2727L45.1923 0Z" fill="white"/>
|
||||
</mask>
|
||||
<g mask="url(#mask5_1:149)">
|
||||
<path d="M65.5288 99.4318H6.77885C3.05048 99.4318 0 96.3636 0 92.6136V93.1818C0 96.9318 3.05048 100 6.77885 100H65.5288C69.2572 100 72.3077 96.9318 72.3077 93.1818V92.6136C72.3077 96.3636 69.2572 99.4318 65.5288 99.4318Z" fill="#1A237E" fill-opacity="0.2"/>
|
||||
</g>
|
||||
<mask id="mask6_1:149" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="0" width="73" height="100">
|
||||
<path d="M45.1923 0H6.77885C3.05048 0 0 3.06818 0 6.81818V93.1818C0 96.9318 3.05048 100 6.77885 100H65.5288C69.2572 100 72.3077 96.9318 72.3077 93.1818V27.2727L45.1923 0Z" fill="white"/>
|
||||
</mask>
|
||||
<g mask="url(#mask6_1:149)">
|
||||
<path d="M51.9712 27.2727C48.2258 27.2727 45.1923 24.2216 45.1923 20.4545V21.0227C45.1923 24.7898 48.2258 27.8409 51.9712 27.8409H72.3077V27.2727H51.9712Z" fill="#1A237E" fill-opacity="0.1"/>
|
||||
</g>
|
||||
<path d="M45.1923 0H6.77885C3.05048 0 0 3.06818 0 6.81818V93.1818C0 96.9318 3.05048 100 6.77885 100H65.5288C69.2572 100 72.3077 96.9318 72.3077 93.1818V27.2727L45.1923 0Z" fill="url(#paint1_radial_1:149)"/>
|
||||
</g>
|
||||
<defs>
|
||||
<linearGradient id="paint0_linear_1:149" x1="59.7428" y1="27.4484" x2="59.7428" y2="50.5547" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#1A237E" stop-opacity="0.2"/>
|
||||
<stop offset="1" stop-color="#1A237E" stop-opacity="0.02"/>
|
||||
</linearGradient>
|
||||
<radialGradient id="paint1_radial_1:149" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(2.29074 1.9765) scale(116.595)">
|
||||
<stop stop-color="white" stop-opacity="0.1"/>
|
||||
<stop offset="1" stop-color="white" stop-opacity="0"/>
|
||||
</radialGradient>
|
||||
<clipPath id="clip0_1:149">
|
||||
<rect width="72.3077" height="100" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.5 KiB |
1
apps/rowboat/public/mcp-server-images/gdrive.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="2500" height="2166" viewBox="0 0 1443.061 1249.993"><path fill="#3777e3" d="M240.525 1249.993l240.492-416.664h962.044l-240.514 416.664z"/><path fill="#ffcf63" d="M962.055 833.329h481.006L962.055 0H481.017z"/><path fill="#11a861" d="M0 833.329l240.525 416.664 481.006-833.328L481.017 0z"/></svg>
|
||||
|
After Width: | Height: | Size: 341 B |
1
apps/rowboat/public/mcp-server-images/github.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg width="98" height="96" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M48.854 0C21.839 0 0 22 0 49.217c0 21.756 13.993 40.172 33.405 46.69 2.427.49 3.316-1.059 3.316-2.362 0-1.141-.08-5.052-.08-9.127-13.59 2.934-16.42-5.867-16.42-5.867-2.184-5.704-5.42-7.17-5.42-7.17-4.448-3.015.324-3.015.324-3.015 4.934.326 7.523 5.052 7.523 5.052 4.367 7.496 11.404 5.378 14.235 4.074.404-3.178 1.699-5.378 3.074-6.6-10.839-1.141-22.243-5.378-22.243-24.283 0-5.378 1.94-9.778 5.014-13.2-.485-1.222-2.184-6.275.486-13.038 0 0 4.125-1.304 13.426 5.052a46.97 46.97 0 0 1 12.214-1.63c4.125 0 8.33.571 12.213 1.63 9.302-6.356 13.427-5.052 13.427-5.052 2.67 6.763.97 11.816.485 13.038 3.155 3.422 5.015 7.822 5.015 13.2 0 18.905-11.404 23.06-22.324 24.283 1.78 1.548 3.316 4.481 3.316 9.126 0 6.6-.08 11.897-.08 13.526 0 1.304.89 2.853 3.316 2.364 19.412-6.52 33.405-24.935 33.405-46.691C97.707 22 75.788 0 48.854 0z" fill="#24292f"/></svg>
|
||||
|
After Width: | Height: | Size: 963 B |
1
apps/rowboat/public/mcp-server-images/gmail.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg id="Capa_1" enable-background="new 0 0 512 512" height="512" viewBox="0 0 512 512" width="512" xmlns="http://www.w3.org/2000/svg"><g><path d="m34.909 448.047h81.455v-197.818l-53.338-93.138-63.026 5.865v250.182c0 19.287 15.622 34.909 34.909 34.909z" fill="#0085f7"/><path d="m395.636 448.047h81.455c19.287 0 34.909-15.622 34.909-34.909v-250.182l-62.935-5.865-53.428 93.138v197.818z" fill="#00a94b"/><path d="m395.636 98.956-47.847 91.303 47.847 59.97 116.364-87.273v-46.545c0-43.142-49.251-67.782-83.782-41.891z" fill="#ffbc00"/><path clip-rule="evenodd" d="m116.364 250.229-45.593-96.31 45.593-54.963 139.636 104.727 139.636-104.727v151.273l-139.636 104.727z" fill="#ff4131" fill-rule="evenodd"/><path d="m0 116.411v46.545l116.364 87.273v-151.273l-32.582-24.436c-34.531-25.891-83.782-1.251-83.782 41.891z" fill="#e51c19"/></g></svg>
|
||||
|
After Width: | Height: | Size: 838 B |
89
apps/rowboat/public/mcp-server-images/gsheets.svg
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg width="49px" height="67px" viewBox="0 0 49 67" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<!-- Generator: Sketch 54.1 (76490) - https://sketchapp.com -->
|
||||
<title>Sheets-icon</title>
|
||||
<desc>Created with Sketch.</desc>
|
||||
<defs>
|
||||
<path d="M29.5833333,0 L4.4375,0 C1.996875,0 0,1.996875 0,4.4375 L0,60.6458333 C0,63.0864583 1.996875,65.0833333 4.4375,65.0833333 L42.8958333,65.0833333 C45.3364583,65.0833333 47.3333333,63.0864583 47.3333333,60.6458333 L47.3333333,17.75 L29.5833333,0 Z" id="path-1"></path>
|
||||
<path d="M29.5833333,0 L4.4375,0 C1.996875,0 0,1.996875 0,4.4375 L0,60.6458333 C0,63.0864583 1.996875,65.0833333 4.4375,65.0833333 L42.8958333,65.0833333 C45.3364583,65.0833333 47.3333333,63.0864583 47.3333333,60.6458333 L47.3333333,17.75 L29.5833333,0 Z" id="path-3"></path>
|
||||
<path d="M29.5833333,0 L4.4375,0 C1.996875,0 0,1.996875 0,4.4375 L0,60.6458333 C0,63.0864583 1.996875,65.0833333 4.4375,65.0833333 L42.8958333,65.0833333 C45.3364583,65.0833333 47.3333333,63.0864583 47.3333333,60.6458333 L47.3333333,17.75 L29.5833333,0 Z" id="path-5"></path>
|
||||
<linearGradient x1="50.0053945%" y1="8.58610612%" x2="50.0053945%" y2="100.013939%" id="linearGradient-7">
|
||||
<stop stop-color="#263238" stop-opacity="0.2" offset="0%"></stop>
|
||||
<stop stop-color="#263238" stop-opacity="0.02" offset="100%"></stop>
|
||||
</linearGradient>
|
||||
<path d="M29.5833333,0 L4.4375,0 C1.996875,0 0,1.996875 0,4.4375 L0,60.6458333 C0,63.0864583 1.996875,65.0833333 4.4375,65.0833333 L42.8958333,65.0833333 C45.3364583,65.0833333 47.3333333,63.0864583 47.3333333,60.6458333 L47.3333333,17.75 L29.5833333,0 Z" id="path-8"></path>
|
||||
<path d="M29.5833333,0 L4.4375,0 C1.996875,0 0,1.996875 0,4.4375 L0,60.6458333 C0,63.0864583 1.996875,65.0833333 4.4375,65.0833333 L42.8958333,65.0833333 C45.3364583,65.0833333 47.3333333,63.0864583 47.3333333,60.6458333 L47.3333333,17.75 L29.5833333,0 Z" id="path-10"></path>
|
||||
<path d="M29.5833333,0 L4.4375,0 C1.996875,0 0,1.996875 0,4.4375 L0,60.6458333 C0,63.0864583 1.996875,65.0833333 4.4375,65.0833333 L42.8958333,65.0833333 C45.3364583,65.0833333 47.3333333,63.0864583 47.3333333,60.6458333 L47.3333333,17.75 L29.5833333,0 Z" id="path-12"></path>
|
||||
<path d="M29.5833333,0 L4.4375,0 C1.996875,0 0,1.996875 0,4.4375 L0,60.6458333 C0,63.0864583 1.996875,65.0833333 4.4375,65.0833333 L42.8958333,65.0833333 C45.3364583,65.0833333 47.3333333,63.0864583 47.3333333,60.6458333 L47.3333333,17.75 L29.5833333,0 Z" id="path-14"></path>
|
||||
<radialGradient cx="3.16804688%" cy="2.71744318%" fx="3.16804688%" fy="2.71744318%" r="161.248516%" gradientTransform="translate(0.031680,0.027174),scale(1.000000,0.727273),translate(-0.031680,-0.027174)" id="radialGradient-16">
|
||||
<stop stop-color="#FFFFFF" stop-opacity="0.1" offset="0%"></stop>
|
||||
<stop stop-color="#FFFFFF" stop-opacity="0" offset="100%"></stop>
|
||||
</radialGradient>
|
||||
</defs>
|
||||
<g id="Page-1" stroke="none" stroke-width="1" fill="none" fill-rule="evenodd">
|
||||
<g id="Consumer-Apps-Sheets-Large-VD-R8-" transform="translate(-451.000000, -451.000000)">
|
||||
<g id="Hero" transform="translate(0.000000, 63.000000)">
|
||||
<g id="Personal" transform="translate(277.000000, 299.000000)">
|
||||
<g id="Sheets-icon" transform="translate(174.833333, 89.958333)">
|
||||
<g id="Group">
|
||||
<g id="Clipped">
|
||||
<mask id="mask-2" fill="white">
|
||||
<use xlink:href="#path-1"></use>
|
||||
</mask>
|
||||
<g id="SVGID_1_"></g>
|
||||
<path d="M29.5833333,0 L4.4375,0 C1.996875,0 0,1.996875 0,4.4375 L0,60.6458333 C0,63.0864583 1.996875,65.0833333 4.4375,65.0833333 L42.8958333,65.0833333 C45.3364583,65.0833333 47.3333333,63.0864583 47.3333333,60.6458333 L47.3333333,17.75 L36.9791667,10.3541667 L29.5833333,0 Z" id="Path" fill="#0F9D58" fill-rule="nonzero" mask="url(#mask-2)"></path>
|
||||
</g>
|
||||
<g id="Clipped">
|
||||
<mask id="mask-4" fill="white">
|
||||
<use xlink:href="#path-3"></use>
|
||||
</mask>
|
||||
<g id="SVGID_1_"></g>
|
||||
<path d="M11.8333333,31.8020833 L11.8333333,53.25 L35.5,53.25 L35.5,31.8020833 L11.8333333,31.8020833 Z M22.1875,50.2916667 L14.7916667,50.2916667 L14.7916667,46.59375 L22.1875,46.59375 L22.1875,50.2916667 Z M22.1875,44.375 L14.7916667,44.375 L14.7916667,40.6770833 L22.1875,40.6770833 L22.1875,44.375 Z M22.1875,38.4583333 L14.7916667,38.4583333 L14.7916667,34.7604167 L22.1875,34.7604167 L22.1875,38.4583333 Z M32.5416667,50.2916667 L25.1458333,50.2916667 L25.1458333,46.59375 L32.5416667,46.59375 L32.5416667,50.2916667 Z M32.5416667,44.375 L25.1458333,44.375 L25.1458333,40.6770833 L32.5416667,40.6770833 L32.5416667,44.375 Z M32.5416667,38.4583333 L25.1458333,38.4583333 L25.1458333,34.7604167 L32.5416667,34.7604167 L32.5416667,38.4583333 Z" id="Shape" fill="#F1F1F1" fill-rule="nonzero" mask="url(#mask-4)"></path>
|
||||
</g>
|
||||
<g id="Clipped">
|
||||
<mask id="mask-6" fill="white">
|
||||
<use xlink:href="#path-5"></use>
|
||||
</mask>
|
||||
<g id="SVGID_1_"></g>
|
||||
<polygon id="Path" fill="url(#linearGradient-7)" fill-rule="nonzero" mask="url(#mask-6)" points="30.8813021 16.4520313 47.3333333 32.9003646 47.3333333 17.75"></polygon>
|
||||
</g>
|
||||
<g id="Clipped">
|
||||
<mask id="mask-9" fill="white">
|
||||
<use xlink:href="#path-8"></use>
|
||||
</mask>
|
||||
<g id="SVGID_1_"></g>
|
||||
<g id="Group" mask="url(#mask-9)">
|
||||
<g transform="translate(26.625000, -2.958333)">
|
||||
<path d="M2.95833333,2.95833333 L2.95833333,16.2708333 C2.95833333,18.7225521 4.94411458,20.7083333 7.39583333,20.7083333 L20.7083333,20.7083333 L2.95833333,2.95833333 Z" id="Path" fill="#87CEAC" fill-rule="nonzero"></path>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
<g id="Clipped">
|
||||
<mask id="mask-11" fill="white">
|
||||
<use xlink:href="#path-10"></use>
|
||||
</mask>
|
||||
<g id="SVGID_1_"></g>
|
||||
<path d="M4.4375,0 C1.996875,0 0,1.996875 0,4.4375 L0,4.80729167 C0,2.36666667 1.996875,0.369791667 4.4375,0.369791667 L29.5833333,0.369791667 L29.5833333,0 L4.4375,0 Z" id="Path" fill-opacity="0.2" fill="#FFFFFF" fill-rule="nonzero" mask="url(#mask-11)"></path>
|
||||
</g>
|
||||
<g id="Clipped">
|
||||
<mask id="mask-13" fill="white">
|
||||
<use xlink:href="#path-12"></use>
|
||||
</mask>
|
||||
<g id="SVGID_1_"></g>
|
||||
<path d="M42.8958333,64.7135417 L4.4375,64.7135417 C1.996875,64.7135417 0,62.7166667 0,60.2760417 L0,60.6458333 C0,63.0864583 1.996875,65.0833333 4.4375,65.0833333 L42.8958333,65.0833333 C45.3364583,65.0833333 47.3333333,63.0864583 47.3333333,60.6458333 L47.3333333,60.2760417 C47.3333333,62.7166667 45.3364583,64.7135417 42.8958333,64.7135417 Z" id="Path" fill-opacity="0.2" fill="#263238" fill-rule="nonzero" mask="url(#mask-13)"></path>
|
||||
</g>
|
||||
<g id="Clipped">
|
||||
<mask id="mask-15" fill="white">
|
||||
<use xlink:href="#path-14"></use>
|
||||
</mask>
|
||||
<g id="SVGID_1_"></g>
|
||||
<path d="M34.0208333,17.75 C31.5691146,17.75 29.5833333,15.7642188 29.5833333,13.3125 L29.5833333,13.6822917 C29.5833333,16.1340104 31.5691146,18.1197917 34.0208333,18.1197917 L47.3333333,18.1197917 L47.3333333,17.75 L34.0208333,17.75 Z" id="Path" fill-opacity="0.1" fill="#263238" fill-rule="nonzero" mask="url(#mask-15)"></path>
|
||||
</g>
|
||||
</g>
|
||||
<path d="M29.5833333,0 L4.4375,0 C1.996875,0 0,1.996875 0,4.4375 L0,60.6458333 C0,63.0864583 1.996875,65.0833333 4.4375,65.0833333 L42.8958333,65.0833333 C45.3364583,65.0833333 47.3333333,63.0864583 47.3333333,60.6458333 L47.3333333,17.75 L29.5833333,0 Z" id="Path" fill="url(#radialGradient-16)" fill-rule="nonzero"></path>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 9 KiB |
1
apps/rowboat/public/mcp-server-images/jira.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg height="2500" preserveAspectRatio="xMidYMid" width="2500" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 -30.632388516510233 255.324 285.95638851651023"><linearGradient id="a"><stop offset=".18" stop-color="#0052cc"/><stop offset="1" stop-color="#2684ff"/></linearGradient><linearGradient id="b" x1="98.031%" x2="58.888%" xlink:href="#a" y1=".161%" y2="40.766%"/><linearGradient id="c" x1="100.665%" x2="55.402%" xlink:href="#a" y1=".455%" y2="44.727%"/><path d="M244.658 0H121.707a55.502 55.502 0 0 0 55.502 55.502h22.649V77.37c.02 30.625 24.841 55.447 55.466 55.467V10.666C255.324 4.777 250.55 0 244.658 0z" fill="#2684ff"/><path d="M183.822 61.262H60.872c.019 30.625 24.84 55.447 55.466 55.467h22.649v21.938c.039 30.625 24.877 55.43 55.502 55.43V71.93c0-5.891-4.776-10.667-10.667-10.667z" fill="url(#b)"/><path d="M122.951 122.489H0c0 30.653 24.85 55.502 55.502 55.502h22.72v21.867c.02 30.597 24.798 55.408 55.396 55.466V133.156c0-5.891-4.776-10.667-10.667-10.667z" fill="url(#c)"/></svg>
|
||||
|
After Width: | Height: | Size: 1 KiB |
BIN
apps/rowboat/public/mcp-server-images/klavis.webp
Normal file
|
After Width: | Height: | Size: 252 B |
BIN
apps/rowboat/public/mcp-server-images/markdown2doc.webp
Normal file
|
After Width: | Height: | Size: 564 B |
1
apps/rowboat/public/mcp-server-images/notion.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="512px" height="512px" viewBox="0 0 24 24"><path fill="currentColor" d="M4.459 4.208c.746.606 1.026.56 2.428.466l13.215-.793c.28 0 .047-.28-.046-.326L17.86 1.968c-.42-.326-.981-.7-2.055-.607L3.01 2.295c-.466.046-.56.28-.374.466zm.793 3.08v13.904c0 .747.373 1.027 1.214.98l14.523-.84c.841-.046.935-.56.935-1.167V6.354c0-.606-.233-.933-.748-.887l-15.177.887c-.56.047-.747.327-.747.933zm14.337.745c.093.42 0 .84-.42.888l-.7.14v10.264c-.608.327-1.168.514-1.635.514c-.748 0-.935-.234-1.495-.933l-4.577-7.186v6.952L12.21 19s0 .84-1.168.84l-3.222.186c-.093-.186 0-.653.327-.746l.84-.233V9.854L7.822 9.76c-.094-.42.14-1.026.793-1.073l3.456-.233l4.764 7.279v-6.44l-1.215-.139c-.093-.514.28-.887.747-.933zM1.936 1.035l13.31-.98c1.634-.14 2.055-.047 3.082.7l4.249 2.986c.7.513.934.653.934 1.213v16.378c0 1.026-.373 1.634-1.68 1.726l-15.458.934c-.98.047-1.448-.093-1.962-.747l-3.129-4.06c-.56-.747-.793-1.306-.793-1.96V2.667c0-.839.374-1.54 1.447-1.632"/></svg>
|
||||
|
After Width: | Height: | Size: 996 B |
22
apps/rowboat/public/mcp-server-images/postgres.svg
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
<?xml version="1.0"?>
|
||||
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN"
|
||||
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
|
||||
|
||||
<svg width="432.071pt" height="445.383pt" viewBox="0 0 432.071 445.383" xml:space="preserve" xmlns="http://www.w3.org/2000/svg">
|
||||
<g id="orginal" style="fill-rule:nonzero;clip-rule:nonzero;stroke:#000000;stroke-miterlimit:4;">
|
||||
</g>
|
||||
<g id="Layer_x0020_3" style="fill-rule:nonzero;clip-rule:nonzero;fill:none;stroke:#FFFFFF;stroke-width:12.4651;stroke-linecap:round;stroke-linejoin:round;stroke-miterlimit:4;">
|
||||
<path style="fill:#000000;stroke:#000000;stroke-width:37.3953;stroke-linecap:butt;stroke-linejoin:miter;" d="M323.205,324.227c2.833-23.601,1.984-27.062,19.563-23.239l4.463,0.392c13.517,0.615,31.199-2.174,41.587-7c22.362-10.376,35.622-27.7,13.572-23.148c-50.297,10.376-53.755-6.655-53.755-6.655c53.111-78.803,75.313-178.836,56.149-203.322 C352.514-5.534,262.036,26.049,260.522,26.869l-0.482,0.089c-9.938-2.062-21.06-3.294-33.554-3.496c-22.761-0.374-40.032,5.967-53.133,15.904c0,0-161.408-66.498-153.899,83.628c1.597,31.936,45.777,241.655,98.47,178.31 c19.259-23.163,37.871-42.748,37.871-42.748c9.242,6.14,20.307,9.272,31.912,8.147l0.897-0.765c-0.281,2.876-0.157,5.689,0.359,9.019c-13.572,15.167-9.584,17.83-36.723,23.416c-27.457,5.659-11.326,15.734-0.797,18.367c12.768,3.193,42.305,7.716,62.268-20.224 l-0.795,3.188c5.325,4.26,4.965,30.619,5.72,49.452c0.756,18.834,2.017,36.409,5.856,46.771c3.839,10.36,8.369,37.05,44.036,29.406c29.809-6.388,52.6-15.582,54.677-101.107"/>
|
||||
<path style="fill:#336791;stroke:none;" d="M402.395,271.23c-50.302,10.376-53.76-6.655-53.76-6.655c53.111-78.808,75.313-178.843,56.153-203.326c-52.27-66.785-142.752-35.2-144.262-34.38l-0.486,0.087c-9.938-2.063-21.06-3.292-33.56-3.496c-22.761-0.373-40.026,5.967-53.127,15.902 c0,0-161.411-66.495-153.904,83.63c1.597,31.938,45.776,241.657,98.471,178.312c19.26-23.163,37.869-42.748,37.869-42.748c9.243,6.14,20.308,9.272,31.908,8.147l0.901-0.765c-0.28,2.876-0.152,5.689,0.361,9.019c-13.575,15.167-9.586,17.83-36.723,23.416 c-27.459,5.659-11.328,15.734-0.796,18.367c12.768,3.193,42.307,7.716,62.266-20.224l-0.796,3.188c5.319,4.26,9.054,27.711,8.428,48.969c-0.626,21.259-1.044,35.854,3.147,47.254c4.191,11.4,8.368,37.05,44.042,29.406c29.809-6.388,45.256-22.942,47.405-50.555 c1.525-19.631,4.976-16.729,5.194-34.28l2.768-8.309c3.192-26.611,0.507-35.196,18.872-31.203l4.463,0.392c13.517,0.615,31.208-2.174,41.591-7c22.358-10.376,35.618-27.7,13.573-23.148z"/>
|
||||
<path d="M215.866,286.484c-1.385,49.516,0.348,99.377,5.193,111.495c4.848,12.118,15.223,35.688,50.9,28.045c29.806-6.39,40.651-18.756,45.357-46.051c3.466-20.082,10.148-75.854,11.005-87.281"/>
|
||||
<path d="M173.104,38.256c0,0-161.521-66.016-154.012,84.109c1.597,31.938,45.779,241.664,98.473,178.316c19.256-23.166,36.671-41.335,36.671-41.335"/>
|
||||
<path d="M260.349,26.207c-5.591,1.753,89.848-34.889,144.087,34.417c19.159,24.484-3.043,124.519-56.153,203.329"/>
|
||||
<path style="stroke-linejoin:bevel;" d="M348.282,263.953c0,0,3.461,17.036,53.764,6.653c22.04-4.552,8.776,12.774-13.577,23.155c-18.345,8.514-59.474,10.696-60.146-1.069c-1.729-30.355,21.647-21.133,19.96-28.739c-1.525-6.85-11.979-13.573-18.894-30.338 c-6.037-14.633-82.796-126.849,21.287-110.183c3.813-0.789-27.146-99.002-124.553-100.599c-97.385-1.597-94.19,119.762-94.19,119.762"/>
|
||||
<path d="M188.604,274.334c-13.577,15.166-9.584,17.829-36.723,23.417c-27.459,5.66-11.326,15.733-0.797,18.365c12.768,3.195,42.307,7.718,62.266-20.229c6.078-8.509-0.036-22.086-8.385-25.547c-4.034-1.671-9.428-3.765-16.361,3.994z"/>
|
||||
<path d="M187.715,274.069c-1.368-8.917,2.93-19.528,7.536-31.942c6.922-18.626,22.893-37.255,10.117-96.339c-9.523-44.029-73.396-9.163-73.436-3.193c-0.039,5.968,2.889,30.26-1.067,58.548c-5.162,36.913,23.488,68.132,56.479,64.938"/>
|
||||
<path style="fill:#FFFFFF;stroke-width:4.155;stroke-linecap:butt;stroke-linejoin:miter;" d="M172.517,141.7c-0.288,2.039,3.733,7.48,8.976,8.207c5.234,0.73,9.714-3.522,9.998-5.559c0.284-2.039-3.732-4.285-8.977-5.015c-5.237-0.731-9.719,0.333-9.996,2.367z"/>
|
||||
<path style="fill:#FFFFFF;stroke-width:2.0775;stroke-linecap:butt;stroke-linejoin:miter;" d="M331.941,137.543c0.284,2.039-3.732,7.48-8.976,8.207c-5.238,0.73-9.718-3.522-10.005-5.559c-0.277-2.039,3.74-4.285,8.979-5.015c5.239-0.73,9.718,0.333,10.002,2.368z"/>
|
||||
<path d="M350.676,123.432c0.863,15.994-3.445,26.888-3.988,43.914c-0.804,24.748,11.799,53.074-7.191,81.435"/>
|
||||
<path style="stroke-width:3;" d="M0,60.232"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.4 KiB |
3
apps/rowboat/public/mcp-server-images/resend.svg
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
<svg width="600" height="600" viewBox="0 0 600 600" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M186 447.471V154H318.062C336.788 154 353.697 158.053 368.79 166.158C384.163 174.263 396.181 185.443 404.845 199.698C413.51 213.672 417.842 229.604 417.842 247.491C417.842 265.938 413.51 282.568 404.845 297.381C396.181 311.915 384.302 323.375 369.209 331.759C354.117 340.144 337.067 344.337 318.062 344.337H253.917V447.471H186ZM348.667 447.471L274.041 314.99L346.99 304.509L430 447.471H348.667ZM253.917 289.835H311.773C319.04 289.835 325.329 288.298 330.639 285.223C336.229 281.869 340.421 277.258 343.216 271.388C346.291 265.519 347.828 258.811 347.828 251.265C347.828 243.718 346.151 237.15 342.797 231.56C339.443 225.691 334.552 221.219 328.124 218.144C321.975 215.07 314.428 213.533 305.484 213.533H253.917V289.835Z" fill="black"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 851 B |
6
apps/rowboat/public/mcp-server-images/slack.svg
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
<svg width="127" height="127" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M27.2 80c0 7.3-5.9 13.2-13.2 13.2C6.7 93.2.8 87.3.8 80c0-7.3 5.9-13.2 13.2-13.2h13.2V80zm6.6 0c0-7.3 5.9-13.2 13.2-13.2 7.3 0 13.2 5.9 13.2 13.2v33c0 7.3-5.9 13.2-13.2 13.2-7.3 0-13.2-5.9-13.2-13.2V80z" fill="#E01E5A"/>
|
||||
<path d="M47 27c-7.3 0-13.2-5.9-13.2-13.2C33.8 6.5 39.7.6 47 .6c7.3 0 13.2 5.9 13.2 13.2V27H47zm0 6.7c7.3 0 13.2 5.9 13.2 13.2 0 7.3-5.9 13.2-13.2 13.2H13.9C6.6 60.1.7 54.2.7 46.9c0-7.3 5.9-13.2 13.2-13.2H47z" fill="#36C5F0"/>
|
||||
<path d="M99.9 46.9c0-7.3 5.9-13.2 13.2-13.2 7.3 0 13.2 5.9 13.2 13.2 0 7.3-5.9 13.2-13.2 13.2H99.9V46.9zm-6.6 0c0 7.3-5.9 13.2-13.2 13.2-7.3 0-13.2-5.9-13.2-13.2V13.8C66.9 6.5 72.8.6 80.1.6c7.3 0 13.2 5.9 13.2 13.2v33.1z" fill="#2EB67D"/>
|
||||
<path d="M80.1 99.8c7.3 0 13.2 5.9 13.2 13.2 0 7.3-5.9 13.2-13.2 13.2-7.3 0-13.2-5.9-13.2-13.2V99.8h13.2zm0-6.6c-7.3 0-13.2-5.9-13.2-13.2 0-7.3 5.9-13.2 13.2-13.2h33.1c7.3 0 13.2 5.9 13.2 13.2 0 7.3-5.9 13.2-13.2 13.2H80.1z" fill="#ECB22E"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1,019 B |
15
apps/rowboat/public/mcp-server-images/supabase.svg
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
<svg width="109" height="113" viewBox="0 0 109 113" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M63.7076 110.284C60.8481 113.885 55.0502 111.912 54.9813 107.314L53.9738 40.0627L99.1935 40.0627C107.384 40.0627 111.952 49.5228 106.859 55.9374L63.7076 110.284Z" fill="url(#paint0_linear)"/>
|
||||
<path d="M63.7076 110.284C60.8481 113.885 55.0502 111.912 54.9813 107.314L53.9738 40.0627L99.1935 40.0627C107.384 40.0627 111.952 49.5228 106.859 55.9374L63.7076 110.284Z" fill="url(#paint1_linear)" fill-opacity="0.2"/>
|
||||
<path d="M45.317 2.07103C48.1765 -1.53037 53.9745 0.442937 54.0434 5.041L54.4849 72.2922H9.83113C1.64038 72.2922 -2.92775 62.8321 2.1655 56.4175L45.317 2.07103Z" fill="#3ECF8E"/>
|
||||
<defs>
|
||||
<linearGradient id="paint0_linear" x1="53.9738" y1="54.974" x2="94.1635" y2="71.8295" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#249361"/>
|
||||
<stop offset="1" stop-color="#3ECF8E"/>
|
||||
</linearGradient>
|
||||
<linearGradient id="paint1_linear" x1="36.1558" y1="30.578" x2="54.4844" y2="65.0806" gradientUnits="userSpaceOnUse">
|
||||
<stop/>
|
||||
<stop offset="1" stop-opacity="0"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
1
apps/rowboat/public/mcp-server-images/wordpress.svg
Normal file
|
|
@ -0,0 +1 @@
|
|||
<svg viewBox="0 0 122.52 122.523" xmlns="http://www.w3.org/2000/svg"><g fill="#464342"><path d="m8.708 61.26c0 20.802 12.089 38.779 29.619 47.298l-25.069-68.686c-2.916 6.536-4.55 13.769-4.55 21.388z"/><path d="m96.74 58.608c0-6.495-2.333-10.993-4.334-14.494-2.664-4.329-5.161-7.995-5.161-12.324 0-4.831 3.664-9.328 8.825-9.328.233 0 .454.029.681.042-9.35-8.566-21.807-13.796-35.489-13.796-18.36 0-34.513 9.42-43.91 23.688 1.233.037 2.395.063 3.382.063 5.497 0 14.006-.667 14.006-.667 2.833-.167 3.167 3.994.337 4.329 0 0-2.847.335-6.015.501l19.138 56.925 11.501-34.493-8.188-22.434c-2.83-.166-5.511-.501-5.511-.501-2.832-.166-2.5-4.496.332-4.329 0 0 8.679.667 13.843.667 5.496 0 14.006-.667 14.006-.667 2.835-.167 3.168 3.994.337 4.329 0 0-2.853.335-6.015.501l18.992 56.494 5.242-17.517c2.272-7.269 4.001-12.49 4.001-16.989z"/><path d="m62.184 65.857-15.768 45.819c4.708 1.384 9.687 2.141 14.846 2.141 6.12 0 11.989-1.058 17.452-2.979-.141-.225-.269-.464-.374-.724z"/><path d="m107.376 36.046c.226 1.674.354 3.471.354 5.404 0 5.333-.996 11.328-3.996 18.824l-16.053 46.413c15.624-9.111 26.133-26.038 26.133-45.426.001-9.137-2.333-17.729-6.438-25.215z"/><path d="m61.262 0c-33.779 0-61.262 27.481-61.262 61.26 0 33.783 27.483 61.263 61.262 61.263 33.778 0 61.265-27.48 61.265-61.263-.001-33.779-27.487-61.26-61.265-61.26zm0 119.715c-32.23 0-58.453-26.223-58.453-58.455 0-32.23 26.222-58.451 58.453-58.451 32.229 0 58.45 26.221 58.45 58.451 0 32.232-26.221 58.455-58.45 58.455z"/></g></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
11
apps/rowboat/public/mcp-server-images/youtube.svg
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
<?xml version="1.0" encoding="iso-8859-1"?>
|
||||
<!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
|
||||
<svg height="800px" width="800px" version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
|
||||
viewBox="0 0 461.001 461.001" xml:space="preserve">
|
||||
<g>
|
||||
<path style="fill:#F61C0D;" d="M365.257,67.393H95.744C42.866,67.393,0,110.259,0,163.137v134.728
|
||||
c0,52.878,42.866,95.744,95.744,95.744h269.513c52.878,0,95.744-42.866,95.744-95.744V163.137
|
||||
C461.001,110.259,418.135,67.393,365.257,67.393z M300.506,237.056l-126.06,60.123c-3.359,1.602-7.239-0.847-7.239-4.568V168.607
|
||||
c0-3.774,3.982-6.22,7.348-4.514l126.06,63.881C304.363,229.873,304.298,235.248,300.506,237.056z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 761 B |
|
|
@ -4,6 +4,7 @@ import aiohttp
|
|||
import jwt
|
||||
import hashlib
|
||||
from agents import OpenAIChatCompletionsModel, trace, add_trace_processor
|
||||
import pprint
|
||||
|
||||
# Import helper functions needed for get_agents
|
||||
from .helpers.access import (
|
||||
|
|
@ -96,13 +97,13 @@ async def call_webhook(tool_name: str, args: str, webhook_url: str, signing_secr
|
|||
|
||||
async def call_mcp(tool_name: str, args: str, mcp_server_url: str) -> str:
|
||||
try:
|
||||
print(f"MCP tool called for: {tool_name}")
|
||||
print(f"MCP tool called for: {tool_name} with args: {args} at url: {mcp_server_url}")
|
||||
async with sse_client(url=mcp_server_url) as streams:
|
||||
async with ClientSession(*streams) as session:
|
||||
await session.initialize()
|
||||
jargs = json.loads(args)
|
||||
response = await session.call_tool(tool_name, arguments=jargs)
|
||||
json_output = json.dumps([item.__dict__ for item in response.content], indent=2)
|
||||
json_output = json.dumps(response.content, default=lambda x: x.__dict__ if hasattr(x, '__dict__') else str(x), indent=2)
|
||||
|
||||
return json_output
|
||||
except Exception as e:
|
||||
|
|
@ -112,8 +113,17 @@ async def call_mcp(tool_name: str, args: str, mcp_server_url: str) -> str:
|
|||
async def catch_all(ctx: RunContextWrapper[Any], args: str, tool_name: str, tool_config: dict, complete_request: dict) -> str:
|
||||
try:
|
||||
print(f"Catch all called for tool: {tool_name}")
|
||||
print(f"Args: {args}")
|
||||
print(f"Tool config: {tool_config}")
|
||||
# Pretty print the complete tool call information
|
||||
logging.info("Tool Call Details:\n%s", pprint.pformat({
|
||||
'tool_name': tool_name,
|
||||
'arguments': json.loads(args) if args else {},
|
||||
'config': {
|
||||
'description': tool_config.get('description', ''),
|
||||
'isMcp': tool_config.get('isMcp', False),
|
||||
'mcpServerName': tool_config.get('mcpServerName', ''),
|
||||
'parameters': tool_config.get('parameters', {})
|
||||
}
|
||||
}, indent=2))
|
||||
|
||||
# Create event loop for async operations
|
||||
try:
|
||||
|
|
@ -131,9 +141,13 @@ async def catch_all(ctx: RunContextWrapper[Any], args: str, tool_name: str, tool
|
|||
response_content = await mock_tool(tool_name, args, tool_config.get("description", ""), tool_config.get("mockInstructions", ""))
|
||||
print(response_content)
|
||||
elif tool_config.get("isMcp", False):
|
||||
mcp_server_name = tool_config.get("mcpServerName", "")
|
||||
mcp_servers = complete_request.get("mcpServers", {})
|
||||
mcp_server_url = next((server.get("url", "") for server in mcp_servers if server.get("name") == mcp_server_name), "")
|
||||
mcp_server_url = tool_config.get("mcpServerURL", "")
|
||||
if not mcp_server_url:
|
||||
# Backwards compatibility for old projects
|
||||
mcp_server_name = tool_config.get("mcpServerName", "")
|
||||
mcp_servers = complete_request.get("mcpServers", {})
|
||||
mcp_server_url = next((server.get("url", "") for server in mcp_servers if server.get("name") == mcp_server_name), "")
|
||||
|
||||
response_content = await call_mcp(tool_name, args, mcp_server_url)
|
||||
else:
|
||||
collection = db["projects"]
|
||||
|
|
@ -210,28 +224,41 @@ def get_agents(agent_configs, tool_configs, complete_request):
|
|||
new_tools = []
|
||||
|
||||
for tool_name in agent_config["tools"]:
|
||||
|
||||
tool_config = get_tool_config_by_name(tool_configs, tool_name)
|
||||
|
||||
if tool_config:
|
||||
# Preserve all JSON Schema properties in the tool parameters
|
||||
tool_params = tool_config.get("parameters", {})
|
||||
if isinstance(tool_params, dict):
|
||||
# Ensure we keep all properties from the schema
|
||||
json_schema_properties = [
|
||||
"enum", "default", "minimum", "maximum", "items", "format",
|
||||
"pattern", "minLength", "maxLength", "minItems", "maxItems",
|
||||
"uniqueItems", "multipleOf", "examples"
|
||||
]
|
||||
for prop_name, prop_schema in tool_params.get("properties", {}).items():
|
||||
# Copy all existing JSON Schema properties
|
||||
for schema_prop in json_schema_properties:
|
||||
if schema_prop in prop_schema:
|
||||
prop_schema[schema_prop] = prop_schema[schema_prop]
|
||||
|
||||
external_tools.append({
|
||||
"type": "function",
|
||||
"function": tool_config
|
||||
})
|
||||
|
||||
if tool_name == "web_search":
|
||||
tool = WebSearchTool()
|
||||
|
||||
elif tool_name == "rag_search":
|
||||
tool = get_rag_tool(agent_config, complete_request)
|
||||
|
||||
else:
|
||||
tool = FunctionTool(
|
||||
name=tool_name,
|
||||
description=tool_config["description"],
|
||||
params_json_schema=tool_config["parameters"],
|
||||
params_json_schema=tool_params, # Use the enriched parameters
|
||||
strict_json_schema=False,
|
||||
on_invoke_tool=lambda ctx, args, _tool_name=tool_name, _tool_config=tool_config, _complete_request=complete_request:
|
||||
catch_all(ctx, args, _tool_name, _tool_config, _complete_request)
|
||||
on_invoke_tool=lambda ctx, args, _tool_name=tool_name, _tool_config=tool_config, _complete_request=complete_request:
|
||||
catch_all(ctx, args, _tool_name, _tool_config, _complete_request)
|
||||
)
|
||||
if tool:
|
||||
new_tools.append(tool)
|
||||
|
|
|
|||
|
|
@ -48,6 +48,9 @@ services:
|
|||
- VOICE_API_URL=${VOICE_API_URL}
|
||||
- PROVIDER_DEFAULT_MODEL=${PROVIDER_DEFAULT_MODEL}
|
||||
- RAG_UPLOADS_DIR=/app/uploads
|
||||
- KLAVIS_API_KEY=${KLAVIS_API_KEY}
|
||||
- KLAVIS_GITHUB_CLIENT_ID=${KLAVIS_GITHUB_CLIENT_ID}
|
||||
- KLAVIS_GOOGLE_CLIENT_ID=${KLAVIS_GOOGLE_CLIENT_ID}
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- uploads:/app/uploads
|
||||
|
|
|
|||