feat(eba): consolidate event bindings, fix command.tsx pointer-events

- Replace legacy pipeline binding card + RoutingRulesEditor with unified
  EventBindingsEditor; remove use_pipeline_uuid/pipeline_routing_rules
  from bot form schema and API update handler
- Add _augment_event_data() to botmgr for filter virtual fields
  (message_text, message_element_types, chat_type)
- Add alembic migration 0009: migrate use_pipeline_uuid and
  pipeline_routing_rules into event_bindings on first run
- Fix command.tsx: data-[disabled] -> data-[disabled=true] so cmdk 1.x
  items (data-disabled=false) are not pointer-events:none
- EventBindingsEditor: onSelect on CommandItems, filter conditions panel,
  disabled bindings section, dnd reorder
- i18n: add filter/condition keys for zh-Hans and en-US
- Update tests to match new bot service behavior

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Junyan Qin
2026-06-26 16:26:38 +08:00
parent d02e1a14a3
commit 1577567a78
14 changed files with 2498 additions and 972 deletions
+4 -23
View File
@@ -190,17 +190,6 @@ class BotService:
# TODO: 检查配置信息格式
bot_data['uuid'] = str(uuid.uuid4())
# bind the most recently updated pipeline if any exist
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(persistence_pipeline.LegacyPipeline)
.order_by(persistence_pipeline.LegacyPipeline.updated_at.desc())
.limit(1)
)
pipeline = result.first()
if pipeline is not None:
bot_data['use_pipeline_uuid'] = pipeline.uuid
bot_data['use_pipeline_name'] = pipeline.name
await self.ap.persistence_mgr.execute_async(sqlalchemy.insert(persistence_bot.Bot).values(bot_data))
bot = await self.get_bot(bot_data['uuid'])
@@ -219,18 +208,10 @@ class BotService:
if 'event_bindings' in update_data:
update_data['event_bindings'] = await self._normalize_event_bindings(update_data.get('event_bindings'))
# set use_pipeline_name
if 'use_pipeline_uuid' in update_data:
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(persistence_pipeline.LegacyPipeline).where(
persistence_pipeline.LegacyPipeline.uuid == update_data['use_pipeline_uuid']
)
)
pipeline = result.first()
if pipeline is not None:
update_data['use_pipeline_name'] = pipeline.name
else:
raise Exception('Pipeline not found')
# clear legacy routing fields — routing is now fully managed via event_bindings
update_data.pop('use_pipeline_uuid', None)
update_data.pop('use_pipeline_name', None)
update_data.pop('pipeline_routing_rules', None)
await self.ap.persistence_mgr.execute_async(
sqlalchemy.update(persistence_bot.Bot).values(update_data).where(persistence_bot.Bot.uuid == bot_uuid)
@@ -0,0 +1,111 @@
"""migrate use_pipeline_uuid and pipeline_routing_rules into event_bindings
Revision ID: 0009_migrate_routing_to_event_bindings
Revises: 0008_agent_product_surface
Create Date: 2026-06-26
"""
import json
import uuid
import sqlalchemy as sa
from alembic import op
revision = '0009_migrate_routing_to_event_bindings'
down_revision = '0008_agent_product_surface'
depends_on = None
def _rule_to_filters(rule: dict) -> list[dict] | None:
"""Convert a pipeline_routing_rule to event_binding filters (best effort).
Rules that don't map cleanly (message_content, message_has_element) are
skipped — callers should handle None as "cannot migrate".
"""
rule_type = rule.get('type')
operator = rule.get('operator', 'eq')
value = rule.get('value', '')
if rule_type == 'launcher_type':
if value == 'group':
return [{'field': 'group', 'operator': 'neq', 'value': None}]
if value == 'person':
return [{'field': 'group', 'operator': 'eq', 'value': None}]
elif rule_type == 'launcher_id':
return [{'field': 'chat_id', 'operator': operator, 'value': value}]
return None # message_content / message_has_element: no clean mapping
def upgrade() -> None:
bind = op.get_bind()
rows = bind.execute(
sa.text('SELECT uuid, use_pipeline_uuid, pipeline_routing_rules, event_bindings FROM bots')
).fetchall()
for bot_uuid, use_pipeline_uuid, routing_rules_raw, event_bindings_raw in rows:
try:
existing = (
json.loads(event_bindings_raw) if isinstance(event_bindings_raw, str) else (event_bindings_raw or [])
)
except Exception:
existing = []
if existing:
continue # already has event_bindings — skip
try:
routing_rules = (
json.loads(routing_rules_raw) if isinstance(routing_rules_raw, str) else (routing_rules_raw or [])
)
except Exception:
routing_rules = []
new_bindings: list[dict] = []
base_priority = len(routing_rules)
for i, rule in enumerate(routing_rules):
target_uuid = rule.get('pipeline_uuid', '')
if not target_uuid:
continue
filters = _rule_to_filters(rule)
if filters is None:
continue
new_bindings.append(
{
'id': str(uuid.uuid4()),
'event_pattern': 'message.*',
'target_type': 'pipeline',
'target_uuid': target_uuid,
'filters': filters,
'priority': base_priority - i,
'enabled': True,
'description': f'Migrated from routing rule ({rule.get("type")})',
'order': i,
}
)
if use_pipeline_uuid:
new_bindings.append(
{
'id': str(uuid.uuid4()),
'event_pattern': 'message.*',
'target_type': 'pipeline',
'target_uuid': use_pipeline_uuid,
'filters': [],
'priority': 0,
'enabled': True,
'description': 'Migrated from default pipeline binding',
'order': len(new_bindings),
}
)
if new_bindings:
bind.execute(
sa.text('UPDATE bots SET event_bindings = :b WHERE uuid = :u'),
{'b': json.dumps(new_bindings, ensure_ascii=False), 'u': bot_uuid},
)
def downgrade() -> None:
pass # not reversible
+22 -6
View File
@@ -192,6 +192,25 @@ class RuntimeBot:
return False
return False
@classmethod
def _augment_event_data(
cls,
event_data: dict[str, typing.Any],
) -> dict[str, typing.Any]:
"""Inject virtual computed fields to simplify common filter patterns."""
message_chain = event_data.get('message_chain')
if isinstance(message_chain, list):
text_parts = [
comp.get('text', '') for comp in message_chain if isinstance(comp, dict) and comp.get('type') == 'Plain'
]
event_data['message_text'] = ''.join(text_parts)
event_data['message_element_types'] = [
comp.get('type', '') for comp in message_chain if isinstance(comp, dict)
]
if 'group' in event_data:
event_data['chat_type'] = 'group' if event_data.get('group') is not None else 'person'
return event_data
@classmethod
def _match_event_filters(
cls,
@@ -203,7 +222,7 @@ class RuntimeBot:
if not isinstance(filters, list):
return False
event_data = cls._safe_model_dump(event)
event_data = cls._augment_event_data(cls._safe_model_dump(event))
return all(
cls._match_event_filter(event_data, event_filter)
for event_filter in filters
@@ -854,11 +873,8 @@ class RuntimeBot:
launcher_id = custom_launcher_id
if pipeline_uuid_override is None:
message_text = str(event.message_chain)
element_types = [comp.type for comp in event.message_chain]
pipeline_uuid, routed_by_rule = self.resolve_pipeline_uuid(
launcher_kind, launcher_id, message_text, element_types
)
pipeline_uuid = None
routed_by_rule = False
else:
pipeline_uuid = pipeline_uuid_override
routed_by_rule = routed_by_event_binding