5f38753f6d
- 移除 Provider 相关的 apiKeyEnvVar 字段(未实现的功能) - 清理 Server routes 中未使用的 Core 类型导入 - 清理 UI Message 接口中未使用的 metadata 字段
768 lines
27 KiB
TypeScript
768 lines
27 KiB
TypeScript
/**
|
|
* ProvidersPanel Component
|
|
*
|
|
* Provider management panel: list providers, test connections, manage custom providers/models
|
|
*/
|
|
|
|
import { useState, useEffect, useCallback } from 'react';
|
|
import {
|
|
X,
|
|
RefreshCw,
|
|
Server,
|
|
Plus,
|
|
ChevronDown,
|
|
ChevronRight,
|
|
Trash2,
|
|
Check,
|
|
AlertCircle,
|
|
Loader2,
|
|
Cpu,
|
|
Key,
|
|
Globe,
|
|
Zap,
|
|
Settings,
|
|
} from 'lucide-react';
|
|
import { motion, AnimatePresence } from 'framer-motion';
|
|
import { toast } from 'sonner';
|
|
import { cn } from '../utils/cn';
|
|
import { modalOverlay, modalContent, smoothTransition } from '../utils/animations';
|
|
import { Button } from '../primitives/Button';
|
|
import { Input } from '../primitives/Input';
|
|
import { Skeleton } from './Skeleton';
|
|
import {
|
|
listProviders,
|
|
getProvider,
|
|
testProviderConnection,
|
|
registerProvider,
|
|
deleteProvider,
|
|
addProviderModel,
|
|
deleteProviderModel,
|
|
type ProviderListItem,
|
|
type ProviderDetail,
|
|
type ModelInfo,
|
|
type CustomProviderDefinition,
|
|
} from '../api/client.js';
|
|
import { ProviderEditor } from './ProviderEditor.js';
|
|
|
|
interface ProvidersPanelProps {
|
|
onClose: () => void;
|
|
/** Enable responsive layout */
|
|
responsive?: boolean;
|
|
}
|
|
|
|
export function ProvidersPanel({ onClose, responsive = false }: ProvidersPanelProps) {
|
|
// Data state
|
|
const [providers, setProviders] = useState<ProviderListItem[]>([]);
|
|
const [expandedProviders, setExpandedProviders] = useState<Set<string>>(new Set());
|
|
const [providerDetails, setProviderDetails] = useState<Record<string, ProviderDetail>>({});
|
|
|
|
// UI state
|
|
const [loading, setLoading] = useState(true);
|
|
const [refreshing, setRefreshing] = useState(false);
|
|
const [testingProvider, setTestingProvider] = useState<string | null>(null);
|
|
const [testResults, setTestResults] = useState<Record<string, { success: boolean; error?: string }>>({});
|
|
|
|
// Editor state
|
|
const [showAddProvider, setShowAddProvider] = useState(false);
|
|
const [showAddModel, setShowAddModel] = useState<string | null>(null);
|
|
const [editingProviderId, setEditingProviderId] = useState<string | null>(null);
|
|
const [newProvider, setNewProvider] = useState<Partial<CustomProviderDefinition>>({});
|
|
const [newModel, setNewModel] = useState<Partial<ModelInfo>>({});
|
|
|
|
// Load provider list
|
|
const loadProviders = useCallback(async (showToast = false) => {
|
|
try {
|
|
const result = await listProviders();
|
|
if (result.success) {
|
|
setProviders(result.data);
|
|
if (showToast) {
|
|
toast.success('Providers refreshed');
|
|
}
|
|
} else {
|
|
toast.error(result.error || 'Failed to load providers');
|
|
}
|
|
} catch (err) {
|
|
toast.error(err instanceof Error ? err.message : 'Failed to load providers');
|
|
}
|
|
}, []);
|
|
|
|
// Initial load
|
|
useEffect(() => {
|
|
setLoading(true);
|
|
loadProviders().finally(() => setLoading(false));
|
|
}, [loadProviders]);
|
|
|
|
// Refresh
|
|
const handleRefresh = async () => {
|
|
setRefreshing(true);
|
|
await loadProviders(true);
|
|
setRefreshing(false);
|
|
};
|
|
|
|
// Load provider detail
|
|
const loadProviderDetail = async (id: string) => {
|
|
if (providerDetails[id]) return providerDetails[id];
|
|
|
|
try {
|
|
const result = await getProvider(id);
|
|
if (result.success && result.data) {
|
|
setProviderDetails((prev) => ({ ...prev, [id]: result.data! }));
|
|
return result.data;
|
|
}
|
|
} catch (err) {
|
|
toast.error(`Failed to load provider details: ${err instanceof Error ? err.message : 'Unknown error'}`);
|
|
}
|
|
return null;
|
|
};
|
|
|
|
// Toggle expanded
|
|
const toggleExpanded = async (id: string) => {
|
|
const newExpanded = new Set(expandedProviders);
|
|
if (newExpanded.has(id)) {
|
|
newExpanded.delete(id);
|
|
} else {
|
|
newExpanded.add(id);
|
|
await loadProviderDetail(id);
|
|
}
|
|
setExpandedProviders(newExpanded);
|
|
};
|
|
|
|
// Test connection
|
|
const handleTestConnection = async (id: string) => {
|
|
setTestingProvider(id);
|
|
try {
|
|
const result = await testProviderConnection(id);
|
|
if (result.success) {
|
|
setTestResults((prev) => ({ ...prev, [id]: result.data }));
|
|
if (result.data.success) {
|
|
toast.success(`${id} connection successful`);
|
|
} else {
|
|
toast.error(result.data.error || `${id} connection failed`);
|
|
}
|
|
} else {
|
|
setTestResults((prev) => ({ ...prev, [id]: { success: false, error: result.error } }));
|
|
toast.error(result.error || 'Test failed');
|
|
}
|
|
} catch (err) {
|
|
const error = err instanceof Error ? err.message : 'Test failed';
|
|
setTestResults((prev) => ({ ...prev, [id]: { success: false, error } }));
|
|
toast.error(error);
|
|
} finally {
|
|
setTestingProvider(null);
|
|
}
|
|
};
|
|
|
|
// Add custom provider
|
|
const handleAddProvider = async () => {
|
|
if (!newProvider.id || !newProvider.name || !newProvider.baseUrl) {
|
|
toast.error('ID, Name, and Base URL are required');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const result = await registerProvider(newProvider as CustomProviderDefinition);
|
|
if (result.success) {
|
|
toast.success(`Provider ${newProvider.id} added`);
|
|
setShowAddProvider(false);
|
|
setNewProvider({});
|
|
loadProviders();
|
|
} else {
|
|
toast.error(result.error || 'Failed to add provider');
|
|
}
|
|
} catch (err) {
|
|
toast.error(err instanceof Error ? err.message : 'Failed to add provider');
|
|
}
|
|
};
|
|
|
|
// Delete custom provider
|
|
const handleDeleteProvider = async (id: string) => {
|
|
if (!confirm(`Are you sure you want to delete provider "${id}"?`)) return;
|
|
|
|
try {
|
|
const result = await deleteProvider(id);
|
|
if (result.success) {
|
|
toast.success(`Provider ${id} deleted`);
|
|
loadProviders();
|
|
setProviderDetails((prev) => {
|
|
const newDetails = { ...prev };
|
|
delete newDetails[id];
|
|
return newDetails;
|
|
});
|
|
} else {
|
|
toast.error(result.error || 'Delete failed');
|
|
}
|
|
} catch (err) {
|
|
toast.error(err instanceof Error ? err.message : 'Delete failed');
|
|
}
|
|
};
|
|
|
|
// Add custom model
|
|
const handleAddModel = async (providerId: string) => {
|
|
if (!newModel.id || !newModel.name) {
|
|
toast.error('Model ID and Name are required');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const result = await addProviderModel(providerId, newModel as ModelInfo);
|
|
if (result.success) {
|
|
toast.success(`Model ${newModel.id} added to ${providerId}`);
|
|
setShowAddModel(null);
|
|
setNewModel({});
|
|
// Reload provider detail
|
|
setProviderDetails((prev) => {
|
|
const newDetails = { ...prev };
|
|
delete newDetails[providerId];
|
|
return newDetails;
|
|
});
|
|
await loadProviderDetail(providerId);
|
|
} else {
|
|
toast.error(result.error || 'Failed to add model');
|
|
}
|
|
} catch (err) {
|
|
toast.error(err instanceof Error ? err.message : 'Failed to add model');
|
|
}
|
|
};
|
|
|
|
// Delete custom model
|
|
const handleDeleteModel = async (providerId: string, modelId: string) => {
|
|
if (!confirm(`Are you sure you want to delete model "${modelId}"?`)) return;
|
|
|
|
try {
|
|
const result = await deleteProviderModel(providerId, modelId);
|
|
if (result.success) {
|
|
toast.success(`Model ${modelId} deleted`);
|
|
// Reload provider detail
|
|
setProviderDetails((prev) => {
|
|
const newDetails = { ...prev };
|
|
delete newDetails[providerId];
|
|
return newDetails;
|
|
});
|
|
await loadProviderDetail(providerId);
|
|
} else {
|
|
toast.error(result.error || 'Delete failed');
|
|
}
|
|
} catch (err) {
|
|
toast.error(err instanceof Error ? err.message : 'Delete failed');
|
|
}
|
|
};
|
|
|
|
// Statistics
|
|
const builtinProviders = providers.filter((p) => p.builtin);
|
|
const customProviders = providers.filter((p) => !p.builtin);
|
|
|
|
// Loading skeleton
|
|
const LoadingSkeleton = () => (
|
|
<div className="space-y-3 p-4">
|
|
{[1, 2, 3].map((i) => (
|
|
<div key={i} className="flex items-center gap-3 p-3 bg-surface-base/50 rounded-lg">
|
|
<Skeleton className="h-4 w-4" />
|
|
<div className="flex-1 space-y-2">
|
|
<Skeleton className="h-4 w-32" />
|
|
<Skeleton className="h-3 w-48" />
|
|
</div>
|
|
<Skeleton className="h-6 w-16 rounded-full" />
|
|
</div>
|
|
))}
|
|
</div>
|
|
);
|
|
|
|
// Provider item component
|
|
const ProviderItem = ({ provider }: { provider: ProviderListItem }) => {
|
|
const isExpanded = expandedProviders.has(provider.id);
|
|
const isTesting = testingProvider === provider.id;
|
|
const testResult = testResults[provider.id];
|
|
const detail = providerDetails[provider.id];
|
|
|
|
return (
|
|
<motion.div
|
|
layout
|
|
initial={{ opacity: 0, y: 10 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
className="bg-surface-base/50 rounded-lg overflow-hidden"
|
|
>
|
|
{/* Provider Header */}
|
|
<div
|
|
className={cn(
|
|
'flex items-center gap-3 p-3',
|
|
'hover:bg-surface-base/80 transition-colors cursor-pointer'
|
|
)}
|
|
onClick={() => toggleExpanded(provider.id)}
|
|
>
|
|
{/* Expand Icon */}
|
|
<button className="text-fg-subtle hover:text-fg-secondary">
|
|
{isExpanded ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
|
|
</button>
|
|
|
|
{/* Icon */}
|
|
{provider.builtin ? (
|
|
<Server size={16} className="text-blue-400" />
|
|
) : (
|
|
<Globe size={16} className="text-green-400" />
|
|
)}
|
|
|
|
{/* Info */}
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center gap-2">
|
|
<span className="font-medium text-fg-secondary">{provider.name}</span>
|
|
<span
|
|
className={cn(
|
|
'text-xs px-2 py-0.5 rounded-full',
|
|
provider.builtin ? 'bg-blue-500/20 text-blue-400' : 'bg-green-500/20 text-green-400'
|
|
)}
|
|
>
|
|
{provider.builtin ? 'Built-in' : 'Custom'}
|
|
</span>
|
|
</div>
|
|
<div className="text-xs text-fg-subtle flex items-center gap-2">
|
|
<span>{provider.modelCount} models</span>
|
|
{provider.hasApiKey ? (
|
|
<span className="text-green-400 flex items-center gap-1">
|
|
<Key size={10} />
|
|
Configured
|
|
</span>
|
|
) : (
|
|
<span className="text-yellow-400 flex items-center gap-1">
|
|
<Key size={10} />
|
|
No API Key
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Status & Actions */}
|
|
<div className="flex items-center gap-2" onClick={(e) => e.stopPropagation()}>
|
|
{/* Test Result */}
|
|
{testResult && (
|
|
testResult.success ? (
|
|
<Check size={16} className="text-green-400" />
|
|
) : (
|
|
<span title={testResult.error}>
|
|
<AlertCircle size={16} className="text-red-400" />
|
|
</span>
|
|
)
|
|
)}
|
|
|
|
{/* Test Button */}
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => handleTestConnection(provider.id)}
|
|
disabled={isTesting}
|
|
className="text-fg-muted hover:text-fg-secondary"
|
|
title="Test Connection"
|
|
>
|
|
{isTesting ? (
|
|
<Loader2 size={14} className="animate-spin" />
|
|
) : (
|
|
<Zap size={14} />
|
|
)}
|
|
</Button>
|
|
|
|
{/* Edit Button */}
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => setEditingProviderId(provider.id)}
|
|
className="text-fg-muted hover:text-fg-secondary"
|
|
title="Configure"
|
|
>
|
|
<Settings size={14} />
|
|
</Button>
|
|
|
|
{/* Delete (only for custom) */}
|
|
{!provider.builtin && (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => handleDeleteProvider(provider.id)}
|
|
className="text-red-400 hover:text-red-300"
|
|
title="Delete"
|
|
>
|
|
<Trash2 size={14} />
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Expanded Content */}
|
|
<AnimatePresence>
|
|
{isExpanded && (
|
|
<motion.div
|
|
initial={{ height: 0, opacity: 0 }}
|
|
animate={{ height: 'auto', opacity: 1 }}
|
|
exit={{ height: 0, opacity: 0 }}
|
|
transition={{ duration: 0.2 }}
|
|
className="overflow-hidden"
|
|
>
|
|
<div className="px-4 pb-3 pt-1 border-t border-line/50 space-y-3">
|
|
{detail ? (
|
|
<>
|
|
{/* Base URL */}
|
|
{detail.baseUrl && (
|
|
<div className="text-xs">
|
|
<span className="text-fg-muted">Base URL:</span>{' '}
|
|
<code className="text-fg-secondary bg-surface-subtle px-1 rounded">{detail.baseUrl}</code>
|
|
</div>
|
|
)}
|
|
|
|
{/* Models */}
|
|
<div className="space-y-2">
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-xs text-fg-muted flex items-center gap-1">
|
|
<Cpu size={12} />
|
|
Models ({detail.models.length + detail.config.customModels.length})
|
|
</span>
|
|
{detail.allowCustomModels && (
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => setShowAddModel(provider.id)}
|
|
className="text-xs text-primary-400"
|
|
>
|
|
<Plus size={12} className="mr-1" />
|
|
Add Model
|
|
</Button>
|
|
)}
|
|
</div>
|
|
|
|
{/* Model List */}
|
|
<div className="space-y-1 max-h-48 overflow-y-auto">
|
|
{/* Built-in models */}
|
|
{detail.models.map((model) => (
|
|
<div
|
|
key={model.id}
|
|
className="flex items-center justify-between text-xs p-2 bg-surface-subtle/50 rounded"
|
|
>
|
|
<div>
|
|
<span className="text-fg-secondary">{model.name}</span>
|
|
<span className="text-fg-subtle ml-2">({model.id})</span>
|
|
</div>
|
|
<div className="flex items-center gap-2 text-fg-subtle">
|
|
{model.capabilities?.vision && (
|
|
<span title="Vision" className="text-[10px]">Vision</span>
|
|
)}
|
|
{model.capabilities?.functionCalling && (
|
|
<span title="Function Calling" className="text-[10px]">Tools</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
))}
|
|
|
|
{/* Custom models */}
|
|
{detail.config.customModels.map((model) => (
|
|
<div
|
|
key={model.id}
|
|
className="flex items-center justify-between text-xs p-2 bg-surface-subtle/50 rounded border border-green-500/20"
|
|
>
|
|
<div>
|
|
<span className="text-fg-secondary">{model.name}</span>
|
|
<span className="text-fg-subtle ml-2">({model.id})</span>
|
|
<span className="text-green-400 ml-2 text-[10px]">custom</span>
|
|
</div>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => handleDeleteModel(provider.id, model.id)}
|
|
className="text-red-400 hover:text-red-300 p-1"
|
|
>
|
|
<Trash2 size={12} />
|
|
</Button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</>
|
|
) : (
|
|
<div className="flex items-center justify-center py-4">
|
|
<Loader2 className="animate-spin h-5 w-5 text-primary-500" />
|
|
</div>
|
|
)}
|
|
</div>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
</motion.div>
|
|
);
|
|
};
|
|
|
|
return (
|
|
<AnimatePresence>
|
|
<motion.div
|
|
variants={modalOverlay}
|
|
initial="initial"
|
|
animate="animate"
|
|
exit="exit"
|
|
transition={{ duration: 0.2 }}
|
|
className={cn(
|
|
'fixed inset-0 bg-black/50 flex z-50',
|
|
responsive ? 'items-end md:items-center justify-center' : 'items-center justify-center'
|
|
)}
|
|
onClick={onClose}
|
|
>
|
|
<motion.div
|
|
variants={modalContent}
|
|
initial="initial"
|
|
animate="animate"
|
|
exit="exit"
|
|
transition={smoothTransition}
|
|
onClick={(e) => e.stopPropagation()}
|
|
className={cn(
|
|
'bg-surface-subtle max-h-[90vh] overflow-hidden flex flex-col',
|
|
responsive
|
|
? 'w-full md:w-full md:max-w-2xl md:mx-4 rounded-t-2xl md:rounded-lg'
|
|
: 'rounded-lg w-full max-w-2xl mx-4'
|
|
)}
|
|
>
|
|
{/* Header */}
|
|
<div
|
|
className={cn(
|
|
'flex items-center justify-between border-b border-line',
|
|
responsive ? 'px-4 md:px-6 py-4' : 'px-6 py-4'
|
|
)}
|
|
>
|
|
{responsive && (
|
|
<div className="absolute top-2 left-1/2 -translate-x-1/2 w-10 h-1 bg-surface-emphasis rounded-full md:hidden" />
|
|
)}
|
|
<div className={cn(responsive && 'mt-2 md:mt-0')}>
|
|
<h2 className="text-lg font-semibold flex items-center gap-2">
|
|
<Server size={20} className="text-primary-400" />
|
|
Model Providers
|
|
</h2>
|
|
<p className="text-xs text-fg-subtle">
|
|
{providers.length} providers ({builtinProviders.length} built-in, {customProviders.length} custom)
|
|
</p>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={() => setShowAddProvider(true)}
|
|
title="Add Provider"
|
|
className={cn(responsive && 'min-w-[44px] min-h-[44px]')}
|
|
>
|
|
<Plus size={18} />
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={handleRefresh}
|
|
disabled={refreshing}
|
|
title="Refresh"
|
|
className={cn(responsive && 'min-w-[44px] min-h-[44px]')}
|
|
>
|
|
<RefreshCw size={18} className={cn(refreshing && 'animate-spin')} />
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={onClose}
|
|
className={cn(responsive && 'min-w-[44px] min-h-[44px]')}
|
|
>
|
|
<X size={20} />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Provider List */}
|
|
<div className="flex-1 overflow-y-auto">
|
|
{loading ? (
|
|
<LoadingSkeleton />
|
|
) : providers.length === 0 ? (
|
|
<div className="flex flex-col items-center justify-center py-12 text-fg-subtle">
|
|
<Server size={48} className="mb-4 opacity-50" />
|
|
<p className="text-center">No providers available</p>
|
|
</div>
|
|
) : (
|
|
<motion.div
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
className={cn('space-y-4', responsive ? 'p-4' : 'p-4')}
|
|
>
|
|
{/* Built-in Providers */}
|
|
{builtinProviders.length > 0 && (
|
|
<div>
|
|
<h3 className="text-xs font-medium text-fg-muted uppercase tracking-wide mb-2 flex items-center gap-2">
|
|
<Server size={12} />
|
|
Built-in Providers
|
|
</h3>
|
|
<div className="space-y-2">
|
|
{builtinProviders.map((provider) => (
|
|
<ProviderItem key={provider.id} provider={provider} />
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Custom Providers */}
|
|
<div>
|
|
<h3 className="text-xs font-medium text-fg-muted uppercase tracking-wide mb-2 flex items-center gap-2">
|
|
<Globe size={12} />
|
|
Custom Providers
|
|
</h3>
|
|
{customProviders.length > 0 ? (
|
|
<div className="space-y-2">
|
|
{customProviders.map((provider) => (
|
|
<ProviderItem key={provider.id} provider={provider} />
|
|
))}
|
|
</div>
|
|
) : (
|
|
<div className="text-center py-6 text-fg-subtle text-sm">
|
|
<p>No custom providers yet</p>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="mt-2"
|
|
onClick={() => setShowAddProvider(true)}
|
|
>
|
|
<Plus size={14} className="mr-1" />
|
|
Add one
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</motion.div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Footer */}
|
|
<div
|
|
className={cn(
|
|
'border-t border-line text-xs text-fg-subtle text-center',
|
|
responsive ? 'px-4 py-3 safe-area-pb' : 'px-6 py-3'
|
|
)}
|
|
>
|
|
Config stored in{' '}
|
|
<code className="font-mono bg-surface-base px-1 rounded">~/.ai-terminal-assistant/providers.json</code>
|
|
</div>
|
|
</motion.div>
|
|
|
|
{/* Add Provider Dialog */}
|
|
<AnimatePresence>
|
|
{showAddProvider && (
|
|
<motion.div
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
exit={{ opacity: 0 }}
|
|
className="fixed inset-0 bg-black/70 flex items-center justify-center z-60"
|
|
onClick={() => setShowAddProvider(false)}
|
|
>
|
|
<motion.div
|
|
initial={{ scale: 0.95, opacity: 0 }}
|
|
animate={{ scale: 1, opacity: 1 }}
|
|
exit={{ scale: 0.95, opacity: 0 }}
|
|
onClick={(e) => e.stopPropagation()}
|
|
className="bg-surface-subtle rounded-lg p-6 w-full max-w-md mx-4 space-y-4"
|
|
>
|
|
<h3 className="text-lg font-semibold">Add Custom Provider</h3>
|
|
<div className="space-y-3">
|
|
<div>
|
|
<label className="text-xs text-fg-muted">ID (e.g., ollama)</label>
|
|
<Input
|
|
value={newProvider.id || ''}
|
|
onChange={(e) => setNewProvider((p) => ({ ...p, id: e.target.value }))}
|
|
placeholder="provider-id"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="text-xs text-fg-muted">Name</label>
|
|
<Input
|
|
value={newProvider.name || ''}
|
|
onChange={(e) => setNewProvider((p) => ({ ...p, name: e.target.value }))}
|
|
placeholder="My Provider"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="text-xs text-fg-muted">Base URL (OpenAI compatible)</label>
|
|
<Input
|
|
value={newProvider.baseUrl || ''}
|
|
onChange={(e) => setNewProvider((p) => ({ ...p, baseUrl: e.target.value }))}
|
|
placeholder="http://localhost:11434/v1"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className="flex justify-end gap-2 pt-2">
|
|
<Button variant="ghost" onClick={() => setShowAddProvider(false)}>
|
|
Cancel
|
|
</Button>
|
|
<Button onClick={handleAddProvider}>
|
|
Add Provider
|
|
</Button>
|
|
</div>
|
|
</motion.div>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
|
|
{/* Add Model Dialog */}
|
|
<AnimatePresence>
|
|
{showAddModel && (
|
|
<motion.div
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
exit={{ opacity: 0 }}
|
|
className="fixed inset-0 bg-black/70 flex items-center justify-center z-60"
|
|
onClick={() => setShowAddModel(null)}
|
|
>
|
|
<motion.div
|
|
initial={{ scale: 0.95, opacity: 0 }}
|
|
animate={{ scale: 1, opacity: 1 }}
|
|
exit={{ scale: 0.95, opacity: 0 }}
|
|
onClick={(e) => e.stopPropagation()}
|
|
className="bg-surface-subtle rounded-lg p-6 w-full max-w-md mx-4 space-y-4"
|
|
>
|
|
<h3 className="text-lg font-semibold">Add Custom Model</h3>
|
|
<div className="space-y-3">
|
|
<div>
|
|
<label className="text-xs text-fg-muted">Model ID</label>
|
|
<Input
|
|
value={newModel.id || ''}
|
|
onChange={(e) => setNewModel((m) => ({ ...m, id: e.target.value }))}
|
|
placeholder="llama3.2"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="text-xs text-fg-muted">Display Name</label>
|
|
<Input
|
|
value={newModel.name || ''}
|
|
onChange={(e) => setNewModel((m) => ({ ...m, name: e.target.value }))}
|
|
placeholder="Llama 3.2"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className="flex justify-end gap-2 pt-2">
|
|
<Button variant="ghost" onClick={() => setShowAddModel(null)}>
|
|
Cancel
|
|
</Button>
|
|
<Button onClick={() => showAddModel && handleAddModel(showAddModel)}>
|
|
Add Model
|
|
</Button>
|
|
</div>
|
|
</motion.div>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
|
|
{/* Provider Editor */}
|
|
{editingProviderId && (
|
|
<ProviderEditor
|
|
providerId={editingProviderId}
|
|
onClose={() => setEditingProviderId(null)}
|
|
onSave={() => {
|
|
setEditingProviderId(null);
|
|
// Clear cached details and reload
|
|
setProviderDetails((prev) => {
|
|
const newDetails = { ...prev };
|
|
delete newDetails[editingProviderId];
|
|
return newDetails;
|
|
});
|
|
loadProviders();
|
|
}}
|
|
responsive={responsive}
|
|
/>
|
|
)}
|
|
</motion.div>
|
|
</AnimatePresence>
|
|
);
|
|
}
|