5f38753f6d
- 移除 Provider 相关的 apiKeyEnvVar 字段(未实现的功能) - 清理 Server routes 中未使用的 Core 类型导入 - 清理 UI Message 接口中未使用的 metadata 字段
529 lines
20 KiB
TypeScript
529 lines
20 KiB
TypeScript
/**
|
|
* ProviderEditor Component
|
|
*
|
|
* Provider configuration editor: API Key, Base URL, enabled state, custom models
|
|
*/
|
|
|
|
import { useState, useEffect } from 'react';
|
|
import {
|
|
X,
|
|
Save,
|
|
Server,
|
|
Key,
|
|
Globe,
|
|
AlertCircle,
|
|
Plus,
|
|
Trash2,
|
|
Eye,
|
|
EyeOff,
|
|
Cpu,
|
|
} 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 {
|
|
getProvider,
|
|
updateProviderConfig,
|
|
addProviderModel,
|
|
deleteProviderModel,
|
|
type ProviderDetail,
|
|
type ProviderConfig,
|
|
type ModelInfo,
|
|
} from '../api/client.js';
|
|
|
|
interface ProviderEditorProps {
|
|
/** Provider ID to edit */
|
|
providerId: string;
|
|
onClose: () => void;
|
|
onSave: () => void;
|
|
/** Enable responsive layout */
|
|
responsive?: boolean;
|
|
}
|
|
|
|
export function ProviderEditor({
|
|
providerId,
|
|
onClose,
|
|
onSave,
|
|
responsive = false,
|
|
}: ProviderEditorProps) {
|
|
// Provider data
|
|
const [provider, setProvider] = useState<ProviderDetail | null>(null);
|
|
|
|
// Form state
|
|
const [apiKey, setApiKey] = useState('');
|
|
const [baseUrl, setBaseUrl] = useState('');
|
|
const [enabled, setEnabled] = useState(true);
|
|
|
|
// Custom model form
|
|
const [showAddModel, setShowAddModel] = useState(false);
|
|
const [newModelId, setNewModelId] = useState('');
|
|
const [newModelName, setNewModelName] = useState('');
|
|
const [newModelVision, setNewModelVision] = useState(false);
|
|
const [newModelTools, setNewModelTools] = useState(false);
|
|
|
|
// UI state
|
|
const [loading, setLoading] = useState(true);
|
|
const [saving, setSaving] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [showApiKey, setShowApiKey] = useState(false);
|
|
|
|
// Load provider data
|
|
useEffect(() => {
|
|
async function loadProvider() {
|
|
setLoading(true);
|
|
setError(null);
|
|
try {
|
|
const result = await getProvider(providerId);
|
|
if (result.success && result.data) {
|
|
setProvider(result.data);
|
|
// Initialize form with current config
|
|
const config = result.data.config;
|
|
// API key is not returned for security, but we show if it's configured via hasApiKey
|
|
setApiKey('');
|
|
setBaseUrl(config.baseUrl || result.data.baseUrl || '');
|
|
setEnabled(config.enabled !== false);
|
|
} else {
|
|
setError(result.error || 'Failed to load provider');
|
|
}
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'Failed to load provider');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}
|
|
|
|
loadProvider();
|
|
}, [providerId]);
|
|
|
|
// Save configuration
|
|
const handleSave = async () => {
|
|
setSaving(true);
|
|
setError(null);
|
|
|
|
try {
|
|
const config: Partial<ProviderConfig> = {
|
|
enabled,
|
|
};
|
|
|
|
// Only include changed values
|
|
if (apiKey.trim()) {
|
|
config.apiKey = apiKey;
|
|
}
|
|
if (baseUrl.trim() && baseUrl !== provider?.baseUrl) {
|
|
config.baseUrl = baseUrl;
|
|
}
|
|
|
|
const result = await updateProviderConfig(providerId, config);
|
|
if (result.success) {
|
|
toast.success(`Provider "${providerId}" updated`);
|
|
onSave();
|
|
} else {
|
|
setError(result.error || 'Failed to save');
|
|
}
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'Failed to save');
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
// Add custom model
|
|
const handleAddModel = async () => {
|
|
if (!newModelId.trim() || !newModelName.trim()) {
|
|
toast.error('Model ID and Name are required');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const model: ModelInfo = {
|
|
id: newModelId.trim(),
|
|
name: newModelName.trim(),
|
|
capabilities: {
|
|
vision: newModelVision,
|
|
functionCalling: newModelTools,
|
|
},
|
|
};
|
|
|
|
const result = await addProviderModel(providerId, model);
|
|
if (result.success) {
|
|
toast.success(`Model "${newModelId}" added`);
|
|
// Reset form
|
|
setNewModelId('');
|
|
setNewModelName('');
|
|
setNewModelVision(false);
|
|
setNewModelTools(false);
|
|
setShowAddModel(false);
|
|
// Reload provider
|
|
const updated = await getProvider(providerId);
|
|
if (updated.success && updated.data) {
|
|
setProvider(updated.data);
|
|
}
|
|
} 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 (modelId: string) => {
|
|
if (!confirm(`Delete model "${modelId}"?`)) return;
|
|
|
|
try {
|
|
const result = await deleteProviderModel(providerId, modelId);
|
|
if (result.success) {
|
|
toast.success(`Model "${modelId}" deleted`);
|
|
// Reload provider
|
|
const updated = await getProvider(providerId);
|
|
if (updated.success && updated.data) {
|
|
setProvider(updated.data);
|
|
}
|
|
} else {
|
|
toast.error(result.error || 'Failed to delete model');
|
|
}
|
|
} catch (err) {
|
|
toast.error(err instanceof Error ? err.message : 'Failed to delete model');
|
|
}
|
|
};
|
|
|
|
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-60',
|
|
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-lg md:mx-4 rounded-t-2xl md:rounded-lg'
|
|
: 'rounded-lg w-full max-w-lg 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" />
|
|
{loading ? 'Loading...' : provider?.name || providerId}
|
|
</h2>
|
|
{provider && (
|
|
<p className="text-xs text-fg-subtle">
|
|
{provider.builtin ? 'Built-in Provider' : 'Custom Provider'}
|
|
</p>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<Button
|
|
variant="default"
|
|
size="sm"
|
|
onClick={handleSave}
|
|
disabled={saving || loading}
|
|
className={cn(responsive && 'min-h-[44px]')}
|
|
>
|
|
<Save size={16} className="mr-1" />
|
|
{saving ? 'Saving...' : 'Save'}
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
onClick={onClose}
|
|
className={cn(responsive && 'min-w-[44px] min-h-[44px]')}
|
|
>
|
|
<X size={20} />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Content */}
|
|
<div className="flex-1 overflow-y-auto p-4 space-y-6">
|
|
{loading ? (
|
|
<div className="flex items-center justify-center py-12">
|
|
<div className="animate-spin rounded-full h-8 w-8 border-t-2 border-b-2 border-primary-500" />
|
|
</div>
|
|
) : (
|
|
<>
|
|
{/* Error */}
|
|
{error && (
|
|
<div className="flex items-start gap-2 p-3 bg-red-500/10 border border-red-500/30 rounded-lg text-red-400 text-sm">
|
|
<AlertCircle size={16} className="mt-0.5 flex-shrink-0" />
|
|
<span>{error}</span>
|
|
</div>
|
|
)}
|
|
|
|
{/* Enabled Toggle */}
|
|
<div className="flex items-center justify-between p-3 bg-surface-base/50 rounded-lg">
|
|
<div>
|
|
<span className="text-sm font-medium">Enabled</span>
|
|
<p className="text-xs text-fg-subtle">Use this provider for model selection</p>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={() => setEnabled(!enabled)}
|
|
className={cn(
|
|
'relative inline-flex h-6 w-11 items-center rounded-full transition-colors',
|
|
enabled ? 'bg-primary-500' : 'bg-surface-emphasis'
|
|
)}
|
|
>
|
|
<span
|
|
className={cn(
|
|
'inline-block h-4 w-4 transform rounded-full bg-white transition-transform',
|
|
enabled ? 'translate-x-6' : 'translate-x-1'
|
|
)}
|
|
/>
|
|
</button>
|
|
</div>
|
|
|
|
{/* API Key Section */}
|
|
<div className="space-y-3">
|
|
<h3 className="text-sm font-medium text-fg-secondary flex items-center gap-2">
|
|
<Key size={14} />
|
|
API Key Configuration
|
|
</h3>
|
|
|
|
{/* API Key Input */}
|
|
<div>
|
|
<label className="block text-xs text-fg-muted mb-1">API Key</label>
|
|
<div className="relative">
|
|
<Input
|
|
type={showApiKey ? 'text' : 'password'}
|
|
value={apiKey}
|
|
onChange={(e) => setApiKey(e.target.value)}
|
|
placeholder={provider?.config.hasApiKey ? '••••••••' : 'Enter API key'}
|
|
className="pr-10"
|
|
/>
|
|
<button
|
|
type="button"
|
|
onClick={() => setShowApiKey(!showApiKey)}
|
|
className="absolute right-3 top-1/2 -translate-y-1/2 text-fg-muted hover:text-fg-secondary"
|
|
>
|
|
{showApiKey ? <EyeOff size={16} /> : <Eye size={16} />}
|
|
</button>
|
|
</div>
|
|
{provider?.config.hasApiKey && (
|
|
<p className="text-xs text-green-400 mt-1">API key is configured</p>
|
|
)}
|
|
</div>
|
|
|
|
</div>
|
|
|
|
{/* Base URL Section */}
|
|
<div className="space-y-3">
|
|
<h3 className="text-sm font-medium text-fg-secondary flex items-center gap-2">
|
|
<Globe size={14} />
|
|
Endpoint Configuration
|
|
</h3>
|
|
|
|
<div>
|
|
<label className="block text-xs text-fg-muted mb-1">Base URL</label>
|
|
<Input
|
|
value={baseUrl}
|
|
onChange={(e) => setBaseUrl(e.target.value)}
|
|
placeholder={provider?.baseUrl || 'https://api.provider.com/v1'}
|
|
/>
|
|
{provider?.builtin && (
|
|
<p className="text-xs text-fg-subtle mt-1">
|
|
Leave empty to use default endpoint
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Custom Models Section */}
|
|
{provider?.allowCustomModels && (
|
|
<div className="space-y-3">
|
|
<div className="flex items-center justify-between">
|
|
<h3 className="text-sm font-medium text-fg-secondary flex items-center gap-2">
|
|
<Cpu size={14} />
|
|
Custom Models ({provider.config.customModels.length})
|
|
</h3>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => setShowAddModel(!showAddModel)}
|
|
className="text-xs text-primary-400"
|
|
>
|
|
<Plus size={14} className="mr-1" />
|
|
Add Model
|
|
</Button>
|
|
</div>
|
|
|
|
{/* Add Model Form */}
|
|
<AnimatePresence>
|
|
{showAddModel && (
|
|
<motion.div
|
|
initial={{ height: 0, opacity: 0 }}
|
|
animate={{ height: 'auto', opacity: 1 }}
|
|
exit={{ height: 0, opacity: 0 }}
|
|
className="overflow-hidden"
|
|
>
|
|
<div className="p-3 bg-surface-base/50 rounded-lg space-y-3 border border-line">
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div>
|
|
<label className="block text-xs text-fg-muted mb-1">Model ID</label>
|
|
<Input
|
|
value={newModelId}
|
|
onChange={(e) => setNewModelId(e.target.value)}
|
|
placeholder="gpt-4o-custom"
|
|
className="text-sm"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-xs text-fg-muted mb-1">Display Name</label>
|
|
<Input
|
|
value={newModelName}
|
|
onChange={(e) => setNewModelName(e.target.value)}
|
|
placeholder="GPT-4o Custom"
|
|
className="text-sm"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-4">
|
|
<label className="flex items-center gap-2 text-sm text-fg-muted cursor-pointer">
|
|
<input
|
|
type="checkbox"
|
|
checked={newModelVision}
|
|
onChange={(e) => setNewModelVision(e.target.checked)}
|
|
className="w-4 h-4 rounded border-line-muted bg-surface-base text-primary-500"
|
|
/>
|
|
Vision
|
|
</label>
|
|
<label className="flex items-center gap-2 text-sm text-fg-muted cursor-pointer">
|
|
<input
|
|
type="checkbox"
|
|
checked={newModelTools}
|
|
onChange={(e) => setNewModelTools(e.target.checked)}
|
|
className="w-4 h-4 rounded border-line-muted bg-surface-base text-primary-500"
|
|
/>
|
|
Function Calling
|
|
</label>
|
|
</div>
|
|
<div className="flex justify-end gap-2">
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => {
|
|
setShowAddModel(false);
|
|
setNewModelId('');
|
|
setNewModelName('');
|
|
}}
|
|
>
|
|
Cancel
|
|
</Button>
|
|
<Button size="sm" onClick={handleAddModel}>
|
|
Add
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
|
|
{/* Custom Models List */}
|
|
{provider.config.customModels.length > 0 ? (
|
|
<div className="space-y-1">
|
|
{provider.config.customModels.map((model) => (
|
|
<div
|
|
key={model.id}
|
|
className="flex items-center justify-between p-2 bg-surface-base/50 rounded-lg text-sm"
|
|
>
|
|
<div>
|
|
<span className="text-fg-secondary">{model.name}</span>
|
|
<span className="text-fg-subtle ml-2">({model.id})</span>
|
|
<div className="flex items-center gap-2 mt-0.5">
|
|
{model.capabilities?.vision && (
|
|
<span className="text-[10px] text-fg-subtle">Vision</span>
|
|
)}
|
|
{model.capabilities?.functionCalling && (
|
|
<span className="text-[10px] text-fg-subtle">Tools</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => handleDeleteModel(model.id)}
|
|
className="text-red-400 hover:text-red-300 p-1"
|
|
>
|
|
<Trash2 size={14} />
|
|
</Button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<p className="text-xs text-fg-subtle text-center py-2">
|
|
No custom models added yet
|
|
</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Built-in Models Info */}
|
|
{provider && (
|
|
<div className="text-xs text-fg-subtle p-3 bg-surface-base/30 rounded-lg">
|
|
<p className="font-medium mb-1">Built-in Models:</p>
|
|
<div className="flex flex-wrap gap-1">
|
|
{provider.models.slice(0, 5).map((m) => (
|
|
<span key={m.id} className="px-1.5 py-0.5 bg-surface-subtle rounded">
|
|
{m.name}
|
|
</span>
|
|
))}
|
|
{provider.models.length > 5 && (
|
|
<span className="px-1.5 py-0.5 text-fg-muted">
|
|
+{provider.models.length - 5} more
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
{/* Footer */}
|
|
<div
|
|
className={cn(
|
|
'border-t border-line flex justify-end gap-2',
|
|
responsive ? 'px-4 py-3 safe-area-pb' : 'px-6 py-3'
|
|
)}
|
|
>
|
|
<Button variant="ghost" onClick={onClose}>
|
|
Cancel
|
|
</Button>
|
|
<Button variant="default" onClick={handleSave} disabled={saving || loading}>
|
|
<Save size={16} className="mr-1" />
|
|
{saving ? 'Saving...' : 'Save'}
|
|
</Button>
|
|
</div>
|
|
</motion.div>
|
|
</motion.div>
|
|
</AnimatePresence>
|
|
);
|
|
}
|