feat(bots): bind plugin processor configurations independently

This commit is contained in:
RockChinQ
2026-09-15 00:28:13 +08:00
parent e913658e03
commit 184037a427
27 changed files with 1253 additions and 82 deletions
+1
View File
@@ -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.
+10 -2
View File
@@ -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": "<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.
Legacy EventListener plugins remain in the Pipeline lifecycle.
+40 -2
View File
@@ -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:
+10 -2
View File
@@ -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)
@@ -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,
@@ -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')
+61 -8
View File
@@ -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,
@@ -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():
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:
@@ -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<AgentPlatformTool[]>([]);
const toolLabels = Object.fromEntries(
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 { 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<BotFormHandle, BotFormProps>(function BotForm(
adapter_config: {},
enable: true,
event_bindings: [],
plugin_processors: [],
},
});
@@ -237,6 +242,7 @@ const BotForm = forwardRef<BotFormHandle, BotFormProps>(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<BotFormHandle, BotFormProps>(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<BotFormHandle, BotFormProps>(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<BotFormHandle, BotFormProps>(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<BotFormHandle, BotFormProps>(function BotForm(
form={form}
botId={initBotId}
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>
</Card>
@@ -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({
))}
</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 && (
<CommandGroup heading={t('bots.targetPipeline')}>
{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',
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')
+6
View File
@@ -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;
+24 -3
View File
@@ -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',
+22 -3
View File
@@ -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',
+21 -3
View File
@@ -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: '完了',
+22 -3
View File
@@ -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: 'Завершено',
+21 -3
View File
@@ -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: 'เสร็จสิ้น',
+22 -3
View File
@@ -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',
+20 -3
View File
@@ -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: '已完成',
+20 -3
View File
@@ -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: '已完成',
+2
View File
@@ -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: {
+135
View File
@@ -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 },
]);
});