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,77 @@
|
||||
/**
|
||||
* AgentConfigEditor
|
||||
*
|
||||
* Monaco-based YAML editor component for editing agent configuration files.
|
||||
* Provides syntax highlighting, line numbers, and configurable editor options.
|
||||
* Validation is handled externally via the onValidate callback.
|
||||
*/
|
||||
|
||||
import React, { useRef, useEffect } from 'react';
|
||||
import Editor, { type OnMount } from '@monaco-editor/react';
|
||||
import type { editor } from 'monaco-editor';
|
||||
|
||||
interface AgentConfigEditorProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
onValidate?: (markers: editor.IMarker[]) => void;
|
||||
readOnly?: boolean;
|
||||
height?: string;
|
||||
}
|
||||
|
||||
export default function AgentConfigEditor({
|
||||
value,
|
||||
onChange,
|
||||
onValidate,
|
||||
readOnly = false,
|
||||
height = '100%',
|
||||
}: AgentConfigEditorProps) {
|
||||
const editorRef = useRef<editor.IStandaloneCodeEditor | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
// Set up validation when editor is mounted
|
||||
if (editorRef.current && onValidate) {
|
||||
const model = editorRef.current.getModel();
|
||||
if (model) {
|
||||
// Server-side validation is handled via API
|
||||
// Monaco provides basic YAML syntax highlighting
|
||||
}
|
||||
}
|
||||
}, [onValidate]);
|
||||
|
||||
const handleEditorDidMount: OnMount = (editorInstance) => {
|
||||
editorRef.current = editorInstance as editor.IStandaloneCodeEditor;
|
||||
|
||||
// Configure editor options
|
||||
editorInstance.updateOptions({
|
||||
minimap: { enabled: false },
|
||||
scrollBeyondLastLine: false,
|
||||
fontSize: 13,
|
||||
lineNumbers: 'on',
|
||||
renderLineHighlight: 'all',
|
||||
folding: true,
|
||||
automaticLayout: true,
|
||||
});
|
||||
};
|
||||
|
||||
const handleEditorChange = (value: string | undefined) => {
|
||||
if (value !== undefined) {
|
||||
onChange(value);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Editor
|
||||
height={height}
|
||||
defaultLanguage="yaml"
|
||||
value={value}
|
||||
onChange={handleEditorChange}
|
||||
onMount={handleEditorDidMount}
|
||||
theme="vs-dark"
|
||||
options={{
|
||||
readOnly,
|
||||
wordWrap: 'on',
|
||||
tabSize: 2,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
/**
|
||||
* ConfigValidationStatus
|
||||
*
|
||||
* Displays real-time validation status for agent configuration editing.
|
||||
* Shows validation state (validating/valid/invalid), error count, warnings,
|
||||
* and detailed error/warning messages with line numbers. Provides visual
|
||||
* feedback during configuration editing to help users fix issues before saving.
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { AlertCircle, CheckCircle, AlertTriangle } from 'lucide-react';
|
||||
import type { ValidationError, ValidationWarning } from '../hooks/useAgentConfig';
|
||||
|
||||
interface ConfigValidationStatusProps {
|
||||
isValidating: boolean;
|
||||
isValid: boolean;
|
||||
errors: ValidationError[];
|
||||
warnings: ValidationWarning[];
|
||||
hasUnsavedChanges: boolean;
|
||||
}
|
||||
|
||||
export default function ConfigValidationStatus({
|
||||
isValidating,
|
||||
isValid,
|
||||
errors,
|
||||
warnings,
|
||||
hasUnsavedChanges,
|
||||
}: ConfigValidationStatusProps) {
|
||||
return (
|
||||
<div className="border-t border-border bg-background px-4 py-3">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
{/* Status indicator */}
|
||||
<div className="flex items-center gap-2 min-w-0 flex-1">
|
||||
{isValidating ? (
|
||||
<>
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
||||
<span className="text-sm text-muted-foreground">Validating...</span>
|
||||
</>
|
||||
) : isValid ? (
|
||||
<>
|
||||
<CheckCircle className="h-4 w-4 text-green-500 flex-shrink-0" />
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Valid configuration
|
||||
{hasUnsavedChanges && ' (unsaved changes)'}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<AlertCircle className="h-4 w-4 text-destructive flex-shrink-0" />
|
||||
<span className="text-sm text-destructive">
|
||||
{errors.length} {errors.length === 1 ? 'error' : 'errors'}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Warnings indicator */}
|
||||
{warnings.length > 0 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertTriangle className="h-4 w-4 text-yellow-500 flex-shrink-0" />
|
||||
<span className="text-sm text-yellow-500">
|
||||
{warnings.length} {warnings.length === 1 ? 'warning' : 'warnings'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Error list */}
|
||||
{errors.length > 0 && (
|
||||
<div className="mt-3 space-y-2 max-h-32 overflow-y-auto">
|
||||
{errors.map((error, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="text-xs bg-destructive/10 text-destructive rounded px-2 py-1.5 flex items-start gap-2"
|
||||
>
|
||||
<AlertCircle className="h-3 w-3 mt-0.5 flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
{error.path && <span className="font-medium">{error.path}: </span>}
|
||||
{error.message}
|
||||
{error.line && (
|
||||
<span className="text-muted-foreground ml-1">
|
||||
(line {error.line}
|
||||
{error.column && `:${error.column}`})
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Warning list */}
|
||||
{warnings.length > 0 && errors.length === 0 && (
|
||||
<div className="mt-3 space-y-2 max-h-32 overflow-y-auto">
|
||||
{warnings.map((warning, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="text-xs bg-yellow-500/10 text-yellow-600 dark:text-yellow-500 rounded px-2 py-1.5 flex items-start gap-2"
|
||||
>
|
||||
<AlertTriangle className="h-3 w-3 mt-0.5 flex-shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<span className="font-medium">{warning.path}: </span>
|
||||
{warning.message}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
782
dexto/packages/webui/components/AgentEditor/CustomizePanel.tsx
Normal file
782
dexto/packages/webui/components/AgentEditor/CustomizePanel.tsx
Normal file
@@ -0,0 +1,782 @@
|
||||
/**
|
||||
* CustomizePanel - Parent coordinator for agent configuration editing
|
||||
*
|
||||
* Responsibilities:
|
||||
* - Load/save configuration via API
|
||||
* - Mode switching (Form ↔ YAML)
|
||||
* - YAML ↔ Config object conversion
|
||||
* - Unsaved changes detection
|
||||
* - Validation orchestration
|
||||
*
|
||||
* The actual editing is delegated to:
|
||||
* - YAMLEditorView - for YAML mode
|
||||
* - FormEditorView - for Form mode
|
||||
*
|
||||
* TODO: Future optimization - derive form metadata from schemas
|
||||
* Currently form sections have manual field definitions. Consider deriving:
|
||||
* - Required/optional fields from schema
|
||||
* - Default values from schema defaults
|
||||
* - Enum options from schema enums
|
||||
* - Field types from schema types
|
||||
* This would eliminate hardcoded UI metadata and reduce maintenance.
|
||||
* See packages/core/src/utils/schema-metadata.ts for the core utilities that enable this (needs runtime fixes).
|
||||
* This TODO is linked with the corresponding TODO in schema-metadata.ts tracking the same goal.
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { useDebounce } from 'use-debounce';
|
||||
import { Button } from '../ui/button';
|
||||
import { X, Save, RefreshCw, AlertTriangle, CheckCircle, ExternalLink } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import {
|
||||
useAgentConfig,
|
||||
useValidateAgent,
|
||||
useSaveAgentConfig,
|
||||
type ValidationError,
|
||||
type ValidationWarning,
|
||||
} from '../hooks/useAgentConfig';
|
||||
import YAMLEditorView from './YAMLEditorView';
|
||||
import FormEditorView from './FormEditorView';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '../ui/dialog';
|
||||
import { Tooltip, TooltipTrigger, TooltipContent } from '../ui/tooltip';
|
||||
import * as yaml from 'yaml';
|
||||
import type { AgentConfig } from '@dexto/core';
|
||||
|
||||
interface CustomizePanelProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
variant?: 'overlay' | 'inline';
|
||||
}
|
||||
|
||||
type EditorMode = 'form' | 'yaml';
|
||||
|
||||
export default function CustomizePanel({
|
||||
isOpen,
|
||||
onClose,
|
||||
variant = 'overlay',
|
||||
}: CustomizePanelProps) {
|
||||
// TanStack Query hooks
|
||||
const {
|
||||
data: configData,
|
||||
isLoading,
|
||||
error: loadError,
|
||||
refetch: refetchConfig,
|
||||
} = useAgentConfig(isOpen);
|
||||
const validateMutation = useValidateAgent();
|
||||
const saveMutation = useSaveAgentConfig();
|
||||
|
||||
// Content state
|
||||
const [yamlContent, setYamlContent] = useState<string>('');
|
||||
const [originalYamlContent, setOriginalYamlContent] = useState<string>('');
|
||||
const [parsedConfig, setParsedConfig] = useState<AgentConfig | null>(null);
|
||||
const [originalParsedConfig, setOriginalParsedConfig] = useState<AgentConfig | null>(null);
|
||||
const [yamlDocument, setYamlDocument] = useState<yaml.Document | null>(null);
|
||||
const [relativePath, setRelativePath] = useState<string>('');
|
||||
|
||||
// Editor mode
|
||||
const [editorMode, setEditorMode] = useState<EditorMode>('yaml');
|
||||
const [parseError, setParseError] = useState<string | null>(null);
|
||||
|
||||
// Validation state
|
||||
const [isValid, setIsValid] = useState(true);
|
||||
const [errors, setErrors] = useState<ValidationError[]>([]);
|
||||
const [warnings, setWarnings] = useState<ValidationWarning[]>([]);
|
||||
|
||||
// Unsaved changes
|
||||
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
|
||||
const [showUnsavedDialog, setShowUnsavedDialog] = useState(false);
|
||||
|
||||
// Save state (for success messages)
|
||||
const [saveSuccess, setSaveSuccess] = useState(false);
|
||||
const [saveMessage, setSaveMessage] = useState<string>('');
|
||||
|
||||
// Debounced validation
|
||||
const [debouncedYamlContent] = useDebounce(yamlContent, 500);
|
||||
const latestValidationRequestRef = useRef(0);
|
||||
|
||||
// Validate YAML content via API
|
||||
const validateYaml = useCallback(
|
||||
async (yaml: string) => {
|
||||
const requestId = latestValidationRequestRef.current + 1;
|
||||
latestValidationRequestRef.current = requestId;
|
||||
|
||||
try {
|
||||
const data = await validateMutation.mutateAsync({ yaml });
|
||||
if (latestValidationRequestRef.current === requestId) {
|
||||
setIsValid(data.valid);
|
||||
setErrors(data.errors || []);
|
||||
setWarnings(data.warnings || []);
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.warn(
|
||||
`Validation error: ${err instanceof Error ? err.message : String(err)}`
|
||||
);
|
||||
if (latestValidationRequestRef.current === requestId) {
|
||||
setIsValid(false);
|
||||
setErrors([
|
||||
{ message: 'Failed to validate configuration', code: 'VALIDATION_ERROR' },
|
||||
]);
|
||||
}
|
||||
}
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[validateMutation.mutateAsync]
|
||||
);
|
||||
|
||||
// Initialize state when config data loads
|
||||
useEffect(() => {
|
||||
if (configData && isOpen) {
|
||||
setYamlContent(configData.yaml);
|
||||
setOriginalYamlContent(configData.yaml);
|
||||
setRelativePath(configData.relativePath);
|
||||
setHasUnsavedChanges(false);
|
||||
|
||||
// Parse for form mode
|
||||
const { config, document } = parseYamlToConfig(configData.yaml);
|
||||
if (config && document) {
|
||||
setParsedConfig(config);
|
||||
setOriginalParsedConfig(config);
|
||||
setYamlDocument(document);
|
||||
}
|
||||
|
||||
// Initial validation
|
||||
validateYaml(configData.yaml);
|
||||
}
|
||||
}, [configData, isOpen, validateYaml]);
|
||||
|
||||
// Parse YAML to config object and document
|
||||
const parseYamlToConfig = (
|
||||
yamlString: string
|
||||
): { config: AgentConfig | null; document: yaml.Document | null; error: string | null } => {
|
||||
console.log('[parseYamlToConfig] Starting parse');
|
||||
try {
|
||||
const document = yaml.parseDocument(yamlString);
|
||||
console.log('[parseYamlToConfig] Document created:', document);
|
||||
|
||||
// Check for parse errors
|
||||
if (document.errors && document.errors.length > 0) {
|
||||
console.debug('[parseYamlToConfig] Parse errors:', document.errors);
|
||||
const message = document.errors.map((e) => e.message).join('; ');
|
||||
return { config: null, document: null, error: message };
|
||||
}
|
||||
|
||||
const config = document.toJS() as AgentConfig;
|
||||
console.log('[parseYamlToConfig] Config parsed successfully:', config);
|
||||
return { config, document, error: null };
|
||||
} catch (err: unknown) {
|
||||
console.debug('[parseYamlToConfig] Exception:', err);
|
||||
const message = err instanceof Error ? err.message : 'Failed to parse YAML';
|
||||
return { config: null, document: null, error: message };
|
||||
}
|
||||
};
|
||||
|
||||
// Update YAML document from config object while preserving comments
|
||||
const updateYamlDocumentFromConfig = (
|
||||
document: yaml.Document,
|
||||
config: AgentConfig
|
||||
): yaml.Document => {
|
||||
console.log('[updateYamlDocumentFromConfig] Starting update');
|
||||
console.log('[updateYamlDocumentFromConfig] Document:', document);
|
||||
console.log('[updateYamlDocumentFromConfig] Config:', config);
|
||||
|
||||
const updateNode = (node: any, value: any): any => {
|
||||
// Handle null/undefined
|
||||
if (value === null || value === undefined) {
|
||||
return document.createNode(value);
|
||||
}
|
||||
|
||||
// Handle arrays - create new sequence
|
||||
if (Array.isArray(value)) {
|
||||
return document.createNode(value);
|
||||
}
|
||||
|
||||
// Handle objects - update map recursively
|
||||
if (typeof value === 'object' && !Array.isArray(value)) {
|
||||
if (!node || !node.items) {
|
||||
// Create new map if node doesn't exist
|
||||
return document.createNode(value);
|
||||
}
|
||||
|
||||
// Update existing map
|
||||
const existingKeys = new Set<string>();
|
||||
|
||||
// Update existing keys and track them
|
||||
for (const pair of node.items) {
|
||||
const key = pair.key.value;
|
||||
existingKeys.add(key);
|
||||
|
||||
if (key in value) {
|
||||
// Update the value while preserving the pair (and its comments)
|
||||
pair.value = updateNode(pair.value, value[key]);
|
||||
}
|
||||
}
|
||||
|
||||
// Add new keys
|
||||
for (const [key, val] of Object.entries(value)) {
|
||||
if (!existingKeys.has(key)) {
|
||||
node.items.push(document.createPair(key, val));
|
||||
}
|
||||
}
|
||||
|
||||
// Remove keys not in new config
|
||||
node.items = node.items.filter((pair: any) => {
|
||||
const key = pair.key.value;
|
||||
return key in value;
|
||||
});
|
||||
|
||||
return node;
|
||||
}
|
||||
|
||||
// Handle primitives - create new scalar
|
||||
return document.createNode(value);
|
||||
};
|
||||
|
||||
try {
|
||||
// Update the root contents
|
||||
document.contents = updateNode(document.contents, config);
|
||||
console.log('[updateYamlDocumentFromConfig] Update successful');
|
||||
return document;
|
||||
} catch (err) {
|
||||
console.error('[updateYamlDocumentFromConfig] Update failed:', err);
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
// Generic deep cleanup to remove null/undefined/empty values
|
||||
const cleanupConfig = (config: AgentConfig): AgentConfig => {
|
||||
const isEmptyValue = (value: unknown): boolean => {
|
||||
// null and undefined are empty
|
||||
if (value === null || value === undefined) return true;
|
||||
// Empty string is empty
|
||||
if (value === '') return true;
|
||||
// Empty arrays are empty
|
||||
if (Array.isArray(value) && value.length === 0) return true;
|
||||
// Empty objects are empty (but not Date, etc)
|
||||
if (
|
||||
typeof value === 'object' &&
|
||||
value !== null &&
|
||||
Object.prototype.toString.call(value) === '[object Object]' &&
|
||||
Object.keys(value).length === 0
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
// Everything else (including false, 0, etc) is not empty
|
||||
return false;
|
||||
};
|
||||
|
||||
const deepCleanup = (obj: any): any => {
|
||||
if (Array.isArray(obj)) {
|
||||
// For arrays, recursively clean each element and filter out empty ones
|
||||
return obj.map(deepCleanup).filter((item) => !isEmptyValue(item));
|
||||
}
|
||||
|
||||
if (typeof obj === 'object' && obj !== null) {
|
||||
const cleaned: any = {};
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
// Skip empty values
|
||||
if (isEmptyValue(value)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Recursively clean objects and arrays
|
||||
if (typeof value === 'object' && value !== null) {
|
||||
const cleanedValue = deepCleanup(value);
|
||||
// Only add if the cleaned value is not empty
|
||||
if (!isEmptyValue(cleanedValue)) {
|
||||
cleaned[key] = cleanedValue;
|
||||
}
|
||||
} else {
|
||||
// Keep non-object, non-empty values
|
||||
cleaned[key] = value;
|
||||
}
|
||||
}
|
||||
return cleaned;
|
||||
}
|
||||
|
||||
// Return primitives as-is
|
||||
return obj;
|
||||
};
|
||||
|
||||
return deepCleanup(config) as AgentConfig;
|
||||
};
|
||||
|
||||
// Serialize config back to YAML while preserving comments
|
||||
const serializeConfigToYaml = (config: AgentConfig, document: yaml.Document): string => {
|
||||
console.log('[serializeConfigToYaml] Starting serialization');
|
||||
console.log('[serializeConfigToYaml] Document:', document);
|
||||
console.log('[serializeConfigToYaml] Config:', config);
|
||||
|
||||
// Clean up config to remove null/undefined optional fields
|
||||
const cleanedConfig = cleanupConfig(config);
|
||||
console.log('[serializeConfigToYaml] Cleaned config:', cleanedConfig);
|
||||
|
||||
// Update document with new config and serialize with comments preserved
|
||||
const updatedDoc = updateYamlDocumentFromConfig(document, cleanedConfig);
|
||||
const result = updatedDoc.toString();
|
||||
console.log('[serializeConfigToYaml] Serialized result length:', result.length);
|
||||
return result;
|
||||
};
|
||||
|
||||
// Deep comparison helper for configs
|
||||
const configsAreEqual = (a: AgentConfig | null, b: AgentConfig | null): boolean => {
|
||||
if (a === b) return true;
|
||||
if (!a || !b) return false;
|
||||
return JSON.stringify(a) === JSON.stringify(b);
|
||||
};
|
||||
|
||||
// Handle YAML editor changes
|
||||
const handleYamlChange = (value: string) => {
|
||||
setYamlContent(value);
|
||||
setHasUnsavedChanges(value !== originalYamlContent);
|
||||
setSaveSuccess(false);
|
||||
|
||||
// Update parsed config and document for potential form mode switch
|
||||
const { config, document } = parseYamlToConfig(value);
|
||||
if (config && document) {
|
||||
setParsedConfig(config);
|
||||
setYamlDocument(document);
|
||||
}
|
||||
// Validation happens automatically via debouncedYamlContent useEffect
|
||||
};
|
||||
|
||||
// Handle form editor changes
|
||||
const handleFormChange = (newConfig: AgentConfig) => {
|
||||
console.log('[handleFormChange] Called with new config');
|
||||
console.log('[handleFormChange] yamlDocument exists?', !!yamlDocument);
|
||||
|
||||
if (!yamlDocument) {
|
||||
console.error('[handleFormChange] No document available - this should not happen!');
|
||||
return;
|
||||
}
|
||||
|
||||
setParsedConfig(newConfig);
|
||||
// Use document to preserve comments
|
||||
const newYaml = serializeConfigToYaml(newConfig, yamlDocument);
|
||||
setYamlContent(newYaml);
|
||||
// Use semantic comparison for form mode to handle YAML formatting differences
|
||||
setHasUnsavedChanges(!configsAreEqual(newConfig, originalParsedConfig));
|
||||
setSaveSuccess(false);
|
||||
// Validation happens automatically via debouncedYamlContent useEffect
|
||||
};
|
||||
|
||||
// Handle mode switch
|
||||
const handleModeSwitch = (newMode: EditorMode) => {
|
||||
console.log(
|
||||
'[handleModeSwitch] Called with newMode:',
|
||||
newMode,
|
||||
'current mode:',
|
||||
editorMode
|
||||
);
|
||||
if (newMode === editorMode) {
|
||||
console.log('[handleModeSwitch] Same mode, returning');
|
||||
return;
|
||||
}
|
||||
|
||||
if (newMode === 'form') {
|
||||
console.log('[handleModeSwitch] Switching to form mode, parsing YAML...');
|
||||
// Switching to form mode - ensure config is parsed
|
||||
const { config, document, error } = parseYamlToConfig(yamlContent);
|
||||
console.log('[handleModeSwitch] Parse result:', { config, document, error });
|
||||
if (error) {
|
||||
console.error('[handleModeSwitch] Parse error, not switching:', error);
|
||||
setParseError(error);
|
||||
// Don't switch modes if parsing fails
|
||||
return;
|
||||
}
|
||||
console.log('[handleModeSwitch] Parse successful, setting state');
|
||||
setParsedConfig(config);
|
||||
setYamlDocument(document);
|
||||
setParseError(null);
|
||||
}
|
||||
|
||||
console.log('[handleModeSwitch] Setting editor mode to:', newMode);
|
||||
setEditorMode(newMode);
|
||||
};
|
||||
|
||||
// Save configuration
|
||||
const handleSave = useCallback(async () => {
|
||||
if (!isValid || errors.length > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSaveSuccess(false);
|
||||
setSaveMessage('');
|
||||
|
||||
try {
|
||||
const data = await saveMutation.mutateAsync({ yaml: yamlContent });
|
||||
|
||||
setOriginalYamlContent(yamlContent);
|
||||
setHasUnsavedChanges(false);
|
||||
setSaveSuccess(true);
|
||||
|
||||
if (data.restarted) {
|
||||
setSaveMessage(
|
||||
`Configuration applied successfully — ${data.changesApplied.join(', ')} updated`
|
||||
);
|
||||
} else {
|
||||
setSaveMessage('Configuration saved successfully (no changes detected)');
|
||||
}
|
||||
|
||||
// Clear success message after 5 seconds
|
||||
setTimeout(() => {
|
||||
setSaveSuccess(false);
|
||||
setSaveMessage('');
|
||||
}, 5000);
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
console.error(`Error saving agent config: ${message}`);
|
||||
}
|
||||
}, [isValid, errors, saveMutation, yamlContent]);
|
||||
|
||||
// Reload configuration
|
||||
const handleReload = () => {
|
||||
if (hasUnsavedChanges) {
|
||||
setShowUnsavedDialog(true);
|
||||
} else {
|
||||
refetchConfig();
|
||||
}
|
||||
};
|
||||
|
||||
// Handle close with unsaved changes check
|
||||
const handleClose = useCallback(() => {
|
||||
if (hasUnsavedChanges) {
|
||||
setShowUnsavedDialog(true);
|
||||
} else {
|
||||
onClose();
|
||||
}
|
||||
}, [hasUnsavedChanges, onClose]);
|
||||
|
||||
// Confirm discard changes
|
||||
const handleDiscardChanges = () => {
|
||||
setShowUnsavedDialog(false);
|
||||
setYamlContent(originalYamlContent);
|
||||
// Also reset parsed config for form mode
|
||||
if (originalParsedConfig) {
|
||||
setParsedConfig(originalParsedConfig);
|
||||
// Re-parse document for comment preservation
|
||||
const { document } = parseYamlToConfig(originalYamlContent);
|
||||
if (document) {
|
||||
setYamlDocument(document);
|
||||
}
|
||||
}
|
||||
setHasUnsavedChanges(false);
|
||||
refetchConfig();
|
||||
};
|
||||
|
||||
// Config loads automatically via useAgentConfig hook when isOpen is true
|
||||
|
||||
// Trigger validation when debounced content changes
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
validateYaml(debouncedYamlContent);
|
||||
}
|
||||
}, [debouncedYamlContent, isOpen, validateYaml]);
|
||||
|
||||
// Keyboard shortcuts
|
||||
useEffect(() => {
|
||||
if (!isOpen) return;
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
// Cmd+S / Ctrl+S to save
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === 's') {
|
||||
e.preventDefault();
|
||||
if (!saveMutation.isPending && isValid) {
|
||||
handleSave();
|
||||
}
|
||||
}
|
||||
// Escape to close
|
||||
if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
handleClose();
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [isOpen, saveMutation.isPending, isValid, hasUnsavedChanges, handleSave, handleClose]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
// Calculate save button disabled reason
|
||||
const getSaveDisabledReason = (): string | null => {
|
||||
if (saveMutation.isPending) return null; // Not really disabled, just in progress
|
||||
if (!hasUnsavedChanges) return 'No changes to save';
|
||||
if (errors.length > 0) {
|
||||
// Find the most relevant error
|
||||
const firstError = errors[0];
|
||||
if (firstError.path) {
|
||||
return `Configuration error in ${firstError.path}: ${firstError.message}`;
|
||||
}
|
||||
return `Configuration error: ${firstError.message}`;
|
||||
}
|
||||
if (!isValid) return 'Configuration has validation errors';
|
||||
return null;
|
||||
};
|
||||
|
||||
const saveDisabledReason = getSaveDisabledReason();
|
||||
const isSaveDisabled =
|
||||
!hasUnsavedChanges || saveMutation.isPending || !isValid || errors.length > 0;
|
||||
|
||||
const panelContent = (
|
||||
<div className="flex flex-col h-full bg-background">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-border">
|
||||
<div className="flex items-center gap-3">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h2 className="text-lg font-semibold">Customize Agent</h2>
|
||||
<a
|
||||
href="https://docs.dexto.ai/docs/guides/configuring-dexto/overview"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-xs text-muted-foreground hover:text-foreground transition-colors flex items-center gap-1"
|
||||
title="View configuration documentation"
|
||||
>
|
||||
View docs
|
||||
<ExternalLink className="h-3 w-3" />
|
||||
</a>
|
||||
</div>
|
||||
{relativePath && (
|
||||
<p className="text-xs text-muted-foreground">{relativePath}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Mode Toggle */}
|
||||
<div className="flex items-center gap-1 bg-muted/50 rounded-md p-1">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant={editorMode === 'yaml' ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => handleModeSwitch('yaml')}
|
||||
className="h-7 px-3"
|
||||
>
|
||||
YAML Editor
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
Edit configuration in raw YAML format with full control
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant={editorMode === 'form' ? 'default' : 'ghost'}
|
||||
size="sm"
|
||||
onClick={() => handleModeSwitch('form')}
|
||||
className="h-7 px-3"
|
||||
>
|
||||
Form Editor
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
Edit configuration using user-friendly forms
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleReload}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<RefreshCw className={cn('h-4 w-4', isLoading && 'animate-spin')} />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Reload configuration</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button variant="ghost" size="sm" onClick={handleClose}>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Close (Esc)</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-hidden flex flex-col">
|
||||
{loadError ? (
|
||||
<div className="flex items-center justify-center h-full p-4">
|
||||
<div className="text-center max-w-md">
|
||||
<AlertTriangle className="h-12 w-12 text-destructive mx-auto mb-4" />
|
||||
<h3 className="text-lg font-semibold mb-2">
|
||||
Failed to load configuration
|
||||
</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
{loadError?.message || 'Unknown error'}
|
||||
</p>
|
||||
<Button onClick={() => refetchConfig()} variant="outline">
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : isLoading ? (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent mx-auto mb-4" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Loading configuration...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : parseError && editorMode === 'form' ? (
|
||||
<div className="flex items-center justify-center h-full p-4">
|
||||
<div className="text-center max-w-md">
|
||||
<AlertTriangle className="h-12 w-12 text-destructive mx-auto mb-4" />
|
||||
<h3 className="text-lg font-semibold mb-2">Cannot parse YAML</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">{parseError}</p>
|
||||
<Button onClick={() => setEditorMode('yaml')} variant="outline">
|
||||
Switch to YAML Editor
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : editorMode === 'yaml' ? (
|
||||
<YAMLEditorView
|
||||
value={yamlContent}
|
||||
onChange={handleYamlChange}
|
||||
isValidating={validateMutation.isPending}
|
||||
isValid={isValid}
|
||||
errors={errors}
|
||||
warnings={warnings}
|
||||
hasUnsavedChanges={hasUnsavedChanges}
|
||||
/>
|
||||
) : parsedConfig ? (
|
||||
<FormEditorView
|
||||
config={parsedConfig}
|
||||
onChange={handleFormChange}
|
||||
errors={errors.reduce(
|
||||
(acc, err) => {
|
||||
if (err.path) {
|
||||
acc[err.path] = err.message;
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, string>
|
||||
)}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
{!loadError && !isLoading && (
|
||||
<div className="flex flex-col border-t border-border">
|
||||
{/* Save status messages */}
|
||||
{(saveSuccess || saveMutation.error) && (
|
||||
<div className="px-4 py-3 bg-muted/50 border-b border-border">
|
||||
{saveSuccess && (
|
||||
<div className="flex items-center gap-2 text-sm text-green-600 dark:text-green-500">
|
||||
<CheckCircle className="h-4 w-4" />
|
||||
<span>{saveMessage}</span>
|
||||
</div>
|
||||
)}
|
||||
{saveMutation.error && (
|
||||
<div className="flex items-center gap-2 text-sm text-destructive">
|
||||
<AlertTriangle className="h-4 w-4" />
|
||||
<span>{saveMutation.error.message}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action buttons */}
|
||||
<div className="flex items-center justify-between px-4 py-3">
|
||||
<div />
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={handleClose}>
|
||||
Close
|
||||
</Button>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div>
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={handleSave}
|
||||
disabled={isSaveDisabled}
|
||||
>
|
||||
{saveMutation.isPending ? (
|
||||
<>
|
||||
<div className="h-4 w-4 animate-spin rounded-full border-2 border-background border-t-transparent mr-2" />
|
||||
Saving...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Save className="h-4 w-4 mr-2" />
|
||||
Save
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{saveDisabledReason || 'Save configuration (⌘S)'}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Unsaved changes dialog */}
|
||||
<Dialog open={showUnsavedDialog} onOpenChange={setShowUnsavedDialog}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Unsaved Changes</DialogTitle>
|
||||
<DialogDescription>
|
||||
You have unsaved changes. Do you want to discard them?
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setShowUnsavedDialog(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={handleDiscardChanges}>
|
||||
Discard Changes
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (variant === 'inline') {
|
||||
return panelContent;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className={cn(
|
||||
'fixed inset-0 z-40 bg-background/60 backdrop-blur-sm transition-opacity duration-300',
|
||||
isOpen ? 'opacity-100' : 'opacity-0 pointer-events-none'
|
||||
)}
|
||||
onClick={handleClose}
|
||||
/>
|
||||
{/* Panel */}
|
||||
<div
|
||||
className={cn(
|
||||
'fixed inset-y-0 right-0 z-50 w-full sm:w-[600px] md:w-[700px] lg:w-[800px] border-l border-border/50 bg-card/95 backdrop-blur-xl shadow-2xl transform transition-transform duration-300',
|
||||
isOpen ? 'translate-x-0' : 'translate-x-full'
|
||||
)}
|
||||
>
|
||||
{panelContent}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
274
dexto/packages/webui/components/AgentEditor/FormEditor.tsx
Normal file
274
dexto/packages/webui/components/AgentEditor/FormEditor.tsx
Normal file
@@ -0,0 +1,274 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { LLMConfigSection } from './form-sections/LLMConfigSection';
|
||||
import { SystemPromptSection } from './form-sections/SystemPromptSection';
|
||||
import { McpServersSection } from './form-sections/McpServersSection';
|
||||
import { StorageSection } from './form-sections/StorageSection';
|
||||
import { ToolConfirmationSection } from './form-sections/ToolConfirmationSection';
|
||||
import { Collapsible } from '../ui/collapsible';
|
||||
import { Input } from '../ui/input';
|
||||
import { LabelWithTooltip } from '../ui/label-with-tooltip';
|
||||
import { AlertCircle } from 'lucide-react';
|
||||
import type { AgentConfig, ContributorConfig } from '@dexto/core';
|
||||
|
||||
interface FormEditorProps {
|
||||
config: AgentConfig;
|
||||
onChange: (config: AgentConfig) => void;
|
||||
errors?: Record<string, string>;
|
||||
}
|
||||
|
||||
type SectionKey = 'basic' | 'llm' | 'systemPrompt' | 'mcpServers' | 'storage' | 'toolConfirmation';
|
||||
|
||||
export default function FormEditor({ config, onChange, errors = {} }: FormEditorProps) {
|
||||
// Convert systemPrompt to contributors format for the UI
|
||||
const systemPromptValue = (() => {
|
||||
if (!config.systemPrompt) {
|
||||
return { contributors: [] };
|
||||
}
|
||||
if (typeof config.systemPrompt === 'string') {
|
||||
// Convert string to contributors array
|
||||
return {
|
||||
contributors: [
|
||||
{
|
||||
id: 'primary',
|
||||
type: 'static' as const,
|
||||
priority: 0,
|
||||
enabled: true,
|
||||
content: config.systemPrompt,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
// Already in object format with contributors - ensure contributors array exists
|
||||
return {
|
||||
contributors: config.systemPrompt.contributors || [],
|
||||
};
|
||||
})();
|
||||
|
||||
// Track which sections are open
|
||||
const [openSections, setOpenSections] = useState<Record<SectionKey, boolean>>({
|
||||
basic: true,
|
||||
llm: false,
|
||||
systemPrompt: false,
|
||||
mcpServers: false,
|
||||
storage: false,
|
||||
toolConfirmation: false,
|
||||
});
|
||||
|
||||
// Map errors to sections
|
||||
const sectionErrors = mapErrorsToSections(errors);
|
||||
|
||||
// Auto-expand sections with errors
|
||||
useEffect(() => {
|
||||
// Compute derived value inside effect to avoid stale closures
|
||||
const derivedSectionErrors = mapErrorsToSections(errors);
|
||||
const sectionsWithErrors = Object.keys(derivedSectionErrors).filter(
|
||||
(section) => derivedSectionErrors[section as SectionKey].length > 0
|
||||
) as SectionKey[];
|
||||
|
||||
if (sectionsWithErrors.length > 0) {
|
||||
setOpenSections((prev) => {
|
||||
const updated = { ...prev };
|
||||
sectionsWithErrors.forEach((section) => {
|
||||
updated[section] = true;
|
||||
});
|
||||
return updated;
|
||||
});
|
||||
}
|
||||
}, [errors]);
|
||||
|
||||
const toggleSection = (section: SectionKey) => {
|
||||
setOpenSections((prev) => ({
|
||||
...prev,
|
||||
[section]: !prev[section],
|
||||
}));
|
||||
};
|
||||
|
||||
// Handle section updates
|
||||
const updateLLM = (llm: AgentConfig['llm']) => {
|
||||
onChange({ ...config, llm });
|
||||
};
|
||||
|
||||
const updateSystemPrompt = (value: { contributors: ContributorConfig[] }) => {
|
||||
onChange({ ...config, systemPrompt: value });
|
||||
};
|
||||
|
||||
const updateMcpServers = (mcpServers: AgentConfig['mcpServers']) => {
|
||||
onChange({ ...config, mcpServers });
|
||||
};
|
||||
|
||||
const updateStorage = (storage: AgentConfig['storage']) => {
|
||||
onChange({ ...config, storage });
|
||||
};
|
||||
|
||||
const updateToolConfirmation = (toolConfirmation: AgentConfig['toolConfirmation']) => {
|
||||
onChange({ ...config, toolConfirmation });
|
||||
};
|
||||
|
||||
// Check if config has advanced features that aren't supported in form mode
|
||||
const hasAdvancedFeatures = checkForAdvancedFeatures(config);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full overflow-auto">
|
||||
{/* Advanced Features Warning */}
|
||||
{hasAdvancedFeatures && (
|
||||
<div className="mx-4 mt-4 p-3 bg-yellow-500/10 border border-yellow-500/20 rounded-lg">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertCircle className="h-4 w-4 text-yellow-600 dark:text-yellow-500 mt-0.5 flex-shrink-0" />
|
||||
<div className="text-sm">
|
||||
<p className="font-medium text-yellow-600 dark:text-yellow-500">
|
||||
Advanced Configuration Detected
|
||||
</p>
|
||||
<p className="text-xs text-yellow-600/80 dark:text-yellow-500/80 mt-1">
|
||||
Some advanced features may not be editable in form mode. Switch to
|
||||
YAML editor for full control.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Form Sections */}
|
||||
<div className="flex-1 p-4 space-y-4">
|
||||
{/* Basic Info Section */}
|
||||
<Collapsible
|
||||
title="Basic Information"
|
||||
open={openSections.basic}
|
||||
onOpenChange={() => toggleSection('basic')}
|
||||
errorCount={sectionErrors.basic.length}
|
||||
sectionErrors={sectionErrors.basic}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<LabelWithTooltip
|
||||
htmlFor="agent-greeting"
|
||||
tooltip="The initial message shown to users when they start a conversation"
|
||||
>
|
||||
Greeting Message
|
||||
</LabelWithTooltip>
|
||||
<Input
|
||||
id="agent-greeting"
|
||||
value={config.greeting || ''}
|
||||
onChange={(e) => onChange({ ...config, greeting: e.target.value })}
|
||||
placeholder="Hello! How can I help you today?"
|
||||
aria-invalid={!!errors.greeting}
|
||||
/>
|
||||
{errors.greeting && (
|
||||
<p className="text-xs text-destructive mt-1">{errors.greeting}</p>
|
||||
)}
|
||||
</div>
|
||||
</Collapsible>
|
||||
|
||||
{/* LLM Configuration */}
|
||||
<LLMConfigSection
|
||||
value={config.llm}
|
||||
onChange={updateLLM}
|
||||
errors={errors}
|
||||
open={openSections.llm}
|
||||
onOpenChange={() => toggleSection('llm')}
|
||||
errorCount={sectionErrors.llm.length}
|
||||
sectionErrors={sectionErrors.llm}
|
||||
/>
|
||||
|
||||
{/* System Prompt */}
|
||||
<SystemPromptSection
|
||||
value={systemPromptValue}
|
||||
onChange={updateSystemPrompt}
|
||||
errors={errors}
|
||||
open={openSections.systemPrompt}
|
||||
onOpenChange={() => toggleSection('systemPrompt')}
|
||||
errorCount={sectionErrors.systemPrompt.length}
|
||||
sectionErrors={sectionErrors.systemPrompt}
|
||||
/>
|
||||
|
||||
{/* MCP Servers */}
|
||||
<McpServersSection
|
||||
value={config.mcpServers || {}}
|
||||
onChange={updateMcpServers}
|
||||
errors={errors}
|
||||
open={openSections.mcpServers}
|
||||
onOpenChange={() => toggleSection('mcpServers')}
|
||||
errorCount={sectionErrors.mcpServers.length}
|
||||
sectionErrors={sectionErrors.mcpServers}
|
||||
/>
|
||||
|
||||
{/* Storage Configuration */}
|
||||
<StorageSection
|
||||
value={
|
||||
config.storage || {
|
||||
cache: { type: 'in-memory' },
|
||||
database: { type: 'in-memory' },
|
||||
blob: { type: 'local', storePath: '/tmp/dexto-blobs' },
|
||||
}
|
||||
}
|
||||
onChange={updateStorage}
|
||||
errors={errors}
|
||||
open={openSections.storage}
|
||||
onOpenChange={() => toggleSection('storage')}
|
||||
errorCount={sectionErrors.storage.length}
|
||||
sectionErrors={sectionErrors.storage}
|
||||
/>
|
||||
|
||||
{/* Tool Confirmation */}
|
||||
<ToolConfirmationSection
|
||||
value={config.toolConfirmation || {}}
|
||||
onChange={updateToolConfirmation}
|
||||
errors={errors}
|
||||
open={openSections.toolConfirmation}
|
||||
onOpenChange={() => toggleSection('toolConfirmation')}
|
||||
errorCount={sectionErrors.toolConfirmation.length}
|
||||
sectionErrors={sectionErrors.toolConfirmation}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if config has advanced features that aren't well-supported in form mode
|
||||
*/
|
||||
function checkForAdvancedFeatures(config: AgentConfig): boolean {
|
||||
// System prompt is now fully supported in form mode via contributors
|
||||
|
||||
// Check for session config customization
|
||||
if (config.sessions && Object.keys(config.sessions).length > 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for internal tools customization
|
||||
if (config.internalTools) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map error paths to form sections
|
||||
*/
|
||||
function mapErrorsToSections(errors: Record<string, string>): Record<SectionKey, string[]> {
|
||||
const sectionErrors: Record<SectionKey, string[]> = {
|
||||
basic: [],
|
||||
llm: [],
|
||||
systemPrompt: [],
|
||||
mcpServers: [],
|
||||
storage: [],
|
||||
toolConfirmation: [],
|
||||
};
|
||||
|
||||
Object.entries(errors).forEach(([path, message]) => {
|
||||
if (path === 'greeting') {
|
||||
sectionErrors.basic.push(message);
|
||||
} else if (path.startsWith('llm.')) {
|
||||
sectionErrors.llm.push(message);
|
||||
} else if (path.startsWith('systemPrompt')) {
|
||||
sectionErrors.systemPrompt.push(message);
|
||||
} else if (path.startsWith('mcpServers')) {
|
||||
sectionErrors.mcpServers.push(message);
|
||||
} else if (path.startsWith('storage.')) {
|
||||
sectionErrors.storage.push(message);
|
||||
} else if (path.startsWith('toolConfirmation.')) {
|
||||
sectionErrors.toolConfirmation.push(message);
|
||||
}
|
||||
});
|
||||
|
||||
return sectionErrors;
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import React from 'react';
|
||||
import FormEditorTabs from './FormEditorTabs';
|
||||
import type { AgentConfig } from '@dexto/core';
|
||||
|
||||
interface FormEditorViewProps {
|
||||
config: AgentConfig;
|
||||
onChange: (config: AgentConfig) => void;
|
||||
errors?: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
* FormEditorView - Pure form editor wrapper with tabbed interface
|
||||
*
|
||||
* This component wraps FormEditorTabs and provides a clean interface.
|
||||
* It doesn't handle YAML conversion or loading/saving - that's the parent's job.
|
||||
*
|
||||
* Reusable in both edit and create flows.
|
||||
*/
|
||||
export default function FormEditorView({ config, onChange, errors = {} }: FormEditorViewProps) {
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<FormEditorTabs config={config} onChange={onChange} errors={errors} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import React from 'react';
|
||||
import AgentConfigEditor from './AgentConfigEditor';
|
||||
import ConfigValidationStatus from './ConfigValidationStatus';
|
||||
import type { editor } from 'monaco-editor';
|
||||
import type { ValidationError, ValidationWarning } from '../hooks/useAgentConfig';
|
||||
|
||||
interface YAMLEditorViewProps {
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
onValidate?: (markers: editor.IMarker[]) => void;
|
||||
isValidating?: boolean;
|
||||
isValid?: boolean;
|
||||
errors?: ValidationError[];
|
||||
warnings?: ValidationWarning[];
|
||||
hasUnsavedChanges?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* YAMLEditorView - Pure YAML editor with validation display
|
||||
*
|
||||
* This component is responsible for rendering the Monaco YAML editor
|
||||
* and the validation status bar. It doesn't handle loading/saving -
|
||||
* that's the parent's job.
|
||||
*
|
||||
* Reusable in both edit and create flows.
|
||||
*/
|
||||
export default function YAMLEditorView({
|
||||
value,
|
||||
onChange,
|
||||
onValidate,
|
||||
isValidating = false,
|
||||
isValid = true,
|
||||
errors = [],
|
||||
warnings = [],
|
||||
hasUnsavedChanges = false,
|
||||
}: YAMLEditorViewProps) {
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Editor */}
|
||||
<div className="flex-1 overflow-hidden">
|
||||
<AgentConfigEditor
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
onValidate={onValidate}
|
||||
height="100%"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Validation Status */}
|
||||
<ConfigValidationStatus
|
||||
isValidating={isValidating}
|
||||
isValid={isValid}
|
||||
errors={errors}
|
||||
warnings={warnings}
|
||||
hasUnsavedChanges={hasUnsavedChanges}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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