mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-09 07:16:04 +00:00
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.
620 lines
19 KiB
TypeScript
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>
|
|
</>
|
|
);
|
|
}
|