open-notebook/frontend/src/app/(dashboard)/models/components/AddModelForm.tsx
Luis Novo a287d3b248
refactor: optimize duplicate model validation and improve error handling (#219)
* feat: prevent duplicate model names under same provider

Implement case-insensitive validation to prevent users from creating
duplicate model names under the same provider. This validation is
implemented both in the backend API and the frontend UI.

Changes:
- Backend: Add duplicate check in create_model endpoint (case-insensitive)
- Frontend: Add client-side validation in AddModelForm
- Frontend: Improve error message display from backend
- Tests: Add unit tests for duplicate model validation

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* refactor: optimize duplicate model validation and improve error handling

- Replace O(n) model iteration with efficient SurrealDB query for duplicate check
- Improve error message to include model name and provider for better UX
- Remove frontend duplicate validation (backend-only enforcement)
- Fix test authentication by setting OPEN_NOTEBOOK_PASSWORD before imports
- Update test mocking to use repo_query instead of Model.get_all()
- Add pytest fixture for TestClient to ensure proper test isolation

All 11 tests passing.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>

* remove unnecessary package

* fix: replace any with unknown type in error handler

- Change error type from 'any' to 'unknown' to satisfy ESLint
- Add proper type assertion for error object structure
- Maintains same runtime behavior with better type safety

---------

Co-authored-by: Claude <noreply@anthropic.com>
2025-10-25 08:48:18 -03:00

136 lines
No EOL
4.5 KiB
TypeScript

'use client'
import { useState } from 'react'
import { useForm } from 'react-hook-form'
import { CreateModelRequest, ProviderAvailability } from '@/lib/types/models'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
import { useCreateModel } from '@/lib/hooks/use-models'
import { Plus } from 'lucide-react'
interface AddModelFormProps {
modelType: 'language' | 'embedding' | 'text_to_speech' | 'speech_to_text'
providers: ProviderAvailability
}
export function AddModelForm({ modelType, providers }: AddModelFormProps) {
const [open, setOpen] = useState(false)
const createModel = useCreateModel()
const { register, handleSubmit, formState: { errors }, reset, setValue, watch } = useForm<CreateModelRequest>({
defaultValues: {
type: modelType
}
})
// Get available providers that support this model type
const availableProviders = providers.available.filter(provider =>
providers.supported_types[provider]?.includes(modelType)
)
const onSubmit = async (data: CreateModelRequest) => {
await createModel.mutateAsync(data)
reset()
setOpen(false)
}
const getModelTypeName = () => {
return modelType.replace(/_/g, ' ')
}
const getModelPlaceholder = () => {
switch (modelType) {
case 'language':
return 'e.g., gpt-5-mini, claude, gemini'
case 'embedding':
return 'e.g., text-embedding-3-small'
case 'text_to_speech':
return 'e.g., tts-gpt-4o-mini-tts, tts-1-hd'
case 'speech_to_text':
return 'e.g., whisper-1'
default:
return 'Enter model name'
}
}
if (availableProviders.length === 0) {
return (
<div className="text-sm text-muted-foreground">
No providers available for {getModelTypeName()} models
</div>
)
}
const handleOpenChange = (isOpen: boolean) => {
setOpen(isOpen)
if (!isOpen) {
reset()
}
}
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogTrigger asChild>
<Button size="sm">
<Plus className="h-4 w-4 mr-2" />
Add Model
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Add {getModelTypeName()} Model</DialogTitle>
<DialogDescription>
Configure a new {getModelTypeName()} model from available providers.
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div>
<Label htmlFor="provider">Provider</Label>
<Select onValueChange={(value) => setValue('provider', value)} required>
<SelectTrigger>
<SelectValue placeholder="Select a provider" />
</SelectTrigger>
<SelectContent>
{availableProviders.map((provider) => (
<SelectItem key={provider} value={provider}>
<span className="capitalize">{provider}</span>
</SelectItem>
))}
</SelectContent>
</Select>
{errors.provider && (
<p className="text-sm text-destructive mt-1">Provider is required</p>
)}
</div>
<div>
<Label htmlFor="name">Model Name</Label>
<Input
id="name"
{...register('name', { required: 'Model name is required' })}
placeholder={getModelPlaceholder()}
/>
{errors.name && (
<p className="text-sm text-destructive mt-1">{errors.name.message}</p>
)}
<p className="text-xs text-muted-foreground mt-1">
{modelType === 'language' && watch('provider') === 'azure' &&
'For Azure, use the deployment name as the model name'}
</p>
</div>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button type="submit" disabled={createModel.isPending}>
{createModel.isPending ? 'Adding...' : 'Add Model'}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
)
}