mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-26 23:44:19 +00:00
Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3de16bbbae | |||
| d0f6fe2cec | |||
| ae49753f74 | |||
| e9a7d7e58b | |||
| 957396b6e2 | |||
| 89221b59ed | |||
| fac56e30aa | |||
| 0ad39b884f | |||
| f157174ae4 | |||
| a5acf41df1 | |||
| ce31fc81b8 | |||
| 40e7481032 | |||
| af459c1a72 | |||
| 1577567a78 | |||
| d02e1a14a3 | |||
| cd6a39d3a2 |
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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'},
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
+111
@@ -0,0 +1,111 @@
|
|||||||
|
"""migrate use_pipeline_uuid and pipeline_routing_rules into event_bindings
|
||||||
|
|
||||||
|
Revision ID: 0009_migrate_routing_to_event_bindings
|
||||||
|
Revises: 0008_agent_product_surface
|
||||||
|
Create Date: 2026-06-26
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = '0009_migrate_routing_to_event_bindings'
|
||||||
|
down_revision = '0008_agent_product_surface'
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def _rule_to_filters(rule: dict) -> list[dict] | None:
|
||||||
|
"""Convert a pipeline_routing_rule to event_binding filters (best effort).
|
||||||
|
|
||||||
|
Rules that don't map cleanly (message_content, message_has_element) are
|
||||||
|
skipped — callers should handle None as "cannot migrate".
|
||||||
|
"""
|
||||||
|
rule_type = rule.get('type')
|
||||||
|
operator = rule.get('operator', 'eq')
|
||||||
|
value = rule.get('value', '')
|
||||||
|
|
||||||
|
if rule_type == 'launcher_type':
|
||||||
|
if value == 'group':
|
||||||
|
return [{'field': 'group', 'operator': 'neq', 'value': None}]
|
||||||
|
if value == 'person':
|
||||||
|
return [{'field': 'group', 'operator': 'eq', 'value': None}]
|
||||||
|
elif rule_type == 'launcher_id':
|
||||||
|
return [{'field': 'chat_id', 'operator': operator, 'value': value}]
|
||||||
|
|
||||||
|
return None # message_content / message_has_element: no clean mapping
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
bind = op.get_bind()
|
||||||
|
rows = bind.execute(
|
||||||
|
sa.text('SELECT uuid, use_pipeline_uuid, pipeline_routing_rules, event_bindings FROM bots')
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
for bot_uuid, use_pipeline_uuid, routing_rules_raw, event_bindings_raw in rows:
|
||||||
|
try:
|
||||||
|
existing = (
|
||||||
|
json.loads(event_bindings_raw) if isinstance(event_bindings_raw, str) else (event_bindings_raw or [])
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
existing = []
|
||||||
|
|
||||||
|
if existing:
|
||||||
|
continue # already has event_bindings — skip
|
||||||
|
|
||||||
|
try:
|
||||||
|
routing_rules = (
|
||||||
|
json.loads(routing_rules_raw) if isinstance(routing_rules_raw, str) else (routing_rules_raw or [])
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
routing_rules = []
|
||||||
|
|
||||||
|
new_bindings: list[dict] = []
|
||||||
|
base_priority = len(routing_rules)
|
||||||
|
|
||||||
|
for i, rule in enumerate(routing_rules):
|
||||||
|
target_uuid = rule.get('pipeline_uuid', '')
|
||||||
|
if not target_uuid:
|
||||||
|
continue
|
||||||
|
filters = _rule_to_filters(rule)
|
||||||
|
if filters is None:
|
||||||
|
continue
|
||||||
|
new_bindings.append(
|
||||||
|
{
|
||||||
|
'id': str(uuid.uuid4()),
|
||||||
|
'event_pattern': 'message.*',
|
||||||
|
'target_type': 'pipeline',
|
||||||
|
'target_uuid': target_uuid,
|
||||||
|
'filters': filters,
|
||||||
|
'priority': base_priority - i,
|
||||||
|
'enabled': True,
|
||||||
|
'description': f'Migrated from routing rule ({rule.get("type")})',
|
||||||
|
'order': i,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if use_pipeline_uuid:
|
||||||
|
new_bindings.append(
|
||||||
|
{
|
||||||
|
'id': str(uuid.uuid4()),
|
||||||
|
'event_pattern': 'message.*',
|
||||||
|
'target_type': 'pipeline',
|
||||||
|
'target_uuid': use_pipeline_uuid,
|
||||||
|
'filters': [],
|
||||||
|
'priority': 0,
|
||||||
|
'enabled': True,
|
||||||
|
'description': 'Migrated from default pipeline binding',
|
||||||
|
'order': len(new_bindings),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
if new_bindings:
|
||||||
|
bind.execute(
|
||||||
|
sa.text('UPDATE bots SET event_bindings = :b WHERE uuid = :u'),
|
||||||
|
{'b': json.dumps(new_bindings, ensure_ascii=False), 'u': bot_uuid},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
pass # not reversible
|
||||||
@@ -12,6 +12,9 @@ metadata:
|
|||||||
icon: telegram.svg
|
icon: telegram.svg
|
||||||
|
|
||||||
spec:
|
spec:
|
||||||
|
categories:
|
||||||
|
- popular
|
||||||
|
- global
|
||||||
config:
|
config:
|
||||||
- name: token
|
- name: token
|
||||||
label:
|
label:
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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'
|
||||||
@@ -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',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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]:
|
||||||
|
|||||||
@@ -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')
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -58,6 +58,7 @@
|
|||||||
"axios": "^1.16.0",
|
"axios": "^1.16.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"cmdk": "^1.1.1",
|
||||||
"highlight.js": "^11.11.1",
|
"highlight.js": "^11.11.1",
|
||||||
"i18next": "^25.1.2",
|
"i18next": "^25.1.2",
|
||||||
"i18next-browser-languagedetector": "^8.1.0",
|
"i18next-browser-languagedetector": "^8.1.0",
|
||||||
@@ -66,6 +67,7 @@
|
|||||||
"lucide-react": "^0.507.0",
|
"lucide-react": "^0.507.0",
|
||||||
"postcss": "^8.5.10",
|
"postcss": "^8.5.10",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
|
"radix-ui": "^1.6.0",
|
||||||
"react": "19.2.1",
|
"react": "19.2.1",
|
||||||
"react-dom": "19.2.1",
|
"react-dom": "19.2.1",
|
||||||
"react-hook-form": "^7.56.3",
|
"react-hook-form": "^7.56.3",
|
||||||
|
|||||||
Generated
+1414
File diff suppressed because it is too large
Load Diff
@@ -1,9 +1,6 @@
|
|||||||
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
import React, { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import i18n from 'i18next';
|
import i18n from 'i18next';
|
||||||
import {
|
import { IChooseAdapterEntity } from '@/app/home/bots/components/bot-form/ChooseEntity';
|
||||||
IChooseAdapterEntity,
|
|
||||||
IPipelineEntity,
|
|
||||||
} from '@/app/home/bots/components/bot-form/ChooseEntity';
|
|
||||||
import {
|
import {
|
||||||
DynamicFormItemConfig,
|
DynamicFormItemConfig,
|
||||||
getDefaultValues,
|
getDefaultValues,
|
||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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[];
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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:
|
||||||
'插件顺序会影响同一事件内的处理顺序,请拖动插件卡片排序',
|
'插件顺序会影响同一事件内的处理顺序,请拖动插件卡片排序',
|
||||||
|
|||||||
@@ -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:
|
||||||
'外掛順序會影響同一事件內的處理順序,請拖曳外掛卡片排序',
|
'外掛順序會影響同一事件內的處理順序,請拖曳外掛卡片排序',
|
||||||
|
|||||||
Reference in New Issue
Block a user