mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-09-15 06:17:14 +00:00
Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f7cef62290 | |||
| 89d42bedaa | |||
| 184037a427 | |||
| e913658e03 | |||
| 1277c6da07 | |||
| fa15f48fd7 | |||
| 18c1ed93b8 | |||
| 8b85876a19 | |||
| 18b84566c3 | |||
| 50d544aa6f | |||
| 33b4035140 | |||
| a3509b3626 | |||
| e631da0073 |
@@ -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
@@ -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" }
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
+1
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'],
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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')
|
||||
+1
@@ -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
|
||||
|
||||
|
||||
+1
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'},
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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'},
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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...',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: 'インストール中...',
|
||||
|
||||
@@ -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: 'Завершено',
|
||||
|
||||
@@ -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: 'เสร็จสิ้น',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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: '正在安装...',
|
||||
|
||||
@@ -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: '已完成',
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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\/([^/]+)$/);
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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: '' },
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user