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:
@@ -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>
|
||||
);
|
||||
}
|
||||
570
dexto/packages/webui/components/Playground/PlaygroundView.tsx
Normal file
570
dexto/packages/webui/components/Playground/PlaygroundView.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
174
dexto/packages/webui/components/Playground/ServersList.tsx
Normal file
174
dexto/packages/webui/components/Playground/ServersList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
341
dexto/packages/webui/components/Playground/ToolInputForm.tsx
Normal file
341
dexto/packages/webui/components/Playground/ToolInputForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
123
dexto/packages/webui/components/Playground/ToolResult.tsx
Normal file
123
dexto/packages/webui/components/Playground/ToolResult.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
186
dexto/packages/webui/components/Playground/ToolsList.tsx
Normal file
186
dexto/packages/webui/components/Playground/ToolsList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user