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,304 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Input } from '../../ui/input';
|
||||
import { LabelWithTooltip } from '../../ui/label-with-tooltip';
|
||||
import { Collapsible } from '../../ui/collapsible';
|
||||
import { Eye, EyeOff } from 'lucide-react';
|
||||
import { LLM_PROVIDERS, isReasoningCapableModel, type AgentConfig } from '@dexto/core';
|
||||
|
||||
type LLMConfig = AgentConfig['llm'];
|
||||
|
||||
interface LLMConfigSectionProps {
|
||||
value: LLMConfig;
|
||||
onChange: (value: LLMConfig) => void;
|
||||
errors?: Record<string, string>;
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
errorCount?: number;
|
||||
sectionErrors?: string[];
|
||||
}
|
||||
|
||||
export function LLMConfigSection({
|
||||
value,
|
||||
onChange,
|
||||
errors = {},
|
||||
open,
|
||||
onOpenChange,
|
||||
errorCount = 0,
|
||||
sectionErrors = [],
|
||||
}: LLMConfigSectionProps) {
|
||||
const [showApiKey, setShowApiKey] = useState(false);
|
||||
|
||||
const handleChange = (field: keyof LLMConfig, newValue: string | number | undefined) => {
|
||||
onChange({ ...value, [field]: newValue } as LLMConfig);
|
||||
};
|
||||
|
||||
return (
|
||||
<Collapsible
|
||||
title="LLM Configuration"
|
||||
defaultOpen={true}
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
errorCount={errorCount}
|
||||
sectionErrors={sectionErrors}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{/* Provider */}
|
||||
<div>
|
||||
<LabelWithTooltip
|
||||
htmlFor="provider"
|
||||
tooltip="The LLM provider to use (e.g., OpenAI, Anthropic)"
|
||||
>
|
||||
Provider *
|
||||
</LabelWithTooltip>
|
||||
<select
|
||||
id="provider"
|
||||
value={value.provider || ''}
|
||||
onChange={(e) => handleChange('provider', e.target.value)}
|
||||
aria-invalid={!!errors['llm.provider']}
|
||||
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive"
|
||||
>
|
||||
<option value="">Select provider...</option>
|
||||
{LLM_PROVIDERS.map((p) => (
|
||||
<option key={p} value={p}>
|
||||
{p}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{errors['llm.provider'] && (
|
||||
<p className="text-xs text-destructive mt-1">{errors['llm.provider']}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Model */}
|
||||
<div>
|
||||
<LabelWithTooltip
|
||||
htmlFor="model"
|
||||
tooltip="The specific model identifier (e.g., gpt-5, claude-sonnet-4-5-20250929)"
|
||||
>
|
||||
Model *
|
||||
</LabelWithTooltip>
|
||||
<Input
|
||||
id="model"
|
||||
value={value.model || ''}
|
||||
onChange={(e) => handleChange('model', e.target.value)}
|
||||
placeholder="e.g., gpt-5, claude-sonnet-4-5-20250929"
|
||||
aria-invalid={!!errors['llm.model']}
|
||||
/>
|
||||
{errors['llm.model'] && (
|
||||
<p className="text-xs text-destructive mt-1">{errors['llm.model']}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* API Key */}
|
||||
<div>
|
||||
<LabelWithTooltip
|
||||
htmlFor="apiKey"
|
||||
tooltip="Use $ENV_VAR for environment variables or enter the API key directly"
|
||||
>
|
||||
API Key *
|
||||
</LabelWithTooltip>
|
||||
<div className="relative">
|
||||
<Input
|
||||
id="apiKey"
|
||||
type={showApiKey ? 'text' : 'password'}
|
||||
value={value.apiKey ?? ''}
|
||||
onChange={(e) => handleChange('apiKey', e.target.value)}
|
||||
placeholder="$OPENAI_API_KEY or direct value"
|
||||
aria-invalid={!!errors['llm.apiKey']}
|
||||
className="pr-10"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowApiKey(!showApiKey)}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 hover:bg-accent rounded transition-colors"
|
||||
aria-label={showApiKey ? 'Hide API key' : 'Show API key'}
|
||||
>
|
||||
{showApiKey ? (
|
||||
<EyeOff className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{errors['llm.apiKey'] && (
|
||||
<p className="text-xs text-destructive mt-1">{errors['llm.apiKey']}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Max Iterations */}
|
||||
<div>
|
||||
<LabelWithTooltip
|
||||
htmlFor="maxIterations"
|
||||
tooltip="Maximum number of agent reasoning iterations per turn"
|
||||
>
|
||||
Max Iterations
|
||||
</LabelWithTooltip>
|
||||
<Input
|
||||
id="maxIterations"
|
||||
type="number"
|
||||
value={value.maxIterations !== undefined ? value.maxIterations : ''}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
if (val === '') {
|
||||
handleChange('maxIterations', undefined);
|
||||
} else {
|
||||
const num = parseInt(val, 10);
|
||||
if (!isNaN(num)) {
|
||||
handleChange('maxIterations', num);
|
||||
}
|
||||
}
|
||||
}}
|
||||
min="1"
|
||||
placeholder="50"
|
||||
aria-invalid={!!errors['llm.maxIterations']}
|
||||
/>
|
||||
{errors['llm.maxIterations'] && (
|
||||
<p className="text-xs text-destructive mt-1">
|
||||
{errors['llm.maxIterations']}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Base URL */}
|
||||
<div>
|
||||
<LabelWithTooltip
|
||||
htmlFor="baseURL"
|
||||
tooltip="Custom base URL for the LLM provider (optional, for proxies or custom endpoints)"
|
||||
>
|
||||
Base URL
|
||||
</LabelWithTooltip>
|
||||
<Input
|
||||
id="baseURL"
|
||||
value={value.baseURL || ''}
|
||||
onChange={(e) => handleChange('baseURL', e.target.value || undefined)}
|
||||
placeholder="https://api.openai.com/v1"
|
||||
aria-invalid={!!errors['llm.baseURL']}
|
||||
/>
|
||||
{errors['llm.baseURL'] && (
|
||||
<p className="text-xs text-destructive mt-1">{errors['llm.baseURL']}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Temperature */}
|
||||
<div>
|
||||
<LabelWithTooltip
|
||||
htmlFor="temperature"
|
||||
tooltip="Controls randomness in responses (0.0 = deterministic, 1.0 = creative)"
|
||||
>
|
||||
Temperature
|
||||
</LabelWithTooltip>
|
||||
<Input
|
||||
id="temperature"
|
||||
type="number"
|
||||
value={value.temperature !== undefined ? value.temperature : ''}
|
||||
onChange={(e) =>
|
||||
handleChange(
|
||||
'temperature',
|
||||
e.target.value ? parseFloat(e.target.value) : undefined
|
||||
)
|
||||
}
|
||||
min="0"
|
||||
max="1"
|
||||
step="0.1"
|
||||
placeholder="0.0 - 1.0"
|
||||
aria-invalid={!!errors['llm.temperature']}
|
||||
/>
|
||||
{errors['llm.temperature'] && (
|
||||
<p className="text-xs text-destructive mt-1">{errors['llm.temperature']}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Max Input/Output Tokens */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<LabelWithTooltip
|
||||
htmlFor="maxInputTokens"
|
||||
tooltip="Maximum input tokens to send to the model. If not specified, defaults to model's limit from registry, or 128,000 tokens for custom endpoints"
|
||||
>
|
||||
Max Input Tokens
|
||||
</LabelWithTooltip>
|
||||
<Input
|
||||
id="maxInputTokens"
|
||||
type="number"
|
||||
value={value.maxInputTokens || ''}
|
||||
onChange={(e) =>
|
||||
handleChange(
|
||||
'maxInputTokens',
|
||||
e.target.value ? parseInt(e.target.value, 10) : undefined
|
||||
)
|
||||
}
|
||||
min="1"
|
||||
placeholder="Auto (128k fallback)"
|
||||
aria-invalid={!!errors['llm.maxInputTokens']}
|
||||
/>
|
||||
{errors['llm.maxInputTokens'] && (
|
||||
<p className="text-xs text-destructive mt-1">
|
||||
{errors['llm.maxInputTokens']}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<LabelWithTooltip
|
||||
htmlFor="maxOutputTokens"
|
||||
tooltip="Maximum output tokens the model can generate. If not specified, uses provider's default (typically 4,096 tokens)"
|
||||
>
|
||||
Max Output Tokens
|
||||
</LabelWithTooltip>
|
||||
<Input
|
||||
id="maxOutputTokens"
|
||||
type="number"
|
||||
value={value.maxOutputTokens || ''}
|
||||
onChange={(e) =>
|
||||
handleChange(
|
||||
'maxOutputTokens',
|
||||
e.target.value ? parseInt(e.target.value, 10) : undefined
|
||||
)
|
||||
}
|
||||
min="1"
|
||||
placeholder="Auto (provider default)"
|
||||
aria-invalid={!!errors['llm.maxOutputTokens']}
|
||||
/>
|
||||
{errors['llm.maxOutputTokens'] && (
|
||||
<p className="text-xs text-destructive mt-1">
|
||||
{errors['llm.maxOutputTokens']}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Provider-Specific Options */}
|
||||
|
||||
{/* Reasoning Effort - Only for models that support it (o1, o3, codex, gpt-5.x) */}
|
||||
{value.model && isReasoningCapableModel(value.model) && (
|
||||
<div>
|
||||
<LabelWithTooltip
|
||||
htmlFor="reasoningEffort"
|
||||
tooltip="Controls reasoning depth for OpenAI models (o1, o3, codex, gpt-5.x). Higher = more thorough but slower/costlier. 'medium' is recommended for most tasks."
|
||||
>
|
||||
Reasoning Effort
|
||||
</LabelWithTooltip>
|
||||
<select
|
||||
id="reasoningEffort"
|
||||
value={value.reasoningEffort || ''}
|
||||
onChange={(e) =>
|
||||
handleChange('reasoningEffort', e.target.value || undefined)
|
||||
}
|
||||
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
>
|
||||
<option value="">Auto (model default)</option>
|
||||
<option value="none">None - No reasoning</option>
|
||||
<option value="minimal">Minimal - Barely any reasoning</option>
|
||||
<option value="low">Low - Light reasoning</option>
|
||||
<option value="medium">Medium - Balanced (recommended)</option>
|
||||
<option value="high">High - Thorough reasoning</option>
|
||||
<option value="xhigh">Extra High - Maximum quality</option>
|
||||
</select>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Only applies to reasoning models (o1, o3, codex, gpt-5.x)
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,538 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Input } from '../../ui/input';
|
||||
import { LabelWithTooltip } from '../../ui/label-with-tooltip';
|
||||
import { Button } from '../../ui/button';
|
||||
import { Collapsible } from '../../ui/collapsible';
|
||||
import { Plus, Trash2, ChevronDown, ChevronUp } from 'lucide-react';
|
||||
|
||||
import type { AgentConfig } from '@dexto/core';
|
||||
import { MCP_SERVER_TYPES, MCP_CONNECTION_MODES, DEFAULT_MCP_CONNECTION_MODE } from '@dexto/core';
|
||||
|
||||
type McpServersConfig = NonNullable<AgentConfig['mcpServers']>;
|
||||
|
||||
interface McpServersSectionProps {
|
||||
value: McpServersConfig;
|
||||
onChange: (value: McpServersConfig) => void;
|
||||
errors?: Record<string, string>;
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
errorCount?: number;
|
||||
sectionErrors?: string[];
|
||||
}
|
||||
|
||||
export function McpServersSection({
|
||||
value,
|
||||
onChange,
|
||||
errors = {},
|
||||
open,
|
||||
onOpenChange,
|
||||
errorCount = 0,
|
||||
sectionErrors = [],
|
||||
}: McpServersSectionProps) {
|
||||
const [expandedServers, setExpandedServers] = useState<Set<string>>(new Set());
|
||||
// Local state for text fields that need special parsing (args, env, headers)
|
||||
// Key is "serverName:fieldName", value is the raw string being edited
|
||||
const [editingFields, setEditingFields] = useState<Record<string, string>>({});
|
||||
|
||||
const servers = Object.entries(value || {});
|
||||
|
||||
const toggleServer = (name: string) => {
|
||||
setExpandedServers((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(name)) {
|
||||
next.delete(name);
|
||||
} else {
|
||||
next.add(name);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const addServer = () => {
|
||||
const newName = `server-${Object.keys(value || {}).length + 1}`;
|
||||
onChange({
|
||||
...value,
|
||||
[newName]: {
|
||||
type: 'stdio',
|
||||
command: '',
|
||||
connectionMode: 'strict',
|
||||
},
|
||||
});
|
||||
setExpandedServers((prev) => new Set(prev).add(newName));
|
||||
};
|
||||
|
||||
const removeServer = (name: string) => {
|
||||
const newValue = { ...value };
|
||||
delete newValue[name];
|
||||
onChange(newValue);
|
||||
setExpandedServers((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(name);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const updateServer = (
|
||||
oldName: string,
|
||||
updates: Partial<Record<string, unknown> & { name?: string }>
|
||||
) => {
|
||||
const server = value[oldName];
|
||||
|
||||
// Extract name from updates if present (it's not part of the server config, just used for the key)
|
||||
const { name: newName, ...serverUpdates } = updates;
|
||||
const newServer = { ...server, ...serverUpdates } as McpServersConfig[string];
|
||||
|
||||
// If name changed via updates, handle the name change
|
||||
if (newName && typeof newName === 'string' && newName !== oldName) {
|
||||
// Guard against collision: prevent overwriting an existing server
|
||||
if (value[newName]) {
|
||||
// TODO: Surface a user-facing error via onChange/errors map or toast notification
|
||||
return; // No-op to avoid overwriting an existing server
|
||||
}
|
||||
const newValue = { ...value };
|
||||
delete newValue[oldName];
|
||||
newValue[newName] = newServer;
|
||||
onChange(newValue);
|
||||
|
||||
// Update expanded state
|
||||
setExpandedServers((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(oldName)) {
|
||||
next.delete(oldName);
|
||||
next.add(newName);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
} else {
|
||||
onChange({ ...value, [oldName]: newServer });
|
||||
}
|
||||
};
|
||||
|
||||
// Get the current value for a field (either from editing state or from config)
|
||||
const getFieldValue = (serverName: string, fieldName: string, fallback: string): string => {
|
||||
const key = `${serverName}:${fieldName}`;
|
||||
return editingFields[key] ?? fallback;
|
||||
};
|
||||
|
||||
// Update local editing state while typing
|
||||
const setFieldValue = (serverName: string, fieldName: string, value: string) => {
|
||||
const key = `${serverName}:${fieldName}`;
|
||||
setEditingFields((prev) => ({ ...prev, [key]: value }));
|
||||
};
|
||||
|
||||
// Clear editing state for a field
|
||||
const clearFieldValue = (serverName: string, fieldName: string) => {
|
||||
const key = `${serverName}:${fieldName}`;
|
||||
setEditingFields((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[key];
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
// Parse and commit args on blur
|
||||
const commitArgs = (serverName: string, argsString: string) => {
|
||||
clearFieldValue(serverName, 'args');
|
||||
|
||||
if (!argsString.trim()) {
|
||||
updateServer(serverName, { args: undefined });
|
||||
return;
|
||||
}
|
||||
|
||||
const args = argsString
|
||||
.split(',')
|
||||
.map((arg) => arg.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
updateServer(serverName, { args: args.length > 0 ? args : undefined });
|
||||
};
|
||||
|
||||
// Parse and commit env on blur
|
||||
const commitEnv = (serverName: string, envString: string) => {
|
||||
clearFieldValue(serverName, 'env');
|
||||
|
||||
if (!envString.trim()) {
|
||||
updateServer(serverName, { env: undefined });
|
||||
return;
|
||||
}
|
||||
|
||||
const env: Record<string, string> = {};
|
||||
envString
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.forEach((line) => {
|
||||
const [key, ...valueParts] = line.split('=');
|
||||
if (key && valueParts.length > 0) {
|
||||
env[key.trim()] = valueParts.join('=').trim();
|
||||
}
|
||||
});
|
||||
updateServer(serverName, { env: Object.keys(env).length > 0 ? env : undefined });
|
||||
};
|
||||
|
||||
// Parse and commit headers on blur
|
||||
const commitHeaders = (serverName: string, headersString: string) => {
|
||||
clearFieldValue(serverName, 'headers');
|
||||
|
||||
if (!headersString.trim()) {
|
||||
updateServer(serverName, { headers: undefined });
|
||||
return;
|
||||
}
|
||||
|
||||
const headers: Record<string, string> = {};
|
||||
headersString
|
||||
.split('\n')
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.forEach((line) => {
|
||||
const [key, ...valueParts] = line.split('=');
|
||||
if (key && valueParts.length > 0) {
|
||||
headers[key.trim()] = valueParts.join('=').trim();
|
||||
}
|
||||
});
|
||||
updateServer(serverName, {
|
||||
headers: Object.keys(headers).length > 0 ? headers : undefined,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Collapsible
|
||||
title="MCP Servers"
|
||||
defaultOpen={false}
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
errorCount={errorCount}
|
||||
sectionErrors={sectionErrors}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{servers.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No MCP servers configured</p>
|
||||
) : (
|
||||
servers.map(([name, server]) => {
|
||||
const isExpanded = expandedServers.has(name);
|
||||
return (
|
||||
<div
|
||||
key={name}
|
||||
className="border border-border rounded-lg overflow-hidden"
|
||||
>
|
||||
{/* Server Header */}
|
||||
<div className="flex items-center justify-between px-3 py-2 bg-muted/30">
|
||||
<button
|
||||
onClick={() => toggleServer(name)}
|
||||
className="flex items-center gap-2 flex-1 text-left hover:text-foreground transition-colors"
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
)}
|
||||
<span className="font-medium text-sm">{name}</span>
|
||||
{'command' in server && server.command && (
|
||||
<span className="text-xs text-muted-foreground truncate">
|
||||
({server.command})
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => removeServer(name)}
|
||||
className="h-7 w-7 p-0"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Server Details */}
|
||||
{isExpanded && (
|
||||
<div className="px-3 py-3 space-y-3">
|
||||
{/* Server Name */}
|
||||
<div>
|
||||
<LabelWithTooltip
|
||||
htmlFor={`server-name-${name}`}
|
||||
tooltip="Unique identifier for this MCP server"
|
||||
>
|
||||
Server Name
|
||||
</LabelWithTooltip>
|
||||
<Input
|
||||
id={`server-name-${name}`}
|
||||
value={name}
|
||||
onChange={(e) =>
|
||||
updateServer(name, { name: e.target.value })
|
||||
}
|
||||
placeholder="e.g., filesystem"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Server Type */}
|
||||
<div>
|
||||
<LabelWithTooltip
|
||||
htmlFor={`server-type-${name}`}
|
||||
tooltip="MCP server connection type"
|
||||
>
|
||||
Connection Type *
|
||||
</LabelWithTooltip>
|
||||
<select
|
||||
id={`server-type-${name}`}
|
||||
value={server.type || 'stdio'}
|
||||
onChange={(e) => {
|
||||
const type = e.target.value as
|
||||
| 'stdio'
|
||||
| 'sse'
|
||||
| 'http';
|
||||
if (type === 'stdio') {
|
||||
updateServer(name, {
|
||||
type: 'stdio',
|
||||
command: '',
|
||||
args: undefined,
|
||||
env: undefined,
|
||||
});
|
||||
} else {
|
||||
updateServer(name, {
|
||||
type,
|
||||
url: '',
|
||||
headers: undefined,
|
||||
});
|
||||
}
|
||||
}}
|
||||
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
>
|
||||
{MCP_SERVER_TYPES.map((type) => (
|
||||
<option key={type} value={type}>
|
||||
{type === 'stdio'
|
||||
? 'Standard I/O (stdio)'
|
||||
: type === 'sse'
|
||||
? 'Server-Sent Events (SSE)'
|
||||
: 'HTTP'}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* stdio-specific fields */}
|
||||
{server.type === 'stdio' && (
|
||||
<>
|
||||
{/* Command */}
|
||||
<div>
|
||||
<LabelWithTooltip
|
||||
htmlFor={`server-command-${name}`}
|
||||
tooltip="The command to execute (e.g., npx, node, python)"
|
||||
>
|
||||
Command *
|
||||
</LabelWithTooltip>
|
||||
<Input
|
||||
id={`server-command-${name}`}
|
||||
value={
|
||||
'command' in server
|
||||
? server.command
|
||||
: ''
|
||||
}
|
||||
onChange={(e) =>
|
||||
updateServer(name, {
|
||||
command: e.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="e.g., npx, node, python"
|
||||
aria-invalid={
|
||||
!!errors[`mcpServers.${name}.command`]
|
||||
}
|
||||
/>
|
||||
{errors[`mcpServers.${name}.command`] && (
|
||||
<p className="text-xs text-destructive mt-1">
|
||||
{errors[`mcpServers.${name}.command`]}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Arguments */}
|
||||
<div>
|
||||
<LabelWithTooltip
|
||||
htmlFor={`server-args-${name}`}
|
||||
tooltip="Command arguments, comma-separated"
|
||||
>
|
||||
Arguments
|
||||
</LabelWithTooltip>
|
||||
<Input
|
||||
id={`server-args-${name}`}
|
||||
value={getFieldValue(
|
||||
name,
|
||||
'args',
|
||||
('args' in server && server.args
|
||||
? server.args
|
||||
: []
|
||||
).join(', ')
|
||||
)}
|
||||
onChange={(e) =>
|
||||
setFieldValue(
|
||||
name,
|
||||
'args',
|
||||
e.target.value
|
||||
)
|
||||
}
|
||||
onBlur={(e) =>
|
||||
commitArgs(name, e.target.value)
|
||||
}
|
||||
placeholder="--port, 3000, --host, localhost"
|
||||
className="font-mono"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Environment Variables */}
|
||||
<div>
|
||||
<LabelWithTooltip
|
||||
htmlFor={`server-env-${name}`}
|
||||
tooltip="Environment variables in KEY=value format, one per line"
|
||||
>
|
||||
Environment Variables
|
||||
</LabelWithTooltip>
|
||||
<textarea
|
||||
id={`server-env-${name}`}
|
||||
value={getFieldValue(
|
||||
name,
|
||||
'env',
|
||||
Object.entries(
|
||||
('env' in server && server.env) ||
|
||||
{}
|
||||
)
|
||||
.map(([k, v]) => `${k}=${v}`)
|
||||
.join('\n')
|
||||
)}
|
||||
onChange={(e) =>
|
||||
setFieldValue(
|
||||
name,
|
||||
'env',
|
||||
e.target.value
|
||||
)
|
||||
}
|
||||
onBlur={(e) =>
|
||||
commitEnv(name, e.target.value)
|
||||
}
|
||||
placeholder={`API_KEY=$MY_API_KEY\nPORT=3000`}
|
||||
rows={4}
|
||||
className="flex w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 font-mono"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* sse/http-specific fields */}
|
||||
{(server.type === 'sse' || server.type === 'http') && (
|
||||
<>
|
||||
{/* URL */}
|
||||
<div>
|
||||
<LabelWithTooltip
|
||||
htmlFor={`server-url-${name}`}
|
||||
tooltip="The URL endpoint for the MCP server"
|
||||
>
|
||||
URL *
|
||||
</LabelWithTooltip>
|
||||
<Input
|
||||
id={`server-url-${name}`}
|
||||
value={'url' in server ? server.url : ''}
|
||||
onChange={(e) =>
|
||||
updateServer(name, {
|
||||
url: e.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="https://example.com/mcp"
|
||||
aria-invalid={
|
||||
!!errors[`mcpServers.${name}.url`]
|
||||
}
|
||||
/>
|
||||
{errors[`mcpServers.${name}.url`] && (
|
||||
<p className="text-xs text-destructive mt-1">
|
||||
{errors[`mcpServers.${name}.url`]}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Headers */}
|
||||
<div>
|
||||
<LabelWithTooltip
|
||||
htmlFor={`server-headers-${name}`}
|
||||
tooltip="HTTP headers in KEY=value format, one per line"
|
||||
>
|
||||
Headers
|
||||
</LabelWithTooltip>
|
||||
<textarea
|
||||
id={`server-headers-${name}`}
|
||||
value={getFieldValue(
|
||||
name,
|
||||
'headers',
|
||||
Object.entries(
|
||||
('headers' in server &&
|
||||
server.headers) ||
|
||||
{}
|
||||
)
|
||||
.map(([k, v]) => `${k}=${v}`)
|
||||
.join('\n')
|
||||
)}
|
||||
onChange={(e) =>
|
||||
setFieldValue(
|
||||
name,
|
||||
'headers',
|
||||
e.target.value
|
||||
)
|
||||
}
|
||||
onBlur={(e) =>
|
||||
commitHeaders(name, e.target.value)
|
||||
}
|
||||
placeholder={`Authorization=Bearer token\nContent-Type=application/json`}
|
||||
rows={4}
|
||||
className="flex w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 font-mono"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Connection Mode */}
|
||||
<div>
|
||||
<LabelWithTooltip
|
||||
htmlFor={`server-mode-${name}`}
|
||||
tooltip="Strict mode fails on any error; lenient mode continues despite errors"
|
||||
>
|
||||
Connection Mode
|
||||
</LabelWithTooltip>
|
||||
<select
|
||||
id={`server-mode-${name}`}
|
||||
value={
|
||||
server.connectionMode ||
|
||||
DEFAULT_MCP_CONNECTION_MODE
|
||||
}
|
||||
onChange={(e) =>
|
||||
updateServer(name, {
|
||||
connectionMode: e.target.value as
|
||||
| 'strict'
|
||||
| 'lenient',
|
||||
})
|
||||
}
|
||||
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
>
|
||||
{MCP_CONNECTION_MODES.map((mode) => (
|
||||
<option key={mode} value={mode}>
|
||||
{mode.charAt(0).toUpperCase() +
|
||||
mode.slice(1)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
|
||||
{/* Add Server Button */}
|
||||
<Button onClick={addServer} variant="outline" size="sm" className="w-full">
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Add MCP Server
|
||||
</Button>
|
||||
|
||||
{errors.mcpServers && (
|
||||
<p className="text-xs text-destructive mt-1">{errors.mcpServers}</p>
|
||||
)}
|
||||
</div>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
import React from 'react';
|
||||
import { Input } from '../../ui/input';
|
||||
import { LabelWithTooltip } from '../../ui/label-with-tooltip';
|
||||
import { Collapsible } from '../../ui/collapsible';
|
||||
import type { AgentConfig, CacheType, DatabaseType } from '@dexto/core';
|
||||
import { CACHE_TYPES, DATABASE_TYPES } from '@dexto/core';
|
||||
|
||||
type StorageConfig = NonNullable<AgentConfig['storage']>;
|
||||
|
||||
interface StorageSectionProps {
|
||||
value: StorageConfig;
|
||||
onChange: (value: StorageConfig) => void;
|
||||
errors?: Record<string, string>;
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
errorCount?: number;
|
||||
sectionErrors?: string[];
|
||||
}
|
||||
|
||||
export function StorageSection({
|
||||
value,
|
||||
onChange,
|
||||
errors = {},
|
||||
open,
|
||||
onOpenChange,
|
||||
errorCount = 0,
|
||||
sectionErrors = [],
|
||||
}: StorageSectionProps) {
|
||||
const updateCache = (updates: Partial<Record<string, unknown>>) => {
|
||||
onChange({
|
||||
...value,
|
||||
cache: { ...value.cache, ...updates } as StorageConfig['cache'],
|
||||
});
|
||||
};
|
||||
|
||||
const updateDatabase = (updates: Partial<Record<string, unknown>>) => {
|
||||
onChange({
|
||||
...value,
|
||||
database: { ...value.database, ...updates } as StorageConfig['database'],
|
||||
});
|
||||
};
|
||||
|
||||
const showCacheUrl = value.cache.type === 'redis';
|
||||
const showDatabaseUrl = value.database.type === 'sqlite' || value.database.type === 'postgres';
|
||||
|
||||
return (
|
||||
<Collapsible
|
||||
title="Storage Configuration"
|
||||
defaultOpen={false}
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
errorCount={errorCount}
|
||||
sectionErrors={sectionErrors}
|
||||
>
|
||||
<div className="space-y-6">
|
||||
{/* Cache Configuration */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-medium">Cache</h4>
|
||||
<div>
|
||||
<LabelWithTooltip
|
||||
htmlFor="cache-type"
|
||||
tooltip="Storage backend for caching data (in-memory or Redis)"
|
||||
>
|
||||
Cache Type
|
||||
</LabelWithTooltip>
|
||||
<select
|
||||
id="cache-type"
|
||||
value={value.cache.type}
|
||||
onChange={(e) => updateCache({ type: e.target.value as CacheType })}
|
||||
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
>
|
||||
{CACHE_TYPES.map((type) => (
|
||||
<option key={type} value={type}>
|
||||
{type}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{showCacheUrl && 'url' in value.cache && (
|
||||
<div>
|
||||
<LabelWithTooltip
|
||||
htmlFor="cache-url"
|
||||
tooltip="Redis connection URL (e.g., redis://localhost:6379)"
|
||||
>
|
||||
Redis URL
|
||||
</LabelWithTooltip>
|
||||
<Input
|
||||
id="cache-url"
|
||||
value={value.cache.url || ''}
|
||||
onChange={(e) => updateCache({ url: e.target.value || undefined })}
|
||||
placeholder="redis://localhost:6379"
|
||||
aria-invalid={!!errors['storage.cache.url']}
|
||||
/>
|
||||
{errors['storage.cache.url'] && (
|
||||
<p className="text-xs text-destructive mt-1">
|
||||
{errors['storage.cache.url']}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Database Configuration */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-medium">Database</h4>
|
||||
<div>
|
||||
<LabelWithTooltip
|
||||
htmlFor="database-type"
|
||||
tooltip="Storage backend for persistent data (in-memory, SQLite, or PostgreSQL)"
|
||||
>
|
||||
Database Type
|
||||
</LabelWithTooltip>
|
||||
<select
|
||||
id="database-type"
|
||||
value={value.database.type}
|
||||
onChange={(e) =>
|
||||
updateDatabase({ type: e.target.value as DatabaseType })
|
||||
}
|
||||
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
>
|
||||
{DATABASE_TYPES.map((type) => (
|
||||
<option key={type} value={type}>
|
||||
{type}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{showDatabaseUrl && (
|
||||
<div>
|
||||
<LabelWithTooltip
|
||||
htmlFor="database-url"
|
||||
tooltip={
|
||||
value.database.type === 'sqlite'
|
||||
? 'File path for SQLite database'
|
||||
: 'PostgreSQL connection string'
|
||||
}
|
||||
>
|
||||
{value.database.type === 'sqlite'
|
||||
? 'SQLite Path'
|
||||
: 'PostgreSQL URL'}
|
||||
</LabelWithTooltip>
|
||||
<Input
|
||||
id="database-url"
|
||||
value={
|
||||
('url' in value.database && value.database.url) ||
|
||||
('path' in value.database && value.database.path) ||
|
||||
''
|
||||
}
|
||||
onChange={(e) => {
|
||||
if (value.database.type === 'sqlite') {
|
||||
updateDatabase({ path: e.target.value || undefined });
|
||||
} else {
|
||||
updateDatabase({ url: e.target.value || undefined });
|
||||
}
|
||||
}}
|
||||
placeholder={
|
||||
value.database.type === 'sqlite'
|
||||
? './dexto.db'
|
||||
: 'postgresql://user:pass@localhost:5432/dexto'
|
||||
}
|
||||
aria-invalid={
|
||||
!!(
|
||||
errors['storage.database.url'] ||
|
||||
errors['storage.database.path']
|
||||
)
|
||||
}
|
||||
/>
|
||||
{(errors['storage.database.url'] ||
|
||||
errors['storage.database.path']) && (
|
||||
<p className="text-xs text-destructive mt-1">
|
||||
{errors['storage.database.url'] ||
|
||||
errors['storage.database.path']}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,582 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Input } from '../../ui/input';
|
||||
import { Textarea } from '../../ui/textarea';
|
||||
import { LabelWithTooltip } from '../../ui/label-with-tooltip';
|
||||
import { Button } from '../../ui/button';
|
||||
import { Collapsible } from '../../ui/collapsible';
|
||||
import { Plus, Trash2, ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import { PROMPT_GENERATOR_SOURCES } from '@dexto/core';
|
||||
import type { ContributorConfig } from '@dexto/core';
|
||||
|
||||
// Component works with the object form of SystemPromptConfig (not the string form)
|
||||
type SystemPromptConfigObject = {
|
||||
contributors: ContributorConfig[];
|
||||
};
|
||||
|
||||
interface SystemPromptSectionProps {
|
||||
value: SystemPromptConfigObject;
|
||||
onChange: (value: SystemPromptConfigObject) => void;
|
||||
errors?: Record<string, string>;
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
errorCount?: number;
|
||||
sectionErrors?: string[];
|
||||
}
|
||||
|
||||
export function SystemPromptSection({
|
||||
value,
|
||||
onChange,
|
||||
errors = {},
|
||||
open,
|
||||
onOpenChange,
|
||||
errorCount = 0,
|
||||
sectionErrors = [],
|
||||
}: SystemPromptSectionProps) {
|
||||
const [expandedContributors, setExpandedContributors] = useState<Set<string>>(new Set());
|
||||
// Local state for file paths (comma-separated editing)
|
||||
const [editingFiles, setEditingFiles] = useState<Record<string, string>>({});
|
||||
|
||||
const contributors = value.contributors || [];
|
||||
|
||||
const toggleContributor = (id: string) => {
|
||||
setExpandedContributors((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) {
|
||||
next.delete(id);
|
||||
} else {
|
||||
next.add(id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const addContributor = () => {
|
||||
const newId = `contributor-${Date.now()}`;
|
||||
const newContributor: ContributorConfig = {
|
||||
id: newId,
|
||||
type: 'static',
|
||||
priority: contributors.length * 10,
|
||||
enabled: true,
|
||||
content: '',
|
||||
};
|
||||
onChange({
|
||||
contributors: [...contributors, newContributor],
|
||||
});
|
||||
setExpandedContributors((prev) => new Set(prev).add(newId));
|
||||
};
|
||||
|
||||
const removeContributor = (id: string) => {
|
||||
onChange({
|
||||
contributors: contributors.filter((c) => c.id !== id),
|
||||
});
|
||||
setExpandedContributors((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const updateContributor = (id: string, updates: Partial<ContributorConfig>) => {
|
||||
onChange({
|
||||
contributors: contributors.map((c) => {
|
||||
if (c.id === id) {
|
||||
// If ID is changing, handle the ID change
|
||||
if (updates.id && updates.id !== id) {
|
||||
// Update expanded state
|
||||
setExpandedContributors((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) {
|
||||
next.delete(id);
|
||||
next.add(updates.id!);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
// If type is changing, create a new contributor with the new type
|
||||
if (updates.type && updates.type !== c.type) {
|
||||
const baseFields = {
|
||||
id: updates.id || c.id,
|
||||
priority:
|
||||
updates.priority !== undefined ? updates.priority : c.priority,
|
||||
enabled: updates.enabled !== undefined ? updates.enabled : c.enabled,
|
||||
};
|
||||
|
||||
if (updates.type === 'static') {
|
||||
return {
|
||||
...baseFields,
|
||||
type: 'static',
|
||||
content: '',
|
||||
} as ContributorConfig;
|
||||
} else if (updates.type === 'dynamic') {
|
||||
return {
|
||||
...baseFields,
|
||||
type: 'dynamic',
|
||||
source: 'date',
|
||||
} as ContributorConfig;
|
||||
} else if (updates.type === 'file') {
|
||||
return { ...baseFields, type: 'file', files: [] } as ContributorConfig;
|
||||
}
|
||||
}
|
||||
|
||||
return { ...c, ...updates } as ContributorConfig;
|
||||
}
|
||||
return c;
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
// Get the current value for file paths (either from editing state or from config)
|
||||
const getFilesValue = (id: string, files: string[]): string => {
|
||||
return editingFiles[id] ?? files.join(', ');
|
||||
};
|
||||
|
||||
// Update local editing state while typing
|
||||
const setFilesValue = (id: string, value: string) => {
|
||||
setEditingFiles((prev) => ({ ...prev, [id]: value }));
|
||||
};
|
||||
|
||||
// Parse and commit files on blur
|
||||
const commitFiles = (id: string, filesString: string) => {
|
||||
setEditingFiles((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[id];
|
||||
return next;
|
||||
});
|
||||
|
||||
const files = filesString
|
||||
.split(',')
|
||||
.map((f) => f.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
updateContributor(id, { files: files.length > 0 ? files : [] });
|
||||
};
|
||||
|
||||
return (
|
||||
<Collapsible
|
||||
title="System Prompt"
|
||||
defaultOpen={true}
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
errorCount={errorCount}
|
||||
sectionErrors={sectionErrors}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Define how the agent should behave using multiple contributors with different
|
||||
priorities.
|
||||
</p>
|
||||
|
||||
{contributors.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">No contributors configured</p>
|
||||
) : (
|
||||
contributors.map((contributor) => {
|
||||
const isExpanded = expandedContributors.has(contributor.id);
|
||||
return (
|
||||
<div
|
||||
key={contributor.id}
|
||||
className="border border-border rounded-lg overflow-hidden"
|
||||
>
|
||||
{/* Contributor Header */}
|
||||
<div className="flex items-center justify-between px-3 py-2 bg-muted/30">
|
||||
<button
|
||||
onClick={() => toggleContributor(contributor.id)}
|
||||
className="flex items-center gap-2 flex-1 text-left hover:text-foreground transition-colors"
|
||||
>
|
||||
{isExpanded ? (
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
)}
|
||||
<span className="font-medium text-sm">
|
||||
{contributor.id}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
({contributor.type}, priority: {contributor.priority})
|
||||
</span>
|
||||
{contributor.enabled === false && (
|
||||
<span className="text-xs text-destructive">
|
||||
(disabled)
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => removeContributor(contributor.id)}
|
||||
className="h-7 w-7 p-0"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Contributor Details */}
|
||||
{isExpanded && (
|
||||
<div className="px-3 py-3 space-y-3">
|
||||
{/* Common Fields */}
|
||||
<div>
|
||||
<LabelWithTooltip
|
||||
htmlFor={`contributor-id-${contributor.id}`}
|
||||
tooltip="Unique identifier for this contributor"
|
||||
>
|
||||
ID *
|
||||
</LabelWithTooltip>
|
||||
<Input
|
||||
id={`contributor-id-${contributor.id}`}
|
||||
value={contributor.id}
|
||||
onChange={(e) =>
|
||||
updateContributor(contributor.id, {
|
||||
id: e.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="e.g., primary, date"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<LabelWithTooltip
|
||||
htmlFor={`contributor-type-${contributor.id}`}
|
||||
tooltip="Type of contributor: static (fixed text), dynamic (runtime generated), or file (from files)"
|
||||
>
|
||||
Type *
|
||||
</LabelWithTooltip>
|
||||
<select
|
||||
id={`contributor-type-${contributor.id}`}
|
||||
value={contributor.type}
|
||||
onChange={(e) =>
|
||||
updateContributor(contributor.id, {
|
||||
type: e.target.value as
|
||||
| 'static'
|
||||
| 'dynamic'
|
||||
| 'file',
|
||||
})
|
||||
}
|
||||
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
>
|
||||
<option value="static">Static</option>
|
||||
<option value="dynamic">Dynamic</option>
|
||||
<option value="file">File</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<LabelWithTooltip
|
||||
htmlFor={`contributor-priority-${contributor.id}`}
|
||||
tooltip="Execution priority (lower numbers run first)"
|
||||
>
|
||||
Priority *
|
||||
</LabelWithTooltip>
|
||||
<Input
|
||||
id={`contributor-priority-${contributor.id}`}
|
||||
type="number"
|
||||
value={contributor.priority}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
const num = Number.parseInt(val, 10);
|
||||
updateContributor(contributor.id, {
|
||||
priority: Number.isNaN(num) ? 0 : num,
|
||||
});
|
||||
}}
|
||||
placeholder="0"
|
||||
min="0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={contributor.enabled !== false}
|
||||
onChange={(e) =>
|
||||
updateContributor(contributor.id, {
|
||||
enabled: e.target.checked,
|
||||
})
|
||||
}
|
||||
className="rounded border-input"
|
||||
/>
|
||||
<span>Enabled</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Type-specific Fields */}
|
||||
{contributor.type === 'static' && (
|
||||
<div>
|
||||
<LabelWithTooltip
|
||||
htmlFor={`contributor-content-${contributor.id}`}
|
||||
tooltip="Static content for the system prompt"
|
||||
>
|
||||
Content *
|
||||
</LabelWithTooltip>
|
||||
<Textarea
|
||||
id={`contributor-content-${contributor.id}`}
|
||||
value={contributor.content}
|
||||
onChange={(e) =>
|
||||
updateContributor(contributor.id, {
|
||||
content: e.target.value,
|
||||
})
|
||||
}
|
||||
placeholder="You are a helpful assistant..."
|
||||
rows={8}
|
||||
className="font-mono text-sm"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{contributor.type === 'dynamic' && (
|
||||
<div>
|
||||
<LabelWithTooltip
|
||||
htmlFor={`contributor-source-${contributor.id}`}
|
||||
tooltip="Source for dynamic content generation"
|
||||
>
|
||||
Source *
|
||||
</LabelWithTooltip>
|
||||
<select
|
||||
id={`contributor-source-${contributor.id}`}
|
||||
value={contributor.source}
|
||||
onChange={(e) =>
|
||||
updateContributor(contributor.id, {
|
||||
source: e.target.value as Extract<
|
||||
ContributorConfig,
|
||||
{ type: 'dynamic' }
|
||||
>['source'],
|
||||
})
|
||||
}
|
||||
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
>
|
||||
{PROMPT_GENERATOR_SOURCES.map((source) => (
|
||||
<option key={source} value={source}>
|
||||
{source}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{contributor.type === 'file' && (
|
||||
<>
|
||||
<div>
|
||||
<LabelWithTooltip
|
||||
htmlFor={`contributor-files-${contributor.id}`}
|
||||
tooltip="File paths to include, comma-separated"
|
||||
>
|
||||
Files *
|
||||
</LabelWithTooltip>
|
||||
<Input
|
||||
id={`contributor-files-${contributor.id}`}
|
||||
value={getFilesValue(
|
||||
contributor.id,
|
||||
contributor.files
|
||||
)}
|
||||
onChange={(e) =>
|
||||
setFilesValue(
|
||||
contributor.id,
|
||||
e.target.value
|
||||
)
|
||||
}
|
||||
onBlur={(e) =>
|
||||
commitFiles(
|
||||
contributor.id,
|
||||
e.target.value
|
||||
)
|
||||
}
|
||||
placeholder="./commands/context.md, ./commands/rules.txt"
|
||||
className="font-mono"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* File Options */}
|
||||
<details className="border border-border rounded-md p-2">
|
||||
<summary className="text-sm font-medium cursor-pointer">
|
||||
File Options
|
||||
</summary>
|
||||
<div className="mt-3 space-y-3">
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={
|
||||
contributor.options
|
||||
?.includeFilenames !== false
|
||||
}
|
||||
onChange={(e) =>
|
||||
updateContributor(
|
||||
contributor.id,
|
||||
{
|
||||
options: {
|
||||
...(contributor.options ??
|
||||
{}),
|
||||
includeFilenames:
|
||||
e.target
|
||||
.checked,
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
className="rounded border-input"
|
||||
/>
|
||||
<span>
|
||||
Include filenames as headers
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={
|
||||
contributor.options
|
||||
?.includeMetadata === true
|
||||
}
|
||||
onChange={(e) =>
|
||||
updateContributor(
|
||||
contributor.id,
|
||||
{
|
||||
options: {
|
||||
...(contributor.options ??
|
||||
{}),
|
||||
includeMetadata:
|
||||
e.target
|
||||
.checked,
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
className="rounded border-input"
|
||||
/>
|
||||
<span>Include file metadata</span>
|
||||
</label>
|
||||
|
||||
<div>
|
||||
<LabelWithTooltip
|
||||
htmlFor={`contributor-separator-${contributor.id}`}
|
||||
tooltip="Separator between multiple files"
|
||||
>
|
||||
Separator
|
||||
</LabelWithTooltip>
|
||||
<Input
|
||||
id={`contributor-separator-${contributor.id}`}
|
||||
value={
|
||||
contributor.options
|
||||
?.separator ?? '\n\n---\n\n'
|
||||
}
|
||||
onChange={(e) =>
|
||||
updateContributor(
|
||||
contributor.id,
|
||||
{
|
||||
options: {
|
||||
...(contributor.options ??
|
||||
{}),
|
||||
separator:
|
||||
e.target.value,
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
placeholder="\n\n---\n\n"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<LabelWithTooltip
|
||||
htmlFor={`contributor-error-handling-${contributor.id}`}
|
||||
tooltip="How to handle missing or unreadable files"
|
||||
>
|
||||
Error Handling
|
||||
</LabelWithTooltip>
|
||||
<select
|
||||
id={`contributor-error-handling-${contributor.id}`}
|
||||
value={
|
||||
contributor.options
|
||||
?.errorHandling || 'skip'
|
||||
}
|
||||
onChange={(e) =>
|
||||
updateContributor(
|
||||
contributor.id,
|
||||
{
|
||||
options: {
|
||||
...(contributor.options ??
|
||||
{}),
|
||||
errorHandling: e
|
||||
.target
|
||||
.value as
|
||||
| 'skip'
|
||||
| 'error',
|
||||
},
|
||||
}
|
||||
)
|
||||
}
|
||||
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
>
|
||||
<option value="skip">
|
||||
Skip missing files
|
||||
</option>
|
||||
<option value="error">
|
||||
Error on missing files
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<LabelWithTooltip
|
||||
htmlFor={`contributor-max-file-size-${contributor.id}`}
|
||||
tooltip="Maximum file size in bytes"
|
||||
>
|
||||
Max File Size (bytes)
|
||||
</LabelWithTooltip>
|
||||
<Input
|
||||
id={`contributor-max-file-size-${contributor.id}`}
|
||||
type="number"
|
||||
value={
|
||||
contributor.options
|
||||
?.maxFileSize || 100000
|
||||
}
|
||||
onChange={(e) => {
|
||||
const val = e.target.value;
|
||||
const num = Number.parseInt(
|
||||
val,
|
||||
10
|
||||
);
|
||||
updateContributor(
|
||||
contributor.id,
|
||||
{
|
||||
options: {
|
||||
...(contributor.options ??
|
||||
{}),
|
||||
maxFileSize:
|
||||
Number.isNaN(
|
||||
num
|
||||
)
|
||||
? undefined
|
||||
: num,
|
||||
},
|
||||
}
|
||||
);
|
||||
}}
|
||||
placeholder="100000"
|
||||
min="1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
|
||||
{/* Add Contributor Button */}
|
||||
<Button onClick={addContributor} variant="outline" size="sm" className="w-full">
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
Add Contributor
|
||||
</Button>
|
||||
|
||||
{errors.systemPrompt && (
|
||||
<p className="text-xs text-destructive mt-1">{errors.systemPrompt}</p>
|
||||
)}
|
||||
</div>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import React from 'react';
|
||||
import { Input } from '../../ui/input';
|
||||
import { LabelWithTooltip } from '../../ui/label-with-tooltip';
|
||||
import { Collapsible } from '../../ui/collapsible';
|
||||
import type { AgentConfig } from '@dexto/core';
|
||||
import {
|
||||
TOOL_CONFIRMATION_MODES,
|
||||
ALLOWED_TOOLS_STORAGE_TYPES,
|
||||
DEFAULT_TOOL_CONFIRMATION_MODE,
|
||||
DEFAULT_ALLOWED_TOOLS_STORAGE,
|
||||
} from '@dexto/core';
|
||||
|
||||
type ToolConfirmationConfig = NonNullable<AgentConfig['toolConfirmation']>;
|
||||
|
||||
interface ToolConfirmationSectionProps {
|
||||
value: ToolConfirmationConfig;
|
||||
onChange: (value: ToolConfirmationConfig) => void;
|
||||
errors?: Record<string, string>;
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
errorCount?: number;
|
||||
sectionErrors?: string[];
|
||||
}
|
||||
|
||||
export function ToolConfirmationSection({
|
||||
value,
|
||||
onChange,
|
||||
errors = {},
|
||||
open,
|
||||
onOpenChange,
|
||||
errorCount = 0,
|
||||
sectionErrors = [],
|
||||
}: ToolConfirmationSectionProps) {
|
||||
const handleChange = (updates: Partial<ToolConfirmationConfig>) => {
|
||||
onChange({ ...value, ...updates });
|
||||
};
|
||||
|
||||
const updateAllowedToolsStorage = (type: 'memory' | 'storage') => {
|
||||
onChange({
|
||||
...value,
|
||||
allowedToolsStorage: type,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<Collapsible
|
||||
title="Tool Confirmation"
|
||||
defaultOpen={false}
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
errorCount={errorCount}
|
||||
sectionErrors={sectionErrors}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{/* Confirmation Mode */}
|
||||
<div>
|
||||
<LabelWithTooltip
|
||||
htmlFor="confirmation-mode"
|
||||
tooltip="How the agent handles tool execution requests"
|
||||
>
|
||||
Confirmation Mode
|
||||
</LabelWithTooltip>
|
||||
<select
|
||||
id="confirmation-mode"
|
||||
value={value.mode || DEFAULT_TOOL_CONFIRMATION_MODE}
|
||||
onChange={(e) =>
|
||||
handleChange({
|
||||
mode: e.target.value as 'auto-approve' | 'manual' | 'auto-deny',
|
||||
})
|
||||
}
|
||||
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
>
|
||||
{TOOL_CONFIRMATION_MODES.map((mode) => (
|
||||
<option key={mode} value={mode}>
|
||||
{mode === 'auto-approve'
|
||||
? 'Auto-approve'
|
||||
: mode === 'manual'
|
||||
? 'Manual'
|
||||
: 'Auto-deny'}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{value.mode === 'manual'
|
||||
? 'Require explicit approval before executing tools'
|
||||
: value.mode === 'auto-deny'
|
||||
? 'Automatically deny all tool executions'
|
||||
: 'Automatically approve tool executions'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Timeout */}
|
||||
{value.mode === 'manual' && (
|
||||
<div>
|
||||
<LabelWithTooltip
|
||||
htmlFor="confirmation-timeout"
|
||||
tooltip="How long to wait for approval before timing out"
|
||||
>
|
||||
Timeout (seconds)
|
||||
</LabelWithTooltip>
|
||||
<Input
|
||||
id="confirmation-timeout"
|
||||
type="number"
|
||||
value={value.timeout || ''}
|
||||
onChange={(e) =>
|
||||
handleChange({
|
||||
timeout: e.target.value
|
||||
? parseInt(e.target.value, 10)
|
||||
: undefined,
|
||||
})
|
||||
}
|
||||
min="1"
|
||||
placeholder="e.g., 60"
|
||||
aria-invalid={!!errors['toolConfirmation.timeout']}
|
||||
/>
|
||||
{errors['toolConfirmation.timeout'] && (
|
||||
<p className="text-xs text-destructive mt-1">
|
||||
{errors['toolConfirmation.timeout']}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Allowed Tools Storage */}
|
||||
<div>
|
||||
<LabelWithTooltip
|
||||
htmlFor="allowed-tools-storage"
|
||||
tooltip="Where to store the list of pre-approved tools (memory or persistent storage)"
|
||||
>
|
||||
Allowed Tools Storage
|
||||
</LabelWithTooltip>
|
||||
<select
|
||||
id="allowed-tools-storage"
|
||||
value={value.allowedToolsStorage || DEFAULT_ALLOWED_TOOLS_STORAGE}
|
||||
onChange={(e) =>
|
||||
updateAllowedToolsStorage(e.target.value as 'memory' | 'storage')
|
||||
}
|
||||
className="flex h-9 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
|
||||
>
|
||||
{ALLOWED_TOOLS_STORAGE_TYPES.map((type) => (
|
||||
<option key={type} value={type}>
|
||||
{type}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</Collapsible>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user