Compare commits

..

16 Commits

Author SHA1 Message Date
huanghuoguoguo 3de16bbbae fix(debug-chat): preserve websocket pipeline routing 2026-06-27 01:31:18 +08:00
huanghuoguoguo d0f6fe2cec feat(agent-runner): support scoped token counting 2026-06-27 01:31:08 +08:00
Junyan Qin ae49753f74 i18n: add missing agents block to es-ES, ru-RU, th-TH, vi-VN, zh-Hant
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 21:47:58 +08:00
Junyan Qin e9a7d7e58b feat(sidebar): add group-by-type toggle to installed extensions section
- Add group-by-type button to extensions category header (mirrors the
  agent group-by-kind pattern) — syncs with the extensions page Switch
  via shared SidebarDataContext state
- Relocate both group and refresh controls to sit left-aligned
  immediately after the title for both Agent and Extensions sections
- Add plugins.groupByTypeShort i18n key to all 8 locales

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 21:42:02 +08:00
Junyan Qin 957396b6e2 feat(sidebar): add group-by-kind toggle to Agent section
Add a toggle (left of the "+" button) that groups the Agent section by
kind, showing "Agent" and "Pipeline" sub-headers. State persists in
localStorage, mirroring the extensions group-by-type pattern.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 20:17:50 +08:00
Junyan Qin 89221b59ed refine(sidebar): show Agent/Pipeline marker as icon only
Drop the text label; keep the icon with a title tooltip so names have
more room in the narrow sidebar.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 20:11:33 +08:00
Junyan Qin fac56e30aa feat(sidebar): mark Agent entries as Agent or Pipeline
Add a trailing badge (icon + label) to each entry in the sidebar Agent
section so users can tell Agent orchestration apart from legacy Pipeline.
Thread the agent `kind` field through SidebarEntityItem.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 20:10:00 +08:00
Junyan Qin 0ad39b884f fix(bots): hide redundant namespace wildcard and show raw event code
- Only surface `ns.*` wildcard when the namespace has 2+ concrete events,
  so single-event adapters (legacy) show just the one event
- Show raw event code as a muted subtitle under each option's label

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 19:58:50 +08:00
Junyan Qin f157174ae4 feat(bots): refine event binding editor UI and i18n
- Move conditions toggle between event select and arrow; drop "IF" label
- Remove "all events" (*) option from event select
- Add i18n labels for concrete event names (zh/en/ja) via bots.eventNames
- Narrow fallback event set to message.received for adapters without
  declared supported_events (legacy adapters only emit message.received)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 19:52:01 +08:00
Junyan Qin a5acf41df1 fix(adapters): add categories to telegram EBA manifest
The Telegram (EBA) manifest was missing spec.categories, so it fell
into the uncategorized/protocol bucket. Restore popular + global to
match the legacy telegram adapter.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 19:18:14 +08:00
Junyan Qin ce31fc81b8 feat(adapters): mark EBA-superseded adapters as legacy and collapse them
The 12 old adapters that now have an EBA replacement are tagged
`spec.legacy: true` in their source manifests. Principle: don't delete,
de-emphasize.

- sources/*.yaml (aiocqhttp, dingtalk, discord, kook, lark,
  officialaccount, qqofficial, slack, telegram, wecom, wecombot,
  wecomcs): add spec.legacy: true
- Adapter / IChooseAdapterEntity types: add optional legacy flag
- BotForm adapter Select: split legacy adapters into a collapsed,
  grayscale group at the bottom with an explanatory hint; auto-expand
  when the bot already uses a legacy adapter
- Wizard platform picker: same collapsed legacy section
- i18n: legacyAdapters / legacyAdaptersHint (zh-Hans, en-US)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 19:10:07 +08:00
Junyan Qin 40e7481032 fix(web): remove discard option from event target selector
Unorchestrated events are discarded by default, so an explicit discard
target is redundant. Drop the discard CommandGroup (and the now-unused
CommandSeparator import); the currentLabel() discard branch is kept so
any pre-existing discard bindings still render correctly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 19:09:35 +08:00
Junyan Qin af459c1a72 fix(web): place each adapter in a single category bucket
groupByCategory pushed multi-category adapters (lark, wecom, discord,
slack) into every matching bucket, so the adapter Select rendered
duplicate SelectItem values — triggering React duplicate-key warnings
and corrupting Radix item tracking. Assign each item to its highest
-priority matching category only. Also de-dupes the wizard card grid.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 16:38:59 +08:00
Junyan Qin 1577567a78 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>
2026-06-26 16:27:25 +08:00
huanghuoguoguo d02e1a14a3 test(agent): cover agent service event bindings 2026-06-25 00:04:59 +08:00
huanghuoguoguo cd6a39d3a2 test(agent): cover pluginized agent runner runtime 2026-06-24 20:46:32 +08:00
66 changed files with 4698 additions and 1473 deletions
@@ -102,7 +102,7 @@ try {
}); });
Object.assign(result, prepared); Object.assign(result, prepared);
if (result.pipeline_id) { if (result.pipeline_id) {
result.pipeline_url = `${frontendUrl.replace(/\/$/, "")}/home/pipelines?id=${encodeURIComponent(result.pipeline_id)}`; result.pipeline_url = `${frontendUrl.replace(/\/$/, "")}/home/agents?id=${encodeURIComponent(result.pipeline_id)}`;
} }
if (writeEnv && result.pipeline_id) { if (writeEnv && result.pipeline_id) {
@@ -89,7 +89,7 @@ try {
}); });
Object.assign(result, prepared); Object.assign(result, prepared);
if (result.pipeline_id) { if (result.pipeline_id) {
result.pipeline_url = `${frontendUrl.replace(/\/$/, "")}/home/pipelines?id=${encodeURIComponent(result.pipeline_id)}`; result.pipeline_url = `${frontendUrl.replace(/\/$/, "")}/home/agents?id=${encodeURIComponent(result.pipeline_id)}`;
} }
if (writeEnv && result.pipeline_id) { if (writeEnv && result.pipeline_id) {
@@ -74,7 +74,7 @@ try {
}); });
Object.assign(result, prepared); Object.assign(result, prepared);
if (result.pipeline_id) { if (result.pipeline_id) {
result.pipeline_url = `${frontendUrl.replace(/\/$/, "")}/home/pipelines?id=${encodeURIComponent(result.pipeline_id)}`; result.pipeline_url = `${frontendUrl.replace(/\/$/, "")}/home/agents?id=${encodeURIComponent(result.pipeline_id)}`;
} }
if (writeEnv && result.pipeline_id) { if (writeEnv && result.pipeline_id) {
+1 -1
View File
@@ -1448,7 +1448,7 @@ test("generic pipeline readiness accepts either URL or name target", () => {
LANGBOT_BROWSER_PROFILE: "/tmp/langbot-test-profile", LANGBOT_BROWSER_PROFILE: "/tmp/langbot-test-profile",
LANGBOT_CHROMIUM_EXECUTABLE: "/tmp/langbot-test-chromium", LANGBOT_CHROMIUM_EXECUTABLE: "/tmp/langbot-test-chromium",
}, () => { }, () => {
process.env.LANGBOT_PIPELINE_URL = "http://127.0.0.1:3000/home/pipelines?id=only-url"; process.env.LANGBOT_PIPELINE_URL = "http://127.0.0.1:3000/home/agents?id=only-url";
process.env.LANGBOT_PIPELINE_NAME = ""; process.env.LANGBOT_PIPELINE_NAME = "";
const ready = capture(() => commandTestPlan(ctx(["test", "plan", "pipeline-debug-chat", "--json"]))); const ready = capture(() => commandTestPlan(ctx(["test", "plan", "pipeline-debug-chat", "--json"])));
@@ -184,7 +184,7 @@ class AgentRunContextBuilder:
def _is_llm_model_resource(model_resource: ModelResource) -> bool: def _is_llm_model_resource(model_resource: ModelResource) -> bool:
operations = model_resource.get('operations') operations = model_resource.get('operations')
if isinstance(operations, list) and operations: if isinstance(operations, list) and operations:
return bool({'invoke', 'stream'} & {str(operation) for operation in operations}) return bool({'invoke', 'stream', 'count_tokens'} & {str(operation) for operation in operations})
return model_resource.get('model_type') != 'rerank' return model_resource.get('model_type') != 'rerank'
async def _build_model_context_window_tokens(self, resources: AgentResources) -> int | None: async def _build_model_context_window_tokens(self, resources: AgentResources) -> int | None:
@@ -220,7 +220,6 @@ class AgentRunContextBuilder:
binding: AgentBinding, binding: AgentBinding,
descriptor: AgentRunnerDescriptor, descriptor: AgentRunnerDescriptor,
resources: AgentResources, resources: AgentResources,
run_id: str | None = None,
) -> AgentRunContextPayload: ) -> AgentRunContextPayload:
"""Build AgentRunContext from event-first envelope. """Build AgentRunContext from event-first envelope.
@@ -236,8 +235,8 @@ class AgentRunContextBuilder:
Returns: Returns:
AgentRunContextPayload for the runner AgentRunContextPayload for the runner
""" """
# Generate new run_id unless an API caller already reserved one. # Generate new run_id
run_id = run_id or str(uuid.uuid4()) run_id = str(uuid.uuid4())
# Build trigger from event # Build trigger from event
trigger: AgentTrigger = { trigger: AgentTrigger = {
@@ -68,7 +68,6 @@ class AgentRunOrchestrator:
binding: AgentBinding, binding: AgentBinding,
bound_plugins: list[str] | None = None, bound_plugins: list[str] | None = None,
adapter_context: dict[str, typing.Any] | None = None, adapter_context: dict[str, typing.Any] | None = None,
run_id: str | None = None,
) -> typing.AsyncGenerator[provider_message.Message | provider_message.MessageChunk, None]: ) -> typing.AsyncGenerator[provider_message.Message | provider_message.MessageChunk, None]:
"""Run an AgentRunner from an event-first envelope.""" """Run an AgentRunner from an event-first envelope."""
runner_id = binding.runner_id runner_id = binding.runner_id
@@ -85,7 +84,6 @@ class AgentRunOrchestrator:
binding=binding, binding=binding,
descriptor=descriptor, descriptor=descriptor,
resources=resources, resources=resources,
run_id=run_id,
) )
session_query_id = None session_query_id = None
+4 -2
View File
@@ -146,8 +146,10 @@ class AgentRunnerRegistry:
Returns: Returns:
List of runner descriptors List of runner descriptors
""" """
if use_cache and self._cache is not None: if use_cache and self._cache is not None and self._cache:
# Filter from cache # Filter from cache. Do not treat an empty cache as final because the
# plugin runtime may still be launching installed plugins when the
# first metadata request arrives.
return self._filter_runners_by_bound_plugins(self._cache, bound_plugins) return self._filter_runners_by_bound_plugins(self._cache, bound_plugins)
# Discover fresh (always full list) # Discover fresh (always full list)
@@ -101,9 +101,9 @@ class AgentResourceBuilder:
seen_model_ids: set[str] = set() seen_model_ids: set[str] = set()
model_perms = set(manifest_perms.models) model_perms = set(manifest_perms.models)
include_llm = bool({'invoke', 'stream'} & model_perms) include_llm = bool({'invoke', 'stream', 'count_tokens'} & model_perms)
include_rerank = 'rerank' in model_perms include_rerank = 'rerank' in model_perms
llm_operations = [operation for operation in ('invoke', 'stream') if operation in model_perms] llm_operations = [operation for operation in ('invoke', 'stream', 'count_tokens') if operation in model_perms]
if not include_llm and not include_rerank: if not include_llm and not include_rerank:
return models return models
@@ -13,7 +13,7 @@ from .context_builder import AgentResources
MAX_STEERING_QUEUE_ITEMS = 100 MAX_STEERING_QUEUE_ITEMS = 100
DEFAULT_RESOURCE_OPERATIONS: dict[str, set[str]] = { DEFAULT_RESOURCE_OPERATIONS: dict[str, set[str]] = {
'model': {'invoke', 'stream', 'rerank'}, 'model': {'invoke', 'stream', 'rerank', 'count_tokens'},
'tool': {'detail', 'call'}, 'tool': {'detail', 'call'},
'knowledge_base': {'list', 'retrieve'}, 'knowledge_base': {'list', 'retrieve'},
'skill': {'activate'}, 'skill': {'activate'},
+4 -23
View File
@@ -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)
@@ -10,7 +10,7 @@ ssereadtimeout) live in ``extra_args`` and are left untouched — the
auto-detecting remote transport consumes them regardless. auto-detecting remote transport consumes them regardless.
Revision ID: 0006_normalize_mcp_remote_mode Revision ID: 0006_normalize_mcp_remote_mode
Revises: 0005_add_llm_context_length Revises: 8d3a1f2c4b6e
Create Date: 2026-06-21 Create Date: 2026-06-21
""" """
@@ -18,7 +18,7 @@ import sqlalchemy as sa
from alembic import op from alembic import op
revision = '0006_normalize_mcp_remote_mode' revision = '0006_normalize_mcp_remote_mode'
down_revision = '0005_add_llm_context_length' down_revision = '8d3a1f2c4b6e'
branch_labels = None branch_labels = None
depends_on = None depends_on = None
@@ -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
@@ -12,6 +12,9 @@ metadata:
icon: telegram.svg icon: telegram.svg
spec: spec:
categories:
- popular
- global
config: config:
- name: token - name: token
label: label:
+38 -8
View File
@@ -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
@@ -906,17 +922,31 @@ class RuntimeBot:
) )
async def initialize(self): async def initialize(self):
def websocket_pipeline_uuid(event, adapter):
if adapter.__class__.__name__ != 'WebSocketAdapter':
return None
value = getattr(event, '_langbot_pipeline_uuid', None)
return value if isinstance(value, str) and value else None
async def on_friend_message( async def on_friend_message(
event: platform_events.FriendMessage, event: platform_events.FriendMessage,
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter, adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter,
): ):
await self._handle_legacy_message_event(event, adapter) await self._handle_legacy_message_event(
event,
adapter,
pipeline_uuid_override=websocket_pipeline_uuid(event, adapter),
)
async def on_group_message( async def on_group_message(
event: platform_events.GroupMessage, event: platform_events.GroupMessage,
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter, adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter,
): ):
await self._handle_legacy_message_event(event, adapter) await self._handle_legacy_message_event(
event,
adapter,
pipeline_uuid_override=websocket_pipeline_uuid(event, adapter),
)
self.adapter.register_listener(platform_events.FriendMessage, on_friend_message) self.adapter.register_listener(platform_events.FriendMessage, on_friend_message)
self.adapter.register_listener(platform_events.GroupMessage, on_group_message) self.adapter.register_listener(platform_events.GroupMessage, on_group_message)
@@ -12,6 +12,7 @@ metadata:
zh_Hant: OneBot v11 適配器,用於接入 QQ 機器人協定端,請查看文件了解使用方式 zh_Hant: OneBot v11 適配器,用於接入 QQ 機器人協定端,請查看文件了解使用方式
icon: onebot.png icon: onebot.png
spec: spec:
legacy: true
categories: categories:
- protocol - protocol
help_links: help_links:
@@ -12,6 +12,7 @@ metadata:
zh_Hant: 釘釘適配器,請查看文件了解使用方式 zh_Hant: 釘釘適配器,請查看文件了解使用方式
icon: dingtalk.svg icon: dingtalk.svg
spec: spec:
legacy: true
categories: categories:
- china - china
help_links: help_links:
@@ -20,6 +20,7 @@ metadata:
es_ES: Adaptador de Discord, requiere un entorno de red con acceso al servidor de Discord es_ES: Adaptador de Discord, requiere un entorno de red con acceso al servidor de Discord
icon: discord.svg icon: discord.svg
spec: spec:
legacy: true
categories: categories:
- popular - popular
- global - global
@@ -12,6 +12,7 @@ metadata:
zh_Hant: KOOK 適配器(原開黑啦),支援頻道訊息和私聊訊息 zh_Hant: KOOK 適配器(原開黑啦),支援頻道訊息和私聊訊息
icon: kook.png icon: kook.png
spec: spec:
legacy: true
categories: categories:
- china - china
help_links: help_links:
@@ -14,6 +14,7 @@ metadata:
ja_JP: Lark アダプター、長期接続およびWebhookモードの両方をサポートしています。使用方法の詳細については、ドキュメントを参照してください。 ja_JP: Lark アダプター、長期接続およびWebhookモードの両方をサポートしています。使用方法の詳細については、ドキュメントを参照してください。
icon: lark.svg icon: lark.svg
spec: spec:
legacy: true
categories: categories:
- popular - popular
- china - china
@@ -12,6 +12,7 @@ metadata:
zh_Hant: 微信公眾號適配器,需要公網地址以接收訊息推送,請查看文件了解使用方式 zh_Hant: 微信公眾號適配器,需要公網地址以接收訊息推送,請查看文件了解使用方式
icon: officialaccount.png icon: officialaccount.png
spec: spec:
legacy: true
categories: categories:
- china - china
help_links: help_links:
@@ -12,6 +12,7 @@ metadata:
zh_Hant: QQ 官方 API,支援 Webhook 和 WebSocket 兩種連線模式 zh_Hant: QQ 官方 API,支援 Webhook 和 WebSocket 兩種連線模式
icon: qqofficial.svg icon: qqofficial.svg
spec: spec:
legacy: true
categories: categories:
- china - china
help_links: help_links:
@@ -20,6 +20,7 @@ metadata:
es_ES: Adaptador de Slack, requiere una dirección pública para recibir notificaciones de mensajes de Slack, consulte la documentación para obtener instrucciones de uso es_ES: Adaptador de Slack, requiere una dirección pública para recibir notificaciones de mensajes de Slack, consulte la documentación para obtener instrucciones de uso
icon: slack.png icon: slack.png
spec: spec:
legacy: true
categories: categories:
- popular - popular
- global - global
@@ -20,6 +20,7 @@ metadata:
es_ES: Adaptador de Telegram, consulte la documentación para obtener instrucciones de uso es_ES: Adaptador de Telegram, consulte la documentación para obtener instrucciones de uso
icon: telegram.svg icon: telegram.svg
spec: spec:
legacy: true
categories: categories:
- popular - popular
- global - global
@@ -447,6 +447,8 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
sender=sender, message_chain=message_chain, time=datetime.now().timestamp() sender=sender, message_chain=message_chain, time=datetime.now().timestamp()
) )
object.__setattr__(event, '_langbot_pipeline_uuid', pipeline_uuid)
# 设置流水线UUID (proxy bot always needs it for reply_message routing) # 设置流水线UUID (proxy bot always needs it for reply_message routing)
self.ap.platform_mgr.websocket_proxy_bot.bot_entity.use_pipeline_uuid = pipeline_uuid self.ap.platform_mgr.websocket_proxy_bot.bot_entity.use_pipeline_uuid = pipeline_uuid
if owner_bot is not None: if owner_bot is not None:
@@ -12,6 +12,7 @@ metadata:
zh_Hant: 企業微信內部機器人,請查看文件了解使用方式 zh_Hant: 企業微信內部機器人,請查看文件了解使用方式
icon: wecom.png icon: wecom.png
spec: spec:
legacy: true
categories: categories:
- popular - popular
- china - china
@@ -12,6 +12,7 @@ metadata:
zh_Hant: 企業微信智慧機器人,支援長連線和 Webhook 兩種接入方式,請查看文件了解使用方式 zh_Hant: 企業微信智慧機器人,支援長連線和 Webhook 兩種接入方式,請查看文件了解使用方式
icon: wecombot.png icon: wecombot.png
spec: spec:
legacy: true
categories: categories:
- china - china
help_links: help_links:
@@ -12,6 +12,7 @@ metadata:
zh_Hant: 企業微信對外客服機器人,需要公網地址以接收訊息推送,請查看文件了解使用方式 zh_Hant: 企業微信對外客服機器人,需要公網地址以接收訊息推送,請查看文件了解使用方式
icon: wecom.png icon: wecom.png
spec: spec:
legacy: true
categories: categories:
- china - china
help_links: help_links:
@@ -3,24 +3,12 @@
from __future__ import annotations from __future__ import annotations
from typing import Any from typing import Any
import asyncio
import time import time
import uuid
from langbot_plugin.runtime.io import handler from langbot_plugin.runtime.io import handler
from langbot_plugin.api.entities.builtin.agent_runner.delivery import DeliveryContext
from langbot_plugin.api.entities.builtin.agent_runner.input import AgentInput
from ..agent.runner.host_models import (
AgentBinding,
AgentEventEnvelope,
BindingScope,
DeliveryPolicy,
ResourcePolicy,
StatePolicy,
)
from ..agent.runner.run_ledger_store import TERMINAL_STATUSES from ..agent.runner.run_ledger_store import TERMINAL_STATUSES
from .agent_run_support import ( from .agent_run_support import (
@@ -42,320 +30,7 @@ from .agent_run_support import (
) )
def _dict_payload(value: Any, *, field_name: str) -> tuple[dict[str, Any], handler.ActionResponse | None]:
if value is None:
return {}, None
if not isinstance(value, dict):
return {}, handler.ActionResponse.error(message=f'{field_name} must be an object')
return dict(value), None
def _build_run_create_event(data: dict[str, Any], *, run_id: str) -> tuple[AgentEventEnvelope | None, handler.ActionResponse | None]:
event_payload, error = _dict_payload(data.get('event'), field_name='event')
if error:
return None, error
input_payload = event_payload.get('input', data.get('input'))
if input_payload is None:
input_payload = {
'text': data.get('text'),
'contents': [],
'attachments': [],
}
if not isinstance(input_payload, dict):
return None, handler.ActionResponse.error(message='input must be an object')
delivery_payload = event_payload.get('delivery', data.get('delivery'))
if delivery_payload is None:
delivery_payload = {
'surface': 'api',
'reply_target': None,
'supports_streaming': False,
'supports_edit': False,
'supports_reaction': False,
'platform_capabilities': {},
}
if not isinstance(delivery_payload, dict):
return None, handler.ActionResponse.error(message='delivery must be an object')
event_data = event_payload.get('data', data.get('data'))
if event_data is None:
event_data = {}
if not isinstance(event_data, dict):
return None, handler.ActionResponse.error(message='event data must be an object')
event_type = str(event_payload.get('event_type') or data.get('event_type') or 'api.invoked')
source = str(event_payload.get('source') or data.get('source') or 'api')
event_id = str(event_payload.get('event_id') or data.get('event_id') or f'{source}:{run_id}')
event_time = event_payload.get('event_time', data.get('event_time'))
if event_time is None:
event_time = int(time.time())
try:
envelope = AgentEventEnvelope(
event_id=event_id,
event_type=event_type,
event_time=int(event_time) if isinstance(event_time, (int, float, str)) else None,
source=source,
source_event_type=event_payload.get('source_event_type') or data.get('source_event_type') or event_type,
bot_id=event_payload.get('bot_id', data.get('bot_id')),
workspace_id=event_payload.get('workspace_id', data.get('workspace_id')),
conversation_id=event_payload.get('conversation_id', data.get('conversation_id')),
thread_id=event_payload.get('thread_id', data.get('thread_id')),
actor=event_payload.get('actor', data.get('actor')),
subject=event_payload.get('subject', data.get('subject')),
input=AgentInput.model_validate(input_payload),
delivery=DeliveryContext.model_validate(delivery_payload),
raw_ref=event_payload.get('raw_ref', data.get('raw_ref')),
data=event_data,
)
except Exception as exc:
return None, handler.ActionResponse.error(message=f'invalid event payload: {exc}')
return envelope, None
def _build_run_create_binding(
data: dict[str, Any],
*,
event: AgentEventEnvelope,
run_id: str,
) -> tuple[AgentBinding | None, handler.ActionResponse | None]:
binding_payload, error = _dict_payload(data.get('binding'), field_name='binding')
if error:
return None, error
runner_id = binding_payload.get('runner_id') or data.get('runner_id')
if not runner_id:
return None, handler.ActionResponse.error(message='runner_id is required')
scope_payload = binding_payload.get('scope')
if scope_payload is None:
agent_id = binding_payload.get('agent_id') or data.get('agent_id')
workspace_id = event.workspace_id
bot_id = event.bot_id
if agent_id:
scope_payload = {'scope_type': 'agent', 'scope_id': agent_id}
elif bot_id:
scope_payload = {'scope_type': 'bot', 'scope_id': bot_id}
elif workspace_id:
scope_payload = {'scope_type': 'workspace', 'scope_id': workspace_id}
else:
scope_payload = {'scope_type': 'global', 'scope_id': None}
if not isinstance(scope_payload, dict):
return None, handler.ActionResponse.error(message='binding.scope must be an object')
runner_config_payload = binding_payload.get('runner_config', data.get('runner_config'))
if runner_config_payload is None:
runner_config_payload = {}
if not isinstance(runner_config_payload, dict):
return None, handler.ActionResponse.error(message='runner_config must be an object')
resource_policy_payload = binding_payload.get('resource_policy', data.get('resource_policy'))
if resource_policy_payload is None:
resource_policy_payload = {}
if not isinstance(resource_policy_payload, dict):
return None, handler.ActionResponse.error(message='resource_policy must be an object')
state_policy_payload = binding_payload.get('state_policy', data.get('state_policy'))
if state_policy_payload is None:
state_policy_payload = {}
if not isinstance(state_policy_payload, dict):
return None, handler.ActionResponse.error(message='state_policy must be an object')
delivery_policy_payload = binding_payload.get('delivery_policy', data.get('delivery_policy'))
if delivery_policy_payload is None:
delivery_policy_payload = {
'enable_streaming': bool(event.delivery.supports_streaming),
'enable_reply': False,
}
if not isinstance(delivery_policy_payload, dict):
return None, handler.ActionResponse.error(message='delivery_policy must be an object')
try:
binding = AgentBinding(
binding_id=str(binding_payload.get('binding_id') or data.get('binding_id') or f'api:{runner_id}:{run_id}'),
scope=BindingScope.model_validate(scope_payload),
event_types=list(binding_payload.get('event_types') or data.get('event_types') or [event.event_type]),
runner_id=str(runner_id),
runner_config=runner_config_payload,
resource_policy=ResourcePolicy.model_validate(resource_policy_payload),
state_policy=StatePolicy.model_validate(state_policy_payload),
delivery_policy=DeliveryPolicy.model_validate(delivery_policy_payload),
enabled=bool(binding_payload.get('enabled', data.get('enabled', True))),
agent_id=binding_payload.get('agent_id') or data.get('agent_id'),
)
except Exception as exc:
return None, handler.ActionResponse.error(message=f'invalid binding payload: {exc}')
if event.event_type not in binding.event_types:
return None, handler.ActionResponse.error(
message=f'binding.event_types must include event type {event.event_type}'
)
return binding, None
async def _consume_programmatic_run(
h,
*,
run_id: str,
event: AgentEventEnvelope,
binding: AgentBinding,
bound_plugins: list[str] | None,
) -> None:
async for _result in h.ap.agent_run_orchestrator.run(
event,
binding,
bound_plugins=bound_plugins,
run_id=run_id,
):
pass
def register(h): def register(h):
@h.action(_plugin_runtime_action('RUN_CREATE', 'run_create'))
async def run_create(data: dict[str, Any]) -> handler.ActionResponse:
"""Create a programmatic AgentRunner run from an explicit event and binding."""
caller_plugin_identity = data.get('caller_plugin_identity')
if not _has_agent_runner_admin_permission(
h.ap,
caller_plugin_identity,
AGENT_RUN_ADMIN_PERMISSION,
):
return handler.ActionResponse.error(message='Run create access not authorized')
orchestrator = getattr(h.ap, 'agent_run_orchestrator', None)
if orchestrator is None:
return handler.ActionResponse.error(message='AgentRunOrchestrator is not available')
run_id = str(data.get('run_id') or uuid.uuid4())
event, event_error = _build_run_create_event(data, run_id=run_id)
if event_error:
return event_error
assert event is not None
binding, binding_error = _build_run_create_binding(data, event=event, run_id=run_id)
if binding_error:
return binding_error
assert binding is not None
include_plugins = data.get('include_plugins')
if include_plugins is not None and not isinstance(include_plugins, list):
return handler.ActionResponse.error(message='include_plugins must be a list')
bound_plugins = [str(item) for item in include_plugins] if include_plugins else None
registry = getattr(h.ap, 'agent_runner_registry', None)
if registry is not None:
try:
await registry.get(binding.runner_id, bound_plugins)
except Exception as exc:
return handler.ActionResponse.error(message=f'Runner {binding.runner_id} is not available: {exc}')
async def background_run(*, raise_errors: bool = False) -> None:
from ..agent.runner.run_ledger_store import RunLedgerStore
store = RunLedgerStore(h.ap.persistence_mgr.get_db_engine())
try:
await _consume_programmatic_run(
h,
run_id=run_id,
event=event,
binding=binding,
bound_plugins=bound_plugins,
)
except Exception as exc:
h.ap.logger.error(f'RUN_CREATE background run {run_id} failed: {exc}', exc_info=True)
if await store.get_run(run_id) is None:
await store.create_run(
run_id=run_id,
event_id=event.event_id,
binding_id=binding.binding_id,
runner_id=binding.runner_id,
conversation_id=event.conversation_id,
thread_id=event.thread_id,
workspace_id=event.workspace_id,
bot_id=event.bot_id,
agent_id=binding.agent_id,
authorization={
'runner_id': binding.runner_id,
'binding_id': binding.binding_id,
'plugin_identity': None,
'resources': {},
'available_apis': {},
'conversation_id': event.conversation_id,
'bot_id': event.bot_id,
'workspace_id': event.workspace_id,
'thread_id': event.thread_id,
},
metadata={
'event_type': event.event_type,
'source': event.source,
'run_create_error': True,
},
status='running',
)
await store.finalize_run(
run_id=run_id,
status='failed',
status_reason=str(exc),
metadata={'run_create_error': True},
)
if raise_errors:
raise
if data.get('wait_for_completion'):
try:
await background_run(raise_errors=True)
except Exception as exc:
return handler.ActionResponse.error(message=f'Run create error: {exc}')
from ..agent.runner.run_ledger_store import RunLedgerStore
store = RunLedgerStore(h.ap.persistence_mgr.get_db_engine())
run = await store.get_run(run_id)
if run is not None:
await _record_agent_runner_admin_action(
h.ap,
store,
action='run_create',
caller_plugin_identity=caller_plugin_identity,
permission=AGENT_RUN_ADMIN_PERMISSION,
durable_run_id=run_id,
detail={'runner_id': binding.runner_id, 'event_type': event.event_type},
)
return handler.ActionResponse.success(data=run)
else:
asyncio.create_task(background_run())
await _record_agent_runner_admin_action(
h.ap,
None,
action='run_create',
caller_plugin_identity=caller_plugin_identity,
permission=AGENT_RUN_ADMIN_PERMISSION,
durable_run_id=run_id,
detail={'runner_id': binding.runner_id, 'event_type': event.event_type},
)
return handler.ActionResponse.success(
data={
'run_id': run_id,
'event_id': event.event_id,
'agent_id': binding.agent_id,
'binding_id': binding.binding_id,
'runner_id': binding.runner_id,
'conversation_id': event.conversation_id,
'thread_id': event.thread_id,
'workspace_id': event.workspace_id,
'bot_id': event.bot_id,
'status': 'created',
'metadata': {
'event_type': event.event_type,
'source': event.source,
'accepted': True,
},
}
)
@h.action(_plugin_runtime_action('RUN_GET', 'run_get')) @h.action(_plugin_runtime_action('RUN_GET', 'run_get'))
async def run_get(data: dict[str, Any]) -> handler.ActionResponse: async def run_get(data: dict[str, Any]) -> handler.ActionResponse:
"""Get one Host-owned run record visible to the current run.""" """Get one Host-owned run record visible to the current run."""
+49
View File
@@ -556,6 +556,55 @@ class RuntimeConnectionHandler(handler.Handler):
}, },
) )
@self.action(PluginToRuntimeAction.COUNT_TOKENS)
async def count_tokens(data: dict[str, Any]) -> handler.ActionResponse:
"""Count model input tokens.
For AgentRunner calls: requires run_id and validates model_uuid against session.resources.models.
For regular plugin calls: no run_id, unrestricted access (backward compatibility).
"""
llm_model_uuid = data['llm_model_uuid']
messages = data['messages']
funcs = data.get('funcs', [])
extra_args = data.get('extra_args', {})
run_id = data.get('run_id')
caller_plugin_identity = data.get('caller_plugin_identity')
if run_id:
_session, error = await _validate_run_authorization(
run_id, 'model', llm_model_uuid, self.ap, caller_plugin_identity, operation='count_tokens'
)
if error:
return error
llm_model = await self.ap.model_mgr.get_model_by_uuid(llm_model_uuid)
if llm_model is None:
return handler.ActionResponse.error(
message=f'LLM model with llm_model_uuid {llm_model_uuid} not found',
)
messages_obj = [provider_message.Message.model_validate(message) for message in messages]
async def _placeholder_func(**kwargs):
pass
funcs_obj = [resource_tool.LLMTool.model_validate({**func, 'func': _placeholder_func}) for func in funcs]
count_tokens_method = getattr(llm_model.provider.requester, 'count_tokens', None)
if not callable(count_tokens_method):
return handler.ActionResponse.error(message='LLM provider does not support token counting')
try:
tokens = await count_tokens_method(
model=llm_model,
messages=messages_obj,
funcs=funcs_obj,
extra_args=extra_args,
)
except Exception as exc:
return handler.ActionResponse.error(message=f'Token counting failed: {exc}')
return handler.ActionResponse.success(data={'tokens': tokens})
@self.action(PluginToRuntimeAction.INVOKE_LLM) @self.action(PluginToRuntimeAction.INVOKE_LLM)
async def invoke_llm(data: dict[str, Any]) -> handler.ActionResponse: async def invoke_llm(data: dict[str, Any]) -> handler.ActionResponse:
"""Invoke llm """Invoke llm
@@ -411,6 +411,20 @@ class ProviderAPIRequester(metaclass=abc.ABCMeta):
""" """
pass pass
async def count_tokens(
self,
model: RuntimeLLMModel,
messages: typing.List[provider_message.Message],
funcs: typing.List[resource_tool.LLMTool] = None,
extra_args: dict[str, typing.Any] = {},
) -> int:
"""Count model input tokens before invoking the model.
Requesters should use the same provider/model conversion path as
``invoke_llm`` so the preflight count matches the actual request shape.
"""
raise NotImplementedError('This requester does not support token counting')
async def invoke_llm_stream( async def invoke_llm_stream(
self, self,
query: pipeline_query.Query, query: pipeline_query.Query,
@@ -521,6 +521,33 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
return args return args
async def count_tokens(
self,
model: requester.RuntimeLLMModel,
messages: typing.List[provider_message.Message],
funcs: typing.List[resource_tool.LLMTool] = None,
extra_args: dict[str, typing.Any] = {},
) -> int:
"""Count input tokens with LiteLLM's model-aware tokenizer."""
args = await self._build_completion_args(model, messages, funcs, extra_args, stream=False)
count_args: dict[str, typing.Any] = {
'model': args['model'],
'messages': args['messages'],
}
if 'tools' in args:
count_args['tools'] = args['tools']
if 'tool_choice' in args:
count_args['tool_choice'] = args['tool_choice']
try:
tokens = litellm.token_counter(**count_args)
except Exception as e:
self._handle_litellm_error(e)
if isinstance(tokens, bool) or not isinstance(tokens, int) or tokens < 0:
raise errors.RequesterError(f'token counter returned invalid value: {tokens!r}')
return tokens
async def invoke_llm( async def invoke_llm(
self, self,
query: pipeline_query.Query, query: pipeline_query.Query,
+1 -1
View File
@@ -61,7 +61,7 @@ def langbot_process(e2e_config_path, e2e_port, e2e_tmpdir):
project_root=project_root, project_root=project_root,
work_dir=e2e_tmpdir, # Run in tmpdir where data/config.yaml exists work_dir=e2e_tmpdir, # Run in tmpdir where data/config.yaml exists
port=e2e_port, port=e2e_port,
timeout=60, # Longer timeout for first startup timeout=180, # Longer timeout for first startup
collect_coverage=collect_coverage, collect_coverage=collect_coverage,
) )
@@ -0,0 +1,473 @@
"""E2E tests for pluginized AgentRunner execution.
This module starts the real LangBot backend with the plugin system enabled and
loads a deterministic AgentRunner plugin through the real SDK Plugin Runtime.
"""
from __future__ import annotations
import shutil
import socket
import sqlite3
import subprocess
import tempfile
import textwrap
import time
from pathlib import Path
import httpx
import pytest
from tests.e2e.utils.config_factory import create_minimal_config, create_test_directories
from tests.e2e.utils.process_manager import LangBotProcess, find_project_root
pytestmark = pytest.mark.e2e
QA_RUNNER_ID = 'plugin:e2e/agent-runner-qa/default'
QA_PLUGIN_DIRNAME = 'e2e__agent-runner-qa'
@pytest.fixture(scope='session')
def agent_runner_e2e_port():
"""Port for the AgentRunner plugin-runtime E2E process."""
return 15310
@pytest.fixture(scope='session')
def agent_runner_e2e_tmpdir():
"""Create temporary directory for AgentRunner E2E testing."""
tmpdir = Path(tempfile.mkdtemp(prefix='langbot_agent_runner_e2e_'))
yield tmpdir
shutil.rmtree(tmpdir, ignore_errors=True)
def _write_qa_agent_runner_plugin(plugin_root: Path) -> None:
"""Write a deterministic AgentRunner plugin used by this E2E."""
runner_dir = plugin_root / 'components' / 'agent_runner'
runner_dir.mkdir(parents=True, exist_ok=True)
(plugin_root / 'assets').mkdir(parents=True, exist_ok=True)
(plugin_root / 'assets' / 'icon.svg').write_text(
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1 1"></svg>',
encoding='utf-8',
)
(plugin_root / 'manifest.yaml').write_text(
textwrap.dedent(
"""
apiVersion: langbot/v1
kind: Plugin
metadata:
author: e2e
name: agent-runner-qa
version: 0.1.0
label:
en_US: AgentRunner QA
zh_Hans: AgentRunner QA
description:
en_US: Deterministic AgentRunner E2E probe.
zh_Hans: 确定性的 AgentRunner E2E 探针
icon: assets/icon.svg
spec:
version: 0.1.0
config: []
components:
AgentRunner:
fromDirs:
- path: components/agent_runner/
pages: []
execution:
python:
path: main.py
attr: AgentRunnerQAPlugin
"""
).strip()
+ '\n',
encoding='utf-8',
)
(plugin_root / 'main.py').write_text(
textwrap.dedent(
"""
from __future__ import annotations
from langbot_plugin.api.definition.plugin import BasePlugin
class AgentRunnerQAPlugin(BasePlugin):
async def initialize(self) -> None:
pass
"""
).strip()
+ '\n',
encoding='utf-8',
)
(runner_dir / 'default.yaml').write_text(
textwrap.dedent(
"""
apiVersion: langbot/v1
kind: AgentRunner
metadata:
name: default
label:
en_US: QA Echo Runner
zh_Hans: QA Echo Runner
description:
en_US: Echoes input and exercises run-scoped state APIs.
zh_Hans: 回显输入并验证运行级状态 API
spec:
config: []
capabilities:
streaming: false
permissions: {}
execution:
python:
path: default.py
attr: DefaultAgentRunner
"""
).strip()
+ '\n',
encoding='utf-8',
)
(runner_dir / 'default.py').write_text(
textwrap.dedent(
"""
from __future__ import annotations
from typing import AsyncGenerator
from langbot_plugin.api.definition.components.agent_runner.runner import AgentRunner
from langbot_plugin.api.entities.builtin.agent_runner.context import AgentRunContext
from langbot_plugin.api.entities.builtin.agent_runner.result import AgentRunResult
from langbot_plugin.api.entities.builtin.provider.message import Message
class DefaultAgentRunner(AgentRunner):
async def run(self, ctx: AgentRunContext) -> AsyncGenerator[AgentRunResult, None]:
text = ctx.input.to_text()
yield AgentRunResult.message_completed(
ctx.run_id,
Message(role='assistant', content=f'e2e echo: {text}'),
)
yield AgentRunResult.state_updated(
ctx.run_id,
'e2e.echo_count',
{'count': 1},
scope='conversation',
)
yield AgentRunResult.run_completed(ctx.run_id, finish_reason='stop')
"""
).strip()
+ '\n',
encoding='utf-8',
)
def _free_port() -> int:
"""Reserve a currently-free localhost TCP port for this E2E process."""
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
sock.bind(('127.0.0.1', 0))
return int(sock.getsockname()[1])
@pytest.fixture(scope='session')
def agent_runner_runtime_ports():
"""Control/debug ports for the standalone plugin runtime."""
control_port = _free_port()
debug_port = _free_port()
while debug_port == control_port:
debug_port = _free_port()
return control_port, debug_port
@pytest.fixture(scope='session')
def agent_runner_e2e_config_path(agent_runner_e2e_tmpdir, agent_runner_e2e_port, agent_runner_runtime_ports):
"""Create a plugin-enabled config and deterministic AgentRunner fixture."""
config_path = create_minimal_config(agent_runner_e2e_tmpdir, port=agent_runner_e2e_port)
create_test_directories(agent_runner_e2e_tmpdir)
import yaml
with open(config_path, encoding='utf-8') as f:
config = yaml.safe_load(f)
config['api']['global_api_key'] = 'e2e-agent-runner-key'
runtime_control_port, _runtime_debug_port = agent_runner_runtime_ports
config['plugin']['enable'] = True
config['plugin']['runtime_ws_url'] = f'ws://127.0.0.1:{runtime_control_port}/control/ws'
config['plugin']['enable_marketplace'] = False
config['box']['enabled'] = False
config['system']['jwt']['secret'] = 'e2e-agent-runner-secret-key'
with open(config_path, 'w', encoding='utf-8') as f:
yaml.safe_dump(config, f, default_flow_style=False)
_write_qa_agent_runner_plugin(agent_runner_e2e_tmpdir / 'data' / 'plugins' / QA_PLUGIN_DIRNAME)
return config_path
@pytest.fixture(scope='session')
def agent_runner_runtime_process(agent_runner_e2e_tmpdir, agent_runner_runtime_ports):
"""Start the real SDK plugin runtime over WebSocket."""
control_port, debug_port = agent_runner_runtime_ports
stdout_path = agent_runner_e2e_tmpdir / 'plugin-runtime.stdout.log'
stderr_path = agent_runner_e2e_tmpdir / 'plugin-runtime.stderr.log'
stdout_file = open(stdout_path, 'wb')
stderr_file = open(stderr_path, 'wb')
proc = subprocess.Popen(
[
str(find_project_root() / '.venv' / 'bin' / 'python'),
'-m',
'langbot_plugin.cli.__init__',
'rt',
'--ws-control-port',
str(control_port),
'--ws-debug-port',
str(debug_port),
],
cwd=agent_runner_e2e_tmpdir,
stdout=stdout_file,
stderr=stderr_file,
start_new_session=True,
)
yield proc
proc.terminate()
try:
proc.wait(timeout=5)
except subprocess.TimeoutExpired:
proc.kill()
proc.wait()
stdout_file.close()
stderr_file.close()
@pytest.fixture(scope='session')
def agent_runner_langbot_process(
agent_runner_e2e_config_path,
agent_runner_e2e_port,
agent_runner_e2e_tmpdir,
agent_runner_runtime_process,
):
"""Start real LangBot with plugin runtime enabled."""
project_root = find_project_root()
proc = LangBotProcess(
project_root=project_root,
work_dir=agent_runner_e2e_tmpdir,
port=agent_runner_e2e_port,
timeout=180,
debug=True,
cli_args=['--standalone-runtime'],
)
success = proc.start()
if not success:
stdout, stderr = proc.get_logs()
pytest.fail(f'LangBot failed to start with AgentRunner plugin runtime:\nstdout: {stdout}\nstderr: {stderr}')
yield proc
proc.stop()
@pytest.fixture
def agent_runner_client(agent_runner_e2e_port, agent_runner_langbot_process):
"""HTTP client for the AgentRunner E2E backend."""
with httpx.Client(
base_url=f'http://127.0.0.1:{agent_runner_e2e_port}',
timeout=90.0,
trust_env=False,
) as client:
yield client
def _init_and_auth(client: httpx.Client) -> str:
"""Initialize the test admin user and return a bearer token."""
init_resp = client.post('/api/v1/user/init', json={'user': 'admin', 'password': 'admin'})
assert init_resp.status_code == 200
assert init_resp.json()['code'] in [0, 1]
auth_resp = client.post('/api/v1/user/auth', json={'user': 'admin', 'password': 'admin'})
assert auth_resp.status_code == 200
payload = auth_resp.json()
assert payload['code'] == 0
return payload['data']['token']
def test_plugin_runtime_discovers_agent_runner(agent_runner_client, agent_runner_langbot_process):
"""Pipeline metadata should include the real runtime-discovered QA runner."""
token = _init_and_auth(agent_runner_client)
start = time.time()
while time.time() - start < 60:
response = agent_runner_client.get(
'/api/v1/pipelines/_/metadata',
headers={'Authorization': f'Bearer {token}'},
)
assert response.status_code == 200
data = response.json()
assert data['code'] == 0
metadata_groups = data['data']['configs']
ai_metadata = next(group for group in metadata_groups if group.get('name') == 'ai')
runner_stage = next(stage for stage in ai_metadata['stages'] if stage['name'] == 'runner')
runner_select = next(item for item in runner_stage['config'] if item['name'] == 'id')
option_names = {option['name'] for option in runner_select['options']}
if QA_RUNNER_ID in option_names:
return
time.sleep(2)
assert QA_RUNNER_ID in option_names
def test_host_orchestrator_runs_agent_runner_and_records_ledger(
agent_runner_e2e_config_path,
agent_runner_e2e_tmpdir,
agent_runner_runtime_process,
):
"""The Host orchestrator should run the pluginized runner and persist run side effects."""
import asyncio
import os
from langbot.pkg.agent.runner.host_models import (
AgentBinding,
AgentEventEnvelope,
BindingScope,
DeliveryPolicy,
StatePolicy,
)
from langbot.pkg.core import boot
from langbot.pkg.utils import platform as platform_utils
from langbot_plugin.api.entities.builtin.agent_runner.delivery import DeliveryContext
from langbot_plugin.api.entities.builtin.agent_runner.event import ActorContext, SubjectContext
from langbot_plugin.api.entities.builtin.agent_runner.input import AgentInput
async def _run_probe():
previous_cwd = Path.cwd()
previous_standalone_runtime = platform_utils.standalone_runtime
os.chdir(agent_runner_e2e_tmpdir)
platform_utils.standalone_runtime = True
ap = None
try:
ap = await boot.make_app(asyncio.get_running_loop())
for _ in range(60):
handler = getattr(ap.plugin_connector, 'handler', None)
if handler is not None:
await handler.ping()
break
await asyncio.sleep(1)
else:
raise AssertionError('Plugin runtime did not connect')
for _ in range(60):
runners = await ap.agent_runner_registry.list_runners(use_cache=False)
if any(runner.id == QA_RUNNER_ID for runner in runners):
break
await asyncio.sleep(1)
else:
raise AssertionError(f'{QA_RUNNER_ID} was not discovered')
event = AgentEventEnvelope(
event_id='e2e-orchestrator-event-001',
event_type='message.received',
source='api',
conversation_id='e2e-conversation',
thread_id='e2e-thread',
actor=ActorContext(actor_type='user', actor_id='user-001', actor_name='E2E User'),
subject=SubjectContext(subject_type='chat', subject_id='chat-001'),
input=AgentInput(text='hello from orchestrator e2e'),
delivery=DeliveryContext(surface='e2e'),
)
binding = AgentBinding(
binding_id='e2e-binding',
scope=BindingScope(scope_type='global'),
runner_id=QA_RUNNER_ID,
state_policy=StatePolicy(enable_state=True, state_scopes=['conversation']),
delivery_policy=DeliveryPolicy(enable_streaming=False, enable_reply=True),
)
return [message async for message in ap.agent_run_orchestrator.run(event, binding)]
finally:
if ap is not None:
ap.dispose()
platform_utils.standalone_runtime = previous_standalone_runtime
os.chdir(previous_cwd)
messages = asyncio.run(_run_probe())
assert len(messages) == 1
assert messages[0].role == 'assistant'
assert messages[0].content == 'e2e echo: hello from orchestrator e2e'
db_path = agent_runner_e2e_tmpdir / 'data' / 'langbot.db'
conn = sqlite3.connect(str(db_path))
try:
run_row = conn.execute(
"SELECT status, runner_id FROM agent_run WHERE event_id = 'e2e-orchestrator-event-001'"
).fetchone()
assert run_row == ('completed', QA_RUNNER_ID)
event_types = {
row[0]
for row in conn.execute(
"SELECT type FROM agent_run_event WHERE run_id = (SELECT run_id FROM agent_run WHERE event_id = 'e2e-orchestrator-event-001')"
).fetchall()
}
assert {'state.updated', 'message.completed', 'run.completed'}.issubset(event_types)
state_row = conn.execute(
"SELECT value_json FROM agent_runner_state WHERE state_key = 'e2e.echo_count'"
).fetchone()
assert state_row is not None
assert '"count": 1' in state_row[0]
finally:
conn.close()
def test_pluginized_agent_runner_executes_through_runtime(agent_runner_client, agent_runner_langbot_process):
"""The Host debug surface should invoke the QA runner through the real Plugin Runtime."""
token = _init_and_auth(agent_runner_client)
start = time.time()
while time.time() - start < 60:
metadata_response = agent_runner_client.get(
'/api/v1/pipelines/_/metadata',
headers={'Authorization': f'Bearer {token}'},
)
assert metadata_response.status_code == 200
metadata = metadata_response.json()['data']['configs']
ai_metadata = next(group for group in metadata if group.get('name') == 'ai')
runner_stage = next(stage for stage in ai_metadata['stages'] if stage['name'] == 'runner')
runner_select = next(item for item in runner_stage['config'] if item['name'] == 'id')
if QA_RUNNER_ID in {option['name'] for option in runner_select['options']}:
break
time.sleep(2)
else:
pytest.fail(f'{QA_RUNNER_ID} was not discovered before run_agent')
response = agent_runner_client.post(
'/api/v1/system/debug/plugin/action',
headers={'Authorization': f'Bearer {token}'},
json={
'action': 'run_agent',
'timeout': 60,
'data': {
'plugin_author': 'e2e',
'plugin_name': 'agent-runner-qa',
'runner_name': 'default',
'context': {
'run_id': 'e2e-run-001',
'trigger': {'type': 'message.received'},
'event': {
'event_id': 'e2e-event-001',
'event_type': 'message.received',
'source': 'api',
},
'input': {'text': 'hello from real e2e'},
'delivery': {'surface': 'e2e'},
'resources': {},
'runtime': {},
},
},
},
)
assert response.status_code == 200
payload = response.json()
assert payload['code'] == 0
result = payload['data']
assert result['type'] == 'message.completed', result
assert result['data']['message']['role'] == 'assistant'
assert result['data']['message']['content'] == 'e2e echo: hello from real e2e'
+25
View File
@@ -141,6 +141,27 @@ def create_minimal_config(tmpdir: Path, port: int = 15300) -> Path:
'delete_batch_size': 1000, 'delete_batch_size': 1000,
}, },
}, },
'box': {
'enabled': False,
'backend': 'local',
'runtime': {
'endpoint': '',
},
'local': {
'profile': 'default',
'image': '',
'host_root': str(tmpdir / 'box'),
'default_workspace': '',
'skills_root': 'skills',
'allowed_mount_roots': [str(tmpdir / 'box'), '/tmp'],
'workspace_quota_mb': None,
},
'e2b': {
'api_key': '',
'api_url': '',
'template': '',
},
},
'space': { 'space': {
'url': 'https://space.langbot.app', 'url': 'https://space.langbot.app',
'models_gateway_api_url': 'https://api.langbot.cloud/v1', 'models_gateway_api_url': 'https://api.langbot.cloud/v1',
@@ -168,8 +189,12 @@ def create_test_directories(tmpdir: Path) -> dict[str, Path]:
"""Create necessary directories for LangBot testing.""" """Create necessary directories for LangBot testing."""
directories = { directories = {
'data': tmpdir / 'data', 'data': tmpdir / 'data',
'labels': tmpdir / 'data' / 'labels',
'metadata': tmpdir / 'data' / 'metadata',
'logs': tmpdir / 'logs', 'logs': tmpdir / 'logs',
'data_logs': tmpdir / 'data' / 'logs',
'storage': tmpdir / 'storage', 'storage': tmpdir / 'storage',
'box': tmpdir / 'box',
'chroma': tmpdir / 'chroma', 'chroma': tmpdir / 'chroma',
} }
+40 -7
View File
@@ -9,6 +9,7 @@ import subprocess
import time import time
import signal import signal
import os import os
import sys
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional
import logging import logging
@@ -26,13 +27,21 @@ class LangBotProcess:
port: int = 15300, port: int = 15300,
timeout: int = 30, timeout: int = 30,
collect_coverage: bool = True, collect_coverage: bool = True,
debug: bool = False,
cli_args: list[str] | None = None,
): ):
self.project_root = project_root self.project_root = project_root
self.work_dir = work_dir # Directory containing data/config.yaml self.work_dir = work_dir # Directory containing data/config.yaml
self.port = port self.port = port
self.timeout = timeout self.timeout = timeout
self.collect_coverage = collect_coverage self.collect_coverage = collect_coverage
self.debug = debug
self.cli_args = cli_args or []
self.process: Optional[subprocess.Popen] = None self.process: Optional[subprocess.Popen] = None
self._stdout_file = None
self._stderr_file = None
self._stdout_path = self.work_dir / 'langbot.stdout.log'
self._stderr_path = self.work_dir / 'langbot.stderr.log'
self._stdout_data: bytes = b'' self._stdout_data: bytes = b''
self._stderr_data: bytes = b'' self._stderr_data: bytes = b''
self._coverage_file: Optional[Path] = None self._coverage_file: Optional[Path] = None
@@ -63,13 +72,14 @@ class LangBotProcess:
# Disable telemetry # Disable telemetry
env['SPACE__DISABLE_TELEMETRY'] = 'true' env['SPACE__DISABLE_TELEMETRY'] = 'true'
env['SPACE__DISABLE_MODELS_SERVICE'] = 'true' env['SPACE__DISABLE_MODELS_SERVICE'] = 'true'
if self.debug:
env['DEBUG'] = 'true'
# Build command # Build command
if self.collect_coverage: if self.collect_coverage:
# Use coverage.py to collect coverage data # Use coverage.py to collect coverage data
# Set COVERAGE_PROCESS_START to enable coverage in subprocess # Set COVERAGE_PROCESS_START to enable coverage in subprocess
self._coverage_file = self.work_dir / '.coverage.e2e' self._coverage_file = self.work_dir / '.coverage.e2e'
env['COVERAGE_PROCESS_START'] = str(self.project_root / '.coveragerc')
env['COVERAGE_FILE'] = str(self._coverage_file) env['COVERAGE_FILE'] = str(self._coverage_file)
# Create .coveragerc for subprocess # Create .coveragerc for subprocess
@@ -88,27 +98,33 @@ precision = 2
coveragerc_path = self.work_dir / '.coveragerc' coveragerc_path = self.work_dir / '.coveragerc'
with open(coveragerc_path, 'w') as f: with open(coveragerc_path, 'w') as f:
f.write(coveragerc_content) f.write(coveragerc_content)
env['COVERAGE_PROCESS_START'] = str(coveragerc_path)
cmd = [ cmd = [
sys.executable,
'-m',
'coverage', 'coverage',
'run', 'run',
'--rcfile=' + str(coveragerc_path), '--rcfile=' + str(coveragerc_path),
'-m', '-m',
'langbot', 'langbot',
*self.cli_args,
] ]
else: else:
cmd = ['uv', 'run', 'python', '-m', 'langbot'] cmd = [sys.executable, '-m', 'langbot', *self.cli_args]
logger.info(f'Starting LangBot in: {self.work_dir}') logger.info(f'Starting LangBot in: {self.work_dir}')
logger.info(f'Command: {cmd}') logger.info(f'Command: {cmd}')
# Start process (run in work_dir so it finds data/config.yaml) # Start process (run in work_dir so it finds data/config.yaml)
self._stdout_file = open(self._stdout_path, 'wb')
self._stderr_file = open(self._stderr_path, 'wb')
self.process = subprocess.Popen( self.process = subprocess.Popen(
cmd, cmd,
cwd=self.work_dir, cwd=self.work_dir,
env=env, env=env,
stdout=subprocess.PIPE, stdout=self._stdout_file,
stderr=subprocess.PIPE, stderr=self._stderr_file,
preexec_fn=os.setsid if os.name != 'nt' else None, preexec_fn=os.setsid if os.name != 'nt' else None,
) )
@@ -117,7 +133,14 @@ precision = 2
while time.time() - start_time < self.timeout: while time.time() - start_time < self.timeout:
# Check if process died # Check if process died
if self.process.poll() is not None: if self.process.poll() is not None:
self._stdout_data, self._stderr_data = self.process.communicate() if self._stdout_file:
self._stdout_file.close()
self._stdout_file = None
if self._stderr_file:
self._stderr_file.close()
self._stderr_file = None
self._stdout_data = self._stdout_path.read_bytes() if self._stdout_path.exists() else b''
self._stderr_data = self._stderr_path.read_bytes() if self._stderr_path.exists() else b''
logger.error(f'LangBot process died: {self._stderr_data.decode()}') logger.error(f'LangBot process died: {self._stderr_data.decode()}')
return False return False
@@ -170,8 +193,14 @@ precision = 2
self.process.wait() self.process.wait()
# Collect output for debugging # Collect output for debugging
if self.process.stdout or self.process.stderr: if self._stdout_file:
self._stdout_data, self._stderr_data = self.process.communicate() self._stdout_file.close()
self._stdout_file = None
if self._stderr_file:
self._stderr_file.close()
self._stderr_file = None
self._stdout_data = self._stdout_path.read_bytes() if self._stdout_path.exists() else b''
self._stderr_data = self._stderr_path.read_bytes() if self._stderr_path.exists() else b''
self.process = None self.process = None
@@ -183,6 +212,10 @@ precision = 2
"""Get stdout and stderr logs.""" """Get stdout and stderr logs."""
stdout = self._stdout_data.decode('utf-8', errors='replace') stdout = self._stdout_data.decode('utf-8', errors='replace')
stderr = self._stderr_data.decode('utf-8', errors='replace') stderr = self._stderr_data.decode('utf-8', errors='replace')
if not stdout and self._stdout_path.exists():
stdout = self._stdout_path.read_text(encoding='utf-8', errors='replace')
if not stderr and self._stderr_path.exists():
stderr = self._stderr_path.read_text(encoding='utf-8', errors='replace')
return stdout, stderr return stdout, stderr
def get_coverage_file(self) -> Optional[Path]: def get_coverage_file(self) -> Optional[Path]:
+1 -1
View File
@@ -77,7 +77,7 @@ def make_session(
} }
authorized_operations: dict[str, dict[str, set[str]]] = { authorized_operations: dict[str, dict[str, set[str]]] = {
'model': { 'model': {
m.get('model_id'): set(m.get('operations') or ['invoke', 'stream', 'rerank']) m.get('model_id'): set(m.get('operations') or ['invoke', 'stream', 'rerank', 'count_tokens'])
for m in res.get('models', []) for m in res.get('models', [])
if m.get('model_id') if m.get('model_id')
}, },
@@ -14,7 +14,7 @@ from langbot.pkg.agent.runner.resource_builder import AgentResourceBuilder
RUNNER_ID = 'plugin:test/runner/default' RUNNER_ID = 'plugin:test/runner/default'
FULL_PERMISSIONS = { FULL_PERMISSIONS = {
'models': ['invoke', 'stream', 'rerank'], 'models': ['count_tokens', 'invoke', 'stream', 'rerank'],
'tools': ['detail', 'call'], 'tools': ['detail', 'call'],
'knowledge_bases': ['list', 'retrieve'], 'knowledge_bases': ['list', 'retrieve'],
'history': ['page', 'search'], 'history': ['page', 'search'],
@@ -139,9 +139,24 @@ async def test_build_models_authorizes_config_declared_llm_and_rerank_models(app
resources = await build_resources(app, query, descriptor) resources = await build_resources(app, query, descriptor)
assert resources['models'] == [ assert resources['models'] == [
{'model_id': 'primary', 'model_type': 'llm', 'provider': 'test-provider', 'operations': ['invoke', 'stream']}, {
{'model_id': 'fallback', 'model_type': 'llm', 'provider': 'test-provider', 'operations': ['invoke', 'stream']}, 'model_id': 'primary',
{'model_id': 'aux', 'model_type': 'llm', 'provider': 'aux-provider', 'operations': ['invoke', 'stream']}, 'model_type': 'llm',
'provider': 'test-provider',
'operations': ['invoke', 'stream', 'count_tokens'],
},
{
'model_id': 'fallback',
'model_type': 'llm',
'provider': 'test-provider',
'operations': ['invoke', 'stream', 'count_tokens'],
},
{
'model_id': 'aux',
'model_type': 'llm',
'provider': 'aux-provider',
'operations': ['invoke', 'stream', 'count_tokens'],
},
{'model_id': 'rerank', 'model_type': 'rerank', 'provider': 'rerank-provider', 'operations': ['rerank']}, {'model_id': 'rerank', 'model_type': 'rerank', 'provider': 'rerank-provider', 'operations': ['rerank']},
] ]
@@ -189,7 +204,12 @@ async def test_build_models_authorizes_rerank_and_llm_refs_from_config(app):
resources = await build_resources(app, query, descriptor) resources = await build_resources(app, query, descriptor)
assert resources['models'] == [ assert resources['models'] == [
{'model_id': 'llm', 'model_type': 'llm', 'provider': 'test-provider', 'operations': ['invoke', 'stream']}, {
'model_id': 'llm',
'model_type': 'llm',
'provider': 'test-provider',
'operations': ['invoke', 'stream', 'count_tokens'],
},
{'model_id': 'rerank', 'model_type': 'rerank', 'provider': 'rerank-provider', 'operations': ['rerank']}, {'model_id': 'rerank', 'model_type': 'rerank', 'provider': 'rerank-provider', 'operations': ['rerank']},
] ]
@@ -222,7 +242,12 @@ async def test_build_resources_accepts_dynamic_form_type_aliases(app):
resources = await build_resources(app, query, descriptor) resources = await build_resources(app, query, descriptor)
assert resources['models'] == [ assert resources['models'] == [
{'model_id': 'llm_alias', 'model_type': 'llm', 'provider': 'test-provider', 'operations': ['invoke', 'stream']}, {
'model_id': 'llm_alias',
'model_type': 'llm',
'provider': 'test-provider',
'operations': ['invoke', 'stream', 'count_tokens'],
},
] ]
assert resources['knowledge_bases'] == [ assert resources['knowledge_bases'] == [
{'kb_id': 'kb_alias', 'kb_name': 'name-kb_alias', 'kb_type': 'default', 'operations': ['list', 'retrieve']}, {'kb_id': 'kb_alias', 'kb_name': 'name-kb_alias', 'kb_type': 'default', 'operations': ['list', 'retrieve']},
@@ -33,12 +33,11 @@ class FakeConnection:
class FakeApplication: class FakeApplication:
def __init__(self, db_engine, admin_plugins=None, runner_registry=None, orchestrator=None): def __init__(self, db_engine, admin_plugins=None, runner_registry=None):
self.logger = MagicMock() self.logger = MagicMock()
self.persistence_mgr = MagicMock() self.persistence_mgr = MagicMock()
self.persistence_mgr.get_db_engine = MagicMock(return_value=db_engine) self.persistence_mgr.get_db_engine = MagicMock(return_value=db_engine)
self.agent_runner_registry = runner_registry self.agent_runner_registry = runner_registry
self.agent_run_orchestrator = orchestrator
self.instance_config = SimpleNamespace( self.instance_config = SimpleNamespace(
data={ data={
'agent_runner': { 'agent_runner': {
@@ -73,66 +72,16 @@ class FakeRunnerRegistry:
self.runners = runners self.runners = runners
self.calls = [] self.calls = []
async def get(self, runner_id, bound_plugins=None):
self.calls.append({'runner_id': runner_id, 'bound_plugins': bound_plugins})
if isinstance(self.runners, dict):
if runner_id not in self.runners:
raise KeyError(runner_id)
return self.runners[runner_id]
for runner in self.runners:
if getattr(runner, 'id', None) == runner_id:
return runner
raise KeyError(runner_id)
async def list_runners(self, *, bound_plugins=None, use_cache=True): async def list_runners(self, *, bound_plugins=None, use_cache=True):
self.calls.append({'bound_plugins': bound_plugins, 'use_cache': use_cache}) self.calls.append({'bound_plugins': bound_plugins, 'use_cache': use_cache})
return self.runners return self.runners
class FakeProgrammaticOrchestrator: def _handler(db_engine, admin_plugins=None, runner_registry=None):
def __init__(self, db_engine):
self.db_engine = db_engine
self.calls = []
async def run(self, event, binding, bound_plugins=None, adapter_context=None, run_id=None):
self.calls.append(
{
'event': event,
'binding': binding,
'bound_plugins': bound_plugins,
'adapter_context': adapter_context,
'run_id': run_id,
}
)
store = RunLedgerStore(self.db_engine)
await store.create_run(
run_id=run_id,
event_id=event.event_id,
binding_id=binding.binding_id,
runner_id=binding.runner_id,
conversation_id=event.conversation_id,
thread_id=event.thread_id,
workspace_id=event.workspace_id,
bot_id=event.bot_id,
authorization={'available_apis': {}},
metadata={'event_type': event.event_type, 'source': event.source},
status='running',
)
await store.finalize_run(run_id=run_id, status='completed', status_reason='done')
if False:
yield None
def _handler(db_engine, admin_plugins=None, runner_registry=None, orchestrator=None):
async def fake_disconnect(): async def fake_disconnect():
return True return True
fake_app = FakeApplication( fake_app = FakeApplication(db_engine, admin_plugins=admin_plugins, runner_registry=runner_registry)
db_engine,
admin_plugins=admin_plugins,
runner_registry=runner_registry,
orchestrator=orchestrator,
)
return RuntimeConnectionHandler(FakeConnection(), fake_disconnect, fake_app) return RuntimeConnectionHandler(FakeConnection(), fake_disconnect, fake_app)
@@ -1311,64 +1260,6 @@ async def test_runtime_register_heartbeat_and_list_actions(session_registry, db_
assert [item['runtime_id'] for item in page.data['items']] == ['runtime_1'] assert [item['runtime_id'] for item in page.data['items']] == ['runtime_1']
@pytest.mark.asyncio
async def test_admin_run_create_starts_programmatic_run(db_engine):
orchestrator = FakeProgrammaticOrchestrator(db_engine)
runner_id = 'plugin:test/runner/default'
registry = FakeRunnerRegistry({runner_id: SimpleNamespace(id=runner_id)})
handler = _handler(
db_engine,
admin_plugins=[{'identity': 'admin/plugin', 'permissions': ['agent_run:admin']}],
runner_registry=registry,
orchestrator=orchestrator,
)
run_create = handler.actions[PluginToRuntimeAction.RUN_CREATE.value]
result = await run_create(
{
'caller_plugin_identity': 'admin/plugin',
'run_id': 'run_programmatic',
'runner_id': runner_id,
'input': {'text': 'work on issue 1', 'contents': [], 'attachments': []},
'conversation_id': 'conv_issue_board',
'event_type': 'api.invoked',
'wait_for_completion': True,
}
)
assert result.code == 0
assert result.data['run_id'] == 'run_programmatic'
assert result.data['runner_id'] == runner_id
assert result.data['status'] == 'completed'
assert len(orchestrator.calls) == 1
call = orchestrator.calls[0]
assert call['run_id'] == 'run_programmatic'
assert call['event'].event_type == 'api.invoked'
assert call['event'].source == 'api'
assert call['event'].conversation_id == 'conv_issue_board'
assert call['binding'].runner_id == runner_id
assert call['binding'].delivery_policy.enable_reply is False
@pytest.mark.asyncio
async def test_run_create_requires_admin_permission(db_engine):
orchestrator = FakeProgrammaticOrchestrator(db_engine)
handler = _handler(db_engine, orchestrator=orchestrator)
run_create = handler.actions[PluginToRuntimeAction.RUN_CREATE.value]
result = await run_create(
{
'caller_plugin_identity': 'regular/plugin',
'runner_id': 'plugin:test/runner/default',
'input': {'text': 'work'},
}
)
assert result.code != 0
assert 'not authorized' in result.message
assert orchestrator.calls == []
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_run_claim_renew_and_release_actions(session_registry, db_engine): async def test_run_claim_renew_and_release_actions(session_registry, db_engine):
await _register_session( await _register_session(
@@ -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',
}
@@ -0,0 +1,309 @@
from __future__ import annotations
import datetime as dt
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock
import pytest
from langbot.pkg.api.http.service.agent import (
AGENT_DEFAULT_EVENT_PATTERNS,
AGENT_KIND_AGENT,
AGENT_KIND_PIPELINE,
PIPELINE_EVENT_PATTERNS,
AgentService,
)
pytestmark = pytest.mark.asyncio
def _result(items: list | None = None, first_item=None):
result = Mock()
result.all = Mock(return_value=items or [])
result.first = Mock(return_value=first_item)
return result
def _agent_row(
agent_uuid: str = 'agent-1',
name: str = 'Test Agent',
updated_at: dt.datetime | str | None = None,
config: dict | None = None,
supported_event_patterns: list[str] | None = None,
):
return SimpleNamespace(
uuid=agent_uuid,
name=name,
description='Agent description',
emoji='A',
kind=AGENT_KIND_AGENT,
component_ref='plugin:test/runner/default',
config=config or {
'runner': {'id': 'plugin:test/runner/default', 'expire-time': 0},
'runner_config': {'plugin:test/runner/default': {'temperature': 0.2}},
},
enabled=True,
supported_event_patterns=supported_event_patterns or ['*'],
created_at=dt.datetime(2026, 1, 1, 9, 0, 0),
updated_at=updated_at or dt.datetime(2026, 1, 1, 10, 0, 0),
)
def _serialize_agent(model_cls, entity, masked_columns=None):
return {
'uuid': entity.uuid,
'name': entity.name,
'description': entity.description,
'emoji': entity.emoji,
'kind': entity.kind,
'component_ref': entity.component_ref,
'config': entity.config,
'enabled': entity.enabled,
'supported_event_patterns': entity.supported_event_patterns,
'created_at': entity.created_at,
'updated_at': entity.updated_at,
}
def _compiled_params(statement):
return statement.compile().params
def _compiled_update_values(statement):
return {
key: value
for key, value in statement.compile().params.items()
if not key.startswith('uuid_')
}
def _make_app():
app = SimpleNamespace()
app.persistence_mgr = SimpleNamespace(
execute_async=AsyncMock(),
serialize_model=Mock(side_effect=_serialize_agent),
)
app.pipeline_service = SimpleNamespace(
get_pipeline_metadata=AsyncMock(return_value=[]),
get_pipelines=AsyncMock(return_value=[]),
get_pipeline=AsyncMock(return_value=None),
create_pipeline=AsyncMock(),
update_pipeline=AsyncMock(),
delete_pipeline=AsyncMock(),
_get_default_values_from_schema=Mock(return_value={}),
)
app.agent_runner_registry = None
app.logger = Mock()
return app
class TestAgentServiceMetadata:
async def test_get_agent_metadata_exposes_runner_config_and_kind_capabilities(self):
app = _make_app()
ai_metadata = {'name': 'ai', 'stages': [{'name': 'runner'}]}
app.pipeline_service.get_pipeline_metadata = AsyncMock(
return_value=[{'name': 'trigger'}, ai_metadata, {'name': 'output'}]
)
metadata = await AgentService(app).get_agent_metadata()
assert metadata['runner_config'] == ai_metadata
assert metadata['kinds'] == [
{
'name': AGENT_KIND_AGENT,
'supported_event_patterns': AGENT_DEFAULT_EVENT_PATTERNS,
'message_only': False,
},
{
'name': AGENT_KIND_PIPELINE,
'supported_event_patterns': PIPELINE_EVENT_PATTERNS,
'message_only': True,
},
]
class TestAgentServiceListAndLookup:
async def test_get_agents_merges_agents_and_pipelines_without_leaking_config(self):
app = _make_app()
app.persistence_mgr.execute_async = AsyncMock(
return_value=_result(
items=[
_agent_row(
agent_uuid='agent-1',
updated_at=dt.datetime(2026, 1, 1, 10, 0, 0),
supported_event_patterns=['platform.member.*'],
)
]
)
)
app.pipeline_service.get_pipelines = AsyncMock(
return_value=[
{
'uuid': 'pipeline-1',
'name': 'Pipeline Agent',
'description': 'Legacy pipeline',
'emoji': 'P',
'config': {'ai': {'runner': {'id': 'pipeline-runner'}}},
'created_at': '2026-01-01T08:00:00',
'updated_at': '2026-01-01T11:00:00',
}
]
)
agents = await AgentService(app).get_agents(sort_by='updated_at', sort_order='DESC')
assert [agent['uuid'] for agent in agents] == ['pipeline-1', 'agent-1']
assert agents[0]['kind'] == AGENT_KIND_PIPELINE
assert agents[0]['component_ref'] == 'pipeline'
assert agents[0]['capability'] == {
'supported_event_patterns': PIPELINE_EVENT_PATTERNS,
'message_only': True,
}
assert agents[1]['kind'] == AGENT_KIND_AGENT
assert agents[1]['capability'] == {
'supported_event_patterns': ['platform.member.*'],
'message_only': False,
}
assert all('config' not in agent for agent in agents)
async def test_get_agent_returns_agent_with_config_before_pipeline_fallback(self):
app = _make_app()
agent = _agent_row(agent_uuid='agent-1')
app.persistence_mgr.execute_async = AsyncMock(return_value=_result(first_item=agent))
result = await AgentService(app).get_agent('agent-1')
assert result['uuid'] == 'agent-1'
assert result['kind'] == AGENT_KIND_AGENT
assert result['config'] == agent.config
app.pipeline_service.get_pipeline.assert_not_awaited()
async def test_get_agent_falls_back_to_pipeline_product_item_with_config(self):
app = _make_app()
app.persistence_mgr.execute_async = AsyncMock(return_value=_result(first_item=None))
app.pipeline_service.get_pipeline = AsyncMock(
return_value={
'uuid': 'pipeline-1',
'name': 'Pipeline Agent',
'description': 'Legacy pipeline',
'emoji': 'P',
'config': {'ai': {'runner': {'id': 'pipeline-runner'}}},
'created_at': '2026-01-01T08:00:00',
'updated_at': '2026-01-01T11:00:00',
}
)
result = await AgentService(app).get_agent('pipeline-1')
assert result['kind'] == AGENT_KIND_PIPELINE
assert result['enabled'] is True
assert result['config'] == {'ai': {'runner': {'id': 'pipeline-runner'}}}
assert result['capability']['message_only'] is True
class TestAgentServiceCreateUpdateDelete:
async def test_create_agent_uses_default_runner_config_from_registry(self):
app = _make_app()
runner = SimpleNamespace(
id='plugin:langbot/local-agent/default',
config_schema=[
{'name': 'model', 'default': 'gpt-4.1'},
{'name': 'temperature', 'default': 0.2},
{'name': 'no-default'},
],
)
app.agent_runner_registry = SimpleNamespace(list_runners=AsyncMock(return_value=[runner]))
app.pipeline_service._get_default_values_from_schema = Mock(
return_value={'model': 'gpt-4.1', 'temperature': 0.2}
)
app.persistence_mgr.execute_async = AsyncMock(return_value=Mock())
result = await AgentService(app).create_agent(
{
'name': 'Support Agent',
'description': 'Handles support events',
'emoji': 'S',
}
)
insert_values = _compiled_params(app.persistence_mgr.execute_async.await_args.args[0])
assert result['kind'] == AGENT_KIND_AGENT
assert result['uuid'] == insert_values['uuid']
assert insert_values['name'] == 'Support Agent'
assert insert_values['component_ref'] == runner.id
assert insert_values['config'] == {
'runner': {'id': runner.id, 'expire-time': 0},
'runner_config': {runner.id: {'model': 'gpt-4.1', 'temperature': 0.2}},
}
assert insert_values['enabled'] is True
assert insert_values['supported_event_patterns'] == AGENT_DEFAULT_EVENT_PATTERNS
app.pipeline_service._get_default_values_from_schema.assert_called_once_with(runner.config_schema)
async def test_update_agent_protects_immutable_fields_and_recalculates_component_ref(self):
app = _make_app()
app.persistence_mgr.execute_async = AsyncMock(
side_effect=[
_result(first_item=_agent_row(agent_uuid='agent-1')),
Mock(),
]
)
new_config = {
'runner': {'id': 'plugin:test/new-runner/default', 'expire-time': 0},
'runner_config': {'plugin:test/new-runner/default': {'timeout': 30}},
}
await AgentService(app).update_agent(
'agent-1',
{
'uuid': 'caller-owned-uuid',
'kind': AGENT_KIND_PIPELINE,
'created_at': '2020-01-01T00:00:00',
'updated_at': '2020-01-01T00:00:00',
'capability': {'message_only': True},
'name': 'Updated Agent',
'config': new_config,
'supported_event_patterns': [],
},
)
update_values = _compiled_update_values(app.persistence_mgr.execute_async.await_args_list[1].args[0])
assert update_values == {
'name': 'Updated Agent',
'config': new_config,
'supported_event_patterns': AGENT_DEFAULT_EVENT_PATTERNS,
'component_ref': 'plugin:test/new-runner/default',
}
async def test_pipeline_kind_create_update_delete_delegate_to_pipeline_service(self):
app = _make_app()
app.persistence_mgr.execute_async = AsyncMock(return_value=_result(first_item=None))
app.pipeline_service.create_pipeline = AsyncMock(return_value='pipeline-created')
app.pipeline_service.get_pipeline = AsyncMock(return_value={'uuid': 'pipeline-1'})
service = AgentService(app)
created = await service.create_agent(
{
'kind': AGENT_KIND_PIPELINE,
'name': 'Pipeline Agent',
'description': 'Legacy pipeline',
'emoji': 'P',
}
)
await service.update_agent('pipeline-1', {'name': 'Updated Pipeline'})
await service.delete_agent('pipeline-1')
assert created == {'uuid': 'pipeline-created', 'kind': AGENT_KIND_PIPELINE}
app.pipeline_service.create_pipeline.assert_awaited_once_with(
{
'name': 'Pipeline Agent',
'description': 'Legacy pipeline',
'emoji': 'P',
'config': {},
}
)
app.pipeline_service.update_pipeline.assert_awaited_once_with(
'pipeline-1',
{'name': 'Updated Pipeline'},
)
app.pipeline_service.delete_pipeline.assert_awaited_once_with('pipeline-1')
+193 -100
View File
@@ -52,6 +52,15 @@ def _create_mock_result(items: list = None, first_item=None):
return result return result
def _compiled_update_values(statement):
"""Return update values without SQLAlchemy WHERE bind params."""
return {
key: value
for key, value in statement.compile().params.items()
if not key.startswith('uuid_')
}
def _create_mock_discover(adapter_webhook_flags: dict[str, bool] = None): def _create_mock_discover(adapter_webhook_flags: dict[str, bool] = None):
"""Create mock ComponentDiscoveryEngine exposing MessagePlatformAdapter manifests. """Create mock ComponentDiscoveryEngine exposing MessagePlatformAdapter manifests.
@@ -386,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."""
@@ -472,63 +428,200 @@ 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.""" class TestBotServiceEventBindings:
# Setup """Tests for EBA event binding validation and persistence."""
async def test_normalize_event_bindings_validates_targets_and_preserves_order(self):
"""Valid bindings are normalized with stable order and target data."""
ap = SimpleNamespace() ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace() ap.persistence_mgr = SimpleNamespace()
ap.persistence_mgr.execute_async = AsyncMock(
# Mock pipeline query returns None side_effect=[
pipeline_result = Mock() _create_mock_result(first_item=SimpleNamespace(uuid='pipeline-1')),
pipeline_result.first = Mock(return_value=None) _create_mock_result(
ap.persistence_mgr.execute_async = AsyncMock(return_value=pipeline_result) first_item=SimpleNamespace(
uuid='agent-1',
supported_event_patterns=['platform.member.*'],
)
),
]
)
service = BotService(ap) service = BotService(ap)
# Execute & Verify normalized = await service._normalize_event_bindings(
with pytest.raises(Exception, match='Pipeline not found'): [
await service.update_bot('test-uuid', {'use_pipeline_uuid': 'nonexistent-pipeline'}) {
'event_pattern': ' message.received ',
'target_type': 'pipeline',
'target_uuid': ' pipeline-1 ',
'filters': [{'field': 'sender.id', 'operator': 'eq', 'value': '1000'}],
'priority': '5',
'enabled': False,
'description': 'Route message events',
},
{
'id': 'agent-binding',
'event_pattern': 'platform.member.joined',
'target_type': 'agent',
'target_uuid': 'agent-1',
'priority': 7,
},
{
'event_pattern': 'platform.member.left',
'target_type': 'discard',
'target_uuid': 'ignored-target',
},
]
)
async def test_update_bot_sets_pipeline_name(self): uuid.UUID(normalized[0]['id'])
"""Sets use_pipeline_name when updating use_pipeline_uuid.""" assert normalized == [
# Setup {
'id': normalized[0]['id'],
'event_pattern': 'message.received',
'target_type': 'pipeline',
'target_uuid': 'pipeline-1',
'filters': [{'field': 'sender.id', 'operator': 'eq', 'value': '1000'}],
'priority': 5,
'enabled': False,
'description': 'Route message events',
'order': 0,
},
{
'id': 'agent-binding',
'event_pattern': 'platform.member.joined',
'target_type': 'agent',
'target_uuid': 'agent-1',
'filters': [],
'priority': 7,
'enabled': True,
'description': '',
'order': 1,
},
{
'id': normalized[2]['id'],
'event_pattern': 'platform.member.left',
'target_type': 'discard',
'target_uuid': '',
'filters': [],
'priority': 0,
'enabled': True,
'description': '',
'order': 2,
},
]
async def test_normalize_event_bindings_rejects_pipeline_for_non_message_event(self):
"""Pipeline targets are limited to message events."""
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace(execute_async=AsyncMock())
service = BotService(ap)
with pytest.raises(ValueError, match='Pipeline can only be bound to message events'):
await service._normalize_event_bindings(
[
{
'event_pattern': 'platform.member.joined',
'target_type': 'pipeline',
'target_uuid': 'pipeline-1',
}
]
)
ap.persistence_mgr.execute_async.assert_not_awaited()
@pytest.mark.parametrize(
('agent', 'error'),
[
(None, 'Agent not found'),
(
SimpleNamespace(uuid='agent-1', supported_event_patterns=['message.*']),
'Agent does not support this event pattern',
),
],
)
async def test_normalize_event_bindings_rejects_invalid_agent_target(self, agent, error):
"""Agent targets must exist and support the requested event pattern."""
ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace(
execute_async=AsyncMock(return_value=_create_mock_result(first_item=agent))
)
service = BotService(ap)
with pytest.raises(ValueError, match=error):
await service._normalize_event_bindings(
[
{
'event_pattern': 'platform.member.joined',
'target_type': 'agent',
'target_uuid': 'agent-1',
}
]
)
async def test_update_bot_persists_normalized_event_bindings_and_reloads_runtime_bot(self):
"""update_bot stores normalized bindings before reloading the runtime bot."""
ap = SimpleNamespace() ap = SimpleNamespace()
ap.persistence_mgr = SimpleNamespace() ap.persistence_mgr = SimpleNamespace()
ap.platform_mgr = SimpleNamespace() ap.persistence_mgr.execute_async = AsyncMock(
ap.platform_mgr.remove_bot = AsyncMock() side_effect=[
_create_mock_result(
# Mock pipeline query first_item=SimpleNamespace(
mock_pipeline = SimpleNamespace() uuid='agent-1',
mock_pipeline.name = 'Updated Pipeline' supported_event_patterns=['platform.member.*'],
pipeline_result = Mock() )
pipeline_result.first = Mock(return_value=mock_pipeline) ),
Mock(),
call_count = 0 ]
)
async def mock_execute(query): runtime_bot = SimpleNamespace(enable=True, run=AsyncMock())
nonlocal call_count loaded_bot = {'uuid': 'bot-1', 'name': 'Bot with bindings'}
call_count += 1 ap.platform_mgr = SimpleNamespace(
if call_count == 1: remove_bot=AsyncMock(),
return pipeline_result load_bot=AsyncMock(return_value=runtime_bot),
return Mock() )
bot_session = SimpleNamespace(using_conversation=SimpleNamespace(bot_uuid='bot-1'))
ap.persistence_mgr.execute_async = AsyncMock(side_effect=mock_execute) other_session = SimpleNamespace(using_conversation=SimpleNamespace(bot_uuid='other-bot'))
ap.sess_mgr = SimpleNamespace() ap.sess_mgr = SimpleNamespace(session_list=[bot_session, other_session])
ap.sess_mgr.session_list = []
service = BotService(ap) service = BotService(ap)
service.get_bot = AsyncMock(return_value={'uuid': 'test-uuid'}) service.get_bot = AsyncMock(return_value=loaded_bot)
runtime_bot = SimpleNamespace() await service.update_bot(
runtime_bot.enable = False 'bot-1',
ap.platform_mgr.load_bot = AsyncMock(return_value=runtime_bot) {
'event_bindings': [
{
'id': 'binding-1',
'event_pattern': 'platform.member.joined',
'target_type': 'agent',
'target_uuid': 'agent-1',
'priority': '9',
}
]
},
)
# Execute update_values = _compiled_update_values(ap.persistence_mgr.execute_async.await_args_list[1].args[0])
await service.update_bot('test-uuid', {'use_pipeline_uuid': 'pipeline-uuid'}) assert update_values['event_bindings'] == [
{
update_params = ap.persistence_mgr.execute_async.await_args_list[1].args[0].compile().params 'id': 'binding-1',
assert update_params['use_pipeline_uuid'] == 'pipeline-uuid' 'event_pattern': 'platform.member.joined',
assert update_params['use_pipeline_name'] == 'Updated Pipeline' 'target_type': 'agent',
'target_uuid': 'agent-1',
'filters': [],
'priority': 9,
'enabled': True,
'description': '',
'order': 0,
}
]
ap.platform_mgr.remove_bot.assert_awaited_once_with('bot-1')
service.get_bot.assert_awaited_once_with('bot-1')
ap.platform_mgr.load_bot.assert_awaited_once_with(loaded_bot)
runtime_bot.run.assert_awaited_once()
assert bot_session.using_conversation is None
assert other_session.using_conversation.bot_uuid == 'other-bot'
class TestBotServiceDeleteBot: class TestBotServiceDeleteBot:
@@ -2,6 +2,7 @@
RuntimeBot.resolve_pipeline_uuid and _match_operator unit tests RuntimeBot.resolve_pipeline_uuid and _match_operator unit tests
""" """
from types import SimpleNamespace
from unittest.mock import Mock from unittest.mock import Mock
@@ -278,3 +279,108 @@ class TestResolvePipelineUuid:
uuid, routed = bot.resolve_pipeline_uuid('person', '123', 'normal message') uuid, routed = bot.resolve_pipeline_uuid('person', '123', 'normal message')
assert uuid == 'default-uuid' assert uuid == 'default-uuid'
assert routed is False assert routed is False
class TestEBAEventBindings:
"""Test RuntimeBot EBA event binding helpers."""
@staticmethod
def _make_bot(bindings):
from langbot.pkg.platform.botmgr import RuntimeBot
bot = object.__new__(RuntimeBot)
bot.bot_entity = SimpleNamespace(event_bindings=bindings)
return bot
def test_resolve_eba_event_binding_uses_enabled_pattern_filters_priority_and_order(self):
"""The selected binding is the first matching highest-priority binding."""
bot = self._make_bot(
[
{
'id': 'disabled',
'enabled': False,
'event_pattern': 'platform.member.joined',
'target_type': 'agent',
'target_uuid': 'agent-disabled',
'priority': 100,
'order': 0,
},
{
'id': 'wrong-room',
'event_pattern': 'platform.member.joined',
'target_type': 'agent',
'target_uuid': 'agent-wrong-room',
'filters': [{'field': 'room.id', 'operator': 'eq', 'value': 'room-2'}],
'priority': 50,
'order': 1,
},
{
'id': 'first-high',
'event_pattern': 'platform.member.joined',
'target_type': 'agent',
'target_uuid': 'agent-first',
'filters': [{'field': 'room.id', 'operator': 'eq', 'value': 'room-1'}],
'priority': 10,
'order': 2,
},
{
'id': 'second-high',
'event_pattern': 'platform.member.*',
'target_type': 'agent',
'target_uuid': 'agent-second',
'filters': [{'field': 'room.id', 'operator': 'eq', 'value': 'room-1'}],
'priority': 10,
'order': 3,
},
{
'id': 'fallback',
'event_pattern': '*',
'target_type': 'discard',
'priority': 1,
'order': 4,
},
]
)
selected = bot._resolve_eba_event_binding(
{'room': {'id': 'room-1'}},
'platform.member.joined',
)
assert selected['id'] == 'first-high'
assert selected['target_uuid'] == 'agent-first'
def test_agent_product_to_binding_projects_runner_config_and_policies(self):
"""Agent products become bot-scoped runner bindings for EBA dispatch."""
from langbot.pkg.platform.botmgr import RuntimeBot
binding = RuntimeBot._agent_product_to_binding(
{
'uuid': 'agent-1',
'component_ref': 'plugin:test/fallback/default',
'config': {
'runner': {'id': 'plugin:test/runner/default'},
'runner_config': {
'plugin:test/runner/default': {
'temperature': 0.2,
'max_tokens': 1000,
}
},
},
},
{'id': 'binding-1'},
'platform.member.joined',
'bot-1',
)
assert binding is not None
assert binding.binding_id == 'bot:bot-1:binding-1'
assert binding.scope.scope_type == 'bot'
assert binding.scope.scope_id == 'bot-1'
assert binding.event_types == ['platform.member.joined']
assert binding.runner_id == 'plugin:test/runner/default'
assert binding.runner_config == {'temperature': 0.2, 'max_tokens': 1000}
assert binding.delivery_policy.enable_streaming is False
assert binding.delivery_policy.enable_reply is True
assert binding.state_policy.state_scopes == ['conversation', 'actor', 'subject', 'runner']
assert binding.agent_id == 'agent-1'
@@ -10,11 +10,17 @@ type and graceful error handling.
from __future__ import annotations from __future__ import annotations
import base64 import base64
import asyncio
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock from unittest.mock import AsyncMock, Mock
import pytest import pytest
from langbot.pkg.platform.sources.websocket_adapter import WebSocketAdapter import langbot_plugin.api.entities.builtin.platform.events as platform_events
import langbot_plugin.api.entities.builtin.platform.entities as platform_entities
import langbot_plugin.api.entities.builtin.platform.message as platform_message
from langbot.pkg.platform.botmgr import RuntimeBot
from langbot.pkg.platform.sources.websocket_adapter import WebSocketAdapter, WebSocketSession
def _make_adapter(load_return=b'hello', load_side_effect=None): def _make_adapter(load_return=b'hello', load_side_effect=None):
@@ -90,3 +96,64 @@ async def test_load_failure_is_logged_not_raised():
await adapter._process_image_components(chain) await adapter._process_image_components(chain)
assert 'base64' not in chain[0] assert 'base64' not in chain[0]
adapter.logger.error.assert_awaited_once() adapter.logger.error.assert_awaited_once()
@pytest.mark.asyncio
async def test_handle_websocket_message_marks_event_with_pipeline_uuid():
adapter, _ = _make_adapter()
adapter.websocket_person_session = WebSocketSession(id='websocketperson')
adapter.listeners = {}
adapter.listeners[platform_events.FriendMessage] = AsyncMock()
adapter.ap.platform_mgr.websocket_proxy_bot.bot_entity.use_pipeline_uuid = ''
connection = SimpleNamespace(
pipeline_uuid='pipeline-123',
session_type='person',
connection_id='conn-1',
)
await adapter.handle_websocket_message(
connection,
{'message': [{'type': 'Plain', 'text': 'hello'}], 'stream': True},
)
await asyncio.sleep(0)
event = adapter.listeners[platform_events.FriendMessage].await_args.args[0]
assert getattr(event, '_langbot_pipeline_uuid') == 'pipeline-123'
@pytest.mark.asyncio
async def test_runtime_bot_websocket_listener_uses_event_pipeline_uuid():
app = Mock()
app.msg_aggregator.add_message = AsyncMock()
app.webhook_pusher = None
logger = Mock()
logger.info = AsyncMock()
logger.warning = AsyncMock()
logger.error = AsyncMock()
bot_entity = Mock()
bot_entity.uuid = 'websocket-proxy-bot'
bot_entity.enable = True
bot_entity.use_pipeline_uuid = ''
adapter = WebSocketAdapter.model_construct(
ap=app,
logger=Mock(error=AsyncMock()),
listeners={},
websocket_person_session=WebSocketSession(id='websocketperson'),
websocket_group_session=WebSocketSession(id='websocketgroup'),
)
bot = RuntimeBot(ap=app, bot_entity=bot_entity, adapter=adapter, logger=logger)
await bot.initialize()
event = platform_events.FriendMessage(
sender=platform_entities.Friend(id='sender-1', nickname='User', remark='User'),
message_chain=platform_message.MessageChain([platform_message.Plain(text='hello')]),
time=1,
)
object.__setattr__(event, '_langbot_pipeline_uuid', 'pipeline-123')
await adapter.listeners[platform_events.FriendMessage](event, adapter)
app.msg_aggregator.add_message.assert_awaited_once()
assert app.msg_aggregator.add_message.await_args.kwargs['pipeline_uuid'] == 'pipeline-123'
@@ -615,6 +615,94 @@ class TestAgentRunProxyActions:
assert response.data['usage'] == usage assert response.data['usage'] == usage
assert model_requester.LLM_USAGE_QUERY_VARIABLE not in query.variables assert model_requester.LLM_USAGE_QUERY_VARIABLE not in query.variables
@pytest.mark.asyncio
async def test_count_tokens_validates_run_authorization_and_calls_provider(self, app):
"""COUNT_TOKENS is run-scoped and forwards messages/tools to the model requester."""
from langbot.pkg.agent.runner.session_registry import get_session_registry
run_id = 'run_proxy_count_tokens'
query = self.query()
app.query_pool.cached_queries[906] = query
registry = get_session_registry()
await registry.unregister(run_id)
await registry.register(
run_id=run_id,
runner_id='plugin:test/runner/default',
query_id=906,
plugin_identity='test/runner',
resources=make_agent_resources(
models=[{'model_id': 'llm_count_001', 'operations': ['count_tokens']}],
),
)
requester = SimpleNamespace(count_tokens=AsyncMock(return_value=37))
model = SimpleNamespace(
model_entity=SimpleNamespace(abilities=[], extra_args={'temperature': 0.2}),
provider=SimpleNamespace(requester=requester),
)
app.model_mgr.get_model_by_uuid.return_value = model
runtime_handler = make_handler(app)
try:
response = await runtime_handler.actions[PluginToRuntimeAction.COUNT_TOKENS.value]({
'run_id': run_id,
'caller_plugin_identity': 'test/runner',
'llm_model_uuid': 'llm_count_001',
'messages': [{'role': 'user', 'content': 'hello'}],
'funcs': [{
'name': 'search',
'human_desc': 'Search',
'description': 'Search',
'parameters': {'type': 'object'},
}],
'extra_args': {'temperature': 0.7},
})
finally:
await registry.unregister(run_id)
assert response.code == 0
assert response.data == {'tokens': 37}
requester.count_tokens.assert_awaited_once()
kwargs = requester.count_tokens.await_args.kwargs
assert kwargs['model'] is model
assert kwargs['messages'][0].content == 'hello'
assert [tool.name for tool in kwargs['funcs']] == ['search']
assert kwargs['extra_args'] == {'temperature': 0.7}
@pytest.mark.asyncio
async def test_count_tokens_rejects_model_without_operation(self, app):
"""COUNT_TOKENS requires the explicit model operation in the run snapshot."""
from langbot.pkg.agent.runner.session_registry import get_session_registry
run_id = 'run_proxy_count_tokens_denied'
registry = get_session_registry()
await registry.unregister(run_id)
await registry.register(
run_id=run_id,
runner_id='plugin:test/runner/default',
query_id=None,
plugin_identity='test/runner',
resources=make_agent_resources(
models=[{'model_id': 'llm_count_002', 'operations': ['invoke']}],
),
)
runtime_handler = make_handler(app)
try:
response = await runtime_handler.actions[PluginToRuntimeAction.COUNT_TOKENS.value]({
'run_id': run_id,
'caller_plugin_identity': 'test/runner',
'llm_model_uuid': 'llm_count_002',
'messages': [{'role': 'user', 'content': 'hello'}],
})
finally:
await registry.unregister(run_id)
assert response.code != 0
assert 'operation count_tokens' in response.message
app.model_mgr.get_model_by_uuid.assert_not_awaited()
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_invoke_llm_stream_restores_query_and_options(self, app): async def test_invoke_llm_stream_restores_query_and_options(self, app):
"""INVOKE_LLM_STREAM applies the same host context as non-streaming calls.""" """INVOKE_LLM_STREAM applies the same host context as non-streaming calls."""
@@ -1,11 +1,17 @@
"""Unit tests for provider_specific_fields round-trip in LiteLLMRequester. """Unit tests for LiteLLMRequester message/tool conversion.
This tests the fix for GitHub issue #1899: Gemini requires thought_signature This includes provider_specific_fields round-trip coverage for GitHub issue
to be preserved across tool call rounds for function calls to work correctly. #1899 and token counting preflight behavior for AgentRunner context budgeting.
""" """
import langbot_plugin.api.entities.builtin.provider.message as provider_message from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock, patch
import pytest
import langbot_plugin.api.entities.builtin.provider.message as provider_message
import langbot_plugin.api.entities.builtin.resource.tool as resource_tool
from langbot.pkg.provider.modelmgr import requester as model_requester
from langbot.pkg.provider.modelmgr.requesters.litellmchat import LiteLLMRequester from langbot.pkg.provider.modelmgr.requesters.litellmchat import LiteLLMRequester
@@ -14,6 +20,84 @@ def _make_requester() -> LiteLLMRequester:
return LiteLLMRequester.__new__(LiteLLMRequester) return LiteLLMRequester.__new__(LiteLLMRequester)
def _make_configured_requester() -> LiteLLMRequester:
req = LiteLLMRequester.__new__(LiteLLMRequester)
req.requester_cfg = {
'base_url': '',
'timeout': 120,
'custom_llm_provider': 'openai',
'drop_params': False,
'num_retries': 0,
'api_version': '',
}
req.ap = SimpleNamespace(
tool_mgr=SimpleNamespace(
generate_tools_for_openai=AsyncMock(
return_value=[
{
'type': 'function',
'function': {
'name': 'search',
'description': 'Search',
'parameters': {'type': 'object'},
},
}
]
)
)
)
return req
def _make_runtime_model() -> model_requester.RuntimeLLMModel:
provider = SimpleNamespace(token_mgr=SimpleNamespace(get_token=Mock(return_value='sk-test')))
return SimpleNamespace(
model_entity=SimpleNamespace(
name='gpt-4.1',
extra_args={'temperature': 0.2},
),
provider=provider,
)
@pytest.mark.asyncio
async def test_count_tokens_uses_litellm_counter_with_request_messages_and_tools():
"""Token preflight uses the same LiteLLM request shape as chat completion."""
req = _make_configured_requester()
model = _make_runtime_model()
tool = resource_tool.LLMTool(
name='search',
human_desc='Search',
description='Search',
parameters={'type': 'object'},
func=lambda **kwargs: None,
)
with patch('langbot.pkg.provider.modelmgr.requesters.litellmchat.litellm.token_counter', return_value=42) as counter:
tokens = await req.count_tokens(
model=model,
messages=[
provider_message.Message(
role='user',
content=[
provider_message.ContentElement(type='text', text='hello'),
provider_message.ContentElement(type='file_url', file_url='https://example.test/a.pdf'),
],
)
],
funcs=[tool],
extra_args={'presence_penalty': 0.1},
)
assert tokens == 42
counter.assert_called_once()
kwargs = counter.call_args.kwargs
assert kwargs['model'] == 'openai/gpt-4.1'
assert kwargs['messages'] == [{'role': 'user', 'content': [{'type': 'text', 'text': 'hello'}]}]
assert kwargs['tools'][0]['function']['name'] == 'search'
assert kwargs['tool_choice'] == 'auto'
def test_convert_messages_preserves_tool_call_provider_specific_fields(): def test_convert_messages_preserves_tool_call_provider_specific_fields():
"""Tool calls should retain provider_specific_fields through _convert_messages.""" """Tool calls should retain provider_specific_fields through _convert_messages."""
req = _make_requester() req = _make_requester()
+2
View File
@@ -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",
+1414
View File
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,
@@ -16,8 +13,7 @@ import { httpClient } from '@/app/infra/http/HttpClient';
import { systemInfo } from '@/app/infra/http'; 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, ChevronDown, ChevronRight } 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<
@@ -170,11 +138,35 @@ export default function BotForm({
const currentAdapter = form.watch('adapter'); const currentAdapter = form.watch('adapter');
const currentAdapterConfig = form.watch('adapter_config'); const currentAdapterConfig = form.watch('adapter_config');
// Group adapters by category for the Select dropdown // Group adapters by category for the Select dropdown. Legacy adapters
const groupedAdapters = useMemo( // (those superseded by an EBA implementation) are split out and shown in a
() => groupByCategory(adapterNameList), // collapsed group at the bottom so they're de-emphasized but still usable.
const activeAdapters = useMemo(
() => adapterNameList.filter((a) => !a.legacy),
[adapterNameList], [adapterNameList],
); );
const legacyAdapters = useMemo(
() => adapterNameList.filter((a) => a.legacy),
[adapterNameList],
);
const groupedAdapters = useMemo(
() => groupByCategory(activeAdapters),
[activeAdapters],
);
// Whether the collapsed legacy adapter group is expanded in the Select.
const [showLegacyAdapters, setShowLegacyAdapters] = useState(false);
// Auto-expand the legacy group when the selected adapter is itself legacy,
// so editing an existing bot on a legacy adapter still reveals the choice.
useEffect(() => {
if (
currentAdapter &&
legacyAdapters.some((a) => a.value === currentAdapter)
) {
setShowLegacyAdapters(true);
}
}, [currentAdapter, legacyAdapters]);
// Notify parent when dirty state changes // Notify parent when dirty state changes
const { isDirty } = form.formState; const { isDirty } = form.formState;
@@ -200,8 +192,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 +221,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);
@@ -252,6 +231,7 @@ export default function BotForm({
label: extractI18nObject(item.label), label: extractI18nObject(item.label),
value: item.name, value: item.name,
categories: item.spec.categories, categories: item.spec.categories,
legacy: item.spec.legacy,
}; };
}), }),
); );
@@ -331,8 +311,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 +355,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 +444,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>
@@ -635,6 +539,62 @@ export default function BotForm({
))} ))}
</SelectGroup> </SelectGroup>
))} ))}
{legacyAdapters.length > 0 && (
<>
<div
role="button"
tabIndex={0}
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
setShowLegacyAdapters((v) => !v);
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
setShowLegacyAdapters((v) => !v);
}
}}
className="flex cursor-pointer items-center gap-1 px-2 py-1.5 text-xs font-medium text-muted-foreground hover:text-foreground border-t mt-1 pt-2"
>
{showLegacyAdapters ? (
<ChevronDown className="h-3.5 w-3.5" />
) : (
<ChevronRight className="h-3.5 w-3.5" />
)}
{t('bots.legacyAdapters')}
<span className="ml-1 rounded bg-muted px-1.5 py-0.5 text-[10px]">
{legacyAdapters.length}
</span>
</div>
{showLegacyAdapters && (
<>
<p className="px-2 pb-1 text-[11px] leading-snug text-muted-foreground">
{t('bots.legacyAdaptersHint')}
</p>
<SelectGroup>
{legacyAdapters.map((item) => (
<SelectItem
key={`legacy:${item.value}`}
value={item.value}
>
<div className="flex items-center gap-2 opacity-70">
<img
src={httpClient.getAdapterIconURL(
item.value,
)}
alt=""
className="h-5 w-5 rounded grayscale"
/>
<span>{item.label}</span>
</div>
</SelectItem>
))}
</SelectGroup>
</>
)}
</>
)}
</SelectContent> </SelectContent>
</Select> </Select>
{currentAdapter && {currentAdapter &&
@@ -2,6 +2,7 @@ export interface IChooseAdapterEntity {
label: string; label: string;
value: string; value: string;
categories?: string[]; categories?: string[];
legacy?: boolean;
} }
export interface IPipelineEntity { export interface IPipelineEntity {
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;
@@ -32,6 +32,9 @@ import {
Server, Server,
Puzzle, Puzzle,
RefreshCcw, RefreshCcw,
Bot,
Workflow,
ListTree,
} from 'lucide-react'; } from 'lucide-react';
import { useTheme } from '@/components/providers/theme-provider'; import { useTheme } from '@/components/providers/theme-provider';
@@ -507,6 +510,7 @@ function NavItems({
const isSkill = categoryId === 'skills'; const isSkill = categoryId === 'skills';
const isBot = categoryId === 'bots'; const isBot = categoryId === 'bots';
const isMCP = categoryId === 'mcp'; const isMCP = categoryId === 'mcp';
const isAgents = categoryId === 'pipelines';
const resolveItemRoute = (item: SidebarEntityItem): string => { const resolveItemRoute = (item: SidebarEntityItem): string => {
if (item.extensionType === 'mcp') { if (item.extensionType === 'mcp') {
@@ -589,6 +593,18 @@ function NavItems({
!inPopover && !inPopover &&
sidebarData.extensionsGroupByType; sidebarData.extensionsGroupByType;
const showAgentGroupHeaders =
isAgents && !inPopover && sidebarData.agentsGroupByKind;
const agentGroupOrder: Array<'agent' | 'pipeline'> = [
'agent',
'pipeline',
];
const agentGroupLabelKey: Record<'agent' | 'pipeline', string> = {
agent: 'agents.kindBadgeAgent',
pipeline: 'agents.kindBadgePipeline',
};
const groupOrder: Array<'plugin' | 'mcp' | 'skill'> = [ const groupOrder: Array<'plugin' | 'mcp' | 'skill'> = [
'plugin', 'plugin',
'mcp', 'mcp',
@@ -723,6 +739,22 @@ function NavItems({
/> />
) : null} ) : null}
<span className="truncate">{item.name}</span> <span className="truncate">{item.name}</span>
{item.kind && (
<span
className="ml-auto flex shrink-0 items-center text-muted-foreground"
title={
item.kind === 'pipeline'
? t('agents.kindBadgePipeline')
: t('agents.kindBadgeAgent')
}
>
{item.kind === 'pipeline' ? (
<Workflow className="size-3.5" />
) : (
<Bot className="size-3.5" />
)}
</span>
)}
{item.debug && ( {item.debug && (
<Bug className="size-3.5 shrink-0 text-orange-400" /> <Bug className="size-3.5 shrink-0 text-orange-400" />
)} )}
@@ -774,7 +806,25 @@ function NavItems({
</div> </div>
); );
}) })
: visibleItems.map((item) => renderItem(item))} : showAgentGroupHeaders
? agentGroupOrder.map((kind) => {
const groupItems = visibleItems.filter(
(it) => (it.kind ?? 'agent') === kind,
);
if (groupItems.length === 0) return null;
return (
<div
key={kind}
className="flex flex-col gap-0.5 mt-0.5"
>
<div className="px-2 pt-1 pb-0.5 text-[0.65rem] font-semibold uppercase tracking-wide text-muted-foreground">
{t(agentGroupLabelKey[kind])}
</div>
{groupItems.map((item) => renderItem(item))}
</div>
);
})
: visibleItems.map((item) => renderItem(item))}
{/* Show more / less toggle when items exceed limit */} {/* Show more / less toggle when items exceed limit */}
{sortedItems.length > maxItems && !inPopover && ( {sortedItems.length > maxItems && !inPopover && (
<SidebarMenuSubItem> <SidebarMenuSubItem>
@@ -1011,22 +1061,69 @@ function NavItems({
<span className="cursor-pointer select-none"> <span className="cursor-pointer select-none">
{config.name} {config.name}
</span> </span>
<div className="ml-auto flex items-center gap-0.5 -mr-1"> {/* Group/refresh controls — left-aligned, hugging the title */}
{isExtensionsCategory && ( {(isAgents || isExtensionsCategory) && (
<button <div className="flex items-center gap-0.5">
type="button" {isAgents && (
title={t('common.refresh', '刷新')} <button
className="p-1 rounded-sm text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground [@media(hover:hover)]:opacity-0 group-hover/category-header:opacity-100 transition-all" type="button"
onClick={handleRefreshExtensions} title={t('agents.groupByKind')}
>
<RefreshCcw
className={cn( className={cn(
'size-3.5', 'flex items-center gap-1 px-1.5 py-1 rounded-sm text-[10px] transition-all',
extRefreshing && 'animate-spin', sidebarData.agentsGroupByKind
? 'text-sidebar-accent-foreground bg-sidebar-accent'
: 'text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground',
)} )}
/> onClick={(e) => {
</button> e.stopPropagation();
)} sidebarData.setAgentsGroupByKind(
!sidebarData.agentsGroupByKind,
);
}}
>
<ListTree className="size-3.5" />
<span>{t('agents.groupByKindShort')}</span>
</button>
)}
{isExtensionsCategory && (
<button
type="button"
title={t('plugins.groupByType')}
className={cn(
'flex items-center gap-1 px-1.5 py-1 rounded-sm text-[10px] transition-all',
sidebarData.extensionsGroupByType
? 'text-sidebar-accent-foreground bg-sidebar-accent'
: 'text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground',
)}
onClick={(e) => {
e.stopPropagation();
sidebarData.setExtensionsGroupByType(
!sidebarData.extensionsGroupByType,
);
}}
>
<ListTree className="size-3.5" />
<span>{t('plugins.groupByTypeShort')}</span>
</button>
)}
{isExtensionsCategory && (
<button
type="button"
title={t('common.refresh', '刷新')}
className="p-1 rounded-sm text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground [@media(hover:hover)]:opacity-0 group-hover/category-header:opacity-100 transition-all"
onClick={handleRefreshExtensions}
>
<RefreshCcw
className={cn(
'size-3.5',
extRefreshing && 'animate-spin',
)}
/>
</button>
)}
</div>
)}
<div className="ml-auto flex items-center gap-0.5 -mr-1">
{canCreate && {canCreate &&
(isPlugin ? ( (isPlugin ? (
<DropdownMenu> <DropdownMenu>
@@ -28,6 +28,8 @@ export interface SidebarEntityItem {
debug?: boolean; debug?: boolean;
// Set when this item appears in the unified extensions list // Set when this item appears in the unified extensions list
extensionType?: 'plugin' | 'mcp' | 'skill'; extensionType?: 'plugin' | 'mcp' | 'skill';
// Agent-specific: distinguishes Agent orchestration from legacy Pipeline
kind?: 'agent' | 'pipeline';
} }
// Plugin page registered by a plugin // Plugin page registered by a plugin
@@ -64,6 +66,9 @@ export interface SidebarDataContextValue {
// Whether the extensions list is grouped by type (shared between page and sidebar) // Whether the extensions list is grouped by type (shared between page and sidebar)
extensionsGroupByType: boolean; extensionsGroupByType: boolean;
setExtensionsGroupByType: (enabled: boolean) => void; setExtensionsGroupByType: (enabled: boolean) => void;
// Whether the Agent list is grouped by kind (Agent vs Pipeline)
agentsGroupByKind: boolean;
setAgentsGroupByKind: (enabled: boolean) => void;
} }
const SidebarDataContext = createContext<SidebarDataContextValue | null>(null); const SidebarDataContext = createContext<SidebarDataContextValue | null>(null);
@@ -95,6 +100,21 @@ export function SidebarDataProvider({
} }
}, []); }, []);
const [agentsGroupByKind, setAgentsGroupByKindState] = useState<boolean>(
() => {
if (typeof window === 'undefined') return false;
return localStorage.getItem('agents_group_by_kind') === 'true';
},
);
const setAgentsGroupByKind = useCallback((enabled: boolean) => {
setAgentsGroupByKindState(enabled);
try {
localStorage.setItem('agents_group_by_kind', String(enabled));
} catch {
// ignore
}
}, []);
const refreshBots = useCallback(async () => { const refreshBots = useCallback(async () => {
try { try {
const resp = await httpClient.getBots(); const resp = await httpClient.getBots();
@@ -123,6 +143,7 @@ export function SidebarDataProvider({
description: p.description, description: p.description,
emoji: p.emoji, emoji: p.emoji,
updatedAt: p.updated_at, updatedAt: p.updated_at,
kind: p.kind,
})), })),
); );
} catch (error) { } catch (error) {
@@ -308,6 +329,8 @@ export function SidebarDataProvider({
setDetailEntityName, setDetailEntityName,
extensionsGroupByType, extensionsGroupByType,
setExtensionsGroupByType, setExtensionsGroupByType,
agentsGroupByKind,
setAgentsGroupByKind,
}} }}
> >
{children} {children}
@@ -30,9 +30,13 @@ export function getOrderedCategories(): AdapterCategoryId[] {
/** /**
* Groups items that have a `categories` string array into ordered category * Groups items that have a `categories` string array into ordered category
* buckets. An item can appear in multiple groups if it belongs to multiple * buckets. Each item is placed into exactly one bucket its highest-priority
* categories. Items without any recognised category are collected into a * matching category in display order (e.g. an adapter tagged both `popular`
* trailing "uncategorized" group (null key). * and `china` lands in `popular`). This keeps item values unique, which is
* required when the result feeds a Select (duplicate values break Radix's
* item tracking and trigger React duplicate-key warnings). Items without any
* recognised category are collected into a trailing "uncategorized" group
* (null key).
*/ */
export function groupByCategory<T extends { categories?: string[] }>( export function groupByCategory<T extends { categories?: string[] }>(
items: T[], items: T[],
@@ -54,10 +58,13 @@ export function groupByCategory<T extends { categories?: string[] }>(
} }
let placed = false; let placed = false;
for (const cat of cats) { // Assign to the highest-priority matching category (display order) only,
if (ordered.includes(cat as AdapterCategoryId)) { // so each item appears in exactly one bucket.
buckets.get(cat as AdapterCategoryId)!.push(item); for (const cat of ordered) {
if (cats.includes(cat)) {
buckets.get(cat)!.push(item);
placed = true; placed = true;
break;
} }
} }
if (!placed) { if (!placed) {
+1
View File
@@ -205,6 +205,7 @@ export interface Adapter {
icon?: string; icon?: string;
spec: { spec: {
categories?: string[]; categories?: string[];
legacy?: boolean;
help_links?: Record<string, string>; help_links?: Record<string, string>;
supported_events?: string[]; supported_events?: string[];
supported_apis?: string[]; supported_apis?: string[];
+74 -2
View File
@@ -7,6 +7,8 @@ import {
ArrowLeft, ArrowLeft,
ArrowRight, ArrowRight,
Check, Check,
ChevronDown,
ChevronRight,
Sparkles, Sparkles,
PartyPopper, PartyPopper,
Loader2, Loader2,
@@ -765,14 +767,24 @@ function StepPlatform({
onSelect: (name: string) => void; onSelect: (name: string) => void;
}) { }) {
const { t } = useTranslation(); const { t } = useTranslation();
const [showLegacy, setShowLegacy] = useState(false);
const activeAdapters = useMemo(
() => adapters.filter((a) => !a.spec.legacy),
[adapters],
);
const legacyAdapters = useMemo(
() => adapters.filter((a) => a.spec.legacy),
[adapters],
);
const groupedAdapters = useMemo(() => { const groupedAdapters = useMemo(() => {
const withCategories = adapters.map((a) => ({ const withCategories = activeAdapters.map((a) => ({
...a, ...a,
categories: a.spec.categories, categories: a.spec.categories,
})); }));
return groupByCategory(withCategories); return groupByCategory(withCategories);
}, [adapters]); }, [activeAdapters]);
return ( return (
<div className="space-y-6 max-w-4xl mx-auto"> <div className="space-y-6 max-w-4xl mx-auto">
@@ -848,6 +860,66 @@ function StepPlatform({
</div> </div>
</div> </div>
))} ))}
{legacyAdapters.length > 0 && (
<div className="border-t pt-4 space-y-3">
<button
type="button"
className="flex items-center gap-2 text-sm text-muted-foreground hover:text-foreground"
onClick={() => setShowLegacy((v) => !v)}
>
{showLegacy ? (
<ChevronDown className="h-4 w-4" />
) : (
<ChevronRight className="h-4 w-4" />
)}
{t('bots.legacyAdapters')}
<span className="rounded bg-muted px-1.5 py-0.5 text-xs">
{legacyAdapters.length}
</span>
</button>
{showLegacy && (
<>
<p className="text-xs text-muted-foreground">
{t('bots.legacyAdaptersHint')}
</p>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 opacity-60">
{legacyAdapters.map((adapter) => (
<Card
key={adapter.name}
className={cn(
'cursor-pointer transition-all hover:shadow-md',
selected === adapter.name
? 'ring-2 ring-primary shadow-md'
: 'hover:border-primary/50',
)}
onClick={() => onSelect(adapter.name)}
>
<CardHeader className="flex flex-row items-center gap-3 pb-2">
<img
src={httpClient.getAdapterIconURL(adapter.name)}
alt=""
className="w-10 h-10 rounded-lg shrink-0 grayscale"
/>
<div className="min-w-0">
<CardTitle className="text-base truncate">
{extractI18nObject(adapter.label)}
</CardTitle>
</div>
{selected === adapter.name && (
<div className="ml-auto shrink-0">
<div className="w-5 h-5 rounded-full bg-primary flex items-center justify-center">
<Check className="w-3 h-3 text-primary-foreground" />
</div>
</div>
)}
</CardHeader>
</Card>
))}
</div>
</>
)}
</div>
)}
</div> </div>
); );
} }
+113
View File
@@ -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,
};
+45
View File
@@ -339,6 +339,9 @@ const enUS = {
deleteConfirmation: 'Are you sure you want to delete this bot?', deleteConfirmation: 'Are you sure you want to delete this bot?',
platformAdapter: 'Platform/Adapter Selection', platformAdapter: 'Platform/Adapter Selection',
selectAdapter: 'Select Adapter', selectAdapter: 'Select Adapter',
legacyAdapters: 'Legacy adapters',
legacyAdaptersHint:
'These adapters are superseded by their newer (EBA) versions. They are kept only for existing configurations and are not recommended for new bots.',
adapterConfig: 'Adapter Configuration', adapterConfig: 'Adapter Configuration',
viewAdapterDocs: 'View Docs', viewAdapterDocs: 'View Docs',
bindPipeline: 'Bind Pipeline', bindPipeline: 'Bind Pipeline',
@@ -372,14 +375,51 @@ 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}}.*',
eventNames: {
message_received: 'Message received',
message_edited: 'Message edited',
message_deleted: 'Message deleted',
message_reaction: 'Message reaction',
feedback_received: 'Feedback received',
friend_request_received: 'Friend request received',
friend_added: 'Friend added',
group_member_joined: 'Member joined group',
group_member_left: 'Member left group',
group_member_banned: 'Member banned',
bot_invited_to_group: 'Bot invited to group',
bot_removed_from_group: 'Bot removed from group',
bot_muted: 'Bot muted',
bot_unmuted: 'Bot unmuted',
platform_specific: 'Platform-specific event',
},
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.',
@@ -477,6 +517,10 @@ const enUS = {
agentOrchestrationDescription: agentOrchestrationDescription:
'Event-first handling logic for messages, group members, friends, feedback, and other EBA events.', 'Event-first handling logic for messages, group members, friends, feedback, and other EBA events.',
pipelineType: 'Pipeline', pipelineType: 'Pipeline',
kindBadgeAgent: 'Agent',
kindBadgePipeline: 'Pipeline',
groupByKind: 'Group by type',
groupByKindShort: 'Group',
pipelineTypeDescription: pipelineTypeDescription:
'Keep the existing no-code message pipeline for backward compatibility. It only handles message events.', 'Keep the existing no-code message pipeline for backward compatibility. It only handles message events.',
allEvents: 'Supports all EBA events', allEvents: 'Supports all EBA events',
@@ -539,6 +583,7 @@ const enUS = {
noExtensionInstalled: 'No extensions installed', noExtensionInstalled: 'No extensions installed',
loadingExtensions: 'Loading extensions...', loadingExtensions: 'Loading extensions...',
groupByType: 'Group by format', groupByType: 'Group by format',
groupByTypeShort: 'Group',
pluginConfig: 'Plugin Configuration', pluginConfig: 'Plugin Configuration',
pluginSort: 'Plugin Sort', pluginSort: 'Plugin Sort',
pluginSortDescription: pluginSortDescription:
+51
View File
@@ -457,6 +457,56 @@ const esES = {
botMessage: 'Asistente', botMessage: 'Asistente',
}, },
}, },
agents: {
title: 'Agent',
description:
'Gestiona orquestaciones de Agent y Pipelines, y vincúlalos a eventos de bot',
create: 'Crear Agent',
editAgent: 'Editar orquestación de Agent',
selectFromSidebar: 'Selecciona un Agent o Pipeline desde la barra lateral',
agentOrchestration: 'Orquestación de Agent',
agentOrchestrationDescription:
'Lógica de procesamiento orientada a eventos EBA para mensajes, miembros de grupo, amigos, retroalimentación y otros eventos.',
pipelineType: 'Pipeline',
kindBadgeAgent: 'Agent',
kindBadgePipeline: 'Pipeline',
groupByKind: 'Agrupar por tipo',
groupByKindShort: 'Agrupar',
pipelineTypeDescription:
'Mantiene el pipeline de mensajes sin código para compatibilidad con versiones anteriores. Solo procesa eventos de mensaje.',
allEvents: 'Compatible con todos los eventos EBA',
messageEventsOnly: 'Solo eventos de mensaje',
basicInfo: 'Información básica',
basicInfoDescription:
'Establece el nombre, icono, descripción y estado de habilitación',
runnerSettings: 'Runner',
eventCapability: 'Capacidad de eventos',
eventCapabilityDescription:
'Declara a qué eventos puede vincularse esta orquestación de Agent. Un patrón de evento por línea; se admiten * y namespace.*.',
supportedEvents: 'Eventos admitidos',
supportedEventsDescription:
'Ejemplos: *, message.received, group.*. Los Pipelines están fijos en message.*.',
enabled: 'Habilitar Agent',
enabledDescription:
'Cuando está deshabilitado, este Agent no debe ser seleccionado por el enrutamiento de eventos.',
nameRequired: 'El nombre no puede estar vacío',
createSuccess: 'Creado correctamente',
createError: 'Error al crear: ',
loadError: 'Error al cargar: ',
saveSuccess: 'Guardado correctamente',
saveError: 'Error al guardar: ',
deleteSuccess: 'Eliminado correctamente',
deleteError: 'Error al eliminar: ',
deleteConfirmation:
'¿Estás seguro de que deseas eliminar esta orquestación de Agent?',
dangerZone: 'Zona de peligro',
dangerZoneDescription: 'Acciones irreversibles y destructivas',
deleteAgentAction: 'Eliminar esta orquestación de Agent',
deleteAgentHint:
'Una vez eliminado, los eventos vinculados a él ya no podrán ejecutarse.',
noRunnerMetadata:
'No hay metadatos de AgentRunner disponibles actualmente.',
},
plugins: { plugins: {
title: 'Extensiones', title: 'Extensiones',
description: description:
@@ -486,6 +536,7 @@ const esES = {
noExtensionInstalled: 'No hay extensiones instaladas', noExtensionInstalled: 'No hay extensiones instaladas',
loadingExtensions: 'Cargando extensiones...', loadingExtensions: 'Cargando extensiones...',
groupByType: 'Agrupar por formato', groupByType: 'Agrupar por formato',
groupByTypeShort: 'Agrupar',
pluginConfig: 'Configuración del plugin', pluginConfig: 'Configuración del plugin',
pluginSort: 'Orden de plugins', pluginSort: 'Orden de plugins',
pluginSortDescription: pluginSortDescription:
+22
View File
@@ -387,6 +387,23 @@ const jaJP = {
eventCustom: 'カスタムイベント', eventCustom: 'カスタムイベント',
eventWildcard: 'すべてのイベント', eventWildcard: 'すべてのイベント',
eventNamespaceWildcard: '{{namespace}}.*', eventNamespaceWildcard: '{{namespace}}.*',
eventNames: {
message_received: 'メッセージ受信',
message_edited: 'メッセージ編集',
message_deleted: 'メッセージ削除',
message_reaction: 'メッセージリアクション',
feedback_received: 'フィードバック受信',
friend_request_received: '友達リクエスト受信',
friend_added: '友達追加',
group_member_joined: 'メンバー参加',
group_member_left: 'メンバー退出',
group_member_banned: 'メンバーBAN',
bot_invited_to_group: 'ボットがグループに招待された',
bot_removed_from_group: 'ボットがグループから削除された',
bot_muted: 'ボットがミュートされた',
bot_unmuted: 'ボットのミュート解除',
platform_specific: 'プラットフォーム固有イベント',
},
routingRules: '条件付きルーティングルール', routingRules: '条件付きルーティングルール',
routingRulesDescription: routingRulesDescription:
'ルールは順番に評価され、最初に一致したルールのパイプラインにルーティングされます。一致しない場合はデフォルトパイプラインが使用されます。', 'ルールは順番に評価され、最初に一致したルールのパイプラインにルーティングされます。一致しない場合はデフォルトパイプラインが使用されます。',
@@ -484,6 +501,10 @@ const jaJP = {
agentOrchestrationDescription: agentOrchestrationDescription:
'メッセージ、グループメンバー、友だち、フィードバックなどの EBA イベント向けの処理ロジックです。', 'メッセージ、グループメンバー、友だち、フィードバックなどの EBA イベント向けの処理ロジックです。',
pipelineType: 'Pipeline', pipelineType: 'Pipeline',
kindBadgeAgent: 'Agent',
kindBadgePipeline: 'パイプライン',
groupByKind: 'タイプ別にグループ化',
groupByKindShort: 'グループ',
pipelineTypeDescription: pipelineTypeDescription:
'既存のノーコードメッセージ Pipeline を互換性のため保持します。メッセージイベントのみ処理できます。', '既存のノーコードメッセージ Pipeline を互換性のため保持します。メッセージイベントのみ処理できます。',
allEvents: 'すべての EBA イベントに対応', allEvents: 'すべての EBA イベントに対応',
@@ -544,6 +565,7 @@ const jaJP = {
noExtensionInstalled: '拡張機能がインストールされていません', noExtensionInstalled: '拡張機能がインストールされていません',
loadingExtensions: '拡張機能を読み込み中...', loadingExtensions: '拡張機能を読み込み中...',
groupByType: '形式でグループ化', groupByType: '形式でグループ化',
groupByTypeShort: 'グループ',
pluginConfig: 'プラグイン設定', pluginConfig: 'プラグイン設定',
pluginSort: 'プラグインの並び替え', pluginSort: 'プラグインの並び替え',
pluginSortDescription: pluginSortDescription:
+48
View File
@@ -455,6 +455,53 @@ const ruRU = {
botMessage: 'Ассистент', botMessage: 'Ассистент',
}, },
}, },
agents: {
title: 'Agent',
description:
'Управляйте оркестровками Agent и Pipeline, привязывая их к событиям бота',
create: 'Создать Agent',
editAgent: 'Редактировать оркестровку Agent',
selectFromSidebar: 'Выберите Agent или Pipeline на боковой панели',
agentOrchestration: 'Оркестровка Agent',
agentOrchestrationDescription:
'Логика обработки событий EBA для сообщений, участников групп, друзей, обратной связи и других событий.',
pipelineType: 'Pipeline',
kindBadgeAgent: 'Agent',
kindBadgePipeline: 'Pipeline',
groupByKind: 'Группировать по типу',
groupByKindShort: 'Группа',
pipelineTypeDescription:
'Сохраняет существующий no-code конвейер сообщений для обратной совместимости. Обрабатывает только события сообщений.',
allEvents: 'Поддерживает все события EBA',
messageEventsOnly: 'Только события сообщений',
basicInfo: 'Основная информация',
basicInfoDescription: 'Задайте имя, иконку, описание и статус активации',
runnerSettings: 'Runner',
eventCapability: 'Возможности событий',
eventCapabilityDescription:
'Объявите, к каким событиям может быть привязана эта оркестровка Agent. Один шаблон события в строке; поддерживаются * и namespace.*.',
supportedEvents: 'Поддерживаемые события',
supportedEventsDescription:
'Примеры: *, message.received, group.*. Pipeline фиксирован на message.*.',
enabled: 'Включить Agent',
enabledDescription:
'При отключении этот Agent не должен выбираться маршрутизацией событий.',
nameRequired: 'Имя не может быть пустым',
createSuccess: 'Успешно создано',
createError: 'Ошибка создания: ',
loadError: 'Ошибка загрузки: ',
saveSuccess: 'Успешно сохранено',
saveError: 'Ошибка сохранения: ',
deleteSuccess: 'Успешно удалено',
deleteError: 'Ошибка удаления: ',
deleteConfirmation: 'Вы уверены, что хотите удалить эту оркестровку Agent?',
dangerZone: 'Опасная зона',
dangerZoneDescription: 'Необратимые и деструктивные действия',
deleteAgentAction: 'Удалить эту оркестровку Agent',
deleteAgentHint:
'После удаления события, привязанные к ней, больше не смогут выполняться.',
noRunnerMetadata: 'Метаданные AgentRunner в данный момент недоступны.',
},
plugins: { plugins: {
title: 'Расширения', title: 'Расширения',
description: description:
@@ -485,6 +532,7 @@ const ruRU = {
noExtensionInstalled: 'Расширения не установлены', noExtensionInstalled: 'Расширения не установлены',
loadingExtensions: 'Загрузка расширений...', loadingExtensions: 'Загрузка расширений...',
groupByType: 'Группировать по формату', groupByType: 'Группировать по формату',
groupByTypeShort: 'Группа',
pluginConfig: 'Настройка плагина', pluginConfig: 'Настройка плагина',
pluginSort: 'Порядок плагинов', pluginSort: 'Порядок плагинов',
pluginSortDescription: pluginSortDescription:
+48
View File
@@ -441,6 +441,53 @@ const thTH = {
botMessage: 'ผู้ช่วย', botMessage: 'ผู้ช่วย',
}, },
}, },
agents: {
title: 'Agent',
description:
'จัดการการประสาน Agent และ Pipeline แล้วเชื่อมกับเหตุการณ์ของบอท',
create: 'สร้าง Agent',
editAgent: 'แก้ไขการประสาน Agent',
selectFromSidebar: 'เลือก Agent หรือ Pipeline จากแถบด้านข้าง',
agentOrchestration: 'การประสาน Agent',
agentOrchestrationDescription:
'ตรรกะการประมวลผลที่เน้นเหตุการณ์ EBA สำหรับข้อความ สมาชิกกลุ่ม เพื่อน ฟีดแบ็ก และเหตุการณ์อื่นๆ',
pipelineType: 'Pipeline',
kindBadgeAgent: 'Agent',
kindBadgePipeline: 'Pipeline',
groupByKind: 'จัดกลุ่มตามประเภท',
groupByKindShort: 'จัดกลุ่ม',
pipelineTypeDescription:
'คงไว้ซึ่ง pipeline ข้อความแบบไม่ต้องเขียนโค้ดเพื่อความเข้ากันได้ย้อนหลัง รองรับเฉพาะเหตุการณ์ข้อความ',
allEvents: 'รองรับทุกเหตุการณ์ EBA',
messageEventsOnly: 'เฉพาะเหตุการณ์ข้อความ',
basicInfo: 'ข้อมูลพื้นฐาน',
basicInfoDescription: 'ตั้งชื่อ ไอคอน คำอธิบาย และสถานะการเปิดใช้งาน',
runnerSettings: 'Runner',
eventCapability: 'ความสามารถด้านเหตุการณ์',
eventCapabilityDescription:
'ประกาศว่าการประสาน Agent นี้สามารถเชื่อมกับเหตุการณ์ใดได้บ้าง หนึ่งรูปแบบเหตุการณ์ต่อบรรทัด รองรับ * และ namespace.*',
supportedEvents: 'เหตุการณ์ที่รองรับ',
supportedEventsDescription:
'ตัวอย่าง: *, message.received, group.* Pipeline ถูกกำหนดไว้ที่ message.*',
enabled: 'เปิดใช้งาน Agent',
enabledDescription:
'เมื่อปิดใช้งาน Agent นี้จะไม่ถูกเลือกโดยการกำหนดเส้นทางเหตุการณ์',
nameRequired: 'ชื่อต้องไม่ว่างเปล่า',
createSuccess: 'สร้างสำเร็จ',
createError: 'สร้างล้มเหลว: ',
loadError: 'โหลดล้มเหลว: ',
saveSuccess: 'บันทึกสำเร็จ',
saveError: 'บันทึกล้มเหลว: ',
deleteSuccess: 'ลบสำเร็จ',
deleteError: 'ลบล้มเหลว: ',
deleteConfirmation: 'คุณแน่ใจหรือว่าต้องการลบการประสาน Agent นี้?',
dangerZone: 'โซนอันตราย',
dangerZoneDescription: 'การดำเนินการที่ไม่สามารถย้อนกลับและทำลายข้อมูล',
deleteAgentAction: 'ลบการประสาน Agent นี้',
deleteAgentHint:
'เมื่อลบแล้ว เหตุการณ์ที่เชื่อมกับมันจะไม่สามารถดำเนินการต่อได้',
noRunnerMetadata: 'ขณะนี้ไม่มีข้อมูลเมตา AgentRunner ที่พร้อมใช้งาน',
},
plugins: { plugins: {
title: 'ส่วนขยาย', title: 'ส่วนขยาย',
description: description:
@@ -470,6 +517,7 @@ const thTH = {
noExtensionInstalled: 'ยังไม่มีส่วนขยายที่ติดตั้ง', noExtensionInstalled: 'ยังไม่มีส่วนขยายที่ติดตั้ง',
loadingExtensions: 'กำลังโหลดส่วนขยาย...', loadingExtensions: 'กำลังโหลดส่วนขยาย...',
groupByType: 'จัดกลุ่มตามรูปแบบ', groupByType: 'จัดกลุ่มตามรูปแบบ',
groupByTypeShort: 'จัดกลุ่ม',
pluginConfig: 'การกำหนดค่าปลั๊กอิน', pluginConfig: 'การกำหนดค่าปลั๊กอิน',
pluginSort: 'เรียงลำดับปลั๊กอิน', pluginSort: 'เรียงลำดับปลั๊กอิน',
pluginSortDescription: pluginSortDescription:
+48
View File
@@ -451,6 +451,53 @@ const viVN = {
botMessage: 'Trợ lý', botMessage: 'Trợ lý',
}, },
}, },
agents: {
title: 'Agent',
description:
'Quản lý dàn dựng Agent và Pipeline, sau đó gắn chúng vào sự kiện của bot',
create: 'Tạo Agent',
editAgent: 'Chỉnh sửa dàn dựng Agent',
selectFromSidebar: 'Chọn một Agent hoặc Pipeline từ thanh bên',
agentOrchestration: 'Dàn dựng Agent',
agentOrchestrationDescription:
'Logic xử lý hướng sự kiện cho tin nhắn, thành viên nhóm, bạn bè, phản hồi và các sự kiện EBA khác.',
pipelineType: 'Pipeline',
kindBadgeAgent: 'Agent',
kindBadgePipeline: 'Pipeline',
groupByKind: 'Nhóm theo loại',
groupByKindShort: 'Nhóm',
pipelineTypeDescription:
'Giữ lại pipeline tin nhắn không cần mã hiện có để tương thích ngược. Chỉ xử lý sự kiện tin nhắn.',
allEvents: 'Hỗ trợ tất cả sự kiện EBA',
messageEventsOnly: 'Chỉ sự kiện tin nhắn',
basicInfo: 'Thông tin cơ bản',
basicInfoDescription: 'Đặt tên, biểu tượng, mô tả và trạng thái kích hoạt',
runnerSettings: 'Runner',
eventCapability: 'Khả năng sự kiện',
eventCapabilityDescription:
'Khai báo những sự kiện mà dàn dựng Agent này có thể được gắn vào. Mỗi dòng một mẫu sự kiện; hỗ trợ * và namespace.*.',
supportedEvents: 'Sự kiện được hỗ trợ',
supportedEventsDescription:
'Ví dụ: *, message.received, group.*. Pipeline cố định ở message.*.',
enabled: 'Kích hoạt Agent',
enabledDescription:
'Khi bị tắt, Agent này sẽ không được định tuyến sự kiện chọn.',
nameRequired: 'Tên không được để trống',
createSuccess: 'Tạo thành công',
createError: 'Tạo thất bại: ',
loadError: 'Tải thất bại: ',
saveSuccess: 'Lưu thành công',
saveError: 'Lưu thất bại: ',
deleteSuccess: 'Xóa thành công',
deleteError: 'Xóa thất bại: ',
deleteConfirmation: 'Bạn có chắc muốn xóa dàn dựng Agent này không?',
dangerZone: 'Vùng nguy hiểm',
dangerZoneDescription: 'Hành động không thể hoàn tác và mang tính phá hủy',
deleteAgentAction: 'Xóa dàn dựng Agent này',
deleteAgentHint:
'Sau khi xóa, các sự kiện đã gắn vào nó sẽ không thể thực thi được nữa.',
noRunnerMetadata: 'Hiện chưa có siêu dữ liệu AgentRunner khả dụng.',
},
plugins: { plugins: {
title: 'Tiện ích mở rộng', title: 'Tiện ích mở rộng',
description: description:
@@ -480,6 +527,7 @@ const viVN = {
noExtensionInstalled: 'Chưa cài đặt tiện ích mở rộng nào', noExtensionInstalled: 'Chưa cài đặt tiện ích mở rộng nào',
loadingExtensions: 'Đang tải tiện ích mở rộng...', loadingExtensions: 'Đang tải tiện ích mở rộng...',
groupByType: 'Nhóm theo định dạng', groupByType: 'Nhóm theo định dạng',
groupByTypeShort: 'Nhóm',
pluginConfig: 'Cấu hình Plugin', pluginConfig: 'Cấu hình Plugin',
pluginSort: 'Sắp xếp Plugin', pluginSort: 'Sắp xếp Plugin',
pluginSortDescription: pluginSortDescription:
+44
View File
@@ -324,6 +324,9 @@ const zhHans = {
deleteConfirmation: '你确定要删除这个机器人吗?', deleteConfirmation: '你确定要删除这个机器人吗?',
platformAdapter: '平台/适配器选择', platformAdapter: '平台/适配器选择',
selectAdapter: '选择适配器', selectAdapter: '选择适配器',
legacyAdapters: '旧版适配器',
legacyAdaptersHint:
'这些适配器已被新版(EBA 架构)取代,仅为兼容存量配置保留,不建议新建机器人时使用。',
adapterConfig: '适配器配置', adapterConfig: '适配器配置',
viewAdapterDocs: '查看文档', viewAdapterDocs: '查看文档',
bindPipeline: '绑定流水线', bindPipeline: '绑定流水线',
@@ -356,14 +359,50 @@ 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}}.*',
eventNames: {
message_received: '收到消息',
message_edited: '消息被编辑',
message_deleted: '消息被删除',
message_reaction: '消息表态',
feedback_received: '收到反馈',
friend_request_received: '收到好友请求',
friend_added: '好友添加成功',
group_member_joined: '成员加入群组',
group_member_left: '成员离开群组',
group_member_banned: '成员被封禁',
bot_invited_to_group: '机器人被邀入群',
bot_removed_from_group: '机器人被移出群',
bot_muted: '机器人被禁言',
bot_unmuted: '机器人被解除禁言',
platform_specific: '平台特定事件',
},
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:
'按顺序匹配,命中第一条规则后路由到对应流水线;都不匹配时使用上方默认流水线', '按顺序匹配,命中第一条规则后路由到对应流水线;都不匹配时使用上方默认流水线',
@@ -458,6 +497,10 @@ const zhHans = {
agentOrchestrationDescription: agentOrchestrationDescription:
'面向 EBA 事件的处理逻辑,可用于消息、群成员、好友、反馈等事件。', '面向 EBA 事件的处理逻辑,可用于消息、群成员、好友、反馈等事件。',
pipelineType: 'Pipeline', pipelineType: 'Pipeline',
kindBadgeAgent: 'Agent',
kindBadgePipeline: '流水线',
groupByKind: '按类型分组',
groupByKindShort: '分组',
pipelineTypeDescription: pipelineTypeDescription:
'保留现有无代码消息流水线,兼容旧配置,只能处理消息事件。', '保留现有无代码消息流水线,兼容旧配置,只能处理消息事件。',
allEvents: '支持全部 EBA 事件', allEvents: '支持全部 EBA 事件',
@@ -517,6 +560,7 @@ const zhHans = {
noExtensionInstalled: '暂未安装任何扩展', noExtensionInstalled: '暂未安装任何扩展',
loadingExtensions: '正在加载扩展...', loadingExtensions: '正在加载扩展...',
groupByType: '按格式分组', groupByType: '按格式分组',
groupByTypeShort: '分组',
pluginSort: '插件排序', pluginSort: '插件排序',
pluginSortDescription: pluginSortDescription:
'插件顺序会影响同一事件内的处理顺序,请拖动插件卡片排序', '插件顺序会影响同一事件内的处理顺序,请拖动插件卡片排序',
+45
View File
@@ -427,6 +427,50 @@ const zhHant = {
botMessage: '助手', botMessage: '助手',
}, },
}, },
agents: {
title: 'Agent',
description: '管理 Agent 編排與 Pipeline,並將它們綁定到機器人事件',
create: '建立 Agent',
editAgent: '編輯 Agent 編排',
selectFromSidebar: '從側邊欄選擇一個 Agent 或 Pipeline',
agentOrchestration: 'Agent 編排',
agentOrchestrationDescription:
'面向 EBA 事件的處理邏輯,可用於訊息、群成員、好友、回饋等事件。',
pipelineType: 'Pipeline',
kindBadgeAgent: 'Agent',
kindBadgePipeline: '流水線',
groupByKind: '依類型分組',
groupByKindShort: '分組',
pipelineTypeDescription:
'保留現有無程式碼訊息流水線,相容舊設定,只能處理訊息事件。',
allEvents: '支援全部 EBA 事件',
messageEventsOnly: '僅支援訊息事件',
basicInfo: '基本資訊',
basicInfoDescription: '設定名稱、圖示、描述和啟用狀態',
runnerSettings: '執行器',
eventCapability: '事件能力',
eventCapabilityDescription:
'宣告此 Agent 編排可被綁定到哪些事件。每行一個事件模式,支援 * 與 namespace.*。',
supportedEvents: '支援的事件',
supportedEventsDescription:
'例如 *、message.received、group.*。Pipeline 固定僅支援 message.*。',
enabled: '啟用 Agent',
enabledDescription: '停用後,此 Agent 不應被事件路由選中。',
nameRequired: '名稱不能為空',
createSuccess: '建立成功',
createError: '建立失敗:',
loadError: '載入失敗:',
saveSuccess: '儲存成功',
saveError: '儲存失敗:',
deleteSuccess: '刪除成功',
deleteError: '刪除失敗:',
deleteConfirmation: '你確定要刪除這個 Agent 編排嗎?',
dangerZone: '危險區域',
dangerZoneDescription: '不可逆的操作',
deleteAgentAction: '刪除此 Agent 編排',
deleteAgentHint: '刪除後,綁定到它的事件將無法繼續執行。',
noRunnerMetadata: '目前沒有可用的 AgentRunner 中繼資料。',
},
plugins: { plugins: {
title: '外掛擴展', title: '外掛擴展',
description: '安裝和設定用於擴展功能的外掛,請在流程線配置中選用', description: '安裝和設定用於擴展功能的外掛,請在流程線配置中選用',
@@ -457,6 +501,7 @@ const zhHant = {
noExtensionInstalled: '暫未安裝任何擴充功能', noExtensionInstalled: '暫未安裝任何擴充功能',
loadingExtensions: '正在載入擴充功能...', loadingExtensions: '正在載入擴充功能...',
groupByType: '依格式分組', groupByType: '依格式分組',
groupByTypeShort: '分組',
pluginSort: '外掛排序', pluginSort: '外掛排序',
pluginSortDescription: pluginSortDescription:
'外掛順序會影響同一事件內的處理順序,請拖曳外掛卡片排序', '外掛順序會影響同一事件內的處理順序,請拖曳外掛卡片排序',