Skip to content

Commit 42caae1

Browse files
authored
feat: Implement extension and bot limitations across services and UI (langbot-app#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.
1 parent aa09a27 commit 42caae1

File tree

17 files changed

+161
-5
lines changed

17 files changed

+161
-5
lines changed

src/langbot/pkg/api/http/controller/groups/plugins.py

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,18 @@
1414

1515
@group.group_class('plugins', '/api/v1/plugins')
1616
class PluginsRouterGroup(group.RouterGroup):
17+
async def _check_extensions_limit(self) -> str | None:
18+
"""Check if extensions limit is reached. Returns error response if limit exceeded, None otherwise."""
19+
limitation = self.ap.instance_config.data.get('system', {}).get('limitation', {})
20+
max_extensions = limitation.get('max_extensions', -1)
21+
if max_extensions >= 0:
22+
plugins = await self.ap.plugin_connector.list_plugins()
23+
mcp_servers = await self.ap.mcp_service.get_mcp_servers()
24+
total_extensions = len(plugins) + len(mcp_servers)
25+
if total_extensions >= max_extensions:
26+
return self.http_status(400, -1, f'Maximum number of extensions ({max_extensions}) reached')
27+
return None
28+
1729
async def initialize(self) -> None:
1830
@self.route('', methods=['GET'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
1931
async def _() -> str:
@@ -239,6 +251,10 @@ async def _() -> str:
239251
@self.route('/install/github', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
240252
async def _() -> str:
241253
"""Install plugin from GitHub release asset"""
254+
limit_error = await self._check_extensions_limit()
255+
if limit_error is not None:
256+
return limit_error
257+
242258
data = await quart.request.json
243259
asset_url = data.get('asset_url', '')
244260
owner = data.get('owner', '')
@@ -273,6 +289,10 @@ async def _() -> str:
273289
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
274290
)
275291
async def _() -> str:
292+
limit_error = await self._check_extensions_limit()
293+
if limit_error is not None:
294+
return limit_error
295+
276296
data = await quart.request.json
277297

278298
ctx = taskmgr.TaskContext.new()
@@ -288,6 +308,10 @@ async def _() -> str:
288308

289309
@self.route('/install/local', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
290310
async def _() -> str:
311+
limit_error = await self._check_extensions_limit()
312+
if limit_error is not None:
313+
return limit_error
314+
291315
file = (await quart.request.files).get('file')
292316
if file is None:
293317
return self.http_status(400, -1, 'file is required')

src/langbot/pkg/api/http/controller/groups/system.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ async def _() -> str:
1313
data={
1414
'version': constants.semantic_version,
1515
'debug': constants.debug_mode,
16+
'edition': constants.edition,
1617
'enable_marketplace': self.ap.instance_config.data.get('plugin', {}).get(
1718
'enable_marketplace', True
1819
),
@@ -25,6 +26,7 @@ async def _() -> str:
2526
'disable_models_service': self.ap.instance_config.data.get('space', {}).get(
2627
'disable_models_service', False
2728
),
29+
'limitation': self.ap.instance_config.data.get('system', {}).get('limitation', {}),
2830
}
2931
)
3032

src/langbot/pkg/api/http/service/bot.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,14 @@ async def get_runtime_bot_info(self, bot_uuid: str, include_secret: bool = True)
8383

8484
async def create_bot(self, bot_data: dict) -> str:
8585
"""Create bot"""
86+
# Check limitation
87+
limitation = self.ap.instance_config.data.get('system', {}).get('limitation', {})
88+
max_bots = limitation.get('max_bots', -1)
89+
if max_bots >= 0:
90+
existing_bots = await self.get_bots()
91+
if len(existing_bots) >= max_bots:
92+
raise ValueError(f'Maximum number of bots ({max_bots}) reached')
93+
8694
# TODO: 检查配置信息格式
8795
bot_data['uuid'] = str(uuid.uuid4())
8896

src/langbot/pkg/api/http/service/mcp.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,16 @@ async def get_mcp_servers(self, contain_runtime_info: bool = False) -> list[dict
3838
return serialized_servers
3939

4040
async def create_mcp_server(self, server_data: dict) -> str:
41+
# Check limitation (extensions = MCP servers + plugins)
42+
limitation = self.ap.instance_config.data.get('system', {}).get('limitation', {})
43+
max_extensions = limitation.get('max_extensions', -1)
44+
if max_extensions >= 0:
45+
existing_mcp_servers = await self.get_mcp_servers()
46+
plugins = await self.ap.plugin_connector.list_plugins()
47+
total_extensions = len(existing_mcp_servers) + len(plugins)
48+
if total_extensions >= max_extensions:
49+
raise ValueError(f'Maximum number of extensions ({max_extensions}) reached')
50+
4151
server_data['uuid'] = str(uuid.uuid4())
4252
await self.ap.persistence_mgr.execute_async(sqlalchemy.insert(persistence_mcp.MCPServer).values(server_data))
4353

src/langbot/pkg/api/http/service/pipeline.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,14 @@ async def get_pipeline(self, pipeline_uuid: str) -> dict | None:
7676
async def create_pipeline(self, pipeline_data: dict, default: bool = False) -> str:
7777
from ....utils import paths as path_utils
7878

79+
# Check limitation
80+
limitation = self.ap.instance_config.data.get('system', {}).get('limitation', {})
81+
max_pipelines = limitation.get('max_pipelines', -1)
82+
if max_pipelines >= 0:
83+
existing_pipelines = await self.get_pipelines()
84+
if len(existing_pipelines) >= max_pipelines:
85+
raise ValueError(f'Maximum number of pipelines ({max_pipelines}) reached')
86+
7987
pipeline_data['uuid'] = str(uuid.uuid4())
8088
pipeline_data['for_version'] = self.ap.ver_mgr.get_current_version()
8189
pipeline_data['stages'] = default_stage_order.copy()
@@ -153,6 +161,14 @@ async def delete_pipeline(self, pipeline_uuid: str) -> None:
153161

154162
async def copy_pipeline(self, pipeline_uuid: str) -> str:
155163
"""Copy a pipeline with all its configurations"""
164+
# Check limitation
165+
limitation = self.ap.instance_config.data.get('system', {}).get('limitation', {})
166+
max_pipelines = limitation.get('max_pipelines', -1)
167+
if max_pipelines >= 0:
168+
existing_pipelines = await self.get_pipelines()
169+
if len(existing_pipelines) >= max_pipelines:
170+
raise ValueError(f'Maximum number of pipelines ({max_pipelines}) reached')
171+
156172
# Get the original pipeline
157173
result = await self.ap.persistence_mgr.execute_async(
158174
sqlalchemy.select(persistence_pipeline.LegacyPipeline).where(

src/langbot/pkg/core/stages/load_config.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,8 +156,10 @@ async def run(self, ap: app.Application):
156156
)
157157

158158
constants.instance_id = ap.instance_id.data['instance_id']
159+
constants.edition = ap.instance_config.data.get('system', {}).get('edition', 'community')
159160

160161
print(f'LangBot instance id: {constants.instance_id}')
162+
print(f'LangBot edition: {constants.edition}')
161163

162164
await ap.instance_id.dump_config()
163165

src/langbot/templates/config.yaml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,13 @@ proxy:
1515
http: ''
1616
https: ''
1717
system:
18+
edition: community
1819
recovery_key: ''
1920
allow_modify_login_info: true
21+
limitation:
22+
max_bots: -1
23+
max_pipelines: -1
24+
max_extensions: -1
2025
jwt:
2126
expire: 604800
2227
secret: ''

web/src/app/home/bots/page.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { useTranslation } from 'react-i18next';
1212
import { extractI18nObject } from '@/i18n/I18nProvider';
1313
import BotDetailDialog from '@/app/home/bots/BotDetailDialog';
1414
import { CustomApiError } from '@/app/infra/entities/common';
15+
import { systemInfo } from '@/app/infra/http';
1516

1617
export default function BotConfigPage() {
1718
const { t } = useTranslation();
@@ -60,6 +61,11 @@ export default function BotConfigPage() {
6061
}
6162

6263
function handleCreateBotClick() {
64+
const maxBots = systemInfo.limitation?.max_bots ?? -1;
65+
if (maxBots >= 0 && botList.length >= maxBots) {
66+
toast.error(t('limitation.maxBotsReached', { max: maxBots }));
67+
return;
68+
}
6369
setSelectedBotId('');
6470
setDetailDialogOpen(true);
6571
}

web/src/app/home/pipelines/page.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import {
1515
SelectTrigger,
1616
SelectValue,
1717
} from '@/components/ui/select';
18+
import { systemInfo } from '@/app/infra/http';
1819

1920
export default function PluginConfigPage() {
2021
const { t } = useTranslation();
@@ -87,6 +88,11 @@ export default function PluginConfigPage() {
8788
};
8889

8990
const handleCreateNew = () => {
91+
const maxPipelines = systemInfo.limitation?.max_pipelines ?? -1;
92+
if (maxPipelines >= 0 && pipelineList.length >= maxPipelines) {
93+
toast.error(t('limitation.maxPipelinesReached', { max: maxPipelines }));
94+
return;
95+
}
9096
setIsEditForm(false);
9197
setSelectedPipelineId('');
9298
setDialogOpen(true);

web/src/app/home/plugins/mcp-server/mcp-form/MCPFormDialog.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -453,7 +453,10 @@ export default function MCPFormDialog({
453453
onSuccess?.();
454454
} catch (error) {
455455
console.error('Failed to save MCP server:', error);
456-
toast.error(isEditMode ? t('mcp.updateFailed') : t('mcp.createFailed'));
456+
const errMsg = (error as CustomApiError).msg || '';
457+
toast.error(
458+
(isEditMode ? t('mcp.updateFailed') : t('mcp.createFailed')) + errMsg,
459+
);
457460
}
458461
}
459462

0 commit comments

Comments
 (0)