Spaces:
No application file
No application file
| 'use client'; | |
| import { useState, useCallback, useEffect } from 'react'; | |
| import { Dialog, DialogContent, DialogTitle, DialogDescription } from '@/components/ui/dialog'; | |
| import { Button } from '@/components/ui/button'; | |
| import { Input } from '@/components/ui/input'; | |
| import { Label } from '@/components/ui/label'; | |
| import { Checkbox } from '@/components/ui/checkbox'; | |
| import { Sparkles, Wrench, Zap, Loader2, CheckCircle, XCircle } from 'lucide-react'; | |
| import { useI18n } from '@/lib/hooks/use-i18n'; | |
| import type { EditingModel } from '@/lib/types/settings'; | |
| import type { ProviderId } from '@/lib/ai/providers'; | |
| import { cn } from '@/lib/utils'; | |
| interface ModelEditDialogProps { | |
| open: boolean; | |
| onOpenChange: (open: boolean) => void; | |
| editingModel: EditingModel | null; | |
| setEditingModel: (model: EditingModel | null) => void; | |
| onSave: () => void; | |
| onAutoSave?: () => void; // Auto-save on blur | |
| providerId: ProviderId; | |
| apiKey: string; | |
| baseUrl?: string; | |
| providerType?: string; | |
| requiresApiKey?: boolean; | |
| } | |
| export function ModelEditDialog({ | |
| open, | |
| onOpenChange, | |
| editingModel, | |
| setEditingModel, | |
| onSave, | |
| onAutoSave, | |
| providerId, | |
| apiKey, | |
| baseUrl, | |
| providerType, | |
| requiresApiKey, | |
| }: ModelEditDialogProps) { | |
| const { t } = useI18n(); | |
| const [testStatus, setTestStatus] = useState<'idle' | 'testing' | 'success' | 'error'>('idle'); | |
| const [testMessage, setTestMessage] = useState(''); | |
| // Reset test status when dialog closes | |
| useEffect(() => { | |
| if (!open) { | |
| // eslint-disable-next-line react-hooks/set-state-in-effect -- Reset state when dialog closes | |
| setTestStatus('idle'); | |
| setTestMessage(''); | |
| } | |
| }, [open]); | |
| const handleClose = () => { | |
| onOpenChange(false); | |
| setEditingModel(null); | |
| }; | |
| const handleTestModel = useCallback(async () => { | |
| if (!editingModel || !apiKey) { | |
| setTestStatus('error'); | |
| setTestMessage(t('settings.apiKeyRequired') || 'API Key is required'); | |
| return; | |
| } | |
| setTestStatus('testing'); | |
| setTestMessage(''); | |
| try { | |
| const response = await fetch('/api/verify-model', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ | |
| apiKey, | |
| baseUrl, | |
| model: `${providerId}:${editingModel.model.id}`, | |
| providerType, | |
| requiresApiKey, | |
| }), | |
| }); | |
| const data = await response.json(); | |
| if (data.success) { | |
| setTestStatus('success'); | |
| setTestMessage(t('settings.connectionSuccess')); | |
| } else { | |
| setTestStatus('error'); | |
| setTestMessage(data.error || t('settings.connectionFailed')); | |
| } | |
| } catch (_error) { | |
| setTestStatus('error'); | |
| setTestMessage(t('settings.connectionFailed')); | |
| } | |
| }, [editingModel, apiKey, baseUrl, providerId, providerType, requiresApiKey, t]); | |
| if (!editingModel) return null; | |
| return ( | |
| <Dialog open={open} onOpenChange={onOpenChange}> | |
| <DialogContent className="sm:max-w-[500px]"> | |
| <DialogTitle className="sr-only"> | |
| {editingModel.modelIndex === null ? t('settings.addNewModel') : t('settings.editModel')} | |
| </DialogTitle> | |
| <DialogDescription className="sr-only"> | |
| {editingModel.modelIndex === null | |
| ? t('settings.addNewModelDescription') | |
| : t('settings.editModelDescription')} | |
| </DialogDescription> | |
| <div className="space-y-4"> | |
| <div className="pb-3 border-b"> | |
| <h2 className="text-lg font-semibold"> | |
| {editingModel.modelIndex === null | |
| ? t('settings.addNewModel') | |
| : t('settings.editModel')} | |
| </h2> | |
| </div> | |
| {/* Model ID */} | |
| <div className="space-y-2"> | |
| <Label>{t('settings.modelId')}</Label> | |
| <Input | |
| placeholder={t('settings.modelIdPlaceholder')} | |
| value={editingModel.model.id} | |
| onChange={(e) => { | |
| const newId = e.target.value; | |
| const currentName = editingModel.model.name; | |
| const currentId = editingModel.model.id; | |
| // Auto-sync name if it's empty or matches the old ID | |
| const shouldSyncName = !currentName || currentName === currentId; | |
| setEditingModel({ | |
| ...editingModel, | |
| model: { | |
| ...editingModel.model, | |
| id: newId, | |
| name: shouldSyncName ? newId : currentName, | |
| }, | |
| }); | |
| // Reset test status when model ID changes | |
| setTestStatus('idle'); | |
| setTestMessage(''); | |
| }} | |
| onBlur={() => onAutoSave?.()} | |
| /> | |
| </div> | |
| {/* Display Name */} | |
| <div className="space-y-2"> | |
| <Label>{t('settings.modelName')}</Label> | |
| <Input | |
| placeholder={t('settings.modelNamePlaceholder')} | |
| value={editingModel.model.name} | |
| onChange={(e) => | |
| setEditingModel({ | |
| ...editingModel, | |
| model: { ...editingModel.model, name: e.target.value }, | |
| }) | |
| } | |
| onBlur={() => onAutoSave?.()} | |
| /> | |
| </div> | |
| {/* Capabilities */} | |
| <div className="space-y-2"> | |
| <Label>{t('settings.modelCapabilities')}</Label> | |
| <div className="flex gap-4"> | |
| <div className="flex items-center space-x-2"> | |
| <Checkbox | |
| id="cap-vision" | |
| checked={editingModel.model.capabilities?.vision || false} | |
| onCheckedChange={(checked) => { | |
| setEditingModel({ | |
| ...editingModel, | |
| model: { | |
| ...editingModel.model, | |
| capabilities: { | |
| ...editingModel.model.capabilities, | |
| vision: checked as boolean, | |
| }, | |
| }, | |
| }); | |
| onAutoSave?.(); | |
| }} | |
| /> | |
| <label | |
| htmlFor="cap-vision" | |
| className="text-sm flex items-center gap-1.5 cursor-pointer" | |
| > | |
| <Sparkles className="h-3.5 w-3.5" /> | |
| {t('settings.capabilities.vision')} | |
| </label> | |
| </div> | |
| <div className="flex items-center space-x-2"> | |
| <Checkbox | |
| id="cap-tools" | |
| checked={editingModel.model.capabilities?.tools || false} | |
| onCheckedChange={(checked) => { | |
| setEditingModel({ | |
| ...editingModel, | |
| model: { | |
| ...editingModel.model, | |
| capabilities: { | |
| ...editingModel.model.capabilities, | |
| tools: checked as boolean, | |
| }, | |
| }, | |
| }); | |
| onAutoSave?.(); | |
| }} | |
| /> | |
| <label | |
| htmlFor="cap-tools" | |
| className="text-sm flex items-center gap-1.5 cursor-pointer" | |
| > | |
| <Wrench className="h-3.5 w-3.5" /> | |
| {t('settings.capabilities.tools')} | |
| </label> | |
| </div> | |
| <div className="flex items-center space-x-2"> | |
| <Checkbox | |
| id="cap-streaming" | |
| checked={editingModel.model.capabilities?.streaming || false} | |
| onCheckedChange={(checked) => { | |
| setEditingModel({ | |
| ...editingModel, | |
| model: { | |
| ...editingModel.model, | |
| capabilities: { | |
| ...editingModel.model.capabilities, | |
| streaming: checked as boolean, | |
| }, | |
| }, | |
| }); | |
| onAutoSave?.(); | |
| }} | |
| /> | |
| <label | |
| htmlFor="cap-streaming" | |
| className="text-sm flex items-center gap-1.5 cursor-pointer" | |
| > | |
| <Zap className="h-3.5 w-3.5" /> | |
| {t('settings.capabilities.streaming')} | |
| </label> | |
| </div> | |
| </div> | |
| </div> | |
| {/* Advanced Settings */} | |
| <div className="space-y-3 pt-3 border-t"> | |
| <Label className="text-base">{t('settings.advancedSettings')}</Label> | |
| <div className="grid grid-cols-2 gap-3"> | |
| <div className="space-y-2"> | |
| <Label className="text-sm">{t('settings.contextWindowLabel')}</Label> | |
| <Input | |
| type="number" | |
| placeholder={t('settings.contextWindowPlaceholder')} | |
| value={editingModel.model.contextWindow || ''} | |
| onChange={(e) => | |
| setEditingModel({ | |
| ...editingModel, | |
| model: { | |
| ...editingModel.model, | |
| contextWindow: e.target.value ? parseInt(e.target.value) : undefined, | |
| }, | |
| }) | |
| } | |
| onBlur={() => onAutoSave?.()} | |
| /> | |
| </div> | |
| <div className="space-y-2"> | |
| <Label className="text-sm">{t('settings.outputWindowLabel')}</Label> | |
| <Input | |
| type="number" | |
| placeholder={t('settings.outputWindowPlaceholder')} | |
| value={editingModel.model.outputWindow || ''} | |
| onChange={(e) => | |
| setEditingModel({ | |
| ...editingModel, | |
| model: { | |
| ...editingModel.model, | |
| outputWindow: e.target.value ? parseInt(e.target.value) : undefined, | |
| }, | |
| }) | |
| } | |
| onBlur={() => onAutoSave?.()} | |
| /> | |
| </div> | |
| </div> | |
| </div> | |
| {/* Test Model */} | |
| <div className="space-y-3 pt-3 border-t"> | |
| <div className="flex items-center justify-between"> | |
| <Label className="text-base">{t('settings.testModel')}</Label> | |
| <Button | |
| variant="outline" | |
| size="sm" | |
| onClick={handleTestModel} | |
| disabled={!editingModel.model.id || testStatus === 'testing'} | |
| className={cn( | |
| testStatus === 'success' && 'border-green-600 text-green-600 hover:bg-green-50', | |
| testStatus === 'error' && 'border-red-600 text-red-600 hover:bg-red-50', | |
| )} | |
| > | |
| {testStatus === 'testing' && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} | |
| {testStatus === 'success' && <CheckCircle className="mr-2 h-4 w-4" />} | |
| {testStatus === 'error' && <XCircle className="mr-2 h-4 w-4" />} | |
| {testStatus === 'testing' ? t('settings.testing') : t('settings.testConnection')} | |
| </Button> | |
| </div> | |
| {testMessage && ( | |
| <div | |
| className={cn( | |
| 'rounded-lg p-3 text-sm', | |
| testStatus === 'success' && 'bg-green-50 text-green-700 border border-green-200', | |
| testStatus === 'error' && 'bg-red-50 text-red-700 border border-red-200', | |
| )} | |
| > | |
| <div className="flex items-start gap-2 flex-wrap"> | |
| {testStatus === 'success' && <CheckCircle className="h-4 w-4 mt-0.5 shrink-0" />} | |
| {testStatus === 'error' && <XCircle className="h-4 w-4 mt-0.5 shrink-0" />} | |
| <p className="flex-1 break-words">{testMessage}</p> | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| {/* Footer */} | |
| <div className="flex items-center justify-end gap-2 pt-3 border-t"> | |
| <Button variant="outline" size="sm" onClick={handleClose}> | |
| {t('settings.cancelEdit')} | |
| </Button> | |
| <Button size="sm" onClick={onSave}> | |
| {t('settings.saveModel')} | |
| </Button> | |
| </div> | |
| </div> | |
| </DialogContent> | |
| </Dialog> | |
| ); | |
| } | |