From 42caae1bcf01d2d75c8c2278484e011f7a745e52 Mon Sep 17 00:00:00 2001 From: Junyan Chin Date: Sun, 22 Feb 2026 04:25:45 -0500 Subject: [PATCH] feat: Implement extension and bot limitations across services and UI (#1991) - Added checks for maximum allowed extensions, bots, and pipelines in the backend services (PluginsRouterGroup, BotService, MCPService, PipelineService). - Updated system configuration to include limitation settings for max_bots, max_pipelines, and max_extensions. - Enhanced frontend components to handle limitations, providing user feedback when limits are reached. - Added internationalization support for limitation messages in English, Japanese, Simplified Chinese, and Traditional Chinese. --- .../pkg/api/http/controller/groups/plugins.py | 24 +++++++++++++ .../pkg/api/http/controller/groups/system.py | 2 ++ src/langbot/pkg/api/http/service/bot.py | 8 +++++ src/langbot/pkg/api/http/service/mcp.py | 10 ++++++ src/langbot/pkg/api/http/service/pipeline.py | 16 +++++++++ src/langbot/pkg/core/stages/load_config.py | 2 ++ src/langbot/templates/config.yaml | 5 +++ web/src/app/home/bots/page.tsx | 6 ++++ web/src/app/home/pipelines/page.tsx | 6 ++++ .../mcp-server/mcp-form/MCPFormDialog.tsx | 5 ++- web/src/app/home/plugins/page.tsx | 36 ++++++++++++++++--- web/src/app/infra/entities/api/index.ts | 8 +++++ web/src/app/infra/http/index.ts | 6 ++++ web/src/i18n/locales/en-US.ts | 8 +++++ web/src/i18n/locales/ja-JP.ts | 8 +++++ web/src/i18n/locales/zh-Hans.ts | 8 +++++ web/src/i18n/locales/zh-Hant.ts | 8 +++++ 17 files changed, 161 insertions(+), 5 deletions(-) diff --git a/src/langbot/pkg/api/http/controller/groups/plugins.py b/src/langbot/pkg/api/http/controller/groups/plugins.py index 8ebd2f43..f432212e 100644 --- a/src/langbot/pkg/api/http/controller/groups/plugins.py +++ b/src/langbot/pkg/api/http/controller/groups/plugins.py @@ -14,6 +14,18 @@ from langbot_plugin.runtime.plugin.mgr import PluginInstallSource @group.group_class('plugins', '/api/v1/plugins') class PluginsRouterGroup(group.RouterGroup): + async def _check_extensions_limit(self) -> str | None: + """Check if extensions limit is reached. Returns error response if limit exceeded, None otherwise.""" + limitation = self.ap.instance_config.data.get('system', {}).get('limitation', {}) + max_extensions = limitation.get('max_extensions', -1) + if max_extensions >= 0: + plugins = await self.ap.plugin_connector.list_plugins() + mcp_servers = await self.ap.mcp_service.get_mcp_servers() + total_extensions = len(plugins) + len(mcp_servers) + if total_extensions >= max_extensions: + return self.http_status(400, -1, f'Maximum number of extensions ({max_extensions}) reached') + return None + async def initialize(self) -> None: @self.route('', methods=['GET'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY) async def _() -> str: @@ -239,6 +251,10 @@ class PluginsRouterGroup(group.RouterGroup): @self.route('/install/github', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY) async def _() -> str: """Install plugin from GitHub release asset""" + limit_error = await self._check_extensions_limit() + if limit_error is not None: + return limit_error + data = await quart.request.json asset_url = data.get('asset_url', '') owner = data.get('owner', '') @@ -273,6 +289,10 @@ class PluginsRouterGroup(group.RouterGroup): auth_type=group.AuthType.USER_TOKEN_OR_API_KEY, ) async def _() -> str: + limit_error = await self._check_extensions_limit() + if limit_error is not None: + return limit_error + data = await quart.request.json ctx = taskmgr.TaskContext.new() @@ -288,6 +308,10 @@ class PluginsRouterGroup(group.RouterGroup): @self.route('/install/local', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY) async def _() -> str: + limit_error = await self._check_extensions_limit() + if limit_error is not None: + return limit_error + file = (await quart.request.files).get('file') if file is None: return self.http_status(400, -1, 'file is required') diff --git a/src/langbot/pkg/api/http/controller/groups/system.py b/src/langbot/pkg/api/http/controller/groups/system.py index e36707a5..4e606dd5 100644 --- a/src/langbot/pkg/api/http/controller/groups/system.py +++ b/src/langbot/pkg/api/http/controller/groups/system.py @@ -13,6 +13,7 @@ class SystemRouterGroup(group.RouterGroup): data={ 'version': constants.semantic_version, 'debug': constants.debug_mode, + 'edition': constants.edition, 'enable_marketplace': self.ap.instance_config.data.get('plugin', {}).get( 'enable_marketplace', True ), @@ -25,6 +26,7 @@ class SystemRouterGroup(group.RouterGroup): 'disable_models_service': self.ap.instance_config.data.get('space', {}).get( 'disable_models_service', False ), + 'limitation': self.ap.instance_config.data.get('system', {}).get('limitation', {}), } ) diff --git a/src/langbot/pkg/api/http/service/bot.py b/src/langbot/pkg/api/http/service/bot.py index 0632935b..71658cc0 100644 --- a/src/langbot/pkg/api/http/service/bot.py +++ b/src/langbot/pkg/api/http/service/bot.py @@ -83,6 +83,14 @@ class BotService: async def create_bot(self, bot_data: dict) -> str: """Create bot""" + # Check limitation + limitation = self.ap.instance_config.data.get('system', {}).get('limitation', {}) + max_bots = limitation.get('max_bots', -1) + if max_bots >= 0: + existing_bots = await self.get_bots() + if len(existing_bots) >= max_bots: + raise ValueError(f'Maximum number of bots ({max_bots}) reached') + # TODO: 检查配置信息格式 bot_data['uuid'] = str(uuid.uuid4()) diff --git a/src/langbot/pkg/api/http/service/mcp.py b/src/langbot/pkg/api/http/service/mcp.py index a1b034d0..aadbcf11 100644 --- a/src/langbot/pkg/api/http/service/mcp.py +++ b/src/langbot/pkg/api/http/service/mcp.py @@ -38,6 +38,16 @@ class MCPService: return serialized_servers async def create_mcp_server(self, server_data: dict) -> str: + # Check limitation (extensions = MCP servers + plugins) + limitation = self.ap.instance_config.data.get('system', {}).get('limitation', {}) + max_extensions = limitation.get('max_extensions', -1) + if max_extensions >= 0: + existing_mcp_servers = await self.get_mcp_servers() + plugins = await self.ap.plugin_connector.list_plugins() + total_extensions = len(existing_mcp_servers) + len(plugins) + if total_extensions >= max_extensions: + raise ValueError(f'Maximum number of extensions ({max_extensions}) reached') + server_data['uuid'] = str(uuid.uuid4()) await self.ap.persistence_mgr.execute_async(sqlalchemy.insert(persistence_mcp.MCPServer).values(server_data)) diff --git a/src/langbot/pkg/api/http/service/pipeline.py b/src/langbot/pkg/api/http/service/pipeline.py index 00aece68..ad75ffe7 100644 --- a/src/langbot/pkg/api/http/service/pipeline.py +++ b/src/langbot/pkg/api/http/service/pipeline.py @@ -76,6 +76,14 @@ class PipelineService: async def create_pipeline(self, pipeline_data: dict, default: bool = False) -> str: from ....utils import paths as path_utils + # Check limitation + limitation = self.ap.instance_config.data.get('system', {}).get('limitation', {}) + max_pipelines = limitation.get('max_pipelines', -1) + if max_pipelines >= 0: + existing_pipelines = await self.get_pipelines() + if len(existing_pipelines) >= max_pipelines: + raise ValueError(f'Maximum number of pipelines ({max_pipelines}) reached') + pipeline_data['uuid'] = str(uuid.uuid4()) pipeline_data['for_version'] = self.ap.ver_mgr.get_current_version() pipeline_data['stages'] = default_stage_order.copy() @@ -153,6 +161,14 @@ class PipelineService: async def copy_pipeline(self, pipeline_uuid: str) -> str: """Copy a pipeline with all its configurations""" + # Check limitation + limitation = self.ap.instance_config.data.get('system', {}).get('limitation', {}) + max_pipelines = limitation.get('max_pipelines', -1) + if max_pipelines >= 0: + existing_pipelines = await self.get_pipelines() + if len(existing_pipelines) >= max_pipelines: + raise ValueError(f'Maximum number of pipelines ({max_pipelines}) reached') + # Get the original pipeline result = await self.ap.persistence_mgr.execute_async( sqlalchemy.select(persistence_pipeline.LegacyPipeline).where( diff --git a/src/langbot/pkg/core/stages/load_config.py b/src/langbot/pkg/core/stages/load_config.py index ccf816bd..43933def 100644 --- a/src/langbot/pkg/core/stages/load_config.py +++ b/src/langbot/pkg/core/stages/load_config.py @@ -156,8 +156,10 @@ class LoadConfigStage(stage.BootingStage): ) constants.instance_id = ap.instance_id.data['instance_id'] + constants.edition = ap.instance_config.data.get('system', {}).get('edition', 'community') print(f'LangBot instance id: {constants.instance_id}') + print(f'LangBot edition: {constants.edition}') await ap.instance_id.dump_config() diff --git a/src/langbot/templates/config.yaml b/src/langbot/templates/config.yaml index bd4bd180..b8f541ab 100644 --- a/src/langbot/templates/config.yaml +++ b/src/langbot/templates/config.yaml @@ -15,8 +15,13 @@ proxy: http: '' https: '' system: + edition: community recovery_key: '' allow_modify_login_info: true + limitation: + max_bots: -1 + max_pipelines: -1 + max_extensions: -1 jwt: expire: 604800 secret: '' diff --git a/web/src/app/home/bots/page.tsx b/web/src/app/home/bots/page.tsx index 1550af53..4fc25d35 100644 --- a/web/src/app/home/bots/page.tsx +++ b/web/src/app/home/bots/page.tsx @@ -12,6 +12,7 @@ import { useTranslation } from 'react-i18next'; import { extractI18nObject } from '@/i18n/I18nProvider'; import BotDetailDialog from '@/app/home/bots/BotDetailDialog'; import { CustomApiError } from '@/app/infra/entities/common'; +import { systemInfo } from '@/app/infra/http'; export default function BotConfigPage() { const { t } = useTranslation(); @@ -60,6 +61,11 @@ export default function BotConfigPage() { } function handleCreateBotClick() { + const maxBots = systemInfo.limitation?.max_bots ?? -1; + if (maxBots >= 0 && botList.length >= maxBots) { + toast.error(t('limitation.maxBotsReached', { max: maxBots })); + return; + } setSelectedBotId(''); setDetailDialogOpen(true); } diff --git a/web/src/app/home/pipelines/page.tsx b/web/src/app/home/pipelines/page.tsx index a6696687..547c814e 100644 --- a/web/src/app/home/pipelines/page.tsx +++ b/web/src/app/home/pipelines/page.tsx @@ -15,6 +15,7 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select'; +import { systemInfo } from '@/app/infra/http'; export default function PluginConfigPage() { const { t } = useTranslation(); @@ -87,6 +88,11 @@ export default function PluginConfigPage() { }; const handleCreateNew = () => { + const maxPipelines = systemInfo.limitation?.max_pipelines ?? -1; + if (maxPipelines >= 0 && pipelineList.length >= maxPipelines) { + toast.error(t('limitation.maxPipelinesReached', { max: maxPipelines })); + return; + } setIsEditForm(false); setSelectedPipelineId(''); setDialogOpen(true); diff --git a/web/src/app/home/plugins/mcp-server/mcp-form/MCPFormDialog.tsx b/web/src/app/home/plugins/mcp-server/mcp-form/MCPFormDialog.tsx index f1d647a8..9675e0ba 100644 --- a/web/src/app/home/plugins/mcp-server/mcp-form/MCPFormDialog.tsx +++ b/web/src/app/home/plugins/mcp-server/mcp-form/MCPFormDialog.tsx @@ -453,7 +453,10 @@ export default function MCPFormDialog({ onSuccess?.(); } catch (error) { console.error('Failed to save MCP server:', error); - toast.error(isEditMode ? t('mcp.updateFailed') : t('mcp.createFailed')); + const errMsg = (error as CustomApiError).msg || ''; + toast.error( + (isEditMode ? t('mcp.updateFailed') : t('mcp.createFailed')) + errMsg, + ); } } diff --git a/web/src/app/home/plugins/page.tsx b/web/src/app/home/plugins/page.tsx index f51e21bd..a951e34c 100644 --- a/web/src/app/home/plugins/page.tsx +++ b/web/src/app/home/plugins/page.tsx @@ -186,6 +186,28 @@ export default function PluginConfigPage() { setFetchingAssets(false); } + async function checkExtensionsLimit(): Promise { + const maxExtensions = systemInfo.limitation?.max_extensions ?? -1; + if (maxExtensions < 0) return true; + try { + const [pluginsResp, mcpResp] = await Promise.all([ + httpClient.getPlugins(), + httpClient.getMCPServers(), + ]); + const total = + (pluginsResp.plugins?.length ?? 0) + (mcpResp.servers?.length ?? 0); + if (total >= maxExtensions) { + toast.error( + t('limitation.maxExtensionsReached', { max: maxExtensions }), + ); + return false; + } + } catch { + // If we can't check, let backend handle it + } + return true; + } + async function fetchGithubReleases() { if (!githubURL.trim()) { toast.error(t('plugins.enterRepoUrl')); @@ -328,6 +350,8 @@ export default function PluginConfigPage() { return; } + if (!(await checkExtensionsLimit())) return; + setModalOpen(true); setPluginInstallStatus(PluginInstallStatus.INSTALLING); setInstallError(null); @@ -336,7 +360,8 @@ export default function PluginConfigPage() { [t, pluginSystemStatus, installPlugin], ); - const handleFileSelect = useCallback(() => { + const handleFileSelect = useCallback(async () => { + if (!(await checkExtensionsLimit())) return; if (fileInputRef.current) { fileInputRef.current.click(); } @@ -633,7 +658,8 @@ export default function PluginConfigPage() { {activeTab === 'mcp-servers' ? ( <> { + onClick={async () => { + if (!(await checkExtensionsLimit())) return; setActiveTab('mcp-servers'); setIsEditMode(false); setEditingServerName(null); @@ -661,7 +687,8 @@ export default function PluginConfigPage() { {t('plugins.uploadLocal')} { + onClick={async () => { + if (!(await checkExtensionsLimit())) return; setInstallSource('github'); setPluginInstallStatus(PluginInstallStatus.WAIT_INPUT); setInstallError(null); @@ -683,7 +710,8 @@ export default function PluginConfigPage() { { + installPlugin={async (plugin: PluginV4) => { + if (!(await checkExtensionsLimit())) return; setInstallSource('marketplace'); setInstallInfo({ plugin_author: plugin.author, diff --git a/web/src/app/infra/entities/api/index.ts b/web/src/app/infra/entities/api/index.ts index 50bc13e8..dfa3f591 100644 --- a/web/src/app/infra/entities/api/index.ts +++ b/web/src/app/infra/entities/api/index.ts @@ -240,13 +240,21 @@ export interface PluginReorderElement { } // system +export interface SystemLimitation { + max_bots: number; + max_pipelines: number; + max_extensions: number; +} + export interface ApiRespSystemInfo { debug: boolean; version: string; + edition: string; cloud_service_url: string; enable_marketplace: boolean; allow_modify_login_info: boolean; disable_models_service: boolean; + limitation: SystemLimitation; } export interface ApiRespPluginSystemStatus { diff --git a/web/src/app/infra/http/index.ts b/web/src/app/infra/http/index.ts index b4b720d2..3ef8761d 100644 --- a/web/src/app/infra/http/index.ts +++ b/web/src/app/infra/http/index.ts @@ -6,10 +6,16 @@ import { ApiRespSystemInfo } from '@/app/infra/entities/api'; export let systemInfo: ApiRespSystemInfo = { debug: false, version: '', + edition: 'community', enable_marketplace: true, cloud_service_url: '', allow_modify_login_info: true, disable_models_service: false, + limitation: { + max_bots: -1, + max_pipelines: -1, + max_extensions: -1, + }, }; // 用户信息 diff --git a/web/src/i18n/locales/en-US.ts b/web/src/i18n/locales/en-US.ts index 685291ca..19772e67 100644 --- a/web/src/i18n/locales/en-US.ts +++ b/web/src/i18n/locales/en-US.ts @@ -962,6 +962,14 @@ const enUS = { sessions: 'Sessions', }, }, + limitation: { + maxBotsReached: + 'Maximum number of bots ({{max}}) reached. Please remove an existing bot before creating a new one.', + maxPipelinesReached: + 'Maximum number of pipelines ({{max}}) reached. Please remove an existing pipeline before creating a new one.', + maxExtensionsReached: + 'Maximum number of extensions ({{max}}) reached. Please remove an existing MCP server or plugin before adding a new one.', + }, }; export default enUS; diff --git a/web/src/i18n/locales/ja-JP.ts b/web/src/i18n/locales/ja-JP.ts index bf23a172..9e8a1dd0 100644 --- a/web/src/i18n/locales/ja-JP.ts +++ b/web/src/i18n/locales/ja-JP.ts @@ -949,6 +949,14 @@ const jaJP = { sessions: 'セッション', }, }, + limitation: { + maxBotsReached: + 'ボット数が上限({{max}}個)に達しました。新しいボットを作成するには、既存のボットを削除してください。', + maxPipelinesReached: + 'パイプライン数が上限({{max}}個)に達しました。新しいパイプラインを作成するには、既存のパイプラインを削除してください。', + maxExtensionsReached: + '拡張機能数が上限({{max}}個)に達しました。新しい MCP サーバーやプラグインを追加するには、既存のものを削除してください。', + }, }; export default jaJP; diff --git a/web/src/i18n/locales/zh-Hans.ts b/web/src/i18n/locales/zh-Hans.ts index f41b2a67..444ad362 100644 --- a/web/src/i18n/locales/zh-Hans.ts +++ b/web/src/i18n/locales/zh-Hans.ts @@ -922,6 +922,14 @@ const zhHans = { sessions: '会话记录', }, }, + limitation: { + maxBotsReached: + '已达到机器人数量上限({{max}}个)。请先删除已有机器人后再创建新的。', + maxPipelinesReached: + '已达到流水线数量上限({{max}}个)。请先删除已有流水线后再创建新的。', + maxExtensionsReached: + '已达到扩展数量上限({{max}}个)。请先删除已有的 MCP 服务器或插件后再添加新的。', + }, }; export default zhHans; diff --git a/web/src/i18n/locales/zh-Hant.ts b/web/src/i18n/locales/zh-Hant.ts index 509071cb..d4e27513 100644 --- a/web/src/i18n/locales/zh-Hant.ts +++ b/web/src/i18n/locales/zh-Hant.ts @@ -897,6 +897,14 @@ const zhHant = { sessions: '會話記錄', }, }, + limitation: { + maxBotsReached: + '已達到機器人數量上限({{max}}個)。請先刪除已有機器人後再建立新的。', + maxPipelinesReached: + '已達到流水線數量上限({{max}}個)。請先刪除已有流水線後再建立新的。', + maxExtensionsReached: + '已達到擴充功能數量上限({{max}}個)。請先刪除已有的 MCP 伺服器或外掛後再新增。', + }, }; export default zhHant;