mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-26 23:44:19 +00:00
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:
@@ -190,17 +190,6 @@ class BotService:
|
|||||||
# TODO: 检查配置信息格式
|
# TODO: 检查配置信息格式
|
||||||
bot_data['uuid'] = str(uuid.uuid4())
|
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))
|
await self.ap.persistence_mgr.execute_async(sqlalchemy.insert(persistence_bot.Bot).values(bot_data))
|
||||||
|
|
||||||
bot = await self.get_bot(bot_data['uuid'])
|
bot = await self.get_bot(bot_data['uuid'])
|
||||||
@@ -219,18 +208,10 @@ class BotService:
|
|||||||
if 'event_bindings' in update_data:
|
if 'event_bindings' in update_data:
|
||||||
update_data['event_bindings'] = await self._normalize_event_bindings(update_data.get('event_bindings'))
|
update_data['event_bindings'] = await self._normalize_event_bindings(update_data.get('event_bindings'))
|
||||||
|
|
||||||
# set use_pipeline_name
|
# clear legacy routing fields — routing is now fully managed via event_bindings
|
||||||
if 'use_pipeline_uuid' in update_data:
|
update_data.pop('use_pipeline_uuid', None)
|
||||||
result = await self.ap.persistence_mgr.execute_async(
|
update_data.pop('use_pipeline_name', None)
|
||||||
sqlalchemy.select(persistence_pipeline.LegacyPipeline).where(
|
update_data.pop('pipeline_routing_rules', None)
|
||||||
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')
|
|
||||||
|
|
||||||
await self.ap.persistence_mgr.execute_async(
|
await self.ap.persistence_mgr.execute_async(
|
||||||
sqlalchemy.update(persistence_bot.Bot).values(update_data).where(persistence_bot.Bot.uuid == bot_uuid)
|
sqlalchemy.update(persistence_bot.Bot).values(update_data).where(persistence_bot.Bot.uuid == bot_uuid)
|
||||||
|
|||||||
+111
@@ -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
|
||||||
@@ -192,6 +192,25 @@ class RuntimeBot:
|
|||||||
return False
|
return False
|
||||||
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
|
@classmethod
|
||||||
def _match_event_filters(
|
def _match_event_filters(
|
||||||
cls,
|
cls,
|
||||||
@@ -203,7 +222,7 @@ class RuntimeBot:
|
|||||||
if not isinstance(filters, list):
|
if not isinstance(filters, list):
|
||||||
return False
|
return False
|
||||||
|
|
||||||
event_data = cls._safe_model_dump(event)
|
event_data = cls._augment_event_data(cls._safe_model_dump(event))
|
||||||
return all(
|
return all(
|
||||||
cls._match_event_filter(event_data, event_filter)
|
cls._match_event_filter(event_data, event_filter)
|
||||||
for event_filter in filters
|
for event_filter in filters
|
||||||
@@ -854,11 +873,8 @@ class RuntimeBot:
|
|||||||
launcher_id = custom_launcher_id
|
launcher_id = custom_launcher_id
|
||||||
|
|
||||||
if pipeline_uuid_override is None:
|
if pipeline_uuid_override is None:
|
||||||
message_text = str(event.message_chain)
|
pipeline_uuid = None
|
||||||
element_types = [comp.type for comp in event.message_chain]
|
routed_by_rule = False
|
||||||
pipeline_uuid, routed_by_rule = self.resolve_pipeline_uuid(
|
|
||||||
launcher_kind, launcher_id, message_text, element_types
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
pipeline_uuid = pipeline_uuid_override
|
pipeline_uuid = pipeline_uuid_override
|
||||||
routed_by_rule = routed_by_event_binding
|
routed_by_rule = routed_by_event_binding
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ class _PersistenceManager:
|
|||||||
return _FakeResult(SimpleNamespace(name='Updated Pipeline'))
|
return _FakeResult(SimpleNamespace(name='Updated Pipeline'))
|
||||||
|
|
||||||
|
|
||||||
async def test_update_bot_copies_input_before_filtering_and_setting_pipeline_name():
|
async def test_update_bot_copies_input_before_filtering_legacy_routing_fields():
|
||||||
persistence_mgr = _PersistenceManager()
|
persistence_mgr = _PersistenceManager()
|
||||||
runtime_bot = SimpleNamespace(enable=False)
|
runtime_bot = SimpleNamespace(enable=False)
|
||||||
platform_mgr = SimpleNamespace(
|
platform_mgr = SimpleNamespace(
|
||||||
@@ -46,17 +46,17 @@ async def test_update_bot_copies_input_before_filtering_and_setting_pipeline_nam
|
|||||||
'uuid': 'caller-owned-uuid',
|
'uuid': 'caller-owned-uuid',
|
||||||
'name': 'Test Bot',
|
'name': 'Test Bot',
|
||||||
'use_pipeline_uuid': 'pipeline-1',
|
'use_pipeline_uuid': 'pipeline-1',
|
||||||
|
'pipeline_routing_rules': [{'type': 'launcher_type'}],
|
||||||
}
|
}
|
||||||
|
|
||||||
await service.update_bot('bot-1', payload)
|
await service.update_bot('bot-1', payload)
|
||||||
|
|
||||||
|
# caller's dict must not be mutated
|
||||||
assert payload == {
|
assert payload == {
|
||||||
'uuid': 'caller-owned-uuid',
|
'uuid': 'caller-owned-uuid',
|
||||||
'name': 'Test Bot',
|
'name': 'Test Bot',
|
||||||
'use_pipeline_uuid': 'pipeline-1',
|
'use_pipeline_uuid': 'pipeline-1',
|
||||||
|
'pipeline_routing_rules': [{'type': 'launcher_type'}],
|
||||||
}
|
}
|
||||||
assert persistence_mgr.update_values == {
|
# legacy routing fields are stripped; only name is persisted
|
||||||
'name': 'Test Bot',
|
assert persistence_mgr.update_values == {'name': 'Test Bot'}
|
||||||
'use_pipeline_uuid': 'pipeline-1',
|
|
||||||
'use_pipeline_name': 'Updated Pipeline',
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -395,59 +395,6 @@ class TestBotServiceCreateBot:
|
|||||||
assert bot_uuid is not None
|
assert bot_uuid is not None
|
||||||
assert len(bot_uuid) == 36 # UUID format
|
assert len(bot_uuid) == 36 # UUID format
|
||||||
|
|
||||||
async def test_create_bot_sets_default_pipeline(self):
|
|
||||||
"""Sets default pipeline when one exists."""
|
|
||||||
# Setup
|
|
||||||
ap = SimpleNamespace()
|
|
||||||
ap.persistence_mgr = SimpleNamespace()
|
|
||||||
ap.instance_config = SimpleNamespace()
|
|
||||||
ap.instance_config.data = {'system': {'limitation': {'max_bots': -1}}}
|
|
||||||
ap.platform_mgr = SimpleNamespace()
|
|
||||||
ap.platform_mgr.load_bot = AsyncMock()
|
|
||||||
|
|
||||||
# Mock default pipeline
|
|
||||||
mock_pipeline = SimpleNamespace()
|
|
||||||
mock_pipeline.uuid = 'default-pipeline-uuid'
|
|
||||||
mock_pipeline.name = 'Default Pipeline'
|
|
||||||
pipeline_result = Mock()
|
|
||||||
pipeline_result.first = Mock(return_value=mock_pipeline)
|
|
||||||
|
|
||||||
# Mock bot after insert
|
|
||||||
bot_result = Mock()
|
|
||||||
bot_result.first = Mock(return_value=_create_mock_bot())
|
|
||||||
|
|
||||||
call_count = 0
|
|
||||||
|
|
||||||
async def mock_execute(query):
|
|
||||||
nonlocal call_count
|
|
||||||
call_count += 1
|
|
||||||
if call_count == 1:
|
|
||||||
return pipeline_result # Check default pipeline
|
|
||||||
elif call_count == 2:
|
|
||||||
return Mock() # Insert
|
|
||||||
return bot_result # Get bot
|
|
||||||
|
|
||||||
ap.persistence_mgr.execute_async = AsyncMock(side_effect=mock_execute)
|
|
||||||
ap.persistence_mgr.serialize_model = Mock(
|
|
||||||
return_value={
|
|
||||||
'uuid': 'new-uuid',
|
|
||||||
'name': 'New Bot',
|
|
||||||
'use_pipeline_uuid': 'default-pipeline-uuid',
|
|
||||||
'use_pipeline_name': 'Default Pipeline',
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
service = BotService(ap)
|
|
||||||
|
|
||||||
# Execute
|
|
||||||
bot_data = {'name': 'New Bot', 'adapter': 'telegram', 'adapter_config': {}}
|
|
||||||
bot_uuid = await service.create_bot(bot_data)
|
|
||||||
|
|
||||||
# Verify - pipeline uuid and name were set
|
|
||||||
assert 'use_pipeline_uuid' in bot_data
|
|
||||||
assert 'use_pipeline_name' in bot_data
|
|
||||||
assert bot_uuid is not None # Verify UUID was returned
|
|
||||||
|
|
||||||
|
|
||||||
class TestBotServiceUpdateBot:
|
class TestBotServiceUpdateBot:
|
||||||
"""Tests for update_bot method."""
|
"""Tests for update_bot method."""
|
||||||
@@ -481,64 +428,6 @@ class TestBotServiceUpdateBot:
|
|||||||
assert update_params['name'] == 'Updated Name'
|
assert update_params['name'] == 'Updated Name'
|
||||||
assert 'should-be-removed' not in update_params.values()
|
assert 'should-be-removed' not in update_params.values()
|
||||||
|
|
||||||
async def test_update_bot_pipeline_not_found_raises(self):
|
|
||||||
"""Raises Exception when updating with nonexistent pipeline UUID."""
|
|
||||||
# Setup
|
|
||||||
ap = SimpleNamespace()
|
|
||||||
ap.persistence_mgr = SimpleNamespace()
|
|
||||||
|
|
||||||
# Mock pipeline query returns None
|
|
||||||
pipeline_result = Mock()
|
|
||||||
pipeline_result.first = Mock(return_value=None)
|
|
||||||
ap.persistence_mgr.execute_async = AsyncMock(return_value=pipeline_result)
|
|
||||||
|
|
||||||
service = BotService(ap)
|
|
||||||
|
|
||||||
# Execute & Verify
|
|
||||||
with pytest.raises(Exception, match='Pipeline not found'):
|
|
||||||
await service.update_bot('test-uuid', {'use_pipeline_uuid': 'nonexistent-pipeline'})
|
|
||||||
|
|
||||||
async def test_update_bot_sets_pipeline_name(self):
|
|
||||||
"""Sets use_pipeline_name when updating use_pipeline_uuid."""
|
|
||||||
# Setup
|
|
||||||
ap = SimpleNamespace()
|
|
||||||
ap.persistence_mgr = SimpleNamespace()
|
|
||||||
ap.platform_mgr = SimpleNamespace()
|
|
||||||
ap.platform_mgr.remove_bot = AsyncMock()
|
|
||||||
|
|
||||||
# Mock pipeline query
|
|
||||||
mock_pipeline = SimpleNamespace()
|
|
||||||
mock_pipeline.name = 'Updated Pipeline'
|
|
||||||
pipeline_result = Mock()
|
|
||||||
pipeline_result.first = Mock(return_value=mock_pipeline)
|
|
||||||
|
|
||||||
call_count = 0
|
|
||||||
|
|
||||||
async def mock_execute(query):
|
|
||||||
nonlocal call_count
|
|
||||||
call_count += 1
|
|
||||||
if call_count == 1:
|
|
||||||
return pipeline_result
|
|
||||||
return Mock()
|
|
||||||
|
|
||||||
ap.persistence_mgr.execute_async = AsyncMock(side_effect=mock_execute)
|
|
||||||
ap.sess_mgr = SimpleNamespace()
|
|
||||||
ap.sess_mgr.session_list = []
|
|
||||||
|
|
||||||
service = BotService(ap)
|
|
||||||
service.get_bot = AsyncMock(return_value={'uuid': 'test-uuid'})
|
|
||||||
|
|
||||||
runtime_bot = SimpleNamespace()
|
|
||||||
runtime_bot.enable = False
|
|
||||||
ap.platform_mgr.load_bot = AsyncMock(return_value=runtime_bot)
|
|
||||||
|
|
||||||
# Execute
|
|
||||||
await service.update_bot('test-uuid', {'use_pipeline_uuid': 'pipeline-uuid'})
|
|
||||||
|
|
||||||
update_params = ap.persistence_mgr.execute_async.await_args_list[1].args[0].compile().params
|
|
||||||
assert update_params['use_pipeline_uuid'] == 'pipeline-uuid'
|
|
||||||
assert update_params['use_pipeline_name'] == 'Updated Pipeline'
|
|
||||||
|
|
||||||
|
|
||||||
class TestBotServiceEventBindings:
|
class TestBotServiceEventBindings:
|
||||||
"""Tests for EBA event binding validation and persistence."""
|
"""Tests for EBA event binding validation and persistence."""
|
||||||
|
|||||||
@@ -58,6 +58,7 @@
|
|||||||
"axios": "^1.16.0",
|
"axios": "^1.16.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"cmdk": "^1.1.1",
|
||||||
"highlight.js": "^11.11.1",
|
"highlight.js": "^11.11.1",
|
||||||
"i18next": "^25.1.2",
|
"i18next": "^25.1.2",
|
||||||
"i18next-browser-languagedetector": "^8.1.0",
|
"i18next-browser-languagedetector": "^8.1.0",
|
||||||
@@ -66,6 +67,7 @@
|
|||||||
"lucide-react": "^0.507.0",
|
"lucide-react": "^0.507.0",
|
||||||
"postcss": "^8.5.10",
|
"postcss": "^8.5.10",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
|
"radix-ui": "^1.6.0",
|
||||||
"react": "19.2.1",
|
"react": "19.2.1",
|
||||||
"react-dom": "19.2.1",
|
"react-dom": "19.2.1",
|
||||||
"react-hook-form": "^7.56.3",
|
"react-hook-form": "^7.56.3",
|
||||||
|
|||||||
Generated
+1414
File diff suppressed because it is too large
Load Diff
@@ -1,9 +1,6 @@
|
|||||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import i18n from 'i18next';
|
import i18n from 'i18next';
|
||||||
import {
|
import { IChooseAdapterEntity } from '@/app/home/bots/components/bot-form/ChooseEntity';
|
||||||
IChooseAdapterEntity,
|
|
||||||
IPipelineEntity,
|
|
||||||
} from '@/app/home/bots/components/bot-form/ChooseEntity';
|
|
||||||
import {
|
import {
|
||||||
DynamicFormItemConfig,
|
DynamicFormItemConfig,
|
||||||
getDefaultValues,
|
getDefaultValues,
|
||||||
@@ -17,7 +14,6 @@ import { systemInfo } from '@/app/infra/http';
|
|||||||
import { Agent, Bot } from '@/app/infra/entities/api';
|
import { Agent, Bot } from '@/app/infra/entities/api';
|
||||||
import { getAdapterDocUrl } from '@/app/infra/entities/adapter-docs';
|
import { getAdapterDocUrl } from '@/app/infra/entities/adapter-docs';
|
||||||
import { ExternalLink } from 'lucide-react';
|
import { ExternalLink } from 'lucide-react';
|
||||||
import RoutingRulesEditor from './RoutingRulesEditor';
|
|
||||||
import EventBindingsEditor from './EventBindingsEditor';
|
import EventBindingsEditor from './EventBindingsEditor';
|
||||||
|
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
@@ -66,29 +62,6 @@ 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(),
|
||||||
use_pipeline_uuid: z.string().optional(),
|
|
||||||
pipeline_routing_rules: z
|
|
||||||
.array(
|
|
||||||
z.object({
|
|
||||||
type: z.enum([
|
|
||||||
'launcher_type',
|
|
||||||
'launcher_id',
|
|
||||||
'message_content',
|
|
||||||
'message_has_element',
|
|
||||||
]),
|
|
||||||
operator: z.enum([
|
|
||||||
'eq',
|
|
||||||
'neq',
|
|
||||||
'contains',
|
|
||||||
'not_contains',
|
|
||||||
'starts_with',
|
|
||||||
'regex',
|
|
||||||
]),
|
|
||||||
value: z.string(),
|
|
||||||
pipeline_uuid: z.string(),
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
.optional(),
|
|
||||||
event_bindings: z
|
event_bindings: z
|
||||||
.array(
|
.array(
|
||||||
z.object({
|
z.object({
|
||||||
@@ -128,8 +101,6 @@ export default function BotForm({
|
|||||||
adapter: '',
|
adapter: '',
|
||||||
adapter_config: {},
|
adapter_config: {},
|
||||||
enable: true,
|
enable: true,
|
||||||
use_pipeline_uuid: '',
|
|
||||||
pipeline_routing_rules: [],
|
|
||||||
event_bindings: [],
|
event_bindings: [],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -154,9 +125,6 @@ export default function BotForm({
|
|||||||
Record<string, string[]>
|
Record<string, string[]>
|
||||||
>({});
|
>({});
|
||||||
|
|
||||||
const [pipelineNameList, setPipelineNameList] = useState<IPipelineEntity[]>(
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
const [agentNameList, setAgentNameList] = useState<Agent[]>([]);
|
const [agentNameList, setAgentNameList] = useState<Agent[]>([]);
|
||||||
|
|
||||||
const [dynamicFormConfigList, setDynamicFormConfigList] = useState<
|
const [dynamicFormConfigList, setDynamicFormConfigList] = useState<
|
||||||
@@ -200,8 +168,6 @@ export default function BotForm({
|
|||||||
adapter: val.adapter,
|
adapter: val.adapter,
|
||||||
adapter_config: val.adapter_config,
|
adapter_config: val.adapter_config,
|
||||||
enable: val.enable,
|
enable: val.enable,
|
||||||
use_pipeline_uuid: val.use_pipeline_uuid || '',
|
|
||||||
pipeline_routing_rules: val.pipeline_routing_rules || [],
|
|
||||||
event_bindings: val.event_bindings || [],
|
event_bindings: val.event_bindings || [],
|
||||||
});
|
});
|
||||||
handleAdapterSelect(val.adapter);
|
handleAdapterSelect(val.adapter);
|
||||||
@@ -231,17 +197,6 @@ export default function BotForm({
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function initBotFormComponent() {
|
async function initBotFormComponent() {
|
||||||
const pipelinesRes = await httpClient.getPipelines();
|
|
||||||
setPipelineNameList(
|
|
||||||
pipelinesRes.pipelines.map((item) => {
|
|
||||||
return {
|
|
||||||
label: item.name,
|
|
||||||
value: item.uuid ?? '',
|
|
||||||
emoji: item.emoji,
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
const agentsRes = await httpClient.getAgents();
|
const agentsRes = await httpClient.getAgents();
|
||||||
setAgentNameList(agentsRes.agents);
|
setAgentNameList(agentsRes.agents);
|
||||||
|
|
||||||
@@ -331,8 +286,6 @@ export default function BotForm({
|
|||||||
name: bot.name,
|
name: bot.name,
|
||||||
adapter_config: bot.adapter_config,
|
adapter_config: bot.adapter_config,
|
||||||
enable: bot.enable ?? true,
|
enable: bot.enable ?? true,
|
||||||
use_pipeline_uuid: bot.use_pipeline_uuid ?? '',
|
|
||||||
pipeline_routing_rules: bot.pipeline_routing_rules ?? [],
|
|
||||||
event_bindings: bot.event_bindings ?? [],
|
event_bindings: bot.event_bindings ?? [],
|
||||||
webhook_full_url: runtimeValues?.webhook_full_url as
|
webhook_full_url: runtimeValues?.webhook_full_url as
|
||||||
| string
|
| string
|
||||||
@@ -377,8 +330,6 @@ export default function BotForm({
|
|||||||
adapter: form.getValues().adapter,
|
adapter: form.getValues().adapter,
|
||||||
adapter_config: form.getValues().adapter_config,
|
adapter_config: form.getValues().adapter_config,
|
||||||
enable: form.getValues().enable,
|
enable: form.getValues().enable,
|
||||||
use_pipeline_uuid: form.getValues().use_pipeline_uuid,
|
|
||||||
pipeline_routing_rules: form.getValues().pipeline_routing_rules ?? [],
|
|
||||||
event_bindings: form.getValues().event_bindings ?? [],
|
event_bindings: form.getValues().event_bindings ?? [],
|
||||||
};
|
};
|
||||||
httpClient
|
httpClient
|
||||||
@@ -468,79 +419,7 @@ export default function BotForm({
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Card 2: Pipeline Binding (edit mode only) */}
|
{/* Card 2: Event Orchestration (edit mode only) */}
|
||||||
{initBotId && (
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle>{t('bots.routingConnection')}</CardTitle>
|
|
||||||
<CardDescription>
|
|
||||||
{t('bots.routingConnectionDescription')}
|
|
||||||
</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<FormField
|
|
||||||
control={form.control}
|
|
||||||
name="use_pipeline_uuid"
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem>
|
|
||||||
<FormLabel>{t('bots.bindPipeline')}</FormLabel>
|
|
||||||
<FormControl>
|
|
||||||
<Select onValueChange={field.onChange} {...field}>
|
|
||||||
<SelectTrigger>
|
|
||||||
{field.value ? (
|
|
||||||
(() => {
|
|
||||||
const pipeline = pipelineNameList.find(
|
|
||||||
(p) => p.value === field.value,
|
|
||||||
);
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{pipeline?.emoji && (
|
|
||||||
<span className="text-sm shrink-0">
|
|
||||||
{pipeline.emoji}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<span>{pipeline?.label ?? field.value}</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})()
|
|
||||||
) : (
|
|
||||||
<SelectValue
|
|
||||||
placeholder={t('bots.selectPipeline')}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectGroup>
|
|
||||||
{pipelineNameList.map((item) => (
|
|
||||||
<SelectItem key={item.value} value={item.value}>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{item.emoji && (
|
|
||||||
<span className="text-sm shrink-0">
|
|
||||||
{item.emoji}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<span>{item.label}</span>
|
|
||||||
</div>
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectGroup>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</FormControl>
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Pipeline Routing Rules */}
|
|
||||||
<RoutingRulesEditor
|
|
||||||
form={form}
|
|
||||||
pipelineNameList={pipelineNameList}
|
|
||||||
/>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Card 3: Event Orchestration (edit mode only) */}
|
|
||||||
{initBotId && (
|
{initBotId && (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,479 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { UseFormReturn } from 'react-hook-form';
|
|
||||||
import {
|
|
||||||
PipelineRoutingRule,
|
|
||||||
RoutingRuleOperator,
|
|
||||||
} from '@/app/infra/entities/api';
|
|
||||||
import { Ban, GripVertical, Plus, Trash2 } from 'lucide-react';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Input } from '@/components/ui/input';
|
|
||||||
import { FormLabel } from '@/components/ui/form';
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectSeparator,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from '@/components/ui/select';
|
|
||||||
import {
|
|
||||||
DndContext,
|
|
||||||
DragOverlay,
|
|
||||||
closestCenter,
|
|
||||||
PointerSensor,
|
|
||||||
KeyboardSensor,
|
|
||||||
useSensor,
|
|
||||||
useSensors,
|
|
||||||
DragEndEvent,
|
|
||||||
DragStartEvent,
|
|
||||||
} from '@dnd-kit/core';
|
|
||||||
import {
|
|
||||||
arrayMove,
|
|
||||||
SortableContext,
|
|
||||||
sortableKeyboardCoordinates,
|
|
||||||
useSortable,
|
|
||||||
verticalListSortingStrategy,
|
|
||||||
} from '@dnd-kit/sortable';
|
|
||||||
import { CSS } from '@dnd-kit/utilities';
|
|
||||||
import { useRef, useMemo, useState } from 'react';
|
|
||||||
|
|
||||||
export const PIPELINE_DISCARD = '__discard__';
|
|
||||||
|
|
||||||
interface PipelineOption {
|
|
||||||
value: string;
|
|
||||||
label: string;
|
|
||||||
emoji?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface RoutingRulesEditorProps {
|
|
||||||
form: UseFormReturn<any>;
|
|
||||||
pipelineNameList: PipelineOption[];
|
|
||||||
}
|
|
||||||
|
|
||||||
const OPERATORS_BY_TYPE: Record<
|
|
||||||
PipelineRoutingRule['type'],
|
|
||||||
{ value: RoutingRuleOperator; labelKey: string }[]
|
|
||||||
> = {
|
|
||||||
launcher_type: [
|
|
||||||
{ value: 'eq', labelKey: 'bots.operatorEq' },
|
|
||||||
{ value: 'neq', labelKey: 'bots.operatorNeq' },
|
|
||||||
],
|
|
||||||
launcher_id: [
|
|
||||||
{ value: 'eq', labelKey: 'bots.operatorEq' },
|
|
||||||
{ value: 'neq', labelKey: 'bots.operatorNeq' },
|
|
||||||
{ value: 'contains', labelKey: 'bots.operatorContains' },
|
|
||||||
{ value: 'not_contains', labelKey: 'bots.operatorNotContains' },
|
|
||||||
{ value: 'regex', labelKey: 'bots.operatorRegex' },
|
|
||||||
],
|
|
||||||
message_content: [
|
|
||||||
{ value: 'eq', labelKey: 'bots.operatorEq' },
|
|
||||||
{ value: 'neq', labelKey: 'bots.operatorNeq' },
|
|
||||||
{ value: 'contains', labelKey: 'bots.operatorContains' },
|
|
||||||
{ value: 'not_contains', labelKey: 'bots.operatorNotContains' },
|
|
||||||
{ value: 'starts_with', labelKey: 'bots.operatorStartsWith' },
|
|
||||||
{ value: 'regex', labelKey: 'bots.operatorRegex' },
|
|
||||||
],
|
|
||||||
message_has_element: [
|
|
||||||
{ value: 'eq', labelKey: 'bots.operatorHas' },
|
|
||||||
{ value: 'neq', labelKey: 'bots.operatorNotHas' },
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
function getValuePlaceholder(
|
|
||||||
t: (key: string) => string,
|
|
||||||
rule: PipelineRoutingRule,
|
|
||||||
): string {
|
|
||||||
if (rule.type === 'launcher_id')
|
|
||||||
return t('bots.ruleValueLauncherIdPlaceholder');
|
|
||||||
if (rule.type === 'message_has_element')
|
|
||||||
return t('bots.ruleValueElementPlaceholder');
|
|
||||||
if (rule.operator === 'regex') return t('bots.ruleValueRegexpPlaceholder');
|
|
||||||
return t('bots.ruleValueMessagePlaceholder');
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Static rule row (used in DragOverlay) ─────────────────────────── */
|
|
||||||
|
|
||||||
interface RuleRowContentProps {
|
|
||||||
rule: PipelineRoutingRule;
|
|
||||||
index: number;
|
|
||||||
pipelineNameList: PipelineOption[];
|
|
||||||
updateRule: (index: number, patch: Partial<PipelineRoutingRule>) => void;
|
|
||||||
removeRule: (index: number) => void;
|
|
||||||
dragHandleProps?: Record<string, unknown>;
|
|
||||||
isOverlay?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
function RuleRowContent({
|
|
||||||
rule,
|
|
||||||
index,
|
|
||||||
pipelineNameList,
|
|
||||||
updateRule,
|
|
||||||
removeRule,
|
|
||||||
dragHandleProps,
|
|
||||||
isOverlay,
|
|
||||||
}: RuleRowContentProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const operatorsForType =
|
|
||||||
OPERATORS_BY_TYPE[rule.type] || OPERATORS_BY_TYPE.message_content;
|
|
||||||
const isDiscard = rule.pipeline_uuid === PIPELINE_DISCARD;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={`flex items-center gap-2 mt-2 p-3 border rounded-md bg-muted/30 ${
|
|
||||||
isOverlay ? 'shadow-lg ring-2 ring-primary/20 bg-background' : ''
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{/* Drag handle */}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="cursor-grab active:cursor-grabbing shrink-0 text-muted-foreground hover:text-foreground touch-none"
|
|
||||||
{...dragHandleProps}
|
|
||||||
>
|
|
||||||
<GripVertical className="h-4 w-4" />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Field selector */}
|
|
||||||
<Select
|
|
||||||
value={rule.type}
|
|
||||||
onValueChange={(val) => {
|
|
||||||
updateRule(index, {
|
|
||||||
type: val as PipelineRoutingRule['type'],
|
|
||||||
operator: 'eq',
|
|
||||||
value: '',
|
|
||||||
});
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="w-[130px]">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="launcher_type">
|
|
||||||
{t('bots.ruleTypeLauncherType')}
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="launcher_id">
|
|
||||||
{t('bots.ruleTypeLauncherId')}
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="message_content">
|
|
||||||
{t('bots.ruleTypeMessageContent')}
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="message_has_element">
|
|
||||||
{t('bots.ruleTypeMessageHasElement')}
|
|
||||||
</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
|
|
||||||
{/* Operator selector */}
|
|
||||||
<Select
|
|
||||||
value={rule.operator || 'eq'}
|
|
||||||
onValueChange={(val) => {
|
|
||||||
updateRule(index, { operator: val as RoutingRuleOperator });
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="w-[120px]">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{operatorsForType.map((op) => (
|
|
||||||
<SelectItem key={op.value} value={op.value}>
|
|
||||||
{t(op.labelKey)}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
|
|
||||||
{/* Value input */}
|
|
||||||
{rule.type === 'launcher_type' ? (
|
|
||||||
<Select
|
|
||||||
value={rule.value}
|
|
||||||
onValueChange={(val) => updateRule(index, { value: val })}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="w-[100px]">
|
|
||||||
<SelectValue placeholder={t('bots.ruleValuePlaceholder')} />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="person">
|
|
||||||
{t('bots.sessionTypePerson')}
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="group">{t('bots.sessionTypeGroup')}</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
) : rule.type === 'message_has_element' ? (
|
|
||||||
<Select
|
|
||||||
value={rule.value}
|
|
||||||
onValueChange={(val) => updateRule(index, { value: val })}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="w-[120px]">
|
|
||||||
<SelectValue placeholder={t('bots.ruleValueElementPlaceholder')} />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="Image">{t('bots.elementImage')}</SelectItem>
|
|
||||||
<SelectItem value="Voice">{t('bots.elementVoice')}</SelectItem>
|
|
||||||
<SelectItem value="File">{t('bots.elementFile')}</SelectItem>
|
|
||||||
<SelectItem value="Forward">{t('bots.elementForward')}</SelectItem>
|
|
||||||
<SelectItem value="Face">{t('bots.elementFace')}</SelectItem>
|
|
||||||
<SelectItem value="At">{t('bots.elementAt')}</SelectItem>
|
|
||||||
<SelectItem value="AtAll">{t('bots.elementAtAll')}</SelectItem>
|
|
||||||
<SelectItem value="Quote">{t('bots.elementQuote')}</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
) : (
|
|
||||||
<Input
|
|
||||||
className="flex-1"
|
|
||||||
placeholder={getValuePlaceholder(t, rule)}
|
|
||||||
value={rule.value}
|
|
||||||
onChange={(e) => updateRule(index, { value: e.target.value })}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<span className="text-sm text-muted-foreground shrink-0">→</span>
|
|
||||||
|
|
||||||
{/* Pipeline selector */}
|
|
||||||
<Select
|
|
||||||
value={rule.pipeline_uuid}
|
|
||||||
onValueChange={(val) => updateRule(index, { pipeline_uuid: val })}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="w-[200px]">
|
|
||||||
{rule.pipeline_uuid ? (
|
|
||||||
isDiscard ? (
|
|
||||||
<div className="flex items-center gap-2 text-destructive">
|
|
||||||
<Ban className="h-3.5 w-3.5 shrink-0" />
|
|
||||||
<span>{t('bots.pipelineDiscard')}</span>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
(() => {
|
|
||||||
const p = pipelineNameList.find(
|
|
||||||
(p) => p.value === rule.pipeline_uuid,
|
|
||||||
);
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{p?.emoji && (
|
|
||||||
<span className="text-sm shrink-0">{p.emoji}</span>
|
|
||||||
)}
|
|
||||||
<span>{p?.label ?? rule.pipeline_uuid}</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})()
|
|
||||||
)
|
|
||||||
) : (
|
|
||||||
<SelectValue placeholder={t('bots.selectPipeline')} />
|
|
||||||
)}
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value={PIPELINE_DISCARD}>
|
|
||||||
<div className="flex items-center gap-2 text-destructive">
|
|
||||||
<Ban className="h-3.5 w-3.5 shrink-0" />
|
|
||||||
<span>{t('bots.pipelineDiscard')}</span>
|
|
||||||
</div>
|
|
||||||
</SelectItem>
|
|
||||||
<SelectSeparator />
|
|
||||||
{pipelineNameList.map((item) => (
|
|
||||||
<SelectItem key={item.value} value={item.value}>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{item.emoji && (
|
|
||||||
<span className="text-sm shrink-0">{item.emoji}</span>
|
|
||||||
)}
|
|
||||||
<span>{item.label}</span>
|
|
||||||
</div>
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="shrink-0"
|
|
||||||
onClick={() => removeRule(index)}
|
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4 text-destructive" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Sortable rule row ─────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
interface SortableRuleRowProps {
|
|
||||||
id: string;
|
|
||||||
rule: PipelineRoutingRule;
|
|
||||||
index: number;
|
|
||||||
pipelineNameList: PipelineOption[];
|
|
||||||
updateRule: (index: number, patch: Partial<PipelineRoutingRule>) => void;
|
|
||||||
removeRule: (index: number) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
function SortableRuleRow({
|
|
||||||
id,
|
|
||||||
rule,
|
|
||||||
index,
|
|
||||||
pipelineNameList,
|
|
||||||
updateRule,
|
|
||||||
removeRule,
|
|
||||||
}: SortableRuleRowProps) {
|
|
||||||
const { attributes, listeners, setNodeRef, transform, isDragging } =
|
|
||||||
useSortable({ id });
|
|
||||||
|
|
||||||
const style = {
|
|
||||||
transform: CSS.Transform.toString(transform),
|
|
||||||
// No transition — items reorder visually during drag via transform;
|
|
||||||
// on drop the data updates and transform resets, so animating would
|
|
||||||
// cause a redundant "swap" flicker.
|
|
||||||
opacity: isDragging ? 0.3 : undefined,
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div ref={setNodeRef} style={style}>
|
|
||||||
<RuleRowContent
|
|
||||||
rule={rule}
|
|
||||||
index={index}
|
|
||||||
pipelineNameList={pipelineNameList}
|
|
||||||
updateRule={updateRule}
|
|
||||||
removeRule={removeRule}
|
|
||||||
dragHandleProps={{ ...attributes, ...listeners }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Main editor ───────────────────────────────────────────────────── */
|
|
||||||
|
|
||||||
export default function RoutingRulesEditor({
|
|
||||||
form,
|
|
||||||
pipelineNameList,
|
|
||||||
}: RoutingRulesEditorProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [activeId, setActiveId] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const rules: PipelineRoutingRule[] =
|
|
||||||
form.watch('pipeline_routing_rules') || [];
|
|
||||||
|
|
||||||
// Stable unique ids for sortable items.
|
|
||||||
// We keep a running counter so newly added rules always get fresh ids.
|
|
||||||
const nextId = useRef(0);
|
|
||||||
const idsRef = useRef<string[]>([]);
|
|
||||||
|
|
||||||
const sortableIds = useMemo(() => {
|
|
||||||
// Grow the id list to match rules length (newly added items get new ids).
|
|
||||||
while (idsRef.current.length < rules.length) {
|
|
||||||
idsRef.current.push(`rule-${nextId.current++}`);
|
|
||||||
}
|
|
||||||
// Shrink if rules were removed from the end.
|
|
||||||
if (idsRef.current.length > rules.length) {
|
|
||||||
idsRef.current = idsRef.current.slice(0, rules.length);
|
|
||||||
}
|
|
||||||
return idsRef.current;
|
|
||||||
}, [rules.length]);
|
|
||||||
|
|
||||||
const updateRules = (newRules: PipelineRoutingRule[]) => {
|
|
||||||
form.setValue('pipeline_routing_rules', newRules, { shouldDirty: true });
|
|
||||||
};
|
|
||||||
|
|
||||||
const addRule = () => {
|
|
||||||
updateRules([
|
|
||||||
...rules,
|
|
||||||
{
|
|
||||||
type: 'launcher_type',
|
|
||||||
operator: 'eq',
|
|
||||||
value: '',
|
|
||||||
pipeline_uuid: '',
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
};
|
|
||||||
|
|
||||||
const updateRule = (index: number, patch: Partial<PipelineRoutingRule>) => {
|
|
||||||
const updated = [...rules];
|
|
||||||
updated[index] = { ...updated[index], ...patch };
|
|
||||||
updateRules(updated);
|
|
||||||
};
|
|
||||||
|
|
||||||
const removeRule = (index: number) => {
|
|
||||||
const updated = [...rules];
|
|
||||||
updated.splice(index, 1);
|
|
||||||
// Also remove the corresponding sortable id so indices stay in sync.
|
|
||||||
idsRef.current.splice(index, 1);
|
|
||||||
updateRules(updated);
|
|
||||||
};
|
|
||||||
|
|
||||||
const sensors = useSensors(
|
|
||||||
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
|
|
||||||
useSensor(KeyboardSensor, {
|
|
||||||
coordinateGetter: sortableKeyboardCoordinates,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleDragStart = (event: DragStartEvent) => {
|
|
||||||
setActiveId(event.active.id as string);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleDragEnd = (event: DragEndEvent) => {
|
|
||||||
setActiveId(null);
|
|
||||||
const { active, over } = event;
|
|
||||||
if (!over || active.id === over.id) return;
|
|
||||||
|
|
||||||
const oldIndex = sortableIds.indexOf(active.id as string);
|
|
||||||
const newIndex = sortableIds.indexOf(over.id as string);
|
|
||||||
if (oldIndex === -1 || newIndex === -1) return;
|
|
||||||
|
|
||||||
idsRef.current = arrayMove(idsRef.current, oldIndex, newIndex);
|
|
||||||
updateRules(arrayMove(rules, oldIndex, newIndex));
|
|
||||||
};
|
|
||||||
|
|
||||||
const activeIndex = activeId ? sortableIds.indexOf(activeId) : -1;
|
|
||||||
const activeRule = activeIndex >= 0 ? rules[activeIndex] : null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mt-6">
|
|
||||||
<div className="flex items-center justify-between mb-2">
|
|
||||||
<div>
|
|
||||||
<FormLabel>{t('bots.routingRules')}</FormLabel>
|
|
||||||
<p className="text-sm text-muted-foreground mt-1">
|
|
||||||
{t('bots.routingRulesDescription')}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Button type="button" variant="outline" size="sm" onClick={addRule}>
|
|
||||||
<Plus className="h-4 w-4 mr-1" />
|
|
||||||
{t('bots.addRoutingRule')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DndContext
|
|
||||||
sensors={sensors}
|
|
||||||
collisionDetection={closestCenter}
|
|
||||||
onDragStart={handleDragStart}
|
|
||||||
onDragEnd={handleDragEnd}
|
|
||||||
>
|
|
||||||
<SortableContext
|
|
||||||
items={sortableIds}
|
|
||||||
strategy={verticalListSortingStrategy}
|
|
||||||
>
|
|
||||||
{rules.map((rule, index) => (
|
|
||||||
<SortableRuleRow
|
|
||||||
key={sortableIds[index]}
|
|
||||||
id={sortableIds[index]}
|
|
||||||
rule={rule}
|
|
||||||
index={index}
|
|
||||||
pipelineNameList={pipelineNameList}
|
|
||||||
updateRule={updateRule}
|
|
||||||
removeRule={removeRule}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</SortableContext>
|
|
||||||
<DragOverlay dropAnimation={null}>
|
|
||||||
{activeRule ? (
|
|
||||||
<RuleRowContent
|
|
||||||
rule={activeRule}
|
|
||||||
index={activeIndex}
|
|
||||||
pipelineNameList={pipelineNameList}
|
|
||||||
updateRule={updateRule}
|
|
||||||
removeRule={removeRule}
|
|
||||||
isOverlay
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
</DragOverlay>
|
|
||||||
</DndContext>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -28,7 +28,7 @@ import {
|
|||||||
Quote,
|
Quote,
|
||||||
Voice,
|
Voice,
|
||||||
} from '@/app/infra/entities/message';
|
} from '@/app/infra/entities/message';
|
||||||
import { PIPELINE_DISCARD } from '@/app/home/bots/components/bot-form/RoutingRulesEditor';
|
import { PIPELINE_DISCARD } from '@/app/home/bots/components/bot-form/EventBindingsEditor';
|
||||||
|
|
||||||
interface SessionInfo {
|
interface SessionInfo {
|
||||||
session_id: string;
|
session_id: string;
|
||||||
|
|||||||
@@ -0,0 +1,113 @@
|
|||||||
|
import * as React from 'react';
|
||||||
|
import { Command as CommandPrimitive } from 'cmdk';
|
||||||
|
import { Search } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
const Command = React.forwardRef<
|
||||||
|
React.ElementRef<typeof CommandPrimitive>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof CommandPrimitive>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<CommandPrimitive
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'flex h-full w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
Command.displayName = CommandPrimitive.displayName;
|
||||||
|
|
||||||
|
const CommandInput = React.forwardRef<
|
||||||
|
React.ElementRef<typeof CommandPrimitive.Input>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Input>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<div className="flex items-center border-b px-3" cmdk-input-wrapper="">
|
||||||
|
<Search className="mr-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
|
<CommandPrimitive.Input
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'flex h-9 w-full rounded-md bg-transparent py-3 text-sm outline-none placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
));
|
||||||
|
CommandInput.displayName = CommandPrimitive.Input.displayName;
|
||||||
|
|
||||||
|
const CommandList = React.forwardRef<
|
||||||
|
React.ElementRef<typeof CommandPrimitive.List>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof CommandPrimitive.List>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<CommandPrimitive.List
|
||||||
|
ref={ref}
|
||||||
|
className={cn('max-h-[300px] overflow-y-auto overflow-x-hidden', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
CommandList.displayName = CommandPrimitive.List.displayName;
|
||||||
|
|
||||||
|
const CommandEmpty = React.forwardRef<
|
||||||
|
React.ElementRef<typeof CommandPrimitive.Empty>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Empty>
|
||||||
|
>((props, ref) => (
|
||||||
|
<CommandPrimitive.Empty
|
||||||
|
ref={ref}
|
||||||
|
className="py-6 text-center text-sm"
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
CommandEmpty.displayName = CommandPrimitive.Empty.displayName;
|
||||||
|
|
||||||
|
const CommandGroup = React.forwardRef<
|
||||||
|
React.ElementRef<typeof CommandPrimitive.Group>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Group>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<CommandPrimitive.Group
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'overflow-hidden p-1 text-foreground [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group-heading]]:text-muted-foreground',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
CommandGroup.displayName = CommandPrimitive.Group.displayName;
|
||||||
|
|
||||||
|
const CommandItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof CommandPrimitive.Item>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Item>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<CommandPrimitive.Item
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'relative flex cursor-pointer select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none aria-selected:bg-accent aria-selected:text-accent-foreground data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50',
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
CommandItem.displayName = CommandPrimitive.Item.displayName;
|
||||||
|
|
||||||
|
const CommandSeparator = React.forwardRef<
|
||||||
|
React.ElementRef<typeof CommandPrimitive.Separator>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof CommandPrimitive.Separator>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<CommandPrimitive.Separator
|
||||||
|
ref={ref}
|
||||||
|
className={cn('-mx-1 h-px bg-border', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
));
|
||||||
|
CommandSeparator.displayName = CommandPrimitive.Separator.displayName;
|
||||||
|
|
||||||
|
export {
|
||||||
|
Command,
|
||||||
|
CommandInput,
|
||||||
|
CommandList,
|
||||||
|
CommandEmpty,
|
||||||
|
CommandGroup,
|
||||||
|
CommandItem,
|
||||||
|
CommandSeparator,
|
||||||
|
};
|
||||||
@@ -372,14 +372,34 @@ const enUS = {
|
|||||||
targetPipeline: 'Pipeline',
|
targetPipeline: 'Pipeline',
|
||||||
targetDiscard: 'Discard',
|
targetDiscard: 'Discard',
|
||||||
selectTarget: 'Select handling logic',
|
selectTarget: 'Select handling logic',
|
||||||
|
searchTarget: 'Search…',
|
||||||
|
noTargetFound: 'No results found',
|
||||||
priority: 'Priority',
|
priority: 'Priority',
|
||||||
enabled: 'Enabled',
|
enabled: 'Enabled',
|
||||||
eventBindingDescriptionPlaceholder: 'Rule description',
|
eventBindingDescriptionPlaceholder: 'Rule description',
|
||||||
noEventBindings: 'No event bindings',
|
noEventBindings: 'No event bindings',
|
||||||
unsupportedPipelineEvent: 'Pipelines can only be used for message.* events',
|
unsupportedPipelineEvent: 'Pipelines can only be used for message.* events',
|
||||||
|
disable: 'Disable',
|
||||||
|
enable: 'Enable',
|
||||||
|
disabledBindings: 'Disabled',
|
||||||
eventCustom: 'Custom event',
|
eventCustom: 'Custom event',
|
||||||
eventWildcard: 'All events',
|
eventWildcard: 'All events',
|
||||||
eventNamespaceWildcard: '{{namespace}}.*',
|
eventNamespaceWildcard: '{{namespace}}.*',
|
||||||
|
conditions: 'Conditions',
|
||||||
|
conditionsDescription:
|
||||||
|
'All conditions must match to trigger this binding. Leave empty to always trigger.',
|
||||||
|
conditionsEmpty: 'No conditions — always triggers.',
|
||||||
|
addFilter: 'Add condition',
|
||||||
|
filterChatType: 'Session type',
|
||||||
|
filterChatId: 'Session ID',
|
||||||
|
filterMessageText: 'Message text',
|
||||||
|
filterMessageElement: 'Message element',
|
||||||
|
operator_eq: 'equals',
|
||||||
|
operator_neq: 'not equals',
|
||||||
|
operator_contains: 'contains',
|
||||||
|
operator_not_contains: 'not contains',
|
||||||
|
operator_starts_with: 'starts with',
|
||||||
|
operator_regex: 'regex',
|
||||||
routingRules: 'Conditional Routing Rules',
|
routingRules: 'Conditional Routing Rules',
|
||||||
routingRulesDescription:
|
routingRulesDescription:
|
||||||
'Rules are evaluated in order; first match routes to its pipeline. Fallback to the default pipeline above if none match.',
|
'Rules are evaluated in order; first match routes to its pipeline. Fallback to the default pipeline above if none match.',
|
||||||
|
|||||||
@@ -356,14 +356,33 @@ const zhHans = {
|
|||||||
targetPipeline: 'Pipeline',
|
targetPipeline: 'Pipeline',
|
||||||
targetDiscard: '丢弃',
|
targetDiscard: '丢弃',
|
||||||
selectTarget: '选择处理逻辑',
|
selectTarget: '选择处理逻辑',
|
||||||
|
searchTarget: '搜索处理逻辑…',
|
||||||
|
noTargetFound: '未找到匹配项',
|
||||||
priority: '优先级',
|
priority: '优先级',
|
||||||
enabled: '启用',
|
enabled: '启用',
|
||||||
eventBindingDescriptionPlaceholder: '规则说明',
|
eventBindingDescriptionPlaceholder: '规则说明',
|
||||||
noEventBindings: '暂无事件绑定',
|
noEventBindings: '暂无事件绑定',
|
||||||
unsupportedPipelineEvent: 'Pipeline 仅可用于 message.* 事件',
|
unsupportedPipelineEvent: 'Pipeline 仅可用于 message.* 事件',
|
||||||
|
disable: '禁用',
|
||||||
|
enable: '启用',
|
||||||
|
disabledBindings: '已禁用',
|
||||||
eventCustom: '自定义事件',
|
eventCustom: '自定义事件',
|
||||||
eventWildcard: '全部事件',
|
eventWildcard: '全部事件',
|
||||||
eventNamespaceWildcard: '{{namespace}}.*',
|
eventNamespaceWildcard: '{{namespace}}.*',
|
||||||
|
conditions: '触发条件',
|
||||||
|
conditionsDescription: '满足所有条件时才触发此绑定,不添加则无条件触发。',
|
||||||
|
conditionsEmpty: '无条件,始终触发。',
|
||||||
|
addFilter: '添加条件',
|
||||||
|
filterChatType: '会话类型',
|
||||||
|
filterChatId: '会话 ID',
|
||||||
|
filterMessageText: '消息文本',
|
||||||
|
filterMessageElement: '消息元素',
|
||||||
|
operator_eq: '等于',
|
||||||
|
operator_neq: '不等于',
|
||||||
|
operator_contains: '包含',
|
||||||
|
operator_not_contains: '不包含',
|
||||||
|
operator_starts_with: '前缀匹配',
|
||||||
|
operator_regex: '正则匹配',
|
||||||
routingRules: '条件路由规则',
|
routingRules: '条件路由规则',
|
||||||
routingRulesDescription:
|
routingRulesDescription:
|
||||||
'按顺序匹配,命中第一条规则后路由到对应流水线;都不匹配时使用上方默认流水线',
|
'按顺序匹配,命中第一条规则后路由到对应流水线;都不匹配时使用上方默认流水线',
|
||||||
|
|||||||
Reference in New Issue
Block a user