mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-02 03:55:55 +00:00
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:
@@ -295,7 +295,7 @@ export default function ModelsDialog({
|
|||||||
|
|
||||||
async function handleScanModels(
|
async function handleScanModels(
|
||||||
providerUuid: string,
|
providerUuid: string,
|
||||||
modelType: ModelType,
|
modelType?: ModelType,
|
||||||
): Promise<ScanModelsResult> {
|
): Promise<ScanModelsResult> {
|
||||||
try {
|
try {
|
||||||
const resp = await httpClient.scanProviderModels(providerUuid, modelType);
|
const resp = await httpClient.scanProviderModels(providerUuid, modelType);
|
||||||
@@ -319,19 +319,26 @@ export default function ModelsDialog({
|
|||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
try {
|
try {
|
||||||
for (const item of models) {
|
for (const item of models) {
|
||||||
if (modelType === 'llm') {
|
const effectiveType = item.model.type || modelType;
|
||||||
|
if (effectiveType === 'llm') {
|
||||||
await httpClient.createProviderLLMModel({
|
await httpClient.createProviderLLMModel({
|
||||||
name: item.model.name,
|
name: item.model.name,
|
||||||
provider_uuid: providerUuid,
|
provider_uuid: providerUuid,
|
||||||
abilities: item.abilities,
|
abilities: item.abilities,
|
||||||
extra_args: {},
|
extra_args: {},
|
||||||
} as never);
|
} as never);
|
||||||
} else {
|
} else if (effectiveType === 'embedding') {
|
||||||
await httpClient.createProviderEmbeddingModel({
|
await httpClient.createProviderEmbeddingModel({
|
||||||
name: item.model.name,
|
name: item.model.name,
|
||||||
provider_uuid: providerUuid,
|
provider_uuid: providerUuid,
|
||||||
extra_args: {},
|
extra_args: {},
|
||||||
} as never);
|
} as never);
|
||||||
|
} else {
|
||||||
|
await httpClient.createProviderRerankModel({
|
||||||
|
name: item.model.name,
|
||||||
|
provider_uuid: providerUuid,
|
||||||
|
extra_args: {},
|
||||||
|
} as never);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
setAddModelPopoverOpen(null);
|
setAddModelPopoverOpen(null);
|
||||||
|
|||||||
@@ -73,10 +73,13 @@ export default function ProviderForm({
|
|||||||
>([]);
|
>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadRequesters();
|
async function init() {
|
||||||
if (providerId) {
|
await loadRequesters();
|
||||||
loadProvider(providerId);
|
if (providerId) {
|
||||||
|
await loadProvider(providerId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
init();
|
||||||
}, [providerId]);
|
}, [providerId]);
|
||||||
|
|
||||||
async function loadRequesters() {
|
async function loadRequesters() {
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import {
|
|||||||
Wrench,
|
Wrench,
|
||||||
Check,
|
Check,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Search,
|
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
@@ -33,6 +32,8 @@ import ExtraArgsEditor from './ExtraArgsEditor';
|
|||||||
|
|
||||||
interface AddModelPopoverProps {
|
interface AddModelPopoverProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
|
initialMode?: 'manual' | 'scan';
|
||||||
|
trigger?: React.ReactNode;
|
||||||
onOpen: () => void;
|
onOpen: () => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onAddModel: (
|
onAddModel: (
|
||||||
@@ -41,7 +42,7 @@ interface AddModelPopoverProps {
|
|||||||
abilities: string[],
|
abilities: string[],
|
||||||
extraArgs: ExtraArg[],
|
extraArgs: ExtraArg[],
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
onScanModels: (modelType: ModelType) => Promise<ScanModelsResult>;
|
onScanModels: (modelType?: ModelType) => Promise<ScanModelsResult>;
|
||||||
onAddScannedModels: (
|
onAddScannedModels: (
|
||||||
modelType: ModelType,
|
modelType: ModelType,
|
||||||
models: SelectedScannedModel[],
|
models: SelectedScannedModel[],
|
||||||
@@ -60,6 +61,8 @@ interface AddModelPopoverProps {
|
|||||||
|
|
||||||
export default function AddModelPopover({
|
export default function AddModelPopover({
|
||||||
isOpen,
|
isOpen,
|
||||||
|
initialMode = 'manual',
|
||||||
|
trigger,
|
||||||
onOpen,
|
onOpen,
|
||||||
onClose,
|
onClose,
|
||||||
onAddModel,
|
onAddModel,
|
||||||
@@ -80,9 +83,7 @@ export default function AddModelPopover({
|
|||||||
const [abilities, setAbilities] = useState<string[]>([]);
|
const [abilities, setAbilities] = useState<string[]>([]);
|
||||||
const [extraArgs, setExtraArgs] = useState<ExtraArg[]>([]);
|
const [extraArgs, setExtraArgs] = useState<ExtraArg[]>([]);
|
||||||
const [scanLoading, setScanLoading] = useState(false);
|
const [scanLoading, setScanLoading] = useState(false);
|
||||||
const [scannedModels, setScannedModels] = useState<ScannedProviderModel[]>(
|
const [scannedModels, setScannedModels] = useState<ScannedProviderModel[]>([]);
|
||||||
[],
|
|
||||||
);
|
|
||||||
const [selectedScannedModels, setSelectedScannedModels] = useState<
|
const [selectedScannedModels, setSelectedScannedModels] = useState<
|
||||||
Record<string, SelectedScannedModel>
|
Record<string, SelectedScannedModel>
|
||||||
>({});
|
>({});
|
||||||
@@ -92,7 +93,7 @@ export default function AddModelPopover({
|
|||||||
const wasOpen = prevIsOpenRef.current;
|
const wasOpen = prevIsOpenRef.current;
|
||||||
if (isOpen && !wasOpen) {
|
if (isOpen && !wasOpen) {
|
||||||
setTab('llm');
|
setTab('llm');
|
||||||
setMode('manual');
|
setMode(initialMode);
|
||||||
setName('');
|
setName('');
|
||||||
setAbilities([]);
|
setAbilities([]);
|
||||||
setExtraArgs([]);
|
setExtraArgs([]);
|
||||||
@@ -101,8 +102,12 @@ export default function AddModelPopover({
|
|||||||
setSelectedScannedModels({});
|
setSelectedScannedModels({});
|
||||||
setScanQuery('');
|
setScanQuery('');
|
||||||
onResetTestResult();
|
onResetTestResult();
|
||||||
|
if (initialMode === 'scan') {
|
||||||
|
handleScan();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
prevIsOpenRef.current = isOpen;
|
prevIsOpenRef.current = isOpen;
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [isOpen, onResetTestResult]);
|
}, [isOpen, onResetTestResult]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -122,9 +127,8 @@ export default function AddModelPopover({
|
|||||||
const handleScan = async () => {
|
const handleScan = async () => {
|
||||||
setScanLoading(true);
|
setScanLoading(true);
|
||||||
try {
|
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 = (
|
const debugData = (
|
||||||
result.debug?.response as { data?: Record<string, unknown>[] }
|
result.debug?.response as { data?: Record<string, unknown>[] }
|
||||||
)?.data;
|
)?.data;
|
||||||
@@ -143,9 +147,9 @@ export default function AddModelPopover({
|
|||||||
| undefined;
|
| undefined;
|
||||||
const tools = features?.tools as Record<string, unknown> | undefined;
|
const tools = features?.tools as Record<string, unknown> | undefined;
|
||||||
if (tools?.function_calling === true) {
|
if (tools?.function_calling === true) {
|
||||||
const abilities = new Set(model.abilities || []);
|
const nextAbilities = new Set(model.abilities || []);
|
||||||
abilities.add('func_call');
|
nextAbilities.add('func_call');
|
||||||
model.abilities = [...abilities];
|
model.abilities = [...nextAbilities];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -247,305 +251,321 @@ export default function AddModelPopover({
|
|||||||
onOpenChange={(open) => (open ? onOpen() : onClose())}
|
onOpenChange={(open) => (open ? onOpen() : onClose())}
|
||||||
>
|
>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<Button
|
{trigger || (
|
||||||
variant="ghost"
|
<Button
|
||||||
size="sm"
|
variant="ghost"
|
||||||
className="h-6 text-xs"
|
size="sm"
|
||||||
onClick={(e) => e.stopPropagation()}
|
className="h-6 text-xs"
|
||||||
>
|
onClick={(e) => e.stopPropagation()}
|
||||||
<Plus className="h-3 w-3 mr-1" />
|
>
|
||||||
{t('models.addModel')}
|
<Plus className="h-3 w-3 mr-1" />
|
||||||
</Button>
|
{t('models.addModel')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent
|
<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"
|
className="w-[min(24rem,calc(100vw-2rem))] max-h-[calc(100vh-8rem)] flex flex-col overflow-hidden"
|
||||||
style={{
|
|
||||||
maxHeight: 'min(70vh, var(--radix-popover-content-available-height))',
|
|
||||||
}}
|
|
||||||
align="end"
|
align="end"
|
||||||
side="left"
|
side="bottom"
|
||||||
sideOffset={8}
|
sideOffset={8}
|
||||||
collisionPadding={16}
|
collisionPadding={16}
|
||||||
onWheel={(e) => e.stopPropagation()}
|
|
||||||
onTouchMove={(e) => e.stopPropagation()}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<Tabs value={tab} onValueChange={(v) => setTab(v as ModelType)}>
|
<Tabs
|
||||||
<TabsList className="grid w-full grid-cols-3">
|
value={tab}
|
||||||
<TabsTrigger value="llm">
|
onValueChange={(v) => setTab(v as ModelType)}
|
||||||
<MessageSquareText className="h-4 w-4 mr-1" />
|
className="flex flex-col min-h-0 flex-1"
|
||||||
{t('models.chat')}
|
>
|
||||||
</TabsTrigger>
|
<div className="flex-shrink-0">
|
||||||
<TabsTrigger value="embedding">
|
{!(trigger && initialMode === 'scan') && (
|
||||||
<Cpu className="h-4 w-4 mr-1" />
|
<TabsList className="grid w-full grid-cols-3">
|
||||||
{t('models.embedding')}
|
<TabsTrigger value="llm">
|
||||||
</TabsTrigger>
|
<MessageSquareText className="h-4 w-4 mr-1" />
|
||||||
<TabsTrigger value="rerank">
|
{t('models.chat')}
|
||||||
<ArrowUpDown className="h-4 w-4 mr-1" />
|
</TabsTrigger>
|
||||||
{t('models.rerank')}
|
<TabsTrigger value="embedding">
|
||||||
</TabsTrigger>
|
<Cpu className="h-4 w-4 mr-1" />
|
||||||
</TabsList>
|
{t('models.embedding')}
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="rerank">
|
||||||
|
<ArrowUpDown className="h-4 w-4 mr-1" />
|
||||||
|
{t('models.rerank')}
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<Tabs
|
<div className="overflow-y-auto flex-1 min-h-0">
|
||||||
value={mode}
|
<Tabs
|
||||||
onValueChange={(v) => setMode(v as 'manual' | 'scan')}
|
value={mode}
|
||||||
>
|
onValueChange={(v) => setMode(v as 'manual' | 'scan')}
|
||||||
<TabsList className="grid w-full grid-cols-2 mt-3">
|
>
|
||||||
<TabsTrigger value="manual">{t('models.manualAdd')}</TabsTrigger>
|
{!trigger && (
|
||||||
<TabsTrigger value="scan">{t('models.scanAdd')}</TabsTrigger>
|
<TabsList className="grid w-full grid-cols-2 mt-3">
|
||||||
</TabsList>
|
<TabsTrigger value="manual">
|
||||||
|
{t('models.manualAdd')}
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="scan">{t('models.scanAdd')}</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
)}
|
||||||
|
|
||||||
<TabsContent value="manual" className="mt-3">
|
<TabsContent value="manual" className="mt-3">
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>{t('models.modelName')}</Label>
|
|
||||||
<Input
|
|
||||||
placeholder={t('models.modelName')}
|
|
||||||
value={name}
|
|
||||||
onChange={(e) => setName(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{tab === 'llm' && (
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>{t('models.abilities')}</Label>
|
<Label>{t('models.modelName')}</Label>
|
||||||
<div className="flex gap-4">
|
<Input
|
||||||
<div className="flex items-center gap-2">
|
placeholder={t('models.modelName')}
|
||||||
<Checkbox
|
value={name}
|
||||||
id="add-vision"
|
onChange={(e) => setName(e.target.value)}
|
||||||
checked={abilities.includes('vision')}
|
/>
|
||||||
onCheckedChange={(checked) =>
|
</div>
|
||||||
toggleAbility('vision', checked as boolean)
|
|
||||||
}
|
{tab === 'llm' && (
|
||||||
/>
|
<div className="space-y-2">
|
||||||
<Label htmlFor="add-vision" className="text-sm">
|
<Label>{t('models.abilities')}</Label>
|
||||||
<Eye className="h-3 w-3 inline mr-1" />
|
<div className="flex gap-4">
|
||||||
{t('models.visionAbility')}
|
<div className="flex items-center gap-2">
|
||||||
</Label>
|
<Checkbox
|
||||||
</div>
|
id="add-vision"
|
||||||
<div className="flex items-center gap-2">
|
checked={abilities.includes('vision')}
|
||||||
<Checkbox
|
onCheckedChange={(checked) =>
|
||||||
id="add-func-call"
|
toggleAbility('vision', checked as boolean)
|
||||||
checked={abilities.includes('func_call')}
|
}
|
||||||
onCheckedChange={(checked) =>
|
/>
|
||||||
toggleAbility('func_call', checked as boolean)
|
<Label htmlFor="add-vision" className="text-sm">
|
||||||
}
|
<Eye className="h-3 w-3 inline mr-1" />
|
||||||
/>
|
{t('models.visionAbility')}
|
||||||
<Label htmlFor="add-func-call" className="text-sm">
|
</Label>
|
||||||
<Wrench className="h-3 w-3 inline mr-1" />
|
</div>
|
||||||
{t('models.functionCallAbility')}
|
<div className="flex items-center gap-2">
|
||||||
</Label>
|
<Checkbox
|
||||||
|
id="add-func-call"
|
||||||
|
checked={abilities.includes('func_call')}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
toggleAbility('func_call', checked as boolean)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="add-func-call" className="text-sm">
|
||||||
|
<Wrench className="h-3 w-3 inline mr-1" />
|
||||||
|
{t('models.functionCallAbility')}
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ExtraArgsEditor
|
||||||
|
args={extraArgs}
|
||||||
|
onChange={setExtraArgs}
|
||||||
|
modelType={tab}
|
||||||
|
/>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
className="flex-1"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleAdd}
|
||||||
|
disabled={isSubmitting || isTesting}
|
||||||
|
>
|
||||||
|
{isSubmitting ? t('common.saving') : t('common.add')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
className="flex-1"
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleTest}
|
||||||
|
disabled={isSubmitting || isTesting}
|
||||||
|
>
|
||||||
|
{isTesting ? (
|
||||||
|
t('common.loading')
|
||||||
|
) : testResult?.success ? (
|
||||||
|
<>
|
||||||
|
<Check className="h-4 w-4 mr-1 text-green-500" />
|
||||||
|
{(testResult.duration / 1000).toFixed(1)}s
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
t('common.test')
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="scan" className="space-y-2 mt-0 pt-0">
|
||||||
|
{scanLoading ? (
|
||||||
|
<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">
|
||||||
|
<Input
|
||||||
|
placeholder={t('models.searchScannedModels')}
|
||||||
|
value={scanQuery}
|
||||||
|
onChange={(e) => setScanQuery(e.target.value)}
|
||||||
|
disabled={scannedModels.length === 0}
|
||||||
|
/>
|
||||||
|
{selectableModels.length > 0 && (
|
||||||
|
<div className="flex items-center gap-2 pt-1">
|
||||||
|
<Checkbox
|
||||||
|
id="scan-select-all"
|
||||||
|
checked={allSelected}
|
||||||
|
onCheckedChange={toggleSelectAll}
|
||||||
|
/>
|
||||||
|
<Label
|
||||||
|
htmlFor="scan-select-all"
|
||||||
|
className="text-sm font-medium"
|
||||||
|
>
|
||||||
|
{t('models.selectAll')}
|
||||||
|
<span className="text-muted-foreground ml-1">
|
||||||
|
({Object.keys(selectedScannedModels).length}/
|
||||||
|
{selectableModels.length})
|
||||||
|
</span>
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="h-64 overflow-y-auto overscroll-contain rounded-md border"
|
||||||
|
onWheel={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="p-3 space-y-2">
|
||||||
|
{filteredScannedModels.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{scannedModels.length === 0
|
||||||
|
? t('models.noScannedModels')
|
||||||
|
: t('models.noScannedModelsMatch')}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
filteredScannedModels.map((model) => {
|
||||||
|
const isSelected = Boolean(
|
||||||
|
selectedScannedModels[model.id],
|
||||||
|
);
|
||||||
|
const selectedAbilities =
|
||||||
|
selectedScannedModels[model.id]?.abilities || [];
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={model.id}
|
||||||
|
className="rounded-md border p-3 space-y-2"
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<Checkbox
|
||||||
|
checked={isSelected || model.already_added}
|
||||||
|
disabled={model.already_added}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
toggleScannedModel(
|
||||||
|
model,
|
||||||
|
checked as boolean,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="text-sm font-medium break-all">
|
||||||
|
{model.name}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{model.already_added
|
||||||
|
? t('models.alreadyAdded')
|
||||||
|
: model.type === 'llm'
|
||||||
|
? t('models.chat')
|
||||||
|
: model.type === 'embedding'
|
||||||
|
? t('models.embedding')
|
||||||
|
: t('models.rerank')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{model.type === 'llm' &&
|
||||||
|
isSelected &&
|
||||||
|
!model.already_added && (
|
||||||
|
<div className="flex gap-4 pl-7">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Checkbox
|
||||||
|
id={`scan-vision-${model.id}`}
|
||||||
|
checked={selectedAbilities.includes(
|
||||||
|
'vision',
|
||||||
|
)}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
toggleScannedModelAbility(
|
||||||
|
model.id,
|
||||||
|
'vision',
|
||||||
|
checked as boolean,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Label
|
||||||
|
htmlFor={`scan-vision-${model.id}`}
|
||||||
|
className="text-sm"
|
||||||
|
>
|
||||||
|
<Eye className="h-3 w-3 inline mr-1" />
|
||||||
|
{t('models.visionAbility')}
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Checkbox
|
||||||
|
id={`scan-func-${model.id}`}
|
||||||
|
checked={selectedAbilities.includes(
|
||||||
|
'func_call',
|
||||||
|
)}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
toggleScannedModelAbility(
|
||||||
|
model.id,
|
||||||
|
'func_call',
|
||||||
|
checked as boolean,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Label
|
||||||
|
htmlFor={`scan-func-${model.id}`}
|
||||||
|
className="text-sm"
|
||||||
|
>
|
||||||
|
<Wrench className="h-3 w-3 inline mr-1" />
|
||||||
|
{t('models.functionCallAbility')}
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<ExtraArgsEditor
|
|
||||||
args={extraArgs}
|
|
||||||
onChange={setExtraArgs}
|
|
||||||
modelType={tab}
|
|
||||||
/>
|
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button
|
<Button
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={handleAdd}
|
onClick={handleAddScanned}
|
||||||
disabled={isSubmitting || isTesting}
|
disabled={
|
||||||
|
isSubmitting ||
|
||||||
|
scanLoading ||
|
||||||
|
Object.keys(selectedScannedModels).length === 0
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{isSubmitting ? t('common.saving') : t('common.add')}
|
{isSubmitting
|
||||||
|
? t('common.saving')
|
||||||
|
: t('models.addSelectedModels')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
className="flex-1"
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={handleTest}
|
size="sm"
|
||||||
disabled={isSubmitting || isTesting}
|
onClick={handleScan}
|
||||||
|
disabled={scanLoading || isSubmitting}
|
||||||
>
|
>
|
||||||
{isTesting ? (
|
<RefreshCw
|
||||||
t('common.loading')
|
className={`h-3.5 w-3.5 ${scanLoading ? 'animate-spin' : ''}`}
|
||||||
) : testResult?.success ? (
|
/>
|
||||||
<>
|
|
||||||
<Check className="h-4 w-4 mr-1 text-green-500" />
|
|
||||||
{(testResult.duration / 1000).toFixed(1)}s
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
t('common.test')
|
|
||||||
)}
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</TabsContent>
|
||||||
</TabsContent>
|
</Tabs>
|
||||||
|
</div>
|
||||||
<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}
|
|
||||||
>
|
|
||||||
{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>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>{t('models.scannedModels')}</Label>
|
|
||||||
<Input
|
|
||||||
placeholder={t('models.searchScannedModels')}
|
|
||||||
value={scanQuery}
|
|
||||||
onChange={(e) => setScanQuery(e.target.value)}
|
|
||||||
disabled={scannedModels.length === 0}
|
|
||||||
/>
|
|
||||||
{selectableModels.length > 0 && (
|
|
||||||
<div className="flex items-center gap-2 pt-1">
|
|
||||||
<Checkbox
|
|
||||||
id="scan-select-all"
|
|
||||||
checked={allSelected}
|
|
||||||
onCheckedChange={toggleSelectAll}
|
|
||||||
/>
|
|
||||||
<Label
|
|
||||||
htmlFor="scan-select-all"
|
|
||||||
className="text-sm font-medium"
|
|
||||||
>
|
|
||||||
{t('models.selectAll')}
|
|
||||||
<span className="text-muted-foreground ml-1">
|
|
||||||
({Object.keys(selectedScannedModels).length}/
|
|
||||||
{selectableModels.length})
|
|
||||||
</span>
|
|
||||||
</Label>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
className="h-64 overflow-y-auto overscroll-none rounded-md border"
|
|
||||||
onWheel={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<div className="p-3 space-y-2">
|
|
||||||
{filteredScannedModels.length === 0 ? (
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{scannedModels.length === 0
|
|
||||||
? t('models.noScannedModels')
|
|
||||||
: t('models.noScannedModelsMatch')}
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
filteredScannedModels.map((model) => {
|
|
||||||
const isSelected = Boolean(
|
|
||||||
selectedScannedModels[model.id],
|
|
||||||
);
|
|
||||||
const selectedAbilities =
|
|
||||||
selectedScannedModels[model.id]?.abilities || [];
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={model.id}
|
|
||||||
className="rounded-md border p-3 space-y-2"
|
|
||||||
>
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<Checkbox
|
|
||||||
checked={isSelected || model.already_added}
|
|
||||||
disabled={model.already_added}
|
|
||||||
onCheckedChange={(checked) =>
|
|
||||||
toggleScannedModel(model, checked as boolean)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<div className="text-sm font-medium break-all">
|
|
||||||
{model.name}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-muted-foreground">
|
|
||||||
{model.already_added
|
|
||||||
? t('models.alreadyAdded')
|
|
||||||
: model.type === 'llm'
|
|
||||||
? t('models.chat')
|
|
||||||
: model.type === 'embedding'
|
|
||||||
? t('models.embedding')
|
|
||||||
: t('models.rerank')}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{tab === 'llm' &&
|
|
||||||
isSelected &&
|
|
||||||
!model.already_added && (
|
|
||||||
<div className="flex gap-4 pl-7">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Checkbox
|
|
||||||
id={`scan-vision-${model.id}`}
|
|
||||||
checked={selectedAbilities.includes(
|
|
||||||
'vision',
|
|
||||||
)}
|
|
||||||
onCheckedChange={(checked) =>
|
|
||||||
toggleScannedModelAbility(
|
|
||||||
model.id,
|
|
||||||
'vision',
|
|
||||||
checked as boolean,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Label
|
|
||||||
htmlFor={`scan-vision-${model.id}`}
|
|
||||||
className="text-sm"
|
|
||||||
>
|
|
||||||
<Eye className="h-3 w-3 inline mr-1" />
|
|
||||||
{t('models.visionAbility')}
|
|
||||||
</Label>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Checkbox
|
|
||||||
id={`scan-func-${model.id}`}
|
|
||||||
checked={selectedAbilities.includes(
|
|
||||||
'func_call',
|
|
||||||
)}
|
|
||||||
onCheckedChange={(checked) =>
|
|
||||||
toggleScannedModelAbility(
|
|
||||||
model.id,
|
|
||||||
'func_call',
|
|
||||||
checked as boolean,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Label
|
|
||||||
htmlFor={`scan-func-${model.id}`}
|
|
||||||
className="text-sm"
|
|
||||||
>
|
|
||||||
<Wrench className="h-3 w-3 inline mr-1" />
|
|
||||||
{t('models.functionCallAbility')}
|
|
||||||
</Label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</TabsContent>
|
|
||||||
</Tabs>
|
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
Trash2,
|
Trash2,
|
||||||
Settings,
|
Settings,
|
||||||
LogIn,
|
LogIn,
|
||||||
|
Radar,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { httpClient, systemInfo } from '@/app/infra/http/HttpClient';
|
import { httpClient, systemInfo } from '@/app/infra/http/HttpClient';
|
||||||
import { ModelProvider } from '@/app/infra/entities/api';
|
import { ModelProvider } from '@/app/infra/entities/api';
|
||||||
@@ -60,7 +61,7 @@ interface ProviderCardProps {
|
|||||||
abilities: string[],
|
abilities: string[],
|
||||||
extraArgs: ExtraArg[],
|
extraArgs: ExtraArg[],
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
onScanModels: (modelType: ModelType) => Promise<ScanModelsResult>;
|
onScanModels: (modelType?: ModelType) => Promise<ScanModelsResult>;
|
||||||
onAddScannedModels: (
|
onAddScannedModels: (
|
||||||
modelType: ModelType,
|
modelType: ModelType,
|
||||||
models: SelectedScannedModel[],
|
models: SelectedScannedModel[],
|
||||||
@@ -130,6 +131,7 @@ export default function ProviderCard({
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [deleteProviderConfirmOpen, setDeleteProviderConfirmOpen] =
|
const [deleteProviderConfirmOpen, setDeleteProviderConfirmOpen] =
|
||||||
useState(false);
|
useState(false);
|
||||||
|
const [addModelMode, setAddModelMode] = useState<'manual' | 'scan'>('manual');
|
||||||
|
|
||||||
const canDelete =
|
const canDelete =
|
||||||
!isLangBotModels &&
|
!isLangBotModels &&
|
||||||
@@ -310,19 +312,75 @@ export default function ProviderCard({
|
|||||||
<div />
|
<div />
|
||||||
)}
|
)}
|
||||||
{!isLangBotModels && (
|
{!isLangBotModels && (
|
||||||
<AddModelPopover
|
<div className="flex items-center gap-1">
|
||||||
isOpen={addModelPopoverOpen === provider.uuid}
|
<AddModelPopover
|
||||||
onOpen={onOpenAddModel}
|
isOpen={
|
||||||
onClose={onCloseAddModel}
|
addModelPopoverOpen === provider.uuid &&
|
||||||
onAddModel={onAddModel}
|
addModelMode === 'manual'
|
||||||
onScanModels={onScanModels}
|
}
|
||||||
onAddScannedModels={onAddScannedModels}
|
initialMode="manual"
|
||||||
onTestModel={onTestModel}
|
trigger={
|
||||||
isSubmitting={isSubmitting}
|
<Button
|
||||||
isTesting={isTesting}
|
variant="ghost"
|
||||||
testResult={testResult}
|
size="sm"
|
||||||
onResetTestResult={onResetTestResult}
|
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}
|
||||||
|
onAddScannedModels={onAddScannedModels}
|
||||||
|
onTestModel={onTestModel}
|
||||||
|
isSubmitting={isSubmitting}
|
||||||
|
isTesting={isTesting}
|
||||||
|
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>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ export interface ProviderCardProps {
|
|||||||
abilities: string[],
|
abilities: string[],
|
||||||
extraArgs: ExtraArg[],
|
extraArgs: ExtraArg[],
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
onScanModels: (modelType: ModelType) => Promise<ScanModelsResult>;
|
onScanModels: (modelType?: ModelType) => Promise<ScanModelsResult>;
|
||||||
onAddScannedModels: (
|
onAddScannedModels: (
|
||||||
modelType: ModelType,
|
modelType: ModelType,
|
||||||
models: SelectedScannedModel[],
|
models: SelectedScannedModel[],
|
||||||
|
|||||||
Reference in New Issue
Block a user