- 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>
1337 lines
52 KiB
TypeScript
1337 lines
52 KiB
TypeScript
import React, { useEffect, useRef, useState } from 'react';
|
|
import { Input } from '../ui/input';
|
|
import { Button } from '../ui/button';
|
|
import {
|
|
Loader2,
|
|
Plus,
|
|
X,
|
|
ChevronDown,
|
|
Eye,
|
|
EyeOff,
|
|
Check,
|
|
ExternalLink,
|
|
Info,
|
|
} from 'lucide-react';
|
|
import { cn } from '../../lib/utils';
|
|
import { validateBaseURL } from './types';
|
|
import { useValidateOpenRouterModel } from '../hooks/useOpenRouter';
|
|
import { useProviderApiKey, type LLMProvider } from '../hooks/useLLM';
|
|
import { useValidateLocalFile } from '../hooks/useModels';
|
|
import { useDextoAuth } from '../hooks/useDextoAuth';
|
|
|
|
const BEDROCK_DOCS_URL = 'https://docs.dexto.ai/docs/guides/supported-llm-providers#amazon-bedrock';
|
|
|
|
// 'vertex' is TODO - see comment in PROVIDER_OPTIONS.
|
|
export type CustomModelProvider =
|
|
| 'openai-compatible'
|
|
| 'openrouter'
|
|
| 'litellm'
|
|
| 'glama'
|
|
| 'bedrock'
|
|
| 'ollama'
|
|
| 'local'
|
|
| 'dexto';
|
|
|
|
export interface CustomModelFormData {
|
|
provider: CustomModelProvider;
|
|
name: string;
|
|
baseURL: string;
|
|
displayName: string;
|
|
maxInputTokens: string;
|
|
maxOutputTokens: string;
|
|
apiKey: string;
|
|
filePath: string;
|
|
}
|
|
|
|
interface CustomModelFormProps {
|
|
formData: CustomModelFormData;
|
|
onChange: (updates: Partial<CustomModelFormData>) => void;
|
|
onSubmit: () => void;
|
|
onCancel: () => void;
|
|
isSubmitting?: boolean;
|
|
error?: string | null;
|
|
isEditing?: boolean;
|
|
}
|
|
|
|
const PROVIDER_OPTIONS: { value: CustomModelProvider; label: string; description: string }[] = [
|
|
{
|
|
value: 'openai-compatible',
|
|
label: 'OpenAI-Compatible',
|
|
description: 'Local or self-hosted models (Ollama, vLLM, etc.)',
|
|
},
|
|
{
|
|
value: 'openrouter',
|
|
label: 'OpenRouter',
|
|
description: 'Access 100+ models via OpenRouter API',
|
|
},
|
|
{
|
|
value: 'litellm',
|
|
label: 'LiteLLM',
|
|
description: 'Unified proxy for 100+ LLM providers',
|
|
},
|
|
{
|
|
value: 'glama',
|
|
label: 'Glama',
|
|
description: 'OpenAI-compatible gateway for multiple providers',
|
|
},
|
|
{
|
|
value: 'bedrock',
|
|
label: 'AWS Bedrock',
|
|
description: 'Custom Bedrock model IDs (uses AWS credentials)',
|
|
},
|
|
{
|
|
value: 'ollama',
|
|
label: 'Ollama',
|
|
description: 'Local Ollama server models',
|
|
},
|
|
{
|
|
value: 'local',
|
|
label: 'Local (GGUF)',
|
|
description: 'Custom GGUF files via node-llama-cpp',
|
|
},
|
|
// TODO: Add 'vertex' provider for custom Vertex AI model IDs (uses ADC auth like Bedrock)
|
|
// Would allow users to add model IDs not yet in registry (e.g., new Gemini previews)
|
|
];
|
|
|
|
// Dexto option is feature-flagged - shown separately when enabled
|
|
const DEXTO_PROVIDER_OPTION = {
|
|
value: 'dexto' as const,
|
|
label: 'Dexto',
|
|
description: 'Access 100+ models with Dexto credits (login required)',
|
|
};
|
|
|
|
// ============================================================================
|
|
// Provider-specific field components
|
|
// ============================================================================
|
|
|
|
interface ProviderFieldsProps {
|
|
formData: CustomModelFormData;
|
|
onChange: (updates: Partial<CustomModelFormData>) => void;
|
|
setLocalError: (error: string | null) => void;
|
|
providerKeyData?: { hasKey: boolean; envVar: string };
|
|
}
|
|
|
|
/**
|
|
* Bedrock fields - just model ID, display name, max tokens
|
|
* No API key (uses AWS credentials)
|
|
*/
|
|
function BedrockFields({ formData, onChange, setLocalError }: ProviderFieldsProps) {
|
|
return (
|
|
<>
|
|
{/* Setup Guide Banner */}
|
|
<div className="p-3 rounded-md bg-blue-500/10 border border-blue-500/30">
|
|
<div className="flex items-start gap-2">
|
|
<Info className="h-4 w-4 text-blue-500 mt-0.5 flex-shrink-0" />
|
|
<div className="space-y-1">
|
|
<p className="text-xs text-blue-700 dark:text-blue-300">
|
|
Bedrock uses AWS credentials from your environment.
|
|
</p>
|
|
<p className="text-xs text-muted-foreground">
|
|
Make sure{' '}
|
|
<code className="px-1 py-0.5 rounded bg-muted text-[10px]">
|
|
AWS_REGION
|
|
</code>{' '}
|
|
and either{' '}
|
|
<code className="px-1 py-0.5 rounded bg-muted text-[10px]">
|
|
AWS_BEARER_TOKEN_BEDROCK
|
|
</code>{' '}
|
|
or IAM credentials are set.
|
|
</p>
|
|
<a
|
|
href={BEDROCK_DOCS_URL}
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="inline-flex items-center gap-1 text-xs text-primary hover:underline mt-1"
|
|
>
|
|
View setup guide
|
|
<ExternalLink className="h-3 w-3" />
|
|
</a>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Model ID */}
|
|
<div className="space-y-1.5">
|
|
<label className="text-xs font-medium text-muted-foreground">Model ID *</label>
|
|
<Input
|
|
value={formData.name}
|
|
onChange={(e) => {
|
|
onChange({ name: e.target.value });
|
|
setLocalError(null);
|
|
}}
|
|
placeholder="e.g., anthropic.claude-3-haiku-20240307-v1:0"
|
|
className="h-9 text-sm"
|
|
/>
|
|
<p className="text-[10px] text-muted-foreground">
|
|
Find model IDs in the{' '}
|
|
<a
|
|
href="https://docs.aws.amazon.com/bedrock/latest/userguide/model-ids.html"
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="text-primary hover:underline"
|
|
>
|
|
AWS Bedrock documentation
|
|
</a>
|
|
</p>
|
|
</div>
|
|
|
|
{/* Display Name */}
|
|
<div className="space-y-1.5">
|
|
<label className="text-xs font-medium text-muted-foreground">
|
|
Display Name <span className="text-muted-foreground/60">(optional)</span>
|
|
</label>
|
|
<Input
|
|
value={formData.displayName}
|
|
onChange={(e) => onChange({ displayName: e.target.value })}
|
|
placeholder="Friendly name for the model"
|
|
className="h-9 text-sm"
|
|
/>
|
|
</div>
|
|
|
|
{/* Max Input Tokens */}
|
|
<div className="space-y-1.5">
|
|
<label className="text-xs font-medium text-muted-foreground">
|
|
Max Input Tokens <span className="text-muted-foreground/60">(optional)</span>
|
|
</label>
|
|
<Input
|
|
value={formData.maxInputTokens}
|
|
onChange={(e) => onChange({ maxInputTokens: e.target.value })}
|
|
placeholder="e.g., 200000 (leave blank for default)"
|
|
type="number"
|
|
className="h-9 text-sm"
|
|
/>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* OpenRouter fields - model ID with live validation, API key
|
|
*/
|
|
function OpenRouterFields({
|
|
formData,
|
|
onChange,
|
|
setLocalError,
|
|
providerKeyData,
|
|
}: ProviderFieldsProps) {
|
|
const { mutateAsync: validateOpenRouterModel } = useValidateOpenRouterModel();
|
|
const validationTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
const [showApiKey, setShowApiKey] = useState(false);
|
|
const [validation, setValidation] = useState<{
|
|
status: 'idle' | 'validating' | 'valid' | 'invalid';
|
|
error?: string;
|
|
}>({ status: 'idle' });
|
|
|
|
// Debounced validation
|
|
useEffect(() => {
|
|
const modelId = formData.name.trim();
|
|
if (!modelId) {
|
|
setValidation({ status: 'idle' });
|
|
return;
|
|
}
|
|
if (!modelId.includes('/')) {
|
|
setValidation({
|
|
status: 'invalid',
|
|
error: 'Format: provider/model (e.g., anthropic/claude-3.5-sonnet)',
|
|
});
|
|
return;
|
|
}
|
|
if (validationTimerRef.current) clearTimeout(validationTimerRef.current);
|
|
setValidation({ status: 'validating' });
|
|
validationTimerRef.current = setTimeout(async () => {
|
|
try {
|
|
const result = await validateOpenRouterModel(modelId);
|
|
setValidation(
|
|
result.valid
|
|
? { status: 'valid' }
|
|
: {
|
|
status: 'invalid',
|
|
error: result.error || `Model '${modelId}' not found`,
|
|
}
|
|
);
|
|
} catch {
|
|
setValidation({ status: 'invalid', error: 'Validation failed - check model ID' });
|
|
}
|
|
}, 500);
|
|
return () => {
|
|
if (validationTimerRef.current) clearTimeout(validationTimerRef.current);
|
|
};
|
|
}, [formData.name, validateOpenRouterModel]);
|
|
|
|
const isValid = validation.status === 'valid';
|
|
const isInvalid = validation.status === 'invalid';
|
|
const isValidating = validation.status === 'validating';
|
|
|
|
return (
|
|
<>
|
|
{/* Model ID */}
|
|
<div className="space-y-1.5">
|
|
<label className="text-xs font-medium text-muted-foreground">Model ID *</label>
|
|
<div className="relative">
|
|
<Input
|
|
value={formData.name}
|
|
onChange={(e) => {
|
|
onChange({ name: e.target.value });
|
|
setLocalError(null);
|
|
}}
|
|
placeholder="e.g., anthropic/claude-3.5-sonnet"
|
|
className={cn(
|
|
'h-9 text-sm pr-8',
|
|
isValid && 'border-green-500 focus-visible:ring-green-500',
|
|
isInvalid && 'border-red-500 focus-visible:ring-red-500'
|
|
)}
|
|
/>
|
|
{formData.name.trim() && (
|
|
<div className="absolute right-2 top-1/2 -translate-y-1/2">
|
|
{isValidating && (
|
|
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
|
)}
|
|
{isValid && <Check className="h-4 w-4 text-green-500" />}
|
|
{isInvalid && <X className="h-4 w-4 text-red-500" />}
|
|
</div>
|
|
)}
|
|
</div>
|
|
{isInvalid && validation.error && (
|
|
<p className="text-[10px] text-red-500">{validation.error}</p>
|
|
)}
|
|
<p className="text-[10px] text-muted-foreground">
|
|
Find model IDs at{' '}
|
|
<a
|
|
href="https://openrouter.ai/models"
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="text-primary hover:underline"
|
|
>
|
|
openrouter.ai/models
|
|
</a>
|
|
</p>
|
|
</div>
|
|
|
|
{/* Display Name */}
|
|
<div className="space-y-1.5">
|
|
<label className="text-xs font-medium text-muted-foreground">
|
|
Display Name <span className="text-muted-foreground/60">(optional)</span>
|
|
</label>
|
|
<Input
|
|
value={formData.displayName}
|
|
onChange={(e) => onChange({ displayName: e.target.value })}
|
|
placeholder="Friendly name for the model"
|
|
className="h-9 text-sm"
|
|
/>
|
|
</div>
|
|
|
|
{/* API Key */}
|
|
<ApiKeyField
|
|
formData={formData}
|
|
onChange={onChange}
|
|
providerKeyData={providerKeyData}
|
|
showApiKey={showApiKey}
|
|
setShowApiKey={setShowApiKey}
|
|
/>
|
|
</>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Glama fields - model ID (provider/model format), API key
|
|
*/
|
|
function GlamaFields({ formData, onChange, setLocalError, providerKeyData }: ProviderFieldsProps) {
|
|
const [showApiKey, setShowApiKey] = useState(false);
|
|
|
|
return (
|
|
<>
|
|
{/* Model ID */}
|
|
<div className="space-y-1.5">
|
|
<label className="text-xs font-medium text-muted-foreground">Model ID *</label>
|
|
<Input
|
|
value={formData.name}
|
|
onChange={(e) => {
|
|
onChange({ name: e.target.value });
|
|
setLocalError(null);
|
|
}}
|
|
placeholder="e.g., openai/gpt-4o, anthropic/claude-3-sonnet"
|
|
className="h-9 text-sm"
|
|
/>
|
|
<p className="text-[10px] text-muted-foreground">
|
|
Format: provider/model. See{' '}
|
|
<a
|
|
href="https://glama.ai/"
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="text-primary hover:underline"
|
|
>
|
|
glama.ai
|
|
</a>{' '}
|
|
for supported providers.
|
|
</p>
|
|
</div>
|
|
|
|
{/* Display Name */}
|
|
<div className="space-y-1.5">
|
|
<label className="text-xs font-medium text-muted-foreground">
|
|
Display Name <span className="text-muted-foreground/60">(optional)</span>
|
|
</label>
|
|
<Input
|
|
value={formData.displayName}
|
|
onChange={(e) => onChange({ displayName: e.target.value })}
|
|
placeholder="Friendly name for the model"
|
|
className="h-9 text-sm"
|
|
/>
|
|
</div>
|
|
|
|
{/* API Key */}
|
|
<ApiKeyField
|
|
formData={formData}
|
|
onChange={onChange}
|
|
providerKeyData={providerKeyData}
|
|
showApiKey={showApiKey}
|
|
setShowApiKey={setShowApiKey}
|
|
/>
|
|
</>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* OpenAI-Compatible fields - model name, baseURL (required), API key, token limits
|
|
*/
|
|
function OpenAICompatibleFields({
|
|
formData,
|
|
onChange,
|
|
setLocalError,
|
|
providerKeyData,
|
|
}: ProviderFieldsProps) {
|
|
const [showApiKey, setShowApiKey] = useState(false);
|
|
|
|
return (
|
|
<>
|
|
{/* Model Name */}
|
|
<div className="space-y-1.5">
|
|
<label className="text-xs font-medium text-muted-foreground">Model Name *</label>
|
|
<Input
|
|
value={formData.name}
|
|
onChange={(e) => {
|
|
onChange({ name: e.target.value });
|
|
setLocalError(null);
|
|
}}
|
|
placeholder="e.g., llama3.2:latest"
|
|
className="h-9 text-sm"
|
|
/>
|
|
</div>
|
|
|
|
{/* Base URL */}
|
|
<div className="space-y-1.5">
|
|
<label className="text-xs font-medium text-muted-foreground">Base URL *</label>
|
|
<Input
|
|
value={formData.baseURL}
|
|
onChange={(e) => {
|
|
onChange({ baseURL: e.target.value });
|
|
setLocalError(null);
|
|
}}
|
|
placeholder="e.g., http://localhost:11434/v1"
|
|
className="h-9 text-sm"
|
|
/>
|
|
<p className="text-[10px] text-muted-foreground">
|
|
The API endpoint URL (must include /v1 for OpenAI-compatible APIs)
|
|
</p>
|
|
</div>
|
|
|
|
{/* Display Name */}
|
|
<div className="space-y-1.5">
|
|
<label className="text-xs font-medium text-muted-foreground">
|
|
Display Name <span className="text-muted-foreground/60">(optional)</span>
|
|
</label>
|
|
<Input
|
|
value={formData.displayName}
|
|
onChange={(e) => onChange({ displayName: e.target.value })}
|
|
placeholder="Friendly name for the model"
|
|
className="h-9 text-sm"
|
|
/>
|
|
</div>
|
|
|
|
{/* API Key */}
|
|
<ApiKeyField
|
|
formData={formData}
|
|
onChange={onChange}
|
|
providerKeyData={providerKeyData}
|
|
showApiKey={showApiKey}
|
|
setShowApiKey={setShowApiKey}
|
|
placeholder="Required if your endpoint needs authentication"
|
|
/>
|
|
|
|
{/* Token limits */}
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div className="space-y-1.5">
|
|
<label className="text-xs font-medium text-muted-foreground">
|
|
Max Input Tokens
|
|
</label>
|
|
<Input
|
|
value={formData.maxInputTokens}
|
|
onChange={(e) => onChange({ maxInputTokens: e.target.value })}
|
|
placeholder="128000"
|
|
type="number"
|
|
className="h-9 text-sm"
|
|
/>
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<label className="text-xs font-medium text-muted-foreground">
|
|
Max Output Tokens
|
|
</label>
|
|
<Input
|
|
value={formData.maxOutputTokens}
|
|
onChange={(e) => onChange({ maxOutputTokens: e.target.value })}
|
|
placeholder="Optional"
|
|
type="number"
|
|
className="h-9 text-sm"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* LiteLLM fields - model name, baseURL (required), API key, token limits
|
|
*/
|
|
function LiteLLMFields({
|
|
formData,
|
|
onChange,
|
|
setLocalError,
|
|
providerKeyData,
|
|
}: ProviderFieldsProps) {
|
|
const [showApiKey, setShowApiKey] = useState(false);
|
|
|
|
return (
|
|
<>
|
|
{/* Model Name */}
|
|
<div className="space-y-1.5">
|
|
<label className="text-xs font-medium text-muted-foreground">Model Name *</label>
|
|
<Input
|
|
value={formData.name}
|
|
onChange={(e) => {
|
|
onChange({ name: e.target.value });
|
|
setLocalError(null);
|
|
}}
|
|
placeholder="e.g., gpt-4, claude-3-sonnet"
|
|
className="h-9 text-sm"
|
|
/>
|
|
</div>
|
|
|
|
{/* Base URL */}
|
|
<div className="space-y-1.5">
|
|
<label className="text-xs font-medium text-muted-foreground">
|
|
LiteLLM Proxy URL *
|
|
</label>
|
|
<Input
|
|
value={formData.baseURL}
|
|
onChange={(e) => {
|
|
onChange({ baseURL: e.target.value });
|
|
setLocalError(null);
|
|
}}
|
|
placeholder="e.g., http://localhost:4000"
|
|
className="h-9 text-sm"
|
|
/>
|
|
<p className="text-[10px] text-muted-foreground">Your LiteLLM proxy URL</p>
|
|
</div>
|
|
|
|
{/* Display Name */}
|
|
<div className="space-y-1.5">
|
|
<label className="text-xs font-medium text-muted-foreground">
|
|
Display Name <span className="text-muted-foreground/60">(optional)</span>
|
|
</label>
|
|
<Input
|
|
value={formData.displayName}
|
|
onChange={(e) => onChange({ displayName: e.target.value })}
|
|
placeholder="Friendly name for the model"
|
|
className="h-9 text-sm"
|
|
/>
|
|
</div>
|
|
|
|
{/* API Key */}
|
|
<ApiKeyField
|
|
formData={formData}
|
|
onChange={onChange}
|
|
providerKeyData={providerKeyData}
|
|
showApiKey={showApiKey}
|
|
setShowApiKey={setShowApiKey}
|
|
/>
|
|
|
|
{/* Token limits */}
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div className="space-y-1.5">
|
|
<label className="text-xs font-medium text-muted-foreground">
|
|
Max Input Tokens
|
|
</label>
|
|
<Input
|
|
value={formData.maxInputTokens}
|
|
onChange={(e) => onChange({ maxInputTokens: e.target.value })}
|
|
placeholder="128000"
|
|
type="number"
|
|
className="h-9 text-sm"
|
|
/>
|
|
</div>
|
|
<div className="space-y-1.5">
|
|
<label className="text-xs font-medium text-muted-foreground">
|
|
Max Output Tokens
|
|
</label>
|
|
<Input
|
|
value={formData.maxOutputTokens}
|
|
onChange={(e) => onChange({ maxOutputTokens: e.target.value })}
|
|
placeholder="Optional"
|
|
type="number"
|
|
className="h-9 text-sm"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Ollama fields - model name, optional baseURL
|
|
* No API key required
|
|
*/
|
|
function OllamaFields({ formData, onChange, setLocalError }: ProviderFieldsProps) {
|
|
return (
|
|
<>
|
|
{/* Setup Guide Banner */}
|
|
<div className="p-3 rounded-md bg-blue-500/10 border border-blue-500/30">
|
|
<div className="flex items-start gap-2">
|
|
<Info className="h-4 w-4 text-blue-500 mt-0.5 flex-shrink-0" />
|
|
<div className="space-y-1">
|
|
<p className="text-xs text-blue-700 dark:text-blue-300">
|
|
Ollama must be installed and running.
|
|
</p>
|
|
<a
|
|
href="https://ollama.ai"
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="inline-flex items-center gap-1 text-xs text-primary hover:underline mt-1"
|
|
>
|
|
Get Ollama
|
|
<ExternalLink className="h-3 w-3" />
|
|
</a>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Model Name */}
|
|
<div className="space-y-1.5">
|
|
<label className="text-xs font-medium text-muted-foreground">Model Name *</label>
|
|
<Input
|
|
value={formData.name}
|
|
onChange={(e) => {
|
|
onChange({ name: e.target.value });
|
|
setLocalError(null);
|
|
}}
|
|
placeholder="e.g., llama3.2:latest, mistral:7b"
|
|
className="h-9 text-sm"
|
|
/>
|
|
<p className="text-[10px] text-muted-foreground">
|
|
Run{' '}
|
|
<code className="px-1 py-0.5 rounded bg-muted text-[10px]">ollama list</code> to
|
|
see available models
|
|
</p>
|
|
</div>
|
|
|
|
{/* Base URL (optional) */}
|
|
<div className="space-y-1.5">
|
|
<label className="text-xs font-medium text-muted-foreground">
|
|
Ollama URL <span className="text-muted-foreground/60">(optional)</span>
|
|
</label>
|
|
<Input
|
|
value={formData.baseURL}
|
|
onChange={(e) => onChange({ baseURL: e.target.value })}
|
|
placeholder="http://localhost:11434 (default)"
|
|
className="h-9 text-sm"
|
|
/>
|
|
</div>
|
|
|
|
{/* Display Name */}
|
|
<div className="space-y-1.5">
|
|
<label className="text-xs font-medium text-muted-foreground">
|
|
Display Name <span className="text-muted-foreground/60">(optional)</span>
|
|
</label>
|
|
<Input
|
|
value={formData.displayName}
|
|
onChange={(e) => onChange({ displayName: e.target.value })}
|
|
placeholder="Friendly name for the model"
|
|
className="h-9 text-sm"
|
|
/>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Local GGUF fields - model ID, file path with validation
|
|
* No API key required
|
|
*/
|
|
function LocalFields({ formData, onChange, setLocalError }: ProviderFieldsProps) {
|
|
const { mutateAsync: validateFile } = useValidateLocalFile();
|
|
const validationTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
const [validation, setValidation] = useState<{
|
|
status: 'idle' | 'validating' | 'valid' | 'invalid';
|
|
sizeBytes?: number;
|
|
error?: string;
|
|
}>({ status: 'idle' });
|
|
|
|
// Debounced file validation
|
|
useEffect(() => {
|
|
const filePath = formData.filePath?.trim();
|
|
if (!filePath) {
|
|
setValidation({ status: 'idle' });
|
|
return;
|
|
}
|
|
if (!filePath.startsWith('/')) {
|
|
setValidation({ status: 'invalid', error: 'Path must be absolute (start with /)' });
|
|
return;
|
|
}
|
|
if (!filePath.endsWith('.gguf')) {
|
|
setValidation({ status: 'invalid', error: 'File must have .gguf extension' });
|
|
return;
|
|
}
|
|
if (validationTimerRef.current) clearTimeout(validationTimerRef.current);
|
|
setValidation({ status: 'validating' });
|
|
validationTimerRef.current = setTimeout(async () => {
|
|
try {
|
|
const result = await validateFile(filePath);
|
|
setValidation(
|
|
result.valid
|
|
? { status: 'valid', sizeBytes: result.sizeBytes }
|
|
: { status: 'invalid', error: result.error || 'File not found' }
|
|
);
|
|
} catch {
|
|
setValidation({ status: 'invalid', error: 'Validation failed' });
|
|
}
|
|
}, 500);
|
|
return () => {
|
|
if (validationTimerRef.current) clearTimeout(validationTimerRef.current);
|
|
};
|
|
}, [formData.filePath, validateFile]);
|
|
|
|
const isValid = validation.status === 'valid';
|
|
const isInvalid = validation.status === 'invalid';
|
|
const isValidating = validation.status === 'validating';
|
|
|
|
// Helper to format file size
|
|
const formatSize = (bytes?: number) => {
|
|
if (!bytes) return '';
|
|
const gb = bytes / (1024 * 1024 * 1024);
|
|
if (gb >= 1) return `${gb.toFixed(1)} GB`;
|
|
const mb = bytes / (1024 * 1024);
|
|
return `${mb.toFixed(0)} MB`;
|
|
};
|
|
|
|
return (
|
|
<>
|
|
{/* Setup Guide Banner */}
|
|
<div className="p-3 rounded-md bg-blue-500/10 border border-blue-500/30">
|
|
<div className="flex items-start gap-2">
|
|
<Info className="h-4 w-4 text-blue-500 mt-0.5 flex-shrink-0" />
|
|
<div className="space-y-1">
|
|
<p className="text-xs text-blue-700 dark:text-blue-300">
|
|
Requires node-llama-cpp to be installed.
|
|
</p>
|
|
<p className="text-xs text-muted-foreground">
|
|
Run{' '}
|
|
<code className="px-1 py-0.5 rounded bg-muted text-[10px]">
|
|
dexto setup
|
|
</code>{' '}
|
|
and select "local" to install dependencies.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Model ID */}
|
|
<div className="space-y-1.5">
|
|
<label className="text-xs font-medium text-muted-foreground">Model ID *</label>
|
|
<Input
|
|
value={formData.name}
|
|
onChange={(e) => {
|
|
onChange({ name: e.target.value });
|
|
setLocalError(null);
|
|
}}
|
|
placeholder="e.g., my-custom-llama"
|
|
className="h-9 text-sm"
|
|
/>
|
|
<p className="text-[10px] text-muted-foreground">
|
|
A unique identifier for this model
|
|
</p>
|
|
</div>
|
|
|
|
{/* File Path */}
|
|
<div className="space-y-1.5">
|
|
<label className="text-xs font-medium text-muted-foreground">
|
|
GGUF File Path *
|
|
</label>
|
|
<div className="relative">
|
|
<Input
|
|
value={formData.filePath}
|
|
onChange={(e) => {
|
|
onChange({ filePath: e.target.value });
|
|
setLocalError(null);
|
|
}}
|
|
placeholder="/path/to/model.gguf"
|
|
className={cn(
|
|
'h-9 text-sm pr-8',
|
|
isValid && 'border-green-500 focus-visible:ring-green-500',
|
|
isInvalid && 'border-red-500 focus-visible:ring-red-500'
|
|
)}
|
|
/>
|
|
{formData.filePath?.trim() && (
|
|
<div className="absolute right-2 top-1/2 -translate-y-1/2">
|
|
{isValidating && (
|
|
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
|
)}
|
|
{isValid && <Check className="h-4 w-4 text-green-500" />}
|
|
{isInvalid && <X className="h-4 w-4 text-red-500" />}
|
|
</div>
|
|
)}
|
|
</div>
|
|
{isValid && validation.sizeBytes && (
|
|
<p className="text-[10px] text-green-600">
|
|
Found: {formatSize(validation.sizeBytes)}
|
|
</p>
|
|
)}
|
|
{isInvalid && validation.error && (
|
|
<p className="text-[10px] text-red-500">{validation.error}</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* Display Name */}
|
|
<div className="space-y-1.5">
|
|
<label className="text-xs font-medium text-muted-foreground">
|
|
Display Name <span className="text-muted-foreground/60">(optional)</span>
|
|
</label>
|
|
<Input
|
|
value={formData.displayName}
|
|
onChange={(e) => onChange({ displayName: e.target.value })}
|
|
placeholder="Friendly name for the model"
|
|
className="h-9 text-sm"
|
|
/>
|
|
</div>
|
|
|
|
{/* Note: Context length is auto-detected by node-llama-cpp from GGUF metadata */}
|
|
<p className="text-[10px] text-muted-foreground/60">
|
|
Context length is automatically detected from the GGUF file.
|
|
</p>
|
|
</>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Dexto fields - model ID with OpenRouter format, no API key (uses OAuth login)
|
|
*/
|
|
function DextoFields({ formData, onChange, setLocalError }: ProviderFieldsProps) {
|
|
const { mutateAsync: validateOpenRouterModel } = useValidateOpenRouterModel();
|
|
const validationTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
const [validation, setValidation] = useState<{
|
|
status: 'idle' | 'validating' | 'valid' | 'invalid';
|
|
error?: string;
|
|
}>({ status: 'idle' });
|
|
|
|
// Debounced validation (reuses OpenRouter validation since Dexto uses OpenRouter model IDs)
|
|
useEffect(() => {
|
|
const modelId = formData.name.trim();
|
|
if (!modelId) {
|
|
setValidation({ status: 'idle' });
|
|
return;
|
|
}
|
|
if (!modelId.includes('/')) {
|
|
setValidation({
|
|
status: 'invalid',
|
|
error: 'Format: provider/model (e.g., anthropic/claude-sonnet-4.5)',
|
|
});
|
|
return;
|
|
}
|
|
if (validationTimerRef.current) clearTimeout(validationTimerRef.current);
|
|
setValidation({ status: 'validating' });
|
|
validationTimerRef.current = setTimeout(async () => {
|
|
try {
|
|
const result = await validateOpenRouterModel(modelId);
|
|
setValidation(
|
|
result.valid
|
|
? { status: 'valid' }
|
|
: {
|
|
status: 'invalid',
|
|
error:
|
|
result.error ||
|
|
`Model '${modelId}' not found. Check https://openrouter.ai/models`,
|
|
}
|
|
);
|
|
} catch {
|
|
setValidation({ status: 'invalid', error: 'Validation failed - check model ID' });
|
|
}
|
|
}, 500);
|
|
return () => {
|
|
if (validationTimerRef.current) clearTimeout(validationTimerRef.current);
|
|
};
|
|
}, [formData.name, validateOpenRouterModel]);
|
|
|
|
const isValid = validation.status === 'valid';
|
|
const isInvalid = validation.status === 'invalid';
|
|
const isValidating = validation.status === 'validating';
|
|
|
|
return (
|
|
<>
|
|
{/* Setup Guide Banner */}
|
|
<div className="p-3 rounded-md bg-purple-500/10 border border-purple-500/30">
|
|
<div className="flex items-start gap-2">
|
|
<Info className="h-4 w-4 text-purple-500 mt-0.5 flex-shrink-0" />
|
|
<div className="space-y-1">
|
|
<p className="text-xs text-purple-700 dark:text-purple-300">
|
|
Uses your Dexto credits with OpenRouter model IDs.
|
|
</p>
|
|
<p className="text-xs text-muted-foreground">
|
|
Requires login: run{' '}
|
|
<code className="px-1 py-0.5 rounded bg-muted text-[10px]">
|
|
dexto login
|
|
</code>{' '}
|
|
from the CLI first.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Model ID */}
|
|
<div className="space-y-1.5">
|
|
<label className="text-xs font-medium text-muted-foreground">Model ID *</label>
|
|
<div className="relative">
|
|
<Input
|
|
value={formData.name}
|
|
onChange={(e) => {
|
|
onChange({ name: e.target.value });
|
|
setLocalError(null);
|
|
}}
|
|
placeholder="e.g., anthropic/claude-sonnet-4.5, openai/gpt-5.2"
|
|
className={cn(
|
|
'h-9 text-sm pr-8',
|
|
isValid && 'border-green-500 focus-visible:ring-green-500',
|
|
isInvalid && 'border-red-500 focus-visible:ring-red-500'
|
|
)}
|
|
/>
|
|
{formData.name.trim() && (
|
|
<div className="absolute right-2 top-1/2 -translate-y-1/2">
|
|
{isValidating && (
|
|
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
|
)}
|
|
{isValid && <Check className="h-4 w-4 text-green-500" />}
|
|
{isInvalid && <X className="h-4 w-4 text-red-500" />}
|
|
</div>
|
|
)}
|
|
</div>
|
|
{isInvalid && validation.error && (
|
|
<p className="text-[10px] text-red-500">{validation.error}</p>
|
|
)}
|
|
<p className="text-[10px] text-muted-foreground">
|
|
Find model IDs at{' '}
|
|
<a
|
|
href="https://openrouter.ai/models"
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
className="text-primary hover:underline"
|
|
>
|
|
openrouter.ai/models
|
|
</a>
|
|
</p>
|
|
</div>
|
|
|
|
{/* Display Name */}
|
|
<div className="space-y-1.5">
|
|
<label className="text-xs font-medium text-muted-foreground">
|
|
Display Name <span className="text-muted-foreground/60">(optional)</span>
|
|
</label>
|
|
<Input
|
|
value={formData.displayName}
|
|
onChange={(e) => onChange({ displayName: e.target.value })}
|
|
placeholder="Friendly name for the model"
|
|
className="h-9 text-sm"
|
|
/>
|
|
</div>
|
|
|
|
{/* No API Key field - Dexto uses OAuth login */}
|
|
</>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Reusable API Key field component
|
|
*/
|
|
function ApiKeyField({
|
|
formData,
|
|
onChange,
|
|
providerKeyData,
|
|
showApiKey,
|
|
setShowApiKey,
|
|
placeholder,
|
|
}: {
|
|
formData: CustomModelFormData;
|
|
onChange: (updates: Partial<CustomModelFormData>) => void;
|
|
providerKeyData?: { hasKey: boolean; envVar: string };
|
|
showApiKey: boolean;
|
|
setShowApiKey: (show: boolean) => void;
|
|
placeholder?: string;
|
|
}) {
|
|
const hasExistingKey = providerKeyData?.hasKey ?? false;
|
|
|
|
return (
|
|
<div className="space-y-1.5">
|
|
<div className="flex items-center gap-2">
|
|
<label className="text-xs font-medium text-muted-foreground">
|
|
API Key <span className="text-muted-foreground/60">(optional)</span>
|
|
</label>
|
|
{hasExistingKey && !formData.apiKey && (
|
|
<span className="inline-flex items-center gap-1 text-[10px] text-green-600 dark:text-green-400">
|
|
<Check className="h-3 w-3" />
|
|
Configured
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="relative">
|
|
<Input
|
|
value={formData.apiKey}
|
|
onChange={(e) => onChange({ apiKey: e.target.value })}
|
|
placeholder={
|
|
placeholder ||
|
|
(hasExistingKey
|
|
? 'Leave empty to use existing key'
|
|
: 'Enter API key for this endpoint')
|
|
}
|
|
type={showApiKey ? 'text' : 'password'}
|
|
className="h-9 text-sm pr-10"
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowApiKey(!showApiKey)}
|
|
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 rounded hover:bg-muted transition-colors"
|
|
>
|
|
{showApiKey ? (
|
|
<EyeOff className="h-4 w-4 text-muted-foreground" />
|
|
) : (
|
|
<Eye className="h-4 w-4 text-muted-foreground" />
|
|
)}
|
|
</button>
|
|
</div>
|
|
<p className="text-[10px] text-muted-foreground">
|
|
{hasExistingKey
|
|
? formData.apiKey
|
|
? `Will override ${providerKeyData?.envVar} for this model`
|
|
: `Using ${providerKeyData?.envVar}`
|
|
: `Will be saved as ${providerKeyData?.envVar || 'provider env var'}`}
|
|
</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// ============================================================================
|
|
// Main form component
|
|
// ============================================================================
|
|
|
|
/**
|
|
* Unified custom model form with provider dropdown and provider-specific fields
|
|
*/
|
|
export function CustomModelForm({
|
|
formData,
|
|
onChange,
|
|
onSubmit,
|
|
onCancel,
|
|
isSubmitting,
|
|
error,
|
|
isEditing = false,
|
|
}: CustomModelFormProps) {
|
|
const [dropdownOpen, setDropdownOpen] = useState(false);
|
|
const [localError, setLocalError] = useState<string | null>(null);
|
|
|
|
// Fetch provider API key status (not the actual key - it's masked for security)
|
|
const { data: providerKeyData } = useProviderApiKey(formData.provider as LLMProvider);
|
|
|
|
// Fetch dexto auth status to conditionally show dexto provider option
|
|
const { data: dextoAuthStatus } = useDextoAuth();
|
|
const showDextoProvider = dextoAuthStatus?.enabled ?? false;
|
|
|
|
// Build provider options list - include dexto when feature is enabled
|
|
const providerOptions = showDextoProvider
|
|
? [...PROVIDER_OPTIONS, DEXTO_PROVIDER_OPTION]
|
|
: PROVIDER_OPTIONS;
|
|
|
|
// Reset error when provider changes
|
|
useEffect(() => {
|
|
setLocalError(null);
|
|
}, [formData.provider]);
|
|
|
|
// Reset provider to default if dexto is selected but becomes unavailable
|
|
useEffect(() => {
|
|
if (dextoAuthStatus && !showDextoProvider && formData.provider === 'dexto') {
|
|
onChange({
|
|
...formData,
|
|
provider: 'openai-compatible',
|
|
});
|
|
}
|
|
}, [dextoAuthStatus, showDextoProvider, formData, onChange]);
|
|
|
|
const handleSubmit = () => {
|
|
// Provider-specific validation
|
|
switch (formData.provider) {
|
|
case 'openai-compatible':
|
|
case 'litellm': {
|
|
if (!formData.name.trim()) {
|
|
setLocalError('Model name is required');
|
|
return;
|
|
}
|
|
if (!formData.baseURL.trim()) {
|
|
setLocalError('Base URL is required');
|
|
return;
|
|
}
|
|
const urlValidation = validateBaseURL(formData.baseURL);
|
|
if (!urlValidation.isValid) {
|
|
setLocalError(urlValidation.error || 'Invalid Base URL');
|
|
return;
|
|
}
|
|
break;
|
|
}
|
|
case 'openrouter':
|
|
if (!formData.name.trim()) {
|
|
setLocalError('Model ID is required');
|
|
return;
|
|
}
|
|
if (!formData.name.includes('/')) {
|
|
setLocalError('Format: provider/model (e.g., anthropic/claude-3.5-sonnet)');
|
|
return;
|
|
}
|
|
break;
|
|
case 'glama':
|
|
if (!formData.name.trim()) {
|
|
setLocalError('Model ID is required');
|
|
return;
|
|
}
|
|
if (!formData.name.includes('/')) {
|
|
setLocalError('Glama models use format: provider/model (e.g., openai/gpt-4o)');
|
|
return;
|
|
}
|
|
break;
|
|
case 'bedrock':
|
|
if (!formData.name.trim()) {
|
|
setLocalError('Model ID is required');
|
|
return;
|
|
}
|
|
break;
|
|
case 'ollama':
|
|
if (!formData.name.trim()) {
|
|
setLocalError('Model name is required');
|
|
return;
|
|
}
|
|
// Optional baseURL validation if provided
|
|
if (formData.baseURL.trim()) {
|
|
const urlValidation = validateBaseURL(formData.baseURL);
|
|
if (!urlValidation.isValid) {
|
|
setLocalError(urlValidation.error || 'Invalid Ollama URL');
|
|
return;
|
|
}
|
|
}
|
|
break;
|
|
case 'local':
|
|
if (!formData.name.trim()) {
|
|
setLocalError('Model ID is required');
|
|
return;
|
|
}
|
|
if (!formData.filePath?.trim()) {
|
|
setLocalError('GGUF file path is required');
|
|
return;
|
|
}
|
|
if (!formData.filePath.startsWith('/')) {
|
|
setLocalError('File path must be absolute (start with /)');
|
|
return;
|
|
}
|
|
if (!formData.filePath.endsWith('.gguf')) {
|
|
setLocalError('File must have .gguf extension');
|
|
return;
|
|
}
|
|
break;
|
|
case 'dexto':
|
|
if (!formData.name.trim()) {
|
|
setLocalError('Model ID is required');
|
|
return;
|
|
}
|
|
if (!formData.name.includes('/')) {
|
|
setLocalError('Format: provider/model (e.g., anthropic/claude-sonnet-4.5)');
|
|
return;
|
|
}
|
|
break;
|
|
}
|
|
setLocalError(null);
|
|
onSubmit();
|
|
};
|
|
|
|
const canSubmit = (() => {
|
|
switch (formData.provider) {
|
|
case 'openai-compatible':
|
|
case 'litellm':
|
|
return formData.name.trim() && formData.baseURL.trim();
|
|
case 'openrouter':
|
|
case 'glama':
|
|
return formData.name.trim() && formData.name.includes('/');
|
|
case 'bedrock':
|
|
return formData.name.trim().length > 0;
|
|
case 'ollama':
|
|
return formData.name.trim().length > 0;
|
|
case 'local':
|
|
return (
|
|
formData.name.trim().length > 0 &&
|
|
formData.filePath?.trim().startsWith('/') &&
|
|
formData.filePath?.trim().endsWith('.gguf')
|
|
);
|
|
case 'dexto':
|
|
return formData.name.trim() && formData.name.includes('/');
|
|
default:
|
|
return false;
|
|
}
|
|
})();
|
|
|
|
const displayError = localError || error;
|
|
const selectedProvider = providerOptions.find((p) => p.value === formData.provider);
|
|
|
|
const renderProviderFields = () => {
|
|
const props: ProviderFieldsProps = {
|
|
formData,
|
|
onChange,
|
|
setLocalError,
|
|
providerKeyData: providerKeyData
|
|
? {
|
|
hasKey: providerKeyData.hasKey,
|
|
envVar: providerKeyData.envVar,
|
|
}
|
|
: undefined,
|
|
};
|
|
|
|
switch (formData.provider) {
|
|
case 'bedrock':
|
|
return <BedrockFields {...props} />;
|
|
case 'openrouter':
|
|
return <OpenRouterFields {...props} />;
|
|
case 'glama':
|
|
return <GlamaFields {...props} />;
|
|
case 'litellm':
|
|
return <LiteLLMFields {...props} />;
|
|
case 'ollama':
|
|
return <OllamaFields {...props} />;
|
|
case 'local':
|
|
return <LocalFields {...props} />;
|
|
case 'dexto':
|
|
return <DextoFields {...props} />;
|
|
case 'openai-compatible':
|
|
default:
|
|
return <OpenAICompatibleFields {...props} />;
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="flex flex-col flex-1 min-h-0 max-h-full">
|
|
{/* Header */}
|
|
<div className="flex-shrink-0 flex items-center justify-between px-4 py-3 border-b border-border/30">
|
|
<h3 className="text-sm font-semibold text-foreground">
|
|
{isEditing ? 'Edit Custom Model' : 'Add Custom Model'}
|
|
</h3>
|
|
<button
|
|
onClick={onCancel}
|
|
className="p-1.5 rounded-md hover:bg-muted transition-colors"
|
|
>
|
|
<X className="h-4 w-4 text-muted-foreground" />
|
|
</button>
|
|
</div>
|
|
|
|
{/* Scrollable Form Content */}
|
|
<div className="flex-1 min-h-0 overflow-y-auto p-4 space-y-4">
|
|
{/* Error Display */}
|
|
{displayError && (
|
|
<div className="p-2 rounded-md bg-destructive/10 border border-destructive/30">
|
|
<p className="text-xs text-destructive">{displayError}</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Provider Dropdown */}
|
|
<div className="space-y-1.5">
|
|
<label className="text-xs font-medium text-muted-foreground">Provider</label>
|
|
<div className="relative">
|
|
<button
|
|
type="button"
|
|
onClick={() => setDropdownOpen(!dropdownOpen)}
|
|
className={cn(
|
|
'w-full flex items-center justify-between px-3 py-2 rounded-md',
|
|
'bg-muted/50 border border-border/50 text-sm',
|
|
'hover:bg-muted transition-colors',
|
|
dropdownOpen && 'ring-2 ring-ring'
|
|
)}
|
|
>
|
|
<span className="text-foreground">{selectedProvider?.label}</span>
|
|
<ChevronDown
|
|
className={cn(
|
|
'h-4 w-4 text-muted-foreground transition-transform',
|
|
dropdownOpen && 'rotate-180'
|
|
)}
|
|
/>
|
|
</button>
|
|
{dropdownOpen && (
|
|
<div className="absolute z-50 w-full mt-1 rounded-md border border-border bg-popover shadow-lg">
|
|
{providerOptions.map((option) => (
|
|
<button
|
|
key={option.value}
|
|
type="button"
|
|
onClick={() => {
|
|
onChange({
|
|
provider: option.value,
|
|
name: '',
|
|
baseURL: '',
|
|
displayName: '',
|
|
apiKey: '',
|
|
maxInputTokens: '',
|
|
maxOutputTokens: '',
|
|
filePath: '',
|
|
});
|
|
setDropdownOpen(false);
|
|
}}
|
|
className={cn(
|
|
'w-full px-3 py-2 text-left hover:bg-accent transition-colors',
|
|
'first:rounded-t-md last:rounded-b-md',
|
|
formData.provider === option.value && 'bg-accent/50'
|
|
)}
|
|
>
|
|
<div className="text-sm font-medium">{option.label}</div>
|
|
<div className="text-xs text-muted-foreground">
|
|
{option.description}
|
|
</div>
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Provider-specific fields */}
|
|
{renderProviderFields()}
|
|
</div>
|
|
|
|
{/* Footer */}
|
|
<div className="flex-shrink-0 px-4 py-3 border-t border-border/30 flex gap-2">
|
|
<Button variant="outline" onClick={onCancel} className="flex-1 h-9 text-sm">
|
|
Cancel
|
|
</Button>
|
|
<Button
|
|
onClick={handleSubmit}
|
|
disabled={isSubmitting || !canSubmit}
|
|
className="flex-1 h-9 text-sm"
|
|
>
|
|
{isSubmitting ? (
|
|
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
|
) : isEditing ? (
|
|
<Check className="h-4 w-4 mr-2" />
|
|
) : (
|
|
<Plus className="h-4 w-4 mr-2" />
|
|
)}
|
|
{isEditing ? 'Save & Select Model' : 'Add & Select Model'}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|