feat: Add intelligent auto-router and enhanced integrations

- Add intelligent-router.sh hook for automatic agent routing
- Add AUTO-TRIGGER-SUMMARY.md documentation
- Add FINAL-INTEGRATION-SUMMARY.md documentation
- Complete Prometheus integration (6 commands + 4 tools)
- Complete Dexto integration (12 commands + 5 tools)
- Enhanced Ralph with access to all agents
- Fix /clawd command (removed disable-model-invocation)
- Update hooks.json to v5 with intelligent routing
- 291 total skills now available
- All 21 commands with automatic routing

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
admin
2026-01-28 00:27:56 +04:00
Unverified
parent 3b128ba3bd
commit b52318eeae
1724 changed files with 351216 additions and 0 deletions

View File

@@ -0,0 +1,80 @@
import React from 'react';
import { Clock, CheckCircle, XCircle, History } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { ScrollArea } from '@/components/ui/scroll-area';
export interface ExecutionHistoryItem {
id: string;
toolName: string;
timestamp: Date;
success: boolean;
duration?: number;
}
interface ExecutionHistoryProps {
history: ExecutionHistoryItem[];
}
export function ExecutionHistory({ history }: ExecutionHistoryProps) {
if (history.length === 0) {
return null;
}
const successCount = history.filter((h) => h.success).length;
const failureCount = history.filter((h) => !h.success).length;
return (
<div className="border-t border-border pt-4 mt-4">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<History className="h-4 w-4 text-muted-foreground" />
<h3 className="text-sm font-semibold text-foreground">Execution History</h3>
<Badge variant="secondary" className="text-xs">
{history.length}
</Badge>
</div>
<div className="flex items-center gap-3 text-xs text-muted-foreground">
<div className="flex items-center gap-1">
<CheckCircle className="h-3 w-3 text-green-500" />
<span>{successCount}</span>
</div>
<div className="flex items-center gap-1">
<XCircle className="h-3 w-3 text-red-500" />
<span>{failureCount}</span>
</div>
</div>
</div>
<ScrollArea className="h-32">
<div className="space-y-2">
{history.map((item) => (
<div
key={item.id}
className="flex items-center justify-between p-2 rounded-md bg-muted/50 hover:bg-muted transition-colors"
>
<div className="flex items-center gap-2 min-w-0 flex-1">
{item.success ? (
<CheckCircle className="h-3 w-3 text-green-500 flex-shrink-0" />
) : (
<XCircle className="h-3 w-3 text-red-500 flex-shrink-0" />
)}
<span className="text-sm font-medium truncate">
{item.toolName}
</span>
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
{item.duration && (
<span className="flex items-center gap-1">
<Clock className="h-3 w-3" />
{item.duration}ms
</span>
)}
<span>{new Date(item.timestamp).toLocaleTimeString()}</span>
</div>
</div>
))}
</div>
</ScrollArea>
</div>
);
}

View File

@@ -0,0 +1,570 @@
import React, { useState, useCallback, useRef } from 'react';
import { Link } from '@tanstack/react-router';
import { ArrowLeft, AlertTriangle, CheckCircle, PanelLeftClose, PanelLeft } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Alert, AlertDescription } from '@/components/ui/alert';
import ConnectServerModal from '../ConnectServerModal';
import { ServersList } from './ServersList';
import { ToolsList } from './ToolsList';
import { ToolInputForm } from './ToolInputForm';
import { ToolResult } from './ToolResult';
import { ExecutionHistory, type ExecutionHistoryItem } from './ExecutionHistory';
import type { ToolResult as ToolResultType } from '@dexto/core';
import { cn } from '@/lib/utils';
import { client } from '@/lib/client';
import { useServers, useServerTools } from '../hooks/useServers';
import type { McpServer, McpTool } from '../hooks/useServers';
export default function PlaygroundView() {
const [selectedServer, setSelectedServer] = useState<McpServer | null>(null);
const [selectedTool, setSelectedTool] = useState<McpTool | null>(null);
const [toolInputs, setToolInputs] = useState<Record<string, any>>({});
const [toolResult, setToolResult] = useState<ToolResultType | null>(null);
const [currentError, setCurrentError] = useState<string | null>(null);
const [inputErrors, setInputErrors] = useState<Record<string, string>>({});
const [isConnectModalOpen, setIsConnectModalOpen] = useState(false);
const [executionLoading, setExecutionLoading] = useState(false);
const [executionHistory, setExecutionHistory] = useState<ExecutionHistoryItem[]>([]);
const [clipboardNotification, setClipboardNotification] = useState<{
message: string;
type: 'success' | 'error';
} | null>(null);
// Search states
const [serverSearchQuery, setServerSearchQuery] = useState('');
const [toolSearchQuery, setToolSearchQuery] = useState('');
// Responsive sidebar states
const [showServersSidebar, setShowServersSidebar] = useState(true);
const [showToolsSidebar, setShowToolsSidebar] = useState(true);
const executionAbortControllerRef = useRef<AbortController | null>(null);
const {
data: servers = [],
isLoading: serversLoading,
error: serversError,
refetch: refetchServers,
} = useServers();
const {
data: tools = [],
isLoading: toolsLoading,
error: toolsError,
} = useServerTools(
selectedServer?.id || null,
!!selectedServer && selectedServer.status === 'connected'
);
const handleError = (message: string, area?: 'servers' | 'tools' | 'execution' | 'input') => {
console.error(`Playground Error (${area || 'general'}):`, message);
if (area !== 'input') {
setCurrentError(message);
}
};
const handleServerSelect = useCallback((server: McpServer) => {
setSelectedServer(server);
setSelectedTool(null);
setToolResult(null);
setCurrentError(null);
setInputErrors({});
}, []);
const handleToolSelect = useCallback((tool: McpTool) => {
setSelectedTool(tool);
setToolResult(null);
setCurrentError(null);
setInputErrors({});
const defaultInputs: Record<string, any> = {};
if (tool.inputSchema && tool.inputSchema.properties) {
for (const key in tool.inputSchema.properties) {
const prop = tool.inputSchema.properties[key];
if (prop.default !== undefined) {
defaultInputs[key] = prop.default;
} else {
if (prop.type === 'boolean') defaultInputs[key] = false;
else if (prop.type === 'number' || prop.type === 'integer')
defaultInputs[key] = '';
else if (prop.type === 'object' || prop.type === 'array')
defaultInputs[key] = '';
else defaultInputs[key] = '';
}
}
}
setToolInputs(defaultInputs);
}, []);
const handleInputChange = useCallback(
(
inputName: string,
value: any,
type?: 'string' | 'number' | 'integer' | 'boolean' | 'object' | 'array'
) => {
setToolInputs((prev) => ({ ...prev, [inputName]: value }));
if (inputErrors[inputName]) {
setInputErrors((prev) => ({ ...prev, [inputName]: '' }));
}
if (type === 'object' || type === 'array') {
if (value === '') return;
try {
JSON.parse(value);
} catch {
setInputErrors((prev) => ({ ...prev, [inputName]: 'Invalid JSON format' }));
return;
}
}
},
[inputErrors]
);
const validateInputs = useCallback((): boolean => {
if (!selectedTool || !selectedTool.inputSchema || !selectedTool.inputSchema.properties) {
return true;
}
const currentInputErrors: Record<string, string> = {};
let allValid = true;
for (const key in selectedTool.inputSchema.properties) {
const prop = selectedTool.inputSchema.properties[key];
const value = toolInputs[key];
if (selectedTool.inputSchema.required?.includes(key)) {
if (
value === undefined ||
value === '' ||
(prop.type === 'boolean' && typeof value !== 'boolean')
) {
currentInputErrors[key] = 'This field is required.';
allValid = false;
continue;
}
}
if (
(prop.type === 'number' || prop.type === 'integer') &&
value !== '' &&
isNaN(Number(value))
) {
currentInputErrors[key] = 'Must be a valid number.';
allValid = false;
}
if ((prop.type === 'object' || prop.type === 'array') && value !== '') {
try {
JSON.parse(value as string);
} catch {
currentInputErrors[key] = 'Invalid JSON format.';
allValid = false;
}
}
}
setInputErrors(currentInputErrors);
return allValid;
}, [selectedTool, toolInputs]);
const handleExecuteTool = useCallback(async () => {
if (!selectedServer || !selectedTool) {
handleError('No server or tool selected for execution.', 'execution');
return;
}
executionAbortControllerRef.current?.abort();
const controller = new AbortController();
executionAbortControllerRef.current = controller;
setCurrentError(null);
setToolResult(null);
if (!validateInputs()) {
handleError('Please correct the input errors.', 'input');
return;
}
const executionStart = Date.now();
const executionId = `exec_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
setExecutionLoading(true);
try {
const processedInputs: Record<string, any> = {};
if (selectedTool.inputSchema && selectedTool.inputSchema.properties) {
for (const key in selectedTool.inputSchema.properties) {
const prop = selectedTool.inputSchema.properties[key];
let value = toolInputs[key];
if (prop.type === 'number') {
value = value === '' ? undefined : Number(value);
} else if (prop.type === 'integer') {
if (value === '') {
value = undefined;
} else {
const num = Number(value);
if (!Number.isInteger(num)) {
setInputErrors((prev) => ({
...prev,
[key]: 'Must be a valid integer.',
}));
setExecutionLoading(false);
return;
}
value = num;
}
} else if (prop.type === 'boolean') {
if (typeof value === 'string') {
value = value === 'true';
} else {
value = Boolean(value);
}
} else if (
(prop.type === 'object' || prop.type === 'array') &&
typeof value === 'string' &&
value.trim() !== ''
) {
try {
value = JSON.parse(value);
} catch {
setInputErrors((prev) => ({
...prev,
[key]: 'Invalid JSON before sending.',
}));
setExecutionLoading(false);
return;
}
} else if (
(prop.type === 'object' || prop.type === 'array') &&
(value === undefined || value === '')
) {
value = undefined;
}
if (value !== undefined) {
processedInputs[key] = value;
}
}
}
const response = await client.api.mcp.servers[':serverId'].tools[
':toolName'
].execute.$post(
{
param: {
serverId: selectedServer.id,
toolName: selectedTool.id,
},
json: processedInputs,
},
{ init: { signal: controller.signal } }
);
if (!response.ok) {
throw new Error('Tool execution failed');
}
const resultData = await response.json();
const duration = Date.now() - executionStart;
setToolResult(resultData);
setExecutionHistory((prev) => [
{
id: executionId,
toolName: selectedTool.name,
timestamp: new Date(),
success: true,
duration,
},
...prev.slice(0, 9),
]);
} catch (err: any) {
if (err.name !== 'AbortError') {
const duration = Date.now() - executionStart;
handleError(err.message, 'execution');
if (
err.message &&
(!toolResult || toolResult.success || toolResult.error !== err.message)
) {
setToolResult({ success: false, error: err.message });
}
setExecutionHistory((prev) => [
{
id: executionId,
toolName: selectedTool?.name || 'Unknown',
timestamp: new Date(),
success: false,
duration,
},
...prev.slice(0, 9),
]);
}
} finally {
if (!controller.signal.aborted) {
setExecutionLoading(false);
}
}
}, [selectedServer, selectedTool, toolInputs, validateInputs, toolResult]);
const handleModalClose = () => {
setIsConnectModalOpen(false);
refetchServers();
};
const copyToClipboard = async (text: string, successMessage?: string) => {
try {
await navigator.clipboard.writeText(text);
setClipboardNotification({
message: successMessage || 'Copied to clipboard',
type: 'success',
});
setTimeout(() => setClipboardNotification(null), 3000);
} catch (err) {
setClipboardNotification({
message: 'Failed to copy to clipboard. Please check browser permissions.',
type: 'error',
});
setTimeout(() => setClipboardNotification(null), 5000);
console.error('Failed to copy to clipboard:', err);
}
};
const copyToolConfiguration = () => {
if (!selectedTool || !selectedServer) return;
const config = {
server: selectedServer.name,
tool: selectedTool.name,
inputs: toolInputs,
timestamp: new Date().toISOString(),
};
copyToClipboard(JSON.stringify(config, null, 2), 'Tool configuration copied!');
};
const copyToolResult = () => {
if (!toolResult) return;
const resultText =
typeof toolResult.data === 'object'
? JSON.stringify(toolResult.data, null, 2)
: String(toolResult.data);
copyToClipboard(resultText, 'Tool result copied!');
};
const shareToolConfig = () => {
if (!selectedTool || !selectedServer) return;
const shareText = `Check out this Dexto tool configuration:\n\nServer: ${selectedServer.name}\nTool: ${selectedTool.name}\nInputs: ${JSON.stringify(toolInputs, null, 2)}`;
if (navigator.share) {
navigator.share({
title: `Dexto Tool: ${selectedTool.name}`,
text: shareText,
});
} else {
copyToClipboard(shareText, 'Tool configuration copied for sharing!');
}
};
return (
<div className="flex h-screen bg-background text-foreground antialiased">
{/* Servers Sidebar */}
<aside
className={cn(
'w-72 flex-shrink-0 border-r border-border bg-card p-4 flex flex-col transition-all duration-300',
'lg:relative lg:translate-x-0',
showServersSidebar
? 'translate-x-0'
: '-translate-x-full absolute lg:w-0 lg:p-0 lg:border-0'
)}
>
{showServersSidebar && (
<>
<div className="flex items-center justify-between pb-3 mb-3 border-b border-border">
<Link to="/">
<Button variant="outline" size="sm" className="gap-1.5">
<ArrowLeft className="h-4 w-4" />
Back
</Button>
</Link>
<Button
variant="ghost"
size="sm"
onClick={() => setShowServersSidebar(false)}
className="lg:hidden"
>
<PanelLeftClose className="h-4 w-4" />
</Button>
</div>
<ServersList
servers={servers}
selectedServer={selectedServer}
isLoading={serversLoading}
error={serversError?.message || currentError}
searchQuery={serverSearchQuery}
onSearchChange={setServerSearchQuery}
onServerSelect={handleServerSelect}
onConnectNew={() => setIsConnectModalOpen(true)}
/>
</>
)}
</aside>
{/* Tools Sidebar */}
<aside
className={cn(
'w-80 flex-shrink-0 border-r border-border bg-card p-4 flex flex-col transition-all duration-300',
'lg:relative lg:translate-x-0',
showToolsSidebar
? 'translate-x-0'
: '-translate-x-full absolute lg:w-0 lg:p-0 lg:border-0'
)}
>
{showToolsSidebar && (
<ToolsList
tools={tools}
selectedTool={selectedTool}
selectedServer={selectedServer}
isLoading={toolsLoading}
error={
toolsError?.message ||
(selectedServer?.status === 'connected' ? currentError : null)
}
searchQuery={toolSearchQuery}
onSearchChange={setToolSearchQuery}
onToolSelect={handleToolSelect}
/>
)}
</aside>
{/* Main Content */}
<main className="flex-1 p-6 flex flex-col bg-muted/30 overflow-y-auto">
{/* Header */}
<div className="pb-3 mb-4 border-b border-border">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{!showServersSidebar && (
<Button
variant="outline"
size="sm"
onClick={() => setShowServersSidebar(true)}
className="lg:hidden"
>
<PanelLeft className="h-4 w-4" />
</Button>
)}
{!showToolsSidebar && (
<Button
variant="outline"
size="sm"
onClick={() => setShowToolsSidebar(true)}
className="lg:hidden"
>
<PanelLeft className="h-4 w-4" />
</Button>
)}
<h2 className="text-lg font-semibold text-foreground">Tool Runner</h2>
</div>
</div>
</div>
{/* Clipboard Notification */}
{clipboardNotification && (
<Alert
variant={clipboardNotification.type === 'error' ? 'destructive' : 'default'}
className={cn(
'mb-4',
clipboardNotification.type === 'success' &&
'border-green-200 bg-green-50 text-green-800 dark:border-green-800 dark:bg-green-950 dark:text-green-400'
)}
>
{clipboardNotification.type === 'error' && (
<AlertTriangle className="h-4 w-4" />
)}
{clipboardNotification.type === 'success' && (
<CheckCircle className="h-4 w-4" />
)}
<AlertDescription>{clipboardNotification.message}</AlertDescription>
</Alert>
)}
{/* Error Display */}
{currentError && selectedTool && (!toolResult || !toolResult.success) && (
<div className="mb-4 p-3 border border-destructive/50 bg-destructive/10 rounded-md text-destructive text-sm">
<p className="font-medium">Error:</p>
<p>{currentError}</p>
</div>
)}
{/* Empty State */}
{!selectedTool && (
<div className="flex items-center justify-center h-full">
<div className="text-center max-w-md">
<div className="mb-4">
<ArrowLeft className="h-12 w-12 mx-auto text-muted-foreground opacity-50" />
</div>
<h3 className="text-lg font-semibold mb-2">Select a Tool</h3>
<p className="text-muted-foreground text-sm">
Choose a tool from the left panel to start testing and experimenting
with MCP capabilities.
</p>
</div>
</div>
)}
{/* Tool Content */}
{selectedTool && (
<div className="space-y-6">
{/* Tool Info Card */}
<div className="p-4 border border-border rounded-lg bg-card shadow-sm">
<div className="flex justify-between items-start">
<div>
<h3 className="text-base font-semibold text-primary mb-1">
{selectedTool.name}
</h3>
{selectedTool.description && (
<p className="text-sm text-muted-foreground">
{selectedTool.description}
</p>
)}
</div>
<div className="text-right text-xs text-muted-foreground">
<p>Server: {selectedServer?.name}</p>
{executionHistory.filter(
(h) => h.toolName === selectedTool.name
).length > 0 && (
<p>
Runs:{' '}
{
executionHistory.filter(
(h) => h.toolName === selectedTool.name
).length
}
</p>
)}
</div>
</div>
</div>
{/* Tool Input Form */}
<ToolInputForm
tool={selectedTool}
inputs={toolInputs}
errors={inputErrors}
isLoading={executionLoading}
onInputChange={handleInputChange}
onSubmit={handleExecuteTool}
onCopyConfig={copyToolConfiguration}
onShareConfig={shareToolConfig}
/>
{/* Tool Result */}
{toolResult && (
<ToolResult
result={toolResult}
toolName={selectedTool.name}
onCopyResult={copyToolResult}
/>
)}
{/* Execution History */}
<ExecutionHistory history={executionHistory} />
</div>
)}
</main>
<ConnectServerModal isOpen={isConnectModalOpen} onClose={handleModalClose} />
</div>
);
}

View File

@@ -0,0 +1,174 @@
import React from 'react';
import { Server, Check, AlertCircle, Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import { cn } from '@/lib/utils';
import type { McpServer } from '@/components/hooks/useServers';
interface ServersListProps {
servers: McpServer[];
selectedServer: McpServer | null;
isLoading: boolean;
error: string | null;
searchQuery: string;
onSearchChange: (query: string) => void;
onServerSelect: (server: McpServer) => void;
onConnectNew: () => void;
}
export function ServersList({
servers,
selectedServer,
isLoading,
error,
searchQuery,
onSearchChange,
onServerSelect,
onConnectNew,
}: ServersListProps) {
const filteredServers = servers.filter((server) =>
server.name.toLowerCase().includes(searchQuery.toLowerCase())
);
const getStatusIcon = (status: McpServer['status']) => {
switch (status) {
case 'connected':
return <Check className="h-3 w-3" />;
case 'error':
return <AlertCircle className="h-3 w-3" />;
case 'disconnected':
default:
return <Loader2 className="h-3 w-3 animate-spin" />;
}
};
const getStatusColor = (status: McpServer['status']) => {
switch (status) {
case 'connected':
return 'bg-green-100 text-green-700 dark:bg-green-700/20 dark:text-green-400';
case 'error':
return 'bg-red-100 text-red-700 dark:bg-red-700/20 dark:text-red-400';
case 'disconnected':
return 'bg-slate-100 text-slate-600 dark:bg-slate-700/20 dark:text-slate-400';
default:
return 'bg-yellow-100 text-yellow-700 dark:bg-yellow-700/20 dark:text-yellow-400';
}
};
return (
<div className="flex flex-col h-full">
{/* Header */}
<div className="pb-3 mb-3 border-b border-border">
<div className="flex items-center gap-2 mb-3">
<Server className="h-4 w-4 text-muted-foreground" />
<h2 className="text-sm font-semibold text-foreground">MCP Servers</h2>
{isLoading && servers.length === 0 && (
<Loader2 className="h-3 w-3 animate-spin text-muted-foreground ml-auto" />
)}
</div>
<Input
placeholder="Search servers..."
value={searchQuery}
onChange={(e) => onSearchChange(e.target.value)}
className="h-8 text-sm"
/>
</div>
{/* Error State */}
{error && servers.length === 0 && !isLoading && (
<div className="p-3 bg-destructive/10 text-destructive text-sm rounded-md">
<p className="font-medium">Error loading servers</p>
<p className="text-xs mt-1">{error}</p>
</div>
)}
{/* Loading State */}
{isLoading && servers.length === 0 && (
<div className="flex-1 space-y-2 pr-1">
{[1, 2, 3].map((i) => (
<div key={i} className="p-2.5 rounded-lg border border-border">
<div className="flex items-center justify-between gap-2">
<Skeleton className="h-4 flex-1" />
<Skeleton className="h-5 w-16" />
</div>
</div>
))}
</div>
)}
{/* Empty State */}
{servers.length === 0 && !isLoading && !error && (
<div className="flex-1 flex items-center justify-center p-4">
<div className="text-center">
<Server className="h-8 w-8 mx-auto text-muted-foreground/50 mb-2" />
<p className="text-sm text-muted-foreground">No servers available</p>
<p className="text-xs text-muted-foreground mt-1">
Connect a server to get started
</p>
</div>
</div>
)}
{/* Servers List */}
{filteredServers.length > 0 && (
<div className="flex-1 overflow-y-auto space-y-1 pr-1">
{filteredServers.map((server) => (
<button
key={server.id}
onClick={() => server.status === 'connected' && onServerSelect(server)}
disabled={server.status !== 'connected'}
className={cn(
'w-full p-2.5 rounded-lg text-left transition-all duration-200',
'hover:shadow-sm border border-transparent',
selectedServer?.id === server.id
? 'bg-primary text-primary-foreground shadow-sm border-primary/20'
: 'hover:bg-muted hover:border-border',
server.status !== 'connected' && 'opacity-50 cursor-not-allowed'
)}
title={
server.status !== 'connected'
? `${server.name} is ${server.status}`
: server.name
}
>
<div className="flex items-center justify-between gap-2">
<span className="font-medium text-sm truncate">{server.name}</span>
<Badge
variant="secondary"
className={cn(
'text-xs px-1.5 py-0 h-5 flex items-center gap-1',
getStatusColor(server.status)
)}
>
{getStatusIcon(server.status)}
{server.status}
</Badge>
</div>
</button>
))}
</div>
)}
{/* No Results */}
{filteredServers.length === 0 && servers.length > 0 && (
<div className="flex-1 flex items-center justify-center p-4">
<p className="text-sm text-muted-foreground">No servers match your search</p>
</div>
)}
{/* Connect Button */}
<Button
onClick={onConnectNew}
variant="outline"
className="mt-auto w-full sticky bottom-0 bg-background"
size="sm"
>
<Server className="h-4 w-4 mr-2" />
Connect New Server
</Button>
</div>
);
}

View File

@@ -0,0 +1,341 @@
import React, { ChangeEvent } from 'react';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Checkbox } from '@/components/ui/checkbox';
import { Label } from '@/components/ui/label';
import { Button } from '@/components/ui/button';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Copy, Share2, Zap } from 'lucide-react';
import type { McpTool } from '@/components/hooks/useServers';
// Infer the property schema type from the tool's input schema
type JsonSchemaProperty = NonNullable<NonNullable<McpTool['inputSchema']>['properties']>[string];
interface ToolInputFormProps {
tool: McpTool;
inputs: Record<string, any>;
errors: Record<string, string>;
isLoading: boolean;
onInputChange: (
name: string,
value: any,
type?: 'string' | 'number' | 'integer' | 'boolean' | 'object' | 'array'
) => void;
onSubmit: () => void;
onCopyConfig?: () => void;
onShareConfig?: () => void;
}
interface ToolTemplate {
name: string;
description: string;
apply: (tool: McpTool) => Record<string, any>;
}
const toolTemplates: ToolTemplate[] = [
{
name: 'Quick Test',
description: 'Fill with test values',
apply: (tool: McpTool) => {
const defaults: Record<string, any> = {};
if (tool.inputSchema?.properties) {
Object.entries(tool.inputSchema.properties).forEach(
([key, prop]: [string, any]) => {
if (prop.type === 'string') defaults[key] = `test-${key}`;
else if (prop.type === 'number') defaults[key] = 42;
else if (prop.type === 'boolean') defaults[key] = true;
else if (prop.type === 'object') defaults[key] = '{"example": "value"}';
else if (prop.type === 'array') defaults[key] = '["example"]';
}
);
}
return defaults;
},
},
{
name: 'Required Only',
description: 'Fill only required fields',
apply: (tool: McpTool) => {
const defaults: Record<string, any> = {};
if (tool.inputSchema?.properties && tool.inputSchema?.required) {
tool.inputSchema.required.forEach((key: string) => {
const prop = tool.inputSchema!.properties![key];
if (prop.type === 'string') defaults[key] = '';
else if (prop.type === 'number') defaults[key] = '';
else if (prop.type === 'boolean') defaults[key] = false;
else if (prop.type === 'object') defaults[key] = '{}';
else if (prop.type === 'array') defaults[key] = '[]';
});
}
return defaults;
},
},
{
name: 'Clear All',
description: 'Clear all fields',
apply: () => ({}),
},
];
export function ToolInputForm({
tool,
inputs,
errors,
isLoading,
onInputChange,
onSubmit,
onCopyConfig,
onShareConfig,
}: ToolInputFormProps) {
const hasInputs =
tool.inputSchema?.properties && Object.keys(tool.inputSchema.properties).length > 0;
const renderInput = (key: string, prop: JsonSchemaProperty) => {
const isRequired = tool.inputSchema?.required?.includes(key);
const errorMsg = errors[key];
const baseInputClassName = `w-full ${errorMsg ? 'border-destructive focus-visible:ring-destructive' : ''}`;
// Enum select
if (prop.enum && Array.isArray(prop.enum)) {
const isEnumBoolean = prop.enum.every(
(v: string | number | boolean) => typeof v === 'boolean'
);
const isEnumNumeric = prop.enum.every(
(v: string | number | boolean) => typeof v === 'number'
);
return (
<Select
value={
inputs[key] === undefined && prop.default !== undefined
? String(prop.default)
: String(inputs[key] || '')
}
onValueChange={(value) => {
let parsedValue: string | number | boolean = value;
if (isEnumBoolean) parsedValue = value === 'true';
else if (isEnumNumeric) parsedValue = Number(value);
onInputChange(key, parsedValue, prop.type);
}}
disabled={isLoading}
>
<SelectTrigger id={key} className={baseInputClassName}>
<SelectValue
placeholder={`Select ${prop.description || key}${isRequired ? '' : ' (optional)'}...`}
/>
</SelectTrigger>
<SelectContent>
{prop.enum.map((enumValue: string | number | boolean) => (
<SelectItem key={String(enumValue)} value={String(enumValue)}>
{String(enumValue)}
</SelectItem>
))}
</SelectContent>
</Select>
);
}
// Boolean checkbox
if (prop.type === 'boolean') {
return (
<Checkbox
id={key}
checked={
inputs[key] === undefined && prop.default !== undefined
? Boolean(prop.default)
: Boolean(inputs[key])
}
onCheckedChange={(checked) => onInputChange(key, checked, prop.type)}
disabled={isLoading}
className={errorMsg ? 'border-destructive ring-destructive' : ''}
/>
);
}
// Object/Array textarea
if (prop.type === 'object' || prop.type === 'array') {
return (
<Textarea
id={key}
value={
inputs[key] === undefined && prop.default !== undefined
? JSON.stringify(prop.default, null, 2)
: inputs[key] || ''
}
onChange={(e: ChangeEvent<HTMLTextAreaElement>) =>
onInputChange(key, e.target.value, prop.type)
}
rows={5}
className={`${baseInputClassName} font-mono text-sm min-h-[100px] resize-y`}
placeholder={`Enter JSON for ${prop.description || key}`}
disabled={isLoading}
/>
);
}
// String/Number input
let inputFieldType: React.HTMLInputTypeAttribute = 'text';
if (prop.type === 'number' || prop.type === 'integer') inputFieldType = 'number';
if (prop.format === 'date-time') inputFieldType = 'datetime-local';
if (prop.format === 'date') inputFieldType = 'date';
if (prop.format === 'email') inputFieldType = 'email';
if (prop.format === 'uri') inputFieldType = 'url';
if (prop.format === 'password') inputFieldType = 'password';
return (
<Input
type={inputFieldType}
id={key}
value={
inputs[key] === undefined && prop.default !== undefined
? String(prop.default)
: String(inputs[key] || '')
}
onChange={(e: ChangeEvent<HTMLInputElement>) =>
onInputChange(key, e.target.value, prop.type)
}
className={baseInputClassName}
placeholder={prop.description || `Enter ${key}`}
disabled={isLoading}
step={prop.type === 'number' || prop.type === 'integer' ? 'any' : undefined}
/>
);
};
return (
<form
onSubmit={(e) => {
e.preventDefault();
onSubmit();
}}
className="space-y-5 p-4 border border-border rounded-lg bg-card shadow-sm"
>
{/* Quick Fill Templates */}
{hasInputs && (
<div className="border-b border-border pb-4">
<h4 className="text-sm font-medium mb-2 text-muted-foreground">Quick Fill</h4>
<div className="flex flex-wrap gap-2">
{toolTemplates.map((template, index) => (
<Button
key={index}
type="button"
variant="outline"
size="sm"
onClick={() => {
const newInputs = template.apply(tool);
Object.entries(newInputs).forEach(([key, value]) => {
const prop = tool.inputSchema?.properties?.[key];
onInputChange(key, value, prop?.type);
});
}}
className="text-xs"
title={template.description}
>
{template.name}
</Button>
))}
</div>
</div>
)}
{/* Form Inputs */}
{!hasInputs && (
<p className="text-sm text-muted-foreground py-2">
This tool does not require any inputs.
</p>
)}
{hasInputs &&
Object.entries(tool.inputSchema!.properties!).map(([key, prop]) => {
const isRequired = tool.inputSchema?.required?.includes(key);
const errorMsg = errors[key];
return (
<div key={key} className="grid gap-1.5">
<div
className={`flex ${
prop.type === 'boolean'
? 'flex-row items-center space-x-3'
: 'flex-col'
}`}
>
<Label
htmlFor={key}
className={`${
prop.type === 'boolean'
? 'leading-none cursor-pointer peer-disabled:cursor-not-allowed peer-disabled:opacity-70'
: 'capitalize font-medium'
}`}
>
{prop.description ||
key
.replace(/([A-Z]+(?=[A-Z][a-z]))|([A-Z][a-z])/g, ' $&')
.trim()
.replace(/_/g, ' ')}
{isRequired && (
<span className="text-destructive text-lg ml-0.5">*</span>
)}
</Label>
{prop.type === 'boolean' ? (
renderInput(key, prop)
) : (
<div className="w-full">{renderInput(key, prop)}</div>
)}
</div>
{errorMsg && <p className="text-xs text-destructive">{errorMsg}</p>}
</div>
);
})}
{/* Action Buttons */}
<div className="flex flex-wrap gap-2 pt-2">
<Button
type="submit"
disabled={isLoading || Object.keys(errors).some((k) => errors[k] !== '')}
className="flex-1"
>
{isLoading ? (
'Executing...'
) : (
<>
<Zap className="h-4 w-4 mr-2" />
Run Tool
</>
)}
</Button>
{hasInputs && Object.keys(inputs).length > 0 && (
<>
{onCopyConfig && (
<Button
type="button"
variant="outline"
size="sm"
onClick={onCopyConfig}
>
<Copy className="h-3 w-3 mr-2" />
Copy
</Button>
)}
{onShareConfig && (
<Button
type="button"
variant="outline"
size="sm"
onClick={onShareConfig}
>
<Share2 className="h-3 w-3 mr-2" />
Share
</Button>
)}
</>
)}
</div>
</form>
);
}

View File

@@ -0,0 +1,123 @@
import React from 'react';
import { CheckCircle, XCircle, Copy } from 'lucide-react';
import { Button } from '@/components/ui/button';
import type { ToolResult as ToolResultType } from '@dexto/core';
interface ToolResultProps {
result: ToolResultType;
toolName: string;
onCopyResult?: () => void;
}
export function ToolResult({ result, toolName, onCopyResult }: ToolResultProps) {
const renderResultContent = () => {
// Check if this is an image result by examining the data structure
const isImageResult =
result.data &&
typeof result.data === 'object' &&
(Array.isArray(result.data) ||
(typeof result.data === 'object' && Array.isArray((result.data as any).content)));
if (isImageResult && result.data) {
let imgSrc = '';
let imagePart: { data?: string; mimeType?: string; type?: string } | null = null;
let nonImageParts: any[] = [];
if (Array.isArray(result.data)) {
imagePart = result.data.find((part) => part && part.type === 'image');
if (imagePart && typeof imagePart.data === 'string' && imagePart.mimeType) {
imgSrc = `data:${imagePart.mimeType};base64,${imagePart.data}`;
}
} else if (
result.data &&
typeof result.data === 'object' &&
Array.isArray((result.data as any).content)
) {
const partsArray = (result.data as any).content as any[];
imagePart = partsArray.find((part) => part && part.type === 'image');
if (imagePart && typeof imagePart.data === 'string' && imagePart.mimeType) {
imgSrc = `data:${imagePart.mimeType};base64,${imagePart.data}`;
}
nonImageParts = partsArray.filter((part) => part && part.type !== 'image');
} else if (typeof result.data === 'string') {
if (result.data.startsWith('data:image')) {
imgSrc = result.data;
} else if (
result.data.startsWith('http://') ||
result.data.startsWith('https://')
) {
imgSrc = result.data;
}
}
if (imgSrc) {
return (
<img
src={imgSrc}
alt="Tool result"
className="my-2 max-h-96 w-auto rounded-lg border border-border shadow-sm"
/>
);
} else if (nonImageParts.length > 0) {
return (
<div className="space-y-3">
{nonImageParts.map((part, idx) => (
<pre
key={idx}
className="whitespace-pre-wrap text-sm bg-muted/50 p-3 rounded-md border border-border font-mono overflow-x-auto max-h-64"
>
{typeof part === 'object'
? JSON.stringify(part, null, 2)
: String(part)}
</pre>
))}
</div>
);
}
}
// Default result rendering
return (
<pre className="whitespace-pre-wrap text-sm bg-muted/50 p-3 rounded-md border border-border overflow-x-auto">
{typeof result.data === 'object'
? JSON.stringify(result.data, null, 2)
: String(result.data)}
</pre>
);
};
return (
<div className="mt-6 p-4 border border-border rounded-lg bg-card shadow-sm">
<div className="flex justify-between items-center mb-3 pb-3 border-b border-border">
<div className="flex items-center gap-2">
{result.success ? (
<CheckCircle className="h-5 w-5 text-green-500" />
) : (
<XCircle className="h-5 w-5 text-red-500" />
)}
<h3 className="text-base font-semibold text-foreground">
{result.success ? 'Success' : 'Error'}
</h3>
<span className="text-sm text-muted-foreground"> {toolName}</span>
</div>
{onCopyResult && result.success && (
<Button variant="outline" size="sm" onClick={onCopyResult}>
<Copy className="h-3 w-3 mr-2" />
Copy Result
</Button>
)}
</div>
{result.success ? (
<div className="space-y-3">{renderResultContent()}</div>
) : (
<div className="p-3 bg-destructive/10 rounded-md">
<p className="text-sm text-destructive font-semibold">Error executing tool:</p>
<pre className="mt-1 text-xs text-destructive whitespace-pre-wrap break-all">
{result.error || 'Unknown error'}
</pre>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,186 @@
import React from 'react';
import { Wrench, Search, Loader2 } from 'lucide-react';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Skeleton } from '@/components/ui/skeleton';
import { cn } from '@/lib/utils';
import type { McpServer, McpTool } from '@/components/hooks/useServers';
interface ToolsListProps {
tools: McpTool[];
selectedTool: McpTool | null;
selectedServer: McpServer | null;
isLoading: boolean;
error: string | null;
searchQuery: string;
onSearchChange: (query: string) => void;
onToolSelect: (tool: McpTool) => void;
}
export function ToolsList({
tools,
selectedTool,
selectedServer,
isLoading,
error,
searchQuery,
onSearchChange,
onToolSelect,
}: ToolsListProps) {
const filteredTools = tools.filter(
(tool) =>
tool.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
tool.description?.toLowerCase().includes(searchQuery.toLowerCase())
);
return (
<div className="flex flex-col h-full">
{/* Header */}
<div className="pb-3 mb-3 border-b border-border">
<div className="flex items-center gap-2 mb-3">
<Wrench className="h-4 w-4 text-muted-foreground" />
<h2 className="text-sm font-semibold text-foreground">Tools</h2>
{isLoading && tools.length === 0 && (
<Loader2 className="h-3 w-3 animate-spin text-muted-foreground ml-auto" />
)}
{tools.length > 0 && (
<Badge variant="secondary" className="ml-auto text-xs">
{filteredTools.length}
</Badge>
)}
</div>
{tools.length > 0 && (
<div className="relative">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 h-3 w-3 text-muted-foreground" />
<Input
placeholder="Search tools..."
value={searchQuery}
onChange={(e) => onSearchChange(e.target.value)}
className="h-8 text-sm pl-7"
/>
</div>
)}
</div>
{/* No Server Selected */}
{!selectedServer && (
<div className="flex-1 flex items-center justify-center p-4">
<div className="text-center">
<Wrench className="h-8 w-8 mx-auto text-muted-foreground/50 mb-2" />
<p className="text-sm text-muted-foreground">Select a server</p>
<p className="text-xs text-muted-foreground mt-1">
Choose a connected server to view its tools
</p>
</div>
</div>
)}
{/* Server Not Connected */}
{selectedServer && selectedServer.status !== 'connected' && (
<div className="flex-1 flex items-center justify-center p-4">
<div className="text-center">
<Wrench className="h-8 w-8 mx-auto text-muted-foreground/50 mb-2" />
<p className="text-sm text-muted-foreground">Server not connected</p>
<p className="text-xs text-muted-foreground mt-1">
"{selectedServer.name}" is {selectedServer.status}
</p>
</div>
</div>
)}
{/* Error State */}
{error && selectedServer?.status === 'connected' && !isLoading && (
<div className="p-3 bg-destructive/10 text-destructive text-sm rounded-md">
<p className="font-medium">Error loading tools</p>
<p className="text-xs mt-1">{error}</p>
</div>
)}
{/* Loading State */}
{isLoading && selectedServer?.status === 'connected' && tools.length === 0 && (
<div className="flex-1 space-y-2 pr-1">
{[1, 2, 3, 4].map((i) => (
<div key={i} className="p-3 rounded-lg border border-border">
<div className="flex items-start gap-2">
<Skeleton className="h-4 w-4 mt-0.5 flex-shrink-0 rounded-full" />
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-3/4" />
<Skeleton className="h-3 w-full" />
<Skeleton className="h-3 w-5/6" />
</div>
</div>
</div>
))}
</div>
)}
{/* Empty State */}
{selectedServer &&
selectedServer.status === 'connected' &&
!isLoading &&
tools.length === 0 &&
!error && (
<div className="flex-1 flex items-center justify-center p-4">
<div className="text-center">
<Wrench className="h-8 w-8 mx-auto text-muted-foreground/50 mb-2" />
<p className="text-sm text-muted-foreground">No tools available</p>
<p className="text-xs text-muted-foreground mt-1">
No tools found for {selectedServer.name}
</p>
</div>
</div>
)}
{/* Tools List */}
{filteredTools.length > 0 && (
<div className="flex-1 overflow-y-auto space-y-1 pr-1">
{filteredTools.map((tool) => (
<button
key={tool.id}
onClick={() => onToolSelect(tool)}
className={cn(
'w-full p-3 rounded-lg text-left transition-all duration-200',
'hover:shadow-sm border border-transparent',
selectedTool?.id === tool.id
? 'bg-primary text-primary-foreground shadow-sm border-primary/20'
: 'hover:bg-muted hover:border-border'
)}
>
<div className="flex items-start gap-2">
<div className="flex-1 min-w-0">
<h3 className="font-medium text-sm truncate">{tool.name}</h3>
{tool.description && (
<p
className={cn(
'text-xs mt-1 line-clamp-2',
selectedTool?.id === tool.id
? 'text-primary-foreground/80'
: 'text-muted-foreground'
)}
>
{tool.description}
</p>
)}
</div>
</div>
</button>
))}
</div>
)}
{/* No Search Results */}
{filteredTools.length === 0 && tools.length > 0 && (
<div className="flex-1 flex items-center justify-center p-4">
<div className="text-center">
<Search className="h-8 w-8 mx-auto text-muted-foreground/50 mb-2" />
<p className="text-sm text-muted-foreground">No tools match your search</p>
<p className="text-xs text-muted-foreground mt-1">
Try a different search term
</p>
</div>
</div>
)}
</div>
);
}