From 184037a427ed43aeb4f2ba00e169a9af0f58ad61 Mon Sep 17 00:00:00 2001 From: RockChinQ Date: Tue, 15 Sep 2026 00:28:13 +0800 Subject: [PATCH] feat(bots): bind plugin processor configurations independently --- ARCHITECTURE.md | 1 + skills/skills/langbot-mcp-ops/SKILL.md | 12 +- src/langbot/pkg/api/http/service/bot.py | 42 +- src/langbot/pkg/api/mcp/server.py | 12 +- src/langbot/pkg/entity/persistence/bot.py | 1 + .../versions/0025_bot_plugin_processors.py | 79 +++ src/langbot/pkg/platform/botmgr.py | 69 ++- .../service/test_bot_plugin_subscriptions.py | 75 +++ .../test_bot_plugin_processor_migration.py | 49 ++ .../platform/test_plugin_subscriptions.py | 107 ++++ .../unit_tests/platform/test_routing_rules.py | 22 +- .../agents/PluginProcessorDetailContent.tsx | 7 +- .../home/bots/components/bot-form/BotForm.tsx | 25 +- .../bot-form/EventBindingsEditor.tsx | 25 - .../bot-form/PluginProcessorBindings.tsx | 466 ++++++++++++++++++ .../components/home-sidebar/HomeSidebar.tsx | 4 +- web/src/app/infra/entities/api/index.ts | 6 + web/src/i18n/locales/en-US.ts | 27 +- web/src/i18n/locales/es-ES.ts | 25 +- web/src/i18n/locales/ja-JP.ts | 24 +- web/src/i18n/locales/ru-RU.ts | 25 +- web/src/i18n/locales/th-TH.ts | 24 +- web/src/i18n/locales/vi-VN.ts | 25 +- web/src/i18n/locales/zh-Hans.ts | 23 +- web/src/i18n/locales/zh-Hant.ts | 23 +- web/tests/e2e/fixtures/langbot-api.ts | 2 + web/tests/e2e/plugin-subscriptions.spec.ts | 135 +++++ 27 files changed, 1253 insertions(+), 82 deletions(-) create mode 100644 src/langbot/pkg/persistence/alembic/versions/0025_bot_plugin_processors.py create mode 100644 tests/unit_tests/api/service/test_bot_plugin_subscriptions.py create mode 100644 tests/unit_tests/persistence/test_bot_plugin_processor_migration.py create mode 100644 tests/unit_tests/platform/test_plugin_subscriptions.py create mode 100644 web/src/app/home/bots/components/bot-form/PluginProcessorBindings.tsx create mode 100644 web/tests/e2e/plugin-subscriptions.spec.ts diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index b3ae6df8d..6f5b39c01 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -118,6 +118,7 @@ Pipeline components are registered by decorators and package import side effects Platform code lives under `pkg/platform/`. - `botmgr.py` owns runtime bots, routing rules, event logging, webhook pushing, and adapter lifecycle. +- Bots store exclusive Agent/Pipeline routes in `event_bindings` and independent plugin subscriptions in `plugin_processors` (`processor_uuid`, `enabled`). Subscriptions resolve event patterns from the installed Runner and fan out alongside the primary route. Configuration, state, debug and run logs belong to the reusable processor instance. - `sources/` contains adapter implementations. Each adapter subclasses `langbot_plugin.api.definition.abstract.platform.adapter.AbstractMessagePlatformAdapter` from the SDK. - Platform entities such as `MessageChain`, `Image`, `At`, `Voice`, and events come from `langbot-plugin-sdk`, not from this repo. diff --git a/skills/skills/langbot-mcp-ops/SKILL.md b/skills/skills/langbot-mcp-ops/SKILL.md index 169f1f980..63aad9c15 100644 --- a/skills/skills/langbot-mcp-ops/SKILL.md +++ b/skills/skills/langbot-mcp-ops/SKILL.md @@ -119,8 +119,16 @@ already have a default pipeline. Create a processor with `kind: "event_processor"` and basic information. Without a component it supports no events. Discover installed components with `get_processor_metadata`, then use `update_processor` with `component_ref` and -optional `parameters`. API callers may also supply these when creating an instance. Bind bot events to this instance with `target_type: "event_processor"` -and `target_id` equal to its UUID. Installation alone never activates a handler. +optional `parameters`. API callers may also supply these when creating an instance. +Bind an instance by updating the bot's `plugin_processors` array with +`{"processor_uuid": "", "enabled": true}`. This replaces the full +subscription list; preserve bindings you want to keep. Do not add plugin processors +to `event_bindings`, which remains exclusive Agent/Pipeline routing. +Each enabled subscription independently receives the installed Runner's declared +events. Slow or failed subscribers do not prevent other subscribers or the primary +route from executing. Installation alone never activates a handler. Reusing an +instance shares its configuration and runtime state. Use a separate instance for +independent settings. Optional plugin behavior belongs in the Runner config schema. `debug_agent` accepts the complete typed event in `payload.data` for this kind. Legacy EventListener plugins remain in the Pipeline lifecycle. diff --git a/src/langbot/pkg/api/http/service/bot.py b/src/langbot/pkg/api/http/service/bot.py index 2190b7159..ef0c680ed 100644 --- a/src/langbot/pkg/api/http/service/bot.py +++ b/src/langbot/pkg/api/http/service/bot.py @@ -16,6 +16,7 @@ from .tenant import TenantContext, require_workspace_uuid, scope_statement from ....utils import httpclient from ....platform.sources import http_bot_signing from ....platform.adapter_names import canonical_adapter_name +from ....agent.runner.errors import RunnerNotFoundError class BotService: @@ -36,6 +37,7 @@ class BotService: 'adapter_config', 'enable', 'event_bindings', + 'plugin_processors', } def __init__(self, ap: app.Application) -> None: @@ -517,7 +519,7 @@ class BotService: ) if result.first() is None: raise ValueError('Pipeline not found') - elif target_type in {'agent', 'event_processor'}: + elif target_type == 'agent': result = await self.ap.persistence_mgr.execute_async( scope_statement( sqlalchemy.select(persistence_agent.Agent).where(persistence_agent.Agent.uuid == target_uuid), @@ -551,6 +553,37 @@ class BotService: return normalized + async def _normalize_plugin_processors(self, context: TenantContext, subscriptions: typing.Any) -> list[dict]: + """Validate explicit subscriptions within this Workspace; events come from the Runner.""" + if not isinstance(subscriptions, list): + raise ValueError('plugin_processors must be an array') + normalized = [] + seen = set() + for subscription in subscriptions: + if not isinstance(subscription, dict): + raise ValueError('Each plugin processor binding must be an object') + processor_uuid = subscription.get('processor_uuid') + if not isinstance(processor_uuid, str) or not processor_uuid.strip(): + raise ValueError('Plugin processor UUID is required') + if processor_uuid in seen: + raise ValueError('A plugin processor can only be bound once per bot') + enabled = subscription.get('enabled', True) + if not isinstance(enabled, bool): + raise ValueError('Plugin processor enabled must be a boolean') + agent = await self._get_agent_entity(context, processor_uuid) + if agent is None or agent.kind != 'event_processor': + raise ValueError('Plugin processor not found') + if enabled: + try: + descriptor = await self.ap.runner_registry.get(context, agent.component_ref) + except RunnerNotFoundError as exc: + raise ValueError('Runner component is unavailable') from exc + if 'event' not in descriptor.usages or not descriptor.supported_event_patterns: + raise ValueError('Select an available Runner that declares event usage') + seen.add(processor_uuid) + normalized.append({'processor_uuid': processor_uuid, 'enabled': enabled}) + return normalized + async def _prepare_bot_data(self, context: TenantContext, bot_data: dict, *, include_uuid: bool) -> dict: """Normalize Bot write payloads to the current event-routing model.""" update_data = bot_data.copy() @@ -564,6 +597,10 @@ class BotService: update_data['event_bindings'] = await self._normalize_event_bindings( context, update_data.get('event_bindings') ) + if 'plugin_processors' in update_data: + update_data['plugin_processors'] = await self._normalize_plugin_processors( + context, update_data['plugin_processors'] + ) return update_data async def get_bots(self, context: TenantContext, include_secret: bool = False) -> list[dict]: @@ -660,6 +697,7 @@ class BotService: bot_data['uuid'] = str(uuid.uuid4()) bot_data['workspace_uuid'] = workspace_uuid bot_data.setdefault('event_bindings', []) + bot_data.setdefault('plugin_processors', []) await self.ap.persistence_mgr.execute_async(sqlalchemy.insert(persistence_bot.Bot).values(bot_data)) @@ -688,7 +726,7 @@ class BotService: if getattr(result, 'rowcount', None) == 0: raise WorkspaceNotFoundError('Bot not found') - runtime_fields = {'adapter', 'adapter_config', 'enable', 'event_bindings'} + runtime_fields = {'adapter', 'adapter_config', 'enable', 'event_bindings', 'plugin_processors'} if not runtime_fields.intersection(update_data): runtime_bot = await self.ap.platform_mgr.get_bot_by_uuid(context, bot_uuid) if runtime_bot is not None: diff --git a/src/langbot/pkg/api/mcp/server.py b/src/langbot/pkg/api/mcp/server.py index cd035a52c..3c282bd5c 100644 --- a/src/langbot/pkg/api/mcp/server.py +++ b/src/langbot/pkg/api/mcp/server.py @@ -109,14 +109,22 @@ class LangBotMCPServer: description=( 'Create a bot. `bot_data` is a JSON object matching the LangBot ' 'POST /api/v1/platform/bots body (e.g. name, adapter, config). ' - 'Returns the new bot UUID.' + 'Use event_bindings for exclusive Agent/Pipeline routes; plugin_processors is an array ' + 'of {processor_uuid, enabled} subscriptions that automatically match Runner-declared events. ' + 'Subscriptions run independently of each other and of event_bindings. Returns the new bot UUID.' ) ) async def create_bot(bot_data: dict) -> str: context = _authorized(Permission.RESOURCE_MANAGE) return _dump({'uuid': await ap.bot_service.create_bot(context, bot_data)}) - @mcp.tool(description='Update a bot by UUID. `bot_data` matches the PUT bot body.') + @mcp.tool( + description=( + 'Update a bot by UUID. `bot_data` matches the PUT bot body. ' + 'plugin_processors replaces the bot subscriptions with [{processor_uuid, enabled}]. ' + 'Do not put event_processor targets in event_bindings; those are exclusive Agent/Pipeline routes.' + ) + ) async def update_bot(bot_uuid: str, bot_data: dict) -> str: context = _authorized(Permission.RESOURCE_MANAGE) await ap.bot_service.update_bot(context, bot_uuid, bot_data) diff --git a/src/langbot/pkg/entity/persistence/bot.py b/src/langbot/pkg/entity/persistence/bot.py index 3d080793a..89b71d50a 100644 --- a/src/langbot/pkg/entity/persistence/bot.py +++ b/src/langbot/pkg/entity/persistence/bot.py @@ -53,6 +53,7 @@ class Bot(Base): adapter_config = sqlalchemy.Column(sqlalchemy.JSON, nullable=False) enable = sqlalchemy.Column(sqlalchemy.Boolean, nullable=False, default=False) event_bindings = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, server_default='[]') + plugin_processors = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, server_default='[]') created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now()) updated_at = sqlalchemy.Column( sqlalchemy.DateTime, diff --git a/src/langbot/pkg/persistence/alembic/versions/0025_bot_plugin_processors.py b/src/langbot/pkg/persistence/alembic/versions/0025_bot_plugin_processors.py new file mode 100644 index 000000000..85ea2f495 --- /dev/null +++ b/src/langbot/pkg/persistence/alembic/versions/0025_bot_plugin_processors.py @@ -0,0 +1,79 @@ +"""Separate plugin processor subscriptions from exclusive bot event routes.""" + +from alembic import op +import sqlalchemy as sa + +revision = '0025_bot_plugin_processors' +down_revision = '0024_unify_runner_state' +branch_labels = None +depends_on = None + + +def upgrade(): + bind = op.get_bind() + inspector = sa.inspect(bind) + if not inspector.has_table('bots'): + return + if 'plugin_processors' not in {column['name'] for column in inspector.get_columns('bots')}: + op.add_column('bots', sa.Column('plugin_processors', sa.JSON(), nullable=False, server_default='[]')) + bots = sa.table( + 'bots', + sa.column('uuid', sa.String()), + sa.column('event_bindings', sa.JSON()), + sa.column('plugin_processors', sa.JSON()), + ) + for row in bind.execute(sa.select(bots)).mappings(): + routes = [] + subscriptions = {item['processor_uuid']: dict(item) for item in (row['plugin_processors'] or [])} + for route in row['event_bindings'] or []: + if route.get('target_type') != 'event_processor': + routes.append(route) + continue + processor_uuid = route.get('target_uuid') + if not processor_uuid: + continue + if processor_uuid not in subscriptions: + subscriptions[processor_uuid] = {'processor_uuid': processor_uuid, 'enabled': False} + subscriptions[processor_uuid]['enabled'] |= bool(route.get('enabled', True)) + bind.execute( + bots.update() + .where(bots.c.uuid == row['uuid']) + .values( + event_bindings=routes, + plugin_processors=list(subscriptions.values()), + ) + ) + + +def downgrade(): + # A subscription may match several events. Represent it as a wildcard route + # when reverting, while retaining the target and enabled state. + bind = op.get_bind() + inspector = sa.inspect(bind) + if not inspector.has_table('bots') or 'plugin_processors' not in { + column['name'] for column in inspector.get_columns('bots') + }: + return + bots = sa.table( + 'bots', + sa.column('uuid', sa.String()), + sa.column('event_bindings', sa.JSON()), + sa.column('plugin_processors', sa.JSON()), + ) + for row in bind.execute(sa.select(bots)).mappings(): + routes = list(row['event_bindings'] or []) + for item in row['plugin_processors'] or []: + routes.append( + { + 'id': f'plugin_processor:{item["processor_uuid"]}', + 'event_pattern': '*', + 'target_type': 'event_processor', + 'target_uuid': item['processor_uuid'], + 'enabled': item['enabled'], + 'filters': [], + 'priority': 0, + 'order': len(routes), + } + ) + bind.execute(bots.update().where(bots.c.uuid == row['uuid']).values(event_bindings=routes)) + op.drop_column('bots', 'plugin_processors') diff --git a/src/langbot/pkg/platform/botmgr.py b/src/langbot/pkg/platform/botmgr.py index dcb5cc647..7dee7ac83 100644 --- a/src/langbot/pkg/platform/botmgr.py +++ b/src/langbot/pkg/platform/botmgr.py @@ -280,6 +280,8 @@ class RuntimeBot: diagnostic_steps: list[dict[str, typing.Any]] = [] for index, binding in enumerate(bindings): + if binding.get('target_type') == 'event_processor': + continue event_pattern = str(binding.get('event_pattern') or '') priority = int(binding.get('priority') or 0) order = int(binding.get('order', index)) @@ -841,22 +843,72 @@ class RuntimeBot: event.bot_uuid = self.bot_entity.uuid await self._record_adapter_event(event, adapter) - if isinstance(event, platform_events.PlatformSpecificEvent) and event.action == 'interaction.submitted': - await self._handle_interaction_submission(event, adapter) - return + primary = ( + self._handle_interaction_submission(event, adapter) + if isinstance(event, platform_events.PlatformSpecificEvent) and event.action == 'interaction.submitted' + else self._dispatch_eba_event_to_processor(event, adapter) + ) + subscriptions = self._get_event_bindings_from_value(getattr(self.bot_entity, 'plugin_processors', [])) + seen = set() + tasks = [primary] + for subscription in subscriptions: + processor_uuid = subscription.get('processor_uuid') + if not processor_uuid or processor_uuid in seen or not subscription.get('enabled', True): + continue + seen.add(processor_uuid) + tasks.append(self._dispatch_plugin_subscription(event, adapter, processor_uuid)) + # Start all deliveries together. A slow or failed subscriber cannot block + # another subscriber or the primary route from receiving the event. + results = await asyncio.gather(*tasks, return_exceptions=True) + for result in results: + if isinstance(result, BaseException): + await self.logger.error(f'Event delivery failed: {result}') - # Legacy listeners run inside Pipeline stages. EBA handlers require an - # explicitly created and routed plugin processor instance. - await self._dispatch_eba_event_to_processor(event, adapter) + async def _dispatch_plugin_subscription(self, event, adapter, processor_uuid): + event_type = event.type + event_binding = { + 'id': f'plugin_processor:{processor_uuid}', + 'target_type': 'event_processor', + 'target_uuid': processor_uuid, + 'event_pattern': event_type, + } + try: + agent = await self.ap.agent_service.get_agent(self.execution_context, processor_uuid) + if not agent or agent.get('kind') != 'event_processor': + raise ValueError('Plugin processor not found') + descriptor = await self.ap.runner_registry.get(self.execution_context, agent.get('component_ref')) + if 'event' not in descriptor.usages: + raise ValueError('Runner does not support event processing') + # Resolve the installed declaration each time, including after plugin updates. + patterns = descriptor.supported_event_patterns + if not patterns or not self._agent_supports_event_type(patterns, event_type): + return + agent = {**agent, 'supported_event_patterns': patterns} + return await self._dispatch_eba_event_to_processor(event, adapter, event_binding, agent) + except Exception as exc: + return await self._record_event_route_trace( + event_type=event_type, + status='failed', + level='error', + binding=event_binding, + target_type='event_processor', + target_uuid=processor_uuid, + failure_code='runner_failed', + reason=str(exc), + text=f'Plugin processor {processor_uuid} failed: {exc}', + ) async def _dispatch_eba_event_to_processor( self, event: platform_events.EBAEvent, adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter, + event_binding: dict | None = None, + agent: dict | None = None, ) -> dict[str, typing.Any]: event_type = getattr(event, 'type', None) or event.__class__.__name__ - event_binding = self._resolve_eba_event_binding(event, event_type) + if event_binding is None: + event_binding = self._resolve_eba_event_binding(event, event_type) if event_binding is None: return await self._record_event_route_trace( event_type=event_type, @@ -938,7 +990,8 @@ class RuntimeBot: ) target_uuid = event_binding.get('target_uuid') - agent = await self.ap.agent_service.get_agent(self.execution_context, target_uuid) + if agent is None: + agent = await self.ap.agent_service.get_agent(self.execution_context, target_uuid) if not agent or agent.get('kind') != target_type: return await self._record_event_route_trace( event_type=event_type, diff --git a/tests/unit_tests/api/service/test_bot_plugin_subscriptions.py b/tests/unit_tests/api/service/test_bot_plugin_subscriptions.py new file mode 100644 index 000000000..3202a9ee1 --- /dev/null +++ b/tests/unit_tests/api/service/test_bot_plugin_subscriptions.py @@ -0,0 +1,75 @@ +"""Validate subscription identity, workspace boundaries and persisted updates.""" + +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest + +from langbot.pkg.api.http.service.bot import BotService +from langbot.pkg.agent.runner.errors import RunnerNotFoundError + + +def make_service(agent=None): + service = BotService( + SimpleNamespace( + runner_registry=SimpleNamespace( + get=AsyncMock( + return_value=SimpleNamespace( + usages=['event'], + supported_event_patterns=['group.member_joined'], + ) + ) + ) + ) + ) + service._get_agent_entity = AsyncMock(return_value=agent) + return service + + +async def test_prepare_accepts_configured_instance_and_preserves_input(): + service = make_service(SimpleNamespace(kind='event_processor', component_ref='plugin:test/runner/default')) + payload = {'plugin_processors': [{'processor_uuid': 'processor', 'enabled': True, 'events': ['*']}]} + result = await service._prepare_bot_data('workspace', payload, include_uuid=False) + assert result == {'plugin_processors': [{'processor_uuid': 'processor', 'enabled': True}]} + assert payload['plugin_processors'][0]['events'] == ['*'] + service._get_agent_entity.assert_awaited_once_with('workspace', 'processor') + service.ap.runner_registry.get.assert_awaited_once_with('workspace', 'plugin:test/runner/default') + + +@pytest.mark.parametrize( + 'items,reason', + [ + ({}, 'must be an array'), + ([None], 'must be an object'), + ([{}], 'UUID is required'), + ([{'processor_uuid': 'p', 'enabled': 'false'}], 'must be a boolean'), + ([{'processor_uuid': 'p'}, {'processor_uuid': 'p'}], 'only be bound once'), + ], +) +async def test_invalid_bindings_are_rejected(items, reason): + service = make_service(SimpleNamespace(kind='event_processor', component_ref='runner')) + with pytest.raises(ValueError, match=reason): + await service._normalize_plugin_processors('workspace', items) + + +@pytest.mark.parametrize('agent', [None, SimpleNamespace(kind='agent')]) +async def test_missing_cross_workspace_or_wrong_kind_is_rejected(agent): + service = make_service(agent) + with pytest.raises(ValueError, match='not found'): + await service._normalize_plugin_processors('workspace', [{'processor_uuid': 'p'}]) + + +async def test_can_disable_binding_when_plugin_is_unavailable(): + service = make_service(SimpleNamespace(kind='event_processor', component_ref='missing')) + service.ap.runner_registry.get.side_effect = ValueError('not installed') + assert await service._normalize_plugin_processors('workspace', [{'processor_uuid': 'p', 'enabled': False}]) == [ + {'processor_uuid': 'p', 'enabled': False}, + ] + service.ap.runner_registry.get.assert_not_called() + + +async def test_enabling_unavailable_runner_has_actionable_validation_error(): + service = make_service(SimpleNamespace(kind='event_processor', component_ref='missing')) + service.ap.runner_registry.get.side_effect = RunnerNotFoundError('missing') + with pytest.raises(ValueError, match='Runner component is unavailable'): + await service._normalize_plugin_processors('workspace', [{'processor_uuid': 'p'}]) diff --git a/tests/unit_tests/persistence/test_bot_plugin_processor_migration.py b/tests/unit_tests/persistence/test_bot_plugin_processor_migration.py new file mode 100644 index 000000000..4e3e4f0ad --- /dev/null +++ b/tests/unit_tests/persistence/test_bot_plugin_processor_migration.py @@ -0,0 +1,49 @@ +"""Move existing plugin routes without discarding primary routes or disabled state.""" + +import importlib +from unittest.mock import patch + +import pytest +import sqlalchemy as sa +from alembic.migration import MigrationContext +from alembic.operations import Operations + +migration = importlib.import_module('langbot.pkg.persistence.alembic.versions.0025_bot_plugin_processors') + + +@pytest.mark.parametrize('column_exists', [False, True]) +def test_migrate_duplicate_routes_and_preserve_primary_and_disabled(column_exists): + engine = sa.create_engine('sqlite://') + metadata = sa.MetaData() + columns = [sa.Column('uuid', sa.String(), primary_key=True), sa.Column('event_bindings', sa.JSON())] + if column_exists: + columns.append(sa.Column('plugin_processors', sa.JSON(), server_default='[]')) + table = sa.Table('bots', metadata, *columns) + metadata.create_all(engine) + primary = {'target_type': 'agent', 'target_uuid': 'agent', 'event_pattern': '*'} + with engine.begin() as conn: + conn.execute( + table.insert().values( + uuid='bot', + event_bindings=[ + primary, + {'target_type': 'event_processor', 'target_uuid': 'one', 'enabled': False}, + {'target_type': 'event_processor', 'target_uuid': 'one', 'enabled': True}, + {'target_type': 'event_processor', 'target_uuid': 'two', 'enabled': False}, + ], + ) + ) + with patch.object(migration, 'op', Operations(MigrationContext.configure(conn))): + migration.upgrade() + migrated = sa.Table('bots', sa.MetaData(), autoload_with=conn) + row = conn.execute(sa.select(migrated)).mappings().one() + assert row['event_bindings'] == [primary] + assert row['plugin_processors'] == [ + {'processor_uuid': 'one', 'enabled': True}, + {'processor_uuid': 'two', 'enabled': False}, + ] + migration.upgrade() + assert conn.execute(sa.select(migrated)).mappings().one() == row + migration.downgrade() + assert 'plugin_processors' not in {c['name'] for c in sa.inspect(conn).get_columns('bots')} + engine.dispose() diff --git a/tests/unit_tests/platform/test_plugin_subscriptions.py b/tests/unit_tests/platform/test_plugin_subscriptions.py new file mode 100644 index 000000000..44819f934 --- /dev/null +++ b/tests/unit_tests/platform/test_plugin_subscriptions.py @@ -0,0 +1,107 @@ +"""Independent bot subscriptions must not change primary route delivery.""" + +import asyncio +from types import SimpleNamespace +from unittest.mock import AsyncMock + +import pytest +from langbot_plugin.api.entities.builtin.platform.events import MemberJoinedEvent + +from langbot.pkg.platform.botmgr import RuntimeBot + + +def make_bot(subscriptions): + bot = object.__new__(RuntimeBot) + bot.bot_entity = SimpleNamespace(uuid='bot', event_bindings=[], plugin_processors=subscriptions) + bot.execution_context = 'workspace' + bot._record_adapter_event = AsyncMock() + bot._record_event_route_trace = AsyncMock() + bot.logger = SimpleNamespace(error=AsyncMock()) + return bot + + +async def test_slow_failed_and_duplicate_subscriptions_do_not_block_primary_route(): + bot = make_bot( + [ + {'processor_uuid': 'slow'}, + {'processor_uuid': 'failed'}, + {'processor_uuid': 'fast'}, + {'processor_uuid': 'fast'}, + {'processor_uuid': 'disabled', 'enabled': False}, + ] + ) + started = [] + release = asyncio.Event() + + async def subscriber(event, adapter, uuid): + started.append(uuid) + if uuid == 'slow': + await release.wait() + if uuid == 'failed': + raise ValueError('plugin error') + + async def primary(event, adapter): + started.append('primary') + + bot._dispatch_plugin_subscription = subscriber + bot._dispatch_eba_event_to_processor = primary + task = asyncio.create_task(bot._handle_platform_event(MemberJoinedEvent(), None)) + for _ in range(10): + await asyncio.sleep(0) + if len(started) == 4: + break + assert set(started) == {'primary', 'slow', 'failed', 'fast'} + assert started.count('fast') == 1 + assert not task.done() + release.set() + await task + bot.logger.error.assert_awaited_once() + + +@pytest.mark.parametrize( + 'patterns,event_usage,expected', + [ + (['group.member_joined'], True, 1), + (['group.*'], True, 1), + (['*'], True, 1), + (['message.received'], True, 0), + ([], True, 0), + (['*'], False, 0), + ], +) +async def test_matching_uses_live_runner_declaration(patterns, event_usage, expected): + bot = make_bot([]) + bot.ap = SimpleNamespace( + agent_service=SimpleNamespace( + get_agent=AsyncMock( + return_value={ + 'kind': 'event_processor', + 'component_ref': 'plugin:test/runner/default', + 'supported_event_patterns': ['stale.event'], + } + ) + ), + runner_registry=SimpleNamespace( + get=AsyncMock( + return_value=SimpleNamespace( + usages=['event'] if event_usage else ['agent'], + supported_event_patterns=patterns, + ) + ) + ), + ) + bot._dispatch_eba_event_to_processor = AsyncMock() + await bot._dispatch_plugin_subscription(MemberJoinedEvent(), None, 'processor') + assert bot._dispatch_eba_event_to_processor.await_count == expected + if expected: + args = bot._dispatch_eba_event_to_processor.await_args.args + assert args[2]['target_uuid'] == 'processor' + assert args[3]['supported_event_patterns'] == patterns + + +async def test_missing_processor_is_logged_without_unscoped_lookup(): + bot = make_bot([]) + bot.ap = SimpleNamespace(agent_service=SimpleNamespace(get_agent=AsyncMock(return_value=None))) + await bot._dispatch_plugin_subscription(MemberJoinedEvent(), None, 'missing') + bot.ap.agent_service.get_agent.assert_awaited_once_with('workspace', 'missing') + assert bot._record_event_route_trace.await_args.kwargs['status'] == 'failed' diff --git a/tests/unit_tests/platform/test_routing_rules.py b/tests/unit_tests/platform/test_routing_rules.py index efac0e85f..1a339c936 100644 --- a/tests/unit_tests/platform/test_routing_rules.py +++ b/tests/unit_tests/platform/test_routing_rules.py @@ -712,19 +712,8 @@ async def test_installed_event_processor_never_receives_unbound_events(): async def test_bound_event_processor_receives_one_complete_typed_event(): from langbot_plugin.api.entities.builtin.platform.events import MemberJoinedEvent - bot = TestEventRouteTrace._make_bot( - [ - { - 'id': 'binding', - 'enabled': True, - 'event_pattern': 'group.member_joined', - 'target_type': 'event_processor', - 'target_uuid': 'processor-1', - 'priority': 0, - 'order': 0, - } - ] - ) + bot = TestEventRouteTrace._make_bot([]) + bot.bot_entity.plugin_processors = [{'processor_uuid': 'processor-1', 'enabled': True}] calls = [] async def run(envelope, binding, adapter_context=None): @@ -748,6 +737,9 @@ async def test_bound_event_processor_receives_one_complete_typed_event(): agent_run_orchestrator=SimpleNamespace(run=run), plugin_connector=SimpleNamespace(emit_event=AsyncMock()), ) + bot.ap.runner_registry = SimpleNamespace( + get=AsyncMock(return_value=SimpleNamespace(usages=['event'], supported_event_patterns=['group.member_joined'])) + ) bot._record_adapter_event = AsyncMock() await bot._handle_platform_event( MemberJoinedEvent( @@ -836,7 +828,9 @@ async def test_processor_outputs_require_explicit_platform_actions(kind, output_ chat_type=entities.ChatType.PRIVATE, chat_id='user-1', ) - trace = await bot._dispatch_eba_event_to_processor(event, adapter) + trace = await bot._dispatch_eba_event_to_processor( + event, adapter, bot.bot_entity.event_bindings[0] if kind == 'event_processor' else None + ) assert trace['status'] == ('failed' if runner_fails else 'delivered') if runner_fails: diff --git a/web/src/app/home/agents/PluginProcessorDetailContent.tsx b/web/src/app/home/agents/PluginProcessorDetailContent.tsx index 40fc25c8f..a2a9c5c08 100644 --- a/web/src/app/home/agents/PluginProcessorDetailContent.tsx +++ b/web/src/app/home/agents/PluginProcessorDetailContent.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { Link } from 'react-router-dom'; +import { Link, useSearchParams } from 'react-router-dom'; import { eventPatternLabel } from '@/app/home/components/event-patterns/event-pattern-groups'; import { RefreshCw, Trash2, ScrollText, Settings2 } from 'lucide-react'; import isEqual from 'lodash/isEqual'; @@ -48,7 +48,10 @@ export default function PluginProcessorDetailContent({ onSaved: () => void; }) { const { t } = useTranslation(); - const [activeTab, setActiveTab] = useState('config'); + const [searchParams] = useSearchParams(); + const [activeTab, setActiveTab] = useState( + searchParams.get('tab') === 'logs' ? 'logs' : 'config', + ); const [platformTools, setPlatformTools] = useState([]); const toolLabels = Object.fromEntries( platformTools.map((tool) => [tool.name, extractI18nObject(tool.label)]), diff --git a/web/src/app/home/bots/components/bot-form/BotForm.tsx b/web/src/app/home/bots/components/bot-form/BotForm.tsx index dd110edf3..7bb36373a 100644 --- a/web/src/app/home/bots/components/bot-form/BotForm.tsx +++ b/web/src/app/home/bots/components/bot-form/BotForm.tsx @@ -24,6 +24,7 @@ import { getAdapterDocUrl } from '@/app/infra/entities/adapter-docs'; import { ExternalLink, ChevronDown, ChevronRight } from 'lucide-react'; import { cn } from '@/lib/utils'; import EventBindingsEditor from './EventBindingsEditor'; +import PluginProcessorBindings from './PluginProcessorBindings'; import { zodResolver } from '@hookform/resolvers/zod'; import { useForm } from 'react-hook-form'; @@ -71,6 +72,9 @@ const getFormSchema = (t: (key: string) => string) => adapter: z.string().min(1, { message: t('bots.adapterRequired') }), adapter_config: z.record(z.string(), z.any()), enable: z.boolean().optional(), + plugin_processors: z + .array(z.object({ processor_uuid: z.string(), enabled: z.boolean() })) + .optional(), event_bindings: z .array( z.object({ @@ -127,6 +131,7 @@ const BotForm = forwardRef(function BotForm( adapter_config: {}, enable: true, event_bindings: [], + plugin_processors: [], }, }); @@ -237,6 +242,7 @@ const BotForm = forwardRef(function BotForm( adapter_config: val.adapter_config, enable: val.enable, event_bindings: val.event_bindings || [], + plugin_processors: val.plugin_processors || [], }); handleAdapterSelect(val.adapter); @@ -360,6 +366,7 @@ const BotForm = forwardRef(function BotForm( adapter_config: bot.adapter_config, enable: bot.enable ?? true, event_bindings: bot.event_bindings ?? [], + plugin_processors: bot.plugin_processors ?? [], webhook_full_url: runtimeValues?.webhook_full_url as | string | undefined, @@ -404,6 +411,7 @@ const BotForm = forwardRef(function BotForm( adapter_config: form.getValues().adapter_config, enable: form.getValues().enable, event_bindings: form.getValues().event_bindings ?? [], + plugin_processors: form.getValues().plugin_processors ?? [], }; httpClient .updateBot(initBotId, updateBot) @@ -427,6 +435,7 @@ const BotForm = forwardRef(function BotForm( adapter_config: form.getValues().adapter_config, enable: form.getValues().enable, event_bindings: form.getValues().event_bindings ?? [], + plugin_processors: form.getValues().plugin_processors ?? [], }; httpClient .createBot(newBot) @@ -752,7 +761,21 @@ const BotForm = forwardRef(function BotForm( form={form} botId={initBotId} supportedEvents={adapterSupportedEvents[currentAdapter] || []} - agentOptions={agentNameList} + agentOptions={agentNameList.filter( + (agent) => agent.kind !== 'event_processor', + )} + /> + + form.setValue('plugin_processors', value, { + shouldDirty: true, + }) + } + agents={agentNameList} + onCreated={(agent) => + setAgentNameList((items) => [...items, agent]) + } /> diff --git a/web/src/app/home/bots/components/bot-form/EventBindingsEditor.tsx b/web/src/app/home/bots/components/bot-form/EventBindingsEditor.tsx index d4c6c2d96..9538ffd65 100644 --- a/web/src/app/home/bots/components/bot-form/EventBindingsEditor.tsx +++ b/web/src/app/home/bots/components/bot-form/EventBindingsEditor.tsx @@ -519,11 +519,6 @@ function TargetCombobox({ const pipelines = pipelineAllowed ? agentOptions.filter((a) => a.kind === 'pipeline') : []; - const eventProcessors = agentOptions.filter( - (item) => - item.kind === 'event_processor' && - agentSupportsEventPattern(item, binding.event_pattern), - ); function currentLabel() { if (targetType === 'discard') @@ -593,26 +588,6 @@ function TargetCombobox({ ))} )} - {eventProcessors.length > 0 && ( - - {eventProcessors.map((item) => ( - - select(encodeTarget('event_processor', item.uuid || '')) - } - > - - {targetLabel(item)} - {current === - encodeTarget('event_processor', item.uuid || '') && ( - - )} - - ))} - - )} {pipelines.length > 0 && ( {pipelines.map((a) => ( diff --git a/web/src/app/home/bots/components/bot-form/PluginProcessorBindings.tsx b/web/src/app/home/bots/components/bot-form/PluginProcessorBindings.tsx new file mode 100644 index 000000000..1a0ec6aa8 --- /dev/null +++ b/web/src/app/home/bots/components/bot-form/PluginProcessorBindings.tsx @@ -0,0 +1,466 @@ +import { useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Link } from 'react-router-dom'; +import { Check, Plus, Settings2, ScrollText, X } from 'lucide-react'; +import { toast } from 'sonner'; +import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext'; +import type { + Agent, + PluginProcessorBinding, + RunnerDescriptor, +} from '@/app/infra/entities/api'; +import { httpClient } from '@/app/infra/http'; +import { extractI18nObject } from '@/i18n/I18nProvider'; +import { eventPatternLabel } from '@/app/home/components/event-patterns/event-pattern-groups'; +import DynamicFormComponent from '@/app/home/components/dynamic-form/DynamicFormComponent'; +import { AuthenticatedPluginIcon } from '@/components/AuthenticatedPluginIcon'; +import { Checkbox } from '@/components/ui/checkbox'; +import { + Tooltip, + TooltipContent, + TooltipTrigger, +} from '@/components/ui/tooltip'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent } from '@/components/ui/card'; +import { Switch } from '@/components/ui/switch'; +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from '@/components/ui/dialog'; +import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'; + +export default function PluginProcessorBindings({ + value, + onChange, + agents, + onCreated, +}: { + value: PluginProcessorBinding[]; + onChange: (value: PluginProcessorBinding[]) => void; + agents: Agent[]; + onCreated: (agent: Agent) => void; +}) { + const { t } = useTranslation(); + const { refreshPipelines } = useSidebarData(); + const [open, setOpen] = useState(false); + const [mode, setMode] = useState('existing'); + const [selected, setSelected] = useState([]); + const [components, setComponents] = useState([]); + const [componentRef, setComponentRef] = useState(''); + const [name, setName] = useState(''); + const [parameters, setParameters] = useState>({}); + const [busy, setBusy] = useState(false); + const submitting = useRef(false); + const validate = useRef<(() => Promise) | null>(null); + const component = components.find((item) => item.id === componentRef); + const available = agents.filter( + (agent) => + agent.kind === 'event_processor' && + agent.component_ref && + !value.some((item) => item.processor_uuid === agent.uuid), + ); + + async function showDialog() { + setSelected([]); + setMode(available.length ? 'existing' : 'new'); + setOpen(true); + try { + const metadata = await httpClient.getAgentMetadata(); + setComponents(metadata.event_processors ?? []); + } catch { + toast.error(t('agents.eventProcessor.loadError')); + } + } + + async function add() { + if (submitting.current) return; + if (mode === 'existing') { + if (!selected.length) return; + onChange([ + ...value, + ...selected.map((processor_uuid) => ({ + processor_uuid, + enabled: true, + })), + ]); + setOpen(false); + return; + } + if (!component || !name.trim()) return; + submitting.current = true; + setBusy(true); + try { + if (!((await validate.current?.()) ?? true)) return; + const agent: Agent = { + name: name.trim(), + description: '', + emoji: '🧩', + kind: 'event_processor', + component_ref: componentRef, + config: { + runner: { id: componentRef }, + runner_config: { [componentRef]: parameters }, + }, + }; + const result = await httpClient.createAgent(agent); + onCreated({ + ...agent, + uuid: result.uuid, + supported_event_patterns: component.supported_event_patterns, + }); + onChange([...value, { processor_uuid: result.uuid, enabled: true }]); + void refreshPipelines(); + setOpen(false); + setName(''); + setComponentRef(''); + setParameters({}); + toast.success(t('bots.pluginSubscriptions.created')); + } catch (error) { + toast.error( + t('agents.createError') + + ((error as { msg?: string }).msg ?? + t('agents.eventProcessor.loadError')), + ); + } finally { + submitting.current = false; + setBusy(false); + } + } + + return ( +
+

+ {t('agents.eventProcessor.type')} +

+

+ {t('bots.pluginSubscriptions.description')} +

+ {value.length === 0 && ( +
+

+ {t('bots.pluginSubscriptions.empty')} +

+
+ )} + {value.map((binding) => { + const agent = agents.find( + (item) => item.uuid === binding.processor_uuid, + ); + const title = agent?.name ?? t('agents.eventProcessor.unavailable'); + return ( + + + +
+
{title}
+

+ {agent?.component_ref?.replace('plugin:', '')} +

+

eventPatternLabel(pattern, t)) + .join(' · ')} + > + {(agent?.supported_event_patterns ?? []) + .map((pattern) => eventPatternLabel(pattern, t)) + .join(' · ') || t('agents.eventProcessor.unavailable')} +

+
+
+ {agent && + ( + [ + ['config', Settings2, 'configure'], + ['logs', ScrollText, 'logs'], + ] as const + ).map(([tab, Icon, key]) => ( + + + + + + {t(`bots.pluginSubscriptions.${key}`)} + + + ))} + + onChange( + value.map((item) => + item.processor_uuid === binding.processor_uuid + ? { ...item, enabled } + : item, + ), + ) + } + /> + +
+
+
+ ); + })} + + { + if (!busy) setOpen(next); + }} + > + + + {t('bots.pluginSubscriptions.add')} + + {t('bots.pluginSubscriptions.saveHint')} + + + + + + {t('bots.pluginSubscriptions.existing')} + + + {t('bots.pluginSubscriptions.new')} + + + +
+ {available.map((agent) => ( + + ))} +
+ {available.length === 0 && ( +

+ {t('bots.pluginSubscriptions.noExisting')} +

+ )} +

+ {t('bots.pluginSubscriptions.shared')} +

+
+ +
+
+ {components.map((descriptor) => { + const label = extractI18nObject({ + en_US: descriptor.id, + zh_Hans: descriptor.id, + ...descriptor.label, + }); + return ( + + ); + })} + {components.length === 0 && ( +

+ {t('agents.eventProcessor.noComponents')} +

+ )} +
+ {component && ( +
+ + setName(e.target.value)} + /> +
+ )} + {component && ( + + setParameters(values as Record) + } + onValidate={(fn) => { + validate.current = fn; + }} + /> + )} +
+
+
+ + + + +
+
+
+ ); +} diff --git a/web/src/app/home/components/home-sidebar/HomeSidebar.tsx b/web/src/app/home/components/home-sidebar/HomeSidebar.tsx index 87d1b891a..613ee3f2d 100644 --- a/web/src/app/home/components/home-sidebar/HomeSidebar.tsx +++ b/web/src/app/home/components/home-sidebar/HomeSidebar.tsx @@ -739,7 +739,7 @@ function NavItems({ > = { agent: 'agents.kindBadgeAgent', pipeline: 'agents.kindBadgePipeline', - event_processor: 'agents.eventProcessor.type', + event_processor: 'agents.eventProcessor.configurations', }; const groupOrder: Array<'plugin' | 'mcp' | 'skill'> = [ @@ -893,7 +893,7 @@ function NavItems({ className="ml-auto flex shrink-0 items-center text-muted-foreground" title={ item.kind === 'event_processor' - ? t('agents.eventProcessor.type') + ? t('agents.eventProcessor.configurations') : item.kind === 'pipeline' ? t('agents.kindBadgePipeline') : t('agents.kindBadgeAgent') diff --git a/web/src/app/infra/entities/api/index.ts b/web/src/app/infra/entities/api/index.ts index 6827fc5ca..4d7926593 100644 --- a/web/src/app/infra/entities/api/index.ts +++ b/web/src/app/infra/entities/api/index.ts @@ -329,11 +329,17 @@ export interface Bot { adapter: string; adapter_config: object; event_bindings?: EventBinding[]; + plugin_processors?: PluginProcessorBinding[]; created_at?: string; updated_at?: string; adapter_runtime_values?: object; } +export interface PluginProcessorBinding { + processor_uuid: string; + enabled: boolean; +} + export interface EventBinding { id?: string; event_pattern: string; diff --git a/web/src/i18n/locales/en-US.ts b/web/src/i18n/locales/en-US.ts index 6d37101b2..f76643cc6 100644 --- a/web/src/i18n/locales/en-US.ts +++ b/web/src/i18n/locales/en-US.ts @@ -354,6 +354,25 @@ const enUS = { }, }, bots: { + pluginSubscriptions: { + description: + 'Automatically receive events declared by the plugin, independently of the routes above.', + empty: 'No plugin processors are bound.', + add: 'Add plugin processor', + existing: 'Choose configuration', + new: 'New configuration', + noExisting: 'No configurations available. Create one.', + shared: + 'Bots using the same configuration share settings and runtime state.', + saveHint: + 'Save the bot after adding a processor to activate the binding.', + createAndBind: 'Create and bind', + created: 'Configuration created. Save the bot to activate the binding.', + enable: 'Enable {{name}}', + remove: 'Unbind {{name}}', + configure: 'Configure', + logs: 'View logs', + }, applyFailed: 'Configuration saved, but could not be applied', internalErrorHint: 'An unexpected error occurred. Check the backend logs using the reference below.', @@ -723,6 +742,7 @@ const enUS = { }, agents: { eventProcessor: { + configurations: 'Plugin processor configurations', configTab: 'Configuration', logsTab: 'Logs', noSettings: 'This plugin processor requires no configuration.', @@ -750,14 +770,15 @@ const enUS = { loadError: 'Unable to load processor details.', refresh: 'Refresh', runs: 'Runs', - noRuns: 'No runs yet. Bind a Bot event to start.', - bindBot: 'Bind Bot events', + noRuns: 'No runs yet. Bind this processor to a bot to start.', + bindBot: 'Bind to a bot', trace: 'Logs and message flow', selectRun: 'Select a run to view details.', input: 'Incoming event', destination: 'Delivery destination', loadMore: 'Load more', - activation: 'Install a plugin, create an instance, then bind Bot events.', + activation: + 'Install a plugin, create a processor configuration, then bind a bot.', status_pending: 'Pending', status_running: 'Running', status_completed: 'Completed', diff --git a/web/src/i18n/locales/es-ES.ts b/web/src/i18n/locales/es-ES.ts index 8165083b4..d36774399 100644 --- a/web/src/i18n/locales/es-ES.ts +++ b/web/src/i18n/locales/es-ES.ts @@ -363,6 +363,24 @@ const esES = { "Uses the Workspace owner's LangBot Account billing and credits.", }, bots: { + pluginSubscriptions: { + description: + 'Recibe automáticamente los eventos declarados por el plugin, de forma independiente de las rutas anteriores.', + empty: 'No hay procesadores vinculados.', + add: 'Añadir procesador de plugin', + existing: 'Elegir configuración', + new: 'Nueva configuración', + noExisting: 'No hay configuraciones disponibles. Crea una.', + shared: + 'Los bots que usan la misma configuración comparten ajustes y estado de ejecución.', + saveHint: 'Guarda el bot para activar el vínculo.', + createAndBind: 'Crear y vincular', + created: 'Configuración creada. Guarda el bot para activar el vínculo.', + enable: 'Activar {{name}}', + remove: 'Desvincular {{name}}', + configure: 'Configurar', + logs: 'Ver registros', + }, applyFailed: 'Configuración guardada, pero no se pudo aplicar', internalErrorHint: 'Se produjo un error interno. Consulta los registros del servidor con esta referencia.', @@ -528,6 +546,7 @@ const esES = { }, agents: { eventProcessor: { + configurations: 'Configuraciones de procesadores de plugins', configTab: 'Configuración', logsTab: 'Registros', noSettings: 'Este procesador de plugin no requiere configuración.', @@ -555,15 +574,15 @@ const esES = { loadError: 'No se pudieron cargar los detalles.', refresh: 'Actualizar', runs: 'Ejecuciones', - noRuns: 'Sin ejecuciones. Vincula eventos de un Bot para empezar.', - bindBot: 'Vincular eventos del Bot', + noRuns: 'Sin ejecuciones. Vincula este procesador a un bot para empezar.', + bindBot: 'Vincular a un bot', trace: 'Registros y flujo de mensajes', selectRun: 'Selecciona una ejecución para ver los detalles.', input: 'Evento recibido', destination: 'Destino de entrega', loadMore: 'Cargar más', activation: - 'Instala un plugin, crea una instancia y vincula eventos del Bot.', + 'Instala un plugin, crea una configuración de procesador y vincula un bot.', status_pending: 'Pendiente', status_running: 'En ejecución', status_completed: 'Completado', diff --git a/web/src/i18n/locales/ja-JP.ts b/web/src/i18n/locales/ja-JP.ts index 98dea61c3..2cfb68663 100644 --- a/web/src/i18n/locales/ja-JP.ts +++ b/web/src/i18n/locales/ja-JP.ts @@ -360,6 +360,23 @@ const jaJP = { }, }, bots: { + pluginSubscriptions: { + description: + 'プラグインが宣言したイベントを自動で受信し、上のルートとは独立して実行します。', + empty: 'プラグインプロセッサーは未登録です。', + add: 'プラグインプロセッサーを追加', + existing: '既存の設定を選択', + new: '設定を新規作成', + noExisting: '追加できる設定がありません。新しく作成してください。', + shared: '同じ設定を使用するボットは設定内容と実行状態を共有します。', + saveHint: '追加後にボットを保存すると有効になります。', + createAndBind: '作成して紐付け', + created: '設定を作成しました。ボットを保存すると紐付けが有効になります。', + enable: '{{name}} を有効化', + remove: '{{name}} の紐付けを解除', + configure: '設定', + logs: 'ログを表示', + }, applyFailed: '設定を保存しましたが、適用に失敗しました', internalErrorHint: '内部エラーが発生しました。エラー番号でバックエンドのログを確認してください。', @@ -736,6 +753,7 @@ const jaJP = { }, agents: { eventProcessor: { + configurations: 'プラグインプロセッサー設定', configTab: '設定', logsTab: 'ログ', noSettings: 'このプラグインプロセッサーに設定項目はありません。', @@ -764,15 +782,15 @@ const jaJP = { loadError: '詳細を読み込めません。', refresh: '更新', runs: '実行履歴', - noRuns: '実行履歴はありません。Bot イベントを紐付けて開始します。', - bindBot: 'Bot イベントを紐付ける', + noRuns: '実行履歴はありません。ボットに紐付けて開始します。', + bindBot: 'ボットに紐付ける', trace: 'ログとメッセージの流れ', selectRun: '実行履歴を選択して詳細を表示します。', input: '受信イベント', destination: '送信先', loadMore: 'さらに読み込む', activation: - 'プラグインをインストールし、インスタンスを作成して Bot イベントを紐付けます。', + 'プラグインをインストールし、プロセッサー設定を作成してボットに紐付けます。', status_pending: '待機中', status_running: '実行中', status_completed: '完了', diff --git a/web/src/i18n/locales/ru-RU.ts b/web/src/i18n/locales/ru-RU.ts index e8d8af125..233ef98c2 100644 --- a/web/src/i18n/locales/ru-RU.ts +++ b/web/src/i18n/locales/ru-RU.ts @@ -360,6 +360,24 @@ const ruRU = { "Uses the Workspace owner's LangBot Account billing and credits.", }, bots: { + pluginSubscriptions: { + description: + 'Автоматически получает события, объявленные плагином, независимо от маршрутов выше.', + empty: 'Обработчики плагинов не привязаны.', + add: 'Добавить обработчик плагина', + existing: 'Выбрать конфигурацию', + new: 'Новая конфигурация', + noExisting: 'Нет доступных конфигураций. Создайте новую.', + shared: + 'Боты с общей конфигурацией используют общие настройки и состояние выполнения.', + saveHint: 'Сохраните бота, чтобы активировать привязку.', + createAndBind: 'Создать и привязать', + created: 'Конфигурация создана. Сохраните бота для активации привязки.', + enable: 'Включить {{name}}', + remove: 'Отвязать {{name}}', + configure: 'Настроить', + logs: 'Журнал', + }, applyFailed: 'Настройки сохранены, но не применены', internalErrorHint: 'Внутренняя ошибка. Проверьте журналы сервера по указанному идентификатору.', @@ -524,6 +542,7 @@ const ruRU = { }, agents: { eventProcessor: { + configurations: 'Конфигурации обработчиков плагинов', configTab: 'Настройки', logsTab: 'Журнал', noSettings: 'Этот обработчик плагина не требует настройки.', @@ -550,15 +569,15 @@ const ruRU = { loadError: 'Не удалось загрузить данные.', refresh: 'Обновить', runs: 'Запуски', - noRuns: 'Запусков пока нет. Привяжите события бота.', - bindBot: 'Привязать события бота', + noRuns: 'Запусков пока нет. Привяжите обработчик к боту.', + bindBot: 'Привязать к боту', trace: 'Журнал и поток сообщений', selectRun: 'Выберите запуск для просмотра.', input: 'Входящее событие', destination: 'Получатель', loadMore: 'Загрузить ещё', activation: - 'Установите плагин, создайте экземпляр и привяжите события бота.', + 'Установите плагин, создайте конфигурацию обработчика и привяжите бота.', status_pending: 'Ожидание', status_running: 'Выполняется', status_completed: 'Завершено', diff --git a/web/src/i18n/locales/th-TH.ts b/web/src/i18n/locales/th-TH.ts index cb6f6f3a6..da7009b62 100644 --- a/web/src/i18n/locales/th-TH.ts +++ b/web/src/i18n/locales/th-TH.ts @@ -347,6 +347,23 @@ const thTH = { "Uses the Workspace owner's LangBot Account billing and credits.", }, bots: { + pluginSubscriptions: { + description: + 'รับเหตุการณ์ที่ปลั๊กอินประกาศไว้โดยอัตโนมัติ และทำงานแยกจากเส้นทางด้านบน', + empty: 'ยังไม่ได้เชื่อมโยงตัวประมวลผลปลั๊กอิน', + add: 'เพิ่มตัวประมวลผลปลั๊กอิน', + existing: 'เลือกการตั้งค่า', + new: 'สร้างการตั้งค่า', + noExisting: 'ไม่มีการตั้งค่าที่ใช้ได้ โปรดสร้างใหม่', + shared: 'บอทที่ใช้การตั้งค่าเดียวกันจะแชร์การตั้งค่าและสถานะการทำงาน', + saveHint: 'บันทึกบอตเพื่อเปิดใช้งานการเชื่อมโยง', + createAndBind: 'สร้างและเชื่อมโยง', + created: 'สร้างการตั้งค่าแล้ว บันทึกบอทเพื่อเปิดใช้งานการเชื่อมโยง', + enable: 'เปิดใช้งาน {{name}}', + remove: 'ยกเลิกการเชื่อมโยง {{name}}', + configure: 'ตั้งค่า', + logs: 'ดูบันทึก', + }, applyFailed: 'บันทึกการตั้งค่าแล้ว แต่ไม่สามารถนำไปใช้ได้', internalErrorHint: 'เกิดข้อผิดพลาดภายใน โปรดตรวจสอบบันทึกของเซิร์ฟเวอร์ด้วยหมายเลขอ้างอิง', @@ -511,6 +528,7 @@ const thTH = { }, agents: { eventProcessor: { + configurations: 'การตั้งค่าตัวประมวลผลปลั๊กอิน', configTab: 'การตั้งค่า', logsTab: 'บันทึก', noSettings: 'ตัวประมวลผลปลั๊กอินนี้ไม่ต้องตั้งค่า', @@ -537,14 +555,14 @@ const thTH = { loadError: 'ไม่สามารถโหลดรายละเอียดได้', refresh: 'รีเฟรช', runs: 'ประวัติการทำงาน', - noRuns: 'ยังไม่มีการทำงาน เชื่อมโยงเหตุการณ์บอทเพื่อเริ่มต้น', - bindBot: 'เชื่อมโยงเหตุการณ์บอท', + noRuns: 'ยังไม่มีการทำงาน เชื่อมโยงตัวประมวลผลกับบอทเพื่อเริ่มต้น', + bindBot: 'เชื่อมโยงกับบอท', trace: 'บันทึกและเส้นทางข้อความ', selectRun: 'เลือกการทำงานเพื่อดูรายละเอียด', input: 'เหตุการณ์ขาเข้า', destination: 'ปลายทางการส่ง', loadMore: 'โหลดเพิ่มเติม', - activation: 'ติดตั้งปลั๊กอิน สร้างอินสแตนซ์ แล้วเชื่อมโยงเหตุการณ์บอท', + activation: 'ติดตั้งปลั๊กอิน สร้างการตั้งค่าตัวประมวลผล แล้วเชื่อมโยงบอท', status_pending: 'รอดำเนินการ', status_running: 'กำลังทำงาน', status_completed: 'เสร็จสิ้น', diff --git a/web/src/i18n/locales/vi-VN.ts b/web/src/i18n/locales/vi-VN.ts index 000b89583..783a35bd4 100644 --- a/web/src/i18n/locales/vi-VN.ts +++ b/web/src/i18n/locales/vi-VN.ts @@ -356,6 +356,24 @@ const viVN = { "Uses the Workspace owner's LangBot Account billing and credits.", }, bots: { + pluginSubscriptions: { + description: + 'Tự động nhận sự kiện do plugin khai báo, hoạt động độc lập với các tuyến ở trên.', + empty: 'Chưa liên kết bộ xử lý plugin.', + add: 'Thêm bộ xử lý plugin', + existing: 'Chọn cấu hình', + new: 'Cấu hình mới', + noExisting: 'Chưa có cấu hình khả dụng. Hãy tạo mới.', + shared: + 'Các bot dùng chung cấu hình sẽ chia sẻ thiết lập và trạng thái chạy.', + saveHint: 'Lưu bot để kích hoạt liên kết.', + createAndBind: 'Tạo và liên kết', + created: 'Đã tạo cấu hình. Lưu bot để kích hoạt liên kết.', + enable: 'Bật {{name}}', + remove: 'Hủy liên kết {{name}}', + configure: 'Cấu hình', + logs: 'Xem nhật ký', + }, applyFailed: 'Đã lưu cấu hình nhưng không thể áp dụng', internalErrorHint: 'Đã xảy ra lỗi nội bộ. Hãy kiểm tra nhật ký máy chủ bằng mã lỗi.', @@ -520,6 +538,7 @@ const viVN = { }, agents: { eventProcessor: { + configurations: 'Cấu hình bộ xử lý plugin', configTab: 'Cấu hình', logsTab: 'Nhật ký', noSettings: 'Bộ xử lý plugin này không cần cấu hình.', @@ -546,14 +565,14 @@ const viVN = { loadError: 'Không thể tải chi tiết.', refresh: 'Làm mới', runs: 'Lịch sử chạy', - noRuns: 'Chưa có lần chạy nào. Liên kết sự kiện Bot để bắt đầu.', - bindBot: 'Liên kết sự kiện Bot', + noRuns: 'Chưa có lần chạy nào. Liên kết bộ xử lý với bot để bắt đầu.', + bindBot: 'Liên kết với bot', trace: 'Nhật ký và luồng tin nhắn', selectRun: 'Chọn một lần chạy để xem chi tiết.', input: 'Sự kiện đầu vào', destination: 'Đích gửi', loadMore: 'Tải thêm', - activation: 'Cài plugin, tạo phiên bản rồi liên kết sự kiện Bot.', + activation: 'Cài plugin, tạo cấu hình bộ xử lý rồi liên kết bot.', status_pending: 'Đang chờ', status_running: 'Đang chạy', status_completed: 'Hoàn tất', diff --git a/web/src/i18n/locales/zh-Hans.ts b/web/src/i18n/locales/zh-Hans.ts index 64d55d8e0..2bd2a70c7 100644 --- a/web/src/i18n/locales/zh-Hans.ts +++ b/web/src/i18n/locales/zh-Hans.ts @@ -339,6 +339,22 @@ const zhHans = { }, }, bots: { + pluginSubscriptions: { + description: '自动接收插件声明的事件,与上方事件路由独立执行。', + empty: '尚未绑定插件处理器。', + add: '添加插件处理器', + existing: '选择已有配置', + new: '新建配置', + noExisting: '没有可添加的配置,可新建一份。', + shared: '使用同一配置的机器人会共享设置和运行状态。', + saveHint: '添加后保存机器人配置,绑定才会生效。', + createAndBind: '创建并绑定', + created: '配置已创建,保存机器人配置后生效。', + enable: '启用 {{name}}', + remove: '解除绑定 {{name}}', + configure: '配置', + logs: '查看日志', + }, applyFailed: '配置已保存,但应用失败', internalErrorHint: '发生内部错误,请通过错误编号查看后端日志。', errorReference: '错误编号:{{id}}', @@ -687,6 +703,7 @@ const zhHans = { }, agents: { eventProcessor: { + configurations: '插件处理器配置', configTab: '配置', logsTab: '日志', noSettings: '此插件处理器无需配置。', @@ -712,14 +729,14 @@ const zhHans = { loadError: '无法加载处理器详情。', refresh: '刷新', runs: '运行记录', - noRuns: '暂无运行记录,绑定机器人事件后开始处理。', - bindBot: '绑定机器人事件', + noRuns: '暂无运行记录,绑定机器人后开始处理。', + bindBot: '绑定机器人', trace: '日志与消息流向', selectRun: '选择一条运行记录查看详情。', input: '传入事件', destination: '投递目标', loadMore: '加载更多', - activation: '安装插件,创建实例,再绑定机器人事件。', + activation: '安装插件,创建处理器配置,再绑定机器人。', status_pending: '待执行', status_running: '运行中', status_completed: '已完成', diff --git a/web/src/i18n/locales/zh-Hant.ts b/web/src/i18n/locales/zh-Hant.ts index 0b5148655..b9641cff3 100644 --- a/web/src/i18n/locales/zh-Hant.ts +++ b/web/src/i18n/locales/zh-Hant.ts @@ -336,6 +336,22 @@ const zhHant = { "Uses the Workspace owner's LangBot Account billing and credits.", }, bots: { + pluginSubscriptions: { + description: '自動接收外掛宣告的事件,與上方事件路由獨立執行。', + empty: '尚未綁定外掛處理器。', + add: '新增外掛處理器', + existing: '選擇現有設定', + new: '新增設定', + noExisting: '沒有可新增的設定,請建立一份。', + shared: '使用同一份設定的機器人會共用設定和執行狀態。', + saveHint: '新增後儲存機器人設定,綁定才會生效。', + createAndBind: '建立並綁定', + created: '設定已建立,儲存機器人設定後生效。', + enable: '啟用 {{name}}', + remove: '解除綁定 {{name}}', + configure: '設定', + logs: '查看日誌', + }, applyFailed: '設定已儲存,但套用失敗', internalErrorHint: '發生內部錯誤,請透過錯誤編號查看後端日誌。', errorReference: '錯誤編號:{{id}}', @@ -494,6 +510,7 @@ const zhHant = { }, agents: { eventProcessor: { + configurations: '外掛處理器設定', configTab: '設定', logsTab: '日誌', noSettings: '此外掛處理器無需設定。', @@ -519,14 +536,14 @@ const zhHant = { loadError: '無法載入處理器詳情。', refresh: '重新整理', runs: '執行記錄', - noRuns: '尚無執行記錄,綁定機器人事件後開始處理。', - bindBot: '綁定機器人事件', + noRuns: '尚無執行記錄,綁定機器人後開始處理。', + bindBot: '綁定機器人', trace: '日誌與訊息流向', selectRun: '選擇一筆執行記錄查看詳情。', input: '傳入事件', destination: '傳送目標', loadMore: '載入更多', - activation: '安裝外掛、建立實例,再綁定機器人事件。', + activation: '安裝外掛、建立處理器設定,再綁定機器人。', status_pending: '待執行', status_running: '執行中', status_completed: '已完成', diff --git a/web/tests/e2e/fixtures/langbot-api.ts b/web/tests/e2e/fixtures/langbot-api.ts index 4fa070ae4..97a26088b 100644 --- a/web/tests/e2e/fixtures/langbot-api.ts +++ b/web/tests/e2e/fixtures/langbot-api.ts @@ -64,6 +64,7 @@ interface BotMock { adapter_config: JsonRecord; use_pipeline_uuid?: string; event_bindings: unknown[]; + plugin_processors: unknown[]; pipeline_routing_rules: unknown[]; adapter_runtime_values: JsonRecord; updated_at: string; @@ -505,6 +506,7 @@ function makeBot( ? String(data.use_pipeline_uuid) : undefined, event_bindings: (data.event_bindings as unknown[] | undefined) || [], + plugin_processors: (data.plugin_processors as unknown[] | undefined) || [], pipeline_routing_rules: (data.pipeline_routing_rules as unknown[] | undefined) || [], adapter_runtime_values: { diff --git a/web/tests/e2e/plugin-subscriptions.spec.ts b/web/tests/e2e/plugin-subscriptions.spec.ts new file mode 100644 index 000000000..99281355b --- /dev/null +++ b/web/tests/e2e/plugin-subscriptions.spec.ts @@ -0,0 +1,135 @@ +import { expect, test } from '@playwright/test'; +import { installLangBotApiMocks } from './fixtures/langbot-api'; + +test('creates a configured processor, persists subscriptions separately and reuses an instance', async ({ + page, +}) => { + await installLangBotApiMocks(page, { + authenticated: true, + withAdapterEvents: true, + }); + const ref = 'plugin:test/welcome/default'; + const processors: Record[] = []; + await page.route('**/api/v1/agents/_/metadata', async (route) => + route.fulfill({ + json: { + code: 0, + data: { + event_processors: [ + { + id: ref, + plugin_author: 'test', + plugin_name: 'welcome', + usages: ['event'], + label: { en_US: 'Welcome members', zh_Hans: '欢迎新成员' }, + supported_event_patterns: ['group.member_joined'], + config_schema: [ + { + name: 'greeting', + type: 'string', + label: { en_US: 'Greeting', zh_Hans: '欢迎语' }, + required: true, + default: 'Hello', + }, + ], + }, + ], + }, + }, + }), + ); + await page.route('**/api/v1/agents', async (route) => { + if (route.request().method() === 'POST') { + processors.push({ + ...route.request().postDataJSON(), + uuid: 'processor-new', + supported_event_patterns: ['group.member_joined'], + }); + return route.fulfill({ + json: { + code: 0, + data: { uuid: 'processor-new', kind: 'event_processor' }, + }, + }); + } + return route.fulfill({ json: { code: 0, data: { agents: processors } } }); + }); + await page.goto('/home/bots?id=new'); + await page.getByRole('combobox').click(); + await page.getByRole('option', { name: 'Playwright Adapter' }).click(); + await page.locator('input[name="name"]').fill('Subscription Bot'); + await page.getByRole('button', { name: /^Submit$/ }).click(); + await expect(page).toHaveURL(/id=bot-1$/); + const section = page.getByRole('region', { + name: 'Plugin processor', + exact: true, + }); + await section.getByRole('button', { name: 'Add plugin processor' }).click(); + const dialog = page.getByRole('dialog'); + await dialog.getByRole('button', { name: /Welcome members/ }).click(); + await dialog.getByLabel('Name', { exact: true }).fill('Customer welcome'); + await dialog.locator('input[name="greeting"]').fill('Welcome aboard'); + await dialog.getByRole('button', { name: 'Create and bind' }).click(); + await expect(dialog).toBeHidden(); + expect(processors[0]).toMatchObject({ + kind: 'event_processor', + component_ref: ref, + config: { runner_config: { [ref]: { greeting: 'Welcome aboard' } } }, + }); + await expect(section).toContainText('Customer welcome'); + const save = async () => { + const request = page.waitForRequest( + (r) => r.method() === 'PUT' && r.url().endsWith('/platform/bots/bot-1'), + ); + await page.getByRole('button', { name: /^Save$/ }).click(); + const body = (await request).postDataJSON(); + await expect(page.getByRole('button', { name: /^Save$/ })).toBeDisabled(); + return body; + }; + const body = await save(); + expect(body.plugin_processors).toEqual([ + { processor_uuid: 'processor-new', enabled: true }, + ]); + expect(body.event_bindings).toEqual([]); + await page.reload(); + await expect(section).toContainText('Customer welcome'); + await expect( + section.getByRole('link', { name: 'View logs' }), + ).toHaveAttribute('href', '/home/agents?id=processor-new&tab=logs'); + await section + .getByRole('switch', { name: 'Enable Customer welcome' }) + .click(); + expect((await save()).plugin_processors).toEqual([ + { processor_uuid: 'processor-new', enabled: false }, + ]); + await section + .getByRole('button', { name: 'Unbind Customer welcome' }) + .click(); + await section.getByRole('button', { name: 'Add plugin processor' }).click(); + await dialog.getByRole('checkbox', { name: /Customer welcome/ }).check(); + await dialog + .getByRole('button', { name: 'Add plugin processor', exact: true }) + .click(); + await expect(section).toContainText('Customer welcome'); + expect(processors).toHaveLength(1); + + processors.push({ + ...processors[0], + uuid: 'processor-observer', + name: 'Event observer', + }); + await page.reload(); + await section + .getByRole('button', { name: 'Unbind Customer welcome' }) + .click(); + await section.getByRole('button', { name: 'Add plugin processor' }).click(); + await dialog.getByRole('checkbox', { name: /Customer welcome/ }).check(); + await dialog.getByRole('checkbox', { name: /Event observer/ }).check(); + await dialog + .getByRole('button', { name: 'Add plugin processor', exact: true }) + .click(); + expect((await save()).plugin_processors).toEqual([ + { processor_uuid: 'processor-new', enabled: true }, + { processor_uuid: 'processor-observer', enabled: true }, + ]); +});