Compare commits

...

13 Commits

Author SHA1 Message Date
dadachann f7cef62290 test: declare agent usage in beta runtime fixture 2026-09-14 16:56:35 +00:00
dadachann 89d42bedaa chore: release 4.11.0-beta.1 with SDK 0.6.0-beta.1 2026-09-14 16:47:47 +00:00
RockChinQ 184037a427 feat(bots): bind plugin processor configurations independently 2026-09-15 00:28:13 +08:00
RockChinQ e913658e03 fix(processors): clarify processing modes and reorder choices 2026-09-13 00:18:36 +08:00
RockChinQ 1277c6da07 feat(runner): filter marketplace recommendations by explicit usage 2026-09-12 12:51:16 +08:00
RockChinQ fa15f48fd7 feat(models): sort catalog by listing date availability and price 2026-09-12 02:07:33 +08:00
RockChinQ 18c1ed93b8 fix(models): show reasoning icon in model selectors 2026-09-12 01:23:06 +08:00
RockChinQ 8b85876a19 fix(models): simplify capability badges 2026-09-12 01:09:12 +08:00
RockChinQ 18b84566c3 fix(models): flag models without pricing 2026-09-12 01:04:03 +08:00
RockChinQ 50d544aa6f fix(models): align selector metadata 2026-09-12 00:57:55 +08:00
RockChinQ 33b4035140 feat(models): show LangBot Models pricing 2026-09-12 00:53:47 +08:00
RockChinQ a3509b3626 feat(models): show LangBot Models availability 2026-09-11 21:17:20 +08:00
Hyu e631da0073 fix(runner): align SDK pin and complete real runtime verification (#2525)
* fix(runner): align SDK pin and workspace-aware integration fixtures

* fix(ci): format sources and resolve current migration head

* test(persistence): align standalone migration fixtures with current models

* test(web): align smoke fixtures with current processor UI

---------

Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
2026-09-11 12:57:29 +08:00
101 changed files with 2847 additions and 412 deletions
+1
View File
@@ -118,6 +118,7 @@ Pipeline components are registered by decorators and package import side effects
Platform code lives under `pkg/platform/`.
- `botmgr.py` owns runtime bots, routing rules, event logging, webhook pushing, and adapter lifecycle.
- Bots store exclusive Agent/Pipeline routes in `event_bindings` and independent plugin subscriptions in `plugin_processors` (`processor_uuid`, `enabled`). Subscriptions resolve event patterns from the installed Runner and fan out alongside the primary route. Configuration, state, debug and run logs belong to the reusable processor instance.
- `sources/` contains adapter implementations. Each adapter subclasses `langbot_plugin.api.definition.abstract.platform.adapter.AbstractMessagePlatformAdapter` from the SDK.
- Platform entities such as `MessageChain`, `Image`, `At`, `Voice`, and events come from `langbot-plugin-sdk`, not from this repo.
+2 -6
View File
@@ -1,6 +1,6 @@
[project]
name = "langbot"
version = "4.11.0"
version = "4.11.0-beta.1"
description = "Production-grade platform for building agentic IM bots"
readme = "README.md"
license-files = ["LICENSE"]
@@ -69,7 +69,7 @@ dependencies = [
"langchain-text-splitters>=1.1.2",
"chromadb>=1.0.0,<2.0.0",
"qdrant-client (>=1.15.1,<2.0.0)",
"langbot-plugin==0.5.5",
"langbot-plugin==0.6.0-beta.1",
"asyncpg>=0.30.0",
"line-bot-sdk>=3.19.0",
"matrix-nio>=0.25.2",
@@ -229,7 +229,3 @@ skip-magic-trailing-comma = false
# Like Black, automatically detect the appropriate line ending.
line-ending = "auto"
[tool.uv.sources]
# Development contract: update to the matching SDK release before publishing.
langbot-plugin = { git = "https://github.com/langbot-app/langbot-plugin-sdk", rev = "eac2c60509534512f9a373cd0c801e75985e0612" }
+10 -2
View File
@@ -119,8 +119,16 @@ already have a default pipeline.
Create a processor with `kind: "event_processor"` and basic information. Without
a component it supports no events. Discover installed components with
`get_processor_metadata`, then use `update_processor` with `component_ref` and
optional `parameters`. API callers may also supply these when creating an instance. Bind bot events to this instance with `target_type: "event_processor"`
and `target_id` equal to its UUID. Installation alone never activates a handler.
optional `parameters`. API callers may also supply these when creating an instance.
Bind an instance by updating the bot's `plugin_processors` array with
`{"processor_uuid": "<instance UUID>", "enabled": true}`. This replaces the full
subscription list; preserve bindings you want to keep. Do not add plugin processors
to `event_bindings`, which remains exclusive Agent/Pipeline routing.
Each enabled subscription independently receives the installed Runner's declared
events. Slow or failed subscribers do not prevent other subscribers or the primary
route from executing. Installation alone never activates a handler. Reusing an
instance shares its configuration and runtime state. Use a separate instance for
independent settings. Optional plugin behavior belongs in the Runner config schema.
`debug_agent` accepts the complete typed event in `payload.data` for this kind.
Legacy EventListener plugins remain in the Pipeline lifecycle.
@@ -9,6 +9,7 @@ metadata:
en_US: Deterministic runner fixture that returns stable QA sentinel output.
zh_Hans: 返回稳定 QA 哨兵输出的确定性 runner 夹具。
spec:
usages: [agent]
capabilities:
streaming: true
tool_calling: false
+1
View File
@@ -17,6 +17,7 @@ from quart import Quart, request, Response, jsonify
from langbot.libs.wecom_ai_bot_api import wecombotevent
from langbot.libs.wecom_ai_bot_api.WXBizMsgCrypt3 import WXBizMsgCrypt
if TYPE_CHECKING:
from langbot.pkg.platform.logger import EventLogger
from langbot.pkg.utils import httpclient
+1 -1
View File
@@ -55,7 +55,7 @@ class RunnerDescriptor(pydantic.BaseModel):
"""Original manifest for reference"""
component_kind: typing.Literal['Runner'] = 'Runner'
usages: list[typing.Literal['agent', 'event']] = pydantic.Field(default_factory=lambda: ['agent'])
usages: list[typing.Literal['agent', 'event']] = pydantic.Field(min_length=1)
supported_event_patterns: list[str] = pydantic.Field(default_factory=lambda: ['*'])
model_config = pydantic.ConfigDict(
@@ -1,4 +1,5 @@
"""EventLog store for writing and querying event records."""
from __future__ import annotations
import json
@@ -44,9 +45,7 @@ class EventLogStore:
def __init__(self, engine: AsyncEngine):
self.engine = engine
self._session_factory = sessionmaker(
engine, class_=AsyncSession, expire_on_commit=False
)
self._session_factory = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
async def append_event(
self,
@@ -101,7 +100,7 @@ class EventLogStore:
# Truncate input summary if too long
if input_summary and len(input_summary) > self.MAX_INPUT_SUMMARY_LENGTH:
input_summary = input_summary[:self.MAX_INPUT_SUMMARY_LENGTH - 3] + "..."
input_summary = input_summary[: self.MAX_INPUT_SUMMARY_LENGTH - 3] + '...'
async with self._session_factory() as session:
event = EventLog(
@@ -144,9 +143,7 @@ class EventLogStore:
Event record as dict, or None if not found
"""
async with self._session_factory() as session:
result = await session.execute(
sqlalchemy.select(EventLog).where(EventLog.event_id == event_id)
)
result = await session.execute(sqlalchemy.select(EventLog).where(EventLog.event_id == event_id))
row = result.scalars().first()
if row is None:
return None
@@ -282,9 +279,7 @@ class EventLogStore:
) -> int:
"""Delete EventLog rows created before the supplied timestamp."""
async with self._session_factory() as session:
result = await session.execute(
sqlalchemy.delete(EventLog).where(EventLog.created_at < before)
)
result = await session.execute(sqlalchemy.delete(EventLog).where(EventLog.created_at < before))
await session.commit()
return result.rowcount or 0
@@ -71,17 +71,8 @@ class EmbedRouterGroup(group.RouterGroup):
``web_page_bot``, is disabled, or has no Pipeline target for messages.
"""
bot = await self.ap.platform_mgr.resolve_public_bot(bot_uuid)
pipeline_uuid = (
bot.get_pipeline_target_for_event_type('message.received')
if bot is not None
else None
)
if (
bot is not None
and bot.bot_entity.adapter == 'web_page_bot'
and bot.bot_entity.enable
and pipeline_uuid
):
pipeline_uuid = bot.get_pipeline_target_for_event_type('message.received') if bot is not None else None
if bot is not None and bot.bot_entity.adapter == 'web_page_bot' and bot.bot_entity.enable and pipeline_uuid:
return bot, pipeline_uuid
return None, None
@@ -14,9 +14,7 @@ class ToolsRouterGroup(group.RouterGroup):
self,
request_context: RequestContext,
) -> list[dict] | None:
pipeline_uuid = quart.request.args.get(
'pipeline_uuid'
) or quart.request.args.get('pipeline_id')
pipeline_uuid = quart.request.args.get('pipeline_uuid') or quart.request.args.get('pipeline_id')
bound_plugins: list[str] | None = None
bound_mcp_servers: list[str] | None = None
@@ -28,14 +26,9 @@ class ToolsRouterGroup(group.RouterGroup):
if pipeline is None:
return None
extensions_prefs = normalize_extension_preferences(
pipeline.get('extensions_preferences')
)
extensions_prefs = normalize_extension_preferences(pipeline.get('extensions_preferences'))
if not extensions_prefs['enable_all_plugins']:
bound_plugins = [
f'{plugin["author"]}/{plugin["name"]}'
for plugin in extensions_prefs['plugins']
]
bound_plugins = [f'{plugin["author"]}/{plugin["name"]}' for plugin in extensions_prefs['plugins']]
if not extensions_prefs['enable_all_mcp_servers']:
bound_mcp_servers = extensions_prefs['mcp_servers']
@@ -220,6 +220,21 @@ class SystemRouterGroup(group.RouterGroup):
return self.http_status(503, -1, str(exc))
return self.success(data=model)
@self.route(
'/model-availability',
methods=['GET'],
auth_type=group.AuthType.USER_TOKEN,
permission=Permission.RESOURCE_VIEW,
)
async def _(request_context: RequestContext) -> str:
"""Expose Space's latest persisted model probes to the WebUI."""
try:
models = await self.ap.space_service.get_model_selection()
except Exception as exc:
self.ap.logger.warning(f'Failed to load LangBot Models availability: {exc}')
return self.http_status(503, -1, 'Model availability is unavailable')
return self.success(data={'models': [model.model_dump(mode='json') for model in models]})
@self.route(
'/tasks',
methods=['GET'],
+40 -2
View File
@@ -16,6 +16,7 @@ from .tenant import TenantContext, require_workspace_uuid, scope_statement
from ....utils import httpclient
from ....platform.sources import http_bot_signing
from ....platform.adapter_names import canonical_adapter_name
from ....agent.runner.errors import RunnerNotFoundError
class BotService:
@@ -36,6 +37,7 @@ class BotService:
'adapter_config',
'enable',
'event_bindings',
'plugin_processors',
}
def __init__(self, ap: app.Application) -> None:
@@ -517,7 +519,7 @@ class BotService:
)
if result.first() is None:
raise ValueError('Pipeline not found')
elif target_type in {'agent', 'event_processor'}:
elif target_type == 'agent':
result = await self.ap.persistence_mgr.execute_async(
scope_statement(
sqlalchemy.select(persistence_agent.Agent).where(persistence_agent.Agent.uuid == target_uuid),
@@ -551,6 +553,37 @@ class BotService:
return normalized
async def _normalize_plugin_processors(self, context: TenantContext, subscriptions: typing.Any) -> list[dict]:
"""Validate explicit subscriptions within this Workspace; events come from the Runner."""
if not isinstance(subscriptions, list):
raise ValueError('plugin_processors must be an array')
normalized = []
seen = set()
for subscription in subscriptions:
if not isinstance(subscription, dict):
raise ValueError('Each plugin processor binding must be an object')
processor_uuid = subscription.get('processor_uuid')
if not isinstance(processor_uuid, str) or not processor_uuid.strip():
raise ValueError('Plugin processor UUID is required')
if processor_uuid in seen:
raise ValueError('A plugin processor can only be bound once per bot')
enabled = subscription.get('enabled', True)
if not isinstance(enabled, bool):
raise ValueError('Plugin processor enabled must be a boolean')
agent = await self._get_agent_entity(context, processor_uuid)
if agent is None or agent.kind != 'event_processor':
raise ValueError('Plugin processor not found')
if enabled:
try:
descriptor = await self.ap.runner_registry.get(context, agent.component_ref)
except RunnerNotFoundError as exc:
raise ValueError('Runner component is unavailable') from exc
if 'event' not in descriptor.usages or not descriptor.supported_event_patterns:
raise ValueError('Select an available Runner that declares event usage')
seen.add(processor_uuid)
normalized.append({'processor_uuid': processor_uuid, 'enabled': enabled})
return normalized
async def _prepare_bot_data(self, context: TenantContext, bot_data: dict, *, include_uuid: bool) -> dict:
"""Normalize Bot write payloads to the current event-routing model."""
update_data = bot_data.copy()
@@ -564,6 +597,10 @@ class BotService:
update_data['event_bindings'] = await self._normalize_event_bindings(
context, update_data.get('event_bindings')
)
if 'plugin_processors' in update_data:
update_data['plugin_processors'] = await self._normalize_plugin_processors(
context, update_data['plugin_processors']
)
return update_data
async def get_bots(self, context: TenantContext, include_secret: bool = False) -> list[dict]:
@@ -660,6 +697,7 @@ class BotService:
bot_data['uuid'] = str(uuid.uuid4())
bot_data['workspace_uuid'] = workspace_uuid
bot_data.setdefault('event_bindings', [])
bot_data.setdefault('plugin_processors', [])
await self.ap.persistence_mgr.execute_async(sqlalchemy.insert(persistence_bot.Bot).values(bot_data))
@@ -688,7 +726,7 @@ class BotService:
if getattr(result, 'rowcount', None) == 0:
raise WorkspaceNotFoundError('Bot not found')
runtime_fields = {'adapter', 'adapter_config', 'enable', 'event_bindings'}
runtime_fields = {'adapter', 'adapter_config', 'enable', 'event_bindings', 'plugin_processors'}
if not runtime_fields.intersection(update_data):
runtime_bot = await self.ap.platform_mgr.get_bot_by_uuid(context, bot_uuid)
if runtime_bot is not None:
+14 -3
View File
@@ -242,13 +242,14 @@ class SpaceService:
models_data = data.get('data', {}).get('models', [])
return [SpaceModel.model_validate(model_dict) for model_dict in models_data]
async def get_model_selection(self, category: str) -> typing.List[SpaceModelSelection]:
async def get_model_selection(self, category: str | None = None) -> typing.List[SpaceModelSelection]:
"""Return Space models in the availability-ranked selection order."""
space_url = self._get_space_config()['url']
session = httpclient.get_session()
params = {'category': category} if category else None
async with session.get(
f'{space_url}/api/v1/models/selection',
params={'category': category},
params=params,
) as response:
if response.status != 200:
error = await httpclient.read_text_limited(response)
@@ -266,7 +267,17 @@ class SpaceService:
models = []
for selection in data:
if isinstance(selection, dict) and isinstance(selection.get('model'), dict):
models.append(selection['model'])
model = dict(selection['model'])
availability = selection.get('availability')
if not isinstance(availability, dict):
# Accept the short-lived pre-release response shape.
availability = {
key: selection[key]
for key in ('up', 'last_probed_at', 'latency_ms', 'http_code')
if key in selection
}
model['availability'] = availability
models.append(model)
else:
models.append(selection)
return [SpaceModelSelection.model_validate(model) for model in models]
+10 -2
View File
@@ -109,14 +109,22 @@ class LangBotMCPServer:
description=(
'Create a bot. `bot_data` is a JSON object matching the LangBot '
'POST /api/v1/platform/bots body (e.g. name, adapter, config). '
'Returns the new bot UUID.'
'Use event_bindings for exclusive Agent/Pipeline routes; plugin_processors is an array '
'of {processor_uuid, enabled} subscriptions that automatically match Runner-declared events. '
'Subscriptions run independently of each other and of event_bindings. Returns the new bot UUID.'
)
)
async def create_bot(bot_data: dict) -> str:
context = _authorized(Permission.RESOURCE_MANAGE)
return _dump({'uuid': await ap.bot_service.create_bot(context, bot_data)})
@mcp.tool(description='Update a bot by UUID. `bot_data` matches the PUT bot body.')
@mcp.tool(
description=(
'Update a bot by UUID. `bot_data` matches the PUT bot body. '
'plugin_processors replaces the bot subscriptions with [{processor_uuid, enabled}]. '
'Do not put event_processor targets in event_bindings; those are exclusive Agent/Pipeline routes.'
)
)
async def update_bot(bot_uuid: str, bot_data: dict) -> str:
context = _authorized(Permission.RESOURCE_MANAGE)
await ap.bot_service.update_bot(context, bot_uuid, bot_data)
+16 -1
View File
@@ -45,12 +45,27 @@ class SpaceModel(pydantic.BaseModel):
is_featured: bool = False
featured_order: int = 0
status: str
listed_at: str | None = None
created_at: str | None = None
updated_at: str | None = None
class SpaceModelAvailability(pydantic.BaseModel):
"""Latest availability probe. ``up`` is None when no probe exists."""
up: bool | None = None
last_probed_at: str | None = None
latency_ms: int = 0
http_code: int = 0
class SpaceModelSelection(pydantic.BaseModel):
"""Minimal model identity returned by the ranked selection endpoint."""
"""Model identity, pricing, and latest persisted probe from Space."""
uuid: str
model_id: str
category: str | None = None
listed_at: str | None = None
input_credits: float | None = None
output_credits: float | None = None
availability: SpaceModelAvailability = pydantic.Field(default_factory=SpaceModelAvailability)
@@ -53,6 +53,7 @@ class Bot(Base):
adapter_config = sqlalchemy.Column(sqlalchemy.JSON, nullable=False)
enable = sqlalchemy.Column(sqlalchemy.Boolean, nullable=False, default=False)
event_bindings = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, server_default='[]')
plugin_processors = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, server_default='[]')
created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now())
updated_at = sqlalchemy.Column(
sqlalchemy.DateTime,
@@ -1,4 +1,5 @@
"""Transcript persistence entity for conversation history projection."""
from __future__ import annotations
import sqlalchemy
@@ -48,12 +48,16 @@ def upgrade() -> None:
sa.column('source', sa.String(32)),
sa.column('created_by_account_uuid', sa.String(36)),
)
workspace_uuids = conn.execute(
sa.select(workspaces.c.uuid).where(
workspaces.c.instance_uuid == instance_uuid.strip(),
workspaces.c.source == 'local',
workspace_uuids = (
conn.execute(
sa.select(workspaces.c.uuid).where(
workspaces.c.instance_uuid == instance_uuid.strip(),
workspaces.c.source == 'local',
)
)
).scalars().all()
.scalars()
.all()
)
if not workspace_uuids:
return
if len(workspace_uuids) > 1:
@@ -99,10 +103,7 @@ def upgrade() -> None:
sa.column('status', sa.String(32)),
)
owner_account_uuid = conn.execute(
sa.select(users.c.uuid)
.where(users.c.status == 'active')
.order_by(users.c.id)
.limit(1)
sa.select(users.c.uuid).where(users.c.status == 'active').order_by(users.c.id).limit(1)
).scalar_one_or_none()
if owner_account_uuid is None:
return
@@ -0,0 +1,79 @@
"""Separate plugin processor subscriptions from exclusive bot event routes."""
from alembic import op
import sqlalchemy as sa
revision = '0025_bot_plugin_processors'
down_revision = '0024_unify_runner_state'
branch_labels = None
depends_on = None
def upgrade():
bind = op.get_bind()
inspector = sa.inspect(bind)
if not inspector.has_table('bots'):
return
if 'plugin_processors' not in {column['name'] for column in inspector.get_columns('bots')}:
op.add_column('bots', sa.Column('plugin_processors', sa.JSON(), nullable=False, server_default='[]'))
bots = sa.table(
'bots',
sa.column('uuid', sa.String()),
sa.column('event_bindings', sa.JSON()),
sa.column('plugin_processors', sa.JSON()),
)
for row in bind.execute(sa.select(bots)).mappings():
routes = []
subscriptions = {item['processor_uuid']: dict(item) for item in (row['plugin_processors'] or [])}
for route in row['event_bindings'] or []:
if route.get('target_type') != 'event_processor':
routes.append(route)
continue
processor_uuid = route.get('target_uuid')
if not processor_uuid:
continue
if processor_uuid not in subscriptions:
subscriptions[processor_uuid] = {'processor_uuid': processor_uuid, 'enabled': False}
subscriptions[processor_uuid]['enabled'] |= bool(route.get('enabled', True))
bind.execute(
bots.update()
.where(bots.c.uuid == row['uuid'])
.values(
event_bindings=routes,
plugin_processors=list(subscriptions.values()),
)
)
def downgrade():
# A subscription may match several events. Represent it as a wildcard route
# when reverting, while retaining the target and enabled state.
bind = op.get_bind()
inspector = sa.inspect(bind)
if not inspector.has_table('bots') or 'plugin_processors' not in {
column['name'] for column in inspector.get_columns('bots')
}:
return
bots = sa.table(
'bots',
sa.column('uuid', sa.String()),
sa.column('event_bindings', sa.JSON()),
sa.column('plugin_processors', sa.JSON()),
)
for row in bind.execute(sa.select(bots)).mappings():
routes = list(row['event_bindings'] or [])
for item in row['plugin_processors'] or []:
routes.append(
{
'id': f'plugin_processor:{item["processor_uuid"]}',
'event_pattern': '*',
'target_type': 'event_processor',
'target_uuid': item['processor_uuid'],
'enabled': item['enabled'],
'filters': [],
'priority': 0,
'order': len(routes),
}
)
bind.execute(bots.update().where(bots.c.uuid == row['uuid']).values(event_bindings=routes))
op.drop_column('bots', 'plugin_processors')
@@ -4,6 +4,7 @@ Revision ID: 58846a8d7a81
Revises: 0005_add_llm_context_length
Create Date: 2026-05-23 15:41:47.030841
"""
from alembic import op
import sqlalchemy as sa
@@ -4,6 +4,7 @@ Revision ID: 7b2c1d9e4f30
Revises: 6dfd3dd7f0c7
Create Date: 2026-06-12
"""
from alembic import op
import sqlalchemy as sa
@@ -90,9 +90,7 @@ def upgrade() -> None:
)
else:
_add_column_if_missing('agent_run', sa.Column('queue_name', sa.String(255), nullable=True))
_add_column_if_missing(
'agent_run', sa.Column('priority', sa.Integer(), nullable=False, server_default='0')
)
_add_column_if_missing('agent_run', sa.Column('priority', sa.Integer(), nullable=False, server_default='0'))
_add_column_if_missing('agent_run', sa.Column('requested_runtime_id', sa.String(255), nullable=True))
_add_column_if_missing('agent_run', sa.Column('claimed_by_runtime_id', sa.String(255), nullable=True))
_add_column_if_missing('agent_run', sa.Column('claim_token', sa.String(255), nullable=True))
@@ -50,22 +50,16 @@ def normalize_extension_preferences(value: typing.Any) -> dict[str, typing.Any]:
normalized['enable_all_plugins'] = value.get('enable_all_plugins', True) is True
normalized['enable_all_mcp_servers'] = value.get('enable_all_mcp_servers', True) is True
normalized['enable_all_skills'] = value.get('enable_all_skills', True) is True
normalized['mcp_resource_agent_read_enabled'] = (
value.get('mcp_resource_agent_read_enabled', True) is True
)
normalized['mcp_resource_agent_read_enabled'] = value.get('mcp_resource_agent_read_enabled', True) is True
plugins = value.get('plugins', [])
plugins_are_valid = isinstance(plugins, list) and all(
_valid_plugin_binding(plugin) for plugin in plugins
)
plugins_are_valid = isinstance(plugins, list) and all(_valid_plugin_binding(plugin) for plugin in plugins)
normalized['plugins'] = list(plugins) if plugins_are_valid else []
if not plugins_are_valid:
normalized['enable_all_plugins'] = False
mcp_servers = value.get('mcp_servers', [])
mcp_servers_are_valid = isinstance(mcp_servers, list) and all(
_valid_name(server) for server in mcp_servers
)
mcp_servers_are_valid = isinstance(mcp_servers, list) and all(_valid_name(server) for server in mcp_servers)
normalized['mcp_servers'] = list(mcp_servers) if mcp_servers_are_valid else []
if not mcp_servers_are_valid:
normalized['enable_all_mcp_servers'] = False
@@ -151,6 +145,4 @@ def _validate_list_field(
raise ValueError(f"{context} field '{field_label}' must be a list")
for index, item in enumerate(items):
if not item_validator(item):
raise ValueError(
f"{context} field '{field_label}[{index}]' must be {item_description}"
)
raise ValueError(f"{context} field '{field_label}[{index}]' must be {item_description}")
@@ -65,8 +65,12 @@ class OfficialAccountMessageConverter(abstract_platform_adapter.AbstractMessageC
else:
components.append(platform_message.Unknown(text='[officialaccount voice message without media id]'))
elif event.type == 'event':
components.append(platform_message.Unknown(text=f'[officialaccount event: {event.detail_type or "unknown"}]'))
components.append(
platform_message.Unknown(text=f'[officialaccount event: {event.detail_type or "unknown"}]')
)
else:
components.append(platform_message.Unknown(text=f'[unsupported officialaccount msgtype: {event.type or "unknown"}]'))
components.append(
platform_message.Unknown(text=f'[unsupported officialaccount msgtype: {event.type or "unknown"}]')
)
return platform_message.MessageChain(components)
@@ -3,4 +3,3 @@
from langbot.pkg.platform.adapters.qqofficial.adapter import QQOfficialAdapter
__all__ = ['QQOfficialAdapter']
@@ -54,7 +54,9 @@ class QQOfficialAPIMixin:
self,
group_id: typing.Union[int, str],
) -> list[platform_entities.UserGroupMember]:
return [member for (cached_group_id, _), member in self._member_cache.items() if cached_group_id == str(group_id)]
return [
member for (cached_group_id, _), member in self._member_cache.items() if cached_group_id == str(group_id)
]
async def edit_message(
self,
@@ -100,4 +102,3 @@ class QQOfficialAPIMixin:
async def leave_group(self, group_id: typing.Union[int, str]):
raise NotSupportedError('leave_group')
@@ -8,4 +8,3 @@ except ModuleNotFoundError:
def __init__(self, api_name: str, *args):
super().__init__(f"API '{api_name}' is not supported by this adapter", *args)
self.api_name = api_name
@@ -34,4 +34,3 @@ PLATFORM_API_MAP: dict[str, typing.Callable[[typing.Any, dict], typing.Awaitable
'get_gateway_url': get_gateway_url,
'get_mode': get_mode,
}
@@ -47,7 +47,9 @@ class SlackAPIMixin:
self,
group_id: typing.Union[int, str],
) -> list[platform_entities.UserGroupMember]:
return [member for (cached_group_id, _), member in self._member_cache.items() if cached_group_id == str(group_id)]
return [
member for (cached_group_id, _), member in self._member_cache.items() if cached_group_id == str(group_id)
]
async def get_group_member_info(
self,
@@ -19,7 +19,9 @@ class SlackEventConverter(abstract_platform_adapter.AbstractEventConverter):
async def yiri2target(event: platform_events.Event) -> typing.Any:
return getattr(event, 'source_platform_object', None)
async def target2legacy(self, event: SlackEvent) -> platform_events.FriendMessage | platform_events.GroupMessage | None:
async def target2legacy(
self, event: SlackEvent
) -> platform_events.FriendMessage | platform_events.GroupMessage | None:
eba_event = await self.target2yiri(event)
if not isinstance(eba_event, platform_events.MessageReceivedEvent):
return None
@@ -1,3 +1,3 @@
from langbot.pkg.platform.adapters.telegram.adapter import TelegramAdapter
__all__ = ["TelegramAdapter"]
__all__ = ['TelegramAdapter']
@@ -14,7 +14,7 @@ async def pin_message(bot: telegram.Bot, params: dict) -> dict:
message_id=params['message_id'],
disable_notification=params.get('disable_notification', False),
)
return {"ok": True}
return {'ok': True}
async def unpin_message(bot: telegram.Bot, params: dict) -> dict:
@@ -23,26 +23,26 @@ async def unpin_message(bot: telegram.Bot, params: dict) -> dict:
chat_id=params['chat_id'],
message_id=params.get('message_id'),
)
return {"ok": True}
return {'ok': True}
async def unpin_all_messages(bot: telegram.Bot, params: dict) -> dict:
"""Unpin all messages in a chat."""
await bot.unpin_all_chat_messages(chat_id=params['chat_id'])
return {"ok": True}
return {'ok': True}
async def get_chat_administrators(bot: telegram.Bot, params: dict) -> dict:
"""Get chat administrator list."""
admins = await bot.get_chat_administrators(chat_id=params['chat_id'])
return {
"administrators": [
'administrators': [
{
"user_id": a.user.id,
"username": a.user.username,
"first_name": a.user.first_name,
"status": a.status,
"custom_title": getattr(a, 'custom_title', None),
'user_id': a.user.id,
'username': a.user.username,
'first_name': a.user.first_name,
'status': a.status,
'custom_title': getattr(a, 'custom_title', None),
}
for a in admins
]
@@ -55,7 +55,7 @@ async def set_chat_title(bot: telegram.Bot, params: dict) -> dict:
chat_id=params['chat_id'],
title=params['title'],
)
return {"ok": True}
return {'ok': True}
async def set_chat_description(bot: telegram.Bot, params: dict) -> dict:
@@ -64,13 +64,13 @@ async def set_chat_description(bot: telegram.Bot, params: dict) -> dict:
chat_id=params['chat_id'],
description=params.get('description', ''),
)
return {"ok": True}
return {'ok': True}
async def get_chat_member_count(bot: telegram.Bot, params: dict) -> dict:
"""Get chat member count."""
count = await bot.get_chat_member_count(chat_id=params['chat_id'])
return {"count": count}
return {'count': count}
async def send_chat_action(bot: telegram.Bot, params: dict) -> dict:
@@ -79,7 +79,7 @@ async def send_chat_action(bot: telegram.Bot, params: dict) -> dict:
chat_id=params['chat_id'],
action=params.get('action', 'typing'),
)
return {"ok": True}
return {'ok': True}
async def create_chat_invite_link(bot: telegram.Bot, params: dict) -> dict:
@@ -91,10 +91,10 @@ async def create_chat_invite_link(bot: telegram.Bot, params: dict) -> dict:
member_limit=params.get('member_limit'),
)
return {
"invite_link": link.invite_link,
"name": link.name,
"is_primary": link.is_primary,
"is_revoked": link.is_revoked,
'invite_link': link.invite_link,
'name': link.name,
'is_primary': link.is_primary,
'is_revoked': link.is_revoked,
}
@@ -106,20 +106,20 @@ async def answer_callback_query(bot: telegram.Bot, params: dict) -> dict:
show_alert=params.get('show_alert', False),
url=params.get('url'),
)
return {"ok": True}
return {'ok': True}
# ---- Action dispatch table ----
PLATFORM_API_MAP: dict[str, typing.Callable[[telegram.Bot, dict], typing.Awaitable[dict]]] = {
"pin_message": pin_message,
"unpin_message": unpin_message,
"unpin_all_messages": unpin_all_messages,
"get_chat_administrators": get_chat_administrators,
"set_chat_title": set_chat_title,
"set_chat_description": set_chat_description,
"get_chat_member_count": get_chat_member_count,
"send_chat_action": send_chat_action,
"create_chat_invite_link": create_chat_invite_link,
"answer_callback_query": answer_callback_query,
'pin_message': pin_message,
'unpin_message': unpin_message,
'unpin_all_messages': unpin_all_messages,
'get_chat_administrators': get_chat_administrators,
'set_chat_title': set_chat_title,
'set_chat_description': set_chat_description,
'get_chat_member_count': get_chat_member_count,
'send_chat_action': send_chat_action,
'create_chat_invite_link': create_chat_invite_link,
'answer_callback_query': answer_callback_query,
}
@@ -7,7 +7,8 @@ from enum import Enum
class TelegramChatType(str, Enum):
"""Telegram chat type."""
PRIVATE = "private"
GROUP = "group"
SUPERGROUP = "supergroup"
CHANNEL = "channel"
PRIVATE = 'private'
GROUP = 'group'
SUPERGROUP = 'supergroup'
CHANNEL = 'channel'
@@ -54,7 +54,9 @@ class WecomBotAPIMixin:
self,
group_id: typing.Union[int, str],
) -> list[platform_entities.UserGroupMember]:
return [member for (cached_group_id, _), member in self._member_cache.items() if cached_group_id == str(group_id)]
return [
member for (cached_group_id, _), member in self._member_cache.items() if cached_group_id == str(group_id)
]
async def upload_file(self, file_data: bytes, filename: str) -> str:
raise NotSupportedError('upload_file')
@@ -19,7 +19,9 @@ class WecomBotEventConverter(abstract_platform_adapter.AbstractEventConverter):
async def yiri2target(event: platform_events.Event) -> typing.Any:
return getattr(event, 'source_platform_object', None)
async def target2legacy(self, event: WecomBotEvent) -> platform_events.FriendMessage | platform_events.GroupMessage | None:
async def target2legacy(
self, event: WecomBotEvent
) -> platform_events.FriendMessage | platform_events.GroupMessage | None:
eba_event = await self.target2yiri(event)
if not isinstance(eba_event, platform_events.MessageReceivedEvent):
return None
@@ -50,7 +52,9 @@ class WecomBotEventConverter(abstract_platform_adapter.AbstractEventConverter):
async def target2yiri(self, event: WecomBotEvent) -> platform_events.Event:
if event.type in {'single', 'group'} and event.msgtype != 'event':
return await self.message_to_eba(event)
return self.platform_specific(event, f'wecombot.{event.get("eventtype") or event.msgtype or event.type or "unknown"}')
return self.platform_specific(
event, f'wecombot.{event.get("eventtype") or event.msgtype or event.type or "unknown"}'
)
async def message_to_eba(self, event: WecomBotEvent) -> platform_events.MessageReceivedEvent:
sender = platform_entities.User(id=event.userid, nickname=event.username or event.userid)
@@ -17,7 +17,9 @@ class WecomCSEventConverter(abstract_platform_adapter.AbstractEventConverter):
return getattr(event, 'source_platform_object', None)
@staticmethod
async def target2legacy(event: WecomCSEvent, bot: WecomCSClient | None = None) -> platform_events.FriendMessage | None:
async def target2legacy(
event: WecomCSEvent, bot: WecomCSClient | None = None
) -> platform_events.FriendMessage | None:
eba_event = await WecomCSEventConverter.target2yiri(event, bot)
if hasattr(eba_event, 'to_legacy_event'):
return eba_event.to_legacy_event()
@@ -30,7 +32,9 @@ class WecomCSEventConverter(abstract_platform_adapter.AbstractEventConverter):
return WecomCSEventConverter.platform_specific(event, f'wecomcs.{event.type or "unknown"}')
@staticmethod
async def message_to_eba(event: WecomCSEvent, bot: WecomCSClient | None = None) -> platform_events.MessageReceivedEvent:
async def message_to_eba(
event: WecomCSEvent, bot: WecomCSClient | None = None
) -> platform_events.MessageReceivedEvent:
message_chain = await WecomCSMessageConverter.target2yiri(event)
sender = await WecomCSEventConverter.user_from_event(event, bot)
return platform_events.MessageReceivedEvent(
+61 -8
View File
@@ -280,6 +280,8 @@ class RuntimeBot:
diagnostic_steps: list[dict[str, typing.Any]] = []
for index, binding in enumerate(bindings):
if binding.get('target_type') == 'event_processor':
continue
event_pattern = str(binding.get('event_pattern') or '')
priority = int(binding.get('priority') or 0)
order = int(binding.get('order', index))
@@ -841,22 +843,72 @@ class RuntimeBot:
event.bot_uuid = self.bot_entity.uuid
await self._record_adapter_event(event, adapter)
if isinstance(event, platform_events.PlatformSpecificEvent) and event.action == 'interaction.submitted':
await self._handle_interaction_submission(event, adapter)
return
primary = (
self._handle_interaction_submission(event, adapter)
if isinstance(event, platform_events.PlatformSpecificEvent) and event.action == 'interaction.submitted'
else self._dispatch_eba_event_to_processor(event, adapter)
)
subscriptions = self._get_event_bindings_from_value(getattr(self.bot_entity, 'plugin_processors', []))
seen = set()
tasks = [primary]
for subscription in subscriptions:
processor_uuid = subscription.get('processor_uuid')
if not processor_uuid or processor_uuid in seen or not subscription.get('enabled', True):
continue
seen.add(processor_uuid)
tasks.append(self._dispatch_plugin_subscription(event, adapter, processor_uuid))
# Start all deliveries together. A slow or failed subscriber cannot block
# another subscriber or the primary route from receiving the event.
results = await asyncio.gather(*tasks, return_exceptions=True)
for result in results:
if isinstance(result, BaseException):
await self.logger.error(f'Event delivery failed: {result}')
# Legacy listeners run inside Pipeline stages. EBA handlers require an
# explicitly created and routed plugin processor instance.
await self._dispatch_eba_event_to_processor(event, adapter)
async def _dispatch_plugin_subscription(self, event, adapter, processor_uuid):
event_type = event.type
event_binding = {
'id': f'plugin_processor:{processor_uuid}',
'target_type': 'event_processor',
'target_uuid': processor_uuid,
'event_pattern': event_type,
}
try:
agent = await self.ap.agent_service.get_agent(self.execution_context, processor_uuid)
if not agent or agent.get('kind') != 'event_processor':
raise ValueError('Plugin processor not found')
descriptor = await self.ap.runner_registry.get(self.execution_context, agent.get('component_ref'))
if 'event' not in descriptor.usages:
raise ValueError('Runner does not support event processing')
# Resolve the installed declaration each time, including after plugin updates.
patterns = descriptor.supported_event_patterns
if not patterns or not self._agent_supports_event_type(patterns, event_type):
return
agent = {**agent, 'supported_event_patterns': patterns}
return await self._dispatch_eba_event_to_processor(event, adapter, event_binding, agent)
except Exception as exc:
return await self._record_event_route_trace(
event_type=event_type,
status='failed',
level='error',
binding=event_binding,
target_type='event_processor',
target_uuid=processor_uuid,
failure_code='runner_failed',
reason=str(exc),
text=f'Plugin processor {processor_uuid} failed: {exc}',
)
async def _dispatch_eba_event_to_processor(
self,
event: platform_events.EBAEvent,
adapter: abstract_platform_adapter.AbstractMessagePlatformAdapter,
event_binding: dict | None = None,
agent: dict | None = None,
) -> dict[str, typing.Any]:
event_type = getattr(event, 'type', None) or event.__class__.__name__
event_binding = self._resolve_eba_event_binding(event, event_type)
if event_binding is None:
event_binding = self._resolve_eba_event_binding(event, event_type)
if event_binding is None:
return await self._record_event_route_trace(
event_type=event_type,
@@ -938,7 +990,8 @@ class RuntimeBot:
)
target_uuid = event_binding.get('target_uuid')
agent = await self.ap.agent_service.get_agent(self.execution_context, target_uuid)
if agent is None:
agent = await self.ap.agent_service.get_agent(self.execution_context, target_uuid)
if not agent or agent.get('kind') != target_type:
return await self._record_event_route_trace(
event_type=event_type,
+6 -4
View File
@@ -89,10 +89,12 @@ def format_human_input_text(
if actions:
lines.append('')
if input_defs:
lines.extend([
'Reply with action plus field values to continue:',
' action: <number or title>',
])
lines.extend(
[
'Reply with action plus field values to continue:',
' action: <number or title>',
]
)
else:
lines.append('Reply with the number or title to continue:')
for idx, action in enumerate(actions, start=1):
+3 -11
View File
@@ -2327,15 +2327,9 @@ class MCPLoader(loader.ToolLoader):
return items
def _session_by_source_id(
self, context: TenantContext, source_id: str
) -> RuntimeMCPSession | None:
def _session_by_source_id(self, context: TenantContext, source_id: str) -> RuntimeMCPSession | None:
return next(
(
session
for session in self._sessions_for_context(context)
if session.server_uuid == source_id
),
(session for session in self._sessions_for_context(context) if session.server_uuid == source_id),
None,
)
@@ -2397,9 +2391,7 @@ class MCPLoader(loader.ToolLoader):
source_id: str | None = None,
) -> typing.Any:
"""执行工具调用"""
execution_context = await self._assert_execution_active(
_execution_context_from_query(query)
)
execution_context = await self._assert_execution_active(_execution_context_from_query(query))
if source_id is None and name == MCP_TOOL_LIST_RESOURCES:
if getattr(query, 'variables', {}).get('_pipeline_mcp_resource_agent_read_enabled', True) is not True:
raise ToolExecutionDeniedError(name, 'MCP resource agent reads are disabled')
+2 -6
View File
@@ -368,9 +368,7 @@ class ToolManager:
return None
return await self.plugin_tool_loader.get_tool(name, source_id=source_id)
if source == 'mcp':
return await self.mcp_tool_loader.get_tool(
context, name, source_id=source_id
)
return await self.mcp_tool_loader.get_tool(context, name, source_id=source_id)
return None
async def generate_tools_for_openai(self, use_funcs: list[resource_tool.LLMTool]) -> list:
@@ -488,9 +486,7 @@ class ToolManager:
if source_ref is not None:
execution_context = get_query_execution_context(query)
await self._bind_plugin_workspace(execution_context)
sandbox_available = await self._workspace_sandbox_available(
execution_context
)
sandbox_available = await self._workspace_sandbox_available(execution_context)
source = source_ref['source']
source_id = source_ref.get('source_id')
uses_source_id = False
+60 -13
View File
@@ -57,7 +57,7 @@ def _package_local_agent_plugin(tmpdir: Path) -> Path:
package_source = tmpdir / 'local-agent-package'
ignore = shutil.ignore_patterns(
'.git',
'.venv',
'.venv*',
'__pycache__',
'.pytest_cache',
'.ruff_cache',
@@ -181,7 +181,20 @@ class _FakeToolManager:
def __init__(self):
self.calls: list[dict[str, Any]] = []
async def get_tool_schema(self, tool_name: str):
async def get_resolved_tool_catalog(
self,
context,
bound_plugins=None,
bound_mcp_servers=None,
include_skill_authoring=True,
include_mcp_resource_tools=False,
):
assert context.workspace_uuid
return [{'name': E2E_TOOL_NAME, 'source': 'native', 'source_id': None}]
async def get_tool_schema(self, context, tool_name: str, source_ref=None):
assert context.workspace_uuid
assert source_ref == {'source': 'native', 'source_id': None}
if tool_name != E2E_TOOL_NAME:
return None, None
return (
@@ -195,14 +208,15 @@ class _FakeToolManager:
},
)
async def get_tool_detail(self, tool_name: str):
description, parameters = await self.get_tool_schema(tool_name)
async def get_tool_detail(self, context, tool_name: str, source_ref=None):
description, parameters = await self.get_tool_schema(context, tool_name, source_ref=source_ref)
if parameters is None:
return None
return {'name': tool_name, 'description': description, 'parameters': parameters}
async def execute_func_call(self, name: str, parameters: dict[str, Any], query: Any = None):
del query
async def execute_func_call(self, name: str, parameters: dict[str, Any], query: Any = None, source_ref=None):
assert query.workspace_uuid
assert source_ref == {'source': 'native', 'source_id': None}
self.calls.append({'name': name, 'parameters': dict(parameters)})
return {
'value': f'tool-result:{parameters.get("query")}',
@@ -223,7 +237,8 @@ class _FakeKnowledgeBase:
def get_name(self) -> str:
return 'E2E Fake KB'
async def retrieve(self, query_text: str, settings: dict[str, Any]):
async def retrieve(self, context, query_text: str, settings: dict[str, Any]):
assert context.workspace_uuid
self.retrieve_calls.append({'query_text': query_text, 'settings': settings})
return [
SimpleNamespace(
@@ -248,7 +263,8 @@ class _FakeRagManager:
self.kb = kb
self.knowledge_bases = {E2E_KB_UUID: kb}
async def get_knowledge_base_by_uuid(self, kb_uuid: str):
async def get_knowledge_base_by_uuid(self, context, kb_uuid: str):
assert context.workspace_uuid
if kb_uuid == E2E_KB_UUID:
return self.kb
return None
@@ -443,10 +459,27 @@ def _scripted_tool_call(
async def _boot_local_agent_app(tmpdir: Path):
"""Boot LangBot and wait until the Local Agent runner is discoverable."""
from langbot.pkg.core import boot
from langbot_plugin.runtime.plugin.mgr import PluginInstallSource
ap = await boot.make_app(asyncio.get_running_loop())
run_task = asyncio.create_task(ap.run(), name='local-agent-e2e-app')
try:
await _wait_for_local_agent_runner(ap, tmpdir)
except BaseException:
# The caller has not received ap yet. Close it here so a boot failure
# cannot reconnect after the probe restores the global transport mode.
try:
await ap.shutdown()
finally:
run_task.cancel()
await asyncio.gather(run_task, return_exceptions=True)
raise
return ap, run_task
async def _wait_for_local_agent_runner(ap, tmpdir: Path):
"""Install and discover the runner on the application-owned connection."""
from langbot_plugin.runtime.plugin.mgr import PluginInstallSource
for _ in range(60):
handler = getattr(ap.plugin_connector, 'handler', None)
if handler is not None:
@@ -478,8 +511,6 @@ async def _boot_local_agent_app(tmpdir: Path):
else:
raise AssertionError(f'{LOCAL_RUNNER_ID} was not discovered after installation')
return ap, run_task
def _run_local_agent_probe(tmpdir: Path, probe):
"""Run one Local Agent probe inside the temporary LangBot app."""
@@ -689,7 +720,13 @@ def test_local_runner_retrieves_authorized_rag_context_through_host_action(
assert retrieve_calls == [
{
'query_text': 'Answer with the retrieved RAG sentinel.',
'settings': {'top_k': 1, 'filters': {}},
'settings': {
'top_k': 1,
'filters': {},
'session_name': 'person_e2e-local-agent-rag-conversation',
'bot_uuid': '',
'sender_id': 'user-001',
},
}
]
assert any(
@@ -738,11 +775,13 @@ def test_local_runner_compacts_history_and_persists_checkpoint(
)
store = TranscriptStore(ap.persistence_mgr.get_db_engine())
execution_context = await ap.plugin_connector._current_execution_context()
for index in range(12):
await store.append_transcript(
transcript_id=None,
event_id=f'e2e-local-agent-history-{index}',
conversation_id='e2e-local-agent-compaction-conversation',
workspace_id=execution_context.workspace_uuid,
role='user' if index % 2 == 0 else 'assistant',
content=(
f'HIST_SENTINEL-{index} This is intentionally long deterministic history for compaction. ' * 10
@@ -847,11 +886,13 @@ def test_local_runner_combines_rag_compaction_and_multi_turn_tool_loop(
ap.rag_mgr = _FakeRagManager(fake_kb)
store = TranscriptStore(ap.persistence_mgr.get_db_engine())
execution_context = await ap.plugin_connector._current_execution_context()
for index in range(16):
await store.append_transcript(
transcript_id=None,
event_id=f'e2e-local-agent-combo-history-{index}',
conversation_id='e2e-local-agent-combo-conversation',
workspace_id=execution_context.workspace_uuid,
role='user' if index % 2 == 0 else 'assistant',
content=(
f'HIST_COMBO_SENTINEL-{index} RAG_TOOL_COMBO_GOAL '
@@ -907,7 +948,13 @@ def test_local_runner_combines_rag_compaction_and_multi_turn_tool_loop(
assert retrieve_calls == [
{
'query_text': 'current combo request must survive; use RAG and tools before answering.',
'settings': {'top_k': 1, 'filters': {}},
'settings': {
'top_k': 1,
'filters': {},
'session_name': 'person_e2e-local-agent-combo-conversation',
'bot_uuid': '',
'sender_id': 'user-001',
},
}
]
assert invoke_count >= 4
+1
View File
@@ -114,6 +114,7 @@ def _write_qa_runner_plugin(plugin_root: Path) -> None:
en_US: Echoes input and exercises run-scoped state APIs.
zh_Hans: 回显输入并验证运行级状态 API
spec:
usages: [agent]
config: []
capabilities:
streaming: false
@@ -16,7 +16,9 @@ import sqlalchemy
from sqlalchemy import text
from sqlalchemy.ext.asyncio import create_async_engine
from langbot.pkg.entity import persistence
from langbot.pkg.entity.persistence.base import Base
from langbot.pkg.utils import importutil
from langbot.pkg.persistence.alembic_runner import (
run_alembic_downgrade,
run_alembic_upgrade,
@@ -28,6 +30,11 @@ from alembic.config import Config
from alembic.script import ScriptDirectory
# Match PersistenceManager's model registration before create_all, including
# workspace foreign-key targets, without relying on other tests being collected.
importutil.import_modules_in_pkg(persistence)
def _get_script_head() -> str:
"""Resolve the current Alembic head revision from the script directory.
@@ -108,7 +115,6 @@ class TestSQLiteMigrationUpgrade:
await run_alembic_upgrade(sqlite_engine, 'head')
assert await get_alembic_current(sqlite_engine) == _get_script_head()
assert _get_script_head() == '0023_drop_agent_enabled'
@pytest.mark.asyncio
async def test_upgrade_from_development_workspace_head_to_merged_head(self, sqlite_engine):
@@ -120,7 +126,6 @@ class TestSQLiteMigrationUpgrade:
await run_alembic_upgrade(sqlite_engine, 'head')
assert await get_alembic_current(sqlite_engine) == _get_script_head()
assert _get_script_head() == '0023_drop_agent_enabled'
@pytest.mark.asyncio
async def test_upgrade_from_reasoning_config_head_to_merged_head(self, sqlite_engine):
@@ -131,7 +136,7 @@ class TestSQLiteMigrationUpgrade:
await run_alembic_stamp(sqlite_engine, '0018_llm_reasoning_config')
await run_alembic_upgrade(sqlite_engine, 'head')
assert await get_alembic_current(sqlite_engine) == '0023_drop_agent_enabled'
assert await get_alembic_current(sqlite_engine) == _get_script_head()
@pytest.mark.asyncio
async def test_upgrade_removes_agent_enabled_column(self, sqlite_engine):
@@ -856,7 +856,7 @@ class TestPostgreSQLTenantRuntime:
adapter='capacity-probe',
adapter_config={},
enable=False,
pipeline_routing_rules=[],
event_bindings=[],
),
persistence_pipeline.LegacyPipeline(
uuid=f'capacity-pipeline-{suffix}',
@@ -32,6 +32,7 @@ def make_descriptor(
permissions: dict | None = None,
) -> RunnerDescriptor:
return RunnerDescriptor(
usages=['agent'],
id='plugin:test/runner/default',
source='plugin',
label={'en_US': 'Test Runner'},
@@ -91,6 +91,7 @@ class TestContextValidation:
def _make_descriptor(self):
"""Create a mock runner descriptor."""
return RunnerDescriptor(
usages=['agent'],
id='plugin:test/plugin/runner',
source='plugin',
label={'en_US': 'Test Runner'},
@@ -125,6 +125,7 @@ class MockApplication:
class FakeRunnerRegistry:
async def get(self, context, runner_id, bound_plugins=None):
return RunnerDescriptor(
usages=['agent'],
id=runner_id,
source='plugin',
label={'en_US': 'Test Runner'},
@@ -0,0 +1,124 @@
"""Keep E2E fake resources compatible with the real Host contract."""
from types import SimpleNamespace
from unittest.mock import Mock
import pytest
from langbot.pkg.agent.runner.descriptor import RunnerDescriptor
from langbot.pkg.agent.runner.resource_builder import AgentResourceBuilder
from langbot.pkg.api.http.context import ExecutionContext
from tests.e2e.test_local_runner_fake_provider import (
E2E_KB_UUID,
E2E_TOOL_NAME,
LOCAL_RUNNER_ID,
_FakeKnowledgeBase,
_FakeRagManager,
_FakeToolManager,
_binding,
_event,
)
CONTEXT = ExecutionContext(instance_uuid='instance-test', workspace_uuid='workspace-test', placement_generation=1)
SOURCE = {'source': 'native', 'source_id': None}
async def _resources(tool_mgr, rag_mgr, binding):
descriptor = RunnerDescriptor(
usages=['agent'],
id=LOCAL_RUNNER_ID,
source='plugin',
label={'en_US': 'Local Agent'},
plugin_author='langbot-team',
plugin_name='LocalAgent',
runner_name='default',
capabilities={'tool_calling': True, 'knowledge_retrieval': True},
permissions={'tools': ['detail', 'call'], 'knowledge_bases': ['list', 'retrieve']},
)
app = SimpleNamespace(logger=Mock(), skill_mgr=None, tool_mgr=tool_mgr, rag_mgr=rag_mgr)
return await AgentResourceBuilder(app).build_resources_from_binding(
CONTEXT,
_event(event_id='evt', conversation_id='conv', text='test'),
binding,
descriptor,
)
@pytest.mark.asyncio
async def test_fake_tool_is_projected_with_frozen_source_and_executed():
manager = _FakeToolManager()
resources = await _resources(
manager, _FakeRagManager(_FakeKnowledgeBase()), _binding(allowed_tool_names=[E2E_TOOL_NAME])
)
assert len(resources['tools']) == 1
tool = resources['tools'][0]
assert tool['tool_name'] == E2E_TOOL_NAME
assert {key: tool[key] for key in SOURCE} == SOURCE
assert tool['parameters']['required'] == ['query']
detail = await manager.get_tool_detail(CONTEXT, E2E_TOOL_NAME, source_ref=SOURCE)
assert detail['parameters'] == tool['parameters']
result = await manager.execute_func_call(
name=E2E_TOOL_NAME,
parameters={'query': 'alpha'},
query=SimpleNamespace(workspace_uuid=CONTEXT.workspace_uuid),
source_ref=SOURCE,
)
assert result['value'] == 'tool-result:alpha'
assert manager.calls == [{'name': E2E_TOOL_NAME, 'parameters': {'query': 'alpha'}}]
@pytest.mark.asyncio
async def test_fake_kb_is_projected_and_retrieved_with_execution_context():
kb = _FakeKnowledgeBase()
manager = _FakeRagManager(kb)
resources = await _resources(_FakeToolManager(), manager, _binding(allowed_kb_uuids=[E2E_KB_UUID]))
assert [item['kb_id'] for item in resources['knowledge_bases']] == [E2E_KB_UUID]
resolved = await manager.get_knowledge_base_by_uuid(CONTEXT, E2E_KB_UUID)
entries = await resolved.retrieve(CONTEXT, 'test', settings={'top_k': 1, 'filters': {}})
assert 'RAG_SENTINEL' in entries[0].content
assert kb.retrieve_calls == [{'query_text': 'test', 'settings': {'top_k': 1, 'filters': {}}}]
def test_local_agent_package_excludes_alternate_virtualenvs(tmp_path, monkeypatch):
import zipfile
from tests.e2e import test_local_runner_fake_provider as fixtures
source = tmp_path / 'source'
source.mkdir()
(source / 'manifest.yaml').write_text('metadata: {author: langbot-team, name: LocalAgent}')
for name in ('.venv', '.venv311'):
(source / name).mkdir()
(source / name / 'not-plugin.py').write_text('pass')
monkeypatch.setattr(fixtures, '_local_agent_repo', lambda: source)
package = fixtures._package_local_agent_plugin(tmp_path / 'package')
with zipfile.ZipFile(package) as archive:
assert archive.namelist() == ['manifest.yaml']
@pytest.mark.asyncio
async def test_failed_local_agent_boot_shuts_down_before_restoring_transport(monkeypatch, tmp_path):
import asyncio
from unittest.mock import AsyncMock
from langbot.pkg.core import boot
from tests.e2e import test_local_runner_fake_provider as fixtures
async def running():
await asyncio.Event().wait()
app = SimpleNamespace(
run=running,
shutdown=AsyncMock(),
plugin_connector=SimpleNamespace(
handler=SimpleNamespace(ping=AsyncMock()), _current_execution_context=AsyncMock(return_value=CONTEXT)
),
runner_registry=SimpleNamespace(list_runners=AsyncMock(side_effect=ValueError('probe boot failure'))),
)
monkeypatch.setattr(boot, 'make_app', AsyncMock(return_value=app))
try:
with pytest.raises(ValueError, match='probe boot failure'):
await fixtures._boot_local_agent_app(tmp_path)
app.shutdown.assert_awaited_once()
assert not any(task.get_name() == 'local-agent-e2e-app' for task in asyncio.all_tasks())
finally:
tasks = [task for task in asyncio.all_tasks() if task.get_name() == 'local-agent-e2e-app']
for task in tasks:
task.cancel()
await asyncio.gather(*tasks, return_exceptions=True)
@@ -164,6 +164,7 @@ class FakeConversation:
def make_descriptor() -> RunnerDescriptor:
return RunnerDescriptor(
usages=['agent'],
id=RUNNER_ID,
source='plugin',
label={'en_US': 'Local Agent'},
+19
View File
@@ -55,6 +55,7 @@ class FakeApplication:
'id': 'plugin:langbot-team/LocalAgent/default',
'name': 'default',
'label': {'en_US': 'Local Agent'},
'usages': ['agent'],
'capabilities': {'streaming': True},
'permissions': {},
'config_schema': [],
@@ -68,6 +69,7 @@ class FakeApplication:
'id': 'plugin:alice/my-agent/custom',
'name': 'custom',
'label': {'en_US': 'Custom Agent'},
'usages': ['agent'],
'capabilities': {},
'permissions': {},
'config_schema': [{'name': 'param1', 'type': 'string'}],
@@ -308,6 +310,7 @@ class TestDescriptorValidation:
def test_validate_runner_descriptor(self):
"""Validate correctly built descriptor."""
descriptor = RunnerDescriptor(
usages=['agent'],
id='plugin:test/my-runner/default',
source='plugin',
label={'en_US': 'Test Runner'},
@@ -323,6 +326,7 @@ class TestDescriptorValidation:
def test_descriptor_capabilities(self):
"""Descriptor capability helper methods."""
descriptor = RunnerDescriptor(
usages=['agent'],
id='plugin:test/my-runner/default',
source='plugin',
label={'en_US': 'Test Runner'},
@@ -365,3 +369,18 @@ async def test_registry_filters_usages_without_splitting_component_identity():
assert [item.runner_name for item in processors] == ['events', 'both']
assert agents[-1].id == processors[-1].id
assert (await registry.get(TEST_CONTEXT, processors[0].id)).usages == ['event']
@pytest.mark.asyncio
async def test_discovery_rejects_runner_without_usage_declaration():
ap = FakeApplication()
original = ap.plugin_connector.list_runners
async def missing_usage(bound_plugins=None):
runners = await original(bound_plugins)
runners[0]['manifest'].pop('usages')
return runners
ap.plugin_connector.list_runners = missing_usage
runners = await RunnerRegistry(ap).list_runners(TEST_CONTEXT, use_cache=False)
assert [runner.id for runner in runners] == ['plugin:alice/my-agent/custom']
@@ -39,6 +39,7 @@ def make_descriptor(
permissions: dict | None = None,
) -> RunnerDescriptor:
return RunnerDescriptor(
usages=['agent'],
id=RUNNER_ID,
source='plugin',
label={'en_US': 'Test Runner'},
@@ -37,6 +37,7 @@ class FakeApplication:
def make_descriptor():
"""Create a test descriptor."""
return RunnerDescriptor(
usages=['agent'],
id='plugin:langbot-team/LocalAgent/default',
source='plugin',
label={'en_US': 'Local Agent', 'zh_Hans': '内置 Agent'},
@@ -25,6 +25,7 @@ from langbot.pkg.agent.runner.state_scope import (
def make_descriptor(runner_id: str = 'plugin:test/my-runner/default') -> RunnerDescriptor:
"""Create a test descriptor."""
return RunnerDescriptor(
usages=['agent'],
id=runner_id,
source='plugin',
label={'en_US': 'Test Runner'},
@@ -0,0 +1,75 @@
"""Validate subscription identity, workspace boundaries and persisted updates."""
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
from langbot.pkg.api.http.service.bot import BotService
from langbot.pkg.agent.runner.errors import RunnerNotFoundError
def make_service(agent=None):
service = BotService(
SimpleNamespace(
runner_registry=SimpleNamespace(
get=AsyncMock(
return_value=SimpleNamespace(
usages=['event'],
supported_event_patterns=['group.member_joined'],
)
)
)
)
)
service._get_agent_entity = AsyncMock(return_value=agent)
return service
async def test_prepare_accepts_configured_instance_and_preserves_input():
service = make_service(SimpleNamespace(kind='event_processor', component_ref='plugin:test/runner/default'))
payload = {'plugin_processors': [{'processor_uuid': 'processor', 'enabled': True, 'events': ['*']}]}
result = await service._prepare_bot_data('workspace', payload, include_uuid=False)
assert result == {'plugin_processors': [{'processor_uuid': 'processor', 'enabled': True}]}
assert payload['plugin_processors'][0]['events'] == ['*']
service._get_agent_entity.assert_awaited_once_with('workspace', 'processor')
service.ap.runner_registry.get.assert_awaited_once_with('workspace', 'plugin:test/runner/default')
@pytest.mark.parametrize(
'items,reason',
[
({}, 'must be an array'),
([None], 'must be an object'),
([{}], 'UUID is required'),
([{'processor_uuid': 'p', 'enabled': 'false'}], 'must be a boolean'),
([{'processor_uuid': 'p'}, {'processor_uuid': 'p'}], 'only be bound once'),
],
)
async def test_invalid_bindings_are_rejected(items, reason):
service = make_service(SimpleNamespace(kind='event_processor', component_ref='runner'))
with pytest.raises(ValueError, match=reason):
await service._normalize_plugin_processors('workspace', items)
@pytest.mark.parametrize('agent', [None, SimpleNamespace(kind='agent')])
async def test_missing_cross_workspace_or_wrong_kind_is_rejected(agent):
service = make_service(agent)
with pytest.raises(ValueError, match='not found'):
await service._normalize_plugin_processors('workspace', [{'processor_uuid': 'p'}])
async def test_can_disable_binding_when_plugin_is_unavailable():
service = make_service(SimpleNamespace(kind='event_processor', component_ref='missing'))
service.ap.runner_registry.get.side_effect = ValueError('not installed')
assert await service._normalize_plugin_processors('workspace', [{'processor_uuid': 'p', 'enabled': False}]) == [
{'processor_uuid': 'p', 'enabled': False},
]
service.ap.runner_registry.get.assert_not_called()
async def test_enabling_unavailable_runner_has_actionable_validation_error():
service = make_service(SimpleNamespace(kind='event_processor', component_ref='missing'))
service.ap.runner_registry.get.side_effect = RunnerNotFoundError('missing')
with pytest.raises(ValueError, match='Runner component is unavailable'):
await service._normalize_plugin_processors('workspace', [{'processor_uuid': 'p'}])
@@ -831,6 +831,7 @@ class TestSpaceServiceGetModelSelection:
{
'uuid': 'best-model',
'model_id': 'best-chat-model',
'listed_at': '2026-09-09T19:00:00.000929Z',
'provider': 'provider-1',
'category': 'chat',
'status': 'active',
@@ -847,7 +848,15 @@ class TestSpaceServiceGetModelSelection:
data = {'models': models}
elif response_shape == 'availability-wrapper':
data = [
{'model': model, 'latency_ms': index + 10, 'http_code': 200}
{
'model': model,
'availability': {
'up': True,
'last_probed_at': '2026-09-11T12:01:18Z',
'latency_ms': index + 10,
'http_code': 200,
},
}
for index, model in enumerate(models)
]
else:
@@ -870,11 +879,62 @@ class TestSpaceServiceGetModelSelection:
result = await service.get_model_selection('chat')
assert [model.uuid for model in result] == ['best-model', 'fallback-model']
assert result[0].model_dump()['listed_at'] == '2026-09-09T19:00:00.000929Z'
assert result[1].listed_at is None
if response_shape == 'availability-wrapper':
assert result[0].availability.up is True
assert result[0].availability.last_probed_at == '2026-09-11T12:01:18Z'
assert result[0].availability.latency_ms == 10
session.get.assert_called_once_with(
'https://space.langbot.app/api/v1/models/selection',
params={'category': 'chat'},
)
async def test_selection_without_category_fetches_all_model_statuses(self):
ap = SimpleNamespace(instance_config=SimpleNamespace(data={}))
service = SpaceService(ap)
payload = {
'code': 0,
'data': {
'models': [
{
'model': {
'uuid': 'embedding-model',
'model_id': 'text-embedding',
'category': 'embedding',
'input_credits': 20,
'output_credits': 40,
},
'availability': {'up': None, 'last_probed_at': None},
}
]
},
}
mock_response = MagicMock(status=200)
with (
patch('langbot.pkg.api.http.service.space.httpclient.get_session') as get_session,
patch(
'langbot.pkg.api.http.service.space.httpclient.read_json_limited',
new=AsyncMock(return_value=payload),
),
):
session = MagicMock()
session.get.return_value.__aenter__ = AsyncMock(return_value=mock_response)
session.get.return_value.__aexit__ = AsyncMock(return_value=None)
get_session.return_value = session
result = await service.get_model_selection()
assert result[0].category == 'embedding'
assert result[0].input_credits == 20
assert result[0].output_credits == 40
assert result[0].availability.up is None
session.get.assert_called_once_with(
'https://space.langbot.app/api/v1/models/selection',
params=None,
)
async def test_recommended_model_uses_first_selection_and_refreshes_once(self):
local_model = SimpleNamespace(uuid='local-model-uuid', name='best-chat-model')
persistence = SimpleNamespace(
@@ -26,6 +26,7 @@ class FakeRegistry:
def make_runner(runner_id: str, config_schema: list[dict]):
parts = runner_id.removeprefix('plugin:').split('/')
return RunnerDescriptor(
usages=['agent'],
id=runner_id,
source='plugin',
label={'en_US': runner_id},
@@ -0,0 +1,49 @@
"""Move existing plugin routes without discarding primary routes or disabled state."""
import importlib
from unittest.mock import patch
import pytest
import sqlalchemy as sa
from alembic.migration import MigrationContext
from alembic.operations import Operations
migration = importlib.import_module('langbot.pkg.persistence.alembic.versions.0025_bot_plugin_processors')
@pytest.mark.parametrize('column_exists', [False, True])
def test_migrate_duplicate_routes_and_preserve_primary_and_disabled(column_exists):
engine = sa.create_engine('sqlite://')
metadata = sa.MetaData()
columns = [sa.Column('uuid', sa.String(), primary_key=True), sa.Column('event_bindings', sa.JSON())]
if column_exists:
columns.append(sa.Column('plugin_processors', sa.JSON(), server_default='[]'))
table = sa.Table('bots', metadata, *columns)
metadata.create_all(engine)
primary = {'target_type': 'agent', 'target_uuid': 'agent', 'event_pattern': '*'}
with engine.begin() as conn:
conn.execute(
table.insert().values(
uuid='bot',
event_bindings=[
primary,
{'target_type': 'event_processor', 'target_uuid': 'one', 'enabled': False},
{'target_type': 'event_processor', 'target_uuid': 'one', 'enabled': True},
{'target_type': 'event_processor', 'target_uuid': 'two', 'enabled': False},
],
)
)
with patch.object(migration, 'op', Operations(MigrationContext.configure(conn))):
migration.upgrade()
migrated = sa.Table('bots', sa.MetaData(), autoload_with=conn)
row = conn.execute(sa.select(migrated)).mappings().one()
assert row['event_bindings'] == [primary]
assert row['plugin_processors'] == [
{'processor_uuid': 'one', 'enabled': True},
{'processor_uuid': 'two', 'enabled': False},
]
migration.upgrade()
assert conn.execute(sa.select(migrated)).mappings().one() == row
migration.downgrade()
assert 'plugin_processors' not in {c['name'] for c in sa.inspect(conn).get_columns('bots')}
engine.dispose()
@@ -32,6 +32,7 @@ RUNNER_ID = 'plugin:langbot-team/LocalAgent/default'
def attach_runner_descriptor(app):
descriptor = RunnerDescriptor(
usages=['agent'],
id=RUNNER_ID,
source='plugin',
label={'en_US': 'Local Agent'},
@@ -17,6 +17,7 @@ def _attach_runner_descriptor(app):
from langbot.pkg.agent.runner.descriptor import RunnerDescriptor
descriptor = RunnerDescriptor(
usages=['agent'],
id=RUNNER_ID,
source='plugin',
label={'en_US': 'Local Agent'},
@@ -0,0 +1,107 @@
"""Independent bot subscriptions must not change primary route delivery."""
import asyncio
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
from langbot_plugin.api.entities.builtin.platform.events import MemberJoinedEvent
from langbot.pkg.platform.botmgr import RuntimeBot
def make_bot(subscriptions):
bot = object.__new__(RuntimeBot)
bot.bot_entity = SimpleNamespace(uuid='bot', event_bindings=[], plugin_processors=subscriptions)
bot.execution_context = 'workspace'
bot._record_adapter_event = AsyncMock()
bot._record_event_route_trace = AsyncMock()
bot.logger = SimpleNamespace(error=AsyncMock())
return bot
async def test_slow_failed_and_duplicate_subscriptions_do_not_block_primary_route():
bot = make_bot(
[
{'processor_uuid': 'slow'},
{'processor_uuid': 'failed'},
{'processor_uuid': 'fast'},
{'processor_uuid': 'fast'},
{'processor_uuid': 'disabled', 'enabled': False},
]
)
started = []
release = asyncio.Event()
async def subscriber(event, adapter, uuid):
started.append(uuid)
if uuid == 'slow':
await release.wait()
if uuid == 'failed':
raise ValueError('plugin error')
async def primary(event, adapter):
started.append('primary')
bot._dispatch_plugin_subscription = subscriber
bot._dispatch_eba_event_to_processor = primary
task = asyncio.create_task(bot._handle_platform_event(MemberJoinedEvent(), None))
for _ in range(10):
await asyncio.sleep(0)
if len(started) == 4:
break
assert set(started) == {'primary', 'slow', 'failed', 'fast'}
assert started.count('fast') == 1
assert not task.done()
release.set()
await task
bot.logger.error.assert_awaited_once()
@pytest.mark.parametrize(
'patterns,event_usage,expected',
[
(['group.member_joined'], True, 1),
(['group.*'], True, 1),
(['*'], True, 1),
(['message.received'], True, 0),
([], True, 0),
(['*'], False, 0),
],
)
async def test_matching_uses_live_runner_declaration(patterns, event_usage, expected):
bot = make_bot([])
bot.ap = SimpleNamespace(
agent_service=SimpleNamespace(
get_agent=AsyncMock(
return_value={
'kind': 'event_processor',
'component_ref': 'plugin:test/runner/default',
'supported_event_patterns': ['stale.event'],
}
)
),
runner_registry=SimpleNamespace(
get=AsyncMock(
return_value=SimpleNamespace(
usages=['event'] if event_usage else ['agent'],
supported_event_patterns=patterns,
)
)
),
)
bot._dispatch_eba_event_to_processor = AsyncMock()
await bot._dispatch_plugin_subscription(MemberJoinedEvent(), None, 'processor')
assert bot._dispatch_eba_event_to_processor.await_count == expected
if expected:
args = bot._dispatch_eba_event_to_processor.await_args.args
assert args[2]['target_uuid'] == 'processor'
assert args[3]['supported_event_patterns'] == patterns
async def test_missing_processor_is_logged_without_unscoped_lookup():
bot = make_bot([])
bot.ap = SimpleNamespace(agent_service=SimpleNamespace(get_agent=AsyncMock(return_value=None)))
await bot._dispatch_plugin_subscription(MemberJoinedEvent(), None, 'missing')
bot.ap.agent_service.get_agent.assert_awaited_once_with('workspace', 'missing')
assert bot._record_event_route_trace.await_args.kwargs['status'] == 'failed'
@@ -712,19 +712,8 @@ async def test_installed_event_processor_never_receives_unbound_events():
async def test_bound_event_processor_receives_one_complete_typed_event():
from langbot_plugin.api.entities.builtin.platform.events import MemberJoinedEvent
bot = TestEventRouteTrace._make_bot(
[
{
'id': 'binding',
'enabled': True,
'event_pattern': 'group.member_joined',
'target_type': 'event_processor',
'target_uuid': 'processor-1',
'priority': 0,
'order': 0,
}
]
)
bot = TestEventRouteTrace._make_bot([])
bot.bot_entity.plugin_processors = [{'processor_uuid': 'processor-1', 'enabled': True}]
calls = []
async def run(envelope, binding, adapter_context=None):
@@ -748,6 +737,9 @@ async def test_bound_event_processor_receives_one_complete_typed_event():
agent_run_orchestrator=SimpleNamespace(run=run),
plugin_connector=SimpleNamespace(emit_event=AsyncMock()),
)
bot.ap.runner_registry = SimpleNamespace(
get=AsyncMock(return_value=SimpleNamespace(usages=['event'], supported_event_patterns=['group.member_joined']))
)
bot._record_adapter_event = AsyncMock()
await bot._handle_platform_event(
MemberJoinedEvent(
@@ -836,7 +828,9 @@ async def test_processor_outputs_require_explicit_platform_actions(kind, output_
chat_type=entities.ChatType.PRIVATE,
chat_id='user-1',
)
trace = await bot._dispatch_eba_event_to_processor(event, adapter)
trace = await bot._dispatch_eba_event_to_processor(
event, adapter, bot.bot_entity.event_bindings[0] if kind == 'event_processor' else None
)
assert trace['status'] == ('failed' if runner_fails else 'delivered')
if runner_fails:
+1
View File
@@ -80,6 +80,7 @@ def _make_app(*, skill_service) -> SimpleNamespace:
model = SimpleNamespace(model_entity=SimpleNamespace(uuid='model-1', abilities={'func_call'}))
tool_mgr = SimpleNamespace(get_resolved_tool_catalog=AsyncMock(return_value=[]))
descriptor = RunnerDescriptor(
usages=['agent'],
id=_RUNNER_ID,
source='plugin',
label={'en_US': 'Local Agent'},
Generated
+8 -5
View File
@@ -1999,7 +1999,7 @@ wheels = [
[[package]]
name = "langbot"
version = "4.11.0"
version = "4.11.0b1"
source = { editable = "." }
dependencies = [
{ name = "aiocqhttp" },
@@ -2119,7 +2119,7 @@ requires-dist = [
{ name = "ebooklib", specifier = ">=0.18" },
{ name = "gewechat-client", specifier = ">=0.1.5" },
{ name = "html2text", specifier = ">=2024.2.26" },
{ name = "langbot-plugin", git = "https://github.com/langbot-app/langbot-plugin-sdk?rev=eac2c60509534512f9a373cd0c801e75985e0612" },
{ name = "langbot-plugin", specifier = "==0.6.0b1" },
{ name = "langchain", specifier = ">=1.3.9" },
{ name = "langchain-core", specifier = ">=1.3.3" },
{ name = "langchain-text-splitters", specifier = ">=1.1.2" },
@@ -2171,7 +2171,6 @@ requires-dist = [
{ name = "valkey-glide", marker = "sys_platform != 'win32'", specifier = ">=2.4.1,<3.0.0" },
{ name = "websockets", specifier = ">=15.0.1" },
]
provides-extras = ["seekdb"]
[package.metadata.requires-dev]
dev = [
@@ -2185,8 +2184,8 @@ dev = [
[[package]]
name = "langbot-plugin"
version = "0.5.5"
source = { git = "https://github.com/langbot-app/langbot-plugin-sdk?rev=eac2c60509534512f9a373cd0c801e75985e0612#eac2c60509534512f9a373cd0c801e75985e0612" }
version = "0.6.0b1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "aiofiles" },
{ name = "aiohttp" },
@@ -2206,6 +2205,10 @@ dependencies = [
{ name = "watchdog" },
{ name = "websockets" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a4/6b/f4dcf44a681ea2bd02cab1a52ffd2599f7a423d7df29d3340e418c64d6ef/langbot_plugin-0.6.0b1.tar.gz", hash = "sha256:8bd100992acc10d80176588221b97e213f9d845d62b2e07847070ec5548c7921", size = 600194 }
wheels = [
{ url = "https://files.pythonhosted.org/packages/3e/ef/8d25a218cd05edf2d35a7985e1552226898e226ddb79aff8731f9073bf82/langbot_plugin-0.6.0b1-py3-none-any.whl", hash = "sha256:6a1282294cd991ea552ceaf563c0e4050e55821bb8333d3687df5fd3ec33983a", size = 400116 },
]
[[package]]
name = "langchain"
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
import { Link, useSearchParams } from 'react-router-dom';
import { eventPatternLabel } from '@/app/home/components/event-patterns/event-pattern-groups';
import { RefreshCw, Trash2, ScrollText, Settings2 } from 'lucide-react';
import isEqual from 'lodash/isEqual';
@@ -48,7 +48,10 @@ export default function PluginProcessorDetailContent({
onSaved: () => void;
}) {
const { t } = useTranslation();
const [activeTab, setActiveTab] = useState('config');
const [searchParams] = useSearchParams();
const [activeTab, setActiveTab] = useState(
searchParams.get('tab') === 'logs' ? 'logs' : 'config',
);
const [platformTools, setPlatformTools] = useState<AgentPlatformTool[]>([]);
const toolLabels = Object.fromEntries(
platformTools.map((tool) => [tool.name, extractI18nObject(tool.label)]),
@@ -90,18 +90,18 @@ export default function AgentCreateContent({
}
const typeOptions = [
{
kind: 'agent' as const,
icon: Bot,
title: t('agents.agentType'),
description: t('agents.agentTypeDescription'),
},
{
kind: 'pipeline' as const,
icon: Workflow,
title: t('agents.pipelineType'),
description: t('agents.pipelineTypeDescription'),
},
{
kind: 'agent' as const,
icon: Bot,
title: t('agents.agentType'),
description: t('agents.agentTypeDescription'),
},
{
kind: 'event_processor' as const,
icon: Puzzle,
@@ -115,7 +115,7 @@ export default function PluginProcessorSettings({
<div className="p-2 text-sm text-muted-foreground">
{t('agents.eventProcessor.noComponents')}
<Button asChild variant="link" className="h-auto px-0">
<Link to="/home/plugins">
<Link to="/home/add-extension?type=plugin&component=Runner&runner_usage=event">
{t('agents.eventProcessor.installPlugin')}
</Link>
</Button>
@@ -227,7 +227,7 @@ export default function RunnerSelect({
setCatalogLoading(true);
setCatalogError(false);
try {
const catalog = await loadRunnerCatalog();
const catalog = await loadRunnerCatalog('agent');
setMarketplaceRunners(catalog.marketplaceRunners);
setInstalledPluginIds(catalog.installedPluginIds);
setInstalledPluginDescriptions(catalog.installedPluginDescriptions);
@@ -392,7 +392,7 @@ export default function RunnerSelect({
{t('agents.marketplaceRunners')}
</span>
<a
href="https://space.langbot.app/market?type=plugin&component=Runner"
href="https://space.langbot.app/market?type=plugin&component=Runner&runner_usage=agent"
target="_blank"
rel="noreferrer"
className="inline-flex shrink-0 items-center gap-1 rounded border px-1.5 py-0.5 text-[10px] font-medium text-foreground hover:bg-accent"
+11 -3
View File
@@ -3,7 +3,11 @@ import { getCloudServiceClient } from '@/app/infra/http';
import { getActiveWorkspaceUuid } from '@/app/infra/http/workspaceContext';
import type { IDynamicFormItemOption } from '@/app/infra/entities/form/dynamic';
import type { PipelineConfigTab } from '@/app/infra/entities/pipeline';
import type { PluginV4 } from '@/app/infra/entities/plugin';
import {
supportsRunnerUsage,
type PluginV4,
type RunnerUsage,
} from '@/app/infra/entities/plugin';
import type { I18nObject } from '@/app/infra/entities/common';
export const RUNNER_COMPONENT_FILTER = 'Runner';
@@ -140,7 +144,9 @@ export function subscribePendingRunnerInstall(
window.removeEventListener(RUNNER_INSTALL_INTENT_EVENT, handleChange);
}
export async function loadRunnerCatalog(): Promise<RunnerCatalog> {
export async function loadRunnerCatalog(
usage: RunnerUsage,
): Promise<RunnerCatalog> {
const cloudClient = await getCloudServiceClient();
const [firstSearchResult, recommendationResult, installedResult] =
await Promise.all([
@@ -150,6 +156,7 @@ export async function loadRunnerCatalog(): Promise<RunnerCatalog> {
page_size: RUNNER_CATALOG_PAGE_SIZE,
type_filter: 'plugin',
component_filter: RUNNER_COMPONENT_FILTER,
runner_usage: usage,
}),
cloudClient.getRecommendationLists().catch(() => ({ lists: [] })),
httpClient.getPlugins().catch(() => ({ plugins: [] })),
@@ -167,6 +174,7 @@ export async function loadRunnerCatalog(): Promise<RunnerCatalog> {
page_size: RUNNER_CATALOG_PAGE_SIZE,
type_filter: 'plugin',
component_filter: RUNNER_COMPONENT_FILTER,
runner_usage: usage,
}),
),
);
@@ -189,7 +197,7 @@ export async function loadRunnerCatalog(): Promise<RunnerCatalog> {
}
const marketplaceRunners = catalogPlugins
.filter((plugin) => plugin.components?.[RUNNER_COMPONENT_FILTER])
.filter((plugin) => supportsRunnerUsage(plugin, usage))
.sort((left, right) => {
const leftOrder = recommendationOrder.get(marketplacePluginId(left));
const rightOrder = recommendationOrder.get(marketplacePluginId(right));
@@ -24,6 +24,7 @@ import { getAdapterDocUrl } from '@/app/infra/entities/adapter-docs';
import { ExternalLink, ChevronDown, ChevronRight } from 'lucide-react';
import { cn } from '@/lib/utils';
import EventBindingsEditor from './EventBindingsEditor';
import PluginProcessorBindings from './PluginProcessorBindings';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
@@ -71,6 +72,9 @@ const getFormSchema = (t: (key: string) => string) =>
adapter: z.string().min(1, { message: t('bots.adapterRequired') }),
adapter_config: z.record(z.string(), z.any()),
enable: z.boolean().optional(),
plugin_processors: z
.array(z.object({ processor_uuid: z.string(), enabled: z.boolean() }))
.optional(),
event_bindings: z
.array(
z.object({
@@ -127,6 +131,7 @@ const BotForm = forwardRef<BotFormHandle, BotFormProps>(function BotForm(
adapter_config: {},
enable: true,
event_bindings: [],
plugin_processors: [],
},
});
@@ -237,6 +242,7 @@ const BotForm = forwardRef<BotFormHandle, BotFormProps>(function BotForm(
adapter_config: val.adapter_config,
enable: val.enable,
event_bindings: val.event_bindings || [],
plugin_processors: val.plugin_processors || [],
});
handleAdapterSelect(val.adapter);
@@ -360,6 +366,7 @@ const BotForm = forwardRef<BotFormHandle, BotFormProps>(function BotForm(
adapter_config: bot.adapter_config,
enable: bot.enable ?? true,
event_bindings: bot.event_bindings ?? [],
plugin_processors: bot.plugin_processors ?? [],
webhook_full_url: runtimeValues?.webhook_full_url as
| string
| undefined,
@@ -404,6 +411,7 @@ const BotForm = forwardRef<BotFormHandle, BotFormProps>(function BotForm(
adapter_config: form.getValues().adapter_config,
enable: form.getValues().enable,
event_bindings: form.getValues().event_bindings ?? [],
plugin_processors: form.getValues().plugin_processors ?? [],
};
httpClient
.updateBot(initBotId, updateBot)
@@ -427,6 +435,7 @@ const BotForm = forwardRef<BotFormHandle, BotFormProps>(function BotForm(
adapter_config: form.getValues().adapter_config,
enable: form.getValues().enable,
event_bindings: form.getValues().event_bindings ?? [],
plugin_processors: form.getValues().plugin_processors ?? [],
};
httpClient
.createBot(newBot)
@@ -752,7 +761,21 @@ const BotForm = forwardRef<BotFormHandle, BotFormProps>(function BotForm(
form={form}
botId={initBotId}
supportedEvents={adapterSupportedEvents[currentAdapter] || []}
agentOptions={agentNameList}
agentOptions={agentNameList.filter(
(agent) => agent.kind !== 'event_processor',
)}
/>
<PluginProcessorBindings
value={form.watch('plugin_processors') ?? []}
onChange={(value) =>
form.setValue('plugin_processors', value, {
shouldDirty: true,
})
}
agents={agentNameList}
onCreated={(agent) =>
setAgentNameList((items) => [...items, agent])
}
/>
</CardContent>
</Card>
@@ -519,11 +519,6 @@ function TargetCombobox({
const pipelines = pipelineAllowed
? agentOptions.filter((a) => a.kind === 'pipeline')
: [];
const eventProcessors = agentOptions.filter(
(item) =>
item.kind === 'event_processor' &&
agentSupportsEventPattern(item, binding.event_pattern),
);
function currentLabel() {
if (targetType === 'discard')
@@ -593,26 +588,6 @@ function TargetCombobox({
))}
</CommandGroup>
)}
{eventProcessors.length > 0 && (
<CommandGroup heading={t('agents.eventProcessor.type')}>
{eventProcessors.map((item) => (
<CommandItem
key={item.uuid}
value={`event_processor:${item.uuid}:${item.name}`}
onSelect={() =>
select(encodeTarget('event_processor', item.uuid || ''))
}
>
<FileCode2 className="mr-2 size-3.5 shrink-0" />
<span className="truncate">{targetLabel(item)}</span>
{current ===
encodeTarget('event_processor', item.uuid || '') && (
<Check className="ml-auto size-3.5 shrink-0" />
)}
</CommandItem>
))}
</CommandGroup>
)}
{pipelines.length > 0 && (
<CommandGroup heading={t('bots.targetPipeline')}>
{pipelines.map((a) => (
@@ -0,0 +1,466 @@
import { useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Link } from 'react-router-dom';
import { Check, Plus, Settings2, ScrollText, X } from 'lucide-react';
import { toast } from 'sonner';
import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext';
import type {
Agent,
PluginProcessorBinding,
RunnerDescriptor,
} from '@/app/infra/entities/api';
import { httpClient } from '@/app/infra/http';
import { extractI18nObject } from '@/i18n/I18nProvider';
import { eventPatternLabel } from '@/app/home/components/event-patterns/event-pattern-groups';
import DynamicFormComponent from '@/app/home/components/dynamic-form/DynamicFormComponent';
import { AuthenticatedPluginIcon } from '@/components/AuthenticatedPluginIcon';
import { Checkbox } from '@/components/ui/checkbox';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { Switch } from '@/components/ui/switch';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from '@/components/ui/dialog';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
export default function PluginProcessorBindings({
value,
onChange,
agents,
onCreated,
}: {
value: PluginProcessorBinding[];
onChange: (value: PluginProcessorBinding[]) => void;
agents: Agent[];
onCreated: (agent: Agent) => void;
}) {
const { t } = useTranslation();
const { refreshPipelines } = useSidebarData();
const [open, setOpen] = useState(false);
const [mode, setMode] = useState('existing');
const [selected, setSelected] = useState<string[]>([]);
const [components, setComponents] = useState<RunnerDescriptor[]>([]);
const [componentRef, setComponentRef] = useState('');
const [name, setName] = useState('');
const [parameters, setParameters] = useState<Record<string, unknown>>({});
const [busy, setBusy] = useState(false);
const submitting = useRef(false);
const validate = useRef<(() => Promise<boolean>) | null>(null);
const component = components.find((item) => item.id === componentRef);
const available = agents.filter(
(agent) =>
agent.kind === 'event_processor' &&
agent.component_ref &&
!value.some((item) => item.processor_uuid === agent.uuid),
);
async function showDialog() {
setSelected([]);
setMode(available.length ? 'existing' : 'new');
setOpen(true);
try {
const metadata = await httpClient.getAgentMetadata();
setComponents(metadata.event_processors ?? []);
} catch {
toast.error(t('agents.eventProcessor.loadError'));
}
}
async function add() {
if (submitting.current) return;
if (mode === 'existing') {
if (!selected.length) return;
onChange([
...value,
...selected.map((processor_uuid) => ({
processor_uuid,
enabled: true,
})),
]);
setOpen(false);
return;
}
if (!component || !name.trim()) return;
submitting.current = true;
setBusy(true);
try {
if (!((await validate.current?.()) ?? true)) return;
const agent: Agent = {
name: name.trim(),
description: '',
emoji: '🧩',
kind: 'event_processor',
component_ref: componentRef,
config: {
runner: { id: componentRef },
runner_config: { [componentRef]: parameters },
},
};
const result = await httpClient.createAgent(agent);
onCreated({
...agent,
uuid: result.uuid,
supported_event_patterns: component.supported_event_patterns,
});
onChange([...value, { processor_uuid: result.uuid, enabled: true }]);
void refreshPipelines();
setOpen(false);
setName('');
setComponentRef('');
setParameters({});
toast.success(t('bots.pluginSubscriptions.created'));
} catch (error) {
toast.error(
t('agents.createError') +
((error as { msg?: string }).msg ??
t('agents.eventProcessor.loadError')),
);
} finally {
submitting.current = false;
setBusy(false);
}
}
return (
<section
className="mt-6 space-y-3 border-t pt-5"
aria-labelledby="plugin-subscriptions-title"
>
<h3
id="plugin-subscriptions-title"
className="text-sm font-semibold text-foreground"
>
{t('agents.eventProcessor.type')}
</h3>
<p className="text-sm text-muted-foreground">
{t('bots.pluginSubscriptions.description')}
</p>
{value.length === 0 && (
<div className="flex h-32 items-center justify-center rounded-lg border-2 border-dashed border-border">
<p className="text-sm text-muted-foreground">
{t('bots.pluginSubscriptions.empty')}
</p>
</div>
)}
{value.map((binding) => {
const agent = agents.find(
(item) => item.uuid === binding.processor_uuid,
);
const title = agent?.name ?? t('agents.eventProcessor.unavailable');
return (
<Card
key={binding.processor_uuid}
className="gap-0 rounded-lg py-0 shadow-none hover:bg-accent"
>
<CardContent className="flex items-center gap-3 p-3">
<span
className="flex size-10 shrink-0 items-center justify-center rounded-lg border bg-muted text-2xl"
aria-hidden="true"
>
{agent?.emoji || '🧩'}
</span>
<div className="min-w-0 flex-1">
<div className="truncate font-medium">{title}</div>
<p
className="truncate text-sm text-muted-foreground"
title={agent?.component_ref?.replace('plugin:', '')}
>
{agent?.component_ref?.replace('plugin:', '')}
</p>
<p
className="truncate text-xs text-muted-foreground"
title={(agent?.supported_event_patterns ?? [])
.map((pattern) => eventPatternLabel(pattern, t))
.join(' · ')}
>
{(agent?.supported_event_patterns ?? [])
.map((pattern) => eventPatternLabel(pattern, t))
.join(' · ') || t('agents.eventProcessor.unavailable')}
</p>
</div>
<div className="flex shrink-0 items-center gap-1">
{agent &&
(
[
['config', Settings2, 'configure'],
['logs', ScrollText, 'logs'],
] as const
).map(([tab, Icon, key]) => (
<Tooltip key={tab}>
<TooltipTrigger asChild>
<Button
asChild
variant="ghost"
size="icon"
className="size-8"
>
<Link
target="_blank"
rel="noopener noreferrer"
to={`/home/agents?id=${agent.uuid}&tab=${tab}`}
aria-label={t(`bots.pluginSubscriptions.${key}`)}
>
<Icon className="size-4" />
</Link>
</Button>
</TooltipTrigger>
<TooltipContent>
{t(`bots.pluginSubscriptions.${key}`)}
</TooltipContent>
</Tooltip>
))}
<Switch
aria-label={t('bots.pluginSubscriptions.enable', {
name: title,
})}
checked={binding.enabled}
onCheckedChange={(enabled) =>
onChange(
value.map((item) =>
item.processor_uuid === binding.processor_uuid
? { ...item, enabled }
: item,
),
)
}
/>
<Button
type="button"
size="icon"
variant="ghost"
aria-label={t('bots.pluginSubscriptions.remove', {
name: title,
})}
onClick={() =>
onChange(
value.filter(
(item) =>
item.processor_uuid !== binding.processor_uuid,
),
)
}
>
<X className="size-4" />
</Button>
</div>
</CardContent>
</Card>
);
})}
<Button
type="button"
variant="outline"
className="w-full"
onClick={showDialog}
>
<Plus className="size-4" />
{t('bots.pluginSubscriptions.add')}
</Button>
<Dialog
open={open}
onOpenChange={(next) => {
if (!busy) setOpen(next);
}}
>
<DialogContent className="flex max-h-[80vh] flex-col overflow-hidden sm:max-w-2xl">
<DialogHeader>
<DialogTitle>{t('bots.pluginSubscriptions.add')}</DialogTitle>
<DialogDescription>
{t('bots.pluginSubscriptions.saveHint')}
</DialogDescription>
</DialogHeader>
<Tabs
value={mode}
onValueChange={setMode}
className="min-h-0 overflow-y-auto"
>
<TabsList className="w-full">
<TabsTrigger value="existing" disabled={busy}>
{t('bots.pluginSubscriptions.existing')}
</TabsTrigger>
<TabsTrigger value="new" disabled={busy}>
{t('bots.pluginSubscriptions.new')}
</TabsTrigger>
</TabsList>
<TabsContent value="existing" className="space-y-3">
<div className="max-h-80 space-y-2 overflow-y-auto">
{available.map((agent) => (
<label
key={agent.uuid}
className="flex cursor-pointer items-center gap-3 rounded-lg border p-3 hover:bg-accent"
>
<Checkbox
checked={selected.includes(agent.uuid!)}
onCheckedChange={(checked) =>
setSelected((current) =>
checked
? [...current, agent.uuid!]
: current.filter((id) => id !== agent.uuid),
)
}
/>
<span
className="flex size-10 shrink-0 items-center justify-center rounded-lg border bg-muted text-2xl"
aria-hidden="true"
>
{agent.emoji || '🧩'}
</span>
<span className="min-w-0 flex-1">
<span className="block truncate font-medium">
{agent.name}
</span>
<span className="block truncate text-sm text-muted-foreground">
{agent.component_ref?.replace('plugin:', '')}
</span>
<span className="block truncate text-xs text-muted-foreground">
{(agent.supported_event_patterns ?? [])
.map((pattern) => eventPatternLabel(pattern, t))
.join(' · ')}
</span>
</span>
</label>
))}
</div>
{available.length === 0 && (
<p className="text-sm text-muted-foreground">
{t('bots.pluginSubscriptions.noExisting')}
</p>
)}
<p className="text-sm text-muted-foreground">
{t('bots.pluginSubscriptions.shared')}
</p>
</TabsContent>
<TabsContent value="new">
<fieldset disabled={busy} className="space-y-4">
<div
className="max-h-60 space-y-2 overflow-y-auto"
role="group"
aria-label={t('agents.eventProcessor.component')}
>
{components.map((descriptor) => {
const label = extractI18nObject({
en_US: descriptor.id,
zh_Hans: descriptor.id,
...descriptor.label,
});
return (
<Button
key={descriptor.id}
type="button"
variant="outline"
aria-pressed={componentRef === descriptor.id}
className="h-auto w-full justify-start gap-3 whitespace-normal p-3 text-left font-normal shadow-none aria-pressed:bg-accent"
onClick={() => {
setComponentRef(descriptor.id);
validate.current = null;
setParameters(
Object.fromEntries(
(descriptor.config_schema ?? [])
.filter((field) => field.default !== undefined)
.map((field) => [field.name, field.default]),
),
);
if (!name.trim()) setName(label);
}}
>
<AuthenticatedPluginIcon
author={descriptor.plugin_author}
name={descriptor.plugin_name}
className="size-10 shrink-0 rounded-lg border bg-muted object-cover"
/>
<span className="min-w-0 flex-1">
<span className="block truncate font-medium">
{label}
</span>
<span className="block truncate text-sm text-muted-foreground">
{descriptor.plugin_author}/{descriptor.plugin_name}
</span>
<span className="block truncate text-xs text-muted-foreground">
{descriptor.supported_event_patterns
.map((pattern) => eventPatternLabel(pattern, t))
.join(' · ')}
</span>
</span>
{componentRef === descriptor.id && (
<Check className="size-4 shrink-0" />
)}
</Button>
);
})}
{components.length === 0 && (
<p className="py-6 text-center text-sm text-muted-foreground">
{t('agents.eventProcessor.noComponents')}
</p>
)}
</div>
{component && (
<div className="space-y-2">
<Label htmlFor="new-plugin-processor-name">
{t('common.name')}
</Label>
<Input
id="new-plugin-processor-name"
value={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
)}
{component && (
<DynamicFormComponent
key={componentRef}
itemConfigList={component.config_schema}
initialValues={parameters}
onSubmit={(values) =>
setParameters(values as Record<string, unknown>)
}
onValidate={(fn) => {
validate.current = fn;
}}
/>
)}
</fieldset>
</TabsContent>
</Tabs>
<DialogFooter>
<Button
type="button"
variant="outline"
disabled={busy}
onClick={() => setOpen(false)}
>
{t('common.cancel')}
</Button>
<Button
type="button"
disabled={
busy ||
(mode === 'existing'
? !selected.length
: !component || !name.trim())
}
onClick={add}
>
{t(
mode === 'existing'
? 'bots.pluginSubscriptions.add'
: 'bots.pluginSubscriptions.createAndBind',
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</section>
);
}
@@ -46,6 +46,7 @@ import {
Eye,
EyeOff,
Wrench,
BrainCircuit,
Trash2,
Sparkles,
Info,
@@ -71,6 +72,13 @@ import { LANGBOT_MODELS_PROVIDER_REQUESTER } from '@/app/home/components/models-
import ReasoningLevelPicker, {
REASONING_LEVELS,
} from '@/app/home/components/reasoning/ReasoningLevelPicker';
import LangBotModelMetadata from '@/app/home/components/model-availability/LangBotModelMetadata';
import { sortModelsByCatalog } from '@/app/home/components/model-availability/sort-models';
import { useLangBotModelAvailability } from '@/app/home/components/model-availability/useLangBotModelAvailability';
const MODEL_SELECT_TRIGGER_CLASS =
'w-full min-w-0 bg-[#ffffff] dark:bg-[#2a2a2e] *:data-[slot=select-value]:min-w-0 *:data-[slot=select-value]:flex-1';
const MODEL_SELECT_ITEM_CLASS = '*:[span]:last:min-w-0 *:[span]:last:flex-1';
function hasUsableUuid<T extends { uuid?: string | null }>(
item: T,
@@ -153,6 +161,54 @@ export default function DynamicFormItemComponent({
const [modelsDialogOpen, setModelsDialogOpen] = useState(false);
const [settingsSection, setSettingsSection] =
useState<SettingsSection>('models');
const isModelSelector = [
DynamicFormItemType.LLM_MODEL_SELECTOR,
DynamicFormItemType.EMBEDDING_MODEL_SELECTOR,
DynamicFormItemType.RERANK_MODEL_SELECTOR,
DynamicFormItemType.MODEL_FALLBACK_SELECTOR,
].includes(config.type);
const {
metadata: langbotModelMetadata,
loaded: langbotModelAvailabilityLoaded,
} = useLangBotModelAvailability(
isModelSelector && !systemInfo.disable_models_service,
);
const renderModelOption = (model: {
uuid: string;
name: string;
abilities?: string[];
reasoning_capabilities?: { supported?: boolean };
provider?: { requester?: string };
}) => (
<span className="grid w-full min-w-0 grid-cols-[minmax(0,1fr)_auto] items-center gap-3">
<span className="inline-flex min-w-0 items-center gap-1">
<span className="truncate">{model.name}</span>
{model.abilities?.includes('vision') && (
<Eye className="h-3 w-3 text-muted-foreground" />
)}
{model.abilities?.includes('func_call') && (
<Wrench className="h-3 w-3 text-muted-foreground" />
)}
{(model.reasoning_capabilities?.supported === true ||
model.abilities?.includes('reasoning')) && (
<BrainCircuit
className="h-3 w-3 shrink-0 text-muted-foreground"
aria-label={t('models.reasoningAbility')}
/>
)}
</span>
{model.provider?.requester === LANGBOT_MODELS_PROVIDER_REQUESTER && (
<LangBotModelMetadata
metadata={
langbotModelMetadata[model.uuid] ?? langbotModelMetadata[model.name]
}
loaded={langbotModelAvailabilityLoaded}
compact
/>
)}
</span>
);
const fetchLlmModels = () => {
httpClient
@@ -529,8 +585,11 @@ export default function DynamicFormItemComponent({
case DynamicFormItemType.LLM_MODEL_SELECTOR:
// Separate space models from regular models
const spaceModels = llmModels.filter(
(m) => m.provider?.requester === LANGBOT_MODELS_PROVIDER_REQUESTER,
const spaceModels = sortModelsByCatalog(
llmModels.filter(
(m) => m.provider?.requester === LANGBOT_MODELS_PROVIDER_REQUESTER,
),
langbotModelMetadata,
);
const regularModels = llmModels.filter(
(m) => m.provider?.requester !== LANGBOT_MODELS_PROVIDER_REQUESTER,
@@ -573,7 +632,7 @@ export default function DynamicFormItemComponent({
<div className="flex w-full max-w-md min-w-0 items-center gap-1.5">
<div className="min-w-0 flex-1">
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger className="min-w-0 bg-[#ffffff] dark:bg-[#2a2a2e]">
<SelectTrigger className={MODEL_SELECT_TRIGGER_CLASS}>
<SelectValue placeholder={t('models.selectModel')} />
</SelectTrigger>
<SelectContent>
@@ -581,16 +640,12 @@ export default function DynamicFormItemComponent({
<SelectGroup key={providerName}>
<SelectLabel>{providerName}</SelectLabel>
{models.map((model) => (
<SelectItem key={model.uuid} value={model.uuid}>
<span className="inline-flex items-center gap-1">
{model.name}
{model.abilities?.includes('vision') && (
<Eye className="h-3 w-3 text-muted-foreground" />
)}
{model.abilities?.includes('func_call') && (
<Wrench className="h-3 w-3 text-muted-foreground" />
)}
</span>
<SelectItem
key={model.uuid}
value={model.uuid}
className={MODEL_SELECT_ITEM_CLASS}
>
{renderModelOption(model)}
</SelectItem>
))}
</SelectGroup>
@@ -685,16 +740,12 @@ export default function DynamicFormItemComponent({
</span>
</SelectLabel>
{models.map((model) => (
<SelectItem key={model.uuid} value={model.uuid}>
<span className="inline-flex items-center gap-1">
{model.name}
{model.abilities?.includes('vision') && (
<Eye className="h-3 w-3 text-muted-foreground" />
)}
{model.abilities?.includes('func_call') && (
<Wrench className="h-3 w-3 text-muted-foreground" />
)}
</span>
<SelectItem
key={model.uuid}
value={model.uuid}
className={MODEL_SELECT_ITEM_CLASS}
>
{renderModelOption(model)}
</SelectItem>
))}
</SelectGroup>
@@ -731,8 +782,11 @@ export default function DynamicFormItemComponent({
);
case DynamicFormItemType.EMBEDDING_MODEL_SELECTOR: {
const spaceEmbeddingModels = embeddingModels.filter(
(m) => m.provider?.requester === LANGBOT_MODELS_PROVIDER_REQUESTER,
const spaceEmbeddingModels = sortModelsByCatalog(
embeddingModels.filter(
(m) => m.provider?.requester === LANGBOT_MODELS_PROVIDER_REQUESTER,
),
langbotModelMetadata,
);
const regularEmbeddingModels = embeddingModels.filter(
(m) => m.provider?.requester !== LANGBOT_MODELS_PROVIDER_REQUESTER,
@@ -771,7 +825,7 @@ export default function DynamicFormItemComponent({
<div className="flex w-full max-w-md min-w-0 items-center gap-1.5">
<div className="min-w-0 flex-1">
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger className="min-w-0 bg-[#ffffff] dark:bg-[#2a2a2e]">
<SelectTrigger className={MODEL_SELECT_TRIGGER_CLASS}>
<SelectValue
placeholder={t('knowledge.selectEmbeddingModel')}
/>
@@ -782,8 +836,12 @@ export default function DynamicFormItemComponent({
<SelectGroup key={providerName}>
<SelectLabel>{providerName}</SelectLabel>
{models.map((model) => (
<SelectItem key={model.uuid} value={model.uuid}>
{model.name}
<SelectItem
key={model.uuid}
value={model.uuid}
className={MODEL_SELECT_ITEM_CLASS}
>
{renderModelOption(model)}
</SelectItem>
))}
</SelectGroup>
@@ -874,8 +932,12 @@ export default function DynamicFormItemComponent({
</span>
</SelectLabel>
{models.map((model) => (
<SelectItem key={model.uuid} value={model.uuid}>
{model.name}
<SelectItem
key={model.uuid}
value={model.uuid}
className={MODEL_SELECT_ITEM_CLASS}
>
{renderModelOption(model)}
</SelectItem>
))}
</SelectGroup>
@@ -922,6 +984,18 @@ export default function DynamicFormItemComponent({
},
{} as Record<string, RerankModel[]>,
);
for (const [providerName, models] of Object.entries(
groupedRerankModels,
)) {
if (
models[0]?.provider?.requester === LANGBOT_MODELS_PROVIDER_REQUESTER
) {
groupedRerankModels[providerName] = sortModelsByCatalog(
models,
langbotModelMetadata,
);
}
}
return (
<div className="w-full max-w-md min-w-0">
@@ -929,7 +1003,7 @@ export default function DynamicFormItemComponent({
value={field.value || '__none__'}
onValueChange={(v) => field.onChange(v === '__none__' ? '' : v)}
>
<SelectTrigger className="min-w-0 bg-[#ffffff] dark:bg-[#2a2a2e]">
<SelectTrigger className={MODEL_SELECT_TRIGGER_CLASS}>
<SelectValue placeholder={t('models.rerank')} />
</SelectTrigger>
<SelectContent>
@@ -939,8 +1013,12 @@ export default function DynamicFormItemComponent({
<SelectGroup key={providerName}>
<SelectLabel>{providerName}</SelectLabel>
{models.map((model) => (
<SelectItem key={model.uuid} value={model.uuid}>
{model.name}
<SelectItem
key={model.uuid}
value={model.uuid}
className={MODEL_SELECT_ITEM_CLASS}
>
{renderModelOption(model)}
</SelectItem>
))}
</SelectGroup>
@@ -953,8 +1031,11 @@ export default function DynamicFormItemComponent({
case DynamicFormItemType.MODEL_FALLBACK_SELECTOR: {
// Separate space models from regular models
const fbSpaceModels = llmModels.filter(
(m) => m.provider?.requester === LANGBOT_MODELS_PROVIDER_REQUESTER,
const fbSpaceModels = sortModelsByCatalog(
llmModels.filter(
(m) => m.provider?.requester === LANGBOT_MODELS_PROVIDER_REQUESTER,
),
langbotModelMetadata,
);
const fbRegularModels = llmModels.filter(
(m) => m.provider?.requester !== LANGBOT_MODELS_PROVIDER_REQUESTER,
@@ -1048,7 +1129,7 @@ export default function DynamicFormItemComponent({
placeholder: string,
) => (
<Select value={value} onValueChange={onChange}>
<SelectTrigger className="min-w-0 bg-[#ffffff] dark:bg-[#2a2a2e]">
<SelectTrigger className={MODEL_SELECT_TRIGGER_CLASS}>
<SelectValue placeholder={placeholder} />
</SelectTrigger>
<SelectContent>
@@ -1057,16 +1138,12 @@ export default function DynamicFormItemComponent({
<SelectGroup key={providerName}>
<SelectLabel>{providerName}</SelectLabel>
{models.map((model) => (
<SelectItem key={model.uuid} value={model.uuid}>
<span className="inline-flex items-center gap-1">
{model.name}
{model.abilities?.includes('vision') && (
<Eye className="h-3 w-3 text-muted-foreground" />
)}
{model.abilities?.includes('func_call') && (
<Wrench className="h-3 w-3 text-muted-foreground" />
)}
</span>
<SelectItem
key={model.uuid}
value={model.uuid}
className={MODEL_SELECT_ITEM_CLASS}
>
{renderModelOption(model)}
</SelectItem>
))}
</SelectGroup>
@@ -1162,16 +1239,12 @@ export default function DynamicFormItemComponent({
</span>
</SelectLabel>
{models.map((model) => (
<SelectItem key={model.uuid} value={model.uuid}>
<span className="inline-flex items-center gap-1">
{model.name}
{model.abilities?.includes('vision') && (
<Eye className="h-3 w-3 text-muted-foreground" />
)}
{model.abilities?.includes('func_call') && (
<Wrench className="h-3 w-3 text-muted-foreground" />
)}
</span>
<SelectItem
key={model.uuid}
value={model.uuid}
className={MODEL_SELECT_ITEM_CLASS}
>
{renderModelOption(model)}
</SelectItem>
))}
</SelectGroup>
@@ -739,7 +739,7 @@ function NavItems({
> = {
agent: 'agents.kindBadgeAgent',
pipeline: 'agents.kindBadgePipeline',
event_processor: 'agents.eventProcessor.type',
event_processor: 'agents.eventProcessor.configurations',
};
const groupOrder: Array<'plugin' | 'mcp' | 'skill'> = [
@@ -893,7 +893,7 @@ function NavItems({
className="ml-auto flex shrink-0 items-center text-muted-foreground"
title={
item.kind === 'event_processor'
? t('agents.eventProcessor.type')
? t('agents.eventProcessor.configurations')
: item.kind === 'pipeline'
? t('agents.kindBadgePipeline')
: t('agents.kindBadgeAgent')
@@ -0,0 +1,98 @@
import type { LangBotModelAvailabilityItem } from '@/app/infra/entities/api';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { Coins, TriangleAlert } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import ModelAvailabilityIndicator from './ModelAvailabilityIndicator';
interface LangBotModelMetadataProps {
metadata?: LangBotModelAvailabilityItem;
loaded: boolean;
compact?: boolean;
}
function formatCredits(value: number, locale: string): string {
if (value >= 1000) {
const thousands = value / 1000;
return `${thousands.toFixed(thousands >= 10 ? 0 : 1).replace(/\.0$/, '')}K`;
}
return new Intl.NumberFormat(locale, {
maximumFractionDigits: 2,
}).format(value);
}
export default function LangBotModelMetadata({
metadata,
loaded,
compact = false,
}: LangBotModelMetadataProps) {
const { t, i18n } = useTranslation();
if (!loaded) return null;
const inputCredits = metadata?.input_credits;
const outputCredits = metadata?.output_credits;
const hasPricing = inputCredits != null && outputCredits != null;
const input =
inputCredits != null ? formatCredits(inputCredits, i18n.language) : '';
const output =
outputCredits != null ? formatCredits(outputCredits, i18n.language) : '';
return (
<span className="ml-auto inline-flex shrink-0 items-center gap-2 pl-3">
{hasPricing ? (
<>
<Tooltip>
<TooltipTrigger asChild>
<span
className="inline-flex items-center gap-1 text-xs tabular-nums text-muted-foreground"
onMouseDown={(event) => event.preventDefault()}
>
<Coins className="size-3" />
{compact
? t('models.pricing.compact', { input, output })
: t('models.pricing.inline', { input, output })}
</span>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-64">
<div className="space-y-0.5">
<p>{t('models.pricing.title')}</p>
<p className="text-xs text-muted-foreground">
{t('models.pricing.input', {
credits: input,
})}
</p>
<p className="text-xs text-muted-foreground">
{t('models.pricing.output', {
credits: output,
})}
</p>
</div>
</TooltipContent>
</Tooltip>
<ModelAvailabilityIndicator
availability={metadata?.availability}
show={loaded}
/>
</>
) : (
<Tooltip>
<TooltipTrigger asChild>
<span
className="inline-flex size-4 shrink-0 items-center justify-center"
aria-label={t('models.pricing.unavailable')}
onMouseDown={(event) => event.preventDefault()}
>
<TriangleAlert className="size-3.5 text-amber-500" />
</span>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-64">
<p>{t('models.pricing.unavailable')}</p>
</TooltipContent>
</Tooltip>
)}
</span>
);
}
@@ -0,0 +1,67 @@
import type { LangBotModelAvailability } from '@/app/infra/entities/api';
import {
Tooltip,
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
import { useTranslation } from 'react-i18next';
interface ModelAvailabilityIndicatorProps {
availability?: LangBotModelAvailability;
show: boolean;
}
export default function ModelAvailabilityIndicator({
availability,
show,
}: ModelAvailabilityIndicatorProps) {
const { t, i18n } = useTranslation();
if (!show) return null;
const state = availability?.up;
const label =
state === true
? t('models.availability.available')
: state === false
? t('models.availability.unavailable')
: t('models.availability.notChecked');
const dotClass =
state === true
? 'bg-emerald-500'
: state === false
? 'bg-destructive'
: 'bg-muted-foreground/50';
const checkedAt = availability?.last_probed_at
? new Intl.DateTimeFormat(i18n.language, {
dateStyle: 'medium',
timeStyle: 'short',
}).format(new Date(availability.last_probed_at))
: null;
return (
<Tooltip>
<TooltipTrigger asChild>
<span
className="inline-flex size-4 shrink-0 items-center justify-center"
aria-label={label}
onMouseDown={(event) => event.preventDefault()}
>
<span className={`size-1.5 rounded-full ${dotClass}`} />
</span>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-64">
<div className="space-y-0.5">
<p>{label}</p>
{checkedAt && (
<p className="text-xs text-muted-foreground">
{t('models.availability.lastChecked', { time: checkedAt })}
{availability && availability.latency_ms > 0
? ` · ${availability.latency_ms} ms`
: ''}
</p>
)}
</div>
</TooltipContent>
</Tooltip>
);
}
@@ -0,0 +1,48 @@
import type { LangBotModelAvailabilityItem } from '@/app/infra/entities/api';
type CatalogModel = { uuid: string; name: string };
type MetadataMap = Record<string, LangBotModelAvailabilityItem>;
function listingDay(value?: string | null): number {
const timestamp = value ? Date.parse(value) : NaN;
// Use UTC calendar days so list order is consistent across user time zones.
return Number.isFinite(timestamp) ? Math.floor(timestamp / 86_400_000) : -1;
}
function availabilityRank(up?: boolean | null): number {
return up === true ? 0 : up == null ? 1 : 2;
}
function price(value?: number | null): number {
return value != null && Number.isFinite(value) && value >= 0
? value
: Infinity;
}
/** Sort one LangBot Models group without changing the source array. */
export function sortModelsByCatalog<T extends CatalogModel>(
models: readonly T[],
metadata: MetadataMap,
): T[] {
// Keep the existing order until catalog metadata is available.
if (Object.keys(metadata).length === 0) return [...models];
return [...models].sort((left, right) => {
const a = metadata[left.uuid] ?? metadata[left.name];
const b = metadata[right.uuid] ?? metadata[right.name];
const dateOrder = listingDay(b?.listed_at) - listingDay(a?.listed_at);
if (dateOrder) return dateOrder;
const statusOrder =
availabilityRank(a?.availability?.up) -
availabilityRank(b?.availability?.up);
if (statusOrder) return statusOrder;
for (const key of ['input_credits', 'output_credits'] as const) {
const aPrice = price(a?.[key]);
const bPrice = price(b?.[key]);
if (aPrice !== bPrice) return aPrice < bPrice ? -1 : 1;
}
return left.name < right.name ? -1 : left.name > right.name ? 1 : 0;
});
}
@@ -0,0 +1,63 @@
import { useEffect, useState } from 'react';
import type { LangBotModelAvailabilityItem } from '@/app/infra/entities/api';
import { httpClient } from '@/app/infra/http/HttpClient';
const CACHE_TTL_MS = 60_000;
type ModelMetadataMap = Record<string, LangBotModelAvailabilityItem>;
let cachedMetadata: ModelMetadataMap | null = null;
let cacheExpiresAt = 0;
let pendingRequest: Promise<ModelMetadataMap> | null = null;
async function loadMetadata(): Promise<ModelMetadataMap> {
if (cachedMetadata && Date.now() < cacheExpiresAt) {
return cachedMetadata;
}
if (pendingRequest) return pendingRequest;
pendingRequest = httpClient
.getLangBotModelAvailability()
.then((response) => {
const next: ModelMetadataMap = {};
for (const item of response.models) {
next[item.uuid] = item;
next[item.model_id] = item;
}
cachedMetadata = next;
cacheExpiresAt = Date.now() + CACHE_TTL_MS;
return next;
})
.finally(() => {
pendingRequest = null;
});
return pendingRequest;
}
export function useLangBotModelAvailability(enabled = true) {
const [metadata, setMetadata] = useState<ModelMetadataMap>(
cachedMetadata ?? {},
);
const [loaded, setLoaded] = useState(
cachedMetadata !== null && Date.now() < cacheExpiresAt,
);
useEffect(() => {
if (!enabled) return;
let active = true;
loadMetadata()
.then((result) => {
if (!active) return;
setMetadata(result);
setLoaded(true);
})
.catch(() => {
// Catalog metadata is supplementary; model configuration remains usable.
});
return () => {
active = false;
};
}, [enabled]);
return { metadata, loaded };
}
@@ -27,6 +27,7 @@ import { CustomApiError } from '@/app/infra/entities/common';
import { PanelBody } from '../settings-dialog/panel-layout';
import { useCurrentWorkspace } from '@/app/infra/http';
import type { WorkspaceSpaceBilling } from '@/app/infra/entities/workspace';
import { useLangBotModelAvailability } from '../model-availability/useLangBotModelAvailability';
interface ModelsPanelProps {
// True when this panel is the active section and the dialog is open.
@@ -89,6 +90,10 @@ export default function ModelsPanel({
const currentWorkspace = useCurrentWorkspace();
const canManage =
currentWorkspace?.permissions.includes('provider_secret.manage') ?? false;
const {
metadata: langbotModelMetadata,
loaded: langbotModelAvailabilityLoaded,
} = useLangBotModelAvailability(active && !systemInfo.disable_models_service);
const [providers, setProviders] = useState<ModelProvider[]>([]);
const [spaceBilling, setSpaceBilling] =
@@ -554,6 +559,8 @@ export default function ModelsPanel({
isWorkspaceOwner={currentWorkspace?.membership.role === 'owner'}
ownerSpaceBound={spaceBilling?.owner_space_bound ?? false}
spaceCredits={spaceBilling?.credits ?? null}
modelMetadata={langbotModelMetadata}
modelAvailabilityLoaded={langbotModelAvailabilityLoaded}
addModelPopoverOpen={addModelPopoverOpen}
editModelPopoverOpen={editModelPopoverOpen}
deleteConfirmOpen={deleteConfirmOpen}
@@ -14,6 +14,7 @@ import { useTranslation } from 'react-i18next';
import {
LLMModel,
EmbeddingModel,
LangBotModelAvailabilityItem,
ReasoningConfig,
} from '@/app/infra/entities/api';
import {
@@ -24,12 +25,15 @@ import {
} from '../types';
import ExtraArgsEditor from './ExtraArgsEditor';
import { userInfo } from '@/app/infra/http';
import LangBotModelMetadata from '../../model-availability/LangBotModelMetadata';
interface ModelItemProps {
model: LLMModel | EmbeddingModel;
canManage: boolean;
modelType: ModelType;
isLangBotModels: boolean;
metadata?: LangBotModelAvailabilityItem;
availabilityLoaded: boolean;
editModelPopoverOpen: string | null;
deleteConfirmOpen: string | null;
onOpenEditModel: (modelId: string) => void;
@@ -86,6 +90,8 @@ export default function ModelItem({
canManage,
modelType,
isLangBotModels,
metadata,
availabilityLoaded,
editModelPopoverOpen,
deleteConfirmOpen,
onOpenEditModel,
@@ -197,7 +203,7 @@ export default function ModelItem({
: 'hover:bg-accent cursor-pointer'
}`}
>
<div className="flex items-center gap-2 flex-wrap">
<div className="flex min-w-0 items-center gap-2 flex-wrap">
<span className="text-sm font-medium">{model.name}</span>
<Badge variant="secondary" className="text-xs">
{modelType === 'llm'
@@ -219,12 +225,21 @@ export default function ModelItem({
</Badge>
)}
{supportsReasoning && (
<Badge variant="outline" className="text-xs gap-1">
<Badge
variant="outline"
className="text-xs gap-1"
aria-label={t('models.reasoningAbility')}
>
<BrainCircuit className="h-3 w-3" />
{t('models.reasoningAbility')}
</Badge>
)}
</div>
{isLangBotModels && (
<LangBotModelMetadata
metadata={metadata}
loaded={availabilityLoaded}
/>
)}
{canManage && !isLangBotModels && (
<Popover
open={isDeleteOpen}
@@ -9,7 +9,11 @@ import {
Radar,
} from 'lucide-react';
import { httpClient, systemInfo } from '@/app/infra/http/HttpClient';
import { ModelProvider, ReasoningConfig } from '@/app/infra/entities/api';
import {
LangBotModelAvailabilityItem,
ModelProvider,
ReasoningConfig,
} from '@/app/infra/entities/api';
import { Button } from '@/components/ui/button';
import {
Collapsible,
@@ -34,6 +38,7 @@ import {
ProviderModels,
} from '../types';
import ModelItem from './ModelItem';
import { sortModelsByCatalog } from '../../model-availability/sort-models';
import AddModelPopover from './AddModelPopover';
interface ProviderCardProps {
@@ -47,6 +52,8 @@ interface ProviderCardProps {
isWorkspaceOwner: boolean;
ownerSpaceBound: boolean;
spaceCredits: number | null;
modelMetadata: Record<string, LangBotModelAvailabilityItem>;
modelAvailabilityLoaded: boolean;
// Popover states
addModelPopoverOpen: string | null;
editModelPopoverOpen: string | null;
@@ -115,6 +122,8 @@ export default function ProviderCard({
isWorkspaceOwner,
ownerSpaceBound,
spaceCredits,
modelMetadata,
modelAvailabilityLoaded,
addModelPopoverOpen,
editModelPopoverOpen,
deleteConfirmOpen,
@@ -417,13 +426,20 @@ export default function ProviderCard({
</p>
) : models ? (
<div className="space-y-2">
{models.llm.map((model) => (
{(isLangBotModels
? sortModelsByCatalog(models.llm, modelMetadata)
: models.llm
).map((model) => (
<ModelItem
key={model.uuid}
model={model}
canManage={canManage}
modelType="llm"
isLangBotModels={isLangBotModels}
metadata={
modelMetadata[model.uuid] ?? modelMetadata[model.name]
}
availabilityLoaded={modelAvailabilityLoaded}
editModelPopoverOpen={editModelPopoverOpen}
deleteConfirmOpen={deleteConfirmOpen}
onOpenEditModel={onOpenEditModel}
@@ -468,13 +484,20 @@ export default function ProviderCard({
onResetTestResult={onResetTestResult}
/>
))}
{models.embedding.map((model) => (
{(isLangBotModels
? sortModelsByCatalog(models.embedding, modelMetadata)
: models.embedding
).map((model) => (
<ModelItem
key={model.uuid}
model={model}
canManage={canManage}
modelType="embedding"
isLangBotModels={isLangBotModels}
metadata={
modelMetadata[model.uuid] ?? modelMetadata[model.name]
}
availabilityLoaded={modelAvailabilityLoaded}
editModelPopoverOpen={editModelPopoverOpen}
deleteConfirmOpen={deleteConfirmOpen}
onOpenEditModel={onOpenEditModel}
@@ -517,13 +540,20 @@ export default function ProviderCard({
onResetTestResult={onResetTestResult}
/>
))}
{models.rerank.map((model) => (
{(isLangBotModels
? sortModelsByCatalog(models.rerank, modelMetadata)
: models.rerank
).map((model) => (
<ModelItem
key={model.uuid}
model={model}
canManage={canManage}
modelType="rerank"
isLangBotModels={isLangBotModels}
metadata={
modelMetadata[model.uuid] ?? modelMetadata[model.name]
}
availabilityLoaded={modelAvailabilityLoaded}
editModelPopoverOpen={editModelPopoverOpen}
deleteConfirmOpen={deleteConfirmOpen}
onOpenEditModel={onOpenEditModel}
@@ -126,6 +126,14 @@ function MarketPageContent({
loadMarketFilters().componentFilter ??
'all',
);
const [runnerUsage, setRunnerUsage] = useState(() => {
const value = searchParams.get('runner_usage');
return value === 'agent' || value === 'event' ? value : 'all';
});
const activeRunnerUsage =
componentFilter === 'Runner' && runnerUsage !== 'all'
? (runnerUsage as 'agent' | 'event')
: undefined;
const [typeFilter, setTypeFilter] = useState<string>(() => {
if (getComponentFilterFromQuery(searchParams)) {
return 'plugin';
@@ -138,7 +146,9 @@ function MarketPageContent({
return saved && MARKET_TYPE_VALUES.includes(saved) ? saved : 'all';
});
const activeAdvancedFilters =
(typeFilter === 'all' ? 0 : 1) + (componentFilter === 'all' ? 0 : 1);
(typeFilter === 'all' ? 0 : 1) +
(componentFilter === 'all' ? 0 : 1) +
(activeRunnerUsage ? 1 : 0);
const [selectedTags, setSelectedTags] = useState<string[]>(
() => loadMarketFilters().selectedTags ?? [],
);
@@ -313,6 +323,7 @@ function MarketPageContent({
type_filter: typeFilter === 'all' ? undefined : typeFilter,
component_filter:
componentFilter === 'all' ? undefined : componentFilter,
runner_usage: activeRunnerUsage,
tags_filter: selectedTags.length > 0 ? selectedTags : undefined,
});
@@ -344,6 +355,7 @@ function MarketPageContent({
[
searchQuery,
componentFilter,
activeRunnerUsage,
selectedTags,
pageSize,
transformToVO,
@@ -502,6 +514,10 @@ function MarketPageContent({
setPlugins([]);
const params = new URLSearchParams(searchParams);
if (value !== 'Runner') {
setRunnerUsage('all');
params.delete('runner_usage');
}
if (value === 'all') {
params.delete('component');
} else {
@@ -517,7 +533,7 @@ function MarketPageContent({
// 当排序选项或组件筛选或类型筛选变化时重新加载数据
useEffect(() => {
fetchPlugins(1, !!searchQuery.trim(), true);
}, [sortOption, componentFilter, typeFilter]);
}, [sortOption, componentFilter, typeFilter, activeRunnerUsage]);
// Tags 筛选变化时重新搜索
useEffect(() => {
@@ -826,6 +842,38 @@ function MarketPageContent({
})}
</ToggleGroup>
</div>
{componentFilter === 'Runner' && (
<div className="space-y-2">
<div className="text-xs font-medium text-muted-foreground">
{t('market.runnerUsage')}
</div>
<ToggleGroup
type="single"
size="sm"
value={runnerUsage}
onValueChange={(value) => {
if (!value) return;
setRunnerUsage(value);
setCurrentPage(1);
setPlugins([]);
const params = new URLSearchParams(searchParams);
if (value === 'all') params.delete('runner_usage');
else params.set('runner_usage', value);
setSearchParams(params, { replace: true });
}}
>
<ToggleGroupItem value="all">
{t('market.runnerUsageAll')}
</ToggleGroupItem>
<ToggleGroupItem value="agent">
{t('market.runnerUsageAgent')}
</ToggleGroupItem>
<ToggleGroupItem value="event">
{t('market.runnerUsageEvent')}
</ToggleGroupItem>
</ToggleGroup>
</div>
)}
</PopoverContent>
</Popover>
+27
View File
@@ -158,6 +158,27 @@ export interface RerankModel {
extra_args?: object;
}
export interface LangBotModelAvailability {
up: boolean | null;
last_probed_at: string | null;
latency_ms: number;
http_code: number;
}
export interface LangBotModelAvailabilityItem {
uuid: string;
model_id: string;
category: string | null;
listed_at?: string | null;
input_credits: number | null;
output_credits: number | null;
availability: LangBotModelAvailability;
}
export interface ApiRespLangBotModelAvailability {
models: LangBotModelAvailabilityItem[];
}
export interface ApiRespPipelines {
pipelines: Pipeline[];
}
@@ -308,11 +329,17 @@ export interface Bot {
adapter: string;
adapter_config: object;
event_bindings?: EventBinding[];
plugin_processors?: PluginProcessorBinding[];
created_at?: string;
updated_at?: string;
adapter_runtime_values?: object;
}
export interface PluginProcessorBinding {
processor_uuid: string;
enabled: boolean;
}
export interface EventBinding {
id?: string;
event_pattern: string;
@@ -52,8 +52,23 @@ export interface PluginV4 {
hot_score?: number;
latest_version: string;
components: Record<string, number>;
runner_usages?: RunnerUsage[];
status: PluginV4Status;
type?: 'plugin' | 'mcp' | 'skill';
created_at: string;
updated_at: string;
}
export type RunnerUsage = 'agent' | 'event';
/** Unknown usage metadata must not become an install recommendation. */
export function supportsRunnerUsage(
plugin: PluginV4,
usage: RunnerUsage,
): boolean {
return Boolean(
plugin.components?.Runner &&
Array.isArray(plugin.runner_usages) &&
plugin.runner_usages.includes(usage),
);
}
+5
View File
@@ -63,6 +63,7 @@ import {
BotRouteDryRunRequest,
BotRouteDryRunResult,
BotEventRouteStatusResponse,
ApiRespLangBotModelAvailability,
} from '@/app/infra/entities/api';
import { Plugin } from '@/app/infra/entities/plugin';
import type { PluginLogEntry } from '@/app/infra/entities/plugin';
@@ -1250,6 +1251,10 @@ export class BackendClient extends BaseHttpClient {
return this.get('/api/v1/system/wizard/recommended-model');
}
public getLangBotModelAvailability(): Promise<ApiRespLangBotModelAvailability> {
return this.get('/api/v1/system/model-availability');
}
public getAsyncTasks(params?: {
type?: string;
kind?: string;
+19 -2
View File
@@ -3,7 +3,11 @@ import {
ApiRespMarketplacePluginDetail,
ApiRespMarketplacePlugins,
} from '@/app/infra/entities/api';
import { PluginV4 } from '@/app/infra/entities/plugin';
import {
PluginV4,
RunnerUsage,
supportsRunnerUsage,
} from '@/app/infra/entities/plugin';
import { I18nObject } from '@/app/infra/entities/common';
/**
@@ -39,6 +43,7 @@ export class CloudServiceClient extends BaseHttpClient {
component_filter?: string,
tags_filter?: string[],
type_filter?: string,
runner_usage?: RunnerUsage,
): Promise<ApiRespMarketplacePlugins> {
// Use different endpoints based on type_filter
if (type_filter === 'mcp') {
@@ -90,6 +95,7 @@ export class CloudServiceClient extends BaseHttpClient {
sort_by,
sort_order,
component_filter,
runner_usage,
tags_filter,
type_filter,
},
@@ -104,6 +110,7 @@ export class CloudServiceClient extends BaseHttpClient {
sort_order?: string;
type_filter?: string;
component_filter?: string;
runner_usage?: RunnerUsage;
tags_filter?: string[];
}): Promise<ApiRespMarketplacePlugins> {
return this.post<{ extensions: PluginV4[]; total: number }>(
@@ -125,7 +132,15 @@ export class CloudServiceClient extends BaseHttpClient {
total: resp?.total || 0,
};
})
.catch(() => this.searchMarketplaceExtensionsLegacy(data));
.catch(() => this.searchMarketplaceExtensionsLegacy(data))
.then((result) => ({
...result,
plugins: data.runner_usage
? result.plugins.filter((plugin) =>
supportsRunnerUsage(plugin, data.runner_usage!),
)
: result.plugins,
}));
}
public getMarketplaceLikedExtensions(
@@ -162,6 +177,7 @@ export class CloudServiceClient extends BaseHttpClient {
sort_order?: string;
type_filter?: string;
component_filter?: string;
runner_usage?: RunnerUsage;
tags_filter?: string[];
}): Promise<ApiRespMarketplacePlugins> {
const query = data.query || '';
@@ -183,6 +199,7 @@ export class CloudServiceClient extends BaseHttpClient {
data.component_filter,
data.tags_filter,
data.component_filter ? 'plugin' : data.type_filter,
data.runner_usage,
).catch((error) => {
if (data.type_filter === 'mcp' || data.type_filter === 'skill') {
return { plugins: [], total: 0 };
+2 -2
View File
@@ -225,7 +225,7 @@ export default function WizardPage() {
setIsRunnerCatalogLoading(true);
setRunnerCatalogError(false);
try {
const catalog = await fetchRunnerCatalog();
const catalog = await fetchRunnerCatalog('agent');
setMarketplaceRunners(catalog.marketplaceRunners);
setInstalledPluginIds(catalog.installedPluginIds);
} catch (error) {
@@ -2087,7 +2087,7 @@ function StepAIEngine({
<div className="flex justify-center">
<Button variant="outline" size="sm" asChild>
<Link to="/home/extensions?type=plugin&component=Runner">
<Link to="/home/extensions?type=plugin&component=Runner&runner_usage=agent">
{t('wizard.aiEngine.browseRunners')}
<ExternalLink className="size-4" />
</Link>
+48 -8
View File
@@ -304,6 +304,20 @@ const enUS = {
usesOwnerSpaceBilling:
"Uses the Workspace owner's LangBot Account billing and credits.",
noModels: 'No models configured',
availability: {
available: 'Available at last check',
unavailable: 'Unavailable at last check',
notChecked: 'No check result',
lastChecked: 'Checked {{time}}',
},
pricing: {
compact: '{{input}} / {{output}}',
inline: '{{input}} input · {{output}} output',
title: 'Credits per 1M tokens',
input: 'Input: {{credits}} credits',
output: 'Output: {{credits}} credits',
unavailable: 'No price found. The model may have been removed.',
},
langbotModels: 'LangBot Models',
spaceTrialTooltip:
'Free trial credits available! Login with LangBot Account to access cloud models with zero configuration.',
@@ -340,6 +354,25 @@ const enUS = {
},
},
bots: {
pluginSubscriptions: {
description:
'Automatically receive events declared by the plugin, independently of the routes above.',
empty: 'No plugin processors are bound.',
add: 'Add plugin processor',
existing: 'Choose configuration',
new: 'New configuration',
noExisting: 'No configurations available. Create one.',
shared:
'Bots using the same configuration share settings and runtime state.',
saveHint:
'Save the bot after adding a processor to activate the binding.',
createAndBind: 'Create and bind',
created: 'Configuration created. Save the bot to activate the binding.',
enable: 'Enable {{name}}',
remove: 'Unbind {{name}}',
configure: 'Configure',
logs: 'View logs',
},
applyFailed: 'Configuration saved, but could not be applied',
internalErrorHint:
'An unexpected error occurred. Check the backend logs using the reference below.',
@@ -709,6 +742,7 @@ const enUS = {
},
agents: {
eventProcessor: {
configurations: 'Plugin processor configurations',
configTab: 'Configuration',
logsTab: 'Logs',
noSettings: 'This plugin processor requires no configuration.',
@@ -727,7 +761,7 @@ const enUS = {
create: 'Create plugin processor',
type: 'Plugin processor',
description:
'Handle events with code and processing logic provided by a plugin.',
'Handle specific, declared event types through logic programmed in a plugin.',
component: 'Plugin processor',
selectComponent: 'Select a plugin processor',
unavailable: 'Component unavailable',
@@ -736,14 +770,15 @@ const enUS = {
loadError: 'Unable to load processor details.',
refresh: 'Refresh',
runs: 'Runs',
noRuns: 'No runs yet. Bind a Bot event to start.',
bindBot: 'Bind Bot events',
noRuns: 'No runs yet. Bind this processor to a bot to start.',
bindBot: 'Bind to a bot',
trace: 'Logs and message flow',
selectRun: 'Select a run to view details.',
input: 'Incoming event',
destination: 'Delivery destination',
loadMore: 'Load more',
activation: 'Install a plugin, create an instance, then bind Bot events.',
activation:
'Install a plugin, create a processor configuration, then bind a bot.',
status_pending: 'Pending',
status_running: 'Running',
status_completed: 'Completed',
@@ -792,14 +827,14 @@ const enUS = {
selectFromSidebar: 'Select a processor from the sidebar',
agentType: 'Agent',
agentTypeDescription:
'Use a runner to handle messages, group members, friends, feedback, and other platform events. Best for scenarios that need autonomous decisions, tool use, or non-message events.',
'Describe how to handle different events in natural language and let AI act, or connect an external Agent platform to process them.',
pipelineType: 'Pipeline',
kindBadgeAgent: 'Agent',
kindBadgePipeline: 'Pipeline',
groupByKind: 'Group by type',
groupByKindShort: 'Group',
pipelineTypeDescription:
'Follow a fixed flow: receive a message, call AI, and reply to the user, with configurable knowledge bases and plugins. Handles message events only, for tasks with clear steps and control over processing.',
'Handle message events only, with AI generating replies directly and practical features such as knowledge bases and plugins.',
allEvents: 'Supports all events',
messageEventsOnly: 'Message events only',
chooseType: 'Choose how it works',
@@ -1215,6 +1250,11 @@ const enUS = {
},
},
market: {
runnerUsage: 'Runner usage',
runnerUsageAll: 'All',
runnerUsageAgent: 'Agent / Pipeline',
runnerUsageEvent: 'Plugin processor',
searchPlaceholder: 'Search plugins...',
searchPlaceholderCount:
'Search {{count}} extensions, capabilities, or use cases...',
@@ -2529,9 +2569,9 @@ const enUS = {
catalogUnavailable: 'Runner catalog is unavailable',
catalogUnavailableDescription:
'Installed runners are still available. Retry the catalog or browse Extensions.',
noMarketplaceRunners: 'No Runner extensions are published yet',
noMarketplaceRunners: 'No Runner plugins match this usage',
noMarketplaceRunnersDescription:
'Retry after runner extensions are published to the configured Marketplace.',
'Use an installed Runner or try again later.',
browseRunners: 'Browse Runner Extensions',
installAndContinue: 'Install & Continue',
installing: 'Installing...',
+37 -3
View File
@@ -306,6 +306,21 @@ const esES = {
loginToUseModels:
'Inicia sesión con una cuenta de LangBot para usar modelos en la nube',
noModels: 'No hay modelos configurados',
availability: {
available: 'Disponible en la última comprobación',
unavailable: 'No disponible en la última comprobación',
notChecked: 'Sin resultado de comprobación',
lastChecked: 'Comprobado {{time}}',
},
pricing: {
compact: '{{input}} / {{output}}',
inline: 'Entrada {{input}} · salida {{output}}',
title: 'Créditos por 1 M de tokens',
input: 'Entrada: {{credits}} créditos',
output: 'Salida: {{credits}} créditos',
unavailable:
'No se encontró el precio. Es posible que el modelo haya sido retirado.',
},
langbotModels: 'Modelos LangBot',
spaceTrialTooltip:
'¡Créditos de prueba gratuitos disponibles! Inicia sesión con una cuenta de LangBot para acceder a modelos en la nube sin configuración.',
@@ -348,6 +363,24 @@ const esES = {
"Uses the Workspace owner's LangBot Account billing and credits.",
},
bots: {
pluginSubscriptions: {
description:
'Recibe automáticamente los eventos declarados por el plugin, de forma independiente de las rutas anteriores.',
empty: 'No hay procesadores vinculados.',
add: 'Añadir procesador de plugin',
existing: 'Elegir configuración',
new: 'Nueva configuración',
noExisting: 'No hay configuraciones disponibles. Crea una.',
shared:
'Los bots que usan la misma configuración comparten ajustes y estado de ejecución.',
saveHint: 'Guarda el bot para activar el vínculo.',
createAndBind: 'Crear y vincular',
created: 'Configuración creada. Guarda el bot para activar el vínculo.',
enable: 'Activar {{name}}',
remove: 'Desvincular {{name}}',
configure: 'Configurar',
logs: 'Ver registros',
},
applyFailed: 'Configuración guardada, pero no se pudo aplicar',
internalErrorHint:
'Se produjo un error interno. Consulta los registros del servidor con esta referencia.',
@@ -513,6 +546,7 @@ const esES = {
},
agents: {
eventProcessor: {
configurations: 'Configuraciones de procesadores de plugins',
configTab: 'Configuración',
logsTab: 'Registros',
noSettings: 'Este procesador de plugin no requiere configuración.',
@@ -540,15 +574,15 @@ const esES = {
loadError: 'No se pudieron cargar los detalles.',
refresh: 'Actualizar',
runs: 'Ejecuciones',
noRuns: 'Sin ejecuciones. Vincula eventos de un Bot para empezar.',
bindBot: 'Vincular eventos del Bot',
noRuns: 'Sin ejecuciones. Vincula este procesador a un bot para empezar.',
bindBot: 'Vincular a un bot',
trace: 'Registros y flujo de mensajes',
selectRun: 'Selecciona una ejecución para ver los detalles.',
input: 'Evento recibido',
destination: 'Destino de entrega',
loadMore: 'Cargar más',
activation:
'Instala un plugin, crea una instancia y vincula eventos del Bot.',
'Instala un plugin, crea una configuración de procesador y vincula un bot.',
status_pending: 'Pendiente',
status_running: 'En ejecución',
status_completed: 'Completado',
+45 -8
View File
@@ -309,6 +309,20 @@ const jaJP = {
usesOwnerSpaceBilling:
'ワークスペース所有者の LangBot アカウント課金とクレジットを使用します。',
noModels: 'モデルがありません',
availability: {
available: '前回のチェックで利用可能',
unavailable: '前回のチェックで利用不可',
notChecked: 'チェック結果なし',
lastChecked: '{{time}} にチェック',
},
pricing: {
compact: '{{input}} / {{output}}',
inline: '入力 {{input}} · 出力 {{output}}',
title: '100万トークンあたりのクレジット',
input: '入力:{{credits}} クレジット',
output: '出力:{{credits}} クレジット',
unavailable: '価格が見つかりません。モデルが削除された可能性があります。',
},
langbotModels: 'LangBot モデル',
spaceTrialTooltip:
'無料トライアルクレジットが利用可能!LangBot アカウントでログインして、設定不要でクラウドモデルを使用できます。',
@@ -346,6 +360,23 @@ const jaJP = {
},
},
bots: {
pluginSubscriptions: {
description:
'プラグインが宣言したイベントを自動で受信し、上のルートとは独立して実行します。',
empty: 'プラグインプロセッサーは未登録です。',
add: 'プラグインプロセッサーを追加',
existing: '既存の設定を選択',
new: '設定を新規作成',
noExisting: '追加できる設定がありません。新しく作成してください。',
shared: '同じ設定を使用するボットは設定内容と実行状態を共有します。',
saveHint: '追加後にボットを保存すると有効になります。',
createAndBind: '作成して紐付け',
created: '設定を作成しました。ボットを保存すると紐付けが有効になります。',
enable: '{{name}} を有効化',
remove: '{{name}} の紐付けを解除',
configure: '設定',
logs: 'ログを表示',
},
applyFailed: '設定を保存しましたが、適用に失敗しました',
internalErrorHint:
'内部エラーが発生しました。エラー番号でバックエンドのログを確認してください。',
@@ -722,6 +753,7 @@ const jaJP = {
},
agents: {
eventProcessor: {
configurations: 'プラグインプロセッサー設定',
configTab: '設定',
logsTab: 'ログ',
noSettings: 'このプラグインプロセッサーに設定項目はありません。',
@@ -740,7 +772,7 @@ const jaJP = {
create: 'プラグインプロセッサーを作成',
type: 'プラグインプロセッサー',
description:
'プラグインが提供するコードと処理ロジックでイベントを処理します。',
'事前に宣言された特定のイベントを、プラグインに実装されたロジックに従って処理します。',
component: 'プラグインプロセッサー',
selectComponent: 'プラグインプロセッサーを選択',
unavailable: 'コンポーネントを利用できません',
@@ -750,15 +782,15 @@ const jaJP = {
loadError: '詳細を読み込めません。',
refresh: '更新',
runs: '実行履歴',
noRuns: '実行履歴はありません。Bot イベントを紐付けて開始します。',
bindBot: 'Bot イベントを紐付ける',
noRuns: '実行履歴はありません。ボットに紐付けて開始します。',
bindBot: 'ボットに紐付ける',
trace: 'ログとメッセージの流れ',
selectRun: '実行履歴を選択して詳細を表示します。',
input: '受信イベント',
destination: '送信先',
loadMore: 'さらに読み込む',
activation:
'プラグインをインストールし、インスタンスを作成して Bot イベントを紐付けます。',
'プラグインをインストールし、プロセッサー設定を作成してボットに紐付けます。',
status_pending: '待機中',
status_running: '実行中',
status_completed: '完了',
@@ -832,14 +864,14 @@ const jaJP = {
selectFromSidebar: 'サイドバーからプロセッサーを選択',
agentType: 'Agent',
agentTypeDescription:
'Runner を使ってメッセージ、グループメンバー、友だち、フィードバックなどのプラットフォームイベントを処理します。自律的な判断、ツール利用、メッセージ以外のイベント対応が必要な場合に適しています。',
'さまざまなイベントの処理方法を自然言語で指定して AI に実行させるか、外部の Agent プラットフォームに接続して処理します。',
pipelineType: 'パイプライン',
kindBadgeAgent: 'Agent',
kindBadgePipeline: 'パイプライン',
groupByKind: 'タイプ別にグループ化',
groupByKindShort: 'グループ',
pipelineTypeDescription:
'メッセージ受信、AI呼び出し、ユーザーへの返信」の固定フローで動作し、ナレッジベースやプラグインを設定できます。メッセージイベントのみを処理し、手順が明確で処理の制御が必要な用途に適しています。',
'メッセージイベントのみを処理し、AI が直接返信を生成します。ナレッジベースやプラグインなどの便利な機能も利用できます。',
allEvents: 'すべてのイベントに対応',
messageEventsOnly: 'メッセージイベントのみ',
chooseType: '処理方法を選択',
@@ -1135,6 +1167,11 @@ const jaJP = {
uploadPluginOnly: '.lbpkg プラグインパッケージのみ対応しています',
},
market: {
runnerUsage: 'ランナーの用途',
runnerUsageAll: 'すべて',
runnerUsageAgent: 'Agent / パイプライン',
runnerUsageEvent: 'プラグインプロセッサー',
searchPlaceholder: 'プラグインを検索...',
searchPlaceholderCount:
'{{count}} 個の拡張機能・機能・ユースケースを検索...',
@@ -2309,9 +2346,9 @@ const jaJP = {
catalogUnavailable: 'Runner カタログを読み込めません',
catalogUnavailableDescription:
'インストール済みの Runner は引き続き使用できます。再試行するか、拡張機能を確認してください。',
noMarketplaceRunners: 'Runner 拡張機能はまだ公開されていません',
noMarketplaceRunners: 'この用途に対応するランナープラグインはありません',
noMarketplaceRunnersDescription:
'設定済みのマーケットプレイスに Runner 拡張機能が公開された後、再試行してください。',
'インストール済みのランナーを使うか、後でもう一度お試しください。',
browseRunners: 'Runner 拡張機能を見る',
installAndContinue: 'インストールして続行',
installing: 'インストール中...',
+36 -3
View File
@@ -303,6 +303,20 @@ const ruRU = {
loginToUseModels:
'Войдите с аккаунтом LangBot, чтобы использовать облачные модели',
noModels: 'Модели не настроены',
availability: {
available: 'Доступна при последней проверке',
unavailable: 'Недоступна при последней проверке',
notChecked: 'Нет результата проверки',
lastChecked: 'Проверено {{time}}',
},
pricing: {
compact: '{{input}} / {{output}}',
inline: 'Ввод {{input}} · вывод {{output}}',
title: 'Кредиты за 1 млн токенов',
input: 'Ввод: {{credits}} кредитов',
output: 'Вывод: {{credits}} кредитов',
unavailable: 'Цена не найдена. Возможно, модель была удалена.',
},
langbotModels: 'Модели LangBot',
spaceTrialTooltip:
'Доступны бесплатные пробные кредиты! Войдите с аккаунтом LangBot, чтобы получить доступ к облачным моделям без настройки.',
@@ -346,6 +360,24 @@ const ruRU = {
"Uses the Workspace owner's LangBot Account billing and credits.",
},
bots: {
pluginSubscriptions: {
description:
'Автоматически получает события, объявленные плагином, независимо от маршрутов выше.',
empty: 'Обработчики плагинов не привязаны.',
add: 'Добавить обработчик плагина',
existing: 'Выбрать конфигурацию',
new: 'Новая конфигурация',
noExisting: 'Нет доступных конфигураций. Создайте новую.',
shared:
'Боты с общей конфигурацией используют общие настройки и состояние выполнения.',
saveHint: 'Сохраните бота, чтобы активировать привязку.',
createAndBind: 'Создать и привязать',
created: 'Конфигурация создана. Сохраните бота для активации привязки.',
enable: 'Включить {{name}}',
remove: 'Отвязать {{name}}',
configure: 'Настроить',
logs: 'Журнал',
},
applyFailed: 'Настройки сохранены, но не применены',
internalErrorHint:
'Внутренняя ошибка. Проверьте журналы сервера по указанному идентификатору.',
@@ -510,6 +542,7 @@ const ruRU = {
},
agents: {
eventProcessor: {
configurations: 'Конфигурации обработчиков плагинов',
configTab: 'Настройки',
logsTab: 'Журнал',
noSettings: 'Этот обработчик плагина не требует настройки.',
@@ -536,15 +569,15 @@ const ruRU = {
loadError: 'Не удалось загрузить данные.',
refresh: 'Обновить',
runs: 'Запуски',
noRuns: 'Запусков пока нет. Привяжите события бота.',
bindBot: 'Привязать события бота',
noRuns: 'Запусков пока нет. Привяжите обработчик к боту.',
bindBot: 'Привязать к боту',
trace: 'Журнал и поток сообщений',
selectRun: 'Выберите запуск для просмотра.',
input: 'Входящее событие',
destination: 'Получатель',
loadMore: 'Загрузить ещё',
activation:
'Установите плагин, создайте экземпляр и привяжите события бота.',
'Установите плагин, создайте конфигурацию обработчика и привяжите бота.',
status_pending: 'Ожидание',
status_running: 'Выполняется',
status_completed: 'Завершено',
+35 -3
View File
@@ -292,6 +292,20 @@ const thTH = {
loginWithSpace: 'เข้าสู่ระบบด้วยบัญชี LangBot',
loginToUseModels: 'เข้าสู่ระบบด้วยบัญชี LangBot เพื่อใช้โมเดลคลาวด์',
noModels: 'ยังไม่มีโมเดลที่กำหนดค่า',
availability: {
available: 'พร้อมใช้งานในการตรวจสอบล่าสุด',
unavailable: 'ไม่พร้อมใช้งานในการตรวจสอบล่าสุด',
notChecked: 'ไม่มีผลการตรวจสอบ',
lastChecked: 'ตรวจสอบเมื่อ {{time}}',
},
pricing: {
compact: '{{input}} / {{output}}',
inline: 'อินพุต {{input}} · เอาต์พุต {{output}}',
title: 'เครดิตต่อ 1 ล้านโทเค็น',
input: 'อินพุต: {{credits}} เครดิต',
output: 'เอาต์พุต: {{credits}} เครดิต',
unavailable: 'ไม่พบราคา โมเดลอาจถูกนำออกแล้ว',
},
langbotModels: 'โมเดล LangBot',
spaceTrialTooltip:
'มีเครดิตทดลองใช้งานฟรี! เข้าสู่ระบบด้วยบัญชี LangBot เพื่อเข้าถึงโมเดลคลาวด์โดยไม่ต้องตั้งค่า',
@@ -333,6 +347,23 @@ const thTH = {
"Uses the Workspace owner's LangBot Account billing and credits.",
},
bots: {
pluginSubscriptions: {
description:
'รับเหตุการณ์ที่ปลั๊กอินประกาศไว้โดยอัตโนมัติ และทำงานแยกจากเส้นทางด้านบน',
empty: 'ยังไม่ได้เชื่อมโยงตัวประมวลผลปลั๊กอิน',
add: 'เพิ่มตัวประมวลผลปลั๊กอิน',
existing: 'เลือกการตั้งค่า',
new: 'สร้างการตั้งค่า',
noExisting: 'ไม่มีการตั้งค่าที่ใช้ได้ โปรดสร้างใหม่',
shared: 'บอทที่ใช้การตั้งค่าเดียวกันจะแชร์การตั้งค่าและสถานะการทำงาน',
saveHint: 'บันทึกบอตเพื่อเปิดใช้งานการเชื่อมโยง',
createAndBind: 'สร้างและเชื่อมโยง',
created: 'สร้างการตั้งค่าแล้ว บันทึกบอทเพื่อเปิดใช้งานการเชื่อมโยง',
enable: 'เปิดใช้งาน {{name}}',
remove: 'ยกเลิกการเชื่อมโยง {{name}}',
configure: 'ตั้งค่า',
logs: 'ดูบันทึก',
},
applyFailed: 'บันทึกการตั้งค่าแล้ว แต่ไม่สามารถนำไปใช้ได้',
internalErrorHint:
'เกิดข้อผิดพลาดภายใน โปรดตรวจสอบบันทึกของเซิร์ฟเวอร์ด้วยหมายเลขอ้างอิง',
@@ -497,6 +528,7 @@ const thTH = {
},
agents: {
eventProcessor: {
configurations: 'การตั้งค่าตัวประมวลผลปลั๊กอิน',
configTab: 'การตั้งค่า',
logsTab: 'บันทึก',
noSettings: 'ตัวประมวลผลปลั๊กอินนี้ไม่ต้องตั้งค่า',
@@ -523,14 +555,14 @@ const thTH = {
loadError: 'ไม่สามารถโหลดรายละเอียดได้',
refresh: 'รีเฟรช',
runs: 'ประวัติการทำงาน',
noRuns: 'ยังไม่มีการทำงาน เชื่อมโยงเหตุการณ์บอทเพื่อเริ่มต้น',
bindBot: 'เชื่อมโยงเหตุการณ์บอท',
noRuns: 'ยังไม่มีการทำงาน เชื่อมโยงตัวประมวลผลกับบอทเพื่อเริ่มต้น',
bindBot: 'เชื่อมโยงกับบอท',
trace: 'บันทึกและเส้นทางข้อความ',
selectRun: 'เลือกการทำงานเพื่อดูรายละเอียด',
input: 'เหตุการณ์ขาเข้า',
destination: 'ปลายทางการส่ง',
loadMore: 'โหลดเพิ่มเติม',
activation: 'ติดตั้งปลั๊กอิน สร้างอินสแตนซ์ แล้วเชื่อมโยงเหตุการณ์บอท',
activation: 'ติดตั้งปลั๊กอิน สร้างการตั้งค่าตัวประมวลผล แล้วเชื่อมโยงบอท',
status_pending: 'รอดำเนินการ',
status_running: 'กำลังทำงาน',
status_completed: 'เสร็จสิ้น',
+36 -3
View File
@@ -300,6 +300,20 @@ const viVN = {
loginToUseModels:
'Đăng nhập bằng tài khoản LangBot để sử dụng mô hình đám mây',
noModels: 'Chưa cấu hình mô hình nào',
availability: {
available: 'Khả dụng ở lần kiểm tra gần nhất',
unavailable: 'Không khả dụng ở lần kiểm tra gần nhất',
notChecked: 'Chưa có kết quả kiểm tra',
lastChecked: 'Đã kiểm tra {{time}}',
},
pricing: {
compact: '{{input}} / {{output}}',
inline: 'Đầu vào {{input}} · đầu ra {{output}}',
title: 'Tín dụng trên 1 triệu token',
input: 'Đầu vào: {{credits}} tín dụng',
output: 'Đầu ra: {{credits}} tín dụng',
unavailable: 'Không tìm thấy giá. Mô hình có thể đã bị gỡ.',
},
langbotModels: 'Mô hình LangBot',
spaceTrialTooltip:
'Có tín dụng dùng thử miễn phí! Đăng nhập bằng tài khoản LangBot để truy cập mô hình đám mây không cần cấu hình.',
@@ -342,6 +356,24 @@ const viVN = {
"Uses the Workspace owner's LangBot Account billing and credits.",
},
bots: {
pluginSubscriptions: {
description:
'Tự động nhận sự kiện do plugin khai báo, hoạt động độc lập với các tuyến ở trên.',
empty: 'Chưa liên kết bộ xử lý plugin.',
add: 'Thêm bộ xử lý plugin',
existing: 'Chọn cấu hình',
new: 'Cấu hình mới',
noExisting: 'Chưa có cấu hình khả dụng. Hãy tạo mới.',
shared:
'Các bot dùng chung cấu hình sẽ chia sẻ thiết lập và trạng thái chạy.',
saveHint: 'Lưu bot để kích hoạt liên kết.',
createAndBind: 'Tạo và liên kết',
created: 'Đã tạo cấu hình. Lưu bot để kích hoạt liên kết.',
enable: 'Bật {{name}}',
remove: 'Hủy liên kết {{name}}',
configure: 'Cấu hình',
logs: 'Xem nhật ký',
},
applyFailed: 'Đã lưu cấu hình nhưng không thể áp dụng',
internalErrorHint:
'Đã xảy ra lỗi nội bộ. Hãy kiểm tra nhật ký máy chủ bằng mã lỗi.',
@@ -506,6 +538,7 @@ const viVN = {
},
agents: {
eventProcessor: {
configurations: 'Cấu hình bộ xử lý plugin',
configTab: 'Cấu hình',
logsTab: 'Nhật ký',
noSettings: 'Bộ xử lý plugin này không cần cấu hình.',
@@ -532,14 +565,14 @@ const viVN = {
loadError: 'Không thể tải chi tiết.',
refresh: 'Làm mới',
runs: 'Lịch sử chạy',
noRuns: 'Chưa có lần chạy nào. Liên kết sự kiện Bot để bắt đầu.',
bindBot: 'Liên kết sự kiện Bot',
noRuns: 'Chưa có lần chạy nào. Liên kết bộ xử lý với bot để bắt đầu.',
bindBot: 'Liên kết với bot',
trace: 'Nhật ký và luồng tin nhắn',
selectRun: 'Chọn một lần chạy để xem chi tiết.',
input: 'Sự kiện đầu vào',
destination: 'Đích gửi',
loadMore: 'Tải thêm',
activation: 'Cài plugin, tạo phiên bản rồi liên kết sự kiện Bot.',
activation: 'Cài plugin, tạo cấu hình bộ xử lý rồi liên kết bot.',
status_pending: 'Đang chờ',
status_running: 'Đang chạy',
status_completed: 'Hoàn tất',
+44 -9
View File
@@ -290,6 +290,20 @@ const zhHans = {
'工作区所有者需要绑定 LangBot 账号才能使用 LangBot 模型。',
usesOwnerSpaceBilling: '使用工作区所有者的 LangBot 账号计费与积分。',
noModels: '暂无模型',
availability: {
available: '上次检测可用',
unavailable: '上次检测不可用',
notChecked: '暂无检测结果',
lastChecked: '检测于 {{time}}',
},
pricing: {
compact: '{{input}} / {{output}}',
inline: '输入 {{input}} · 输出 {{output}}',
title: '每 1M tokens 消耗积分',
input: '输入:{{credits}} 积分',
output: '输出:{{credits}} 积分',
unavailable: '未查询到价格,模型可能已被下架',
},
langbotModels: 'LangBot 模型',
spaceTrialTooltip:
'免费试用积分已就绪!通过 LangBot 账号登录即可零配置使用云端模型。',
@@ -325,6 +339,22 @@ const zhHans = {
},
},
bots: {
pluginSubscriptions: {
description: '自动接收插件声明的事件,与上方事件路由独立执行。',
empty: '尚未绑定插件处理器。',
add: '添加插件处理器',
existing: '选择已有配置',
new: '新建配置',
noExisting: '没有可添加的配置,可新建一份。',
shared: '使用同一配置的机器人会共享设置和运行状态。',
saveHint: '添加后保存机器人配置,绑定才会生效。',
createAndBind: '创建并绑定',
created: '配置已创建,保存机器人配置后生效。',
enable: '启用 {{name}}',
remove: '解除绑定 {{name}}',
configure: '配置',
logs: '查看日志',
},
applyFailed: '配置已保存,但应用失败',
internalErrorHint: '发生内部错误,请通过错误编号查看后端日志。',
errorReference: '错误编号:{{id}}',
@@ -673,6 +703,7 @@ const zhHans = {
},
agents: {
eventProcessor: {
configurations: '插件处理器配置',
configTab: '配置',
logsTab: '日志',
noSettings: '此插件处理器无需配置。',
@@ -689,7 +720,7 @@ const zhHans = {
create: '创建插件处理器',
type: '插件处理器',
description: '由插件中的代码处理事件,处理逻辑由插件实现。',
description: '由插件代码处理预先声明的特定事件,按插件编写的逻辑执行。',
component: '插件处理器',
selectComponent: '选择插件处理器',
unavailable: '组件不可用',
@@ -698,14 +729,14 @@ const zhHans = {
loadError: '无法加载处理器详情。',
refresh: '刷新',
runs: '运行记录',
noRuns: '暂无运行记录,绑定机器人事件后开始处理。',
bindBot: '绑定机器人事件',
noRuns: '暂无运行记录,绑定机器人后开始处理。',
bindBot: '绑定机器人',
trace: '日志与消息流向',
selectRun: '选择一条运行记录查看详情。',
input: '传入事件',
destination: '投递目标',
loadMore: '加载更多',
activation: '安装插件,创建实例,再绑定机器人事件。',
activation: '安装插件,创建处理器配置,再绑定机器人。',
status_pending: '待执行',
status_running: '运行中',
status_completed: '已完成',
@@ -754,14 +785,14 @@ const zhHans = {
selectFromSidebar: '从侧边栏选择一个处理器',
agentType: 'Agent',
agentTypeDescription:
'通过运行器处理消息、群成员、好友、反馈等平台事件。适合需要自主判断、调用工具或响应非消息事件的场景。',
'用自然语言描述多种事件的处理方式,让 AI 执行;也可接入外部 Agent 平台处理事件。',
pipelineType: '流水线',
kindBadgeAgent: 'Agent',
kindBadgePipeline: '流水线',
groupByKind: '按类型分组',
groupByKindShort: '分组',
pipelineTypeDescription:
'按“接收消息、调用 AI、回复用户”的固定流程运行,可配置知识库插件扩展。仅处理消息事件,适合步骤明确、需要控制处理过程的场景。',
'只处理消息事件,由 AI 直接生成回复,并提供知识库插件等实用功能。',
allEvents: '支持全部事件',
messageEventsOnly: '仅支持消息事件',
chooseType: '选择处理方式',
@@ -1152,6 +1183,11 @@ const zhHans = {
},
},
market: {
runnerUsage: '运行器用途',
runnerUsageAll: '全部',
runnerUsageAgent: 'Agent / 流水线',
runnerUsageEvent: '插件处理器',
searchPlaceholder: '搜索插件...',
searchPlaceholderCount: '搜索 {{count}} 个扩展、能力或场景...',
searchResults: '搜索到 {{count}} 个扩展',
@@ -2390,9 +2426,8 @@ const zhHans = {
catalogUnavailable: '无法加载运行器目录',
catalogUnavailableDescription:
'已安装的运行器仍可使用。你可以重试,或前往扩展页面查看。',
noMarketplaceRunners: '市场暂未发布运行器扩展',
noMarketplaceRunnersDescription:
'请在运行器扩展发布到当前配置的市场后重试。',
noMarketplaceRunners: '暂无适用于此用途的运行器插件',
noMarketplaceRunnersDescription: '可以使用已安装的运行器,或稍后重试。',
browseRunners: '浏览运行器扩展',
installAndContinue: '安装并继续',
installing: '正在安装...',
+34 -3
View File
@@ -281,6 +281,20 @@ const zhHant = {
loginWithSpace: '使用 LangBot 帳號登入',
loginToUseModels: '使用 LangBot 帳號登入以使用雲端模型',
noModels: '暫無模型',
availability: {
available: '上次檢測可用',
unavailable: '上次檢測不可用',
notChecked: '暫無檢測結果',
lastChecked: '檢測於 {{time}}',
},
pricing: {
compact: '{{input}} / {{output}}',
inline: '輸入 {{input}} · 輸出 {{output}}',
title: '每 1M tokens 消耗積分',
input: '輸入:{{credits}} 積分',
output: '輸出:{{credits}} 積分',
unavailable: '未查詢到價格,模型可能已被下架',
},
langbotModels: 'LangBot 模型',
spaceTrialTooltip:
'免費試用積分已就緒!使用 LangBot 帳號登入即可零設定使用雲端模型。',
@@ -322,6 +336,22 @@ const zhHant = {
"Uses the Workspace owner's LangBot Account billing and credits.",
},
bots: {
pluginSubscriptions: {
description: '自動接收外掛宣告的事件,與上方事件路由獨立執行。',
empty: '尚未綁定外掛處理器。',
add: '新增外掛處理器',
existing: '選擇現有設定',
new: '新增設定',
noExisting: '沒有可新增的設定,請建立一份。',
shared: '使用同一份設定的機器人會共用設定和執行狀態。',
saveHint: '新增後儲存機器人設定,綁定才會生效。',
createAndBind: '建立並綁定',
created: '設定已建立,儲存機器人設定後生效。',
enable: '啟用 {{name}}',
remove: '解除綁定 {{name}}',
configure: '設定',
logs: '查看日誌',
},
applyFailed: '設定已儲存,但套用失敗',
internalErrorHint: '發生內部錯誤,請透過錯誤編號查看後端日誌。',
errorReference: '錯誤編號:{{id}}',
@@ -480,6 +510,7 @@ const zhHant = {
},
agents: {
eventProcessor: {
configurations: '外掛處理器設定',
configTab: '設定',
logsTab: '日誌',
noSettings: '此外掛處理器無需設定。',
@@ -505,14 +536,14 @@ const zhHant = {
loadError: '無法載入處理器詳情。',
refresh: '重新整理',
runs: '執行記錄',
noRuns: '尚無執行記錄,綁定機器人事件後開始處理。',
bindBot: '綁定機器人事件',
noRuns: '尚無執行記錄,綁定機器人後開始處理。',
bindBot: '綁定機器人',
trace: '日誌與訊息流向',
selectRun: '選擇一筆執行記錄查看詳情。',
input: '傳入事件',
destination: '傳送目標',
loadMore: '載入更多',
activation: '安裝外掛、建立實例,再綁定機器人事件。',
activation: '安裝外掛、建立處理器設定,再綁定機器人。',
status_pending: '待執行',
status_running: '執行中',
status_completed: '已完成',
+33 -14
View File
@@ -89,7 +89,7 @@ test.describe('frontend CRUD smoke flows', () => {
await expect(
page.locator('[data-processor-kind="pipeline"]'),
).toContainText(
'流水线即为经典的“收到消息、请求AI、回复用户”流程,并辅以常用的配置功能。仅处理消息事件,适合步骤明确、需要稳定控制处理过程的场景。',
'流水线只处理消息事件,由 AI 直接生成回复,并提供知识库、插件等实用功能。',
);
});
@@ -1034,7 +1034,7 @@ test.describe('agent runner resource selectors', () => {
await expect(
page.getByText('No Runner extension is installed yet.'),
).toBeVisible();
await expect(page.getByText('Runner Marketplace')).toBeVisible();
await expect(page.getByText('Runner plugins in Marketplace')).toBeVisible();
const selectorPopup = page.locator('[data-slot="select-content"]');
await expect(
selectorPopup.getByText('Runner used by the grouped selector test.', {
@@ -1055,20 +1055,35 @@ test.describe('agent runner resource selectors', () => {
});
await page
.getByRole('option')
.filter({ hasText: 'Marketplace Runner' })
.getByRole('button', { name: 'Install Marketplace Runner', exact: true })
.click();
await expect.poll(() => installRequests).toBe(1);
await expect.poll(() => taskPolls).toBeGreaterThan(0);
await expect(
page.getByRole('option', {
name: 'Marketplace Runner Runner used by the grouped selector test.',
exact: true,
}),
).toBeVisible();
await page
.getByRole('option', {
name: 'Marketplace Runner Runner used by the grouped selector test.',
exact: true,
})
.click();
await expect(runnerSelect).toContainText('Marketplace Runner');
await runnerSelect.click();
await expect(
page.getByRole('option').filter({ hasText: runnerId }),
page.getByRole('option', {
name: 'Marketplace Runner Runner used by the grouped selector test.',
exact: true,
}),
).toBeVisible();
await expect(
page.getByText('Runner used by the grouped selector test.', {
page.getByRole('button', {
name: 'Install Marketplace Runner',
exact: true,
}),
).toHaveCount(0);
@@ -1130,7 +1145,7 @@ test.describe('agent runner resource selectors', () => {
const runnerSelect = page.getByRole('combobox', { name: 'Runner' });
await runnerSelect.click();
await expect(page.getByText('Installed Runners')).toBeVisible();
await expect(page.getByText('Runner Marketplace')).toBeVisible();
await expect(page.getByText('Runner plugins in Marketplace')).toBeVisible();
await expect(
page
.locator('[data-slot="select-content"]')
@@ -1139,9 +1154,10 @@ test.describe('agent runner resource selectors', () => {
}),
).toBeVisible();
await expect(
page
.getByRole('option')
.filter({ hasText: 'Pipeline Marketplace Runner' }),
page.getByRole('button', {
name: 'Install Pipeline Marketplace Runner',
exact: true,
}),
).toBeVisible();
});
@@ -1214,8 +1230,11 @@ test.describe('agent and pipeline save concurrency', () => {
await page.goto('/home/agents?id=agent-save-race');
const saveButton = page.getByRole('button', { name: /^Save$/ });
await page.getByRole('tab', { name: 'Bindable Event Range' }).click();
const eventPatterns = page.getByLabel('Event Range');
await page.getByRole('tab', { name: 'Events & tools' }).click();
const eventPatterns = page.getByRole('button', {
name: 'Add event',
exact: true,
});
await expect(eventPatterns).toBeVisible();
await eventPatterns.click();
@@ -1231,8 +1250,8 @@ test.describe('agent and pipeline save concurrency', () => {
await eventPatterns.click();
await page.getByRole('option').filter({ hasText: 'group.*' }).click();
await page
.getByRole('option')
.filter({ hasText: 'message.received' })
.getByRole('button', { name: 'Remove event', exact: true })
.first()
.click();
await page.keyboard.press('Escape');
await forceFormSubmit(page, '#agent-form');
+15 -3
View File
@@ -64,6 +64,7 @@ interface BotMock {
adapter_config: JsonRecord;
use_pipeline_uuid?: string;
event_bindings: unknown[];
plugin_processors: unknown[];
pipeline_routing_rules: unknown[];
adapter_runtime_values: JsonRecord;
updated_at: string;
@@ -505,6 +506,7 @@ function makeBot(
? String(data.use_pipeline_uuid)
: undefined,
event_bindings: (data.event_bindings as unknown[] | undefined) || [],
plugin_processors: (data.plugin_processors as unknown[] | undefined) || [],
pipeline_routing_rules:
(data.pipeline_routing_rules as unknown[] | undefined) || [],
adapter_runtime_values: {
@@ -725,10 +727,12 @@ async function handleBackendApi(route: Route, state: LangBotApiMockState) {
return fulfillJson(route, { agents: state.pipelines });
}
const agentDebugMatch = path.match(/^\/api\/v1\/agents\/([^/]+)\/debug$/);
const agentDebugMatch = path.match(
/^\/api\/v1\/agents\/([^/]+)\/debug(?:\/stream)?$/,
);
if (agentDebugMatch) {
const payload = parseJsonBody(route);
return fulfillJson(route, {
const result = {
event_id: nextId(state, 'event'),
event_type: String(payload.event_type || 'message.received'),
conversation_id: String(payload.conversation_id || 'debug-session'),
@@ -740,7 +744,15 @@ async function handleBackendApi(route: Route, state: LangBotApiMockState) {
text: 'Mock Agent response',
},
],
});
};
if (path.endsWith('/stream')) {
return route.fulfill({
status: 200,
contentType: 'application/x-ndjson',
body: JSON.stringify({ kind: 'completed', data: result }) + '\n',
});
}
return fulfillJson(route, result);
}
const agentMatch = path.match(/^\/api\/v1\/agents\/([^/]+)$/);
+1 -1
View File
@@ -11,7 +11,7 @@ const appRoutes = [
{
path: '/home/agents',
heading: 'Processors',
bodyText: 'Select an Agent or Pipeline from the sidebar',
bodyText: 'Select a processor from the sidebar',
},
{
path: '/home/extensions',
+135
View File
@@ -0,0 +1,135 @@
import { expect, test } from '@playwright/test';
import { installLangBotApiMocks } from './fixtures/langbot-api';
test('creates a configured processor, persists subscriptions separately and reuses an instance', async ({
page,
}) => {
await installLangBotApiMocks(page, {
authenticated: true,
withAdapterEvents: true,
});
const ref = 'plugin:test/welcome/default';
const processors: Record<string, unknown>[] = [];
await page.route('**/api/v1/agents/_/metadata', async (route) =>
route.fulfill({
json: {
code: 0,
data: {
event_processors: [
{
id: ref,
plugin_author: 'test',
plugin_name: 'welcome',
usages: ['event'],
label: { en_US: 'Welcome members', zh_Hans: '欢迎新成员' },
supported_event_patterns: ['group.member_joined'],
config_schema: [
{
name: 'greeting',
type: 'string',
label: { en_US: 'Greeting', zh_Hans: '欢迎语' },
required: true,
default: 'Hello',
},
],
},
],
},
},
}),
);
await page.route('**/api/v1/agents', async (route) => {
if (route.request().method() === 'POST') {
processors.push({
...route.request().postDataJSON(),
uuid: 'processor-new',
supported_event_patterns: ['group.member_joined'],
});
return route.fulfill({
json: {
code: 0,
data: { uuid: 'processor-new', kind: 'event_processor' },
},
});
}
return route.fulfill({ json: { code: 0, data: { agents: processors } } });
});
await page.goto('/home/bots?id=new');
await page.getByRole('combobox').click();
await page.getByRole('option', { name: 'Playwright Adapter' }).click();
await page.locator('input[name="name"]').fill('Subscription Bot');
await page.getByRole('button', { name: /^Submit$/ }).click();
await expect(page).toHaveURL(/id=bot-1$/);
const section = page.getByRole('region', {
name: 'Plugin processor',
exact: true,
});
await section.getByRole('button', { name: 'Add plugin processor' }).click();
const dialog = page.getByRole('dialog');
await dialog.getByRole('button', { name: /Welcome members/ }).click();
await dialog.getByLabel('Name', { exact: true }).fill('Customer welcome');
await dialog.locator('input[name="greeting"]').fill('Welcome aboard');
await dialog.getByRole('button', { name: 'Create and bind' }).click();
await expect(dialog).toBeHidden();
expect(processors[0]).toMatchObject({
kind: 'event_processor',
component_ref: ref,
config: { runner_config: { [ref]: { greeting: 'Welcome aboard' } } },
});
await expect(section).toContainText('Customer welcome');
const save = async () => {
const request = page.waitForRequest(
(r) => r.method() === 'PUT' && r.url().endsWith('/platform/bots/bot-1'),
);
await page.getByRole('button', { name: /^Save$/ }).click();
const body = (await request).postDataJSON();
await expect(page.getByRole('button', { name: /^Save$/ })).toBeDisabled();
return body;
};
const body = await save();
expect(body.plugin_processors).toEqual([
{ processor_uuid: 'processor-new', enabled: true },
]);
expect(body.event_bindings).toEqual([]);
await page.reload();
await expect(section).toContainText('Customer welcome');
await expect(
section.getByRole('link', { name: 'View logs' }),
).toHaveAttribute('href', '/home/agents?id=processor-new&tab=logs');
await section
.getByRole('switch', { name: 'Enable Customer welcome' })
.click();
expect((await save()).plugin_processors).toEqual([
{ processor_uuid: 'processor-new', enabled: false },
]);
await section
.getByRole('button', { name: 'Unbind Customer welcome' })
.click();
await section.getByRole('button', { name: 'Add plugin processor' }).click();
await dialog.getByRole('checkbox', { name: /Customer welcome/ }).check();
await dialog
.getByRole('button', { name: 'Add plugin processor', exact: true })
.click();
await expect(section).toContainText('Customer welcome');
expect(processors).toHaveLength(1);
processors.push({
...processors[0],
uuid: 'processor-observer',
name: 'Event observer',
});
await page.reload();
await section
.getByRole('button', { name: 'Unbind Customer welcome' })
.click();
await section.getByRole('button', { name: 'Add plugin processor' }).click();
await dialog.getByRole('checkbox', { name: /Customer welcome/ }).check();
await dialog.getByRole('checkbox', { name: /Event observer/ }).check();
await dialog
.getByRole('button', { name: 'Add plugin processor', exact: true })
.click();
expect((await save()).plugin_processors).toEqual([
{ processor_uuid: 'processor-new', enabled: true },
{ processor_uuid: 'processor-observer', enabled: true },
]);
});
@@ -60,8 +60,8 @@ test.describe('processor detail workbench', () => {
const appShell = page.locator('[class*="group/sidebar-wrapper"]');
const sidebarInset = page.locator('[data-slot="sidebar-inset"]');
await expect(appShell).toHaveCSS('overflow', 'clip');
await expect(sidebarInset).toHaveCSS('overflow', 'clip');
await expect(appShell).toHaveCSS('overflow', 'hidden');
await expect(sidebarInset).toHaveCSS('overflow', 'hidden');
await appShell.evaluate((element) => {
element.scrollTop = 300;
});
@@ -79,9 +79,7 @@ test.describe('processor detail workbench', () => {
const flow = configPanel.getByRole('tablist');
await expect(flow.getByRole('tab').nth(0)).toContainText('Runner');
await expect(flow.getByRole('tab').nth(1)).toContainText('Local Agent');
await expect(flow.getByRole('tab').nth(2)).toContainText(
'Bindable Event Range',
);
await expect(flow.getByRole('tab').nth(2)).toContainText('Events & tools');
await expect(flow.getByRole('tab')).toHaveCount(3);
await expect(flow.getByText('Management')).toHaveCount(0);
@@ -126,19 +124,32 @@ test.describe('processor detail workbench', () => {
await flow.getByRole('tab').nth(2).click();
await expect(
configPanel.getByText('Bindable Event Range', { exact: true }).last(),
configPanel.getByText('Events & tools', { exact: true }).last(),
).toBeVisible();
const eventPicker = configPanel.getByRole('combobox', {
name: 'Event Range',
const eventPicker = configPanel.getByRole('button', {
name: 'Add event',
exact: true,
});
await expect(eventPicker).toBeVisible();
await expect(configPanel.getByRole('textbox')).toHaveCount(0);
await expect(configPanel.getByRole('textbox')).toHaveCount(1);
await expect(
configPanel.getByRole('textbox', { name: 'Search tools…' }),
).toBeVisible();
await eventPicker.click();
await expect(
page.getByRole('option').filter({ hasText: 'message.received' }),
).toBeVisible();
await expect(page.getByRole('group', { name: 'Messages' })).toHaveCount(1);
await expect(page.getByRole('group', { name: 'Groups' })).toHaveCount(1);
await expect(
page.getByRole('option').filter({ hasText: 'group.*' }),
).toBeVisible();
await expect(
page
.locator('[cmdk-group-heading]')
.getByText('Messages', { exact: true }),
).toBeVisible();
await expect(
page.locator('[cmdk-group-heading]').getByText('Groups', { exact: true }),
).toBeVisible();
await page
.getByRole('option')
.filter({ hasText: 'message.*' })
@@ -189,7 +200,7 @@ test.describe('processor detail workbench', () => {
}
if (
request.method() === 'POST' &&
path === '/api/v1/agents/agent-workbench/debug'
path === '/api/v1/agents/agent-workbench/debug/stream'
) {
requests.push('debug');
}
@@ -237,15 +248,17 @@ test.describe('processor detail workbench', () => {
}) => {
await installLangBotApiMocks(page, { authenticated: true });
await page.route(
'**/api/v1/agents/agent-workbench/debug',
'**/api/v1/agents/agent-workbench/debug/stream',
async (route) => {
await route.fulfill({
status: 422,
contentType: 'application/json',
body: JSON.stringify({
code: 'dify.config_invalid',
msg: 'api-key is required',
}),
status: 200,
contentType: 'application/x-ndjson',
body:
JSON.stringify({
kind: 'error',
code: 'dify.config_invalid',
msg: 'api-key is required',
}) + '\n',
});
},
);
@@ -454,8 +467,8 @@ test.describe('processor detail workbench', () => {
const appShell = page.locator('[class*="group/sidebar-wrapper"]');
const sidebarInset = page.locator('[data-slot="sidebar-inset"]');
await expect(appShell).toHaveCSS('overflow', 'clip');
await expect(sidebarInset).toHaveCSS('overflow', 'clip');
await expect(appShell).toHaveCSS('overflow', 'hidden');
await expect(sidebarInset).toHaveCSS('overflow', 'hidden');
await expect
.poll(() => appShell.evaluate((element) => element.scrollTop))
.toBe(0);
+105 -37
View File
@@ -2,69 +2,137 @@ import assert from 'node:assert/strict';
import fs from 'node:fs';
import test from 'node:test';
import ts from 'typescript';
const source = fs.readFileSync(new URL('../../src/app/home/agents/components/debug-execution.ts', import.meta.url), 'utf8');
const source = fs.readFileSync(
new URL(
'../../src/app/home/agents/components/debug-execution.ts',
import.meta.url,
),
'utf8',
);
const module = { exports: {} };
new Function('exports', ts.transpileModule(source, { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ES2022 } }).outputText)(module.exports);
new Function(
'exports',
ts.transpileModule(source, {
compilerOptions: {
module: ts.ModuleKind.CommonJS,
target: ts.ScriptTarget.ES2022,
},
}).outputText,
)(module.exports);
const { executionSteps } = module.exports;
const event = (type, data) => ({type, data});
const event = (type, data) => ({ type, data });
test('separates streamed thinking and text, replaces final snapshot without duplication', () => {
assert.deepEqual(executionSteps([
event('message.delta', {chunk: {content:'<think>plan'}}),
event('message.delta', {chunk: {content:'</think>hello'}}),
event('message.completed', {message: {content:'<think>plan</think>hello'}}),
event('run.completed', {message: {content:'hello'}}),
]), [{kind:'message', text:'hello', reasoning:'plan'}]);
assert.deepEqual(
executionSteps([
event('message.delta', { chunk: { content: '<think>plan' } }),
event('message.delta', { chunk: { content: '</think>hello' } }),
event('message.completed', {
message: { content: '<think>plan</think>hello' },
}),
event('run.completed', { message: { content: 'hello' } }),
]),
[{ kind: 'message', text: 'hello', reasoning: 'plan' }],
);
});
test('retains structured reasoning and tool parameters/results in order', () => {
const steps = executionSteps([
event('message.delta', {chunk: {provider_specific_fields:{reasoning_content:'plan'}}}),
event('message.completed', {message: {content:''}}),
event('tool.call.started', {tool_call_id:'1',tool_name:'exec', parameters:{command:'echo hi'}}),
event('tool.call.started', {tool_call_id:'2',tool_name:'exec', parameters:{command:'bad'}}),
event('tool.call.completed', {tool_call_id:'2',tool_name:'exec', error:'failed'}),
event('tool.call.completed', {tool_call_id:'1',tool_name:'exec', result:{stdout:'hi'}}),
event('message.delta', {
chunk: { provider_specific_fields: { reasoning_content: 'plan' } },
}),
event('message.completed', { message: { content: '' } }),
event('tool.call.started', {
tool_call_id: '1',
tool_name: 'exec',
parameters: { command: 'echo hi' },
}),
event('tool.call.started', {
tool_call_id: '2',
tool_name: 'exec',
parameters: { command: 'bad' },
}),
event('tool.call.completed', {
tool_call_id: '2',
tool_name: 'exec',
error: 'failed',
}),
event('tool.call.completed', {
tool_call_id: '1',
tool_name: 'exec',
result: { stdout: 'hi' },
}),
event('run.failed', {}),
]);
assert.equal(steps[0].reasoning, 'plan');
assert.equal(steps[1].parameters.command, 'echo hi');
assert.deepEqual(steps[1].result, {stdout:'hi'});
assert.deepEqual(steps[1].result, { stdout: 'hi' });
assert.equal(steps[2].status, 'failed');
assert.equal(steps[2].error, 'failed');
});
test('replaces LocalAgent cumulative snapshots instead of repeating text', () => {
assert.deepEqual(executionSteps([
event('message.delta', {chunk: {content:'hello', msg_sequence:1}}),
event('message.delta', {chunk: {content:'hello world', msg_sequence:2}}),
event('message.delta', {chunk: {content:'hello world', msg_sequence:3, is_final:true}}),
]), [{kind:'message', text:'hello world', reasoning:''}]);
assert.deepEqual(
executionSteps([
event('message.delta', { chunk: { content: 'hello', msg_sequence: 1 } }),
event('message.delta', {
chunk: { content: 'hello world', msg_sequence: 2 },
}),
event('message.delta', {
chunk: { content: 'hello world', msg_sequence: 3, is_final: true },
}),
]),
[{ kind: 'message', text: 'hello world', reasoning: '' }],
);
});
test('shows failed tool results even when the call transport completed', () => {
const steps = executionSteps([
event('tool.call.started', {tool_call_id:'exit7', tool_name:'exec', parameters:{command:'exit 7'}}),
event('tool.call.completed', {tool_call_id:'exit7', tool_name:'exec', result:{ok:false, exit_code:7, stderr:'expected'}}),
event('tool.call.started', {
tool_call_id: 'exit7',
tool_name: 'exec',
parameters: { command: 'exit 7' },
}),
event('tool.call.completed', {
tool_call_id: 'exit7',
tool_name: 'exec',
result: { ok: false, exit_code: 7, stderr: 'expected' },
}),
]);
assert.equal(steps[0].status,'failed');
assert.equal(steps[0].result.exit_code,7);
assert.equal(steps[0].status, 'failed');
assert.equal(steps[0].result.exit_code, 7);
});
test('does not repeat prior thinking across LocalAgent tool turns', () => {
const prefix = '<think>first thought</think>';
const steps = executionSteps([
event('message.delta', {chunk:{content:prefix, msg_sequence:1}}),
event('tool.call.started', {tool_call_id:'w',tool_name:'write',parameters:{path:'/workspace/a'}}),
event('tool.call.completed', {tool_call_id:'w',tool_name:'write',result:{ok:true}}),
event('message.delta', {chunk:{content:prefix+'now read',msg_sequence:1}}),
event('tool.call.started', {tool_call_id:'r',tool_name:'read'}),
event('tool.call.completed', {tool_call_id:'r',tool_name:'read',result:{ok:true}}),
event('message.delta', {chunk:{content:prefix+'now read'+'done',msg_sequence:1}}),
event('message.completed', {message:{content:'done'}}),
event('message.delta', { chunk: { content: prefix, msg_sequence: 1 } }),
event('tool.call.started', {
tool_call_id: 'w',
tool_name: 'write',
parameters: { path: '/workspace/a' },
}),
event('tool.call.completed', {
tool_call_id: 'w',
tool_name: 'write',
result: { ok: true },
}),
event('message.delta', {
chunk: { content: prefix + 'now read', msg_sequence: 1 },
}),
event('tool.call.started', { tool_call_id: 'r', tool_name: 'read' }),
event('tool.call.completed', {
tool_call_id: 'r',
tool_name: 'read',
result: { ok: true },
}),
event('message.delta', {
chunk: { content: prefix + 'now read' + 'done', msg_sequence: 1 },
}),
event('message.completed', { message: { content: 'done' } }),
]);
const messages = steps.filter(s=>s.kind==='message');
const messages = steps.filter((s) => s.kind === 'message');
assert.deepEqual(messages, [
{kind:'message',text:'',reasoning:'first thought'},
{kind:'message',text:'now read',reasoning:''},
{kind:'message',text:'done',reasoning:''},
{ kind: 'message', text: '', reasoning: 'first thought' },
{ kind: 'message', text: 'now read', reasoning: '' },
{ kind: 'message', text: 'done', reasoning: '' },
]);
});
+108
View File
@@ -0,0 +1,108 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import test from 'node:test';
import ts from 'typescript';
const source = fs.readFileSync(
new URL(
'../../src/app/home/components/model-availability/sort-models.ts',
import.meta.url,
),
'utf8',
);
const exports = {};
new Function(
'exports',
ts.transpileModule(source, {
compilerOptions: {
module: ts.ModuleKind.CommonJS,
target: ts.ScriptTarget.ES2022,
},
}).outputText,
)(exports);
const { sortModelsByCatalog } = exports;
const model = (name) => ({ uuid: name, name });
const metadata = (listed_at, up, input_credits = 10, output_credits = 20) => ({
listed_at,
availability: { up },
input_credits,
output_credits,
});
const names = (models) => models.map((m) => m.name);
test('newest UTC day precedes availability; same-day time is ignored', () => {
const items = ['old-up', 'new-down', 'new-up'].map(model);
const catalog = {
'old-up': metadata('2026-09-08T23:59:59Z', true),
'new-down': metadata('2026-09-09T23:59:59Z', false),
'new-up': metadata('2026-09-10T01:00:00+08:00', true),
};
assert.deepEqual(names(sortModelsByCatalog(items, catalog)), [
'new-up',
'new-down',
'old-up',
]);
assert.deepEqual(names(items), ['old-up', 'new-down', 'new-up']);
});
test('same-day availability ranks up, unknown, down before prices', () => {
const catalog = {
down: metadata('2026-09-09', false, 0, 0),
unknown: metadata('2026-09-09', null, 1, 1),
up: metadata('2026-09-09', true, 100, 100),
};
assert.deepEqual(
names(sortModelsByCatalog(Object.keys(catalog).map(model), catalog)),
['up', 'unknown', 'down'],
);
});
test('prices compare input then output; free prices remain valid', () => {
const catalog = {
expensive: metadata('2026-09-09', true, 20, 1),
'output-high': metadata('2026-09-09', true, 10, 50),
'output-low': metadata('2026-09-09', true, 10, 20),
free: metadata('2026-09-09', true, 0, 0),
missing: metadata('2026-09-09', true, null, null),
invalid: metadata('2026-09-09', true, NaN, -1),
};
assert.deepEqual(
names(sortModelsByCatalog(Object.keys(catalog).map(model), catalog)),
['free', 'output-low', 'output-high', 'expensive', 'invalid', 'missing'],
);
});
test('unknown dates sort after known dates and missing catalog entries are safe', () => {
const catalog = {
known: metadata('2026-01-01', false, 100, 100),
invalid: metadata('invalid', true),
missing: metadata(null, true),
};
assert.deepEqual(
names(
sortModelsByCatalog(
['missing', 'absent', 'invalid', 'known'].map(model),
catalog,
),
),
['known', 'invalid', 'missing', 'absent'],
);
});
test('UUID lookup takes precedence, name lookup works, empty metadata preserves order', () => {
const items = [{ uuid: 'local-id', name: 'alias' }, model('other')];
const catalog = {
alias: metadata('2026-09-09', true),
other: metadata('2026-09-08', true),
};
assert.deepEqual(names(sortModelsByCatalog(items, catalog)), [
'alias',
'other',
]);
catalog['local-id'] = metadata('2026-09-07', true);
assert.deepEqual(names(sortModelsByCatalog(items, catalog)), [
'other',
'alias',
]);
assert.deepEqual(sortModelsByCatalog(items, {}), items);
});

Some files were not shown because too many files have changed in this diff Show More