Files
LangBot/web/src/app/home/components/models-dialog/ModelsDialog.tsx
Junyan Chin 8db55267d8 feat(models): support object type in extra parameters (#2158)
Add 'object' as a new value type for model extra parameters so users can
configure nested JSON like {"thinking": {"type": "disabled"}} required by
DeepSeek-v4 non-thinking mode (refs #2157).

UI: add 'Object' option to the type dropdown in ExtraArgsEditor; render a
full-width JSON Textarea (resize-y, monospace) with live JSON validation.
On save, JSON is parsed and rejected if not a plain object.

Also make the model edit and add-model popovers scrollable: cap height at
min(70vh, --radix-popover-content-available-height), stop wheel/touchmove
propagation so the dialog's react-remove-scroll lock doesn't swallow
events, and use overscroll-none to avoid the bottom border seam from
rubber-band overscroll.

i18n updated for all 8 locales.

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 20:44:17 +08:00

613 lines
18 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) {
if (modelType === 'llm') {
await httpClient.createProviderLLMModel({
name: item.model.name,
provider_uuid: providerUuid,
abilities: item.abilities,
extra_args: {},
} as never);
} else {
await httpClient.createProviderEmbeddingModel({
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>
</>
);
}