Compare commits

...

7 Commits

Author SHA1 Message Date
dadachann 2383485b86 refactor: move admin management into session monitor, fix session_id enum format
- Remove standalone admins tab, replace with BotAdminsDialog in session monitor
- Add admin toggle button inline in chat header next to Active status
- Add BotAdminsDialog component with useBotAdmins hook
- Fix session_id written as LauncherTypes.PERSON_xxx instead of person_xxx
  (monitoring_helper.py x4, pipelinemgr.py x1 missing .value on launcher_type)
- Fix duplicate platform/person label in session list and chat header
- Migrate existing malformed session_id records in DB
2026-06-27 05:17:52 -04:00
dadachann 9ab346c678 fix(ci): eslint-prettier fix BackendClient.ts single-param formatting 2026-06-26 12:53:47 -04:00
dadachann a0ea0704fc fix(ci): prettier format BackendClient.ts and i18n locales 2026-06-26 12:52:49 -04:00
dadachann 6c5b01fa3c fix(ci): ruff/prettier format, fix test_importutil assertion 2026-06-26 12:50:26 -04:00
dadachann 2ef3aebe16 feat(platform): migrate bot admins from config.yaml to database
- Add BotAdmin ORM model (bot_admins table) scoped per bot_uuid
- Add Alembic migration 0007 to create table and migrate legacy config admins
- Remove top-level admins key from config.yaml template
- Add GET/POST/DELETE /api/v1/platform/bots/<uuid>/admins endpoints
- Update cmdmgr privilege check to query bot_admins table (bot-scoped)
- Add BotAdminsPanel frontend component in bot detail sessions tab
- Add i18n keys (zh-Hans, en-US)
2026-06-26 12:43:30 -04:00
彼方 48905ea080 feat(plugin): report deferred response delivery failures (#2287)
* feat(plugin): report deferred response delivery failures

* style: fix ruff format issues in plugin_diagnostics and test_handler_actions

---------

Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com>
2026-06-26 23:45:10 +08:00
Hyu ddb77fc43c fix(api): guard /set-password with allow_modify_login_info (#2288)
The /change-password and /bind-space endpoints already refuse when
system.allow_modify_login_info is false, but /set-password did not,
leaving a path to alter login credentials on locked-down deployments
(e.g. public demo instances). Apply the same guard.

Co-authored-by: dadachann <185672915+dadachann@users.noreply.github.com>
2026-06-26 16:35:50 +08:00
27 changed files with 1558 additions and 279 deletions
@@ -18,7 +18,6 @@ class BotsRouterGroup(group.RouterGroup):
@self.route('/<bot_uuid>', methods=['GET', 'PUT', 'DELETE'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY) @self.route('/<bot_uuid>', methods=['GET', 'PUT', 'DELETE'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
async def _(bot_uuid: str) -> str: async def _(bot_uuid: str) -> str:
if quart.request.method == 'GET': if quart.request.method == 'GET':
# 返回运行时信息,包括webhook地址等
bot = await self.ap.bot_service.get_runtime_bot_info(bot_uuid) bot = await self.ap.bot_service.get_runtime_bot_info(bot_uuid)
if bot is None: if bot is None:
return self.http_status(404, -1, 'bot not found') return self.http_status(404, -1, 'bot not found')
@@ -37,30 +36,21 @@ class BotsRouterGroup(group.RouterGroup):
from_index = json_data.get('from_index', -1) from_index = json_data.get('from_index', -1)
max_count = json_data.get('max_count', 10) max_count = json_data.get('max_count', 10)
logs, total_count = await self.ap.bot_service.list_event_logs(bot_uuid, from_index, max_count) logs, total_count = await self.ap.bot_service.list_event_logs(bot_uuid, from_index, max_count)
return self.success( return self.success(data={'logs': logs, 'total_count': total_count})
data={
'logs': logs,
'total_count': total_count,
}
)
@self.route('/<bot_uuid>/send_message', methods=['POST'], auth_type=group.AuthType.API_KEY) @self.route('/<bot_uuid>/send_message', methods=['POST'], auth_type=group.AuthType.API_KEY)
async def _(bot_uuid: str) -> str: async def _(bot_uuid: str) -> str:
"""Send message to a specific target via bot"""
json_data = await quart.request.json json_data = await quart.request.json
target_type = json_data.get('target_type') target_type = json_data.get('target_type')
target_id = json_data.get('target_id') target_id = json_data.get('target_id')
message_chain_data = json_data.get('message_chain') message_chain_data = json_data.get('message_chain')
# Validate required fields
if not target_type: if not target_type:
return self.http_status(400, -1, 'target_type is required') return self.http_status(400, -1, 'target_type is required')
if not target_id: if not target_id:
return self.http_status(400, -1, 'target_id is required') return self.http_status(400, -1, 'target_id is required')
if not message_chain_data: if not message_chain_data:
return self.http_status(400, -1, 'message_chain is required') return self.http_status(400, -1, 'message_chain is required')
# Validate target_type
if target_type not in ['person', 'group']: if target_type not in ['person', 'group']:
return self.http_status(400, -1, 'target_type must be either "person" or "group"') return self.http_status(400, -1, 'target_type must be either "person" or "group"')
@@ -72,3 +62,29 @@ class BotsRouterGroup(group.RouterGroup):
traceback.print_exc() traceback.print_exc()
return self.http_status(500, -1, f'Failed to send message: {str(e)}') return self.http_status(500, -1, f'Failed to send message: {str(e)}')
# ============ Bot Admins ============
@self.route('/<bot_uuid>/admins', methods=['GET', 'POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
async def _(bot_uuid: str) -> str:
if quart.request.method == 'GET':
admins = await self.ap.bot_service.get_bot_admins(bot_uuid)
return self.success(data={'admins': admins})
elif quart.request.method == 'POST':
json_data = await quart.request.json
launcher_type = json_data.get('launcher_type', '').strip()
launcher_id = str(json_data.get('launcher_id', '')).strip()
if not launcher_type or not launcher_id:
return self.http_status(400, -1, 'launcher_type and launcher_id are required')
try:
admin_id = await self.ap.bot_service.add_bot_admin(bot_uuid, launcher_type, launcher_id)
return self.success(data={'id': admin_id})
except Exception as e:
return self.http_status(409, -1, str(e))
@self.route(
'/<bot_uuid>/admins/<int:admin_id>', methods=['DELETE'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY
)
async def _(bot_uuid: str, admin_id: int) -> str:
await self.ap.bot_service.delete_bot_admin(bot_uuid, admin_id)
return self.success()
@@ -195,6 +195,13 @@ class UserRouterGroup(group.RouterGroup):
@self.route('/set-password', methods=['POST'], auth_type=group.AuthType.USER_TOKEN) @self.route('/set-password', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
async def _(user_email: str) -> str: async def _(user_email: str) -> str:
"""Set password for Space account (first time) or change password""" """Set password for Space account (first time) or change password"""
# Check if modifying login info is allowed
allow_modify_login_info = self.ap.instance_config.data.get('system', {}).get(
'allow_modify_login_info', True
)
if not allow_modify_login_info:
return self.http_status(403, -1, 'Modifying login info is disabled')
json_data = await quart.request.json json_data = await quart.request.json
new_password = json_data.get('new_password') new_password = json_data.get('new_password')
current_password = json_data.get('current_password') current_password = json_data.get('current_password')
+32
View File
@@ -199,3 +199,35 @@ class BotService:
# Send message via adapter # Send message via adapter
await runtime_bot.adapter.send_message(target_type, str(target_id), message_chain) await runtime_bot.adapter.send_message(target_type, str(target_id), message_chain)
# ============ Bot Admins ============
async def get_bot_admins(self, bot_uuid: str) -> list[dict]:
from ....entity.persistence import bot as persistence_bot
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(persistence_bot.BotAdmin).where(persistence_bot.BotAdmin.bot_uuid == bot_uuid)
)
return [{'id': r.id, 'launcher_type': r.launcher_type, 'launcher_id': r.launcher_id} for r in result.all()]
async def add_bot_admin(self, bot_uuid: str, launcher_type: str, launcher_id: str) -> int:
from ....entity.persistence import bot as persistence_bot
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.insert(persistence_bot.BotAdmin).values(
bot_uuid=bot_uuid,
launcher_type=launcher_type,
launcher_id=launcher_id,
)
)
return result.inserted_primary_key[0]
async def delete_bot_admin(self, bot_uuid: str, admin_id: int) -> None:
from ....entity.persistence import bot as persistence_bot
await self.ap.persistence_mgr.execute_async(
sqlalchemy.delete(persistence_bot.BotAdmin).where(
persistence_bot.BotAdmin.bot_uuid == bot_uuid,
persistence_bot.BotAdmin.id == admin_id,
)
)
+11 -1
View File
@@ -84,7 +84,17 @@ class CommandManager:
privilege = 1 privilege = 1
if f'{query.launcher_type.value}_{query.launcher_id}' in self.ap.instance_config.data['admins']: import sqlalchemy as _sa
from ..entity.persistence.bot import BotAdmin as _BotAdmin
_admins = await self.ap.persistence_mgr.execute_async(
_sa.select(_BotAdmin).where(
_BotAdmin.bot_uuid == (query.bot_uuid or ''),
_BotAdmin.launcher_type == query.launcher_type.value,
_BotAdmin.launcher_id == str(query.launcher_id),
)
)
if _admins.first() is not None:
privilege = 2 privilege = 2
ctx = command_context.ExecuteContext( ctx = command_context.ExecuteContext(
+14
View File
@@ -3,6 +3,20 @@ import sqlalchemy
from .base import Base from .base import Base
class BotAdmin(Base):
"""Bot admin — a launcher that has admin privilege for a specific bot's commands"""
__tablename__ = 'bot_admins'
id = sqlalchemy.Column(sqlalchemy.Integer, primary_key=True, autoincrement=True)
bot_uuid = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
launcher_type = sqlalchemy.Column(sqlalchemy.String(64), nullable=False)
launcher_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now())
__table_args__ = (sqlalchemy.UniqueConstraint('bot_uuid', 'launcher_type', 'launcher_id', name='uq_bot_admin'),)
class Bot(Base): class Bot(Base):
"""Bot""" """Bot"""
@@ -0,0 +1,84 @@
"""add bot_admins table and migrate config admins
Revision ID: 0007_add_bot_admins
Revises: 0006_normalize_mcp_remote_mode
Create Date: 2026-06-26
"""
import sqlalchemy as sa
from alembic import op
revision = '0007_add_bot_admins'
down_revision = '0006_normalize_mcp_remote_mode'
branch_labels = None
depends_on = None
def upgrade() -> None:
conn = op.get_bind()
if 'bot_admins' in sa.inspect(conn).get_table_names():
return
op.create_table(
'bot_admins',
sa.Column('id', sa.Integer, primary_key=True, autoincrement=True),
sa.Column('bot_uuid', sa.String(255), nullable=False),
sa.Column('launcher_type', sa.String(64), nullable=False),
sa.Column('launcher_id', sa.String(255), nullable=False),
sa.Column('created_at', sa.DateTime, nullable=False, server_default=sa.func.now()),
sa.UniqueConstraint('bot_uuid', 'launcher_type', 'launcher_id', name='uq_bot_admin'),
)
# Migrate old config-based admins into the first bot (best-effort)
inspector = sa.inspect(conn)
tables = inspector.get_table_names()
if 'bots' not in tables:
return
# Read the first bot uuid
row = conn.execute(sa.text('SELECT uuid FROM bots ORDER BY created_at LIMIT 1')).first()
if row is None:
return
first_bot_uuid = row[0]
# Read instance_config metadata key that holds the admins list
if 'metadata' not in tables:
return
meta_row = conn.execute(sa.text("SELECT value FROM metadata WHERE key = 'instance_config'")).first()
if meta_row is None:
return
import json
try:
cfg = json.loads(meta_row[0])
except Exception:
return
admins = cfg.get('admins', [])
for entry in admins:
parts = entry.split('_', 1)
if len(parts) != 2:
continue
launcher_type, launcher_id = parts
try:
conn.execute(
sa.text(
'INSERT OR IGNORE INTO bot_admins (bot_uuid, launcher_type, launcher_id) VALUES (:bu, :lt, :li)'
),
{'bu': first_bot_uuid, 'lt': launcher_type, 'li': launcher_id},
)
except Exception:
pass
# Remove admins key from stored config
if 'admins' in cfg:
del cfg['admins']
conn.execute(
sa.text("UPDATE metadata SET value = :v WHERE key = 'instance_config'"),
{'v': json.dumps(cfg)},
)
def downgrade() -> None:
op.drop_table('bot_admins')
@@ -32,7 +32,7 @@ class MonitoringHelper:
"""Record the start of query processing, returns message_id""" """Record the start of query processing, returns message_id"""
try: try:
# Check if session exists, if not, record session start # Check if session exists, if not, record session start
session_id = f'{query.launcher_type}_{query.launcher_id}' session_id = f'{query.launcher_type.value if hasattr(query.launcher_type, "value") else query.launcher_type}_{query.launcher_id}'
# Get sender name from message event # Get sender name from message event
sender_name = None sender_name = None
@@ -137,7 +137,7 @@ class MonitoringHelper:
): ):
"""Record bot response message to monitoring""" """Record bot response message to monitoring"""
try: try:
session_id = f'{query.launcher_type}_{query.launcher_id}' session_id = f'{query.launcher_type.value if hasattr(query.launcher_type, "value") else query.launcher_type}_{query.launcher_id}'
# Get sender name from message event # Get sender name from message event
sender_name = None sender_name = None
@@ -202,7 +202,7 @@ class MonitoringHelper:
) -> str: ) -> str:
"""Record query processing error, returns message_id""" """Record query processing error, returns message_id"""
try: try:
session_id = f'{query.launcher_type}_{query.launcher_id}' session_id = f'{query.launcher_type.value if hasattr(query.launcher_type, "value") else query.launcher_type}_{query.launcher_id}'
# Get sender name from message event # Get sender name from message event
sender_name = None sender_name = None
@@ -268,7 +268,7 @@ class MonitoringHelper:
): ):
"""Record LLM call""" """Record LLM call"""
try: try:
session_id = f'{query.launcher_type}_{query.launcher_id}' session_id = f'{query.launcher_type.value if hasattr(query.launcher_type, "value") else query.launcher_type}_{query.launcher_id}'
await ap.monitoring_service.record_llm_call( await ap.monitoring_service.record_llm_call(
bot_id=bot_id, bot_id=bot_id,
+1 -1
View File
@@ -178,7 +178,7 @@ class RuntimePipeline:
bot_name = query.variables.get('_monitoring_bot_name', 'Unknown') bot_name = query.variables.get('_monitoring_bot_name', 'Unknown')
pipeline_name = query.variables.get('_monitoring_pipeline_name', 'Unknown') pipeline_name = query.variables.get('_monitoring_pipeline_name', 'Unknown')
message_id = query.variables.get('_monitoring_message_id', '') message_id = query.variables.get('_monitoring_message_id', '')
session_id = f'{query.launcher_type}_{query.launcher_id}' session_id = f'{query.launcher_type.value if hasattr(query.launcher_type, "value") else query.launcher_type}_{query.launcher_id}'
# Update message status to error # Update message status to error
if message_id: if message_id:
@@ -0,0 +1,274 @@
from __future__ import annotations
import traceback
import weakref
from dataclasses import dataclass, field
from typing import Any
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
import langbot_plugin.api.entities.builtin.platform.message as platform_message
@dataclass(frozen=True)
class PluginResponseSource:
plugin: dict[str, str]
event_name: str | None = None
is_approximate: bool = False
@dataclass
class QueryDiagnosticState:
pending_by_chain_id: dict[int, list[PluginResponseSource]] = field(default_factory=dict)
by_response_index: dict[int, list[PluginResponseSource]] = field(default_factory=dict)
finalizer: weakref.finalize | None = None
_QUERY_STATES: dict[int, QueryDiagnosticState] = {}
def record_plugin_response_source(
query: pipeline_query.Query,
response_index: int,
response_sources: list[dict[str, Any]] | None,
emitted_plugins: list[dict[str, Any]] | None = None,
event_name: str | None = None,
) -> None:
plugin_sources = _build_plugin_sources(response_sources, emitted_plugins, event_name)
if not plugin_sources:
return
state = _get_or_create_query_state(query)
state.by_response_index[response_index] = plugin_sources
def record_last_plugin_response_source(
query: pipeline_query.Query,
response_sources: list[dict[str, Any]] | None,
emitted_plugins: list[dict[str, Any]] | None = None,
event_name: str | None = None,
) -> None:
record_plugin_response_source(
query,
len(query.resp_message_chain) - 1,
response_sources,
emitted_plugins,
event_name,
)
def record_pending_plugin_response_source(
query: pipeline_query.Query,
message_chain: platform_message.MessageChain,
response_sources: list[dict[str, Any]] | None,
emitted_plugins: list[dict[str, Any]] | None = None,
event_name: str | None = None,
) -> None:
plugin_sources = _build_plugin_sources(response_sources, emitted_plugins, event_name)
if not plugin_sources:
return
state = _get_or_create_query_state(query)
state.pending_by_chain_id[id(message_chain)] = plugin_sources
def consume_pending_plugin_response_source(
query: pipeline_query.Query,
message_chain: platform_message.MessageChain,
response_index: int,
) -> None:
state = _get_query_state(query)
if state is None:
return
source = state.pending_by_chain_id.pop(id(message_chain), None)
if source is None:
return
state.by_response_index[response_index] = source
def clear_response_source(query: pipeline_query.Query, response_index: int) -> None:
state = _get_query_state(query)
if state is None:
return
state.by_response_index.pop(response_index, None)
_discard_query_state_if_empty(query)
async def notify_response_delivery_failure(
ap: Any,
query: pipeline_query.Query,
response_index: int,
message_chain: platform_message.MessageChain,
error: Exception,
) -> None:
try:
plugin_refs = _get_response_sources(query, response_index)
if not plugin_refs:
return
connector = getattr(ap, 'plugin_connector', None)
if connector is None or not hasattr(connector, 'notify_plugin_diagnostic'):
return
for source in plugin_refs:
payload = _build_delivery_failure_payload(
plugin_ref=source.plugin,
event_name=source.event_name,
is_approximate=source.is_approximate,
query=query,
response_index=response_index,
message_chain=message_chain,
error=error,
)
try:
await connector.notify_plugin_diagnostic(payload)
except Exception as diag_error:
_debug(ap, f'Plugin diagnostic forwarding failed: {diag_error}')
except Exception as diag_error:
_debug(ap, f'Plugin diagnostic forwarding skipped: {diag_error}')
def get_emitted_plugins(event_ctx: Any) -> list[dict[str, Any]]:
emitted_plugins = getattr(event_ctx, '_emitted_plugins', [])
return emitted_plugins if isinstance(emitted_plugins, list) else []
def get_response_sources(event_ctx: Any) -> list[dict[str, Any]] | None:
event_attrs = vars(event_ctx)
if '_response_sources' not in event_attrs:
return None
response_sources = event_attrs['_response_sources']
return response_sources if isinstance(response_sources, list) else []
def _get_or_create_query_state(query: pipeline_query.Query) -> QueryDiagnosticState:
query_key = id(query)
state = _QUERY_STATES.get(query_key)
if state is not None:
return state
state = QueryDiagnosticState()
try:
state.finalizer = weakref.finalize(query, _discard_query_state, query_key)
except TypeError:
state.finalizer = None
_QUERY_STATES[query_key] = state
return state
def _get_query_state(query: pipeline_query.Query) -> QueryDiagnosticState | None:
return _QUERY_STATES.get(id(query))
def _discard_query_state(query_key: int) -> None:
_QUERY_STATES.pop(query_key, None)
def _discard_query_state_if_empty(query: pipeline_query.Query) -> None:
query_key = id(query)
state = _QUERY_STATES.get(query_key)
if state is None:
return
if state.pending_by_chain_id or state.by_response_index:
return
if state.finalizer is not None:
state.finalizer.detach()
_discard_query_state(query_key)
def _get_response_sources(
query: pipeline_query.Query,
response_index: int,
) -> list[PluginResponseSource]:
state = _get_query_state(query)
if state is None:
return []
return state.by_response_index.get(response_index, [])
def _extract_plugin_ref(plugin: Any) -> dict[str, str] | None:
manifest = plugin.get('manifest') if isinstance(plugin, dict) else None
metadata = manifest.get('metadata') if isinstance(manifest, dict) else None
if not isinstance(metadata, dict):
return None
author = metadata.get('author')
name = metadata.get('name')
if not author or not name:
return None
return {'author': str(author), 'name': str(name)}
def _extract_response_source_plugin_ref(source: Any) -> dict[str, str] | None:
if not isinstance(source, dict):
return None
if source.get('kind') != 'reply_message_chain':
return None
plugin_ref = source.get('plugin')
if not isinstance(plugin_ref, dict):
return None
author = plugin_ref.get('author')
name = plugin_ref.get('name')
if not author or not name:
return None
return {'author': str(author), 'name': str(name)}
def _build_plugin_sources(
response_sources: list[dict[str, Any]] | None,
emitted_plugins: list[dict[str, Any]] | None,
event_name: str | None,
) -> list[PluginResponseSource]:
if response_sources is not None:
plugin_refs = [_extract_response_source_plugin_ref(source) for source in response_sources]
return [
PluginResponseSource(plugin=plugin, event_name=event_name) for plugin in plugin_refs if plugin is not None
]
if emitted_plugins:
plugin_refs = [_extract_plugin_ref(plugin) for plugin in emitted_plugins]
return [
PluginResponseSource(plugin=plugin, event_name=event_name, is_approximate=True)
for plugin in plugin_refs
if plugin is not None
]
return []
def _debug(ap: Any, message: str) -> None:
logger = getattr(ap, 'logger', None)
if logger is not None:
logger.debug(message)
def _build_delivery_failure_payload(
plugin_ref: dict[str, str],
event_name: str | None,
is_approximate: bool,
query: pipeline_query.Query,
response_index: int,
message_chain: platform_message.MessageChain,
error: Exception,
) -> dict[str, Any]:
details: dict[str, Any] = {
'message_component_types': [component.__class__.__name__ for component in message_chain],
'message_preview': str(message_chain)[:200],
}
if is_approximate:
details['attribution_warning'] = (
'This diagnostic was delivered to all plugins that handled the event because the '
'plugin runtime did not report the exact reply_message_chain source.'
)
return {
'level': 'ERROR',
'code': 'response_delivery_failed',
'message': 'Failed to deliver a plugin-provided response message.',
'plugin': plugin_ref,
'query': {
'query_id': query.query_id,
'event_name': event_name or query.message_event.__class__.__name__,
'stage': query.current_stage_name or 'SendResponseBackStage',
'response_index': response_index,
},
'details': details,
'delivery': {
'error_type': error.__class__.__name__,
'error_message': str(error),
'traceback': traceback.format_exception_only(type(error), error)[-1].strip(),
},
}
@@ -9,6 +9,7 @@ from datetime import datetime
from .. import handler from .. import handler
from ... import entities from ... import entities
from ... import plugin_diagnostics
from ....provider import runner as runner_module from ....provider import runner as runner_module
import langbot_plugin.api.entities.events as events import langbot_plugin.api.entities.events as events
@@ -58,6 +59,13 @@ class ChatMessageHandler(handler.MessageHandler):
if event_ctx.is_prevented_default(): if event_ctx.is_prevented_default():
if event_ctx.event.reply_message_chain is not None: if event_ctx.event.reply_message_chain is not None:
mc = event_ctx.event.reply_message_chain mc = event_ctx.event.reply_message_chain
plugin_diagnostics.record_pending_plugin_response_source(
query,
mc,
plugin_diagnostics.get_response_sources(event_ctx),
plugin_diagnostics.get_emitted_plugins(event_ctx),
event.event_name,
)
query.resp_messages.append(mc) query.resp_messages.append(mc)
yield entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query) yield entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query)
@@ -4,6 +4,7 @@ import typing
from .. import handler from .. import handler
from ... import entities from ... import entities
from ... import plugin_diagnostics
import langbot_plugin.api.entities.builtin.provider.message as provider_message import langbot_plugin.api.entities.builtin.provider.message as provider_message
import langbot_plugin.api.entities.builtin.provider.session as provider_session import langbot_plugin.api.entities.builtin.provider.session as provider_session
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
@@ -52,6 +53,13 @@ class CommandHandler(handler.MessageHandler):
if event_ctx.is_prevented_default(): if event_ctx.is_prevented_default():
if event_ctx.event.reply_message_chain is not None: if event_ctx.event.reply_message_chain is not None:
mc = event_ctx.event.reply_message_chain mc = event_ctx.event.reply_message_chain
plugin_diagnostics.record_pending_plugin_response_source(
query,
mc,
plugin_diagnostics.get_response_sources(event_ctx),
plugin_diagnostics.get_emitted_plugins(event_ctx),
event.event_name,
)
query.resp_messages.append(mc) query.resp_messages.append(mc)
+30 -14
View File
@@ -9,6 +9,7 @@ import langbot_plugin.api.entities.builtin.platform.message as platform_message
import langbot_plugin.api.entities.builtin.provider.message as provider_message import langbot_plugin.api.entities.builtin.provider.message as provider_message
from .. import stage, entities from .. import stage, entities
from .. import plugin_diagnostics
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
@@ -39,20 +40,35 @@ class SendResponseBackStage(stage.PipelineStage):
has_chunks = any(isinstance(msg, provider_message.MessageChunk) for msg in query.resp_messages) has_chunks = any(isinstance(msg, provider_message.MessageChunk) for msg in query.resp_messages)
# TODO 命令与流式的兼容性问题 # TODO 命令与流式的兼容性问题
if await query.adapter.is_stream_output_supported() and has_chunks: response_index = len(query.resp_message_chain) - 1
is_final = [msg.is_final for msg in query.resp_messages][0] message_chain = query.resp_message_chain[-1]
await query.adapter.reply_message_chunk(
message_source=query.message_event, try:
bot_message=query.resp_messages[-1], if await query.adapter.is_stream_output_supported() and has_chunks:
message=query.resp_message_chain[-1], is_final = [msg.is_final for msg in query.resp_messages][0]
quote_origin=quote_origin, await query.adapter.reply_message_chunk(
is_final=is_final, message_source=query.message_event,
) bot_message=query.resp_messages[-1],
else: message=message_chain,
await query.adapter.reply_message( quote_origin=quote_origin,
message_source=query.message_event, is_final=is_final,
message=query.resp_message_chain[-1], )
quote_origin=quote_origin, else:
await query.adapter.reply_message(
message_source=query.message_event,
message=message_chain,
quote_origin=quote_origin,
)
except Exception as e:
await plugin_diagnostics.notify_response_delivery_failure(
self.ap,
query,
response_index,
message_chain,
e,
) )
plugin_diagnostics.clear_response_source(query, response_index)
raise
plugin_diagnostics.clear_response_source(query, response_index)
return entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query) return entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query)
@@ -3,6 +3,7 @@ from __future__ import annotations
import typing import typing
from .. import entities from .. import entities
from .. import plugin_diagnostics
from .. import stage from .. import stage
import langbot_plugin.api.entities.builtin.platform.message as platform_message import langbot_plugin.api.entities.builtin.platform.message as platform_message
@@ -78,6 +79,11 @@ class ResponseWrapper(stage.PipelineStage):
# 如果 resp_messages[-1] 已经是 MessageChain 了 # 如果 resp_messages[-1] 已经是 MessageChain 了
if isinstance(query.resp_messages[-1], platform_message.MessageChain): if isinstance(query.resp_messages[-1], platform_message.MessageChain):
query.resp_message_chain.append(query.resp_messages[-1]) query.resp_message_chain.append(query.resp_messages[-1])
plugin_diagnostics.consume_pending_plugin_response_source(
query,
query.resp_messages[-1],
len(query.resp_message_chain) - 1,
)
yield entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query) yield entities.StageProcessResult(result_type=entities.ResultType.CONTINUE, new_query=query)
@@ -129,8 +135,10 @@ class ResponseWrapper(stage.PipelineStage):
else: else:
if event_ctx.event.reply_message_chain is not None: if event_ctx.event.reply_message_chain is not None:
reply_chain = event_ctx.event.reply_message_chain reply_chain = event_ctx.event.reply_message_chain
is_plugin_reply = True
else: else:
reply_chain = result.get_content_platform_message_chain() reply_chain = result.get_content_platform_message_chain()
is_plugin_reply = False
# Attach files the agent produced in the sandbox # Attach files the agent produced in the sandbox
# outbox, but only on the terminal assistant message. # outbox, but only on the terminal assistant message.
@@ -138,6 +146,13 @@ class ResponseWrapper(stage.PipelineStage):
await self._append_outbound_attachments(query, reply_chain) await self._append_outbound_attachments(query, reply_chain)
query.resp_message_chain.append(reply_chain) query.resp_message_chain.append(reply_chain)
if is_plugin_reply:
plugin_diagnostics.record_last_plugin_response_source(
query,
plugin_diagnostics.get_response_sources(event_ctx),
plugin_diagnostics.get_emitted_plugins(event_ctx),
event.event_name,
)
yield entities.StageProcessResult( yield entities.StageProcessResult(
result_type=entities.ResultType.CONTINUE, result_type=entities.ResultType.CONTINUE,
@@ -180,6 +195,12 @@ class ResponseWrapper(stage.PipelineStage):
else: else:
if event_ctx.event.reply_message_chain is not None: if event_ctx.event.reply_message_chain is not None:
query.resp_message_chain.append(event_ctx.event.reply_message_chain) query.resp_message_chain.append(event_ctx.event.reply_message_chain)
plugin_diagnostics.record_last_plugin_response_source(
query,
plugin_diagnostics.get_response_sources(event_ctx),
plugin_diagnostics.get_emitted_plugins(event_ctx),
event.event_name,
)
else: else:
query.resp_message_chain.append( query.resp_message_chain.append(
+14
View File
@@ -737,6 +737,8 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
event_ctx = context.EventContext.from_event(event) event_ctx = context.EventContext.from_event(event)
if not self.is_enable_plugin: if not self.is_enable_plugin:
event_ctx._emitted_plugins = []
event_ctx._response_sources = []
return event_ctx return event_ctx
# Pass include_plugins to runtime for filtering # Pass include_plugins to runtime for filtering
@@ -745,9 +747,21 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
) )
event_ctx = context.EventContext.model_validate(event_ctx_result['event_context']) event_ctx = context.EventContext.model_validate(event_ctx_result['event_context'])
event_ctx._emitted_plugins = event_ctx_result.get('emitted_plugins', [])
if 'response_sources' in event_ctx_result:
event_ctx._response_sources = event_ctx_result['response_sources']
return event_ctx return event_ctx
async def notify_plugin_diagnostic(self, diagnostic: dict[str, Any]) -> None:
"""Best-effort diagnostic forwarding to the plugin runtime."""
if not self.is_enable_plugin:
return
try:
await self.handler.notify_plugin_diagnostic(diagnostic)
except Exception as e:
self.ap.logger.debug(f'Plugin diagnostic forwarding skipped: {e}')
async def list_tools(self, bound_plugins: list[str] | None = None) -> list[ComponentManifest]: async def list_tools(self, bound_plugins: list[str] | None = None) -> list[ComponentManifest]:
if not self.is_enable_plugin: if not self.is_enable_plugin:
return [] return []
+21
View File
@@ -26,6 +26,15 @@ from ..core import app
from ..utils import constants from ..utils import constants
class _RawAction:
def __init__(self, value: str):
self.value = value
def _langbot_to_runtime_action(enum_name: str, fallback_value: str) -> Any:
return getattr(LangBotToRuntimeAction, enum_name, _RawAction(fallback_value))
def _make_rag_error_response(error: Exception, error_type: str, **extra_context) -> handler.ActionResponse: def _make_rag_error_response(error: Exception, error_type: str, **extra_context) -> handler.ActionResponse:
"""Create a clean error response for RAG operations. """Create a clean error response for RAG operations.
@@ -923,6 +932,18 @@ class RuntimeConnectionHandler(handler.Handler):
return result return result
async def notify_plugin_diagnostic(self, diagnostic: dict[str, Any]) -> dict[str, Any]:
"""Notify the plugin runtime about a best-effort plugin diagnostic.
This intentionally uses the raw protocol string instead of a SDK enum so
LangBot can keep running with older langbot-plugin versions.
"""
return await self.call_action(
_langbot_to_runtime_action('PLUGIN_DIAGNOSTIC', 'plugin_diagnostic'),
diagnostic,
timeout=5,
)
async def list_tools(self, include_plugins: list[str] | None = None) -> list[dict[str, Any]]: async def list_tools(self, include_plugins: list[str] | None = None) -> list[dict[str, Any]]:
"""List tools""" """List tools"""
result = await self.call_action( result = await self.call_action(
-1
View File
@@ -1,4 +1,3 @@
admins: []
api: api:
port: 5300 port: 5300
webhook_prefix: 'http://127.0.0.1:5300' webhook_prefix: 'http://127.0.0.1:5300'
@@ -662,6 +662,100 @@ class TestSendResponseBackStage:
assert len(outbound) == 1 assert len(outbound) == 1
assert outbound[0]['type'] == 'reply' assert outbound[0]['type'] == 'reply'
@pytest.mark.asyncio
async def test_send_response_failure_notifies_plugin_diagnostic(self, pipeline_app):
"""Plugin-provided deferred replies should report delivery failures."""
from langbot.pkg.pipeline import plugin_diagnostics
from langbot.pkg.pipeline.respback import respback
from tests.factories.message import text_chain
from langbot_plugin.api.entities.builtin.provider.message import Message
query = text_query('hello')
query.adapter.reply_message.side_effect = RuntimeError('send failed')
query.pipeline_config = create_minimal_pipeline_config()
query.current_stage_name = 'SendResponseBackStage'
query.resp_messages = [Message(role='assistant', content='test response')]
query.resp_message_chain = [text_chain('test response')]
plugin_diagnostics.record_plugin_response_source(
query,
0,
[
{
'kind': 'reply_message_chain',
'plugin': {'author': 'tester', 'name': 'demo'},
}
],
[{'manifest': {'metadata': {'author': 'observer', 'name': 'not-reply-source'}}}],
'NormalMessageResponded',
)
pipeline_app.plugin_connector.notify_plugin_diagnostic = AsyncMock()
respback_stage = respback.SendResponseBackStage(pipeline_app)
with pytest.raises(RuntimeError, match='send failed'):
await respback_stage.process(query, 'SendResponseBackStage')
pipeline_app.plugin_connector.notify_plugin_diagnostic.assert_awaited_once()
payload = pipeline_app.plugin_connector.notify_plugin_diagnostic.await_args.args[0]
assert payload['code'] == 'response_delivery_failed'
assert payload['plugin'] == {'author': 'tester', 'name': 'demo'}
assert payload['query']['event_name'] == 'NormalMessageResponded'
assert payload['delivery']['error_type'] == 'RuntimeError'
assert 'attribution_warning' not in payload['details']
@pytest.mark.asyncio
async def test_send_response_failure_warns_for_old_runtime_attribution(self, pipeline_app):
"""Older plugin runtimes without response_sources should get approximate diagnostics."""
from langbot.pkg.pipeline import plugin_diagnostics
from langbot.pkg.pipeline.respback import respback
from tests.factories.message import text_chain
from langbot_plugin.api.entities.builtin.provider.message import Message
query = text_query('hello')
query.adapter.reply_message.side_effect = RuntimeError('send failed')
query.pipeline_config = create_minimal_pipeline_config()
query.resp_messages = [Message(role='assistant', content='test response')]
query.resp_message_chain = [text_chain('test response')]
plugin_diagnostics.record_plugin_response_source(
query,
0,
None,
[{'manifest': {'metadata': {'author': 'tester', 'name': 'demo'}}}],
'NormalMessageResponded',
)
pipeline_app.plugin_connector.notify_plugin_diagnostic = AsyncMock()
respback_stage = respback.SendResponseBackStage(pipeline_app)
with pytest.raises(RuntimeError, match='send failed'):
await respback_stage.process(query, 'SendResponseBackStage')
payload = pipeline_app.plugin_connector.notify_plugin_diagnostic.await_args.args[0]
assert payload['plugin'] == {'author': 'tester', 'name': 'demo'}
assert 'attribution_warning' in payload['details']
@pytest.mark.asyncio
async def test_send_response_failure_ignores_query_variable_spoofing(self, pipeline_app):
"""Plugin-controlled query variables must not mask delivery failures."""
from langbot.pkg.pipeline.respback import respback
from tests.factories.message import text_chain
from langbot_plugin.api.entities.builtin.provider.message import Message
query = text_query('hello')
query.adapter.reply_message.side_effect = RuntimeError('send failed')
query.pipeline_config = create_minimal_pipeline_config()
query.resp_messages = [Message(role='assistant', content='test response')]
query.resp_message_chain = [text_chain('test response')]
query.variables['_plugin_response_sources'] = {0: ['malformed']}
pipeline_app.plugin_connector.notify_plugin_diagnostic = AsyncMock()
respback_stage = respback.SendResponseBackStage(pipeline_app)
with pytest.raises(RuntimeError, match='send failed'):
await respback_stage.process(query, 'SendResponseBackStage')
pipeline_app.plugin_connector.notify_plugin_diagnostic.assert_not_called()
@pytest.mark.usefixtures('mock_circular_import_chain') @pytest.mark.usefixtures('mock_circular_import_chain')
class TestStageChainIntegration: class TestStageChainIntegration:
+142
View File
@@ -36,6 +36,11 @@ def get_entities_module():
return import_module('langbot.pkg.pipeline.entities') return import_module('langbot.pkg.pipeline.entities')
def get_plugin_diagnostics_module():
"""Lazy import for plugin diagnostic attribution helpers."""
return import_module('langbot.pkg.pipeline.plugin_diagnostics')
def make_wrapper_config(): def make_wrapper_config():
"""Create a pipeline config for wrapper tests.""" """Create a pipeline config for wrapper tests."""
return { return {
@@ -106,6 +111,45 @@ class TestResponseWrapperMessageChain:
assert results[0].result_type == entities.ResultType.CONTINUE assert results[0].result_type == entities.ResultType.CONTINUE
assert len(results[0].new_query.resp_message_chain) == 1 assert len(results[0].new_query.resp_message_chain) == 1
@pytest.mark.asyncio
async def test_message_chain_direct_append_consumes_pending_plugin_source(self):
"""MessageChain replies from earlier plugin events keep attribution."""
wrapper = get_wrapper_module()
app = FakeApp()
stage = wrapper.ResponseWrapper(app)
await stage.initialize(make_wrapper_config())
reply_chain = platform_message.MessageChain([platform_message.Plain(text='response')])
query = text_query('hello')
query.pipeline_config = make_wrapper_config()
query.resp_messages = [reply_chain]
query.resp_message_chain = []
plugin_diagnostics = get_plugin_diagnostics_module()
plugin_diagnostics.record_pending_plugin_response_source(
query,
reply_chain,
[
{
'kind': 'reply_message_chain',
'plugin': {'author': 'tester', 'name': 'demo'},
}
],
[{'manifest': {'metadata': {'author': 'observer', 'name': 'not-reply-source'}}}],
'PersonNormalMessageReceived',
)
results = []
async for result in stage.process(query, 'ResponseWrapper'):
results.append(result)
sources = plugin_diagnostics._get_response_sources(results[0].new_query, 0)
assert sources[0].plugin == {'author': 'tester', 'name': 'demo'}
assert sources[0].event_name == 'PersonNormalMessageReceived'
assert sources[0].is_approximate is False
assert '_plugin_response_sources' not in query.variables
assert '_plugin_pending_response_sources' not in query.variables
class TestResponseWrapperCommand: class TestResponseWrapperCommand:
"""Tests for command response wrapping.""" """Tests for command response wrapping."""
@@ -421,6 +465,104 @@ class TestResponseWrapperCustomReply:
chain = results[0].new_query.resp_message_chain[0] chain = results[0].new_query.resp_message_chain[0]
assert 'Custom reply' in str(chain) assert 'Custom reply' in str(chain)
@pytest.mark.asyncio
async def test_custom_reply_records_plugin_source(self):
"""Plugin reply_message_chain should keep emitted plugin attribution."""
wrapper = get_wrapper_module()
app = FakeApp()
app.sess_mgr.get_session = AsyncMock(return_value=make_session())
custom_chain = platform_message.MessageChain([platform_message.Plain(text='Custom reply')])
mock_event_ctx = Mock()
mock_event_ctx.is_prevented_default = Mock(return_value=False)
mock_event_ctx.event = Mock()
mock_event_ctx.event.reply_message_chain = custom_chain
mock_event_ctx._emitted_plugins = [
{
'manifest': {'metadata': {'author': 'observer', 'name': 'not-reply-source'}},
'plugin_config': {'token': 'secret-token'},
},
]
mock_event_ctx._response_sources = [
{
'kind': 'reply_message_chain',
'plugin': {'author': 'tester', 'name': 'demo'},
}
]
app.plugin_connector.emit_event = AsyncMock(return_value=mock_event_ctx)
stage = wrapper.ResponseWrapper(app)
pipeline_config = make_wrapper_config()
await stage.initialize(pipeline_config)
query = text_query('hello')
query.pipeline_config = pipeline_config
query.resp_message_chain = []
assistant_resp = Mock()
assistant_resp.role = 'assistant'
assistant_resp.content = 'Default reply'
assistant_resp.tool_calls = None
assistant_resp.get_content_platform_message_chain = Mock(
return_value=platform_message.MessageChain([platform_message.Plain(text='Default reply')])
)
query.resp_messages = [assistant_resp]
results = []
async for result in stage.process(query, 'ResponseWrapper'):
results.append(result)
plugin_diagnostics = get_plugin_diagnostics_module()
sources = plugin_diagnostics._get_response_sources(results[0].new_query, 0)
assert sources[0].plugin == {'author': 'tester', 'name': 'demo'}
assert sources[0].event_name == 'NormalMessageResponded'
assert sources[0].is_approximate is False
assert 'secret-token' not in str(sources)
assert '_plugin_response_sources' not in query.variables
@pytest.mark.asyncio
async def test_custom_reply_falls_back_to_emitted_plugins_for_old_runtime(self):
"""Older plugin runtimes without response_sources keep approximate attribution."""
wrapper = get_wrapper_module()
app = FakeApp()
app.sess_mgr.get_session = AsyncMock(return_value=make_session())
custom_chain = platform_message.MessageChain([platform_message.Plain(text='Custom reply')])
mock_event_ctx = Mock()
mock_event_ctx.is_prevented_default = Mock(return_value=False)
mock_event_ctx.event = Mock()
mock_event_ctx.event.reply_message_chain = custom_chain
mock_event_ctx._emitted_plugins = [
{'manifest': {'metadata': {'author': 'tester', 'name': 'demo'}}},
]
app.plugin_connector.emit_event = AsyncMock(return_value=mock_event_ctx)
stage = wrapper.ResponseWrapper(app)
pipeline_config = make_wrapper_config()
await stage.initialize(pipeline_config)
query = text_query('hello')
query.pipeline_config = pipeline_config
query.resp_message_chain = []
assistant_resp = Mock()
assistant_resp.role = 'assistant'
assistant_resp.content = 'Default reply'
assistant_resp.tool_calls = None
assistant_resp.get_content_platform_message_chain = Mock(
return_value=platform_message.MessageChain([platform_message.Plain(text='Default reply')])
)
query.resp_messages = [assistant_resp]
results = []
async for result in stage.process(query, 'ResponseWrapper'):
results.append(result)
plugin_diagnostics = get_plugin_diagnostics_module()
sources = plugin_diagnostics._get_response_sources(results[0].new_query, 0)
assert sources[0].plugin == {'author': 'tester', 'name': 'demo'}
assert sources[0].is_approximate is True
class TestResponseWrapperVariables: class TestResponseWrapperVariables:
"""Tests for bound plugins variable.""" """Tests for bound plugins variable."""
@@ -13,6 +13,8 @@ import pytest
from unittest.mock import Mock, AsyncMock from unittest.mock import Mock, AsyncMock
from importlib import import_module from importlib import import_module
from tests.factories import text_query
def get_connector_module(): def get_connector_module():
"""Lazy import to avoid circular import issues.""" """Lazy import to avoid circular import issues."""
@@ -132,6 +134,130 @@ class TestListPlugins:
assert result[0]['debug'] is True assert result[0]['debug'] is True
class TestPluginDiagnostics:
@pytest.mark.asyncio
async def test_emit_event_preserves_response_sources(self):
connector = create_mock_connector()
query = text_query('hello')
event = query.message_event
object.__setattr__(event, 'query', query)
connector_module = get_connector_module()
original_from_event = connector_module.context.EventContext.from_event
original_model_validate = connector_module.context.EventContext.model_validate
response_sources = [
{
'kind': 'reply_message_chain',
'plugin': {'author': 'tester', 'name': 'demo'},
}
]
async def emit_event_response(event_context, include_plugins=None):
return {
'event_context': event_context,
'emitted_plugins': [],
'response_sources': response_sources,
}
connector.handler = AsyncMock()
connector.handler.emit_event = AsyncMock(side_effect=emit_event_response)
fake_event_ctx = Mock()
event_dump = event.model_dump()
event_dump['event_name'] = 'FriendMessage'
fake_event_ctx.model_dump.return_value = {
'query_id': query.query_id,
'eid': 0,
'event_name': 'FriendMessage',
'event': event_dump,
'is_prevent_default': False,
'is_prevent_postorder': False,
}
connector_module.context.EventContext.from_event = Mock(return_value=fake_event_ctx)
parsed_event_ctx = Mock()
connector_module.context.EventContext.model_validate = Mock(return_value=parsed_event_ctx)
try:
event_ctx = await connector.emit_event(event)
finally:
connector_module.context.EventContext.from_event = original_from_event
connector_module.context.EventContext.model_validate = original_model_validate
assert event_ctx is parsed_event_ctx
assert event_ctx._response_sources == response_sources
@pytest.mark.asyncio
async def test_emit_event_leaves_response_sources_absent_for_old_runtime(self):
connector = create_mock_connector()
query = text_query('hello')
event = query.message_event
object.__setattr__(event, 'query', query)
connector_module = get_connector_module()
original_from_event = connector_module.context.EventContext.from_event
original_model_validate = connector_module.context.EventContext.model_validate
async def emit_event_response(event_context, include_plugins=None):
return {
'event_context': event_context,
'emitted_plugins': [
{'manifest': {'metadata': {'author': 'tester', 'name': 'demo'}}},
],
}
connector.handler = AsyncMock()
connector.handler.emit_event = AsyncMock(side_effect=emit_event_response)
fake_event_ctx = Mock()
event_dump = event.model_dump()
event_dump['event_name'] = 'FriendMessage'
fake_event_ctx.model_dump.return_value = {
'query_id': query.query_id,
'eid': 0,
'event_name': 'FriendMessage',
'event': event_dump,
'is_prevent_default': False,
'is_prevent_postorder': False,
}
connector_module.context.EventContext.from_event = Mock(return_value=fake_event_ctx)
parsed_event_ctx = Mock()
connector_module.context.EventContext.model_validate = Mock(return_value=parsed_event_ctx)
try:
event_ctx = await connector.emit_event(event)
finally:
connector_module.context.EventContext.from_event = original_from_event
connector_module.context.EventContext.model_validate = original_model_validate
assert '_response_sources' not in vars(event_ctx)
assert event_ctx._emitted_plugins == [
{'manifest': {'metadata': {'author': 'tester', 'name': 'demo'}}},
]
@pytest.mark.asyncio
async def test_notify_plugin_diagnostic_skips_when_disabled(self):
connector_module = get_connector_module()
async def mock_disconnect(conn):
pass
mock_app = create_mock_app()
mock_app.instance_config.data = {'plugin': {'enable': False}}
connector = connector_module.PluginRuntimeConnector(mock_app, mock_disconnect)
connector.handler = AsyncMock()
await connector.notify_plugin_diagnostic({'code': 'response_delivery_failed'})
connector.handler.notify_plugin_diagnostic.assert_not_called()
@pytest.mark.asyncio
async def test_notify_plugin_diagnostic_is_best_effort(self):
connector = create_mock_connector()
connector.handler = AsyncMock()
connector.handler.notify_plugin_diagnostic = AsyncMock(side_effect=RuntimeError('action not found'))
await connector.notify_plugin_diagnostic({'code': 'response_delivery_failed'})
connector.handler.notify_plugin_diagnostic.assert_awaited_once()
connector.ap.logger.debug.assert_called_once()
class TestListKnowledgeEngines: class TestListKnowledgeEngines:
"""Tests for list_knowledge_engines method.""" """Tests for list_knowledge_engines method."""
+30
View File
@@ -159,6 +159,36 @@ class TestHandlerRagErrorResponse:
assert 'KeyError' in response.message assert 'KeyError' in response.message
class TestHandlerPluginDiagnostic:
@pytest.mark.asyncio
async def test_notify_plugin_diagnostic_falls_back_to_raw_protocol_action(self):
"""Diagnostic forwarding works before the SDK enum exists."""
app = SimpleNamespace()
app.logger = SimpleNamespace(debug=MagicMock())
runtime_handler = make_handler(app)
runtime_handler.call_action = AsyncMock(return_value={})
payload = {'code': 'response_delivery_failed'}
await runtime_handler.notify_plugin_diagnostic(payload)
action = runtime_handler.call_action.await_args.args[0]
assert action.value == 'plugin_diagnostic'
assert runtime_handler.call_action.await_args.args[1] is payload
assert runtime_handler.call_action.await_args.kwargs['timeout'] == 5
def test_langbot_to_runtime_action_uses_enum_when_available(self):
"""The compatibility helper should prefer SDK enums once available."""
from langbot.pkg.plugin import handler as plugin_handler
sentinel = object()
original = plugin_handler.LangBotToRuntimeAction
plugin_handler.LangBotToRuntimeAction = SimpleNamespace(PLUGIN_DIAGNOSTIC=sentinel)
try:
assert plugin_handler._langbot_to_runtime_action('PLUGIN_DIAGNOSTIC', 'plugin_diagnostic') is sentinel
finally:
plugin_handler.LangBotToRuntimeAction = original
class TestConstantsSemanticVersion: class TestConstantsSemanticVersion:
"""Tests for version constant access.""" """Tests for version constant access."""
+17 -15
View File
@@ -51,13 +51,15 @@ class TestRagRerankAction:
app.model_mgr.get_rerank_model_by_uuid = AsyncMock(return_value=rerank_model) app.model_mgr.get_rerank_model_by_uuid = AsyncMock(return_value=rerank_model)
runtime_handler = make_handler(app) runtime_handler = make_handler(app)
response = await runtime_handler.actions[PluginToRuntimeAction.INVOKE_RERANK.value]({ response = await runtime_handler.actions[PluginToRuntimeAction.INVOKE_RERANK.value](
'rerank_model_uuid': 'rerank-1', {
'query': 'hello', 'rerank_model_uuid': 'rerank-1',
'documents': ['a', 'b'], 'query': 'hello',
'top_k': 1, 'documents': ['a', 'b'],
'extra_args': {'return_documents': False}, 'top_k': 1,
}) 'extra_args': {'return_documents': False},
}
)
assert response.code == 0 assert response.code == 0
assert response.data['results'] == [{'index': 1, 'relevance_score': 0.9}] assert response.data['results'] == [{'index': 1, 'relevance_score': 0.9}]
@@ -72,16 +74,16 @@ class TestRagRerankAction:
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_returns_error_when_rerank_model_missing(self, app): async def test_returns_error_when_rerank_model_missing(self, app):
"""Missing rerank model returns an action error.""" """Missing rerank model returns an action error."""
app.model_mgr.get_rerank_model_by_uuid = AsyncMock( app.model_mgr.get_rerank_model_by_uuid = AsyncMock(side_effect=ValueError('not found'))
side_effect=ValueError('not found')
)
runtime_handler = make_handler(app) runtime_handler = make_handler(app)
response = await runtime_handler.actions[PluginToRuntimeAction.INVOKE_RERANK.value]({ response = await runtime_handler.actions[PluginToRuntimeAction.INVOKE_RERANK.value](
'rerank_model_uuid': 'missing', {
'query': 'hello', 'rerank_model_uuid': 'missing',
'documents': ['a'], 'query': 'hello',
}) 'documents': ['a'],
}
)
assert response.code != 0 assert response.code != 0
assert 'Rerank model with rerank_model_uuid missing not found' in response.message assert 'Rerank model with rerank_model_uuid missing not found' in response.message
+2 -2
View File
@@ -138,7 +138,7 @@ class TestReadResourceFile:
from langbot.pkg.utils import importutil from langbot.pkg.utils import importutil
content = importutil.read_resource_file('templates/config.yaml') content = importutil.read_resource_file('templates/config.yaml')
assert 'admins:' in content assert 'api:' in content
assert 'edition: community' in content assert 'edition: community' in content
def test_raises_for_nonexistent_file(self): def test_raises_for_nonexistent_file(self):
@@ -157,7 +157,7 @@ class TestReadResourceFileBytes:
from langbot.pkg.utils import importutil from langbot.pkg.utils import importutil
content = importutil.read_resource_file_bytes('templates/config.yaml') content = importutil.read_resource_file_bytes('templates/config.yaml')
assert b'admins:' in content assert b'api:' in content
assert b'edition: community' in content assert b'edition: community' in content
def test_raises_for_nonexistent_file_bytes(self): def test_raises_for_nonexistent_file_bytes(self):
@@ -0,0 +1,199 @@
import { useState, useEffect, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { httpClient } from '@/app/infra/http/HttpClient';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from '@/components/ui/dialog';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Trash2, Plus, ShieldCheck } from 'lucide-react';
import { toast } from 'sonner';
export interface BotAdmin {
id: number;
launcher_type: string;
launcher_id: string;
}
interface BotAdminsDialogProps {
botId: string;
open: boolean;
onOpenChange: (open: boolean) => void;
admins: BotAdmin[];
onAdminsChange: () => void;
}
export default function BotAdminsDialog({
botId,
open,
onOpenChange,
admins,
onAdminsChange,
}: BotAdminsDialogProps) {
const { t } = useTranslation();
const [newType, setNewType] = useState('person');
const [newId, setNewId] = useState('');
const [adding, setAdding] = useState(false);
async function handleAdd() {
if (!newId.trim()) return;
setAdding(true);
try {
await httpClient.addBotAdmin(botId, newType, newId.trim());
toast.success(t('bots.admins.addSuccess'));
setNewId('');
onAdminsChange();
} catch (e: unknown) {
const err = e as { msg?: string; message?: string };
toast.error(t('bots.admins.addError') + (err?.msg ?? err?.message ?? ''));
} finally {
setAdding(false);
}
}
async function handleDelete(id: number) {
try {
await httpClient.deleteBotAdmin(botId, id);
toast.success(t('bots.admins.deleteSuccess'));
onAdminsChange();
} catch (e: unknown) {
const err = e as { msg?: string; message?: string };
toast.error(
t('bots.admins.deleteError') + (err?.msg ?? err?.message ?? ''),
);
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<ShieldCheck className="size-4" />
{t('bots.admins.title')}
</DialogTitle>
<DialogDescription>{t('bots.admins.description')}</DialogDescription>
</DialogHeader>
<div className="space-y-4">
{/* Add row */}
<div className="flex gap-2 items-center">
<Select value={newType} onValueChange={setNewType}>
<SelectTrigger className="w-28 shrink-0">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="person">
{t('bots.admins.typePerson')}
</SelectItem>
<SelectItem value="group">
{t('bots.admins.typeGroup')}
</SelectItem>
</SelectContent>
</Select>
<Input
className="flex-1"
placeholder={t('bots.admins.placeholderId')}
value={newId}
onChange={(e) => setNewId(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleAdd()}
/>
<Button
size="sm"
onClick={handleAdd}
disabled={adding || !newId.trim()}
>
<Plus className="size-4 mr-1" />
{t('bots.admins.addAdmin')}
</Button>
</div>
{/* List */}
{admins.length === 0 ? (
<div className="text-sm text-muted-foreground py-6 text-center">
{t('bots.admins.noAdmins')}
</div>
) : (
<ScrollArea className="max-h-64">
<div className="border rounded-md overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b bg-muted/40">
<th className="text-left px-3 py-2 font-medium text-muted-foreground w-28">
{t('bots.admins.launcherType')}
</th>
<th className="text-left px-3 py-2 font-medium text-muted-foreground">
{t('bots.admins.launcherId')}
</th>
<th className="w-10" />
</tr>
</thead>
<tbody>
{admins.map((admin) => (
<tr
key={admin.id}
className="border-b last:border-0 hover:bg-muted/30"
>
<td className="px-3 py-2">
<span className="px-1.5 py-0.5 rounded bg-muted text-xs">
{admin.launcher_type === 'person'
? t('bots.admins.typePerson')
: t('bots.admins.typeGroup')}
</span>
</td>
<td className="px-3 py-2 font-mono">
{admin.launcher_id}
</td>
<td className="px-2 py-2">
<button
type="button"
className="text-muted-foreground hover:text-destructive transition-colors"
onClick={() => handleDelete(admin.id)}
>
<Trash2 className="size-4" />
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</ScrollArea>
)}
</div>
</DialogContent>
</Dialog>
);
}
// Shared hook so the session monitor and the dialog stay in sync.
export function useBotAdmins(botId: string) {
const [admins, setAdmins] = useState<BotAdmin[]>([]);
const reload = useCallback(async () => {
try {
const res = await httpClient.getBotAdmins(botId);
setAdmins(res.admins ?? []);
} catch (error) {
console.error('Failed to load bot admins:', error);
}
}, [botId]);
useEffect(() => {
reload();
}, [reload]);
return { admins, reload };
}
@@ -18,7 +18,14 @@ import {
Workflow, Workflow,
ThumbsUp, ThumbsUp,
ThumbsDown, ThumbsDown,
ShieldCheck,
ShieldOff,
} from 'lucide-react'; } from 'lucide-react';
import { toast } from 'sonner';
import BotAdminsDialog, {
useBotAdmins,
} from '@/app/home/bots/components/bot-admins/BotAdminsDialog';
import type { BotAdmin } from '@/app/home/bots/components/bot-admins/BotAdminsDialog';
import { copyToClipboard } from '@/app/utils/clipboard'; import { copyToClipboard } from '@/app/utils/clipboard';
import { import {
MessageChainComponent, MessageChainComponent,
@@ -94,15 +101,60 @@ const BotSessionMonitor = forwardRef<
Record<string, SessionFeedback> Record<string, SessionFeedback>
>({}); >({});
const messagesContainerRef = useRef<HTMLDivElement>(null); const messagesContainerRef = useRef<HTMLDivElement>(null);
const { admins, reload: reloadAdmins } = useBotAdmins(botId);
const [adminsDialogOpen, setAdminsDialogOpen] = useState(false);
const [togglingAdmin, setTogglingAdmin] = useState<string | null>(null);
const parseSessionType = (sessionId: string): string | null => { const parseSessionType = (sessionId: string): string | null => {
const idx = sessionId.indexOf('_'); const lower = sessionId.toLowerCase();
if (idx === -1) return null; if (lower.includes('person')) return 'person';
const type = sessionId.slice(0, idx); if (lower.includes('group')) return 'group';
if (type === 'person' || type === 'group') return type;
return null; return null;
}; };
const isSessionAdmin = (session: SessionInfo): boolean => {
const type = parseSessionType(session.session_id);
const lid =
session.user_id ??
session.session_id.replace(
/^.*?[._](?:PERSON|GROUP|person|group)[._]/i,
'',
);
return admins.some(
(a: BotAdmin) => a.launcher_type === type && a.launcher_id === lid,
);
};
const toggleAdmin = async (session: SessionInfo) => {
const type = parseSessionType(session.session_id);
if (!type) return;
const lid =
session.user_id ??
session.session_id.replace(
/^.*?[._](?:PERSON|GROUP|person|group)[._]/i,
'',
);
const key = session.session_id;
setTogglingAdmin(key);
try {
const existing = admins.find(
(a: BotAdmin) => a.launcher_type === type && a.launcher_id === lid,
);
if (existing) {
await httpClient.deleteBotAdmin(botId, existing.id);
toast.success(t('bots.admins.deleteSuccess'));
} else {
await httpClient.addBotAdmin(botId, type, lid);
toast.success(t('bots.admins.addSuccess'));
}
await reloadAdmins();
} catch {
toast.error(t('bots.admins.addError'));
} finally {
setTogglingAdmin(null);
}
};
const abbreviateId = (id: string): string => { const abbreviateId = (id: string): string => {
if (id.length <= 10) return id; if (id.length <= 10) return id;
return `${id.slice(0, 4)}..${id.slice(-4)}`; return `${id.slice(0, 4)}..${id.slice(-4)}`;
@@ -384,257 +436,307 @@ const BotSessionMonitor = forwardRef<
); );
return ( return (
<div className="flex flex-col md:flex-row h-full min-h-0 rounded-lg border overflow-hidden"> <>
{/* Left Panel: Session List */} <div className="flex flex-col md:flex-row h-full min-h-0 rounded-lg border overflow-hidden">
<div className="max-h-48 md:max-h-none md:w-60 flex-shrink-0 border-b md:border-b-0 md:border-r flex flex-col min-h-0"> {/* Left Panel: Session List */}
{/* Session List */} <div className="max-h-48 md:max-h-none md:w-60 flex-shrink-0 border-b md:border-b-0 md:border-r flex flex-col min-h-0">
<ScrollArea className="flex-1 min-h-0"> {/* Admin header */}
{loadingSessions && sessions.length === 0 ? ( <div className="px-2 py-1.5 border-b shrink-0 flex items-center justify-between">
<div className="flex items-center justify-center py-12 text-sm text-muted-foreground"> <button
{t('bots.sessionMonitor.loading')} type="button"
</div> className="inline-flex items-center gap-1.5 text-sm font-medium hover:text-foreground transition-colors"
) : sessions.length === 0 ? ( onClick={() => setAdminsDialogOpen(true)}
<div className="text-center text-muted-foreground py-12 text-sm"> >
{t('bots.sessionMonitor.noSessions')} <ShieldCheck className="size-4" />
<span>
{t('bots.admins.configureAdmins')}
{admins.length > 0 && (
<span className="ml-1 tabular-nums text-xs text-muted-foreground">
({admins.length})
</span>
)}
</span>
</button>
</div>
{/* Session List */}
<ScrollArea className="flex-1 min-h-0">
{loadingSessions && sessions.length === 0 ? (
<div className="flex items-center justify-center py-12 text-sm text-muted-foreground">
{t('bots.sessionMonitor.loading')}
</div>
) : sessions.length === 0 ? (
<div className="text-center text-muted-foreground py-12 text-sm">
{t('bots.sessionMonitor.noSessions')}
</div>
) : (
<div className="p-1.5">
{sessions.map((session) => {
const isSelected = selectedSessionId === session.session_id;
const sessionType = parseSessionType(session.session_id);
const sessionIsAdmin = isSessionAdmin(session);
return (
<div
key={session.session_id}
role="button"
tabIndex={0}
className={cn(
'w-full text-left px-2.5 py-2 rounded-md transition-colors cursor-pointer',
isSelected ? 'bg-accent' : 'hover:bg-accent/50',
)}
onClick={() => setSelectedSessionId(session.session_id)}
>
<div className="flex items-center justify-between mb-0.5">
<span className="text-sm font-medium truncate mr-2">
{session.user_name ||
session.user_id ||
session.session_id.slice(0, 12)}
</span>
<span className="text-[11px] text-muted-foreground tabular-nums flex-shrink-0">
{formatRelativeTime(session.last_activity)}
</span>
</div>
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
{sessionType && (
<span className="px-1 py-0.5 rounded bg-muted text-[10px]">
{sessionType}
</span>
)}
{session.user_id && (
<span className="truncate text-[10px]">
{abbreviateId(session.user_id)}
</span>
)}
{session.is_active && (
<span className="flex items-center gap-0.5 text-green-600 dark:text-green-400">
<span className="w-1.5 h-1.5 rounded-full bg-green-500 inline-block" />
</span>
)}
</div>
</div>
);
})}
</div>
)}
</ScrollArea>
</div>
{/* Right Panel: Messages */}
<div className="flex-1 flex flex-col min-h-0 min-w-0">
{!selectedSessionId ? (
<div className="text-center text-muted-foreground text-sm flex-1 flex items-center justify-center">
{t('bots.sessionMonitor.selectSession')}
</div> </div>
) : ( ) : (
<div className="p-1.5"> <>
{sessions.map((session) => { {/* Chat Header */}
const isSelected = selectedSessionId === session.session_id; <div className="px-4 py-2.5 border-b shrink-0 flex items-center justify-between">
return ( <div className="min-w-0 flex-1">
<button <div className="text-sm font-medium truncate">
key={session.session_id} {selectedSession?.user_name ||
type="button" selectedSession?.user_id ||
className={cn( selectedSessionId.slice(0, 20)}
'w-full text-left px-2.5 py-2 rounded-md transition-colors', </div>
isSelected ? 'bg-accent' : 'hover:bg-accent/50', <div className="flex items-center gap-1.5 text-xs text-muted-foreground mt-0.5">
{parseSessionType(selectedSessionId) && (
<span>{parseSessionType(selectedSessionId)}</span>
)} )}
onClick={() => setSelectedSessionId(session.session_id)} {selectedSession?.user_id && (
> <>
<div className="flex items-center justify-between mb-0.5"> <span>·</span>
<span className="text-sm font-medium truncate mr-2"> <span className="font-mono">
{session.user_name || {selectedSession.user_id}
session.user_id ||
session.session_id.slice(0, 12)}
</span>
<span className="text-[11px] text-muted-foreground tabular-nums flex-shrink-0">
{formatRelativeTime(session.last_activity)}
</span>
</div>
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
{parseSessionType(session.session_id) && (
<span className="px-1 py-0.5 rounded bg-muted text-[10px]">
{parseSessionType(session.session_id)}
</span> </span>
)} <button
{session.platform && ( type="button"
<span className="px-1 py-0.5 rounded bg-muted text-[10px]"> onClick={() => copyUserId(selectedSession.user_id!)}
{session.platform} className="inline-flex items-center text-muted-foreground hover:text-foreground transition-colors"
</span> title={t('common.copy')}
)} >
{session.user_id && ( {copiedUserId ? (
<span className="truncate text-[10px]"> <Check className="w-3 h-3 text-green-600" />
{abbreviateId(session.user_id)} ) : (
</span> <Copy className="w-3 h-3" />
)} )}
{session.is_active && ( </button>
<span className="flex items-center gap-0.5 text-green-600 dark:text-green-400"> </>
)}
{selectedSession?.is_active && (
<>
<span>·</span>
<span className="flex items-center gap-1 text-green-600 dark:text-green-400">
<span className="w-1.5 h-1.5 rounded-full bg-green-500 inline-block" /> <span className="w-1.5 h-1.5 rounded-full bg-green-500 inline-block" />
Active
</span> </span>
)} </>
</div> )}
</button> {selectedSession && parseSessionType(selectedSessionId) && (
); <>
})} <span>·</span>
</div> <button
)} type="button"
</ScrollArea> className={cn(
</div> 'inline-flex items-center gap-1 transition-colors',
isSessionAdmin(selectedSession)
{/* Right Panel: Messages */} ? 'text-blue-500'
<div className="flex-1 flex flex-col min-h-0 min-w-0"> : 'text-muted-foreground hover:text-blue-500',
{!selectedSessionId ? ( )}
<div className="text-center text-muted-foreground text-sm flex-1 flex items-center justify-center"> disabled={togglingAdmin === selectedSessionId}
{t('bots.sessionMonitor.selectSession')} title={
</div> isSessionAdmin(selectedSession)
) : ( ? t('bots.admins.removeAdminTitle')
<> : t('bots.admins.setAdminTitle')
{/* Chat Header */} }
<div className="px-4 py-2.5 border-b shrink-0"> onClick={() => toggleAdmin(selectedSession)}
<div className="min-w-0"> >
<div className="text-sm font-medium truncate"> {isSessionAdmin(selectedSession) ? (
{selectedSession?.user_name || <ShieldCheck className="size-3.5" />
selectedSession?.user_id || ) : (
selectedSessionId.slice(0, 20)} <ShieldOff className="size-3.5" />
</div> )}
<div className="flex items-center gap-1.5 text-xs text-muted-foreground mt-0.5"> <span>{t('bots.admins.adminBadge')}</span>
{parseSessionType(selectedSessionId) && ( </button>
<span>{parseSessionType(selectedSessionId)}</span> </>
)} )}
{selectedSession?.platform && ( </div>
<>
{parseSessionType(selectedSessionId) && <span>·</span>}
<span>{selectedSession.platform}</span>
</>
)}
{selectedSession?.user_id && (
<>
<span>·</span>
<span className="font-mono">
{selectedSession.user_id}
</span>
<button
type="button"
onClick={() => copyUserId(selectedSession.user_id!)}
className="inline-flex items-center text-muted-foreground hover:text-foreground transition-colors"
title={t('common.copy')}
>
{copiedUserId ? (
<Check className="w-3 h-3 text-green-600" />
) : (
<Copy className="w-3 h-3" />
)}
</button>
</>
)}
{selectedSession?.is_active && (
<>
<span>·</span>
<span className="flex items-center gap-1 text-green-600 dark:text-green-400">
<span className="w-1.5 h-1.5 rounded-full bg-green-500 inline-block" />
Active
</span>
</>
)}
</div> </div>
</div> </div>
</div>
{/* Messages Area */} {/* Messages Area */}
<ScrollArea <ScrollArea
ref={messagesContainerRef} ref={messagesContainerRef}
className="flex-1 px-4 py-4 overflow-y-auto min-h-0" className="flex-1 px-4 py-4 overflow-y-auto min-h-0"
> >
<div className="space-y-4"> <div className="space-y-4">
{loadingMessages ? ( {loadingMessages ? (
<div className="text-center text-muted-foreground py-12 text-sm"> <div className="text-center text-muted-foreground py-12 text-sm">
{t('bots.sessionMonitor.loading')} {t('bots.sessionMonitor.loading')}
</div> </div>
) : messages.length === 0 ? ( ) : messages.length === 0 ? (
<div className="text-center text-muted-foreground py-12 text-sm"> <div className="text-center text-muted-foreground py-12 text-sm">
{t('bots.sessionMonitor.noMessages')} {t('bots.sessionMonitor.noMessages')}
</div> </div>
) : ( ) : (
messages.map((msg, msgIndex) => { messages.map((msg, msgIndex) => {
const isUser = isUserMessage(msg); const isUser = isUserMessage(msg);
const isDiscarded = const isDiscarded =
msg.status === 'discarded' || msg.status === 'discarded' ||
msg.pipeline_id === PIPELINE_DISCARD; msg.pipeline_id === PIPELINE_DISCARD;
// For bot replies, find feedback linked to the preceding user message // For bot replies, find feedback linked to the preceding user message
let msgFeedback: SessionFeedback | undefined; let msgFeedback: SessionFeedback | undefined;
if (!isUser) { if (!isUser) {
for (let i = msgIndex - 1; i >= 0; i--) { for (let i = msgIndex - 1; i >= 0; i--) {
if (isUserMessage(messages[i])) { if (isUserMessage(messages[i])) {
msgFeedback = feedbackMap[messages[i].id]; msgFeedback = feedbackMap[messages[i].id];
break; break;
}
} }
} }
} return (
return (
<div
key={msg.id}
className={cn(
'flex',
isUser ? 'justify-end' : 'justify-start',
)}
>
<div <div
key={msg.id}
className={cn( className={cn(
'max-w-3xl px-4 py-2.5 rounded-2xl text-sm', 'flex',
isUser isUser ? 'justify-end' : 'justify-start',
? 'bg-primary/10 rounded-br-sm'
: 'bg-muted rounded-bl-sm',
msg.status === 'error' && 'ring-1 ring-red-400/50',
isDiscarded && 'opacity-60',
)} )}
> >
{renderMessageContent(msg)}
{/* Role label + pipeline + timestamp */}
<div <div
className={cn( className={cn(
'text-[11px] mt-1.5 flex items-center gap-1.5 text-muted-foreground', 'max-w-3xl px-4 py-2.5 rounded-2xl text-sm',
isUser
? 'bg-primary/10 rounded-br-sm'
: 'bg-muted rounded-bl-sm',
msg.status === 'error' &&
'ring-1 ring-red-400/50',
isDiscarded && 'opacity-60',
)} )}
> >
<span> {renderMessageContent(msg)}
{isUser {/* Role label + pipeline + timestamp */}
? t('bots.sessionMonitor.userMessage', { <div
defaultValue: 'User', className={cn(
}) 'text-[11px] mt-1.5 flex items-center gap-1.5 text-muted-foreground',
: t('bots.sessionMonitor.botMessage', { )}
defaultValue: 'Assistant', >
<span>
{isUser
? t('bots.sessionMonitor.userMessage', {
defaultValue: 'User',
})
: t('bots.sessionMonitor.botMessage', {
defaultValue: 'Assistant',
})}
</span>
<span className="tabular-nums">
{formatTime(msg.timestamp)}
</span>
{isDiscarded ? (
<span className="inline-flex items-center gap-0.5 text-destructive">
<Ban className="w-3 h-3" />
{t('bots.sessionMonitor.discarded', {
defaultValue: 'Discarded',
})} })}
</span>
<span className="tabular-nums">
{formatTime(msg.timestamp)}
</span>
{isDiscarded ? (
<span className="inline-flex items-center gap-0.5 text-destructive">
<Ban className="w-3 h-3" />
{t('bots.sessionMonitor.discarded', {
defaultValue: 'Discarded',
})}
</span>
) : msg.pipeline_name ? (
<span className="inline-flex items-center gap-0.5 opacity-70">
<Workflow className="w-3 h-3" />
{msg.pipeline_name}
</span>
) : null}
{msg.status === 'error' && (
<span className="text-red-500">error</span>
)}
{msg.runner_name && (
<span className="inline-flex items-center gap-0.5 opacity-70">
<Bot className="w-3 h-3" />
{msg.runner_name}
</span>
)}
{/* Feedback indicator — same line, pushed right */}
{!isUser &&
msgFeedback &&
(msgFeedback.feedback_type === 1 ? (
<span className="inline-flex items-center gap-1 ml-auto text-green-600 dark:text-green-400 cursor-default relative group">
<ThumbsUp className="w-3 h-3 flex-shrink-0" />
{t('monitoring.feedback.like')}
{msgFeedback.feedback_content && (
<span className="hidden group-hover:block absolute bottom-full right-0 mb-1 px-3 py-1.5 rounded-lg bg-popover border text-popover-foreground text-xs whitespace-nowrap shadow-md z-10">
{msgFeedback.feedback_content}
</span>
)}
</span> </span>
) : ( ) : msg.pipeline_name ? (
<span className="inline-flex items-center gap-1 ml-auto text-red-500 dark:text-red-400 cursor-default relative group"> <span className="inline-flex items-center gap-0.5 opacity-70">
<ThumbsDown className="w-3 h-3 flex-shrink-0" /> <Workflow className="w-3 h-3" />
{t('monitoring.feedback.dislike')} {msg.pipeline_name}
{msgFeedback.feedback_content && (
<span className="hidden group-hover:block absolute bottom-full right-0 mb-1 px-3 py-1.5 rounded-lg bg-popover border text-popover-foreground text-xs whitespace-nowrap shadow-md z-10">
{msgFeedback.feedback_content}
</span>
)}
</span> </span>
))} ) : null}
{msg.status === 'error' && (
<span className="text-red-500">error</span>
)}
{msg.runner_name && (
<span className="inline-flex items-center gap-0.5 opacity-70">
<Bot className="w-3 h-3" />
{msg.runner_name}
</span>
)}
{/* Feedback indicator — same line, pushed right */}
{!isUser &&
msgFeedback &&
(msgFeedback.feedback_type === 1 ? (
<span className="inline-flex items-center gap-1 ml-auto text-green-600 dark:text-green-400 cursor-default relative group">
<ThumbsUp className="w-3 h-3 flex-shrink-0" />
{t('monitoring.feedback.like')}
{msgFeedback.feedback_content && (
<span className="hidden group-hover:block absolute bottom-full right-0 mb-1 px-3 py-1.5 rounded-lg bg-popover border text-popover-foreground text-xs whitespace-nowrap shadow-md z-10">
{msgFeedback.feedback_content}
</span>
)}
</span>
) : (
<span className="inline-flex items-center gap-1 ml-auto text-red-500 dark:text-red-400 cursor-default relative group">
<ThumbsDown className="w-3 h-3 flex-shrink-0" />
{t('monitoring.feedback.dislike')}
{msgFeedback.feedback_content && (
<span className="hidden group-hover:block absolute bottom-full right-0 mb-1 px-3 py-1.5 rounded-lg bg-popover border text-popover-foreground text-xs whitespace-nowrap shadow-md z-10">
{msgFeedback.feedback_content}
</span>
)}
</span>
))}
</div>
</div> </div>
</div> </div>
</div> );
); })
}) )}
)} </div>
</div> </ScrollArea>
</ScrollArea> </>
</> )}
)} </div>
</div> </div>
</div>
<BotAdminsDialog
botId={botId}
open={adminsDialogOpen}
onOpenChange={setAdminsDialogOpen}
admins={admins}
onAdminsChange={reloadAdmins}
/>
</>
); );
}); });
+21
View File
@@ -394,6 +394,27 @@ export class BackendClient extends BaseHttpClient {
return this.delete(`/api/v1/platform/bots/${uuid}`); return this.delete(`/api/v1/platform/bots/${uuid}`);
} }
public getBotAdmins(botId: string): Promise<{
admins: Array<{ id: number; launcher_type: string; launcher_id: string }>;
}> {
return this.get(`/api/v1/platform/bots/${botId}/admins`);
}
public addBotAdmin(
botId: string,
launcher_type: string,
launcher_id: string,
): Promise<{ id: number }> {
return this.post(`/api/v1/platform/bots/${botId}/admins`, {
launcher_type,
launcher_id,
});
}
public deleteBotAdmin(botId: string, adminId: number): Promise<object> {
return this.delete(`/api/v1/platform/bots/${botId}/admins/${adminId}`);
}
public getBotLogs( public getBotLogs(
botId: string, botId: string,
request: GetBotLogsRequest, request: GetBotLogsRequest,
+20
View File
@@ -444,6 +444,26 @@ const enUS = {
userMessage: 'User', userMessage: 'User',
botMessage: 'Assistant', botMessage: 'Assistant',
}, },
admins: {
title: 'Admins',
description:
"Launchers (person/group IDs) that have admin privilege for this bot's commands",
addAdmin: 'Add Admin',
launcherType: 'Type',
launcherId: 'ID',
typePerson: 'Person',
typeGroup: 'Group',
placeholderId: 'User or group ID',
addSuccess: 'Admin added',
addError: 'Failed to add admin: ',
deleteSuccess: 'Admin removed',
deleteError: 'Failed to remove admin: ',
noAdmins: 'No admins configured',
setAdminTitle: 'Set as admin',
adminBadge: 'Admin',
configureAdmins: 'Manage Admins',
removeAdminTitle: 'Remove admin',
},
}, },
plugins: { plugins: {
title: 'Extensions', title: 'Extensions',
+19
View File
@@ -426,6 +426,25 @@ const zhHans = {
userMessage: '用户', userMessage: '用户',
botMessage: '助手', botMessage: '助手',
}, },
admins: {
title: '管理员',
description: '拥有此机器人命令管理员权限的会话(用户/群组 ID)',
addAdmin: '添加管理员',
launcherType: '类型',
launcherId: 'ID',
typePerson: '私聊',
typeGroup: '群聊',
placeholderId: '用户或群组 ID',
addSuccess: '添加成功',
addError: '添加失败:',
deleteSuccess: '已移除',
deleteError: '移除失败:',
noAdmins: '暂无管理员',
setAdminTitle: '设为管理员',
adminBadge: '管理员',
configureAdmins: '配置管理员',
removeAdminTitle: '移除管理员权限',
},
}, },
plugins: { plugins: {
title: '插件扩展', title: '插件扩展',