feat(wizard): improve model scan with search and manual add

- Provider form submit button changed to 'Save & Next'
- Remove extra Next button from provider form
- Add search input to filter scanned models
- Add manual model name input for APIs that don't support scanning
- Add divider between scanned list and manual add section
- Add i18n translations for all locales
This commit is contained in:
langbot-dev
2026-08-12 02:29:36 +08:00
parent d7a034a562
commit 72c03276ff
10 changed files with 173 additions and 51 deletions
@@ -42,6 +42,7 @@ interface ProviderFormProps {
providerId?: string; providerId?: string;
initialValues?: ProviderFormInitialValues; initialValues?: ProviderFormInitialValues;
onValuesChange?: (values: ProviderFormInitialValues) => void; onValuesChange?: (values: ProviderFormInitialValues) => void;
submitButtonText?: string;
onFormSubmit: (providerUuid?: string) => void; onFormSubmit: (providerUuid?: string) => void;
onFormCancel: () => void; onFormCancel: () => void;
} }
@@ -50,6 +51,7 @@ export default function ProviderForm({
providerId, providerId,
initialValues, initialValues,
onValuesChange, onValuesChange,
submitButtonText,
onFormSubmit, onFormSubmit,
onFormCancel, onFormCancel,
}: ProviderFormProps) { }: ProviderFormProps) {
@@ -399,7 +401,7 @@ export default function ProviderForm({
/> />
<DialogFooter> <DialogFooter>
<Button type="submit">{t('common.save')}</Button> <Button type="submit">{submitButtonText || t('common.save')}</Button>
<Button type="button" variant="outline" onClick={onFormCancel}> <Button type="button" variant="outline" onClick={onFormCancel}>
{t('common.cancel')} {t('common.cancel')}
</Button> </Button>
+122 -50
View File
@@ -51,6 +51,7 @@ import { getAdapterDocUrl } from '@/app/infra/entities/adapter-docs';
import i18n from 'i18next'; import i18n from 'i18next';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { import {
Card, Card,
CardContent, CardContent,
@@ -1151,6 +1152,9 @@ function WizardModelScan({
); );
const [adding, setAdding] = useState(false); const [adding, setAdding] = useState(false);
const [scanDone, setScanDone] = useState(false); const [scanDone, setScanDone] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const [manualModelName, setManualModelName] = useState('');
const [manualAdding, setManualAdding] = useState(false);
// Auto-scan on mount // Auto-scan on mount
useEffect(() => { useEffect(() => {
@@ -1178,6 +1182,12 @@ function WizardModelScan({
}; };
}, [providerUuid]); }, [providerUuid]);
const filteredModels = searchQuery
? scannedModels.filter((m) =>
m.name.toLowerCase().includes(searchQuery.toLowerCase()),
)
: scannedModels;
const toggleModel = (name: string) => { const toggleModel = (name: string) => {
setSelectedModels((prev) => { setSelectedModels((prev) => {
const next = new Set(prev); const next = new Set(prev);
@@ -1214,6 +1224,29 @@ function WizardModelScan({
} }
}; };
const handleManualAdd = async () => {
const name = manualModelName.trim();
if (!providerUuid || !name) return;
setManualAdding(true);
try {
await httpClient.createProviderLLMModel({
name,
provider_uuid: providerUuid,
abilities: ['llm'],
reasoning_config: { level: 'provider_default' },
context_length: null,
extra_args: {},
} as never);
toast.success(t('wizard.provider.modelsAdded', { count: 1 }));
setManualModelName('');
onModelsAdded();
} catch {
toast.error(t('wizard.provider.modelsAddError'));
} finally {
setManualAdding(false);
}
};
return ( return (
<div className="max-w-2xl mx-auto w-full animate-in fade-in slide-in-from-bottom-2 duration-300"> <div className="max-w-2xl mx-auto w-full animate-in fade-in slide-in-from-bottom-2 duration-300">
<div className="text-center mb-6"> <div className="text-center mb-6">
@@ -1233,55 +1266,101 @@ function WizardModelScan({
</div> </div>
)} )}
{!scanning && scanDone && scannedModels.length === 0 && ( {!scanning && scanDone && (
<div className="text-center py-8 text-muted-foreground">
<p>{t('wizard.provider.noModelsFound')}</p>
</div>
)}
{!scanning && scannedModels.length > 0 && (
<> <>
<div className="max-h-60 overflow-y-auto space-y-2"> {/* Search input for scanned models */}
{scannedModels.map((model) => ( {scannedModels.length > 0 && (
<label <Input
key={model.name} placeholder={t('wizard.provider.searchModels')}
className="flex items-center gap-3 p-3 rounded-lg border cursor-pointer hover:bg-accent transition-colors" value={searchQuery}
> onChange={(e) => setSearchQuery(e.target.value)}
<input className="mb-2"
type="checkbox" />
checked={selectedModels.has(model.name)} )}
onChange={() => toggleModel(model.name)}
className="w-4 h-4 rounded" {/* Scanned models list */}
/> {scannedModels.length > 0 ? (
<div className="flex-1 min-w-0"> <>
<span className="text-sm font-medium truncate block"> <div className="max-h-48 overflow-y-auto space-y-2">
{model.name} {filteredModels.map((model) => (
</span> <label
{model.context_length && ( key={model.name}
<span className="text-xs text-muted-foreground"> className="flex items-center gap-3 p-3 rounded-lg border cursor-pointer hover:bg-accent transition-colors"
ctx: {model.context_length.toLocaleString()} >
</span> <input
type="checkbox"
checked={selectedModels.has(model.name)}
onChange={() => toggleModel(model.name)}
className="w-4 h-4 rounded"
/>
<div className="flex-1 min-w-0">
<span className="text-sm font-medium truncate block">
{model.name}
</span>
{model.context_length && (
<span className="text-xs text-muted-foreground">
ctx: {model.context_length.toLocaleString()}
</span>
)}
</div>
</label>
))}
{filteredModels.length === 0 && (
<p className="text-sm text-muted-foreground text-center py-4">
{t('wizard.provider.noMatch')}
</p>
)}
</div>
<div className="flex gap-3 pt-2">
<Button
onClick={handleAddSelected}
disabled={selectedModels.size === 0 || adding}
className="flex-1"
>
{adding && (
<Loader2 className="w-4 h-4 mr-1.5 animate-spin" />
)} )}
</div> {t('wizard.provider.addSelected', {
</label> count: selectedModels.size,
))} })}
</Button>
<Button variant="outline" onClick={onModelsAdded}>
{t('wizard.provider.skipModelAdd')}
</Button>
</div>
</>
) : (
<div className="text-center py-4 text-muted-foreground">
<p>{t('wizard.provider.noModelsFound')}</p>
</div>
)}
{/* Divider */}
<div className="flex items-center gap-3 py-2">
<div className="flex-1 h-px bg-border" />
<span className="text-xs text-muted-foreground">
{t('wizard.provider.orDivider')}
</span>
<div className="flex-1 h-px bg-border" />
</div> </div>
<div className="flex gap-3 pt-2"> {/* Manual add */}
<div className="flex gap-2">
<Input
placeholder={t('wizard.provider.manualModelPlaceholder')}
value={manualModelName}
onChange={(e) => setManualModelName(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleManualAdd()}
/>
<Button <Button
onClick={handleAddSelected} onClick={handleManualAdd}
disabled={selectedModels.size === 0 || adding} disabled={!manualModelName.trim() || manualAdding}
className="flex-1"
> >
{adding && ( {manualAdding && (
<Loader2 className="w-4 h-4 mr-1.5 animate-spin" /> <Loader2 className="w-4 h-4 mr-1.5 animate-spin" />
)} )}
{t('wizard.provider.addSelected', { {t('wizard.provider.addManual')}
count: selectedModels.size,
})}
</Button>
<Button variant="outline" onClick={onModelsAdded}>
{t('wizard.provider.skipModelAdd')}
</Button> </Button>
</div> </div>
</> </>
@@ -1540,23 +1619,16 @@ function StepAIEngine({
{t('wizard.provider.description')} {t('wizard.provider.description')}
</p> </p>
</div> </div>
<div className="border rounded-lg p-6 bg-card space-y-4"> <div className="border rounded-lg p-6 bg-card">
<ProviderForm <ProviderForm
initialValues={savedProviderForm} initialValues={savedProviderForm}
onValuesChange={onSavedProviderFormChange} onValuesChange={onSavedProviderFormChange}
submitButtonText={t('wizard.provider.saveAndNext')}
onFormSubmit={(uuid) => { onFormSubmit={(uuid) => {
onProviderCreated(uuid); onProviderCreated(uuid);
}} }}
onFormCancel={onResetModelSource} onFormCancel={onResetModelSource}
/> />
{providerCreated && (
<div className="flex justify-end pt-2 border-t">
<Button onClick={onGoToNextLayer}>
{t('wizard.next')}
<ArrowRight className="w-4 h-4 ml-1.5" />
</Button>
</div>
)}
</div> </div>
</div> </div>
); );
+6
View File
@@ -1867,6 +1867,12 @@ const enUS = {
modelsAdded: 'Added {{count}} model(s)', modelsAdded: 'Added {{count}} model(s)',
modelsAddError: 'Failed to add models', modelsAddError: 'Failed to add models',
skipModelAdd: 'Skip, add later', skipModelAdd: 'Skip, add later',
searchModels: 'Search models...',
noMatch: 'No matching models',
orDivider: 'or',
manualModelPlaceholder: 'Enter model name to add manually',
addManual: 'Add',
saveAndNext: 'Save & Next',
}, },
modelSource: { modelSource: {
title: 'Choose Model Source', title: 'Choose Model Source',
+6
View File
@@ -1725,6 +1725,12 @@ const esES = {
modelsAdded: '{{count}} modelo(s) agregado(s)', modelsAdded: '{{count}} modelo(s) agregado(s)',
modelsAddError: 'Error al agregar modelos', modelsAddError: 'Error al agregar modelos',
skipModelAdd: 'Omitir, agregar después', skipModelAdd: 'Omitir, agregar después',
searchModels: 'Buscar modelos...',
noMatch: 'No hay modelos coincidentes',
orDivider: 'o',
manualModelPlaceholder: 'Ingresa el nombre del modelo para agregar manualmente',
addManual: 'Agregar',
saveAndNext: 'Guardar y siguiente',
}, },
modelSource: { modelSource: {
title: 'Elegir fuente del modelo', title: 'Elegir fuente del modelo',
+6
View File
@@ -1786,6 +1786,12 @@ const jaJP = {
modelsAdded: '{{count}}個のモデルを追加しました', modelsAdded: '{{count}}個のモデルを追加しました',
modelsAddError: 'モデルの追加に失敗しました', modelsAddError: 'モデルの追加に失敗しました',
skipModelAdd: 'スキップ(後で追加)', skipModelAdd: 'スキップ(後で追加)',
searchModels: 'モデルを検索...',
noMatch: '一致するモデルがありません',
orDivider: 'または',
manualModelPlaceholder: 'モデル名を入力して手動追加',
addManual: '追加',
saveAndNext: '保存して次へ',
}, },
modelSource: { modelSource: {
title: 'モデルソースを選択', title: 'モデルソースを選択',
+6
View File
@@ -1695,6 +1695,12 @@ const ruRU = {
modelsAdded: 'Добавлено {{count}} модель(ей)', modelsAdded: 'Добавлено {{count}} модель(ей)',
modelsAddError: 'Ошибка добавления моделей', modelsAddError: 'Ошибка добавления моделей',
skipModelAdd: 'Пропустить, добавить позже', skipModelAdd: 'Пропустить, добавить позже',
searchModels: 'Поиск моделей...',
noMatch: 'Нет подходящих моделей',
orDivider: 'или',
manualModelPlaceholder: 'Введите имя модели для ручного добавления',
addManual: 'Добавить',
saveAndNext: 'Сохранить и далее',
}, },
modelSource: { modelSource: {
title: 'Выберите источник модели', title: 'Выберите источник модели',
+6
View File
@@ -1659,6 +1659,12 @@ const thTH = {
modelsAdded: 'เพิ่ม {{count}} โมเดลแล้ว', modelsAdded: 'เพิ่ม {{count}} โมเดลแล้ว',
modelsAddError: 'เพิ่มโมเดลไม่สำเร็จ', modelsAddError: 'เพิ่มโมเดลไม่สำเร็จ',
skipModelAdd: 'ข้าม เพิ่มทีหลัง', skipModelAdd: 'ข้าม เพิ่มทีหลัง',
searchModels: 'ค้นหาโมเดล...',
noMatch: 'ไม่พบโมเดลที่ตรงกัน',
orDivider: 'หรือ',
manualModelPlaceholder: 'กรอกชื่อโมเดลเพื่อเพิ่มด้วยตนเอง',
addManual: 'เพิ่ม',
saveAndNext: 'บันทึกและถัดไป',
}, },
modelSource: { modelSource: {
title: 'เลือกแหล่งโมเดล', title: 'เลือกแหล่งโมเดล',
+6
View File
@@ -1685,6 +1685,12 @@ const viVN = {
modelsAdded: 'Đã thêm {{count}} mô hình', modelsAdded: 'Đã thêm {{count}} mô hình',
modelsAddError: 'Thêm mô hình thất bại', modelsAddError: 'Thêm mô hình thất bại',
skipModelAdd: 'Bỏ qua, thêm sau', skipModelAdd: 'Bỏ qua, thêm sau',
searchModels: 'Tìm kiếm mô hình...',
noMatch: 'Không tìm thấy mô hình phù hợp',
orDivider: 'hoặc',
manualModelPlaceholder: 'Nhập tên mô hình để thêm thủ công',
addManual: 'Thêm',
saveAndNext: 'Lưu & Tiếp theo',
}, },
modelSource: { modelSource: {
title: 'Chọn nguồn mô hình', title: 'Chọn nguồn mô hình',
+6
View File
@@ -1783,6 +1783,12 @@ const zhHans = {
modelsAdded: '已添加 {{count}} 个模型', modelsAdded: '已添加 {{count}} 个模型',
modelsAddError: '添加模型失败', modelsAddError: '添加模型失败',
skipModelAdd: '跳过,稍后添加', skipModelAdd: '跳过,稍后添加',
searchModels: '搜索模型...',
noMatch: '没有匹配的模型',
orDivider: '或',
manualModelPlaceholder: '输入模型名称手动添加',
addManual: '添加',
saveAndNext: '保存并下一步',
}, },
modelSource: { modelSource: {
title: '选择模型来源', title: '选择模型来源',
+6
View File
@@ -1606,6 +1606,12 @@ const zhHant = {
modelsAdded: '已新增 {{count}} 個模型', modelsAdded: '已新增 {{count}} 個模型',
modelsAddError: '新增模型失敗', modelsAddError: '新增模型失敗',
skipModelAdd: '跳過,稍後新增', skipModelAdd: '跳過,稍後新增',
searchModels: '搜尋模型...',
noMatch: '沒有符合的模型',
orDivider: '或',
manualModelPlaceholder: '輸入模型名稱手動新增',
addManual: '新增',
saveAndNext: '儲存並下一步',
}, },
modelSource: { modelSource: {
title: '選擇模型來源', title: '選擇模型來源',