Files
ai-terminal-assistant/packages/ui/src/components/ProvidersPanel.tsx
T
kurihada 5f38753f6d refactor: 清理未使用的类型定义和接口字段
- 移除 Provider 相关的 apiKeyEnvVar 字段(未实现的功能)
- 清理 Server routes 中未使用的 Core 类型导入
- 清理 UI Message 接口中未使用的 metadata 字段
2025-12-30 10:41:38 +08:00

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>
);
}