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:
766
dexto/packages/webui/components/AgentEditor/FormEditorTabs.tsx
Normal file
766
dexto/packages/webui/components/AgentEditor/FormEditorTabs.tsx
Normal file
@@ -0,0 +1,766 @@
|
||||
/**
|
||||
* FormEditorTabs - Clean tabbed form editor for agent configuration
|
||||
*
|
||||
* Design follows session/server panel patterns:
|
||||
* - Minimal borders, spacing-based hierarchy
|
||||
* - Section headers as uppercase labels
|
||||
* - shadcn Select components
|
||||
*/
|
||||
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from '../ui/tabs';
|
||||
import { Input } from '../ui/input';
|
||||
import { Textarea } from '../ui/textarea';
|
||||
import { Button } from '../ui/button';
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select';
|
||||
import {
|
||||
Settings,
|
||||
Brain,
|
||||
Wrench,
|
||||
Eye,
|
||||
EyeOff,
|
||||
Plus,
|
||||
Trash2,
|
||||
Info,
|
||||
Loader2,
|
||||
ChevronRight,
|
||||
ChevronDown,
|
||||
Server,
|
||||
} from 'lucide-react';
|
||||
import type { AgentConfig, ContributorConfig } from '@dexto/core';
|
||||
import { LLM_PROVIDERS, MCP_SERVER_TYPES } from '@dexto/core';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useDiscovery } from '../hooks/useDiscovery';
|
||||
import { useLLMCatalog } from '../hooks/useLLM';
|
||||
|
||||
// Providers that support custom baseURL
|
||||
const BASE_URL_PROVIDERS = ['openai-compatible', 'litellm'];
|
||||
|
||||
interface FormEditorTabsProps {
|
||||
config: AgentConfig;
|
||||
onChange: (config: AgentConfig) => void;
|
||||
errors?: Record<string, string>;
|
||||
}
|
||||
|
||||
type TabValue = 'model' | 'behavior' | 'tools';
|
||||
|
||||
export default function FormEditorTabs({ config, onChange, errors = {} }: FormEditorTabsProps) {
|
||||
const [activeTab, setActiveTab] = useState<TabValue>('model');
|
||||
|
||||
// Count errors per tab
|
||||
const modelErrors = Object.keys(errors).filter(
|
||||
(k) => k.startsWith('llm.') || k === 'greeting'
|
||||
).length;
|
||||
const behaviorErrors = Object.keys(errors).filter((k) => k.startsWith('systemPrompt')).length;
|
||||
const toolsErrors = Object.keys(errors).filter(
|
||||
(k) =>
|
||||
k.startsWith('mcpServers') ||
|
||||
k.startsWith('internalTools') ||
|
||||
k.startsWith('customTools')
|
||||
).length;
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
value={activeTab}
|
||||
onValueChange={(v) => setActiveTab(v as TabValue)}
|
||||
className="flex flex-col h-full"
|
||||
>
|
||||
<TabsList className="shrink-0">
|
||||
<TabsTrigger
|
||||
value="model"
|
||||
icon={<Settings className="h-3.5 w-3.5" />}
|
||||
badge={modelErrors > 0 ? <ErrorBadge count={modelErrors} /> : undefined}
|
||||
>
|
||||
Model
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="behavior"
|
||||
icon={<Brain className="h-3.5 w-3.5" />}
|
||||
badge={behaviorErrors > 0 ? <ErrorBadge count={behaviorErrors} /> : undefined}
|
||||
>
|
||||
Behavior
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="tools"
|
||||
icon={<Wrench className="h-3.5 w-3.5" />}
|
||||
badge={toolsErrors > 0 ? <ErrorBadge count={toolsErrors} /> : undefined}
|
||||
>
|
||||
Tools
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="model" className="flex-1 overflow-y-auto">
|
||||
<ModelTab config={config} onChange={onChange} errors={errors} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="behavior" className="flex-1 overflow-y-auto">
|
||||
<BehaviorTab config={config} onChange={onChange} errors={errors} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="tools" className="flex-1 overflow-y-auto">
|
||||
<ToolsTab config={config} onChange={onChange} errors={errors} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
);
|
||||
}
|
||||
|
||||
function ErrorBadge({ count }: { count: number }) {
|
||||
return (
|
||||
<span className="ml-1.5 inline-flex items-center justify-center w-4 h-4 text-[10px] font-medium bg-destructive text-destructive-foreground rounded-full">
|
||||
{count}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// MODEL TAB - LLM Configuration
|
||||
// ============================================================================
|
||||
|
||||
interface TabProps {
|
||||
config: AgentConfig;
|
||||
onChange: (config: AgentConfig) => void;
|
||||
errors: Record<string, string>;
|
||||
}
|
||||
|
||||
function ModelTab({ config, onChange, errors }: TabProps) {
|
||||
const [showApiKey, setShowApiKey] = useState(false);
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
|
||||
const { data: catalogData, isLoading: catalogLoading } = useLLMCatalog({ mode: 'grouped' });
|
||||
|
||||
const currentProvider = config.llm?.provider || '';
|
||||
const supportsBaseURL = BASE_URL_PROVIDERS.includes(currentProvider);
|
||||
|
||||
const providerModels = useMemo(() => {
|
||||
if (!catalogData || !('providers' in catalogData) || !currentProvider) return [];
|
||||
const providerData =
|
||||
catalogData.providers[currentProvider as keyof typeof catalogData.providers];
|
||||
if (!providerData?.models) return [];
|
||||
return providerData.models.map((m) => ({
|
||||
id: m.name,
|
||||
displayName: m.displayName || m.name,
|
||||
}));
|
||||
}, [catalogData, currentProvider]);
|
||||
|
||||
const updateLLM = (updates: Partial<NonNullable<AgentConfig['llm']>>) => {
|
||||
onChange({
|
||||
...config,
|
||||
llm: { ...config.llm, ...updates } as AgentConfig['llm'],
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-5 space-y-8">
|
||||
{/* Language Model Section */}
|
||||
<Section title="Language Model">
|
||||
<div className="space-y-4">
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Field label="Provider" required error={errors['llm.provider']}>
|
||||
<Select
|
||||
value={currentProvider}
|
||||
onValueChange={(value) => {
|
||||
updateLLM({
|
||||
provider: value as never,
|
||||
model: '', // Reset model when switching providers
|
||||
...(value &&
|
||||
!BASE_URL_PROVIDERS.includes(value) && {
|
||||
baseURL: undefined,
|
||||
}),
|
||||
});
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Select provider..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{LLM_PROVIDERS.map((p) => (
|
||||
<SelectItem key={p} value={p}>
|
||||
{p.charAt(0).toUpperCase() +
|
||||
p.slice(1).replace(/-/g, ' ')}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
|
||||
<Field label="Model" required error={errors['llm.model']}>
|
||||
{catalogLoading ? (
|
||||
<div className="flex items-center h-9 px-3 text-sm text-muted-foreground border border-input rounded-md">
|
||||
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
||||
Loading...
|
||||
</div>
|
||||
) : providerModels.length > 0 ? (
|
||||
<Select
|
||||
value={config.llm?.model || ''}
|
||||
onValueChange={(value) => updateLLM({ model: value })}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue placeholder="Select model..." />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{providerModels.map((m) => (
|
||||
<SelectItem key={m.id} value={m.id}>
|
||||
{m.displayName}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<Input
|
||||
value={config.llm?.model || ''}
|
||||
onChange={(e) => updateLLM({ model: e.target.value })}
|
||||
placeholder={
|
||||
currentProvider
|
||||
? 'Enter model name'
|
||||
: 'Select provider first'
|
||||
}
|
||||
aria-invalid={!!errors['llm.model']}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<Field label="API Key" hint="Use $ENV_VAR for environment variables">
|
||||
<div className="relative">
|
||||
<Input
|
||||
type={showApiKey ? 'text' : 'password'}
|
||||
value={config.llm?.apiKey ?? ''}
|
||||
onChange={(e) => updateLLM({ apiKey: e.target.value })}
|
||||
placeholder="$ANTHROPIC_API_KEY"
|
||||
className="pr-10"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowApiKey(!showApiKey)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 p-1 rounded hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
{showApiKey ? (
|
||||
<EyeOff className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</Field>
|
||||
|
||||
{/* Base URL - Only for OpenAI-compatible providers */}
|
||||
{supportsBaseURL && (
|
||||
<Field
|
||||
label="Base URL"
|
||||
required
|
||||
hint="Custom API endpoint for this provider"
|
||||
error={errors['llm.baseURL']}
|
||||
>
|
||||
<Input
|
||||
value={config.llm?.baseURL ?? ''}
|
||||
onChange={(e) =>
|
||||
updateLLM({ baseURL: e.target.value || undefined })
|
||||
}
|
||||
placeholder="https://api.example.com/v1"
|
||||
/>
|
||||
</Field>
|
||||
)}
|
||||
|
||||
{/* Advanced Settings */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowAdvanced(!showAdvanced)}
|
||||
className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground transition-colors py-1"
|
||||
>
|
||||
{showAdvanced ? (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
)}
|
||||
<span className="font-medium">Advanced Settings</span>
|
||||
</button>
|
||||
|
||||
{showAdvanced && (
|
||||
<div className="ml-6 space-y-4 pl-4 border-l-2 border-border/30">
|
||||
<Field
|
||||
label="Max Output Tokens"
|
||||
hint="Maximum tokens for model responses"
|
||||
>
|
||||
<Input
|
||||
type="number"
|
||||
value={config.llm?.maxOutputTokens ?? ''}
|
||||
onChange={(e) =>
|
||||
updateLLM({
|
||||
maxOutputTokens: e.target.value
|
||||
? parseInt(e.target.value, 10)
|
||||
: undefined,
|
||||
})
|
||||
}
|
||||
placeholder="4096"
|
||||
min="1"
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
{/* Greeting Section */}
|
||||
<Section title="Greeting">
|
||||
<Field hint="Initial message shown to users">
|
||||
<Input
|
||||
value={config.greeting || ''}
|
||||
onChange={(e) => onChange({ ...config, greeting: e.target.value })}
|
||||
placeholder="Hello! How can I help you today?"
|
||||
/>
|
||||
</Field>
|
||||
</Section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// BEHAVIOR TAB - System Prompt
|
||||
// ============================================================================
|
||||
|
||||
function BehaviorTab({ config, onChange, errors }: TabProps) {
|
||||
const getPromptContent = (): string => {
|
||||
if (!config.systemPrompt) return '';
|
||||
if (typeof config.systemPrompt === 'string') return config.systemPrompt;
|
||||
const primary = config.systemPrompt.contributors?.find((c) => c.type === 'static');
|
||||
return primary && 'content' in primary ? primary.content : '';
|
||||
};
|
||||
|
||||
const updatePromptContent = (content: string) => {
|
||||
if (!config.systemPrompt || typeof config.systemPrompt === 'string') {
|
||||
onChange({
|
||||
...config,
|
||||
systemPrompt: {
|
||||
contributors: [
|
||||
{ id: 'primary', type: 'static', priority: 0, enabled: true, content },
|
||||
],
|
||||
},
|
||||
});
|
||||
} else {
|
||||
const contributors = [...(config.systemPrompt.contributors || [])];
|
||||
const primaryIdx = contributors.findIndex((c) => c.id === 'primary');
|
||||
if (primaryIdx >= 0) {
|
||||
contributors[primaryIdx] = {
|
||||
...contributors[primaryIdx],
|
||||
content,
|
||||
} as ContributorConfig;
|
||||
} else {
|
||||
contributors.unshift({
|
||||
id: 'primary',
|
||||
type: 'static',
|
||||
priority: 0,
|
||||
enabled: true,
|
||||
content,
|
||||
});
|
||||
}
|
||||
onChange({ ...config, systemPrompt: { contributors } });
|
||||
}
|
||||
};
|
||||
|
||||
const hasMultipleContributors =
|
||||
config.systemPrompt &&
|
||||
typeof config.systemPrompt === 'object' &&
|
||||
config.systemPrompt.contributors &&
|
||||
config.systemPrompt.contributors.length > 1;
|
||||
|
||||
return (
|
||||
<div className="p-5 h-full flex flex-col">
|
||||
<Section title="System Prompt" className="flex-1 flex flex-col">
|
||||
<Field error={errors.systemPrompt} className="flex-1 flex flex-col">
|
||||
<Textarea
|
||||
value={getPromptContent()}
|
||||
onChange={(e) => updatePromptContent(e.target.value)}
|
||||
placeholder="You are a helpful assistant..."
|
||||
className="font-mono text-sm resize-none flex-1 min-h-[400px]"
|
||||
/>
|
||||
</Field>
|
||||
{hasMultipleContributors && (
|
||||
<p className="text-xs text-muted-foreground flex items-center gap-1.5 mt-3">
|
||||
<Info className="h-3.5 w-3.5" />
|
||||
This agent has multiple prompt contributors. Edit in YAML for full control.
|
||||
</p>
|
||||
)}
|
||||
</Section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// TOOLS TAB - Internal Tools, Custom Tools, MCP Servers
|
||||
// ============================================================================
|
||||
|
||||
function ToolsTab({ config, onChange, errors }: TabProps) {
|
||||
const { data: discovery, isLoading: discoveryLoading } = useDiscovery();
|
||||
const servers = Object.entries(config.mcpServers || {});
|
||||
|
||||
const enabledInternalTools = (config.internalTools || []) as string[];
|
||||
const toggleInternalTool = (toolName: string) => {
|
||||
const next = enabledInternalTools.includes(toolName)
|
||||
? enabledInternalTools.filter((t) => t !== toolName)
|
||||
: [...enabledInternalTools, toolName];
|
||||
onChange({ ...config, internalTools: next as typeof config.internalTools });
|
||||
};
|
||||
|
||||
const enabledCustomTools = (config.customTools || []).map((t) => t.type);
|
||||
const toggleCustomTool = (toolType: string) => {
|
||||
const current = config.customTools || [];
|
||||
const isEnabled = current.some((t) => t.type === toolType);
|
||||
const next = isEnabled
|
||||
? current.filter((t) => t.type !== toolType)
|
||||
: [...current, { type: toolType }];
|
||||
onChange({ ...config, customTools: next });
|
||||
};
|
||||
|
||||
const toolPolicies = config.toolConfirmation?.toolPolicies || {
|
||||
alwaysAllow: [],
|
||||
alwaysDeny: [],
|
||||
};
|
||||
const alwaysAllowList = toolPolicies.alwaysAllow || [];
|
||||
|
||||
const isToolAutoApproved = (qualifiedName: string) => alwaysAllowList.includes(qualifiedName);
|
||||
|
||||
const toggleToolAutoApprove = (qualifiedName: string) => {
|
||||
const newAlwaysAllow = isToolAutoApproved(qualifiedName)
|
||||
? alwaysAllowList.filter((t) => t !== qualifiedName)
|
||||
: [...alwaysAllowList, qualifiedName];
|
||||
|
||||
onChange({
|
||||
...config,
|
||||
toolConfirmation: {
|
||||
...config.toolConfirmation,
|
||||
toolPolicies: {
|
||||
...toolPolicies,
|
||||
alwaysAllow: newAlwaysAllow,
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const addServer = () => {
|
||||
const newName = `server-${servers.length + 1}`;
|
||||
onChange({
|
||||
...config,
|
||||
mcpServers: {
|
||||
...config.mcpServers,
|
||||
[newName]: { type: 'stdio', command: '', connectionMode: 'lenient' },
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const removeServer = (name: string) => {
|
||||
const newServers = { ...config.mcpServers };
|
||||
delete newServers[name];
|
||||
onChange({ ...config, mcpServers: newServers });
|
||||
};
|
||||
|
||||
const updateServer = (
|
||||
name: string,
|
||||
updates: Partial<NonNullable<AgentConfig['mcpServers']>[string]>
|
||||
) => {
|
||||
const server = config.mcpServers?.[name];
|
||||
if (!server) return;
|
||||
onChange({
|
||||
...config,
|
||||
mcpServers: {
|
||||
...config.mcpServers,
|
||||
[name]: { ...server, ...updates } as NonNullable<AgentConfig['mcpServers']>[string],
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const internalToolsCount = discovery?.internalTools?.length || 0;
|
||||
const customToolsCount = discovery?.customTools?.length || 0;
|
||||
|
||||
return (
|
||||
<div className="p-5 space-y-8">
|
||||
{/* Internal Tools */}
|
||||
<Section title="Internal Tools" description="Built-in capabilities">
|
||||
{discoveryLoading ? (
|
||||
<div className="flex items-center gap-2 py-6 justify-center text-sm text-muted-foreground">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
Loading tools...
|
||||
</div>
|
||||
) : internalToolsCount > 0 ? (
|
||||
<div className="space-y-1">
|
||||
{discovery!.internalTools.map((tool) => {
|
||||
const isEnabled = enabledInternalTools.includes(tool.name);
|
||||
const qualifiedName = `internal--${tool.name}`;
|
||||
const isAutoApproved = isToolAutoApproved(qualifiedName);
|
||||
|
||||
return (
|
||||
<ToolRow
|
||||
key={tool.name}
|
||||
name={tool.name}
|
||||
description={tool.description}
|
||||
isEnabled={isEnabled}
|
||||
isAutoApproved={isAutoApproved}
|
||||
onToggleEnabled={() => toggleInternalTool(tool.name)}
|
||||
onToggleAutoApprove={() => toggleToolAutoApprove(qualifiedName)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<p className="text-xs text-muted-foreground/60 pt-3">
|
||||
{enabledInternalTools.length} of {internalToolsCount} enabled
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground py-4 text-center">
|
||||
No internal tools available
|
||||
</p>
|
||||
)}
|
||||
</Section>
|
||||
|
||||
{/* Custom Tools */}
|
||||
{customToolsCount > 0 && (
|
||||
<Section title="Custom Tools" description="Additional providers">
|
||||
<div className="space-y-1">
|
||||
{discovery!.customTools.map((tool) => {
|
||||
const isEnabled = enabledCustomTools.includes(tool.type);
|
||||
const qualifiedName = `custom--${tool.type}`;
|
||||
const isAutoApproved = isToolAutoApproved(qualifiedName);
|
||||
const displayName = tool.metadata?.displayName || tool.type;
|
||||
const description = tool.metadata?.description;
|
||||
|
||||
return (
|
||||
<ToolRow
|
||||
key={tool.type}
|
||||
name={displayName}
|
||||
description={description}
|
||||
isEnabled={isEnabled}
|
||||
isAutoApproved={isAutoApproved}
|
||||
onToggleEnabled={() => toggleCustomTool(tool.type)}
|
||||
onToggleAutoApprove={() => toggleToolAutoApprove(qualifiedName)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<p className="text-xs text-muted-foreground/60 pt-3">
|
||||
{enabledCustomTools.length} of {customToolsCount} enabled
|
||||
</p>
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* MCP Servers */}
|
||||
<Section title="MCP Servers" description="External tools via Model Context Protocol">
|
||||
{servers.length === 0 ? (
|
||||
<div className="py-8 text-center">
|
||||
<Server className="h-8 w-8 text-muted-foreground/30 mx-auto mb-3" />
|
||||
<p className="text-sm text-muted-foreground mb-4">No servers configured</p>
|
||||
<Button onClick={addServer} variant="outline" size="sm">
|
||||
<Plus className="h-4 w-4 mr-1.5" />
|
||||
Add Server
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{servers.map(([name, server]) => (
|
||||
<ServerCard
|
||||
key={name}
|
||||
name={name}
|
||||
server={server}
|
||||
onUpdate={(updates) => updateServer(name, updates)}
|
||||
onRemove={() => removeServer(name)}
|
||||
errors={errors}
|
||||
/>
|
||||
))}
|
||||
<Button onClick={addServer} variant="outline" size="sm" className="w-full">
|
||||
<Plus className="h-4 w-4 mr-1.5" />
|
||||
Add Server
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ToolRow({
|
||||
name,
|
||||
description,
|
||||
isEnabled,
|
||||
isAutoApproved,
|
||||
onToggleEnabled,
|
||||
onToggleAutoApprove,
|
||||
}: {
|
||||
name: string;
|
||||
description?: string;
|
||||
isEnabled: boolean;
|
||||
isAutoApproved: boolean;
|
||||
onToggleEnabled: () => void;
|
||||
onToggleAutoApprove: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-3 px-3 py-2.5 rounded-lg transition-colors',
|
||||
isEnabled ? 'bg-muted/40' : 'hover:bg-muted/20'
|
||||
)}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isEnabled}
|
||||
onChange={onToggleEnabled}
|
||||
className="h-4 w-4 rounded cursor-pointer shrink-0"
|
||||
/>
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className={cn('text-sm font-medium', !isEnabled && 'text-muted-foreground')}>
|
||||
{name}
|
||||
</span>
|
||||
{description && (
|
||||
<p className="text-xs text-muted-foreground/70 truncate mt-0.5">
|
||||
{description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{isEnabled && (
|
||||
<label className="flex items-center gap-1.5 text-xs text-muted-foreground cursor-pointer shrink-0 px-2 py-1 rounded hover:bg-muted/50 transition-colors">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isAutoApproved}
|
||||
onChange={onToggleAutoApprove}
|
||||
className="h-3 w-3 rounded"
|
||||
/>
|
||||
<span>Auto-approve</span>
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ServerCard({
|
||||
name,
|
||||
server,
|
||||
onUpdate,
|
||||
onRemove,
|
||||
errors,
|
||||
}: {
|
||||
name: string;
|
||||
server: NonNullable<AgentConfig['mcpServers']>[string];
|
||||
onUpdate: (updates: Partial<NonNullable<AgentConfig['mcpServers']>[string]>) => void;
|
||||
onRemove: () => void;
|
||||
errors: Record<string, string>;
|
||||
}) {
|
||||
const isStdio = server.type === 'stdio';
|
||||
|
||||
return (
|
||||
<div className="group p-4 rounded-lg bg-muted/30 hover:bg-muted/40 transition-colors">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex-1 space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm font-medium text-foreground">{name}</span>
|
||||
<Select
|
||||
value={server.type}
|
||||
onValueChange={(type: 'stdio' | 'sse' | 'http') => {
|
||||
if (type === 'stdio') {
|
||||
onUpdate({ type: 'stdio', command: '' } as never);
|
||||
} else {
|
||||
onUpdate({ type, url: '' } as never);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="h-7 w-24 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{MCP_SERVER_TYPES.map((t) => (
|
||||
<SelectItem key={t} value={t}>
|
||||
{t.toUpperCase()}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{isStdio ? (
|
||||
<Input
|
||||
value={'command' in server ? server.command : ''}
|
||||
onChange={(e) => onUpdate({ command: e.target.value } as never)}
|
||||
placeholder="npx -y @modelcontextprotocol/server-filesystem"
|
||||
className="text-sm font-mono"
|
||||
aria-invalid={!!errors[`mcpServers.${name}.command`]}
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
value={'url' in server ? server.url : ''}
|
||||
onChange={(e) => onUpdate({ url: e.target.value } as never)}
|
||||
placeholder="https://mcp.example.com/sse"
|
||||
className="text-sm"
|
||||
aria-invalid={!!errors[`mcpServers.${name}.url`]}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={onRemove}
|
||||
className="h-8 w-8 p-0 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// SHARED COMPONENTS
|
||||
// ============================================================================
|
||||
|
||||
function Section({
|
||||
title,
|
||||
description,
|
||||
className,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
description?: string;
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn('rounded-xl bg-muted/20 p-5', className)}>
|
||||
<div className="mb-4">
|
||||
<h3 className="text-xs font-semibold text-muted-foreground/80 uppercase tracking-wider">
|
||||
{title}
|
||||
</h3>
|
||||
{description && (
|
||||
<p className="text-xs text-muted-foreground/60 mt-0.5">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({
|
||||
label,
|
||||
required,
|
||||
hint,
|
||||
error,
|
||||
className,
|
||||
children,
|
||||
}: {
|
||||
label?: string;
|
||||
required?: boolean;
|
||||
hint?: string;
|
||||
error?: string;
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className={className}>
|
||||
{label && (
|
||||
<label className="block text-xs font-medium text-muted-foreground mb-1.5">
|
||||
{label}
|
||||
{required && <span className="text-destructive ml-0.5">*</span>}
|
||||
</label>
|
||||
)}
|
||||
{children}
|
||||
{hint && !error && (
|
||||
<p className="text-[11px] text-muted-foreground/60 mt-1.5">{hint}</p>
|
||||
)}
|
||||
{error && <p className="text-xs text-destructive mt-1.5">{error}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user