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.
This commit is contained in:
Junyan Qin
2026-04-19 20:47:51 +08:00
parent f0061817ea
commit 699545a196
5 changed files with 393 additions and 305 deletions

View File

@@ -295,7 +295,7 @@ export default function ModelsDialog({
async function handleScanModels(
providerUuid: string,
modelType: ModelType,
modelType?: ModelType,
): Promise<ScanModelsResult> {
try {
const resp = await httpClient.scanProviderModels(providerUuid, modelType);
@@ -319,19 +319,26 @@ export default function ModelsDialog({
setIsSubmitting(true);
try {
for (const item of models) {
if (modelType === 'llm') {
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 {
} 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);

View File

@@ -73,10 +73,13 @@ export default function ProviderForm({
>([]);
useEffect(() => {
loadRequesters();
async function init() {
await loadRequesters();
if (providerId) {
loadProvider(providerId);
await loadProvider(providerId);
}
}
init();
}, [providerId]);
async function loadRequesters() {

View File

@@ -8,7 +8,6 @@ import {
Wrench,
Check,
RefreshCw,
Search,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
@@ -33,6 +32,8 @@ import ExtraArgsEditor from './ExtraArgsEditor';
interface AddModelPopoverProps {
isOpen: boolean;
initialMode?: 'manual' | 'scan';
trigger?: React.ReactNode;
onOpen: () => void;
onClose: () => void;
onAddModel: (
@@ -41,7 +42,7 @@ interface AddModelPopoverProps {
abilities: string[],
extraArgs: ExtraArg[],
) => Promise<void>;
onScanModels: (modelType: ModelType) => Promise<ScanModelsResult>;
onScanModels: (modelType?: ModelType) => Promise<ScanModelsResult>;
onAddScannedModels: (
modelType: ModelType,
models: SelectedScannedModel[],
@@ -60,6 +61,8 @@ interface AddModelPopoverProps {
export default function AddModelPopover({
isOpen,
initialMode = 'manual',
trigger,
onOpen,
onClose,
onAddModel,
@@ -80,9 +83,7 @@ export default function AddModelPopover({
const [abilities, setAbilities] = useState<string[]>([]);
const [extraArgs, setExtraArgs] = useState<ExtraArg[]>([]);
const [scanLoading, setScanLoading] = useState(false);
const [scannedModels, setScannedModels] = useState<ScannedProviderModel[]>(
[],
);
const [scannedModels, setScannedModels] = useState<ScannedProviderModel[]>([]);
const [selectedScannedModels, setSelectedScannedModels] = useState<
Record<string, SelectedScannedModel>
>({});
@@ -92,7 +93,7 @@ export default function AddModelPopover({
const wasOpen = prevIsOpenRef.current;
if (isOpen && !wasOpen) {
setTab('llm');
setMode('manual');
setMode(initialMode);
setName('');
setAbilities([]);
setExtraArgs([]);
@@ -101,8 +102,12 @@ export default function AddModelPopover({
setSelectedScannedModels({});
setScanQuery('');
onResetTestResult();
if (initialMode === 'scan') {
handleScan();
}
}
prevIsOpenRef.current = isOpen;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isOpen, onResetTestResult]);
useEffect(() => {
@@ -122,9 +127,8 @@ export default function AddModelPopover({
const handleScan = async () => {
setScanLoading(true);
try {
const result = await onScanModels(tab);
const result = await onScanModels(trigger ? undefined : tab);
// Enrich abilities from debug.response.data (e.g. features.tools.function_calling)
const debugData = (
result.debug?.response as { data?: Record<string, unknown>[] }
)?.data;
@@ -143,9 +147,9 @@ export default function AddModelPopover({
| undefined;
const tools = features?.tools as Record<string, unknown> | undefined;
if (tools?.function_calling === true) {
const abilities = new Set(model.abilities || []);
abilities.add('func_call');
model.abilities = [...abilities];
const nextAbilities = new Set(model.abilities || []);
nextAbilities.add('func_call');
model.abilities = [...nextAbilities];
}
}
}
@@ -247,6 +251,7 @@ export default function AddModelPopover({
onOpenChange={(open) => (open ? onOpen() : onClose())}
>
<PopoverTrigger asChild>
{trigger || (
<Button
variant="ghost"
size="sm"
@@ -256,21 +261,23 @@ export default function AddModelPopover({
<Plus className="h-3 w-3 mr-1" />
{t('models.addModel')}
</Button>
)}
</PopoverTrigger>
<PopoverContent
className="w-[min(24rem,calc(100vw-2rem))] max-h-[70vh] overflow-y-auto overscroll-none focus:outline-none focus-visible:outline-none focus-visible:ring-0"
style={{
maxHeight: 'min(70vh, var(--radix-popover-content-available-height))',
}}
className="w-[min(24rem,calc(100vw-2rem))] max-h-[calc(100vh-8rem)] flex flex-col overflow-hidden"
align="end"
side="left"
side="bottom"
sideOffset={8}
collisionPadding={16}
onWheel={(e) => e.stopPropagation()}
onTouchMove={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
>
<Tabs value={tab} onValueChange={(v) => setTab(v as ModelType)}>
<Tabs
value={tab}
onValueChange={(v) => setTab(v as ModelType)}
className="flex flex-col min-h-0 flex-1"
>
<div className="flex-shrink-0">
{!(trigger && initialMode === 'scan') && (
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="llm">
<MessageSquareText className="h-4 w-4 mr-1" />
@@ -285,15 +292,22 @@ export default function AddModelPopover({
{t('models.rerank')}
</TabsTrigger>
</TabsList>
)}
</div>
<div className="overflow-y-auto flex-1 min-h-0">
<Tabs
value={mode}
onValueChange={(v) => setMode(v as 'manual' | 'scan')}
>
{!trigger && (
<TabsList className="grid w-full grid-cols-2 mt-3">
<TabsTrigger value="manual">{t('models.manualAdd')}</TabsTrigger>
<TabsTrigger value="manual">
{t('models.manualAdd')}
</TabsTrigger>
<TabsTrigger value="scan">{t('models.scanAdd')}</TabsTrigger>
</TabsList>
)}
<TabsContent value="manual" className="mt-3">
<div className="space-y-3">
@@ -376,44 +390,17 @@ export default function AddModelPopover({
</div>
</TabsContent>
<TabsContent value="scan" className="space-y-3 mt-3">
<div className="text-xs text-muted-foreground">
{t('models.scanModelsHint')}
</div>
<div className="flex gap-2">
<Button
className="flex-1"
size="sm"
variant="outline"
onClick={handleScan}
disabled={scanLoading || isSubmitting}
>
<TabsContent value="scan" className="space-y-2 mt-0 pt-0">
{scanLoading ? (
<RefreshCw className="h-4 w-4 mr-1 animate-spin" />
) : (
<Search className="h-4 w-4 mr-1" />
)}
{t('models.scanModels')}
</Button>
<Button
className="flex-1"
size="sm"
onClick={handleAddScanned}
disabled={
isSubmitting ||
scanLoading ||
Object.keys(selectedScannedModels).length === 0
}
>
{isSubmitting
? t('common.saving')
: t('models.addSelectedModels')}
</Button>
<div className="flex items-center justify-center py-4">
<RefreshCw className="h-4 w-4 mr-2 animate-spin text-muted-foreground" />
<span className="text-sm text-muted-foreground">
{t('models.scanModels')}...
</span>
</div>
) : (
<>
<div className="space-y-2">
<Label>{t('models.scannedModels')}</Label>
<Input
placeholder={t('models.searchScannedModels')}
value={scanQuery}
@@ -442,7 +429,7 @@ export default function AddModelPopover({
</div>
<div
className="h-64 overflow-y-auto overscroll-none rounded-md border"
className="h-64 overflow-y-auto overscroll-contain rounded-md border"
onWheel={(e) => e.stopPropagation()}
>
<div className="p-3 space-y-2">
@@ -469,7 +456,10 @@ export default function AddModelPopover({
checked={isSelected || model.already_added}
disabled={model.already_added}
onCheckedChange={(checked) =>
toggleScannedModel(model, checked as boolean)
toggleScannedModel(
model,
checked as boolean,
)
}
/>
<div className="min-w-0 flex-1">
@@ -488,7 +478,7 @@ export default function AddModelPopover({
</div>
</div>
{tab === 'llm' &&
{model.type === 'llm' &&
isSelected &&
!model.already_added && (
<div className="flex gap-4 pl-7">
@@ -544,8 +534,38 @@ export default function AddModelPopover({
)}
</div>
</div>
</>
)}
<div className="flex gap-2">
<Button
className="flex-1"
size="sm"
onClick={handleAddScanned}
disabled={
isSubmitting ||
scanLoading ||
Object.keys(selectedScannedModels).length === 0
}
>
{isSubmitting
? t('common.saving')
: t('models.addSelectedModels')}
</Button>
<Button
variant="outline"
size="sm"
onClick={handleScan}
disabled={scanLoading || isSubmitting}
>
<RefreshCw
className={`h-3.5 w-3.5 ${scanLoading ? 'animate-spin' : ''}`}
/>
</Button>
</div>
</TabsContent>
</Tabs>
</div>
</Tabs>
</PopoverContent>
</Popover>

View File

@@ -6,6 +6,7 @@ import {
Trash2,
Settings,
LogIn,
Radar,
} from 'lucide-react';
import { httpClient, systemInfo } from '@/app/infra/http/HttpClient';
import { ModelProvider } from '@/app/infra/entities/api';
@@ -60,7 +61,7 @@ interface ProviderCardProps {
abilities: string[],
extraArgs: ExtraArg[],
) => Promise<void>;
onScanModels: (modelType: ModelType) => Promise<ScanModelsResult>;
onScanModels: (modelType?: ModelType) => Promise<ScanModelsResult>;
onAddScannedModels: (
modelType: ModelType,
models: SelectedScannedModel[],
@@ -130,6 +131,7 @@ export default function ProviderCard({
const { t } = useTranslation();
const [deleteProviderConfirmOpen, setDeleteProviderConfirmOpen] =
useState(false);
const [addModelMode, setAddModelMode] = useState<'manual' | 'scan'>('manual');
const canDelete =
!isLangBotModels &&
@@ -310,9 +312,31 @@ export default function ProviderCard({
<div />
)}
{!isLangBotModels && (
<div className="flex items-center gap-1">
<AddModelPopover
isOpen={addModelPopoverOpen === provider.uuid}
onOpen={onOpenAddModel}
isOpen={
addModelPopoverOpen === provider.uuid &&
addModelMode === 'manual'
}
initialMode="manual"
trigger={
<Button
variant="ghost"
size="sm"
className="h-6 text-xs"
onClick={(e) => {
e.stopPropagation();
setAddModelMode('manual');
}}
>
<Plus className="h-3 w-3 mr-1" />
{t('models.addModel')}
</Button>
}
onOpen={() => {
setAddModelMode('manual');
onOpenAddModel();
}}
onClose={onCloseAddModel}
onAddModel={onAddModel}
onScanModels={onScanModels}
@@ -323,6 +347,40 @@ export default function ProviderCard({
testResult={testResult}
onResetTestResult={onResetTestResult}
/>
<AddModelPopover
isOpen={
addModelPopoverOpen === provider.uuid &&
addModelMode === 'scan'
}
initialMode="scan"
trigger={
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={(e) => {
e.stopPropagation();
setAddModelMode('scan');
}}
>
<Radar className="h-3 w-3" />
</Button>
}
onOpen={() => {
setAddModelMode('scan');
onOpenAddModel();
}}
onClose={onCloseAddModel}
onAddModel={onAddModel}
onScanModels={onScanModels}
onAddScannedModels={onAddScannedModels}
onTestModel={onTestModel}
isSubmitting={isSubmitting}
isTesting={isTesting}
testResult={testResult}
onResetTestResult={onResetTestResult}
/>
</div>
)}
</div>
</CardHeader>

View File

@@ -90,7 +90,7 @@ export interface ProviderCardProps {
abilities: string[],
extraArgs: ExtraArg[],
) => Promise<void>;
onScanModels: (modelType: ModelType) => Promise<ScanModelsResult>;
onScanModels: (modelType?: ModelType) => Promise<ScanModelsResult>;
onAddScannedModels: (
modelType: ModelType,
models: SelectedScannedModel[],