feat(wizard): add model scan step after provider creation

After creating a custom provider, scan for available LLM models
and let the user select which ones to add before proceeding.
Support back navigation from config form to model scan to provider form.
Pass provider UUID from ProviderForm callback.
This commit is contained in:
langbot-dev
2026-08-12 01:51:28 +08:00
parent af80425627
commit d06c4287bf
10 changed files with 287 additions and 8 deletions
@@ -33,7 +33,7 @@ const getFormSchema = (t: (key: string) => string) =>
interface ProviderFormProps {
providerId?: string;
onFormSubmit: () => void;
onFormSubmit: (providerUuid?: string) => void;
onFormCancel: () => void;
}
@@ -174,11 +174,12 @@ export default function ProviderForm({
if (providerId) {
await httpClient.updateModelProvider(providerId, data);
toast.success(t('models.providerSaved'));
onFormSubmit();
} else {
await httpClient.createModelProvider(data);
const resp = await httpClient.createModelProvider(data);
toast.success(t('models.providerCreated'));
onFormSubmit(resp.uuid);
}
onFormSubmit();
} catch (err) {
toast.error(t('models.providerSaveError') + (err as CustomApiError).msg);
}
+214 -5
View File
@@ -103,6 +103,10 @@ export default function WizardPage() {
null,
);
const [providerCreated, setProviderCreated] = useState(false);
const [createdProviderUuid, setCreatedProviderUuid] = useState<string | null>(
null,
);
const [modelsAdded, setModelsAdded] = useState(false);
const [createdBotUuid, setCreatedBotUuid] = useState<string | null>(null);
const [webhookUrl, setWebhookUrl] = useState<string>('');
const [extraWebhookUrl, setExtraWebhookUrl] = useState<string>('');
@@ -314,7 +318,7 @@ export default function WizardPage() {
if (aiEngineMode === 'llm') {
return (
modelSource === 'space' ||
(modelSource === 'custom' && providerCreated && selectedRunner !== null)
(modelSource === 'custom' && modelsAdded)
);
}
return false;
@@ -343,10 +347,28 @@ export default function WizardPage() {
const goPrev = useCallback(() => {
if (currentStep === 2) {
// Intra-step back navigation for AI Engine step
if (selectedRunner && aiEngineMode === 'orchestrated') {
if (aiEngineMode === 'orchestrated' && selectedRunner) {
setSelectedRunner(null);
return;
}
// LLM + custom: config form → model scan
if (aiEngineMode === 'llm' && modelSource === 'custom' && modelsAdded) {
setModelsAdded(false);
setSelectedRunner(null);
return;
}
// LLM + custom: model scan → provider form
if (
aiEngineMode === 'llm' &&
modelSource === 'custom' &&
providerCreated &&
!modelsAdded
) {
setProviderCreated(false);
setCreatedProviderUuid(null);
return;
}
// LLM + custom: provider form → model source
if (
aiEngineMode === 'llm' &&
modelSource === 'custom' &&
@@ -355,10 +377,12 @@ export default function WizardPage() {
setModelSource(null);
return;
}
// LLM: model source → main choice
if (modelSource) {
setModelSource(null);
return;
}
// Main choice → previous step
if (aiEngineMode) {
setAiEngineMode(null);
return;
@@ -369,7 +393,7 @@ export default function WizardPage() {
setCurrentStep(prevStep);
saveProgress({ step: prevStep });
}
}, [currentStep, saveProgress, aiEngineMode, selectedRunner, modelSource, providerCreated]);
}, [currentStep, saveProgress, aiEngineMode, selectedRunner, modelSource, providerCreated, modelsAdded]);
// ---- Create Bot (Step 0) ----
// Creates a disabled bot using the adapter label as name.
@@ -741,8 +765,14 @@ export default function WizardPage() {
modelSource={modelSource}
onModelSourceSelect={handleModelSourceSelect}
providerCreated={providerCreated}
onProviderCreated={() => {
createdProviderUuid={createdProviderUuid}
modelsAdded={modelsAdded}
onProviderCreated={(uuid) => {
setProviderCreated(true);
setCreatedProviderUuid(uuid ?? null);
}}
onModelsAdded={() => {
setModelsAdded(true);
handleSelectRunner('local-agent');
}}
onResetModelSource={() => setModelSource(null)}
@@ -1080,6 +1110,164 @@ function StepBotConfig({
);
}
// ---------------------------------------------------------------------------
// Model scan sub-component (used within Step 2)
// ---------------------------------------------------------------------------
function WizardModelScan({
providerUuid,
onModelsAdded,
}: {
providerUuid: string | null;
onModelsAdded: () => void;
}) {
const { t } = useTranslation();
const [scanning, setScanning] = useState(false);
const [scannedModels, setScannedModels] = useState<
{ name: string; context_length?: number | null }[]
>([]);
const [selectedModels, setSelectedModels] = useState<Set<string>>(new Set());
const [adding, setAdding] = useState(false);
const [scanDone, setScanDone] = useState(false);
// Auto-scan on mount
useEffect(() => {
if (!providerUuid) return;
let cancelled = false;
(async () => {
setScanning(true);
try {
const resp = await httpClient.scanProviderModels(
providerUuid,
'llm',
);
if (!cancelled) {
setScannedModels(resp.models ?? []);
setScanDone(true);
}
} catch {
if (!cancelled) setScanDone(true);
} finally {
if (!cancelled) setScanning(false);
}
})();
return () => {
cancelled = true;
};
}, [providerUuid]);
const toggleModel = (name: string) => {
setSelectedModels((prev) => {
const next = new Set(prev);
if (next.has(name)) next.delete(name);
else next.add(name);
return next;
});
};
const handleAddSelected = async () => {
if (!providerUuid || selectedModels.size === 0) return;
setAdding(true);
try {
for (const name of selectedModels) {
const model = scannedModels.find((m) => m.name === name);
await httpClient.createProviderLLMModel({
name,
provider_uuid: providerUuid,
abilities: ['llm'],
reasoning_config: { enabled: false } as never,
context_length: model?.context_length ?? null,
extra_args: {},
} as never);
}
toast.success(
t('wizard.provider.modelsAdded', { count: selectedModels.size }),
);
onModelsAdded();
} catch {
toast.error(t('wizard.provider.modelsAddError'));
} finally {
setAdding(false);
}
};
return (
<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">
<h2 className="text-xl font-semibold">
{t('wizard.provider.scanTitle')}
</h2>
<p className="text-sm text-muted-foreground mt-1">
{t('wizard.provider.scanDescription')}
</p>
</div>
<div className="border rounded-lg p-6 bg-card space-y-4">
{scanning && (
<div className="flex items-center justify-center gap-2 py-8 text-muted-foreground">
<Loader2 className="w-5 h-5 animate-spin" />
<span>{t('wizard.provider.scanning')}</span>
</div>
)}
{!scanning && scanDone && scannedModels.length === 0 && (
<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">
{scannedModels.map((model) => (
<label
key={model.name}
className="flex items-center gap-3 p-3 rounded-lg border cursor-pointer hover:bg-accent transition-colors"
>
<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>
))}
</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" />
)}
{t('wizard.provider.addSelected', {
count: selectedModels.size,
})}
</Button>
<Button variant="outline" onClick={onModelsAdded}>
{t('wizard.provider.skipModelAdd')}
</Button>
</div>
</>
)}
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Step 2: Select & Configure AI Engine
// ---------------------------------------------------------------------------
@@ -1095,7 +1283,10 @@ function StepAIEngine({
modelSource,
onModelSourceSelect,
providerCreated,
createdProviderUuid,
modelsAdded,
onProviderCreated,
onModelsAdded,
onResetModelSource,
runnerConfigItems,
runnerConfigValues,
@@ -1111,7 +1302,10 @@ function StepAIEngine({
modelSource: 'space' | 'custom' | null;
onModelSourceSelect: (source: 'space' | 'custom') => void;
providerCreated: boolean;
onProviderCreated: () => void;
createdProviderUuid: string | null;
modelsAdded: boolean;
onProviderCreated: (uuid?: string) => void;
onModelsAdded: () => void;
onResetModelSource: () => void;
runnerConfigItems: IDynamicFormItemSchema[];
runnerConfigValues: Record<string, unknown>;
@@ -1325,6 +1519,21 @@ function StepAIEngine({
);
}
// ---- Layer 2.5: Model scan & add after provider creation ----
if (
aiEngineMode === 'llm' &&
modelSource === 'custom' &&
providerCreated &&
!modelsAdded
) {
return (
<WizardModelScan
providerUuid={createdProviderUuid}
onModelsAdded={onModelsAdded}
/>
);
}
// ---- Layer 3: Runner configuration form ----
return (
<div className="flex flex-col lg:flex-1 lg:min-h-0 max-w-6xl mx-auto w-full">
+8
View File
@@ -1859,6 +1859,14 @@ const enUS = {
title: 'Add Model Provider',
description:
'Add your own model provider. Configure the API key to get started.',
scanTitle: 'Scan Available Models',
scanDescription: 'Scanning your provider for available LLM models.',
scanning: 'Scanning models...',
noModelsFound: 'No models found. Please check your provider config.',
addSelected: 'Add {{count}} selected model(s)',
modelsAdded: 'Added {{count}} model(s)',
modelsAddError: 'Failed to add models',
skipModelAdd: 'Skip, add later',
},
modelSource: {
title: 'Choose Model Source',
+9
View File
@@ -1716,6 +1716,15 @@ const esES = {
title: 'Agregar proveedor de modelos',
description:
'Agrega tu propio proveedor de modelos. Configura la API key para comenzar.',
scanTitle: 'Escanear modelos disponibles',
scanDescription: 'Escaneando modelos LLM de tu proveedor.',
scanning: 'Escaneando modelos...',
noModelsFound:
'No se encontraron modelos. Verifica la configuración del proveedor.',
addSelected: 'Agregar {{count}} modelo(s) seleccionado(s)',
modelsAdded: '{{count}} modelo(s) agregado(s)',
modelsAddError: 'Error al agregar modelos',
skipModelAdd: 'Omitir, agregar después',
},
modelSource: {
title: 'Elegir fuente del modelo',
+9
View File
@@ -1777,6 +1777,15 @@ const jaJP = {
title: 'モデルプロバイダーを追加',
description:
'独自のモデルプロバイダーを追加します。APIキーを設定すると使用可能になります。',
scanTitle: '利用可能なモデルをスキャン',
scanDescription: 'プロバイダーから利用可能なLLMモデルをスキャン中です。',
scanning: 'モデルをスキャン中...',
noModelsFound:
'モデルが見つかりません。プロバイダー設定を確認してください。',
addSelected: '選択した{{count}}個のモデルを追加',
modelsAdded: '{{count}}個のモデルを追加しました',
modelsAddError: 'モデルの追加に失敗しました',
skipModelAdd: 'スキップ(後で追加)',
},
modelSource: {
title: 'モデルソースを選択',
+9
View File
@@ -1686,6 +1686,15 @@ const ruRU = {
title: 'Добавить провайдера моделей',
description:
'Добавьте своего провайдера моделей. Настройте API-ключ для начала работы.',
scanTitle: 'Сканирование доступных моделей',
scanDescription: 'Сканирование LLM-моделей у вашего провайдера.',
scanning: 'Сканирование моделей...',
noModelsFound:
'Модели не найдены. Проверьте настройки провайдера.',
addSelected: 'Добавить {{count}} выбранную(ых)',
modelsAdded: 'Добавлено {{count}} модель(ей)',
modelsAddError: 'Ошибка добавления моделей',
skipModelAdd: 'Пропустить, добавить позже',
},
modelSource: {
title: 'Выберите источник модели',
+9
View File
@@ -1650,6 +1650,15 @@ const thTH = {
title: 'เพิ่มผู้ให้บริการโมเดล',
description:
'เพิ่มผู้ให้บริการโมเดลของคุณเอง กำหนดค่า API key เพื่อเริ่มต้น',
scanTitle: 'สแกนโมเดลที่ใช้ได้',
scanDescription: 'กำลังสแกนโมเดล LLM จากผู้ให้บริการของคุณ',
scanning: 'กำลังสแกนโมเดล...',
noModelsFound:
'ไม่พบโมเดล กรุณาตรวจสอบการตั้งค่าผู้ให้บริการ',
addSelected: 'เพิ่ม {{count}} โมเดลที่เลือก',
modelsAdded: 'เพิ่ม {{count}} โมเดลแล้ว',
modelsAddError: 'เพิ่มโมเดลไม่สำเร็จ',
skipModelAdd: 'ข้าม เพิ่มทีหลัง',
},
modelSource: {
title: 'เลือกแหล่งโมเดล',
+9
View File
@@ -1676,6 +1676,15 @@ const viVN = {
title: 'Thêm nhà cung cấp mô hình',
description:
'Thêm nhà cung cấp mô hình của bạn. Cấu hình API key để bắt đầu.',
scanTitle: 'Quét mô hình khả dụng',
scanDescription: 'Đang quét mô hình LLM từ nhà cung cấp của bạn.',
scanning: 'Đang quét mô hình...',
noModelsFound:
'Không tìm thấy mô hình. Vui lòng kiểm tra cấu hình nhà cung cấp.',
addSelected: 'Thêm {{count}} mô hình đã chọn',
modelsAdded: 'Đã thêm {{count}} mô hình',
modelsAddError: 'Thêm mô hình thất bại',
skipModelAdd: 'Bỏ qua, thêm sau',
},
modelSource: {
title: 'Chọn nguồn mô hình',
+8
View File
@@ -1775,6 +1775,14 @@ const zhHans = {
provider: {
title: '添加模型供应商',
description: '添加你自己的模型供应商,配置 API Key 后即可使用。',
scanTitle: '扫描可用模型',
scanDescription: '正在从你的供应商中扫描可用的 LLM 模型。',
scanning: '正在扫描模型...',
noModelsFound: '未发现可用模型,请检查供应商配置。',
addSelected: '添加选中的 {{count}} 个模型',
modelsAdded: '已添加 {{count}} 个模型',
modelsAddError: '添加模型失败',
skipModelAdd: '跳过,稍后添加',
},
modelSource: {
title: '选择模型来源',
+8
View File
@@ -1598,6 +1598,14 @@ const zhHant = {
provider: {
title: '新增模型供應商',
description: '新增你自己的模型供應商,配置 API Key 後即可使用。',
scanTitle: '掃描可用模型',
scanDescription: '正在從你的供應商中掃描可用的 LLM 模型。',
scanning: '正在掃描模型...',
noModelsFound: '未發現可用模型,請檢查供應商配置。',
addSelected: '新增選取的 {{count}} 個模型',
modelsAdded: '已新增 {{count}} 個模型',
modelsAddError: '新增模型失敗',
skipModelAdd: '跳過,稍後新增',
},
modelSource: {
title: '選擇模型來源',