feat(provider): 添加独立的 Provider 模块管理模型提供商
实现可扩展的 Provider 系统,支持动态注册自定义提供商: Core 模块 (packages/core/src/provider/): - types.ts: Provider 相关类型定义 - builtin/: 内置提供商 (Anthropic, OpenAI, DeepSeek) - registry.ts: ProviderRegistry 单例类 - config.ts: 配置持久化 (~/.ai-terminal-assistant/providers.json) - utils.ts: 连接测试等工具函数 Server API (packages/server/src/routes/providers.ts): - GET/POST/PUT/DELETE /providers 提供商管理 - POST /providers/:id/test 连接测试 - 自定义模型管理接口 Frontend (packages/ui/): - ProvidersPanel 组件用于管理提供商 - API client 函数和类型定义 主要功能: - 支持动态注册 OpenAI 兼容服务 (Ollama, vLLM 等) - 每个提供商独立的 API Key 配置 - 预设模型列表 + 自定义模型输入 - 连接测试验证
This commit is contained in:
@@ -0,0 +1,750 @@
|
||||
/**
|
||||
* 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,
|
||||
} 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';
|
||||
|
||||
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 [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-gray-900/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-gray-900/50 rounded-lg overflow-hidden"
|
||||
>
|
||||
{/* Provider Header */}
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-3 p-3',
|
||||
'hover:bg-gray-900/80 transition-colors cursor-pointer'
|
||||
)}
|
||||
onClick={() => toggleExpanded(provider.id)}
|
||||
>
|
||||
{/* Expand Icon */}
|
||||
<button className="text-gray-500 hover:text-gray-300">
|
||||
{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-gray-200">{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-gray-500 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-gray-400 hover:text-gray-300"
|
||||
title="Test Connection"
|
||||
>
|
||||
{isTesting ? (
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
) : (
|
||||
<Zap 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-gray-700/50 space-y-3">
|
||||
{detail ? (
|
||||
<>
|
||||
{/* Base URL */}
|
||||
{detail.baseUrl && (
|
||||
<div className="text-xs">
|
||||
<span className="text-gray-400">Base URL:</span>{' '}
|
||||
<code className="text-gray-300 bg-gray-800 px-1 rounded">{detail.baseUrl}</code>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* API Key Env Var */}
|
||||
{detail.apiKeyEnvVar && (
|
||||
<div className="text-xs">
|
||||
<span className="text-gray-400">API Key Env:</span>{' '}
|
||||
<code className="text-gray-300 bg-gray-800 px-1 rounded">{detail.apiKeyEnvVar}</code>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Models */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs text-gray-400 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-gray-800/50 rounded"
|
||||
>
|
||||
<div>
|
||||
<span className="text-gray-200">{model.name}</span>
|
||||
<span className="text-gray-500 ml-2">({model.id})</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-gray-500">
|
||||
{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-gray-800/50 rounded border border-green-500/20"
|
||||
>
|
||||
<div>
|
||||
<span className="text-gray-200">{model.name}</span>
|
||||
<span className="text-gray-500 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-gray-800 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-gray-700',
|
||||
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-gray-600 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-gray-500">
|
||||
{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-gray-500">
|
||||
<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-gray-400 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-gray-400 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-gray-500 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-gray-700 text-xs text-gray-500 text-center',
|
||||
responsive ? 'px-4 py-3 safe-area-pb' : 'px-6 py-3'
|
||||
)}
|
||||
>
|
||||
Config stored in{' '}
|
||||
<code className="font-mono bg-gray-900 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-gray-800 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-gray-400">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-gray-400">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-gray-400">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>
|
||||
<label className="text-xs text-gray-400">API Key Env Var (optional)</label>
|
||||
<Input
|
||||
value={newProvider.apiKeyEnvVar || ''}
|
||||
onChange={(e) => setNewProvider((p) => ({ ...p, apiKeyEnvVar: e.target.value }))}
|
||||
placeholder="OLLAMA_API_KEY"
|
||||
/>
|
||||
</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-gray-800 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-gray-400">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-gray-400">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>
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user