- 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>
571 lines
24 KiB
TypeScript
571 lines
24 KiB
TypeScript
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>
|
|
);
|
|
}
|