axonhub-chat / frontend /src /features /models /components /models-batch-create-dialog.tsx
llzai's picture
Upload 1793 files
9853396 verified
Raw
History Blame Contribute Delete
16.1 kB
import { useEffect, useState, useMemo, useCallback } from 'react';
import { IconPlus, IconTrash } from '@tabler/icons-react';
import { toc } from '@lobehub/icons';
import { useTranslation } from 'react-i18next';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { AutoComplete } from '@/components/auto-complete';
import { AutoCompleteSelect } from '@/components/auto-complete-select';
import { useModels } from '../context/models-context';
import { DEVELOPER_IDS, DEVELOPER_ICONS } from '../data/constants';
import { useBulkCreateModels } from '../data/models';
import { useDevelopersData } from '../data/providers';
import { type Provider, type ProviderModel } from '../data/providers.schema';
import { CreateModelInput, ModelCard } from '../data/schema';
interface ModelRow {
id: string;
modelId: string;
developer: string;
name: string;
icon: string;
group: string;
modelCard: ModelCard | null;
}
interface ValidationErrors {
[rowId: string]: {
developer?: boolean;
modelId?: boolean;
name?: boolean;
icon?: boolean;
};
}
const MAX_ROWS = 10;
function generateId(): string {
return `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
}
function isDeveloper(provider: string) {
return DEVELOPER_IDS.includes(provider);
}
export function ModelsBatchCreateDialog() {
const { t } = useTranslation();
const { open, setOpen } = useModels();
const bulkCreateModels = useBulkCreateModels();
const { data: developersData } = useDevelopersData();
const [rows, setRows] = useState<ModelRow[]>([]);
const [validationErrors, setValidationErrors] = useState<ValidationErrors>({});
const [dialogContent, setDialogContent] = useState<HTMLDivElement | null>(null);
const isOpen = open === 'batchCreate';
const providers = useMemo(() => {
if (!developersData) return [];
return Object.entries(developersData.providers)
.filter(([key]) => isDeveloper(key))
.map(([key, provider]: [string, Provider]) => ({
id: key,
name: provider.display_name || provider.name,
models: provider.models || [],
}));
}, [developersData]);
const developerOptions = useMemo(() => {
return DEVELOPER_IDS.map((id) => ({
value: id,
label: id,
}));
}, []);
const iconOptions = useMemo(() => {
return (
Object.entries(toc)
// @ts-ignore
.filter(([_, value]) => value.group == 'provider' || value.group == 'model')
.map(([_, value]) => ({
// @ts-ignore
value: value.id,
// @ts-ignore
label: value.id,
}))
);
}, []);
useEffect(() => {
if (isOpen && rows.length === 0) {
setRows([
{
id: generateId(),
modelId: '',
developer: '',
name: '',
icon: '',
group: '',
modelCard: null,
},
]);
}
}, [isOpen, rows.length]);
const handleAddRow = useCallback(() => {
if (rows.length >= MAX_ROWS) {
toast.error(t('models.dialogs.batchCreate.maxRowsReached', { max: MAX_ROWS }));
return;
}
setRows((prev) => [
...prev,
{
id: generateId(),
modelId: '',
developer: '',
name: '',
icon: '',
group: '',
modelCard: null,
},
]);
}, [rows.length, t]);
const handleRemoveRow = useCallback((id: string) => {
setRows((prev) => prev.filter((row) => row.id !== id));
}, []);
const handleModelIdChange = useCallback(
(id: string, modelId: string) => {
setRows((prev) =>
prev.map((row) => {
if (row.id !== id) return row;
if (!row.developer) {
return { ...row, modelId, name: '', group: '', modelCard: null };
}
const provider = providers.find((p) => p.id === row.developer);
if (!provider) {
return { ...row, modelId, name: '', group: '', modelCard: null };
}
const selectedModel = provider.models.find((m: ProviderModel) => m.id === modelId);
if (selectedModel) {
const modelCard: ModelCard = {
reasoning: {
supported: selectedModel.reasoning?.supported || false,
default: selectedModel.reasoning?.default || false,
},
toolCall: selectedModel.tool_call || false,
temperature: selectedModel.temperature || false,
modalities: {
input: selectedModel.modalities?.input || [],
output: selectedModel.modalities?.output || [],
},
vision: selectedModel.attachment || false,
cost: {
input: selectedModel.cost?.input || 0,
output: selectedModel.cost?.output || 0,
cacheRead: selectedModel.cost?.cache_read,
cacheWrite: selectedModel.cost?.cache_write,
},
limit: {
context: selectedModel.limit?.context || 0,
output: selectedModel.limit?.output || 0,
},
knowledge: selectedModel.knowledge,
releaseDate: selectedModel.release_date,
lastUpdated: selectedModel.last_updated,
};
return {
...row,
modelId,
name: selectedModel.display_name || selectedModel.name || '',
group: selectedModel.family || row.developer,
modelCard,
};
}
return { ...row, modelId, name: '', group: row.developer, modelCard: null };
})
);
},
[providers]
);
const handleDeveloperChange = useCallback((id: string, developer: string) => {
setRows((prev) =>
prev.map((row) => {
if (row.id !== id) return row;
const icon = DEVELOPER_ICONS[developer] || developer;
return {
...row,
developer,
icon,
modelId: '',
name: '',
group: '',
modelCard: null,
};
})
);
}, []);
const handleNameChange = useCallback((id: string, name: string) => {
setRows((prev) => prev.map((row) => (row.id === id ? { ...row, name } : row)));
}, []);
const handleIconChange = useCallback((id: string, icon: string) => {
setRows((prev) => prev.map((row) => (row.id === id ? { ...row, icon } : row)));
}, []);
const clearValidationError = useCallback(
(rowId: string, field: keyof ValidationErrors[string]) => {
if (!validationErrors[rowId]?.[field]) return;
setValidationErrors((prev) => {
const newErrors = { ...prev };
if (newErrors[rowId]) {
const rowErrors = { ...newErrors[rowId] };
delete rowErrors[field];
if (Object.keys(rowErrors).length === 0) {
delete newErrors[rowId];
} else {
newErrors[rowId] = rowErrors;
}
}
return newErrors;
});
},
[validationErrors]
);
const handleSubmit = useCallback(async () => {
const errors: ValidationErrors = {};
rows.forEach((row) => {
const rowErrors: ValidationErrors[string] = {};
if (!row.developer) rowErrors.developer = true;
if (!row.modelId) rowErrors.modelId = true;
if (!row.name) rowErrors.name = true;
if (!row.icon) rowErrors.icon = true;
if (Object.keys(rowErrors).length > 0) {
errors[row.id] = rowErrors;
}
});
if (Object.keys(errors).length > 0) {
setValidationErrors(errors);
return;
}
setValidationErrors({});
const validRows = rows.filter((row) => row.modelId && row.developer && row.name && row.icon && row.group);
if (validRows.length === 0) {
return;
}
const inputs: CreateModelInput[] = validRows.map((row) => ({
developer: row.developer,
modelID: row.modelId,
type: 'chat',
name: row.name,
icon: row.icon,
group: row.group,
modelCard: row.modelCard || {
reasoning: { supported: false, default: false },
toolCall: false,
temperature: false,
modalities: { input: [], output: [] },
vision: false,
cost: { input: 0, output: 0 },
limit: { context: 0, output: 0 },
},
settings: {
associations: [
{
type: 'model',
priority: 0,
modelId: {
modelId: row.modelId,
},
},
],
},
}));
try {
await bulkCreateModels.mutateAsync(inputs);
handleClose();
} catch (_error) {
// Error is handled by mutation
}
}, [rows, bulkCreateModels, t]);
const handleClose = useCallback(() => {
setOpen(null);
setRows([]);
setValidationErrors({});
}, [setOpen]);
const getModelIdOptions = useCallback(
(developer: string) => {
const provider = providers.find((p) => p.id === developer);
if (!provider) return [];
return provider.models.map((m: ProviderModel) => ({
value: m.id,
label: m.id,
}));
},
[providers]
);
return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent ref={setDialogContent} className='flex flex-col overflow-hidden sm:max-w-4xl' style={{ height: '600px' }}>
<DialogHeader className='flex-shrink-0 text-left'>
<DialogTitle>{t('models.dialogs.batchCreate.title')}</DialogTitle>
<DialogDescription>{t('models.dialogs.batchCreate.description')}</DialogDescription>
</DialogHeader>
<div className='min-h-0 flex-1 overflow-x-auto overflow-y-auto pr-2 md:overflow-x-hidden'>
<div className='min-w-[600px] space-y-2'>
<div className='flex items-start gap-2 px-2 pb-2'>
<div className='min-w-32 flex-[2]'>
<Label className='text-sm font-medium'>{t('models.fields.developer')}</Label>
</div>
<div className='min-w-40 flex-[3]'>
<Label className='text-sm font-medium'>{t('models.fields.modelId')}</Label>
</div>
<div className='min-w-24 flex-[2]'>
<Label className='text-sm font-medium'>{t('models.fields.name')}</Label>
</div>
<div className='min-w-32 flex-[3]'>
<Label className='text-sm font-medium'>{t('models.fields.icon')}</Label>
</div>
<div className='w-8 flex-shrink-0'></div>
</div>
{rows.map((row) => (
<div key={row.id} className='rounded-lg border p-2'>
<div className='flex items-start gap-2'>
<div className='min-w-32 flex-[2] space-y-1'>
<AutoComplete
selectedValue={row.developer}
onSelectedValueChange={(value) => {
handleDeveloperChange(row.id, value);
clearValidationError(row.id, 'developer');
clearValidationError(row.id, 'icon');
}}
searchValue={row.developer}
onSearchValueChange={(value) => handleDeveloperChange(row.id, value)}
items={developerOptions}
placeholder={t('models.fields.developer')}
emptyMessage={t('models.fields.noModels')}
portalContainer={dialogContent}
/>
{validationErrors[row.id]?.developer && (
<p className='text-xs text-red-600'>{t('models.dialogs.batchCreate.required')}</p>
)}
</div>
<div className='min-w-40 flex-[3] space-y-1'>
<AutoComplete
selectedValue={row.modelId}
onSelectedValueChange={(value) => {
handleModelIdChange(row.id, value);
clearValidationError(row.id, 'modelId');
clearValidationError(row.id, 'name');
clearValidationError(row.id, 'icon');
}}
searchValue={row.modelId}
onSearchValueChange={(value) => handleModelIdChange(row.id, value)}
items={row.developer ? getModelIdOptions(row.developer) : []}
placeholder={t('models.fields.modelId')}
emptyMessage={t('models.fields.noModels')}
portalContainer={dialogContent}
/>
{validationErrors[row.id]?.modelId && (
<p className='text-xs text-red-600'>{t('models.dialogs.batchCreate.required')}</p>
)}
</div>
<div className='min-w-24 flex-[2] space-y-1'>
<Input
value={row.name}
onChange={(e) => {
handleNameChange(row.id, e.target.value);
clearValidationError(row.id, 'name');
}}
placeholder={t('models.fields.name')}
/>
{validationErrors[row.id]?.name && <p className='text-xs text-red-600'>{t('models.dialogs.batchCreate.required')}</p>}
</div>
<div className='min-w-32 flex-[3] space-y-1'>
<AutoCompleteSelect
selectedValue={row.icon}
onSelectedValueChange={(value) => {
handleIconChange(row.id, value);
clearValidationError(row.id, 'icon');
}}
items={iconOptions}
placeholder={t('models.fields.icon')}
emptyMessage={t('models.fields.noIcons')}
portalContainer={dialogContent}
/>
{validationErrors[row.id]?.icon && <p className='text-xs text-red-600'>{t('models.dialogs.batchCreate.required')}</p>}
</div>
<Button
type='button'
variant='ghost'
size='icon'
className='h-8 w-8 flex-shrink-0'
onClick={() => handleRemoveRow(row.id)}
disabled={rows.length === 1}
>
<IconTrash className='h-4 w-4' />
</Button>
</div>
</div>
))}
</div>
</div>
<div className='flex flex-shrink-0 flex-col gap-3 border-t pt-4'>
<Button type='button' variant='outline' onClick={handleAddRow} disabled={rows.length >= MAX_ROWS}>
<IconPlus className='mr-2 h-4 w-4' />
{t('models.dialogs.batchCreate.addRow')} ({rows.length}/{MAX_ROWS})
</Button>
<div className='flex justify-end gap-2'>
<Button type='button' variant='outline' onClick={handleClose}>
{t('common.buttons.cancel')}
</Button>
<Button type='button' onClick={handleSubmit} disabled={bulkCreateModels.isPending}>
{t('common.buttons.create')}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}