Files
LangBot/web/src/app/home/components/models-dialog/ModelsDialog.tsx
Junyan Qin 699545a196 fix(web): fix models dialog provider type select and split add/scan popovers
1. Fix provider type select showing blank when editing: await
   loadRequesters() before loadProvider() to ensure options are
   populated before setting the selected value.

2. Split 'Add Model' into two separate entries: a '+ Add Model'
   button for manual add and a Radar icon button for scan. Each
   opens its own popover with only one layer of tabs (model type
   for manual, no tabs for scan since types are auto-detected).

3. Fix popover position: side='bottom' instead of 'left'.

4. Fix popover scroll: model type tabs stay fixed at top, content
   area scrolls independently when it overflows.

5. Scan mode now fetches all model types at once (no modelType
   filter), and routes each scanned model to the correct API
   based on its own type field.
2026-05-20 18:21:40 +08:00

620 lines
19 KiB
TypeScript

import { useState, useEffect } from 'react';
import { Plus, Boxes } from 'lucide-react';
import { httpClient, systemInfo } from '@/app/infra/http/HttpClient';
import { ModelProvider } from '@/app/infra/entities/api';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { toast } from 'sonner';
import { useTranslation } from 'react-i18next';
import ProviderForm from './component/provider-form/ProviderForm';
import { ProviderCard } from './components';
import {
ExtraArg,
ModelType,
ScanModelsResult,
SelectedScannedModel,
TestResult,
ProviderModels,
LANGBOT_MODELS_PROVIDER_REQUESTER,
} from './types';
import { CustomApiError } from '@/app/infra/entities/common';
interface ModelsDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
type ExtraArgValue = string | number | boolean | Record<string, unknown>;
function convertExtraArgsToObject(
args: ExtraArg[],
): Record<string, ExtraArgValue> {
const obj: Record<string, ExtraArgValue> = {};
args.forEach((arg) => {
if (!arg.key.trim()) return;
if (arg.type === 'number') {
obj[arg.key] = Number(arg.value);
} else if (arg.type === 'boolean') {
obj[arg.key] = arg.value === 'true';
} else if (arg.type === 'object') {
const raw = arg.value.trim() || '{}';
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
throw new Error(`Invalid JSON for extra parameter "${arg.key}"`);
}
if (
parsed === null ||
typeof parsed !== 'object' ||
Array.isArray(parsed)
) {
throw new Error(`Extra parameter "${arg.key}" must be a JSON object`);
}
obj[arg.key] = parsed as Record<string, unknown>;
} else {
obj[arg.key] = arg.value;
}
});
return obj;
}
export default function ModelsDialog({
open,
onOpenChange,
}: ModelsDialogProps) {
const { t } = useTranslation();
const [providers, setProviders] = useState<ModelProvider[]>([]);
const [accountType, setAccountType] = useState<'local' | 'space'>('local');
const [spaceCredits, setSpaceCredits] = useState<number | null>(null);
// Expanded providers and their models
const [expandedProviders, setExpandedProviders] = useState<Set<string>>(
new Set(),
);
const [providerModels, setProviderModels] = useState<
Record<string, ProviderModels>
>({});
const [loadingProviders, setLoadingProviders] = useState<Set<string>>(
new Set(),
);
// Provider form modal
const [providerFormOpen, setProviderFormOpen] = useState(false);
const [editingProviderId, setEditingProviderId] = useState<string | null>(
null,
);
// Popover states
const [addModelPopoverOpen, setAddModelPopoverOpen] = useState<string | null>(
null,
);
const [editModelPopoverOpen, setEditModelPopoverOpen] = useState<
string | null
>(null);
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState<string | null>(
null,
);
// Form states
const [isSubmitting, setIsSubmitting] = useState(false);
const [isTesting, setIsTesting] = useState(false);
const [testResult, setTestResult] = useState<TestResult | null>(null);
// Track if providers have been loaded initially
const [providersLoaded, setProvidersLoaded] = useState(false);
// Separate LangBot Models provider (hide when models service is disabled)
const langbotProvider = systemInfo.disable_models_service
? undefined
: providers.find((p) => p.requester === LANGBOT_MODELS_PROVIDER_REQUESTER);
const otherProviders = providers.filter(
(p) => p.requester !== LANGBOT_MODELS_PROVIDER_REQUESTER,
);
useEffect(() => {
if (open) {
loadUserInfo();
loadProviders();
}
}, [open]);
// Auto-expand LangBot Models when no external providers exist
useEffect(() => {
if (providersLoaded && langbotProvider && otherProviders.length === 0) {
if (!expandedProviders.has(langbotProvider.uuid)) {
setExpandedProviders(new Set([langbotProvider.uuid]));
if (!providerModels[langbotProvider.uuid]) {
loadProviderModels(langbotProvider.uuid);
}
}
}
}, [providersLoaded, providers]);
async function loadUserInfo() {
try {
const userInfo = await httpClient.getUserInfo();
setAccountType(userInfo.account_type);
if (userInfo.account_type === 'space') {
const creditsInfo = await httpClient.getSpaceCredits();
setSpaceCredits(creditsInfo.credits);
}
} catch {
setAccountType('local');
}
}
async function loadProviders() {
try {
const resp = await httpClient.getModelProviders();
setProviders(resp.providers);
setProvidersLoaded(true);
} catch (err) {
console.error('Failed to load providers', err);
toast.error(t('models.loadError'));
}
}
async function loadProviderModels(providerUuid: string, silent = false) {
if (loadingProviders.has(providerUuid)) return;
if (!silent) {
setLoadingProviders((prev) => new Set(prev).add(providerUuid));
}
try {
const [llmResp, embeddingResp, rerankResp] = await Promise.all([
httpClient.getProviderLLMModels(providerUuid),
httpClient.getProviderEmbeddingModels(providerUuid),
httpClient.getProviderRerankModels(providerUuid),
]);
setProviderModels((prev) => ({
...prev,
[providerUuid]: {
llm: llmResp.models,
embedding: embeddingResp.models,
rerank: rerankResp.models,
},
}));
} catch (err) {
console.error('Failed to load models', err);
} finally {
if (!silent) {
setLoadingProviders((prev) => {
const next = new Set(prev);
next.delete(providerUuid);
return next;
});
}
}
}
function toggleProvider(providerUuid: string) {
setExpandedProviders((prev) => {
const next = new Set(prev);
if (next.has(providerUuid)) {
next.delete(providerUuid);
} else {
next.add(providerUuid);
if (!providerModels[providerUuid]) {
loadProviderModels(providerUuid);
}
}
return next;
});
}
function handleCreateProvider() {
setEditingProviderId(null);
setProviderFormOpen(true);
}
function handleEditProvider(providerId: string) {
setEditingProviderId(providerId);
setProviderFormOpen(true);
}
async function handleDeleteProvider(providerId: string) {
try {
await httpClient.deleteModelProvider(providerId);
toast.success(t('models.providerDeleted'));
loadProviders();
} catch (err) {
toast.error(t('models.providerDeleteError') + (err as Error).message);
}
}
async function handleSpaceLogin() {
try {
const token = localStorage.getItem('token');
if (!token) {
toast.error(t('common.error'));
return;
}
const currentOrigin = window.location.origin;
const redirectUri = `${currentOrigin}/auth/space/callback?mode=bind`;
const response = await httpClient.getSpaceAuthorizeUrl(
redirectUri,
token,
);
window.location.href = response.authorize_url;
} catch {
toast.error(t('common.spaceLoginFailed'));
}
}
async function handleAddModel(
providerUuid: string,
modelType: ModelType,
name: string,
abilities: string[],
extraArgs: ExtraArg[],
) {
if (!name.trim()) {
toast.error(t('models.modelNameRequired'));
return;
}
setIsSubmitting(true);
try {
const extraArgsObj = convertExtraArgsToObject(extraArgs);
if (modelType === 'llm') {
await httpClient.createProviderLLMModel({
name,
provider_uuid: providerUuid,
abilities,
extra_args: extraArgsObj,
} as never);
} else if (modelType === 'embedding') {
await httpClient.createProviderEmbeddingModel({
name,
provider_uuid: providerUuid,
extra_args: extraArgsObj,
} as never);
} else {
await httpClient.createProviderRerankModel({
name,
provider_uuid: providerUuid,
extra_args: extraArgsObj,
} as never);
}
setAddModelPopoverOpen(null);
loadProviderModels(providerUuid, true);
loadProviders();
} catch (err) {
toast.error(t('models.createError') + (err as Error).message);
} finally {
setIsSubmitting(false);
}
}
async function handleScanModels(
providerUuid: string,
modelType?: ModelType,
): Promise<ScanModelsResult> {
try {
const resp = await httpClient.scanProviderModels(providerUuid, modelType);
return {
models: resp.models,
debug: resp.debug,
};
} catch (err) {
toast.error(t('models.getModelListError') + (err as CustomApiError).msg);
return { models: [] };
}
}
async function handleAddScannedModels(
providerUuid: string,
modelType: ModelType,
models: SelectedScannedModel[],
) {
if (models.length === 0) return;
setIsSubmitting(true);
try {
for (const item of models) {
const effectiveType = item.model.type || modelType;
if (effectiveType === 'llm') {
await httpClient.createProviderLLMModel({
name: item.model.name,
provider_uuid: providerUuid,
abilities: item.abilities,
extra_args: {},
} as never);
} else if (effectiveType === 'embedding') {
await httpClient.createProviderEmbeddingModel({
name: item.model.name,
provider_uuid: providerUuid,
extra_args: {},
} as never);
} else {
await httpClient.createProviderRerankModel({
name: item.model.name,
provider_uuid: providerUuid,
extra_args: {},
} as never);
}
}
setAddModelPopoverOpen(null);
loadProviderModels(providerUuid, true);
loadProviders();
toast.success(
t('models.addSelectedModelsSuccess', { count: models.length }),
);
} catch (err) {
toast.error(t('models.createError') + (err as CustomApiError).msg);
} finally {
setIsSubmitting(false);
}
}
async function handleUpdateModel(
providerUuid: string,
modelId: string,
modelType: ModelType,
name: string,
abilities: string[],
extraArgs: ExtraArg[],
) {
if (!name.trim()) {
toast.error(t('models.modelNameRequired'));
return;
}
setIsSubmitting(true);
try {
const extraArgsObj = convertExtraArgsToObject(extraArgs);
if (modelType === 'llm') {
await httpClient.updateProviderLLMModel(modelId, {
name,
provider_uuid: providerUuid,
abilities,
extra_args: extraArgsObj,
} as never);
} else if (modelType === 'embedding') {
await httpClient.updateProviderEmbeddingModel(modelId, {
name,
provider_uuid: providerUuid,
extra_args: extraArgsObj,
} as never);
} else {
await httpClient.updateProviderRerankModel(modelId, {
name,
provider_uuid: providerUuid,
extra_args: extraArgsObj,
} as never);
}
setEditModelPopoverOpen(null);
loadProviderModels(providerUuid, true);
loadProviders();
} catch (err) {
toast.error(t('models.saveError') + (err as Error).message);
} finally {
setIsSubmitting(false);
}
}
async function handleDeleteModel(
providerUuid: string,
modelId: string,
modelType: ModelType,
) {
try {
if (modelType === 'llm') {
await httpClient.deleteProviderLLMModel(modelId);
} else if (modelType === 'embedding') {
await httpClient.deleteProviderEmbeddingModel(modelId);
} else {
await httpClient.deleteProviderRerankModel(modelId);
}
toast.success(t('models.deleteSuccess'));
loadProviderModels(providerUuid, true);
loadProviders();
} catch (err) {
toast.error(t('models.deleteError') + (err as Error).message);
}
}
async function handleTestModel(
providerUuid: string,
name: string,
modelType: ModelType,
abilities: string[],
extraArgs: ExtraArg[],
) {
setIsTesting(true);
setTestResult(null);
const startTime = Date.now();
try {
const extraArgsObj = convertExtraArgsToObject(extraArgs);
// Get the provider info
const provider = providers.find((p) => p.uuid === providerUuid);
const providerData = {
requester: provider?.requester || '',
base_url: provider?.base_url || '',
api_keys: provider?.api_keys || [],
};
if (modelType === 'llm') {
await httpClient.testLLMModel('_', {
uuid: '',
name,
provider_uuid: '',
provider: providerData,
abilities,
extra_args: extraArgsObj,
} as never);
} else if (modelType === 'embedding') {
await httpClient.testEmbeddingModel('_', {
uuid: '',
name,
provider_uuid: '',
provider: providerData,
extra_args: extraArgsObj,
} as never);
} else {
await httpClient.testRerankModel('_', {
uuid: '',
name,
provider_uuid: '',
provider: providerData,
extra_args: extraArgsObj,
} as never);
}
const duration = Date.now() - startTime;
setTestResult({ success: true, duration });
} catch (err) {
console.error('Failed to test model', err);
toast.error(t('models.testError') + ': ' + (err as CustomApiError).msg);
setTestResult(null);
} finally {
setIsTesting(false);
}
}
function handleFormClose() {
setProviderFormOpen(false);
loadProviders();
// Refresh expanded providers
expandedProviders.forEach((uuid) => loadProviderModels(uuid));
}
function renderProviderCard(
provider: ModelProvider,
isLangBotModels: boolean = false,
) {
return (
<ProviderCard
key={provider.uuid}
provider={provider}
isLangBotModels={isLangBotModels}
isExpanded={expandedProviders.has(provider.uuid)}
isLoading={loadingProviders.has(provider.uuid)}
models={providerModels[provider.uuid]}
accountType={accountType}
spaceCredits={spaceCredits}
addModelPopoverOpen={addModelPopoverOpen}
editModelPopoverOpen={editModelPopoverOpen}
deleteConfirmOpen={deleteConfirmOpen}
onToggle={() => toggleProvider(provider.uuid)}
onEditProvider={() => handleEditProvider(provider.uuid)}
onDeleteProvider={() => handleDeleteProvider(provider.uuid)}
onSpaceLogin={handleSpaceLogin}
onOpenAddModel={() => setAddModelPopoverOpen(provider.uuid)}
onCloseAddModel={() => setAddModelPopoverOpen(null)}
onAddModel={(modelType, name, abilities, extraArgs) =>
handleAddModel(provider.uuid, modelType, name, abilities, extraArgs)
}
onScanModels={(modelType) => handleScanModels(provider.uuid, modelType)}
onAddScannedModels={(modelType, models) =>
handleAddScannedModels(provider.uuid, modelType, models)
}
onOpenEditModel={(modelId) => setEditModelPopoverOpen(modelId)}
onCloseEditModel={() => setEditModelPopoverOpen(null)}
onUpdateModel={(modelId, modelType, name, abilities, extraArgs) =>
handleUpdateModel(
provider.uuid,
modelId,
modelType,
name,
abilities,
extraArgs,
)
}
onOpenDeleteConfirm={(modelId) => setDeleteConfirmOpen(modelId)}
onCloseDeleteConfirm={() => setDeleteConfirmOpen(null)}
onDeleteModel={(modelId, modelType) =>
handleDeleteModel(provider.uuid, modelId, modelType)
}
onTestModel={(name, modelType, abilities, extraArgs) =>
handleTestModel(provider.uuid, name, modelType, abilities, extraArgs)
}
isSubmitting={isSubmitting}
isTesting={isTesting}
testResult={testResult}
onResetTestResult={() => setTestResult(null)}
/>
);
}
return (
<>
<Dialog
open={open}
onOpenChange={(newOpen) => {
if (!newOpen && providerFormOpen) return;
onOpenChange(newOpen);
}}
>
<DialogContent className="overflow-hidden p-0 h-[80vh] flex flex-col !max-w-[37rem]">
<DialogHeader className="px-6 pt-6 pb-0 flex-shrink-0">
<DialogTitle>{t('models.title')}</DialogTitle>
</DialogHeader>
<div className="flex-1 overflow-auto px-6 pb-6 mt-0">
{/* LangBot Models Card */}
{langbotProvider && renderProviderCard(langbotProvider, true)}
{/* Add Provider Button */}
<div className="mb-3 flex justify-between items-center sticky top-0 bg-background py-2 z-10">
<span className="text-sm text-muted-foreground">
{otherProviders.length === 0
? t(
systemInfo.disable_models_service
? 'models.addProviderHintSimple'
: 'models.addProviderHint',
)
: t('models.providerCount', { count: otherProviders.length })}
</span>
<div className="flex gap-2">
<Button
size="sm"
variant="outline"
onClick={handleCreateProvider}
>
<Plus className="h-4 w-4 mr-1" />
{t('models.addProvider')}
</Button>
</div>
</div>
{/* Provider List */}
{otherProviders.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-muted-foreground">
<Boxes className="h-12 w-12 mb-3 opacity-50" />
<p className="text-sm">{t('models.noProviders')}</p>
</div>
) : (
otherProviders.map((p) => renderProviderCard(p))
)}
</div>
</DialogContent>
</Dialog>
<Dialog open={providerFormOpen} onOpenChange={setProviderFormOpen}>
<DialogContent className="w-[600px] p-6">
<DialogHeader>
<DialogTitle>
{editingProviderId
? t('models.editProvider')
: t('models.addProvider')}
</DialogTitle>
</DialogHeader>
<ProviderForm
providerId={editingProviderId || undefined}
onFormSubmit={handleFormClose}
onFormCancel={() => setProviderFormOpen(false)}
/>
</DialogContent>
</Dialog>
</>
);
}