- 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>
767 lines
31 KiB
TypeScript
767 lines
31 KiB
TypeScript
/**
|
|
* 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>
|
|
);
|
|
}
|