mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-08-09 04:40:57 +00:00
e1ac5e0fc8
* Document multi-tenant workspace architecture * Add OSS and commercial workspace boundaries * docs: redesign multi-tenant workspace architecture * feat(tenancy): implement workspace isolation * docs(tenancy): record verification evidence * docs(tenancy): revise single-instance SaaS topology * docs(tenancy): refine architecture options * docs: finalize cloud v2 multi-tenant decisions * feat(tenancy): establish cloud isolation foundations * feat(tenancy): harden shared cloud runtime boundaries * docs(tenancy): record final isolation verification * fix(tenancy): close isolation and permission gaps * docs(tenancy): record final isolation verification * feat(tenancy): connect cloud workspace control plane * fix(build): install git for pinned SDK * docs(cloud): update control plane verification * chore: update multi-tenant SDK pin * fix(cloud): skip legacy model sync during startup * test(cloud): preserve minimal model manager fixtures * fix(cloud): preserve authenticated account context * fix(cloud): reuse authenticated account for user info * feat(cloud): complete Workspace settings navigation * test(web): cover Workspace dropdown menu * feat(web): place workspace controls in sidebar * refactor(web): streamline workspace controls * style(web): format workspace layout test * fix(cloud): surface runtime and workspace plan status * fix(plugin): keep runtime identity stable across restarts * fix(ui): widen and center workspace switcher * fix(ui): hide roles from workspace switcher * fix(ui): align workspace switcher with sidebar entries * feat(workspace): add in-product collaboration and direct Cloud launch * style: format collaboration changes * fix(workspace): bind collaboration APIs to tenant UoW * fix(cloud): preserve Core-owned collaboration state * test(cloud): require Space identity for invite registration * feat(cloud): complete secure invitation experience * style(web): format invitation flows * fix(cloud): recover box runtime without unscoped skill reload * feat(oss): enforce invitation account and owner billing flows * style: format OSS account service * test(oss): cover invitation logout handoff * fix(oss): resolve workspace owner in scoped session * feat(cloud): harden multi-tenant runtime resources * fix(cloud): bound runtime restart storms * fix(cloud): eliminate periodic runtime CPU spikes * fix(cloud): enforce instance capacity ceilings * fix(cloud): scope public login capability discovery * fix(cloud): bound tenant maintenance and monitoring work * fix(runtime): bound tenant resource amplification * fix(deps): pin green multi-tenant plugin SDK * fix(cloud): handle unavailable skill capability * fix(security): require authentication for image file endpoint (H-2) - Changed /api/v1/files/image from AuthType.NONE to USER_TOKEN_OR_API_KEY - Added Permission.RESOURCE_VIEW requirement - Prevents unauthenticated cross-tenant file access via leaked keys - Fixes HIGH severity finding from multi-tenant security review docs: add comprehensive database migration guide - Complete migration steps for OSS → multi-tenant - Backup, execution, verification procedures - Rollback scenarios and recovery plans - Performance tuning recommendations * test: add comprehensive cross-tenant isolation tests Added 7 critical test scenarios for multi-tenant boundaries: - Cross-tenant bot access prevention - Viewer role read-only enforcement - Removed member immediate access revocation - Model provider credential isolation - WebSocket message isolation - Invitation token workspace scoping - Multi-workspace context validation These tests address P0-2 coverage gaps for: - workspaces.py (membership & invitation flows) - user.py (authentication & authorization) - websocket_chat.py (real-time isolation) - plugins.py (resource access control) docs: finalize database migration guide * fix(security): resolve M-1, M-2, M-3 security findings M-1: WebSocket authorization TOCTOU race (FIXED) - Changed _revalidate_websocket_authorization to return RequestContext - Ensures validated context is used immediately without race window - Prevents removed members from sending messages during revalidation gap M-2: Model Manager cache workspace isolation (VERIFIED) - Confirmed _CacheKey already uses 4-tuple: (instance, workspace, generation, resource) - Cache is properly scoped per workspace, no cross-tenant leakage possible - No code change needed, documented as working correctly M-3: Invitation lock workspace scoping (FIXED) - Changed lock key from token_digest to workspace_uuid:token_digest - Prevents DoS where attacker locks token in Workspace A to block Workspace B - Locks now isolated per workspace All MEDIUM severity findings from security review now resolved. * fix(cloud): unblock tenant CI and enforce knowledge quotas * fix(tenancy): scope rerank model sync --------- Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
663 lines
20 KiB
TypeScript
663 lines
20 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';
|
|
import { PanelBody } from '../settings-dialog/panel-layout';
|
|
import { useCurrentWorkspace } from '@/app/infra/http';
|
|
import type { WorkspaceSpaceBilling } from '@/app/infra/entities/workspace';
|
|
|
|
interface ModelsPanelProps {
|
|
// True when this panel is the active section and the dialog is open.
|
|
active: boolean;
|
|
// Notify parent when a nested modal (provider form) should block outer-close.
|
|
onBlockingChange?: (blocking: 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;
|
|
}
|
|
|
|
function parseContextLength(
|
|
value: number | null | undefined,
|
|
invalidMessage: string,
|
|
): number | null {
|
|
if (value === undefined || value === null) return null;
|
|
if (!Number.isInteger(value) || value <= 0) {
|
|
throw new Error(invalidMessage);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
export default function ModelsPanel({
|
|
active,
|
|
onBlockingChange,
|
|
}: ModelsPanelProps) {
|
|
const { t } = useTranslation();
|
|
const currentWorkspace = useCurrentWorkspace();
|
|
const canManage =
|
|
currentWorkspace?.permissions.includes('provider_secret.manage') ?? false;
|
|
|
|
const [providers, setProviders] = useState<ModelProvider[]>([]);
|
|
const [spaceBilling, setSpaceBilling] =
|
|
useState<WorkspaceSpaceBilling | 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,
|
|
);
|
|
|
|
// Map of requester name -> support_type[] (from requester manifests),
|
|
// used to restrict which model-type tabs are shown when adding models.
|
|
const [requesterSupportTypes, setRequesterSupportTypes] = useState<
|
|
Record<string, string[]>
|
|
>({});
|
|
|
|
// 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 (active) {
|
|
loadWorkspaceBilling();
|
|
loadProviders();
|
|
loadRequesterSupportTypes();
|
|
}
|
|
}, [active]);
|
|
|
|
// Notify parent of blocking state so it can guard outer-close.
|
|
useEffect(() => {
|
|
onBlockingChange?.(providerFormOpen);
|
|
}, [providerFormOpen, onBlockingChange]);
|
|
|
|
// 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 loadWorkspaceBilling() {
|
|
try {
|
|
setSpaceBilling(await httpClient.getWorkspaceSpaceBilling());
|
|
} catch {
|
|
setSpaceBilling(null);
|
|
}
|
|
}
|
|
|
|
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 loadRequesterSupportTypes() {
|
|
try {
|
|
const resp = await httpClient.getProviderRequesters();
|
|
const map: Record<string, string[]> = {};
|
|
for (const r of resp.requesters) {
|
|
map[r.name] = r.spec?.support_type ?? [];
|
|
}
|
|
setRequesterSupportTypes(map);
|
|
} catch (err) {
|
|
console.error('Failed to load requester support types', err);
|
|
}
|
|
}
|
|
|
|
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 currentOrigin = window.location.origin;
|
|
const redirectUri = `${currentOrigin}/auth/space/callback?mode=bind`;
|
|
const response = await httpClient.getSpaceBindAuthorizeUrl(redirectUri);
|
|
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[],
|
|
contextLength?: number | null,
|
|
) {
|
|
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,
|
|
context_length: parseContextLength(
|
|
contextLength,
|
|
t('models.contextLengthInvalid'),
|
|
),
|
|
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,
|
|
context_length: item.model.context_length ?? null,
|
|
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[],
|
|
contextLength?: number | null,
|
|
) {
|
|
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,
|
|
context_length: parseContextLength(
|
|
contextLength,
|
|
t('models.contextLengthInvalid'),
|
|
),
|
|
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}
|
|
canManage={canManage}
|
|
isLangBotModels={isLangBotModels}
|
|
supportTypes={requesterSupportTypes[provider.requester]}
|
|
isExpanded={expandedProviders.has(provider.uuid)}
|
|
isLoading={loadingProviders.has(provider.uuid)}
|
|
models={providerModels[provider.uuid]}
|
|
isWorkspaceOwner={currentWorkspace?.membership.role === 'owner'}
|
|
ownerSpaceBound={spaceBilling?.owner_space_bound ?? false}
|
|
spaceCredits={spaceBilling?.credits ?? null}
|
|
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, contextLength) =>
|
|
handleAddModel(
|
|
provider.uuid,
|
|
modelType,
|
|
name,
|
|
abilities,
|
|
extraArgs,
|
|
contextLength,
|
|
)
|
|
}
|
|
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,
|
|
contextLength,
|
|
) =>
|
|
handleUpdateModel(
|
|
provider.uuid,
|
|
modelId,
|
|
modelType,
|
|
name,
|
|
abilities,
|
|
extraArgs,
|
|
contextLength,
|
|
)
|
|
}
|
|
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 (
|
|
<>
|
|
<PanelBody>
|
|
{/* LangBot Models (Space) provider card is intentionally pinned to the
|
|
top, above the "add custom provider" action row. */}
|
|
{langbotProvider && renderProviderCard(langbotProvider, true)}
|
|
|
|
{/* Add-provider row: stays below the pinned card by design. */}
|
|
<div className="mb-3 flex items-center justify-between gap-3">
|
|
<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>
|
|
{canManage && (
|
|
<Button size="sm" variant="outline" onClick={handleCreateProvider}>
|
|
<Plus className="h-4 w-4 mr-1" />
|
|
{t('models.addProvider')}
|
|
</Button>
|
|
)}
|
|
</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))
|
|
)}
|
|
</PanelBody>
|
|
|
|
<Dialog open={providerFormOpen} onOpenChange={setProviderFormOpen}>
|
|
<DialogContent className="w-full max-w-[calc(100%-2rem)] p-4 sm:max-w-[600px] sm:p-6">
|
|
<DialogHeader>
|
|
<DialogTitle>
|
|
{editingProviderId
|
|
? t('models.editProvider')
|
|
: t('models.addProvider')}
|
|
</DialogTitle>
|
|
</DialogHeader>
|
|
<ProviderForm
|
|
providerId={editingProviderId || undefined}
|
|
onFormSubmit={handleFormClose}
|
|
onFormCancel={() => setProviderFormOpen(false)}
|
|
/>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</>
|
|
);
|
|
}
|