mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-08-23 18:47:15 +00:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2383485b86 | |||
| 9ab346c678 | |||
| a0ea0704fc | |||
| 6c5b01fa3c | |||
| 2ef3aebe16 | |||
| 48905ea080 | |||
| ddb77fc43c |
@@ -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')
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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 命令与流式的兼容性问题
|
||||||
|
response_index = len(query.resp_message_chain) - 1
|
||||||
|
message_chain = query.resp_message_chain[-1]
|
||||||
|
|
||||||
|
try:
|
||||||
if await query.adapter.is_stream_output_supported() and has_chunks:
|
if await query.adapter.is_stream_output_supported() and has_chunks:
|
||||||
is_final = [msg.is_final for msg in query.resp_messages][0]
|
is_final = [msg.is_final for msg in query.resp_messages][0]
|
||||||
await query.adapter.reply_message_chunk(
|
await query.adapter.reply_message_chunk(
|
||||||
message_source=query.message_event,
|
message_source=query.message_event,
|
||||||
bot_message=query.resp_messages[-1],
|
bot_message=query.resp_messages[-1],
|
||||||
message=query.resp_message_chain[-1],
|
message=message_chain,
|
||||||
quote_origin=quote_origin,
|
quote_origin=quote_origin,
|
||||||
is_final=is_final,
|
is_final=is_final,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
await query.adapter.reply_message(
|
await query.adapter.reply_message(
|
||||||
message_source=query.message_event,
|
message_source=query.message_event,
|
||||||
message=query.resp_message_chain[-1],
|
message=message_chain,
|
||||||
quote_origin=quote_origin,
|
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(
|
||||||
|
|||||||
@@ -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 []
|
||||||
|
|||||||
@@ -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,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:
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|
||||||
|
|||||||
@@ -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',
|
'rerank_model_uuid': 'rerank-1',
|
||||||
'query': 'hello',
|
'query': 'hello',
|
||||||
'documents': ['a', 'b'],
|
'documents': ['a', 'b'],
|
||||||
'top_k': 1,
|
'top_k': 1,
|
||||||
'extra_args': {'return_documents': False},
|
'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',
|
'rerank_model_uuid': 'missing',
|
||||||
'query': 'hello',
|
'query': 'hello',
|
||||||
'documents': ['a'],
|
'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
|
||||||
|
|||||||
@@ -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,9 +436,28 @@ const BotSessionMonitor = forwardRef<
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<div className="flex flex-col md:flex-row h-full min-h-0 rounded-lg border overflow-hidden">
|
<div className="flex flex-col md:flex-row h-full min-h-0 rounded-lg border overflow-hidden">
|
||||||
{/* Left Panel: Session List */}
|
{/* Left Panel: 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">
|
<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">
|
||||||
|
{/* Admin header */}
|
||||||
|
<div className="px-2 py-1.5 border-b shrink-0 flex items-center justify-between">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="inline-flex items-center gap-1.5 text-sm font-medium hover:text-foreground transition-colors"
|
||||||
|
onClick={() => setAdminsDialogOpen(true)}
|
||||||
|
>
|
||||||
|
<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 */}
|
{/* Session List */}
|
||||||
<ScrollArea className="flex-1 min-h-0">
|
<ScrollArea className="flex-1 min-h-0">
|
||||||
{loadingSessions && sessions.length === 0 ? (
|
{loadingSessions && sessions.length === 0 ? (
|
||||||
@@ -401,12 +472,15 @@ const BotSessionMonitor = forwardRef<
|
|||||||
<div className="p-1.5">
|
<div className="p-1.5">
|
||||||
{sessions.map((session) => {
|
{sessions.map((session) => {
|
||||||
const isSelected = selectedSessionId === session.session_id;
|
const isSelected = selectedSessionId === session.session_id;
|
||||||
|
const sessionType = parseSessionType(session.session_id);
|
||||||
|
const sessionIsAdmin = isSessionAdmin(session);
|
||||||
return (
|
return (
|
||||||
<button
|
<div
|
||||||
key={session.session_id}
|
key={session.session_id}
|
||||||
type="button"
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
className={cn(
|
className={cn(
|
||||||
'w-full text-left px-2.5 py-2 rounded-md transition-colors',
|
'w-full text-left px-2.5 py-2 rounded-md transition-colors cursor-pointer',
|
||||||
isSelected ? 'bg-accent' : 'hover:bg-accent/50',
|
isSelected ? 'bg-accent' : 'hover:bg-accent/50',
|
||||||
)}
|
)}
|
||||||
onClick={() => setSelectedSessionId(session.session_id)}
|
onClick={() => setSelectedSessionId(session.session_id)}
|
||||||
@@ -422,16 +496,12 @@ const BotSessionMonitor = forwardRef<
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||||
{parseSessionType(session.session_id) && (
|
{sessionType && (
|
||||||
<span className="px-1 py-0.5 rounded bg-muted text-[10px]">
|
<span className="px-1 py-0.5 rounded bg-muted text-[10px]">
|
||||||
{parseSessionType(session.session_id)}
|
{sessionType}
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{session.platform && (
|
|
||||||
<span className="px-1 py-0.5 rounded bg-muted text-[10px]">
|
|
||||||
{session.platform}
|
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{session.user_id && (
|
{session.user_id && (
|
||||||
<span className="truncate text-[10px]">
|
<span className="truncate text-[10px]">
|
||||||
{abbreviateId(session.user_id)}
|
{abbreviateId(session.user_id)}
|
||||||
@@ -443,7 +513,7 @@ const BotSessionMonitor = forwardRef<
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
@@ -460,8 +530,8 @@ const BotSessionMonitor = forwardRef<
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{/* Chat Header */}
|
{/* Chat Header */}
|
||||||
<div className="px-4 py-2.5 border-b shrink-0">
|
<div className="px-4 py-2.5 border-b shrink-0 flex items-center justify-between">
|
||||||
<div className="min-w-0">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="text-sm font-medium truncate">
|
<div className="text-sm font-medium truncate">
|
||||||
{selectedSession?.user_name ||
|
{selectedSession?.user_name ||
|
||||||
selectedSession?.user_id ||
|
selectedSession?.user_id ||
|
||||||
@@ -471,12 +541,6 @@ const BotSessionMonitor = forwardRef<
|
|||||||
{parseSessionType(selectedSessionId) && (
|
{parseSessionType(selectedSessionId) && (
|
||||||
<span>{parseSessionType(selectedSessionId)}</span>
|
<span>{parseSessionType(selectedSessionId)}</span>
|
||||||
)}
|
)}
|
||||||
{selectedSession?.platform && (
|
|
||||||
<>
|
|
||||||
{parseSessionType(selectedSessionId) && <span>·</span>}
|
|
||||||
<span>{selectedSession.platform}</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{selectedSession?.user_id && (
|
{selectedSession?.user_id && (
|
||||||
<>
|
<>
|
||||||
<span>·</span>
|
<span>·</span>
|
||||||
@@ -506,6 +570,34 @@ const BotSessionMonitor = forwardRef<
|
|||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
{selectedSession && parseSessionType(selectedSessionId) && (
|
||||||
|
<>
|
||||||
|
<span>·</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={cn(
|
||||||
|
'inline-flex items-center gap-1 transition-colors',
|
||||||
|
isSessionAdmin(selectedSession)
|
||||||
|
? 'text-blue-500'
|
||||||
|
: 'text-muted-foreground hover:text-blue-500',
|
||||||
|
)}
|
||||||
|
disabled={togglingAdmin === selectedSessionId}
|
||||||
|
title={
|
||||||
|
isSessionAdmin(selectedSession)
|
||||||
|
? t('bots.admins.removeAdminTitle')
|
||||||
|
: t('bots.admins.setAdminTitle')
|
||||||
|
}
|
||||||
|
onClick={() => toggleAdmin(selectedSession)}
|
||||||
|
>
|
||||||
|
{isSessionAdmin(selectedSession) ? (
|
||||||
|
<ShieldCheck className="size-3.5" />
|
||||||
|
) : (
|
||||||
|
<ShieldOff className="size-3.5" />
|
||||||
|
)}
|
||||||
|
<span>{t('bots.admins.adminBadge')}</span>
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -554,7 +646,8 @@ const BotSessionMonitor = forwardRef<
|
|||||||
isUser
|
isUser
|
||||||
? 'bg-primary/10 rounded-br-sm'
|
? 'bg-primary/10 rounded-br-sm'
|
||||||
: 'bg-muted rounded-bl-sm',
|
: 'bg-muted rounded-bl-sm',
|
||||||
msg.status === 'error' && 'ring-1 ring-red-400/50',
|
msg.status === 'error' &&
|
||||||
|
'ring-1 ring-red-400/50',
|
||||||
isDiscarded && 'opacity-60',
|
isDiscarded && 'opacity-60',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -635,6 +728,15 @@ const BotSessionMonitor = forwardRef<
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<BotAdminsDialog
|
||||||
|
botId={botId}
|
||||||
|
open={adminsDialogOpen}
|
||||||
|
onOpenChange={setAdminsDialogOpen}
|
||||||
|
admins={admins}
|
||||||
|
onAdminsChange={reloadAdmins}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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: '插件扩展',
|
||||||
|
|||||||
Reference in New Issue
Block a user