mirror of
https://github.com/samanhappy/mcphub.git
synced 2025-12-24 02:39:19 -05:00
feat: Implement tool management features including tool execution and result handling (#152)
This commit is contained in:
@@ -69,9 +69,12 @@ const AddServerForm = ({ onAdd }: AddServerFormProps) => {
|
||||
<div>
|
||||
<button
|
||||
onClick={toggleModal}
|
||||
className="w-full bg-blue-100 text-blue-800 rounded hover:bg-blue-200 py-2 px-4"
|
||||
className="w-full bg-blue-100 text-blue-800 rounded hover:bg-blue-200 py-2 px-4 flex items-center justify-center"
|
||||
>
|
||||
{t('server.addServer')}
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4 mr-2" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fillRule="evenodd" d="M10 3a1 1 0 011 1v5h5a1 1 0 110 2h-5v5a1 1 0 11-2 0v-5H4a1 1 0 110-2h5V4a1 1 0 011-1z" clipRule="evenodd" />
|
||||
</svg>
|
||||
{t('server.add')}
|
||||
</button>
|
||||
|
||||
{modalVisible && (
|
||||
|
||||
@@ -50,7 +50,7 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle }: ServerCardProps) =>
|
||||
const handleToggle = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
if (isToggling || !onToggle) return
|
||||
|
||||
|
||||
setIsToggling(true)
|
||||
try {
|
||||
await onToggle(server, !(server.enabled !== false))
|
||||
@@ -112,26 +112,34 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle }: ServerCardProps) =>
|
||||
<div className="flex items-center space-x-3">
|
||||
<h2 className={`text-xl font-semibold ${server.enabled === false ? 'text-gray-600' : 'text-gray-900'}`}>{server.name}</h2>
|
||||
<StatusBadge status={server.status} />
|
||||
|
||||
|
||||
{/* Tool count display */}
|
||||
<div className="flex items-center px-2 py-1 bg-blue-50 text-blue-700 rounded-full text-sm">
|
||||
<svg className="w-4 h-4 mr-1" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fillRule="evenodd" d="M11.3 1.046A1 1 0 0112 2v5h4a1 1 0 01.82 1.573l-7 10A1 1 0 018 18v-5H4a1 1 0 01-.82-1.573l7-10a1 1 0 011.12-.38z" clipRule="evenodd" />
|
||||
</svg>
|
||||
<span>{server.tools?.length || 0} {t('server.tools')}</span>
|
||||
</div>
|
||||
|
||||
{server.error && (
|
||||
<div className="relative">
|
||||
<div
|
||||
className="cursor-pointer"
|
||||
<div
|
||||
className="cursor-pointer"
|
||||
onClick={handleErrorIconClick}
|
||||
aria-label={t('server.viewErrorDetails')}
|
||||
>
|
||||
<AlertCircle className="text-red-500 hover:text-red-600" size={18} />
|
||||
</div>
|
||||
|
||||
|
||||
{showErrorPopover && (
|
||||
<div
|
||||
<div
|
||||
ref={errorPopoverRef}
|
||||
className="absolute z-10 mt-2 bg-white border border-gray-200 rounded-md shadow-lg p-0 w-120"
|
||||
style={{
|
||||
left: '-231px',
|
||||
top: '24px',
|
||||
maxHeight: '300px',
|
||||
overflowY: 'auto',
|
||||
style={{
|
||||
left: '-231px',
|
||||
top: '24px',
|
||||
maxHeight: '300px',
|
||||
overflowY: 'auto',
|
||||
width: '480px',
|
||||
transform: 'translateX(50%)'
|
||||
}}
|
||||
@@ -140,7 +148,7 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle }: ServerCardProps) =>
|
||||
<div className="flex justify-between items-center sticky top-0 bg-white py-2 px-4 border-b border-gray-200 z-20 shadow-sm">
|
||||
<div className="flex items-center space-x-2">
|
||||
<h4 className="text-sm font-medium text-red-600">{t('server.errorDetails')}</h4>
|
||||
<button
|
||||
<button
|
||||
onClick={copyToClipboard}
|
||||
className="p-1 text-gray-400 hover:text-gray-600 transition-colors"
|
||||
title={t('common.copy')}
|
||||
@@ -148,7 +156,7 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle }: ServerCardProps) =>
|
||||
{copied ? <Check size={14} className="text-green-500" /> : <Copy size={14} />}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setShowErrorPopover(false)
|
||||
@@ -176,19 +184,18 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle }: ServerCardProps) =>
|
||||
<div className="flex items-center">
|
||||
<button
|
||||
onClick={handleToggle}
|
||||
className={`px-3 py-1 text-sm rounded transition-colors ${
|
||||
isToggling
|
||||
? 'bg-gray-200 text-gray-500'
|
||||
: server.enabled !== false
|
||||
? 'bg-green-100 text-green-800 hover:bg-green-200'
|
||||
: 'bg-gray-100 text-gray-800 hover:bg-gray-200'
|
||||
}`}
|
||||
className={`px-3 py-1 text-sm rounded transition-colors ${isToggling
|
||||
? 'bg-gray-200 text-gray-500'
|
||||
: server.enabled !== false
|
||||
? 'bg-green-100 text-green-800 hover:bg-green-200'
|
||||
: 'bg-gray-100 text-gray-800 hover:bg-gray-200'
|
||||
}`}
|
||||
disabled={isToggling}
|
||||
>
|
||||
{isToggling
|
||||
{isToggling
|
||||
? t('common.processing')
|
||||
: server.enabled !== false
|
||||
? t('server.disable')
|
||||
: server.enabled !== false
|
||||
? t('server.disable')
|
||||
: t('server.enable')
|
||||
}
|
||||
</button>
|
||||
@@ -207,10 +214,10 @@ const ServerCard = ({ server, onRemove, onEdit, onToggle }: ServerCardProps) =>
|
||||
|
||||
{isExpanded && server.tools && (
|
||||
<div className="mt-6">
|
||||
<h3 className={`text-lg font-medium ${server.enabled === false ? 'text-gray-600' : 'text-gray-900'} mb-4`}>{t('server.tools')}</h3>
|
||||
<h6 className={`font-medium ${server.enabled === false ? 'text-gray-600' : 'text-gray-900'} mb-4`}>{t('server.tools')}</h6>
|
||||
<div className="space-y-4">
|
||||
{server.tools.map((tool, index) => (
|
||||
<ToolCard key={index} tool={tool} />
|
||||
<ToolCard key={index} server={server.name} tool={tool} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,38 @@
|
||||
import { ChevronDown, ChevronRight, Edit, Trash, Copy, Check, User, Settings, LogOut, Info } from 'lucide-react'
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Edit,
|
||||
Trash,
|
||||
Copy,
|
||||
Check,
|
||||
User,
|
||||
Settings,
|
||||
LogOut,
|
||||
Info,
|
||||
Play,
|
||||
Loader,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
AlertCircle
|
||||
} from 'lucide-react'
|
||||
|
||||
export { ChevronDown, ChevronRight, Edit, Trash, Copy, Check, User, Settings, LogOut, Info }
|
||||
export {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Edit,
|
||||
Trash,
|
||||
Copy,
|
||||
Check,
|
||||
User,
|
||||
Settings,
|
||||
LogOut,
|
||||
Info,
|
||||
Play,
|
||||
Loader,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
AlertCircle
|
||||
}
|
||||
|
||||
const LucideIcons = {
|
||||
ChevronDown,
|
||||
@@ -12,7 +44,12 @@ const LucideIcons = {
|
||||
User,
|
||||
Settings,
|
||||
LogOut,
|
||||
Info
|
||||
Info,
|
||||
Play,
|
||||
Loader,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
AlertCircle
|
||||
}
|
||||
|
||||
export default LucideIcons
|
||||
363
frontend/src/components/ui/DynamicForm.tsx
Normal file
363
frontend/src/components/ui/DynamicForm.tsx
Normal file
@@ -0,0 +1,363 @@
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ToolInputSchema } from '@/types';
|
||||
|
||||
interface JsonSchema {
|
||||
type: string;
|
||||
properties?: Record<string, JsonSchema>;
|
||||
required?: string[];
|
||||
items?: JsonSchema;
|
||||
enum?: any[];
|
||||
description?: string;
|
||||
default?: any;
|
||||
}
|
||||
|
||||
interface DynamicFormProps {
|
||||
schema: ToolInputSchema;
|
||||
onSubmit: (values: Record<string, any>) => void;
|
||||
onCancel: () => void;
|
||||
loading?: boolean;
|
||||
storageKey?: string; // Optional key for localStorage persistence
|
||||
}
|
||||
|
||||
const DynamicForm: React.FC<DynamicFormProps> = ({ schema, onSubmit, onCancel, loading = false, storageKey }) => {
|
||||
const { t } = useTranslation();
|
||||
const [formValues, setFormValues] = useState<Record<string, any>>({});
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
|
||||
// Convert ToolInputSchema to JsonSchema - memoized to prevent infinite re-renders
|
||||
const jsonSchema = useMemo(() => {
|
||||
const convertToJsonSchema = (schema: ToolInputSchema): JsonSchema => {
|
||||
const convertProperty = (prop: unknown): JsonSchema => {
|
||||
if (typeof prop === 'object' && prop !== null) {
|
||||
const obj = prop as any;
|
||||
return {
|
||||
type: obj.type || 'string',
|
||||
description: obj.description,
|
||||
enum: obj.enum,
|
||||
default: obj.default,
|
||||
properties: obj.properties ? Object.fromEntries(
|
||||
Object.entries(obj.properties).map(([key, value]) => [key, convertProperty(value)])
|
||||
) : undefined,
|
||||
required: obj.required,
|
||||
items: obj.items ? convertProperty(obj.items) : undefined,
|
||||
};
|
||||
}
|
||||
return { type: 'string' };
|
||||
};
|
||||
|
||||
return {
|
||||
type: schema.type,
|
||||
properties: schema.properties ? Object.fromEntries(
|
||||
Object.entries(schema.properties).map(([key, value]) => [key, convertProperty(value)])
|
||||
) : undefined,
|
||||
required: schema.required,
|
||||
};
|
||||
};
|
||||
|
||||
return convertToJsonSchema(schema);
|
||||
}, [schema]);
|
||||
|
||||
// Initialize form values with defaults or from localStorage
|
||||
useEffect(() => {
|
||||
const initializeValues = (schema: JsonSchema, path: string = ''): Record<string, any> => {
|
||||
const values: Record<string, any> = {};
|
||||
|
||||
if (schema.type === 'object' && schema.properties) {
|
||||
Object.entries(schema.properties).forEach(([key, propSchema]) => {
|
||||
const fullPath = path ? `${path}.${key}` : key;
|
||||
if (propSchema.default !== undefined) {
|
||||
values[key] = propSchema.default;
|
||||
} else if (propSchema.type === 'string') {
|
||||
values[key] = '';
|
||||
} else if (propSchema.type === 'number' || propSchema.type === 'integer') {
|
||||
values[key] = 0;
|
||||
} else if (propSchema.type === 'boolean') {
|
||||
values[key] = false;
|
||||
} else if (propSchema.type === 'array') {
|
||||
values[key] = [];
|
||||
} else if (propSchema.type === 'object') {
|
||||
values[key] = initializeValues(propSchema, fullPath);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return values;
|
||||
};
|
||||
|
||||
let initialValues = initializeValues(jsonSchema);
|
||||
|
||||
// Try to load saved form data from localStorage
|
||||
if (storageKey) {
|
||||
try {
|
||||
const savedData = localStorage.getItem(storageKey);
|
||||
if (savedData) {
|
||||
const parsedData = JSON.parse(savedData);
|
||||
// Merge saved data with initial values, preserving structure
|
||||
initialValues = { ...initialValues, ...parsedData };
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to load saved form data:', error);
|
||||
}
|
||||
}
|
||||
|
||||
setFormValues(initialValues);
|
||||
}, [jsonSchema, storageKey]);
|
||||
|
||||
const handleInputChange = (path: string, value: any) => {
|
||||
setFormValues(prev => {
|
||||
const newValues = { ...prev };
|
||||
const keys = path.split('.');
|
||||
let current = newValues;
|
||||
|
||||
for (let i = 0; i < keys.length - 1; i++) {
|
||||
if (!current[keys[i]]) {
|
||||
current[keys[i]] = {};
|
||||
}
|
||||
current = current[keys[i]];
|
||||
}
|
||||
|
||||
current[keys[keys.length - 1]] = value;
|
||||
|
||||
// Save to localStorage if storageKey is provided
|
||||
if (storageKey) {
|
||||
try {
|
||||
localStorage.setItem(storageKey, JSON.stringify(newValues));
|
||||
} catch (error) {
|
||||
console.warn('Failed to save form data to localStorage:', error);
|
||||
}
|
||||
}
|
||||
|
||||
return newValues;
|
||||
});
|
||||
|
||||
// Clear error for this field
|
||||
if (errors[path]) {
|
||||
setErrors(prev => {
|
||||
const newErrors = { ...prev };
|
||||
delete newErrors[path];
|
||||
return newErrors;
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const validateForm = (): boolean => {
|
||||
const newErrors: Record<string, string> = {};
|
||||
|
||||
const validateObject = (schema: JsonSchema, values: any, path: string = '') => {
|
||||
if (schema.type === 'object' && schema.properties) {
|
||||
Object.entries(schema.properties).forEach(([key, propSchema]) => {
|
||||
const fullPath = path ? `${path}.${key}` : key;
|
||||
const value = values?.[key];
|
||||
|
||||
// Check required fields
|
||||
if (schema.required?.includes(key) && (value === undefined || value === null || value === '')) {
|
||||
newErrors[fullPath] = `${key} is required`;
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate type
|
||||
if (value !== undefined && value !== null && value !== '') {
|
||||
if (propSchema.type === 'string' && typeof value !== 'string') {
|
||||
newErrors[fullPath] = `${key} must be a string`;
|
||||
} else if (propSchema.type === 'number' && typeof value !== 'number') {
|
||||
newErrors[fullPath] = `${key} must be a number`;
|
||||
} else if (propSchema.type === 'integer' && (!Number.isInteger(value) || typeof value !== 'number')) {
|
||||
newErrors[fullPath] = `${key} must be an integer`;
|
||||
} else if (propSchema.type === 'boolean' && typeof value !== 'boolean') {
|
||||
newErrors[fullPath] = `${key} must be a boolean`;
|
||||
} else if (propSchema.type === 'object' && typeof value === 'object') {
|
||||
validateObject(propSchema, value, fullPath);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
validateObject(jsonSchema, formValues);
|
||||
setErrors(newErrors);
|
||||
return Object.keys(newErrors).length === 0;
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (validateForm()) {
|
||||
onSubmit(formValues);
|
||||
}
|
||||
};
|
||||
|
||||
const renderField = (key: string, propSchema: JsonSchema, path: string = ''): React.ReactNode => {
|
||||
const fullPath = path ? `${path}.${key}` : key;
|
||||
const value = formValues[key];
|
||||
const error = errors[fullPath];
|
||||
|
||||
if (propSchema.type === 'string') {
|
||||
if (propSchema.enum) {
|
||||
return (
|
||||
<div key={fullPath} className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{key}
|
||||
{jsonSchema.required?.includes(key) && <span className="text-red-500 ml-1">*</span>}
|
||||
</label>
|
||||
{propSchema.description && (
|
||||
<p className="text-xs text-gray-500 mb-2">{propSchema.description}</p>
|
||||
)}
|
||||
<select
|
||||
value={value || ''}
|
||||
onChange={(e) => handleInputChange(fullPath, e.target.value)}
|
||||
className={`w-full border rounded-md px-3 py-2 ${error ? 'border-red-500' : 'border-gray-300'} focus:outline-none focus:ring-2 focus:ring-blue-500`}
|
||||
>
|
||||
<option value="">{t('tool.selectOption')}</option>
|
||||
{propSchema.enum.map((option, idx) => (
|
||||
<option key={idx} value={option}>
|
||||
{option}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{error && <p className="text-red-500 text-xs mt-1">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<div key={fullPath} className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{key}
|
||||
{jsonSchema.required?.includes(key) && <span className="text-red-500 ml-1">*</span>}
|
||||
</label>
|
||||
{propSchema.description && (
|
||||
<p className="text-xs text-gray-500 mb-2">{propSchema.description}</p>
|
||||
)}
|
||||
<input
|
||||
type="text"
|
||||
value={value || ''}
|
||||
onChange={(e) => handleInputChange(fullPath, e.target.value)}
|
||||
className={`w-full border rounded-md px-3 py-2 ${error ? 'border-red-500' : 'border-gray-300'} focus:outline-none focus:ring-2 focus:ring-blue-500`}
|
||||
/>
|
||||
{error && <p className="text-red-500 text-xs mt-1">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (propSchema.type === 'number' || propSchema.type === 'integer') {
|
||||
return (
|
||||
<div key={fullPath} className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{key}
|
||||
{jsonSchema.required?.includes(key) && <span className="text-red-500 ml-1">*</span>}
|
||||
</label>
|
||||
{propSchema.description && (
|
||||
<p className="text-xs text-gray-500 mb-2">{propSchema.description}</p>
|
||||
)}
|
||||
<input
|
||||
type="number"
|
||||
step={propSchema.type === 'integer' ? '1' : 'any'}
|
||||
value={value || ''}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value === '' ? '' : propSchema.type === 'integer' ? parseInt(e.target.value) : parseFloat(e.target.value);
|
||||
handleInputChange(fullPath, val);
|
||||
}}
|
||||
className={`w-full border rounded-md px-3 py-2 ${error ? 'border-red-500' : 'border-gray-300'} focus:outline-none focus:ring-2 focus:ring-blue-500`}
|
||||
/>
|
||||
{error && <p className="text-red-500 text-xs mt-1">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (propSchema.type === 'boolean') {
|
||||
return (
|
||||
<div key={fullPath} className="mb-4">
|
||||
<div className="flex items-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={value || false}
|
||||
onChange={(e) => handleInputChange(fullPath, e.target.checked)}
|
||||
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
|
||||
/>
|
||||
<label className="ml-2 block text-sm text-gray-700">
|
||||
{key}
|
||||
{jsonSchema.required?.includes(key) && <span className="text-red-500 ml-1">*</span>}
|
||||
</label>
|
||||
</div>
|
||||
{propSchema.description && (
|
||||
<p className="text-xs text-gray-500 mt-1">{propSchema.description}</p>
|
||||
)}
|
||||
{error && <p className="text-red-500 text-xs mt-1">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// For other types, show as text input with description
|
||||
return (
|
||||
<div key={fullPath} className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||||
{key}
|
||||
{jsonSchema.required?.includes(key) && <span className="text-red-500 ml-1">*</span>}
|
||||
<span className="text-xs text-gray-500 ml-1">({propSchema.type})</span>
|
||||
</label>
|
||||
{propSchema.description && (
|
||||
<p className="text-xs text-gray-500 mb-2">{propSchema.description}</p>
|
||||
)}
|
||||
<input
|
||||
type="text"
|
||||
value={value || ''}
|
||||
onChange={(e) => handleInputChange(fullPath, e.target.value)}
|
||||
placeholder={t('tool.enterValue', { type: propSchema.type })}
|
||||
className={`w-full border rounded-md px-3 py-2 ${error ? 'border-red-500' : 'border-gray-300'} focus:outline-none focus:ring-2 focus:ring-blue-500`}
|
||||
/>
|
||||
{error && <p className="text-red-500 text-xs mt-1">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
if (!jsonSchema.properties) {
|
||||
return (
|
||||
<div className="p-4 bg-gray-50 rounded-md">
|
||||
<p className="text-sm text-gray-600">{t('tool.noParameters')}</p>
|
||||
<div className="flex justify-end space-x-2 mt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="px-4 py-2 text-sm text-gray-600 bg-gray-100 rounded-md hover:bg-gray-200"
|
||||
>
|
||||
{t('tool.cancel')}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onSubmit({})}
|
||||
disabled={loading}
|
||||
className="px-4 py-2 text-sm text-white bg-blue-600 rounded-md hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{loading ? t('tool.running') : t('tool.runTool')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{Object.entries(jsonSchema.properties || {}).map(([key, propSchema]) =>
|
||||
renderField(key, propSchema)
|
||||
)}
|
||||
|
||||
<div className="flex justify-end space-x-2 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCancel}
|
||||
className="px-4 py-2 text-sm text-gray-600 bg-gray-100 rounded-md hover:bg-gray-200"
|
||||
>
|
||||
{t('tool.cancel')}
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="px-4 py-2 text-sm text-white bg-blue-600 rounded-md hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
{loading ? t('tool.running') : t('tool.runTool')}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export default DynamicForm;
|
||||
@@ -1,34 +1,135 @@
|
||||
import { useState } from 'react'
|
||||
import { useState, useCallback } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Tool } from '@/types'
|
||||
import { ChevronDown, ChevronRight } from '@/components/icons/LucideIcons'
|
||||
import { ChevronDown, ChevronRight, Play, Loader } from '@/components/icons/LucideIcons'
|
||||
import { callTool, ToolCallResult } from '@/services/toolService'
|
||||
import DynamicForm from './DynamicForm'
|
||||
import ToolResult from './ToolResult'
|
||||
|
||||
interface ToolCardProps {
|
||||
server: string
|
||||
tool: Tool
|
||||
}
|
||||
|
||||
const ToolCard = ({ tool }: ToolCardProps) => {
|
||||
const ToolCard = ({ tool, server }: ToolCardProps) => {
|
||||
const { t } = useTranslation()
|
||||
const [isExpanded, setIsExpanded] = useState(false)
|
||||
const [showRunForm, setShowRunForm] = useState(false)
|
||||
const [isRunning, setIsRunning] = useState(false)
|
||||
const [result, setResult] = useState<ToolCallResult | null>(null)
|
||||
|
||||
// Generate a unique key for localStorage based on tool name and server
|
||||
const getStorageKey = useCallback(() => {
|
||||
return `mcphub_tool_form_${server ? `${server}_` : ''}${tool.name}`
|
||||
}, [tool.name, server])
|
||||
|
||||
// Clear form data from localStorage
|
||||
const clearStoredFormData = useCallback(() => {
|
||||
localStorage.removeItem(getStorageKey())
|
||||
}, [getStorageKey])
|
||||
|
||||
const handleRunTool = async (arguments_: Record<string, any>) => {
|
||||
setIsRunning(true)
|
||||
try {
|
||||
const result = await callTool({
|
||||
toolName: tool.name,
|
||||
arguments: arguments_,
|
||||
}, server)
|
||||
|
||||
setResult(result)
|
||||
// Clear form data on successful submission
|
||||
// clearStoredFormData()
|
||||
} catch (error) {
|
||||
setResult({
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error occurred',
|
||||
})
|
||||
} finally {
|
||||
setIsRunning(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCancelRun = () => {
|
||||
setShowRunForm(false)
|
||||
// Clear form data when cancelled
|
||||
clearStoredFormData()
|
||||
setResult(null)
|
||||
}
|
||||
|
||||
const handleCloseResult = () => {
|
||||
setResult(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-white shadow rounded-lg p-4 mb-4">
|
||||
<div className="bg-white border border-gray-300 shadow rounded-lg p-4 mb-4">
|
||||
<div
|
||||
className="flex justify-between items-center cursor-pointer"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
>
|
||||
<h3 className="text-lg font-medium text-gray-900">{tool.name}</h3>
|
||||
<button className="text-gray-400 hover:text-gray-600">
|
||||
{isExpanded ? <ChevronDown size={18} /> : <ChevronRight size={18} />}
|
||||
</button>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-lg font-medium text-gray-900">
|
||||
{tool.name}
|
||||
<span className="ml-2 text-sm font-normal text-gray-600">
|
||||
{tool.description || t('tool.noDescription')}
|
||||
</span>
|
||||
</h3>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setIsExpanded(true) // Ensure card is expanded when showing run form
|
||||
setShowRunForm(true)
|
||||
}}
|
||||
className="flex items-center space-x-1 px-3 py-1 text-sm text-blue-600 bg-blue-50 hover:bg-blue-100 rounded-md transition-colors"
|
||||
disabled={isRunning}
|
||||
>
|
||||
{isRunning ? (
|
||||
<Loader size={14} className="animate-spin" />
|
||||
) : (
|
||||
<Play size={14} />
|
||||
)}
|
||||
<span>{isRunning ? t('tool.running') : t('tool.run')}</span>
|
||||
</button>
|
||||
<button className="text-gray-400 hover:text-gray-600">
|
||||
{isExpanded ? <ChevronDown size={18} /> : <ChevronRight size={18} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isExpanded && (
|
||||
<div className="mt-4">
|
||||
<p className="text-gray-600 mb-2">{tool.description || 'No description available'}</p>
|
||||
<div className="bg-gray-50 rounded p-2">
|
||||
<h4 className="text-sm font-medium text-gray-900 mb-2">Input Schema:</h4>
|
||||
<pre className="text-xs text-gray-600 overflow-auto">
|
||||
{JSON.stringify(tool.inputSchema, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
<div className="mt-4 space-y-4">
|
||||
{/* Schema Display */}
|
||||
{!showRunForm && (
|
||||
<div className="bg-gray-50 rounded p-3 border border-gray-300">
|
||||
<h4 className="text-sm font-medium text-gray-900 mb-2">{t('tool.inputSchema')}</h4>
|
||||
<pre className="text-xs text-gray-600 overflow-auto">
|
||||
{JSON.stringify(tool.inputSchema, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Run Form */}
|
||||
{showRunForm && (
|
||||
<div className="border border-gray-300 rounded-lg p-4 bg-blue-50">
|
||||
<h4 className="text-sm font-medium text-gray-900 mb-3">{t('tool.runToolWithName', { name: tool.name })}</h4>
|
||||
<DynamicForm
|
||||
schema={tool.inputSchema || { type: 'object' }}
|
||||
onSubmit={handleRunTool}
|
||||
onCancel={handleCancelRun}
|
||||
loading={isRunning}
|
||||
storageKey={getStorageKey()}
|
||||
/>
|
||||
{/* Tool Result */}
|
||||
{result && (
|
||||
<div className="mt-4">
|
||||
<ToolResult result={result} onClose={handleCloseResult} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
159
frontend/src/components/ui/ToolResult.tsx
Normal file
159
frontend/src/components/ui/ToolResult.tsx
Normal file
@@ -0,0 +1,159 @@
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { CheckCircle, XCircle, AlertCircle } from '@/components/icons/LucideIcons';
|
||||
|
||||
interface ToolResultProps {
|
||||
result: {
|
||||
success: boolean;
|
||||
content?: Array<{
|
||||
type: string;
|
||||
text?: string;
|
||||
[key: string]: any;
|
||||
}>;
|
||||
error?: string;
|
||||
message?: string;
|
||||
};
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const ToolResult: React.FC<ToolResultProps> = ({ result, onClose }) => {
|
||||
const { t } = useTranslation();
|
||||
// Extract content from data.content
|
||||
const content = result.content;
|
||||
|
||||
const renderContent = (content: any): React.ReactNode => {
|
||||
if (Array.isArray(content)) {
|
||||
return content.map((item, index) => (
|
||||
<div key={index} className="mb-3 last:mb-0">
|
||||
{renderContentItem(item)}
|
||||
</div>
|
||||
));
|
||||
}
|
||||
|
||||
return renderContentItem(content);
|
||||
};
|
||||
|
||||
const renderContentItem = (item: any): React.ReactNode => {
|
||||
if (typeof item === 'string') {
|
||||
return (
|
||||
<div className="bg-gray-50 rounded-md p-3">
|
||||
<pre className="whitespace-pre-wrap text-sm text-gray-800 font-mono">{item}</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (typeof item === 'object' && item !== null) {
|
||||
if (item.type === 'text' && item.text) {
|
||||
return (
|
||||
<div className="bg-gray-50 rounded-md p-3">
|
||||
<pre className="whitespace-pre-wrap text-sm text-gray-800 font-mono">{item.text}</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (item.type === 'image' && item.data) {
|
||||
return (
|
||||
<div className="bg-gray-50 rounded-md p-3">
|
||||
<img
|
||||
src={`data:${item.mimeType || 'image/png'};base64,${item.data}`}
|
||||
alt={t('tool.toolResult')}
|
||||
className="max-w-full h-auto rounded-md"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// For other structured content, try to parse as JSON
|
||||
try {
|
||||
const jsonString = typeof item === 'string' ? item : JSON.stringify(item, null, 2);
|
||||
const parsed = typeof item === 'string' ? JSON.parse(item) : item;
|
||||
|
||||
return (
|
||||
<div className="bg-gray-50 rounded-md p-3">
|
||||
<div className="text-xs text-gray-500 mb-2">{t('tool.jsonResponse')}</div>
|
||||
<pre className="text-sm text-gray-800 overflow-auto">{JSON.stringify(parsed, null, 2)}</pre>
|
||||
</div>
|
||||
);
|
||||
} catch {
|
||||
// If not valid JSON, show as string
|
||||
return (
|
||||
<div className="bg-gray-50 rounded-md p-3">
|
||||
<pre className="whitespace-pre-wrap text-sm text-gray-800 font-mono">{String(item)}</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-gray-50 rounded-md p-3">
|
||||
<pre className="whitespace-pre-wrap text-sm text-gray-800 font-mono">{String(item)}</pre>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="border border-gray-300 rounded-lg bg-white shadow-sm">
|
||||
<div className="border-b border-gray-300 px-4 py-3 bg-gray-50 rounded-t-lg">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center space-x-2">
|
||||
{result.success ? (
|
||||
<CheckCircle size={20} className="text-green-500" />
|
||||
) : (
|
||||
<XCircle size={20} className="text-red-500" />
|
||||
)}
|
||||
<div>
|
||||
<h4 className="text-sm font-medium text-gray-900">
|
||||
{t('tool.execution')} {result.success ? t('tool.successful') : t('tool.failed')}
|
||||
</h4>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-gray-600 text-sm"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4">
|
||||
{result.success ? (
|
||||
<div>
|
||||
{result.content && result.content.length > 0 ? (
|
||||
<div>
|
||||
<div className="text-sm text-gray-600 mb-3">{t('tool.result')}</div>
|
||||
{renderContent(result.content)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm text-gray-500 italic">
|
||||
{t('tool.noContent')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
<div className="flex items-center space-x-2 mb-3">
|
||||
<AlertCircle size={16} className="text-red-500" />
|
||||
<span className="text-sm font-medium text-red-700">{t('tool.error')}</span>
|
||||
</div>
|
||||
{content && content.length > 0 ? (
|
||||
<div>
|
||||
<div className="text-sm text-gray-600 mb-3">{t('tool.errorDetails')}</div>
|
||||
{renderContent(content)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="bg-red-50 border border-red-300 rounded-md p-3">
|
||||
<pre className="text-sm text-red-800 whitespace-pre-wrap">
|
||||
{result.error || result.message || t('tool.unknownError')}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ToolResult;
|
||||
@@ -264,6 +264,28 @@
|
||||
"showing": "Showing {{from}}-{{to}} of {{total}} servers",
|
||||
"perPage": "Per page"
|
||||
},
|
||||
"tool": {
|
||||
"run": "Run",
|
||||
"running": "Running...",
|
||||
"runTool": "Run Tool",
|
||||
"cancel": "Cancel",
|
||||
"noDescription": "No description available",
|
||||
"inputSchema": "Input Schema:",
|
||||
"runToolWithName": "Run Tool: {{name}}",
|
||||
"execution": "Tool Execution",
|
||||
"successful": "Successful",
|
||||
"failed": "Failed",
|
||||
"result": "Result:",
|
||||
"error": "Error",
|
||||
"errorDetails": "Error Details:",
|
||||
"noContent": "Tool executed successfully but returned no content.",
|
||||
"unknownError": "Unknown error occurred",
|
||||
"jsonResponse": "JSON Response:",
|
||||
"toolResult": "Tool result",
|
||||
"noParameters": "This tool does not require any parameters.",
|
||||
"selectOption": "Select an option",
|
||||
"enterValue": "Enter {{type}} value"
|
||||
},
|
||||
"settings": {
|
||||
"enableGlobalRoute": "Enable Global Route",
|
||||
"enableGlobalRouteDescription": "Allow connections to /sse endpoint without specifying a group ID",
|
||||
|
||||
@@ -265,6 +265,28 @@
|
||||
"showing": "显示 {{from}}-{{to}}/{{total}} 个服务器",
|
||||
"perPage": "每页显示"
|
||||
},
|
||||
"tool": {
|
||||
"run": "运行",
|
||||
"running": "运行中...",
|
||||
"runTool": "运行工具",
|
||||
"cancel": "取消",
|
||||
"noDescription": "无描述信息",
|
||||
"inputSchema": "输入模式:",
|
||||
"runToolWithName": "运行工具:{{name}}",
|
||||
"execution": "工具执行",
|
||||
"successful": "成功",
|
||||
"failed": "失败",
|
||||
"result": "结果:",
|
||||
"error": "错误",
|
||||
"errorDetails": "错误详情:",
|
||||
"noContent": "工具执行成功但未返回内容。",
|
||||
"unknownError": "发生未知错误",
|
||||
"jsonResponse": "JSON 响应:",
|
||||
"toolResult": "工具结果",
|
||||
"noParameters": "此工具不需要任何参数。",
|
||||
"selectOption": "选择一个选项",
|
||||
"enterValue": "输入{{type}}值"
|
||||
},
|
||||
"settings": {
|
||||
"enableGlobalRoute": "启用全局路由",
|
||||
"enableGlobalRouteDescription": "允许不指定组 ID 就连接到 /sse 端点",
|
||||
|
||||
72
frontend/src/services/toolService.ts
Normal file
72
frontend/src/services/toolService.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { getApiUrl } from '../utils/runtime';
|
||||
import { getToken } from './authService';
|
||||
|
||||
export interface ToolCallRequest {
|
||||
toolName: string;
|
||||
arguments?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface ToolCallResult {
|
||||
success: boolean;
|
||||
content?: Array<{
|
||||
type: string;
|
||||
text?: string;
|
||||
[key: string]: any;
|
||||
}>;
|
||||
error?: string;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Call a MCP tool via the call_tool API
|
||||
*/
|
||||
export const callTool = async (
|
||||
request: ToolCallRequest,
|
||||
server?: string,
|
||||
): Promise<ToolCallResult> => {
|
||||
try {
|
||||
const token = getToken();
|
||||
if (!token) {
|
||||
throw new Error('Authentication token not found. Please log in.');
|
||||
}
|
||||
|
||||
// Construct the URL with optional server parameter
|
||||
const url = server ? `/tools/call/${server}` : '/tools/call';
|
||||
|
||||
const response = await fetch(getApiUrl(url), {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'x-auth-token': token,
|
||||
Authorization: `Bearer ${token}`, // Add bearer auth for MCP routing
|
||||
},
|
||||
body: JSON.stringify({
|
||||
toolName: request.toolName,
|
||||
arguments: request.arguments,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
if (!data.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: data.message || 'Tool call failed',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
content: data.data.content || [],
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error calling tool:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : 'Unknown error occurred',
|
||||
};
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user