mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-09-17 15:27:15 +00:00
feat(bots): bind plugin processor configurations independently
This commit is contained in:
@@ -118,6 +118,7 @@ Pipeline components are registered by decorators and package import side effects
|
|||||||
Platform code lives under `pkg/platform/`.
|
Platform code lives under `pkg/platform/`.
|
||||||
|
|
||||||
- `botmgr.py` owns runtime bots, routing rules, event logging, webhook pushing, and adapter lifecycle.
|
- `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.
|
- `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.
|
- Platform entities such as `MessageChain`, `Image`, `At`, `Voice`, and events come from `langbot-plugin-sdk`, not from this repo.
|
||||||
|
|
||||||
|
|||||||
@@ -119,8 +119,16 @@ already have a default pipeline.
|
|||||||
Create a processor with `kind: "event_processor"` and basic information. Without
|
Create a processor with `kind: "event_processor"` and basic information. Without
|
||||||
a component it supports no events. Discover installed components with
|
a component it supports no events. Discover installed components with
|
||||||
`get_processor_metadata`, then use `update_processor` with `component_ref` and
|
`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"`
|
optional `parameters`. API callers may also supply these when creating an instance.
|
||||||
and `target_id` equal to its UUID. Installation alone never activates a handler.
|
Bind an instance by updating the bot's `plugin_processors` array with
|
||||||
|
`{"processor_uuid": "<instance 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.
|
`debug_agent` accepts the complete typed event in `payload.data` for this kind.
|
||||||
Legacy EventListener plugins remain in the Pipeline lifecycle.
|
Legacy EventListener plugins remain in the Pipeline lifecycle.
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ from .tenant import TenantContext, require_workspace_uuid, scope_statement
|
|||||||
from ....utils import httpclient
|
from ....utils import httpclient
|
||||||
from ....platform.sources import http_bot_signing
|
from ....platform.sources import http_bot_signing
|
||||||
from ....platform.adapter_names import canonical_adapter_name
|
from ....platform.adapter_names import canonical_adapter_name
|
||||||
|
from ....agent.runner.errors import RunnerNotFoundError
|
||||||
|
|
||||||
|
|
||||||
class BotService:
|
class BotService:
|
||||||
@@ -36,6 +37,7 @@ class BotService:
|
|||||||
'adapter_config',
|
'adapter_config',
|
||||||
'enable',
|
'enable',
|
||||||
'event_bindings',
|
'event_bindings',
|
||||||
|
'plugin_processors',
|
||||||
}
|
}
|
||||||
|
|
||||||
def __init__(self, ap: app.Application) -> None:
|
def __init__(self, ap: app.Application) -> None:
|
||||||
@@ -517,7 +519,7 @@ class BotService:
|
|||||||
)
|
)
|
||||||
if result.first() is None:
|
if result.first() is None:
|
||||||
raise ValueError('Pipeline not found')
|
raise ValueError('Pipeline not found')
|
||||||
elif target_type in {'agent', 'event_processor'}:
|
elif target_type == 'agent':
|
||||||
result = await self.ap.persistence_mgr.execute_async(
|
result = await self.ap.persistence_mgr.execute_async(
|
||||||
scope_statement(
|
scope_statement(
|
||||||
sqlalchemy.select(persistence_agent.Agent).where(persistence_agent.Agent.uuid == target_uuid),
|
sqlalchemy.select(persistence_agent.Agent).where(persistence_agent.Agent.uuid == target_uuid),
|
||||||
@@ -551,6 +553,37 @@ class BotService:
|
|||||||
|
|
||||||
return normalized
|
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:
|
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."""
|
"""Normalize Bot write payloads to the current event-routing model."""
|
||||||
update_data = bot_data.copy()
|
update_data = bot_data.copy()
|
||||||
@@ -564,6 +597,10 @@ class BotService:
|
|||||||
update_data['event_bindings'] = await self._normalize_event_bindings(
|
update_data['event_bindings'] = await self._normalize_event_bindings(
|
||||||
context, update_data.get('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
|
return update_data
|
||||||
|
|
||||||
async def get_bots(self, context: TenantContext, include_secret: bool = False) -> list[dict]:
|
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['uuid'] = str(uuid.uuid4())
|
||||||
bot_data['workspace_uuid'] = workspace_uuid
|
bot_data['workspace_uuid'] = workspace_uuid
|
||||||
bot_data.setdefault('event_bindings', [])
|
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))
|
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:
|
if getattr(result, 'rowcount', None) == 0:
|
||||||
raise WorkspaceNotFoundError('Bot not found')
|
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):
|
if not runtime_fields.intersection(update_data):
|
||||||
runtime_bot = await self.ap.platform_mgr.get_bot_by_uuid(context, bot_uuid)
|
runtime_bot = await self.ap.platform_mgr.get_bot_by_uuid(context, bot_uuid)
|
||||||
if runtime_bot is not None:
|
if runtime_bot is not None:
|
||||||
|
|||||||
@@ -109,14 +109,22 @@ class LangBotMCPServer:
|
|||||||
description=(
|
description=(
|
||||||
'Create a bot. `bot_data` is a JSON object matching the LangBot '
|
'Create a bot. `bot_data` is a JSON object matching the LangBot '
|
||||||
'POST /api/v1/platform/bots body (e.g. name, adapter, config). '
|
'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:
|
async def create_bot(bot_data: dict) -> str:
|
||||||
context = _authorized(Permission.RESOURCE_MANAGE)
|
context = _authorized(Permission.RESOURCE_MANAGE)
|
||||||
return _dump({'uuid': await ap.bot_service.create_bot(context, bot_data)})
|
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:
|
async def update_bot(bot_uuid: str, bot_data: dict) -> str:
|
||||||
context = _authorized(Permission.RESOURCE_MANAGE)
|
context = _authorized(Permission.RESOURCE_MANAGE)
|
||||||
await ap.bot_service.update_bot(context, bot_uuid, bot_data)
|
await ap.bot_service.update_bot(context, bot_uuid, bot_data)
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ class Bot(Base):
|
|||||||
adapter_config = sqlalchemy.Column(sqlalchemy.JSON, nullable=False)
|
adapter_config = sqlalchemy.Column(sqlalchemy.JSON, nullable=False)
|
||||||
enable = sqlalchemy.Column(sqlalchemy.Boolean, nullable=False, default=False)
|
enable = sqlalchemy.Column(sqlalchemy.Boolean, nullable=False, default=False)
|
||||||
event_bindings = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, server_default='[]')
|
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())
|
created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now())
|
||||||
updated_at = sqlalchemy.Column(
|
updated_at = sqlalchemy.Column(
|
||||||
sqlalchemy.DateTime,
|
sqlalchemy.DateTime,
|
||||||
|
|||||||
@@ -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')
|
||||||
@@ -280,6 +280,8 @@ class RuntimeBot:
|
|||||||
diagnostic_steps: list[dict[str, typing.Any]] = []
|
diagnostic_steps: list[dict[str, typing.Any]] = []
|
||||||
|
|
||||||
for index, binding in enumerate(bindings):
|
for index, binding in enumerate(bindings):
|
||||||
|
if binding.get('target_type') == 'event_processor':
|
||||||
|
continue
|
||||||
event_pattern = str(binding.get('event_pattern') or '')
|
event_pattern = str(binding.get('event_pattern') or '')
|
||||||
priority = int(binding.get('priority') or 0)
|
priority = int(binding.get('priority') or 0)
|
||||||
order = int(binding.get('order', index))
|
order = int(binding.get('order', index))
|
||||||
@@ -841,22 +843,72 @@ class RuntimeBot:
|
|||||||
event.bot_uuid = self.bot_entity.uuid
|
event.bot_uuid = self.bot_entity.uuid
|
||||||
await self._record_adapter_event(event, adapter)
|
await self._record_adapter_event(event, adapter)
|
||||||
|
|
||||||
if isinstance(event, platform_events.PlatformSpecificEvent) and event.action == 'interaction.submitted':
|
primary = (
|
||||||
await self._handle_interaction_submission(event, adapter)
|
self._handle_interaction_submission(event, adapter)
|
||||||
return
|
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
|
async def _dispatch_plugin_subscription(self, event, adapter, processor_uuid):
|
||||||
# explicitly created and routed plugin processor instance.
|
event_type = event.type
|
||||||
await self._dispatch_eba_event_to_processor(event, adapter)
|
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(
|
async def _dispatch_eba_event_to_processor(
|
||||||
self,
|
self,
|
||||||
event: platform_events.EBAEvent,
|
event: platform_events.EBAEvent,
|
||||||
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter,
|
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter,
|
||||||
|
event_binding: dict | None = None,
|
||||||
|
agent: dict | None = None,
|
||||||
) -> dict[str, typing.Any]:
|
) -> dict[str, typing.Any]:
|
||||||
event_type = getattr(event, 'type', None) or event.__class__.__name__
|
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:
|
if event_binding is None:
|
||||||
return await self._record_event_route_trace(
|
return await self._record_event_route_trace(
|
||||||
event_type=event_type,
|
event_type=event_type,
|
||||||
@@ -938,7 +990,8 @@ class RuntimeBot:
|
|||||||
)
|
)
|
||||||
|
|
||||||
target_uuid = event_binding.get('target_uuid')
|
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:
|
if not agent or agent.get('kind') != target_type:
|
||||||
return await self._record_event_route_trace(
|
return await self._record_event_route_trace(
|
||||||
event_type=event_type,
|
event_type=event_type,
|
||||||
|
|||||||
@@ -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'}])
|
||||||
@@ -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()
|
||||||
@@ -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'
|
||||||
@@ -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():
|
async def test_bound_event_processor_receives_one_complete_typed_event():
|
||||||
from langbot_plugin.api.entities.builtin.platform.events import MemberJoinedEvent
|
from langbot_plugin.api.entities.builtin.platform.events import MemberJoinedEvent
|
||||||
|
|
||||||
bot = TestEventRouteTrace._make_bot(
|
bot = TestEventRouteTrace._make_bot([])
|
||||||
[
|
bot.bot_entity.plugin_processors = [{'processor_uuid': 'processor-1', 'enabled': True}]
|
||||||
{
|
|
||||||
'id': 'binding',
|
|
||||||
'enabled': True,
|
|
||||||
'event_pattern': 'group.member_joined',
|
|
||||||
'target_type': 'event_processor',
|
|
||||||
'target_uuid': 'processor-1',
|
|
||||||
'priority': 0,
|
|
||||||
'order': 0,
|
|
||||||
}
|
|
||||||
]
|
|
||||||
)
|
|
||||||
calls = []
|
calls = []
|
||||||
|
|
||||||
async def run(envelope, binding, adapter_context=None):
|
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),
|
agent_run_orchestrator=SimpleNamespace(run=run),
|
||||||
plugin_connector=SimpleNamespace(emit_event=AsyncMock()),
|
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()
|
bot._record_adapter_event = AsyncMock()
|
||||||
await bot._handle_platform_event(
|
await bot._handle_platform_event(
|
||||||
MemberJoinedEvent(
|
MemberJoinedEvent(
|
||||||
@@ -836,7 +828,9 @@ async def test_processor_outputs_require_explicit_platform_actions(kind, output_
|
|||||||
chat_type=entities.ChatType.PRIVATE,
|
chat_type=entities.ChatType.PRIVATE,
|
||||||
chat_id='user-1',
|
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')
|
assert trace['status'] == ('failed' if runner_fails else 'delivered')
|
||||||
if runner_fails:
|
if runner_fails:
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
import { useTranslation } from 'react-i18next';
|
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 { eventPatternLabel } from '@/app/home/components/event-patterns/event-pattern-groups';
|
||||||
import { RefreshCw, Trash2, ScrollText, Settings2 } from 'lucide-react';
|
import { RefreshCw, Trash2, ScrollText, Settings2 } from 'lucide-react';
|
||||||
import isEqual from 'lodash/isEqual';
|
import isEqual from 'lodash/isEqual';
|
||||||
@@ -48,7 +48,10 @@ export default function PluginProcessorDetailContent({
|
|||||||
onSaved: () => void;
|
onSaved: () => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
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<AgentPlatformTool[]>([]);
|
const [platformTools, setPlatformTools] = useState<AgentPlatformTool[]>([]);
|
||||||
const toolLabels = Object.fromEntries(
|
const toolLabels = Object.fromEntries(
|
||||||
platformTools.map((tool) => [tool.name, extractI18nObject(tool.label)]),
|
platformTools.map((tool) => [tool.name, extractI18nObject(tool.label)]),
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import { getAdapterDocUrl } from '@/app/infra/entities/adapter-docs';
|
|||||||
import { ExternalLink, ChevronDown, ChevronRight } from 'lucide-react';
|
import { ExternalLink, ChevronDown, ChevronRight } from 'lucide-react';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import EventBindingsEditor from './EventBindingsEditor';
|
import EventBindingsEditor from './EventBindingsEditor';
|
||||||
|
import PluginProcessorBindings from './PluginProcessorBindings';
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { useForm } from 'react-hook-form';
|
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: z.string().min(1, { message: t('bots.adapterRequired') }),
|
||||||
adapter_config: z.record(z.string(), z.any()),
|
adapter_config: z.record(z.string(), z.any()),
|
||||||
enable: z.boolean().optional(),
|
enable: z.boolean().optional(),
|
||||||
|
plugin_processors: z
|
||||||
|
.array(z.object({ processor_uuid: z.string(), enabled: z.boolean() }))
|
||||||
|
.optional(),
|
||||||
event_bindings: z
|
event_bindings: z
|
||||||
.array(
|
.array(
|
||||||
z.object({
|
z.object({
|
||||||
@@ -127,6 +131,7 @@ const BotForm = forwardRef<BotFormHandle, BotFormProps>(function BotForm(
|
|||||||
adapter_config: {},
|
adapter_config: {},
|
||||||
enable: true,
|
enable: true,
|
||||||
event_bindings: [],
|
event_bindings: [],
|
||||||
|
plugin_processors: [],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -237,6 +242,7 @@ const BotForm = forwardRef<BotFormHandle, BotFormProps>(function BotForm(
|
|||||||
adapter_config: val.adapter_config,
|
adapter_config: val.adapter_config,
|
||||||
enable: val.enable,
|
enable: val.enable,
|
||||||
event_bindings: val.event_bindings || [],
|
event_bindings: val.event_bindings || [],
|
||||||
|
plugin_processors: val.plugin_processors || [],
|
||||||
});
|
});
|
||||||
handleAdapterSelect(val.adapter);
|
handleAdapterSelect(val.adapter);
|
||||||
|
|
||||||
@@ -360,6 +366,7 @@ const BotForm = forwardRef<BotFormHandle, BotFormProps>(function BotForm(
|
|||||||
adapter_config: bot.adapter_config,
|
adapter_config: bot.adapter_config,
|
||||||
enable: bot.enable ?? true,
|
enable: bot.enable ?? true,
|
||||||
event_bindings: bot.event_bindings ?? [],
|
event_bindings: bot.event_bindings ?? [],
|
||||||
|
plugin_processors: bot.plugin_processors ?? [],
|
||||||
webhook_full_url: runtimeValues?.webhook_full_url as
|
webhook_full_url: runtimeValues?.webhook_full_url as
|
||||||
| string
|
| string
|
||||||
| undefined,
|
| undefined,
|
||||||
@@ -404,6 +411,7 @@ const BotForm = forwardRef<BotFormHandle, BotFormProps>(function BotForm(
|
|||||||
adapter_config: form.getValues().adapter_config,
|
adapter_config: form.getValues().adapter_config,
|
||||||
enable: form.getValues().enable,
|
enable: form.getValues().enable,
|
||||||
event_bindings: form.getValues().event_bindings ?? [],
|
event_bindings: form.getValues().event_bindings ?? [],
|
||||||
|
plugin_processors: form.getValues().plugin_processors ?? [],
|
||||||
};
|
};
|
||||||
httpClient
|
httpClient
|
||||||
.updateBot(initBotId, updateBot)
|
.updateBot(initBotId, updateBot)
|
||||||
@@ -427,6 +435,7 @@ const BotForm = forwardRef<BotFormHandle, BotFormProps>(function BotForm(
|
|||||||
adapter_config: form.getValues().adapter_config,
|
adapter_config: form.getValues().adapter_config,
|
||||||
enable: form.getValues().enable,
|
enable: form.getValues().enable,
|
||||||
event_bindings: form.getValues().event_bindings ?? [],
|
event_bindings: form.getValues().event_bindings ?? [],
|
||||||
|
plugin_processors: form.getValues().plugin_processors ?? [],
|
||||||
};
|
};
|
||||||
httpClient
|
httpClient
|
||||||
.createBot(newBot)
|
.createBot(newBot)
|
||||||
@@ -752,7 +761,21 @@ const BotForm = forwardRef<BotFormHandle, BotFormProps>(function BotForm(
|
|||||||
form={form}
|
form={form}
|
||||||
botId={initBotId}
|
botId={initBotId}
|
||||||
supportedEvents={adapterSupportedEvents[currentAdapter] || []}
|
supportedEvents={adapterSupportedEvents[currentAdapter] || []}
|
||||||
agentOptions={agentNameList}
|
agentOptions={agentNameList.filter(
|
||||||
|
(agent) => agent.kind !== 'event_processor',
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<PluginProcessorBindings
|
||||||
|
value={form.watch('plugin_processors') ?? []}
|
||||||
|
onChange={(value) =>
|
||||||
|
form.setValue('plugin_processors', value, {
|
||||||
|
shouldDirty: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
agents={agentNameList}
|
||||||
|
onCreated={(agent) =>
|
||||||
|
setAgentNameList((items) => [...items, agent])
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -519,11 +519,6 @@ function TargetCombobox({
|
|||||||
const pipelines = pipelineAllowed
|
const pipelines = pipelineAllowed
|
||||||
? agentOptions.filter((a) => a.kind === 'pipeline')
|
? agentOptions.filter((a) => a.kind === 'pipeline')
|
||||||
: [];
|
: [];
|
||||||
const eventProcessors = agentOptions.filter(
|
|
||||||
(item) =>
|
|
||||||
item.kind === 'event_processor' &&
|
|
||||||
agentSupportsEventPattern(item, binding.event_pattern),
|
|
||||||
);
|
|
||||||
|
|
||||||
function currentLabel() {
|
function currentLabel() {
|
||||||
if (targetType === 'discard')
|
if (targetType === 'discard')
|
||||||
@@ -593,26 +588,6 @@ function TargetCombobox({
|
|||||||
))}
|
))}
|
||||||
</CommandGroup>
|
</CommandGroup>
|
||||||
)}
|
)}
|
||||||
{eventProcessors.length > 0 && (
|
|
||||||
<CommandGroup heading={t('agents.eventProcessor.type')}>
|
|
||||||
{eventProcessors.map((item) => (
|
|
||||||
<CommandItem
|
|
||||||
key={item.uuid}
|
|
||||||
value={`event_processor:${item.uuid}:${item.name}`}
|
|
||||||
onSelect={() =>
|
|
||||||
select(encodeTarget('event_processor', item.uuid || ''))
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<FileCode2 className="mr-2 size-3.5 shrink-0" />
|
|
||||||
<span className="truncate">{targetLabel(item)}</span>
|
|
||||||
{current ===
|
|
||||||
encodeTarget('event_processor', item.uuid || '') && (
|
|
||||||
<Check className="ml-auto size-3.5 shrink-0" />
|
|
||||||
)}
|
|
||||||
</CommandItem>
|
|
||||||
))}
|
|
||||||
</CommandGroup>
|
|
||||||
)}
|
|
||||||
{pipelines.length > 0 && (
|
{pipelines.length > 0 && (
|
||||||
<CommandGroup heading={t('bots.targetPipeline')}>
|
<CommandGroup heading={t('bots.targetPipeline')}>
|
||||||
{pipelines.map((a) => (
|
{pipelines.map((a) => (
|
||||||
|
|||||||
@@ -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<string[]>([]);
|
||||||
|
const [components, setComponents] = useState<RunnerDescriptor[]>([]);
|
||||||
|
const [componentRef, setComponentRef] = useState('');
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
const [parameters, setParameters] = useState<Record<string, unknown>>({});
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const submitting = useRef(false);
|
||||||
|
const validate = useRef<(() => Promise<boolean>) | 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 (
|
||||||
|
<section
|
||||||
|
className="mt-6 space-y-3 border-t pt-5"
|
||||||
|
aria-labelledby="plugin-subscriptions-title"
|
||||||
|
>
|
||||||
|
<h3
|
||||||
|
id="plugin-subscriptions-title"
|
||||||
|
className="text-sm font-semibold text-foreground"
|
||||||
|
>
|
||||||
|
{t('agents.eventProcessor.type')}
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t('bots.pluginSubscriptions.description')}
|
||||||
|
</p>
|
||||||
|
{value.length === 0 && (
|
||||||
|
<div className="flex h-32 items-center justify-center rounded-lg border-2 border-dashed border-border">
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t('bots.pluginSubscriptions.empty')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{value.map((binding) => {
|
||||||
|
const agent = agents.find(
|
||||||
|
(item) => item.uuid === binding.processor_uuid,
|
||||||
|
);
|
||||||
|
const title = agent?.name ?? t('agents.eventProcessor.unavailable');
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
key={binding.processor_uuid}
|
||||||
|
className="gap-0 rounded-lg py-0 shadow-none hover:bg-accent"
|
||||||
|
>
|
||||||
|
<CardContent className="flex items-center gap-3 p-3">
|
||||||
|
<span
|
||||||
|
className="flex size-10 shrink-0 items-center justify-center rounded-lg border bg-muted text-2xl"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
{agent?.emoji || '🧩'}
|
||||||
|
</span>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="truncate font-medium">{title}</div>
|
||||||
|
<p
|
||||||
|
className="truncate text-sm text-muted-foreground"
|
||||||
|
title={agent?.component_ref?.replace('plugin:', '')}
|
||||||
|
>
|
||||||
|
{agent?.component_ref?.replace('plugin:', '')}
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
className="truncate text-xs text-muted-foreground"
|
||||||
|
title={(agent?.supported_event_patterns ?? [])
|
||||||
|
.map((pattern) => eventPatternLabel(pattern, t))
|
||||||
|
.join(' · ')}
|
||||||
|
>
|
||||||
|
{(agent?.supported_event_patterns ?? [])
|
||||||
|
.map((pattern) => eventPatternLabel(pattern, t))
|
||||||
|
.join(' · ') || t('agents.eventProcessor.unavailable')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 items-center gap-1">
|
||||||
|
{agent &&
|
||||||
|
(
|
||||||
|
[
|
||||||
|
['config', Settings2, 'configure'],
|
||||||
|
['logs', ScrollText, 'logs'],
|
||||||
|
] as const
|
||||||
|
).map(([tab, Icon, key]) => (
|
||||||
|
<Tooltip key={tab}>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
asChild
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="size-8"
|
||||||
|
>
|
||||||
|
<Link
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
to={`/home/agents?id=${agent.uuid}&tab=${tab}`}
|
||||||
|
aria-label={t(`bots.pluginSubscriptions.${key}`)}
|
||||||
|
>
|
||||||
|
<Icon className="size-4" />
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
{t(`bots.pluginSubscriptions.${key}`)}
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
))}
|
||||||
|
<Switch
|
||||||
|
aria-label={t('bots.pluginSubscriptions.enable', {
|
||||||
|
name: title,
|
||||||
|
})}
|
||||||
|
checked={binding.enabled}
|
||||||
|
onCheckedChange={(enabled) =>
|
||||||
|
onChange(
|
||||||
|
value.map((item) =>
|
||||||
|
item.processor_uuid === binding.processor_uuid
|
||||||
|
? { ...item, enabled }
|
||||||
|
: item,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
size="icon"
|
||||||
|
variant="ghost"
|
||||||
|
aria-label={t('bots.pluginSubscriptions.remove', {
|
||||||
|
name: title,
|
||||||
|
})}
|
||||||
|
onClick={() =>
|
||||||
|
onChange(
|
||||||
|
value.filter(
|
||||||
|
(item) =>
|
||||||
|
item.processor_uuid !== binding.processor_uuid,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<X className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
className="w-full"
|
||||||
|
onClick={showDialog}
|
||||||
|
>
|
||||||
|
<Plus className="size-4" />
|
||||||
|
{t('bots.pluginSubscriptions.add')}
|
||||||
|
</Button>
|
||||||
|
<Dialog
|
||||||
|
open={open}
|
||||||
|
onOpenChange={(next) => {
|
||||||
|
if (!busy) setOpen(next);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogContent className="flex max-h-[80vh] flex-col overflow-hidden sm:max-w-2xl">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{t('bots.pluginSubscriptions.add')}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{t('bots.pluginSubscriptions.saveHint')}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<Tabs
|
||||||
|
value={mode}
|
||||||
|
onValueChange={setMode}
|
||||||
|
className="min-h-0 overflow-y-auto"
|
||||||
|
>
|
||||||
|
<TabsList className="w-full">
|
||||||
|
<TabsTrigger value="existing" disabled={busy}>
|
||||||
|
{t('bots.pluginSubscriptions.existing')}
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="new" disabled={busy}>
|
||||||
|
{t('bots.pluginSubscriptions.new')}
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
<TabsContent value="existing" className="space-y-3">
|
||||||
|
<div className="max-h-80 space-y-2 overflow-y-auto">
|
||||||
|
{available.map((agent) => (
|
||||||
|
<label
|
||||||
|
key={agent.uuid}
|
||||||
|
className="flex cursor-pointer items-center gap-3 rounded-lg border p-3 hover:bg-accent"
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
checked={selected.includes(agent.uuid!)}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
setSelected((current) =>
|
||||||
|
checked
|
||||||
|
? [...current, agent.uuid!]
|
||||||
|
: current.filter((id) => id !== agent.uuid),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span
|
||||||
|
className="flex size-10 shrink-0 items-center justify-center rounded-lg border bg-muted text-2xl"
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
{agent.emoji || '🧩'}
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0 flex-1">
|
||||||
|
<span className="block truncate font-medium">
|
||||||
|
{agent.name}
|
||||||
|
</span>
|
||||||
|
<span className="block truncate text-sm text-muted-foreground">
|
||||||
|
{agent.component_ref?.replace('plugin:', '')}
|
||||||
|
</span>
|
||||||
|
<span className="block truncate text-xs text-muted-foreground">
|
||||||
|
{(agent.supported_event_patterns ?? [])
|
||||||
|
.map((pattern) => eventPatternLabel(pattern, t))
|
||||||
|
.join(' · ')}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{available.length === 0 && (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t('bots.pluginSubscriptions.noExisting')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t('bots.pluginSubscriptions.shared')}
|
||||||
|
</p>
|
||||||
|
</TabsContent>
|
||||||
|
<TabsContent value="new">
|
||||||
|
<fieldset disabled={busy} className="space-y-4">
|
||||||
|
<div
|
||||||
|
className="max-h-60 space-y-2 overflow-y-auto"
|
||||||
|
role="group"
|
||||||
|
aria-label={t('agents.eventProcessor.component')}
|
||||||
|
>
|
||||||
|
{components.map((descriptor) => {
|
||||||
|
const label = extractI18nObject({
|
||||||
|
en_US: descriptor.id,
|
||||||
|
zh_Hans: descriptor.id,
|
||||||
|
...descriptor.label,
|
||||||
|
});
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
key={descriptor.id}
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
aria-pressed={componentRef === descriptor.id}
|
||||||
|
className="h-auto w-full justify-start gap-3 whitespace-normal p-3 text-left font-normal shadow-none aria-pressed:bg-accent"
|
||||||
|
onClick={() => {
|
||||||
|
setComponentRef(descriptor.id);
|
||||||
|
validate.current = null;
|
||||||
|
setParameters(
|
||||||
|
Object.fromEntries(
|
||||||
|
(descriptor.config_schema ?? [])
|
||||||
|
.filter((field) => field.default !== undefined)
|
||||||
|
.map((field) => [field.name, field.default]),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (!name.trim()) setName(label);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<AuthenticatedPluginIcon
|
||||||
|
author={descriptor.plugin_author}
|
||||||
|
name={descriptor.plugin_name}
|
||||||
|
className="size-10 shrink-0 rounded-lg border bg-muted object-cover"
|
||||||
|
/>
|
||||||
|
<span className="min-w-0 flex-1">
|
||||||
|
<span className="block truncate font-medium">
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
<span className="block truncate text-sm text-muted-foreground">
|
||||||
|
{descriptor.plugin_author}/{descriptor.plugin_name}
|
||||||
|
</span>
|
||||||
|
<span className="block truncate text-xs text-muted-foreground">
|
||||||
|
{descriptor.supported_event_patterns
|
||||||
|
.map((pattern) => eventPatternLabel(pattern, t))
|
||||||
|
.join(' · ')}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
{componentRef === descriptor.id && (
|
||||||
|
<Check className="size-4 shrink-0" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{components.length === 0 && (
|
||||||
|
<p className="py-6 text-center text-sm text-muted-foreground">
|
||||||
|
{t('agents.eventProcessor.noComponents')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{component && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="new-plugin-processor-name">
|
||||||
|
{t('common.name')}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="new-plugin-processor-name"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{component && (
|
||||||
|
<DynamicFormComponent
|
||||||
|
key={componentRef}
|
||||||
|
itemConfigList={component.config_schema}
|
||||||
|
initialValues={parameters}
|
||||||
|
onSubmit={(values) =>
|
||||||
|
setParameters(values as Record<string, unknown>)
|
||||||
|
}
|
||||||
|
onValidate={(fn) => {
|
||||||
|
validate.current = fn;
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</fieldset>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
>
|
||||||
|
{t('common.cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
disabled={
|
||||||
|
busy ||
|
||||||
|
(mode === 'existing'
|
||||||
|
? !selected.length
|
||||||
|
: !component || !name.trim())
|
||||||
|
}
|
||||||
|
onClick={add}
|
||||||
|
>
|
||||||
|
{t(
|
||||||
|
mode === 'existing'
|
||||||
|
? 'bots.pluginSubscriptions.add'
|
||||||
|
: 'bots.pluginSubscriptions.createAndBind',
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -739,7 +739,7 @@ function NavItems({
|
|||||||
> = {
|
> = {
|
||||||
agent: 'agents.kindBadgeAgent',
|
agent: 'agents.kindBadgeAgent',
|
||||||
pipeline: 'agents.kindBadgePipeline',
|
pipeline: 'agents.kindBadgePipeline',
|
||||||
event_processor: 'agents.eventProcessor.type',
|
event_processor: 'agents.eventProcessor.configurations',
|
||||||
};
|
};
|
||||||
|
|
||||||
const groupOrder: Array<'plugin' | 'mcp' | 'skill'> = [
|
const groupOrder: Array<'plugin' | 'mcp' | 'skill'> = [
|
||||||
@@ -893,7 +893,7 @@ function NavItems({
|
|||||||
className="ml-auto flex shrink-0 items-center text-muted-foreground"
|
className="ml-auto flex shrink-0 items-center text-muted-foreground"
|
||||||
title={
|
title={
|
||||||
item.kind === 'event_processor'
|
item.kind === 'event_processor'
|
||||||
? t('agents.eventProcessor.type')
|
? t('agents.eventProcessor.configurations')
|
||||||
: item.kind === 'pipeline'
|
: item.kind === 'pipeline'
|
||||||
? t('agents.kindBadgePipeline')
|
? t('agents.kindBadgePipeline')
|
||||||
: t('agents.kindBadgeAgent')
|
: t('agents.kindBadgeAgent')
|
||||||
|
|||||||
@@ -329,11 +329,17 @@ export interface Bot {
|
|||||||
adapter: string;
|
adapter: string;
|
||||||
adapter_config: object;
|
adapter_config: object;
|
||||||
event_bindings?: EventBinding[];
|
event_bindings?: EventBinding[];
|
||||||
|
plugin_processors?: PluginProcessorBinding[];
|
||||||
created_at?: string;
|
created_at?: string;
|
||||||
updated_at?: string;
|
updated_at?: string;
|
||||||
adapter_runtime_values?: object;
|
adapter_runtime_values?: object;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PluginProcessorBinding {
|
||||||
|
processor_uuid: string;
|
||||||
|
enabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface EventBinding {
|
export interface EventBinding {
|
||||||
id?: string;
|
id?: string;
|
||||||
event_pattern: string;
|
event_pattern: string;
|
||||||
|
|||||||
@@ -354,6 +354,25 @@ const enUS = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
bots: {
|
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',
|
applyFailed: 'Configuration saved, but could not be applied',
|
||||||
internalErrorHint:
|
internalErrorHint:
|
||||||
'An unexpected error occurred. Check the backend logs using the reference below.',
|
'An unexpected error occurred. Check the backend logs using the reference below.',
|
||||||
@@ -723,6 +742,7 @@ const enUS = {
|
|||||||
},
|
},
|
||||||
agents: {
|
agents: {
|
||||||
eventProcessor: {
|
eventProcessor: {
|
||||||
|
configurations: 'Plugin processor configurations',
|
||||||
configTab: 'Configuration',
|
configTab: 'Configuration',
|
||||||
logsTab: 'Logs',
|
logsTab: 'Logs',
|
||||||
noSettings: 'This plugin processor requires no configuration.',
|
noSettings: 'This plugin processor requires no configuration.',
|
||||||
@@ -750,14 +770,15 @@ const enUS = {
|
|||||||
loadError: 'Unable to load processor details.',
|
loadError: 'Unable to load processor details.',
|
||||||
refresh: 'Refresh',
|
refresh: 'Refresh',
|
||||||
runs: 'Runs',
|
runs: 'Runs',
|
||||||
noRuns: 'No runs yet. Bind a Bot event to start.',
|
noRuns: 'No runs yet. Bind this processor to a bot to start.',
|
||||||
bindBot: 'Bind Bot events',
|
bindBot: 'Bind to a bot',
|
||||||
trace: 'Logs and message flow',
|
trace: 'Logs and message flow',
|
||||||
selectRun: 'Select a run to view details.',
|
selectRun: 'Select a run to view details.',
|
||||||
input: 'Incoming event',
|
input: 'Incoming event',
|
||||||
destination: 'Delivery destination',
|
destination: 'Delivery destination',
|
||||||
loadMore: 'Load more',
|
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_pending: 'Pending',
|
||||||
status_running: 'Running',
|
status_running: 'Running',
|
||||||
status_completed: 'Completed',
|
status_completed: 'Completed',
|
||||||
|
|||||||
@@ -363,6 +363,24 @@ const esES = {
|
|||||||
"Uses the Workspace owner's LangBot Account billing and credits.",
|
"Uses the Workspace owner's LangBot Account billing and credits.",
|
||||||
},
|
},
|
||||||
bots: {
|
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',
|
applyFailed: 'Configuración guardada, pero no se pudo aplicar',
|
||||||
internalErrorHint:
|
internalErrorHint:
|
||||||
'Se produjo un error interno. Consulta los registros del servidor con esta referencia.',
|
'Se produjo un error interno. Consulta los registros del servidor con esta referencia.',
|
||||||
@@ -528,6 +546,7 @@ const esES = {
|
|||||||
},
|
},
|
||||||
agents: {
|
agents: {
|
||||||
eventProcessor: {
|
eventProcessor: {
|
||||||
|
configurations: 'Configuraciones de procesadores de plugins',
|
||||||
configTab: 'Configuración',
|
configTab: 'Configuración',
|
||||||
logsTab: 'Registros',
|
logsTab: 'Registros',
|
||||||
noSettings: 'Este procesador de plugin no requiere configuración.',
|
noSettings: 'Este procesador de plugin no requiere configuración.',
|
||||||
@@ -555,15 +574,15 @@ const esES = {
|
|||||||
loadError: 'No se pudieron cargar los detalles.',
|
loadError: 'No se pudieron cargar los detalles.',
|
||||||
refresh: 'Actualizar',
|
refresh: 'Actualizar',
|
||||||
runs: 'Ejecuciones',
|
runs: 'Ejecuciones',
|
||||||
noRuns: 'Sin ejecuciones. Vincula eventos de un Bot para empezar.',
|
noRuns: 'Sin ejecuciones. Vincula este procesador a un bot para empezar.',
|
||||||
bindBot: 'Vincular eventos del Bot',
|
bindBot: 'Vincular a un bot',
|
||||||
trace: 'Registros y flujo de mensajes',
|
trace: 'Registros y flujo de mensajes',
|
||||||
selectRun: 'Selecciona una ejecución para ver los detalles.',
|
selectRun: 'Selecciona una ejecución para ver los detalles.',
|
||||||
input: 'Evento recibido',
|
input: 'Evento recibido',
|
||||||
destination: 'Destino de entrega',
|
destination: 'Destino de entrega',
|
||||||
loadMore: 'Cargar más',
|
loadMore: 'Cargar más',
|
||||||
activation:
|
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_pending: 'Pendiente',
|
||||||
status_running: 'En ejecución',
|
status_running: 'En ejecución',
|
||||||
status_completed: 'Completado',
|
status_completed: 'Completado',
|
||||||
|
|||||||
@@ -360,6 +360,23 @@ const jaJP = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
bots: {
|
bots: {
|
||||||
|
pluginSubscriptions: {
|
||||||
|
description:
|
||||||
|
'プラグインが宣言したイベントを自動で受信し、上のルートとは独立して実行します。',
|
||||||
|
empty: 'プラグインプロセッサーは未登録です。',
|
||||||
|
add: 'プラグインプロセッサーを追加',
|
||||||
|
existing: '既存の設定を選択',
|
||||||
|
new: '設定を新規作成',
|
||||||
|
noExisting: '追加できる設定がありません。新しく作成してください。',
|
||||||
|
shared: '同じ設定を使用するボットは設定内容と実行状態を共有します。',
|
||||||
|
saveHint: '追加後にボットを保存すると有効になります。',
|
||||||
|
createAndBind: '作成して紐付け',
|
||||||
|
created: '設定を作成しました。ボットを保存すると紐付けが有効になります。',
|
||||||
|
enable: '{{name}} を有効化',
|
||||||
|
remove: '{{name}} の紐付けを解除',
|
||||||
|
configure: '設定',
|
||||||
|
logs: 'ログを表示',
|
||||||
|
},
|
||||||
applyFailed: '設定を保存しましたが、適用に失敗しました',
|
applyFailed: '設定を保存しましたが、適用に失敗しました',
|
||||||
internalErrorHint:
|
internalErrorHint:
|
||||||
'内部エラーが発生しました。エラー番号でバックエンドのログを確認してください。',
|
'内部エラーが発生しました。エラー番号でバックエンドのログを確認してください。',
|
||||||
@@ -736,6 +753,7 @@ const jaJP = {
|
|||||||
},
|
},
|
||||||
agents: {
|
agents: {
|
||||||
eventProcessor: {
|
eventProcessor: {
|
||||||
|
configurations: 'プラグインプロセッサー設定',
|
||||||
configTab: '設定',
|
configTab: '設定',
|
||||||
logsTab: 'ログ',
|
logsTab: 'ログ',
|
||||||
noSettings: 'このプラグインプロセッサーに設定項目はありません。',
|
noSettings: 'このプラグインプロセッサーに設定項目はありません。',
|
||||||
@@ -764,15 +782,15 @@ const jaJP = {
|
|||||||
loadError: '詳細を読み込めません。',
|
loadError: '詳細を読み込めません。',
|
||||||
refresh: '更新',
|
refresh: '更新',
|
||||||
runs: '実行履歴',
|
runs: '実行履歴',
|
||||||
noRuns: '実行履歴はありません。Bot イベントを紐付けて開始します。',
|
noRuns: '実行履歴はありません。ボットに紐付けて開始します。',
|
||||||
bindBot: 'Bot イベントを紐付ける',
|
bindBot: 'ボットに紐付ける',
|
||||||
trace: 'ログとメッセージの流れ',
|
trace: 'ログとメッセージの流れ',
|
||||||
selectRun: '実行履歴を選択して詳細を表示します。',
|
selectRun: '実行履歴を選択して詳細を表示します。',
|
||||||
input: '受信イベント',
|
input: '受信イベント',
|
||||||
destination: '送信先',
|
destination: '送信先',
|
||||||
loadMore: 'さらに読み込む',
|
loadMore: 'さらに読み込む',
|
||||||
activation:
|
activation:
|
||||||
'プラグインをインストールし、インスタンスを作成して Bot イベントを紐付けます。',
|
'プラグインをインストールし、プロセッサー設定を作成してボットに紐付けます。',
|
||||||
status_pending: '待機中',
|
status_pending: '待機中',
|
||||||
status_running: '実行中',
|
status_running: '実行中',
|
||||||
status_completed: '完了',
|
status_completed: '完了',
|
||||||
|
|||||||
@@ -360,6 +360,24 @@ const ruRU = {
|
|||||||
"Uses the Workspace owner's LangBot Account billing and credits.",
|
"Uses the Workspace owner's LangBot Account billing and credits.",
|
||||||
},
|
},
|
||||||
bots: {
|
bots: {
|
||||||
|
pluginSubscriptions: {
|
||||||
|
description:
|
||||||
|
'Автоматически получает события, объявленные плагином, независимо от маршрутов выше.',
|
||||||
|
empty: 'Обработчики плагинов не привязаны.',
|
||||||
|
add: 'Добавить обработчик плагина',
|
||||||
|
existing: 'Выбрать конфигурацию',
|
||||||
|
new: 'Новая конфигурация',
|
||||||
|
noExisting: 'Нет доступных конфигураций. Создайте новую.',
|
||||||
|
shared:
|
||||||
|
'Боты с общей конфигурацией используют общие настройки и состояние выполнения.',
|
||||||
|
saveHint: 'Сохраните бота, чтобы активировать привязку.',
|
||||||
|
createAndBind: 'Создать и привязать',
|
||||||
|
created: 'Конфигурация создана. Сохраните бота для активации привязки.',
|
||||||
|
enable: 'Включить {{name}}',
|
||||||
|
remove: 'Отвязать {{name}}',
|
||||||
|
configure: 'Настроить',
|
||||||
|
logs: 'Журнал',
|
||||||
|
},
|
||||||
applyFailed: 'Настройки сохранены, но не применены',
|
applyFailed: 'Настройки сохранены, но не применены',
|
||||||
internalErrorHint:
|
internalErrorHint:
|
||||||
'Внутренняя ошибка. Проверьте журналы сервера по указанному идентификатору.',
|
'Внутренняя ошибка. Проверьте журналы сервера по указанному идентификатору.',
|
||||||
@@ -524,6 +542,7 @@ const ruRU = {
|
|||||||
},
|
},
|
||||||
agents: {
|
agents: {
|
||||||
eventProcessor: {
|
eventProcessor: {
|
||||||
|
configurations: 'Конфигурации обработчиков плагинов',
|
||||||
configTab: 'Настройки',
|
configTab: 'Настройки',
|
||||||
logsTab: 'Журнал',
|
logsTab: 'Журнал',
|
||||||
noSettings: 'Этот обработчик плагина не требует настройки.',
|
noSettings: 'Этот обработчик плагина не требует настройки.',
|
||||||
@@ -550,15 +569,15 @@ const ruRU = {
|
|||||||
loadError: 'Не удалось загрузить данные.',
|
loadError: 'Не удалось загрузить данные.',
|
||||||
refresh: 'Обновить',
|
refresh: 'Обновить',
|
||||||
runs: 'Запуски',
|
runs: 'Запуски',
|
||||||
noRuns: 'Запусков пока нет. Привяжите события бота.',
|
noRuns: 'Запусков пока нет. Привяжите обработчик к боту.',
|
||||||
bindBot: 'Привязать события бота',
|
bindBot: 'Привязать к боту',
|
||||||
trace: 'Журнал и поток сообщений',
|
trace: 'Журнал и поток сообщений',
|
||||||
selectRun: 'Выберите запуск для просмотра.',
|
selectRun: 'Выберите запуск для просмотра.',
|
||||||
input: 'Входящее событие',
|
input: 'Входящее событие',
|
||||||
destination: 'Получатель',
|
destination: 'Получатель',
|
||||||
loadMore: 'Загрузить ещё',
|
loadMore: 'Загрузить ещё',
|
||||||
activation:
|
activation:
|
||||||
'Установите плагин, создайте экземпляр и привяжите события бота.',
|
'Установите плагин, создайте конфигурацию обработчика и привяжите бота.',
|
||||||
status_pending: 'Ожидание',
|
status_pending: 'Ожидание',
|
||||||
status_running: 'Выполняется',
|
status_running: 'Выполняется',
|
||||||
status_completed: 'Завершено',
|
status_completed: 'Завершено',
|
||||||
|
|||||||
@@ -347,6 +347,23 @@ const thTH = {
|
|||||||
"Uses the Workspace owner's LangBot Account billing and credits.",
|
"Uses the Workspace owner's LangBot Account billing and credits.",
|
||||||
},
|
},
|
||||||
bots: {
|
bots: {
|
||||||
|
pluginSubscriptions: {
|
||||||
|
description:
|
||||||
|
'รับเหตุการณ์ที่ปลั๊กอินประกาศไว้โดยอัตโนมัติ และทำงานแยกจากเส้นทางด้านบน',
|
||||||
|
empty: 'ยังไม่ได้เชื่อมโยงตัวประมวลผลปลั๊กอิน',
|
||||||
|
add: 'เพิ่มตัวประมวลผลปลั๊กอิน',
|
||||||
|
existing: 'เลือกการตั้งค่า',
|
||||||
|
new: 'สร้างการตั้งค่า',
|
||||||
|
noExisting: 'ไม่มีการตั้งค่าที่ใช้ได้ โปรดสร้างใหม่',
|
||||||
|
shared: 'บอทที่ใช้การตั้งค่าเดียวกันจะแชร์การตั้งค่าและสถานะการทำงาน',
|
||||||
|
saveHint: 'บันทึกบอตเพื่อเปิดใช้งานการเชื่อมโยง',
|
||||||
|
createAndBind: 'สร้างและเชื่อมโยง',
|
||||||
|
created: 'สร้างการตั้งค่าแล้ว บันทึกบอทเพื่อเปิดใช้งานการเชื่อมโยง',
|
||||||
|
enable: 'เปิดใช้งาน {{name}}',
|
||||||
|
remove: 'ยกเลิกการเชื่อมโยง {{name}}',
|
||||||
|
configure: 'ตั้งค่า',
|
||||||
|
logs: 'ดูบันทึก',
|
||||||
|
},
|
||||||
applyFailed: 'บันทึกการตั้งค่าแล้ว แต่ไม่สามารถนำไปใช้ได้',
|
applyFailed: 'บันทึกการตั้งค่าแล้ว แต่ไม่สามารถนำไปใช้ได้',
|
||||||
internalErrorHint:
|
internalErrorHint:
|
||||||
'เกิดข้อผิดพลาดภายใน โปรดตรวจสอบบันทึกของเซิร์ฟเวอร์ด้วยหมายเลขอ้างอิง',
|
'เกิดข้อผิดพลาดภายใน โปรดตรวจสอบบันทึกของเซิร์ฟเวอร์ด้วยหมายเลขอ้างอิง',
|
||||||
@@ -511,6 +528,7 @@ const thTH = {
|
|||||||
},
|
},
|
||||||
agents: {
|
agents: {
|
||||||
eventProcessor: {
|
eventProcessor: {
|
||||||
|
configurations: 'การตั้งค่าตัวประมวลผลปลั๊กอิน',
|
||||||
configTab: 'การตั้งค่า',
|
configTab: 'การตั้งค่า',
|
||||||
logsTab: 'บันทึก',
|
logsTab: 'บันทึก',
|
||||||
noSettings: 'ตัวประมวลผลปลั๊กอินนี้ไม่ต้องตั้งค่า',
|
noSettings: 'ตัวประมวลผลปลั๊กอินนี้ไม่ต้องตั้งค่า',
|
||||||
@@ -537,14 +555,14 @@ const thTH = {
|
|||||||
loadError: 'ไม่สามารถโหลดรายละเอียดได้',
|
loadError: 'ไม่สามารถโหลดรายละเอียดได้',
|
||||||
refresh: 'รีเฟรช',
|
refresh: 'รีเฟรช',
|
||||||
runs: 'ประวัติการทำงาน',
|
runs: 'ประวัติการทำงาน',
|
||||||
noRuns: 'ยังไม่มีการทำงาน เชื่อมโยงเหตุการณ์บอทเพื่อเริ่มต้น',
|
noRuns: 'ยังไม่มีการทำงาน เชื่อมโยงตัวประมวลผลกับบอทเพื่อเริ่มต้น',
|
||||||
bindBot: 'เชื่อมโยงเหตุการณ์บอท',
|
bindBot: 'เชื่อมโยงกับบอท',
|
||||||
trace: 'บันทึกและเส้นทางข้อความ',
|
trace: 'บันทึกและเส้นทางข้อความ',
|
||||||
selectRun: 'เลือกการทำงานเพื่อดูรายละเอียด',
|
selectRun: 'เลือกการทำงานเพื่อดูรายละเอียด',
|
||||||
input: 'เหตุการณ์ขาเข้า',
|
input: 'เหตุการณ์ขาเข้า',
|
||||||
destination: 'ปลายทางการส่ง',
|
destination: 'ปลายทางการส่ง',
|
||||||
loadMore: 'โหลดเพิ่มเติม',
|
loadMore: 'โหลดเพิ่มเติม',
|
||||||
activation: 'ติดตั้งปลั๊กอิน สร้างอินสแตนซ์ แล้วเชื่อมโยงเหตุการณ์บอท',
|
activation: 'ติดตั้งปลั๊กอิน สร้างการตั้งค่าตัวประมวลผล แล้วเชื่อมโยงบอท',
|
||||||
status_pending: 'รอดำเนินการ',
|
status_pending: 'รอดำเนินการ',
|
||||||
status_running: 'กำลังทำงาน',
|
status_running: 'กำลังทำงาน',
|
||||||
status_completed: 'เสร็จสิ้น',
|
status_completed: 'เสร็จสิ้น',
|
||||||
|
|||||||
@@ -356,6 +356,24 @@ const viVN = {
|
|||||||
"Uses the Workspace owner's LangBot Account billing and credits.",
|
"Uses the Workspace owner's LangBot Account billing and credits.",
|
||||||
},
|
},
|
||||||
bots: {
|
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',
|
applyFailed: 'Đã lưu cấu hình nhưng không thể áp dụng',
|
||||||
internalErrorHint:
|
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.',
|
'Đã 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: {
|
agents: {
|
||||||
eventProcessor: {
|
eventProcessor: {
|
||||||
|
configurations: 'Cấu hình bộ xử lý plugin',
|
||||||
configTab: 'Cấu hình',
|
configTab: 'Cấu hình',
|
||||||
logsTab: 'Nhật ký',
|
logsTab: 'Nhật ký',
|
||||||
noSettings: 'Bộ xử lý plugin này không cần cấu hình.',
|
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.',
|
loadError: 'Không thể tải chi tiết.',
|
||||||
refresh: 'Làm mới',
|
refresh: 'Làm mới',
|
||||||
runs: 'Lịch sử chạy',
|
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.',
|
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 sự kiện Bot',
|
bindBot: 'Liên kết với bot',
|
||||||
trace: 'Nhật ký và luồng tin nhắn',
|
trace: 'Nhật ký và luồng tin nhắn',
|
||||||
selectRun: 'Chọn một lần chạy để xem chi tiết.',
|
selectRun: 'Chọn một lần chạy để xem chi tiết.',
|
||||||
input: 'Sự kiện đầu vào',
|
input: 'Sự kiện đầu vào',
|
||||||
destination: 'Đích gửi',
|
destination: 'Đích gửi',
|
||||||
loadMore: 'Tải thêm',
|
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_pending: 'Đang chờ',
|
||||||
status_running: 'Đang chạy',
|
status_running: 'Đang chạy',
|
||||||
status_completed: 'Hoàn tất',
|
status_completed: 'Hoàn tất',
|
||||||
|
|||||||
@@ -339,6 +339,22 @@ const zhHans = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
bots: {
|
bots: {
|
||||||
|
pluginSubscriptions: {
|
||||||
|
description: '自动接收插件声明的事件,与上方事件路由独立执行。',
|
||||||
|
empty: '尚未绑定插件处理器。',
|
||||||
|
add: '添加插件处理器',
|
||||||
|
existing: '选择已有配置',
|
||||||
|
new: '新建配置',
|
||||||
|
noExisting: '没有可添加的配置,可新建一份。',
|
||||||
|
shared: '使用同一配置的机器人会共享设置和运行状态。',
|
||||||
|
saveHint: '添加后保存机器人配置,绑定才会生效。',
|
||||||
|
createAndBind: '创建并绑定',
|
||||||
|
created: '配置已创建,保存机器人配置后生效。',
|
||||||
|
enable: '启用 {{name}}',
|
||||||
|
remove: '解除绑定 {{name}}',
|
||||||
|
configure: '配置',
|
||||||
|
logs: '查看日志',
|
||||||
|
},
|
||||||
applyFailed: '配置已保存,但应用失败',
|
applyFailed: '配置已保存,但应用失败',
|
||||||
internalErrorHint: '发生内部错误,请通过错误编号查看后端日志。',
|
internalErrorHint: '发生内部错误,请通过错误编号查看后端日志。',
|
||||||
errorReference: '错误编号:{{id}}',
|
errorReference: '错误编号:{{id}}',
|
||||||
@@ -687,6 +703,7 @@ const zhHans = {
|
|||||||
},
|
},
|
||||||
agents: {
|
agents: {
|
||||||
eventProcessor: {
|
eventProcessor: {
|
||||||
|
configurations: '插件处理器配置',
|
||||||
configTab: '配置',
|
configTab: '配置',
|
||||||
logsTab: '日志',
|
logsTab: '日志',
|
||||||
noSettings: '此插件处理器无需配置。',
|
noSettings: '此插件处理器无需配置。',
|
||||||
@@ -712,14 +729,14 @@ const zhHans = {
|
|||||||
loadError: '无法加载处理器详情。',
|
loadError: '无法加载处理器详情。',
|
||||||
refresh: '刷新',
|
refresh: '刷新',
|
||||||
runs: '运行记录',
|
runs: '运行记录',
|
||||||
noRuns: '暂无运行记录,绑定机器人事件后开始处理。',
|
noRuns: '暂无运行记录,绑定机器人后开始处理。',
|
||||||
bindBot: '绑定机器人事件',
|
bindBot: '绑定机器人',
|
||||||
trace: '日志与消息流向',
|
trace: '日志与消息流向',
|
||||||
selectRun: '选择一条运行记录查看详情。',
|
selectRun: '选择一条运行记录查看详情。',
|
||||||
input: '传入事件',
|
input: '传入事件',
|
||||||
destination: '投递目标',
|
destination: '投递目标',
|
||||||
loadMore: '加载更多',
|
loadMore: '加载更多',
|
||||||
activation: '安装插件,创建实例,再绑定机器人事件。',
|
activation: '安装插件,创建处理器配置,再绑定机器人。',
|
||||||
status_pending: '待执行',
|
status_pending: '待执行',
|
||||||
status_running: '运行中',
|
status_running: '运行中',
|
||||||
status_completed: '已完成',
|
status_completed: '已完成',
|
||||||
|
|||||||
@@ -336,6 +336,22 @@ const zhHant = {
|
|||||||
"Uses the Workspace owner's LangBot Account billing and credits.",
|
"Uses the Workspace owner's LangBot Account billing and credits.",
|
||||||
},
|
},
|
||||||
bots: {
|
bots: {
|
||||||
|
pluginSubscriptions: {
|
||||||
|
description: '自動接收外掛宣告的事件,與上方事件路由獨立執行。',
|
||||||
|
empty: '尚未綁定外掛處理器。',
|
||||||
|
add: '新增外掛處理器',
|
||||||
|
existing: '選擇現有設定',
|
||||||
|
new: '新增設定',
|
||||||
|
noExisting: '沒有可新增的設定,請建立一份。',
|
||||||
|
shared: '使用同一份設定的機器人會共用設定和執行狀態。',
|
||||||
|
saveHint: '新增後儲存機器人設定,綁定才會生效。',
|
||||||
|
createAndBind: '建立並綁定',
|
||||||
|
created: '設定已建立,儲存機器人設定後生效。',
|
||||||
|
enable: '啟用 {{name}}',
|
||||||
|
remove: '解除綁定 {{name}}',
|
||||||
|
configure: '設定',
|
||||||
|
logs: '查看日誌',
|
||||||
|
},
|
||||||
applyFailed: '設定已儲存,但套用失敗',
|
applyFailed: '設定已儲存,但套用失敗',
|
||||||
internalErrorHint: '發生內部錯誤,請透過錯誤編號查看後端日誌。',
|
internalErrorHint: '發生內部錯誤,請透過錯誤編號查看後端日誌。',
|
||||||
errorReference: '錯誤編號:{{id}}',
|
errorReference: '錯誤編號:{{id}}',
|
||||||
@@ -494,6 +510,7 @@ const zhHant = {
|
|||||||
},
|
},
|
||||||
agents: {
|
agents: {
|
||||||
eventProcessor: {
|
eventProcessor: {
|
||||||
|
configurations: '外掛處理器設定',
|
||||||
configTab: '設定',
|
configTab: '設定',
|
||||||
logsTab: '日誌',
|
logsTab: '日誌',
|
||||||
noSettings: '此外掛處理器無需設定。',
|
noSettings: '此外掛處理器無需設定。',
|
||||||
@@ -519,14 +536,14 @@ const zhHant = {
|
|||||||
loadError: '無法載入處理器詳情。',
|
loadError: '無法載入處理器詳情。',
|
||||||
refresh: '重新整理',
|
refresh: '重新整理',
|
||||||
runs: '執行記錄',
|
runs: '執行記錄',
|
||||||
noRuns: '尚無執行記錄,綁定機器人事件後開始處理。',
|
noRuns: '尚無執行記錄,綁定機器人後開始處理。',
|
||||||
bindBot: '綁定機器人事件',
|
bindBot: '綁定機器人',
|
||||||
trace: '日誌與訊息流向',
|
trace: '日誌與訊息流向',
|
||||||
selectRun: '選擇一筆執行記錄查看詳情。',
|
selectRun: '選擇一筆執行記錄查看詳情。',
|
||||||
input: '傳入事件',
|
input: '傳入事件',
|
||||||
destination: '傳送目標',
|
destination: '傳送目標',
|
||||||
loadMore: '載入更多',
|
loadMore: '載入更多',
|
||||||
activation: '安裝外掛、建立實例,再綁定機器人事件。',
|
activation: '安裝外掛、建立處理器設定,再綁定機器人。',
|
||||||
status_pending: '待執行',
|
status_pending: '待執行',
|
||||||
status_running: '執行中',
|
status_running: '執行中',
|
||||||
status_completed: '已完成',
|
status_completed: '已完成',
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ interface BotMock {
|
|||||||
adapter_config: JsonRecord;
|
adapter_config: JsonRecord;
|
||||||
use_pipeline_uuid?: string;
|
use_pipeline_uuid?: string;
|
||||||
event_bindings: unknown[];
|
event_bindings: unknown[];
|
||||||
|
plugin_processors: unknown[];
|
||||||
pipeline_routing_rules: unknown[];
|
pipeline_routing_rules: unknown[];
|
||||||
adapter_runtime_values: JsonRecord;
|
adapter_runtime_values: JsonRecord;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
@@ -505,6 +506,7 @@ function makeBot(
|
|||||||
? String(data.use_pipeline_uuid)
|
? String(data.use_pipeline_uuid)
|
||||||
: undefined,
|
: undefined,
|
||||||
event_bindings: (data.event_bindings as unknown[] | undefined) || [],
|
event_bindings: (data.event_bindings as unknown[] | undefined) || [],
|
||||||
|
plugin_processors: (data.plugin_processors as unknown[] | undefined) || [],
|
||||||
pipeline_routing_rules:
|
pipeline_routing_rules:
|
||||||
(data.pipeline_routing_rules as unknown[] | undefined) || [],
|
(data.pipeline_routing_rules as unknown[] | undefined) || [],
|
||||||
adapter_runtime_values: {
|
adapter_runtime_values: {
|
||||||
|
|||||||
@@ -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<string, unknown>[] = [];
|
||||||
|
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 },
|
||||||
|
]);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user