241 lines
10 KiB
TypeScript
241 lines
10 KiB
TypeScript
|
|
import React, { useState } from 'react';
|
|
import { Plus, Trash2, Bot, Key, Globe, ChevronDown, ChevronUp } from 'lucide-react';
|
|
import { AppConfig, ApiProvider, CustomModel } from '../../types';
|
|
import { MODELS } from '../../config';
|
|
|
|
interface ModelSectionProps {
|
|
config: AppConfig;
|
|
setConfig: (c: AppConfig) => void;
|
|
}
|
|
|
|
const ModelSection = ({ config, setConfig }: ModelSectionProps) => {
|
|
const [newModelName, setNewModelName] = useState('');
|
|
const [newModelProvider, setNewModelProvider] = useState<ApiProvider>('custom');
|
|
const [newModelApiKey, setNewModelApiKey] = useState('');
|
|
const [newModelBaseUrl, setNewModelBaseUrl] = useState('');
|
|
const [expandedModelId, setExpandedModelId] = useState<string | null>(null);
|
|
|
|
const customModels = config.customModels || [];
|
|
|
|
const handleAddModel = () => {
|
|
if (!newModelName.trim()) return;
|
|
|
|
const trimmedName = newModelName.trim();
|
|
|
|
// Check if model name already exists in preset models
|
|
const existingPresetModel = MODELS.find(m => m.value === trimmedName);
|
|
if (existingPresetModel) {
|
|
alert(`Model name "${trimmedName}" already exists as a preset model. Please choose a different name.`);
|
|
return;
|
|
}
|
|
|
|
// Check if model name already exists in custom models
|
|
const existingCustomModel = customModels.find(m => m.name === trimmedName);
|
|
if (existingCustomModel) {
|
|
alert(`Model name "${trimmedName}" already exists. Please choose a different name.`);
|
|
return;
|
|
}
|
|
|
|
const newModel: CustomModel = {
|
|
id: `custom-${Date.now()}`,
|
|
name: trimmedName,
|
|
provider: newModelProvider,
|
|
apiKey: newModelApiKey || undefined,
|
|
baseUrl: newModelBaseUrl || undefined
|
|
};
|
|
|
|
setConfig({
|
|
...config,
|
|
customModels: [...customModels, newModel]
|
|
});
|
|
|
|
setNewModelName('');
|
|
setNewModelApiKey('');
|
|
setNewModelBaseUrl('');
|
|
};
|
|
|
|
const handleDeleteModel = (modelId: string) => {
|
|
setConfig({
|
|
...config,
|
|
customModels: customModels.filter(m => m.id !== modelId)
|
|
});
|
|
if (expandedModelId === modelId) {
|
|
setExpandedModelId(null);
|
|
}
|
|
};
|
|
|
|
const handleUpdateModel = (modelId: string, updates: Partial<CustomModel>) => {
|
|
setConfig({
|
|
...config,
|
|
customModels: customModels.map(m =>
|
|
m.id === modelId ? { ...m, ...updates } : m
|
|
)
|
|
});
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-4 pt-1">
|
|
<h3 className="text-xs font-bold text-slate-400 uppercase tracking-wider">Custom Models</h3>
|
|
|
|
<div className="p-4 bg-slate-50 rounded-lg border border-slate-100 space-y-4">
|
|
<div className="space-y-3">
|
|
<div className="space-y-2">
|
|
<label className="text-sm font-medium text-slate-700 flex items-center gap-2">
|
|
<Bot size={14} className="text-slate-400" />
|
|
Model Name
|
|
</label>
|
|
<input
|
|
type="text"
|
|
placeholder="e.g., llama-3-8b-instruct, qwen-72b-chat"
|
|
value={newModelName}
|
|
onChange={(e) => setNewModelName(e.target.value)}
|
|
onKeyDown={(e) => e.key === 'Enter' && handleAddModel()}
|
|
className="w-full bg-white border border-slate-200 text-slate-800 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block p-2.5 outline-none placeholder:text-slate-400"
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<label className="text-sm font-medium text-slate-700">Provider</label>
|
|
<select
|
|
value={newModelProvider}
|
|
onChange={(e) => setNewModelProvider(e.target.value as ApiProvider)}
|
|
className="w-full bg-white border border-slate-200 text-slate-800 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block p-2.5 outline-none"
|
|
>
|
|
<option value="custom">Custom (OpenAI-compatible)</option>
|
|
<option value="openai">OpenAI</option>
|
|
<option value="deepseek">DeepSeek</option>
|
|
<option value="anthropic">Anthropic</option>
|
|
<option value="xai">xAI</option>
|
|
<option value="mistral">Mistral</option>
|
|
<option value="google">Google</option>
|
|
</select>
|
|
</div>
|
|
|
|
{(newModelProvider === 'custom' || newModelProvider === 'openai' || newModelProvider === 'anthropic' || newModelProvider === 'xai' || newModelProvider === 'mistral') && (
|
|
<>
|
|
<div className="space-y-2">
|
|
<label className="text-sm font-medium text-slate-700 flex items-center gap-2">
|
|
<Key size={14} className="text-slate-400" />
|
|
API Key (optional)
|
|
</label>
|
|
<input
|
|
type="password"
|
|
placeholder="sk-..."
|
|
value={newModelApiKey}
|
|
onChange={(e) => setNewModelApiKey(e.target.value)}
|
|
className="w-full bg-white border border-slate-200 text-slate-800 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block p-2.5 outline-none placeholder:text-slate-400"
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<label className="text-sm font-medium text-slate-700 flex items-center gap-2">
|
|
<Globe size={14} className="text-slate-400" />
|
|
Base URL (optional)
|
|
</label>
|
|
<input
|
|
type="text"
|
|
placeholder="https://api.example.com/v1"
|
|
value={newModelBaseUrl}
|
|
onChange={(e) => setNewModelBaseUrl(e.target.value)}
|
|
className="w-full bg-white border border-slate-200 text-slate-800 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block p-2.5 outline-none placeholder:text-slate-400"
|
|
/>
|
|
</div>
|
|
</>
|
|
)}
|
|
|
|
<button
|
|
onClick={handleAddModel}
|
|
disabled={!newModelName.trim()}
|
|
className="w-full flex items-center justify-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 disabled:bg-slate-300 disabled:cursor-not-allowed text-white text-sm font-medium rounded-lg transition-all shadow-sm"
|
|
>
|
|
<Plus size={16} />
|
|
Add Model
|
|
</button>
|
|
</div>
|
|
|
|
{customModels.length > 0 && (
|
|
<div className="border-t border-slate-200 pt-4">
|
|
<div className="text-xs font-medium text-slate-500 mb-3">
|
|
Added Models ({customModels.length})
|
|
</div>
|
|
<div className="space-y-2">
|
|
{customModels.map((model) => (
|
|
<div
|
|
key={model.id}
|
|
className="bg-white rounded-lg border border-slate-200 hover:border-slate-300 transition-colors"
|
|
>
|
|
<div
|
|
className="flex items-center justify-between p-3 cursor-pointer"
|
|
onClick={() => setExpandedModelId(expandedModelId === model.id ? null : model.id)}
|
|
>
|
|
<div className="flex-1 min-w-0">
|
|
<div className="text-sm font-medium text-slate-800 truncate">
|
|
{model.name}
|
|
</div>
|
|
<div className="text-xs text-slate-500 capitalize">
|
|
{model.provider} {model.apiKey && '• Configured'}
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-1">
|
|
{expandedModelId === model.id ? (
|
|
<ChevronUp size={16} className="text-slate-400" />
|
|
) : (
|
|
<ChevronDown size={16} className="text-slate-400" />
|
|
)}
|
|
<button
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
handleDeleteModel(model.id);
|
|
}}
|
|
className="p-1.5 text-slate-400 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors"
|
|
title="Remove model"
|
|
>
|
|
<Trash2 size={16} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{expandedModelId === model.id && (
|
|
<div className="px-3 pb-3 pt-0 space-y-3 animate-in fade-in slide-in-from-top-2 duration-200">
|
|
<div className="space-y-2">
|
|
<label className="text-sm font-medium text-slate-700 flex items-center gap-2">
|
|
<Key size={14} className="text-slate-400" />
|
|
API Key
|
|
</label>
|
|
<input
|
|
type="password"
|
|
placeholder="sk-..."
|
|
value={model.apiKey || ''}
|
|
onChange={(e) => handleUpdateModel(model.id, { apiKey: e.target.value || undefined })}
|
|
className="w-full bg-white border border-slate-200 text-slate-800 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block p-2.5 outline-none placeholder:text-slate-400"
|
|
/>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<label className="text-sm font-medium text-slate-700 flex items-center gap-2">
|
|
<Globe size={14} className="text-slate-400" />
|
|
Base URL
|
|
</label>
|
|
<input
|
|
type="text"
|
|
placeholder="https://api.example.com/v1"
|
|
value={model.baseUrl || ''}
|
|
onChange={(e) => handleUpdateModel(model.id, { baseUrl: e.target.value || undefined })}
|
|
className="w-full bg-white border border-slate-200 text-slate-800 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block p-2.5 outline-none placeholder:text-slate-400"
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default ModelSection;
|