mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-02 12:05:54 +00:00
Compare commits
2 Commits
feat/add-m
...
feat/human
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d3d366b569 | ||
|
|
db68c5d0c9 |
97
src/langbot/pkg/api/http/controller/groups/human_takeover.py
Normal file
97
src/langbot/pkg/api/http/controller/groups/human_takeover.py
Normal file
@@ -0,0 +1,97 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import quart
|
||||
|
||||
from .. import group
|
||||
|
||||
|
||||
@group.group_class('human-takeover', '/api/v1/human-takeover')
|
||||
class HumanTakeoverRouterGroup(group.RouterGroup):
|
||||
async def initialize(self) -> None:
|
||||
@self.route('/sessions', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
|
||||
async def get_sessions():
|
||||
"""Get list of takeover sessions, optionally filtered by bot UUID."""
|
||||
bot_uuid = quart.request.args.get('botUuid')
|
||||
limit = int(quart.request.args.get('limit', 100))
|
||||
offset = int(quart.request.args.get('offset', 0))
|
||||
|
||||
sessions, total = await self.ap.human_takeover_service.get_active_sessions(
|
||||
bot_uuid=bot_uuid if bot_uuid else None,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
|
||||
return self.success(
|
||||
data={
|
||||
'sessions': sessions,
|
||||
'total': total,
|
||||
'limit': limit,
|
||||
'offset': offset,
|
||||
}
|
||||
)
|
||||
|
||||
@self.route('/sessions/<session_id>', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
|
||||
async def get_session_detail(session_id: str):
|
||||
"""Get detail for a specific takeover session."""
|
||||
detail = await self.ap.human_takeover_service.get_session_detail(session_id)
|
||||
if not detail:
|
||||
return self.success(data={'found': False, 'session_id': session_id})
|
||||
return self.success(data={'found': True, 'session': detail})
|
||||
|
||||
@self.route('/sessions/<session_id>/takeover', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
|
||||
async def takeover_session(session_id: str, user_email: str = None):
|
||||
"""Take over a conversation session."""
|
||||
data = await quart.request.get_json(silent=True) or {}
|
||||
|
||||
bot_uuid = data.get('bot_uuid')
|
||||
if not bot_uuid:
|
||||
return self.fail(-1, 'bot_uuid is required')
|
||||
|
||||
platform = data.get('platform')
|
||||
user_id = data.get('user_id')
|
||||
user_name = data.get('user_name')
|
||||
|
||||
try:
|
||||
result = await self.ap.human_takeover_service.takeover_session(
|
||||
session_id=session_id,
|
||||
bot_uuid=bot_uuid,
|
||||
taken_by=user_email or data.get('taken_by'),
|
||||
platform=platform,
|
||||
user_id=user_id,
|
||||
user_name=user_name,
|
||||
)
|
||||
return self.success(data=result)
|
||||
except ValueError as e:
|
||||
return self.fail(-1, str(e))
|
||||
|
||||
@self.route('/sessions/<session_id>/release', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
|
||||
async def release_session(session_id: str):
|
||||
"""Release a taken-over session back to AI pipeline."""
|
||||
try:
|
||||
result = await self.ap.human_takeover_service.release_session(session_id)
|
||||
return self.success(data=result)
|
||||
except ValueError as e:
|
||||
return self.fail(-1, str(e))
|
||||
|
||||
@self.route('/sessions/<session_id>/message', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
|
||||
async def send_message(session_id: str, user_email: str = None):
|
||||
"""Send a message from the operator to the user."""
|
||||
data = await quart.request.get_json(silent=True) or {}
|
||||
|
||||
message_text = data.get('message')
|
||||
if not message_text:
|
||||
return self.fail(-1, 'message is required')
|
||||
|
||||
operator_name = user_email or data.get('operator_name', 'Operator')
|
||||
|
||||
try:
|
||||
result = await self.ap.human_takeover_service.send_message(
|
||||
session_id=session_id,
|
||||
message_text=message_text,
|
||||
operator_name=operator_name,
|
||||
)
|
||||
return self.success(data=result)
|
||||
except ValueError as e:
|
||||
return self.fail(-1, str(e))
|
||||
except RuntimeError as e:
|
||||
return self.fail(-2, str(e))
|
||||
314
src/langbot/pkg/api/http/service/human_takeover.py
Normal file
314
src/langbot/pkg/api/http/service/human_takeover.py
Normal file
@@ -0,0 +1,314 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
import datetime
|
||||
import json
|
||||
import logging
|
||||
|
||||
import sqlalchemy
|
||||
|
||||
from ....core import app
|
||||
from ....entity.persistence import human_takeover as persistence_human_takeover
|
||||
|
||||
import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
||||
|
||||
|
||||
class HumanTakeoverService:
|
||||
"""Human takeover service.
|
||||
|
||||
Manages operator takeover of user conversation sessions, bypassing
|
||||
the normal AI pipeline. Uses an in-memory cache for fast synchronous
|
||||
lookups on the hot message path, backed by database persistence.
|
||||
"""
|
||||
|
||||
ap: app.Application
|
||||
|
||||
# In-memory cache: session_id -> HumanTakeoverSession record id
|
||||
# Only contains sessions with status='active'
|
||||
_active_sessions: dict[str, str]
|
||||
|
||||
logger: logging.Logger
|
||||
|
||||
def __init__(self, ap: app.Application) -> None:
|
||||
self.ap = ap
|
||||
self._active_sessions = {}
|
||||
self.logger = logging.getLogger('human-takeover')
|
||||
|
||||
async def initialize(self) -> None:
|
||||
"""Load active takeover sessions from DB into memory cache."""
|
||||
try:
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.select(persistence_human_takeover.HumanTakeoverSession).where(
|
||||
persistence_human_takeover.HumanTakeoverSession.status == 'active'
|
||||
)
|
||||
)
|
||||
rows = result.all()
|
||||
for row in rows:
|
||||
session = row[0] if isinstance(row, tuple) else row
|
||||
self._active_sessions[session.session_id] = session.id
|
||||
self.logger.info(f'Loaded {len(self._active_sessions)} active takeover sessions from DB')
|
||||
except Exception as e:
|
||||
self.logger.warning(f'Failed to load active takeover sessions: {e}')
|
||||
|
||||
def is_taken_over(self, session_id: str) -> bool:
|
||||
"""Check if a session is currently under human takeover.
|
||||
|
||||
This is a synchronous in-memory lookup for performance, since it
|
||||
is called on every incoming message (hot path).
|
||||
"""
|
||||
return session_id in self._active_sessions
|
||||
|
||||
async def takeover_session(
|
||||
self,
|
||||
session_id: str,
|
||||
bot_uuid: str,
|
||||
taken_by: str | None = None,
|
||||
platform: str | None = None,
|
||||
user_id: str | None = None,
|
||||
user_name: str | None = None,
|
||||
) -> dict:
|
||||
"""Take over a conversation session.
|
||||
|
||||
Args:
|
||||
session_id: The session to take over (e.g. 'person_123' or 'group_456').
|
||||
bot_uuid: UUID of the bot whose session is being taken over.
|
||||
taken_by: Email/username of the admin performing the takeover.
|
||||
platform: Platform name.
|
||||
user_id: The end-user's ID in the session.
|
||||
user_name: The end-user's display name.
|
||||
|
||||
Returns:
|
||||
Dict with the created takeover session record.
|
||||
|
||||
Raises:
|
||||
ValueError: If the session is already taken over.
|
||||
"""
|
||||
if self.is_taken_over(session_id):
|
||||
raise ValueError(f'Session {session_id} is already taken over')
|
||||
|
||||
record_id = str(uuid.uuid4())
|
||||
now = datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None)
|
||||
|
||||
record_data = {
|
||||
'id': record_id,
|
||||
'session_id': session_id,
|
||||
'bot_uuid': bot_uuid,
|
||||
'status': 'active',
|
||||
'taken_by': taken_by,
|
||||
'taken_at': now,
|
||||
'released_at': None,
|
||||
'platform': platform,
|
||||
'user_id': user_id,
|
||||
'user_name': user_name,
|
||||
}
|
||||
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.insert(persistence_human_takeover.HumanTakeoverSession).values(record_data)
|
||||
)
|
||||
|
||||
# Update in-memory cache
|
||||
self._active_sessions[session_id] = record_id
|
||||
|
||||
self.logger.info(f'Session {session_id} taken over by {taken_by}')
|
||||
|
||||
return record_data
|
||||
|
||||
async def release_session(self, session_id: str) -> dict:
|
||||
"""Release a taken-over session back to AI pipeline processing.
|
||||
|
||||
Args:
|
||||
session_id: The session to release.
|
||||
|
||||
Returns:
|
||||
Dict with the updated takeover session record.
|
||||
|
||||
Raises:
|
||||
ValueError: If the session is not currently taken over.
|
||||
"""
|
||||
if not self.is_taken_over(session_id):
|
||||
raise ValueError(f'Session {session_id} is not currently taken over')
|
||||
|
||||
now = datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None)
|
||||
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.update(persistence_human_takeover.HumanTakeoverSession)
|
||||
.where(
|
||||
sqlalchemy.and_(
|
||||
persistence_human_takeover.HumanTakeoverSession.session_id == session_id,
|
||||
persistence_human_takeover.HumanTakeoverSession.status == 'active',
|
||||
)
|
||||
)
|
||||
.values(status='released', released_at=now)
|
||||
)
|
||||
|
||||
# Remove from in-memory cache
|
||||
self._active_sessions.pop(session_id, None)
|
||||
|
||||
self.logger.info(f'Session {session_id} released back to AI pipeline')
|
||||
|
||||
return {
|
||||
'session_id': session_id,
|
||||
'status': 'released',
|
||||
'released_at': now.isoformat(),
|
||||
}
|
||||
|
||||
async def send_message(
|
||||
self,
|
||||
session_id: str,
|
||||
message_text: str,
|
||||
operator_name: str | None = None,
|
||||
) -> dict:
|
||||
"""Send a message from the operator to the user via the platform adapter.
|
||||
|
||||
Args:
|
||||
session_id: The taken-over session ID (e.g. 'person_123' or 'group_456').
|
||||
message_text: The text message to send.
|
||||
operator_name: Name of the operator sending the message.
|
||||
|
||||
Returns:
|
||||
Dict with send result info.
|
||||
|
||||
Raises:
|
||||
ValueError: If the session is not currently taken over.
|
||||
RuntimeError: If the bot or adapter cannot be found.
|
||||
"""
|
||||
if not self.is_taken_over(session_id):
|
||||
raise ValueError(f'Session {session_id} is not currently taken over')
|
||||
|
||||
# Look up the takeover record to get bot_uuid
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.select(persistence_human_takeover.HumanTakeoverSession).where(
|
||||
sqlalchemy.and_(
|
||||
persistence_human_takeover.HumanTakeoverSession.session_id == session_id,
|
||||
persistence_human_takeover.HumanTakeoverSession.status == 'active',
|
||||
)
|
||||
)
|
||||
)
|
||||
row = result.first()
|
||||
if not row:
|
||||
raise RuntimeError(f'Active takeover record not found for session {session_id}')
|
||||
|
||||
takeover_record = row[0] if isinstance(row, tuple) else row
|
||||
bot_uuid = takeover_record.bot_uuid
|
||||
|
||||
# Get the runtime bot
|
||||
runtime_bot = await self.ap.platform_mgr.get_bot_by_uuid(bot_uuid)
|
||||
if not runtime_bot:
|
||||
raise RuntimeError(f'Bot {bot_uuid} not found or not running')
|
||||
|
||||
# Parse session_id to determine target_type and target_id
|
||||
# Format: 'person_{id}' or 'group_{id}'
|
||||
if session_id.startswith('person_'):
|
||||
target_type = 'person'
|
||||
target_id = session_id[len('person_') :]
|
||||
elif session_id.startswith('group_'):
|
||||
target_type = 'group'
|
||||
target_id = session_id[len('group_') :]
|
||||
else:
|
||||
raise ValueError(f'Invalid session_id format: {session_id}')
|
||||
|
||||
# Build message chain
|
||||
message_chain = platform_message.MessageChain([platform_message.Plain(text=message_text)])
|
||||
|
||||
# Send via adapter
|
||||
await runtime_bot.adapter.send_message(target_type, target_id, message_chain)
|
||||
|
||||
# Record the operator message in monitoring
|
||||
bot_name = runtime_bot.bot_entity.name or bot_uuid
|
||||
try:
|
||||
message_content = json.dumps(message_chain.model_dump(), ensure_ascii=False)
|
||||
except Exception:
|
||||
message_content = message_text
|
||||
|
||||
await self.ap.monitoring_service.record_message(
|
||||
bot_id=bot_uuid,
|
||||
bot_name=bot_name,
|
||||
pipeline_id='__human_takeover__',
|
||||
pipeline_name='Human Takeover',
|
||||
message_content=message_content,
|
||||
session_id=session_id,
|
||||
status='success',
|
||||
level='info',
|
||||
platform=takeover_record.platform,
|
||||
user_id=operator_name or 'operator',
|
||||
user_name=operator_name or 'Operator',
|
||||
role='operator',
|
||||
)
|
||||
|
||||
self.logger.info(f'Operator message sent to session {session_id}: {message_text[:50]}...')
|
||||
|
||||
return {
|
||||
'session_id': session_id,
|
||||
'message_sent': True,
|
||||
}
|
||||
|
||||
async def get_active_sessions(
|
||||
self,
|
||||
bot_uuid: str | None = None,
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
) -> tuple[list[dict], int]:
|
||||
"""Get list of active (or all) takeover sessions.
|
||||
|
||||
Args:
|
||||
bot_uuid: Optional filter by bot UUID.
|
||||
limit: Maximum number of results.
|
||||
offset: Pagination offset.
|
||||
|
||||
Returns:
|
||||
Tuple of (list of session dicts, total count).
|
||||
"""
|
||||
conditions = []
|
||||
|
||||
if bot_uuid:
|
||||
conditions.append(persistence_human_takeover.HumanTakeoverSession.bot_uuid == bot_uuid)
|
||||
|
||||
# Count
|
||||
count_query = sqlalchemy.select(sqlalchemy.func.count(persistence_human_takeover.HumanTakeoverSession.id))
|
||||
if conditions:
|
||||
count_query = count_query.where(sqlalchemy.and_(*conditions))
|
||||
|
||||
count_result = await self.ap.persistence_mgr.execute_async(count_query)
|
||||
total = count_result.scalar() or 0
|
||||
|
||||
# Fetch records
|
||||
query = sqlalchemy.select(persistence_human_takeover.HumanTakeoverSession).order_by(
|
||||
persistence_human_takeover.HumanTakeoverSession.taken_at.desc()
|
||||
)
|
||||
if conditions:
|
||||
query = query.where(sqlalchemy.and_(*conditions))
|
||||
|
||||
query = query.limit(limit).offset(offset)
|
||||
|
||||
result = await self.ap.persistence_mgr.execute_async(query)
|
||||
rows = result.all()
|
||||
|
||||
sessions = []
|
||||
for row in rows:
|
||||
session = row[0] if isinstance(row, tuple) else row
|
||||
sessions.append(
|
||||
self.ap.persistence_mgr.serialize_model(persistence_human_takeover.HumanTakeoverSession, session)
|
||||
)
|
||||
|
||||
return sessions, total
|
||||
|
||||
async def get_session_detail(self, session_id: str) -> dict | None:
|
||||
"""Get detail for a specific takeover session.
|
||||
|
||||
Args:
|
||||
session_id: The session ID to look up.
|
||||
|
||||
Returns:
|
||||
Session dict or None if not found.
|
||||
"""
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.select(persistence_human_takeover.HumanTakeoverSession)
|
||||
.where(persistence_human_takeover.HumanTakeoverSession.session_id == session_id)
|
||||
.order_by(persistence_human_takeover.HumanTakeoverSession.taken_at.desc())
|
||||
)
|
||||
row = result.first()
|
||||
if not row:
|
||||
return None
|
||||
|
||||
session = row[0] if isinstance(row, tuple) else row
|
||||
return self.ap.persistence_mgr.serialize_model(persistence_human_takeover.HumanTakeoverSession, session)
|
||||
@@ -31,6 +31,7 @@ from ..api.http.service import mcp as mcp_service
|
||||
from ..api.http.service import apikey as apikey_service
|
||||
from ..api.http.service import webhook as webhook_service
|
||||
from ..api.http.service import monitoring as monitoring_service
|
||||
from ..api.http.service import human_takeover as human_takeover_service
|
||||
|
||||
from ..discover import engine as discover_engine
|
||||
from ..storage import mgr as storagemgr
|
||||
@@ -153,6 +154,8 @@ class Application:
|
||||
|
||||
monitoring_service: monitoring_service.MonitoringService = None
|
||||
|
||||
human_takeover_service: human_takeover_service.HumanTakeoverService = None
|
||||
|
||||
def __init__(self):
|
||||
pass
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ from ...api.http.service import mcp as mcp_service
|
||||
from ...api.http.service import apikey as apikey_service
|
||||
from ...api.http.service import webhook as webhook_service
|
||||
from ...api.http.service import monitoring as monitoring_service
|
||||
from ...api.http.service import human_takeover as human_takeover_service
|
||||
from ...discover import engine as discover_engine
|
||||
from ...storage import mgr as storagemgr
|
||||
from ...utils import logcache
|
||||
@@ -164,6 +165,10 @@ class BuildAppStage(stage.BootingStage):
|
||||
monitoring_service_inst = monitoring_service.MonitoringService(ap)
|
||||
ap.monitoring_service = monitoring_service_inst
|
||||
|
||||
human_takeover_service_inst = human_takeover_service.HumanTakeoverService(ap)
|
||||
await human_takeover_service_inst.initialize()
|
||||
ap.human_takeover_service = human_takeover_service_inst
|
||||
|
||||
async def runtime_disconnect_callback(connector: plugin_connector.PluginRuntimeConnector) -> None:
|
||||
await asyncio.sleep(3)
|
||||
await plugin_connector_inst.initialize()
|
||||
|
||||
36
src/langbot/pkg/entity/persistence/human_takeover.py
Normal file
36
src/langbot/pkg/entity/persistence/human_takeover.py
Normal file
@@ -0,0 +1,36 @@
|
||||
import sqlalchemy
|
||||
|
||||
from .base import Base
|
||||
|
||||
|
||||
class HumanTakeoverSession(Base):
|
||||
"""Human takeover session records.
|
||||
|
||||
Tracks which conversation sessions are currently under human operator control,
|
||||
bypassing the normal AI pipeline processing.
|
||||
"""
|
||||
|
||||
__tablename__ = 'human_takeover_sessions'
|
||||
|
||||
id = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True)
|
||||
session_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, unique=True, index=True)
|
||||
"""Corresponds to monitoring_sessions.session_id, format: 'person_{id}' or 'group_{id}'"""
|
||||
|
||||
bot_uuid = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, index=True)
|
||||
"""UUID of the bot whose session is being taken over"""
|
||||
|
||||
status = sqlalchemy.Column(sqlalchemy.String(50), nullable=False, default='active', index=True)
|
||||
"""Takeover status: 'active' or 'released'"""
|
||||
|
||||
taken_by = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
|
||||
"""Email/username of the admin who took over the session"""
|
||||
|
||||
taken_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False)
|
||||
"""Timestamp when the takeover started"""
|
||||
|
||||
released_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=True)
|
||||
"""Timestamp when the takeover was released (null if still active)"""
|
||||
|
||||
platform = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
|
||||
user_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
|
||||
user_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
|
||||
@@ -0,0 +1,36 @@
|
||||
import sqlalchemy
|
||||
from .. import migration
|
||||
|
||||
|
||||
@migration.migration_class(26)
|
||||
class DBMigrateHumanTakeoverSessions(migration.DBMigration):
|
||||
"""Create human_takeover_sessions table for human operator takeover support"""
|
||||
|
||||
async def upgrade(self):
|
||||
sql_text = sqlalchemy.text("""
|
||||
CREATE TABLE IF NOT EXISTS human_takeover_sessions (
|
||||
id VARCHAR(255) PRIMARY KEY,
|
||||
session_id VARCHAR(255) NOT NULL UNIQUE,
|
||||
bot_uuid VARCHAR(255) NOT NULL,
|
||||
status VARCHAR(50) NOT NULL DEFAULT 'active',
|
||||
taken_by VARCHAR(255),
|
||||
taken_at DATETIME NOT NULL,
|
||||
released_at DATETIME,
|
||||
platform VARCHAR(255),
|
||||
user_id VARCHAR(255),
|
||||
user_name VARCHAR(255)
|
||||
)
|
||||
""")
|
||||
await self.ap.persistence_mgr.execute_async(sql_text)
|
||||
|
||||
# Create indexes
|
||||
for idx_sql in [
|
||||
'CREATE INDEX IF NOT EXISTS idx_hts_session_id ON human_takeover_sessions (session_id)',
|
||||
'CREATE INDEX IF NOT EXISTS idx_hts_bot_uuid ON human_takeover_sessions (bot_uuid)',
|
||||
'CREATE INDEX IF NOT EXISTS idx_hts_status ON human_takeover_sessions (status)',
|
||||
]:
|
||||
await self.ap.persistence_mgr.execute_async(sqlalchemy.text(idx_sql))
|
||||
|
||||
async def downgrade(self):
|
||||
sql_text = sqlalchemy.text('DROP TABLE IF EXISTS human_takeover_sessions')
|
||||
await self.ap.persistence_mgr.execute_async(sql_text)
|
||||
@@ -220,6 +220,47 @@ class RuntimeBot:
|
||||
|
||||
# Only add to query pool if no webhook requested to skip pipeline
|
||||
if not skip_pipeline:
|
||||
# Check if session is under human takeover
|
||||
person_session_id = f'person_{event.sender.id}'
|
||||
if (
|
||||
hasattr(self.ap, 'human_takeover_service')
|
||||
and self.ap.human_takeover_service
|
||||
and self.ap.human_takeover_service.is_taken_over(person_session_id)
|
||||
):
|
||||
# Session is taken over: record message to monitoring then stop
|
||||
await self.logger.info(
|
||||
f'Person message intercepted by human takeover for session {person_session_id}'
|
||||
)
|
||||
try:
|
||||
if hasattr(event.message_chain, 'model_dump'):
|
||||
msg_content = json.dumps(event.message_chain.model_dump(), ensure_ascii=False)
|
||||
else:
|
||||
msg_content = str(event.message_chain)
|
||||
|
||||
sender_name = None
|
||||
if hasattr(event, 'sender') and hasattr(event.sender, 'nickname'):
|
||||
sender_name = event.sender.nickname
|
||||
|
||||
await self.ap.monitoring_service.record_message(
|
||||
bot_id=self.bot_entity.uuid,
|
||||
bot_name=self.bot_entity.name or self.bot_entity.uuid,
|
||||
pipeline_id='__human_takeover__',
|
||||
pipeline_name='Human Takeover',
|
||||
message_content=msg_content,
|
||||
session_id=person_session_id,
|
||||
status='success',
|
||||
level='info',
|
||||
platform=adapter.__class__.__name__,
|
||||
user_id=str(event.sender.id),
|
||||
user_name=sender_name,
|
||||
role='user',
|
||||
)
|
||||
|
||||
await self.ap.monitoring_service.update_session_activity(person_session_id)
|
||||
except Exception as e:
|
||||
await self.logger.error(f'Failed to record takeover message: {e}')
|
||||
return
|
||||
|
||||
launcher_id = event.sender.id
|
||||
|
||||
if hasattr(adapter, 'get_launcher_id'):
|
||||
@@ -281,6 +322,50 @@ class RuntimeBot:
|
||||
|
||||
# Only add to query pool if no webhook requested to skip pipeline
|
||||
if not skip_pipeline:
|
||||
# Check if session is under human takeover
|
||||
group_session_id = f'group_{event.group.id}'
|
||||
if (
|
||||
hasattr(self.ap, 'human_takeover_service')
|
||||
and self.ap.human_takeover_service
|
||||
and self.ap.human_takeover_service.is_taken_over(group_session_id)
|
||||
):
|
||||
# Session is taken over: record message to monitoring then stop
|
||||
await self.logger.info(
|
||||
f'Group message intercepted by human takeover for session {group_session_id}'
|
||||
)
|
||||
try:
|
||||
if hasattr(event.message_chain, 'model_dump'):
|
||||
msg_content = json.dumps(event.message_chain.model_dump(), ensure_ascii=False)
|
||||
else:
|
||||
msg_content = str(event.message_chain)
|
||||
|
||||
sender_name = None
|
||||
if hasattr(event, 'sender'):
|
||||
if hasattr(event.sender, 'member_name'):
|
||||
sender_name = event.sender.member_name
|
||||
elif hasattr(event.sender, 'nickname'):
|
||||
sender_name = event.sender.nickname
|
||||
|
||||
await self.ap.monitoring_service.record_message(
|
||||
bot_id=self.bot_entity.uuid,
|
||||
bot_name=self.bot_entity.name or self.bot_entity.uuid,
|
||||
pipeline_id='__human_takeover__',
|
||||
pipeline_name='Human Takeover',
|
||||
message_content=msg_content,
|
||||
session_id=group_session_id,
|
||||
status='success',
|
||||
level='info',
|
||||
platform=adapter.__class__.__name__,
|
||||
user_id=str(event.sender.id),
|
||||
user_name=sender_name,
|
||||
role='user',
|
||||
)
|
||||
|
||||
await self.ap.monitoring_service.update_session_activity(group_session_id)
|
||||
except Exception as e:
|
||||
await self.logger.error(f'Failed to record takeover message: {e}')
|
||||
return
|
||||
|
||||
launcher_id = event.group.id
|
||||
|
||||
if hasattr(adapter, 'get_launcher_id'):
|
||||
|
||||
@@ -2,7 +2,7 @@ import langbot
|
||||
|
||||
semantic_version = f'v{langbot.__version__}'
|
||||
|
||||
required_database_version = 25
|
||||
required_database_version = 26
|
||||
"""Tag the version of the database schema, used to check if the database needs to be migrated"""
|
||||
|
||||
debug_mode = False
|
||||
|
||||
8
uv.lock
generated
8
uv.lock
generated
@@ -1937,7 +1937,7 @@ requires-dist = [
|
||||
{ name = "ebooklib", specifier = ">=0.18" },
|
||||
{ name = "gewechat-client", specifier = ">=0.1.5" },
|
||||
{ name = "html2text", specifier = ">=2024.2.26" },
|
||||
{ name = "langbot-plugin", specifier = "==0.3.6" },
|
||||
{ name = "langbot-plugin", specifier = "==0.3.7" },
|
||||
{ name = "langchain", specifier = ">=0.2.0" },
|
||||
{ name = "langchain-text-splitters", specifier = ">=0.0.1" },
|
||||
{ name = "lark-oapi", specifier = ">=1.4.15" },
|
||||
@@ -1993,7 +1993,7 @@ dev = [
|
||||
|
||||
[[package]]
|
||||
name = "langbot-plugin"
|
||||
version = "0.3.6"
|
||||
version = "0.3.7"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "aiofiles" },
|
||||
@@ -2011,9 +2011,9 @@ dependencies = [
|
||||
{ name = "watchdog" },
|
||||
{ name = "websockets" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ff/f0/e5561bd1ebda0b9345ad6b98718b5f002bb3ca79b5ec294dc77cc10957b9/langbot_plugin-0.3.6.tar.gz", hash = "sha256:20db981e416a640f22246e54517abc2a095d8ccf5e69e06c2674fb8a443f5dbe", size = 179266, upload-time = "2026-03-30T15:58:58.523Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/12/31/8dc7106cb65004a01e363308343c5a95e35f1722f26c87853e6e12c6fee1/langbot_plugin-0.3.7.tar.gz", hash = "sha256:bc0dea6b1c515d9fc8c3ab14af74bdf3e006d7e20c097b6cb5034f5af4a73cc9", size = 179764, upload-time = "2026-04-03T09:43:17.343Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/f5/ac424c2620e1be98a54a0b8ec0ed256a9c06cea7cd32a30732a1aea5fdc5/langbot_plugin-0.3.6-py3-none-any.whl", hash = "sha256:3238448436c41d50a0a0cf37438d845f0a1371159d440af3411a984e3d4e9eb7", size = 156752, upload-time = "2026-03-30T15:59:00.229Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/51/1982c199bd4efbfa3c327c95cca7e4ab502610251567000b348c72bca1b1/langbot_plugin-0.3.7-py3-none-any.whl", hash = "sha256:2e2b9e99163ceb14da28b8ce7c4cbc6990dea15684ec78976bc015e5378feea2", size = 157324, upload-time = "2026-04-03T09:43:15.782Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -10,7 +10,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Ban, Bot, Copy, Check, Workflow } from 'lucide-react';
|
||||
import { Ban, Bot, Copy, Check, Workflow, UserCheck, Send } from 'lucide-react';
|
||||
import {
|
||||
MessageChainComponent,
|
||||
Plain,
|
||||
@@ -77,6 +77,16 @@ const BotSessionMonitor = forwardRef<
|
||||
const [copiedUserId, setCopiedUserId] = useState(false);
|
||||
const messagesContainerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Human takeover state
|
||||
const [isTakenOver, setIsTakenOver] = useState(false);
|
||||
const [takeoverLoading, setTakeoverLoading] = useState(false);
|
||||
const [operatorMessage, setOperatorMessage] = useState('');
|
||||
const [sendingMessage, setSendingMessage] = useState(false);
|
||||
// Track which sessions are taken over for showing badges in the list
|
||||
const [takenOverSessions, setTakenOverSessions] = useState<Set<string>>(
|
||||
new Set(),
|
||||
);
|
||||
|
||||
const parseSessionType = (sessionId: string): string | null => {
|
||||
const idx = sessionId.indexOf('_');
|
||||
if (idx === -1) return null;
|
||||
@@ -109,6 +119,24 @@ const BotSessionMonitor = forwardRef<
|
||||
}
|
||||
}, [botId]);
|
||||
|
||||
// Load active takeover sessions to know which ones show a badge
|
||||
const loadTakeoverStatus = useCallback(async () => {
|
||||
try {
|
||||
const response = await httpClient.getHumanTakeoverSessions({
|
||||
botUuid: botId,
|
||||
});
|
||||
const activeIds = new Set<string>();
|
||||
for (const session of response.sessions ?? []) {
|
||||
if (session.status === 'active') {
|
||||
activeIds.add(session.session_id);
|
||||
}
|
||||
}
|
||||
setTakenOverSessions(activeIds);
|
||||
} catch {
|
||||
// Silently ignore — takeover feature may not be available
|
||||
}
|
||||
}, [botId]);
|
||||
|
||||
useImperativeHandle(
|
||||
ref,
|
||||
() => ({
|
||||
@@ -133,17 +161,45 @@ const BotSessionMonitor = forwardRef<
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Check takeover status for selected session
|
||||
const checkTakeoverStatus = useCallback(
|
||||
async (sessionId: string) => {
|
||||
try {
|
||||
const response =
|
||||
await httpClient.getHumanTakeoverSessionDetail(sessionId);
|
||||
const isActive =
|
||||
response.found && response.session?.status === 'active';
|
||||
setIsTakenOver(isActive);
|
||||
} catch {
|
||||
setIsTakenOver(false);
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
loadSessions();
|
||||
}, [loadSessions]);
|
||||
loadTakeoverStatus();
|
||||
}, [loadSessions, loadTakeoverStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedSessionId) {
|
||||
loadMessages(selectedSessionId);
|
||||
checkTakeoverStatus(selectedSessionId);
|
||||
} else {
|
||||
setMessages([]);
|
||||
setIsTakenOver(false);
|
||||
}
|
||||
}, [selectedSessionId, loadMessages]);
|
||||
}, [selectedSessionId, loadMessages, checkTakeoverStatus]);
|
||||
|
||||
// Auto-refresh messages when session is taken over (polling)
|
||||
useEffect(() => {
|
||||
if (!selectedSessionId || !isTakenOver) return;
|
||||
const interval = setInterval(() => {
|
||||
loadMessages(selectedSessionId);
|
||||
}, 3000);
|
||||
return () => clearInterval(interval);
|
||||
}, [selectedSessionId, isTakenOver, loadMessages]);
|
||||
|
||||
useEffect(() => {
|
||||
if (messages.length === 0) return;
|
||||
@@ -160,6 +216,76 @@ const BotSessionMonitor = forwardRef<
|
||||
});
|
||||
}, [messages]);
|
||||
|
||||
const handleTakeover = async () => {
|
||||
if (!selectedSessionId || !selectedSession) return;
|
||||
if (!confirm(t('bots.sessionMonitor.takeoverConfirm'))) return;
|
||||
|
||||
setTakeoverLoading(true);
|
||||
try {
|
||||
await httpClient.takeoverSession(selectedSessionId, {
|
||||
bot_uuid: botId,
|
||||
platform: selectedSession.platform ?? undefined,
|
||||
user_id: selectedSession.user_id ?? undefined,
|
||||
user_name: selectedSession.user_name ?? undefined,
|
||||
});
|
||||
setIsTakenOver(true);
|
||||
setTakenOverSessions((prev) => new Set(prev).add(selectedSessionId));
|
||||
} catch (error) {
|
||||
console.error('Takeover failed:', error);
|
||||
alert(t('bots.sessionMonitor.takeoverFailed'));
|
||||
} finally {
|
||||
setTakeoverLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRelease = async () => {
|
||||
if (!selectedSessionId) return;
|
||||
if (!confirm(t('bots.sessionMonitor.releaseConfirm'))) return;
|
||||
|
||||
setTakeoverLoading(true);
|
||||
try {
|
||||
await httpClient.releaseSession(selectedSessionId);
|
||||
setIsTakenOver(false);
|
||||
setTakenOverSessions((prev) => {
|
||||
const next = new Set(prev);
|
||||
next.delete(selectedSessionId);
|
||||
return next;
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Release failed:', error);
|
||||
alert(t('bots.sessionMonitor.releaseFailed'));
|
||||
} finally {
|
||||
setTakeoverLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSendMessage = async () => {
|
||||
if (!selectedSessionId || !operatorMessage.trim()) return;
|
||||
|
||||
setSendingMessage(true);
|
||||
try {
|
||||
await httpClient.sendTakeoverMessage(
|
||||
selectedSessionId,
|
||||
operatorMessage.trim(),
|
||||
);
|
||||
setOperatorMessage('');
|
||||
// Reload messages to show the sent one
|
||||
await loadMessages(selectedSessionId);
|
||||
} catch (error) {
|
||||
console.error('Send message failed:', error);
|
||||
alert(t('bots.sessionMonitor.sendFailed'));
|
||||
} finally {
|
||||
setSendingMessage(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleMessageKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSendMessage();
|
||||
}
|
||||
};
|
||||
|
||||
const parseMessageChain = (content: string): MessageChainComponent[] => {
|
||||
try {
|
||||
const parsed = JSON.parse(content);
|
||||
@@ -173,11 +299,16 @@ const BotSessionMonitor = forwardRef<
|
||||
};
|
||||
|
||||
const isUserMessage = (msg: SessionMessage): boolean => {
|
||||
if (msg.role === 'operator') return false;
|
||||
if (msg.role === 'assistant') return false;
|
||||
if (msg.role === 'user') return true;
|
||||
return !msg.runner_name;
|
||||
};
|
||||
|
||||
const isOperatorMessage = (msg: SessionMessage): boolean => {
|
||||
return msg.role === 'operator';
|
||||
};
|
||||
|
||||
const renderMessageComponent = (
|
||||
component: MessageChainComponent,
|
||||
index: number,
|
||||
@@ -243,7 +374,7 @@ const BotSessionMonitor = forwardRef<
|
||||
key={index}
|
||||
className="inline-flex items-center gap-1 text-muted-foreground text-xs"
|
||||
>
|
||||
🎙 [Voice]
|
||||
[Voice]
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -277,7 +408,7 @@ const BotSessionMonitor = forwardRef<
|
||||
const file = component as MessageChainComponent & { name?: string };
|
||||
return (
|
||||
<span key={index} className="text-muted-foreground text-xs">
|
||||
📎 {file.name || 'File'}
|
||||
[{file.name || 'File'}]
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -337,6 +468,22 @@ const BotSessionMonitor = forwardRef<
|
||||
(s) => s.session_id === selectedSessionId,
|
||||
);
|
||||
|
||||
const getMessageRoleLabel = (msg: SessionMessage): string => {
|
||||
if (isOperatorMessage(msg)) {
|
||||
return t('bots.sessionMonitor.operatorMessage', {
|
||||
defaultValue: 'Operator',
|
||||
});
|
||||
}
|
||||
if (isUserMessage(msg)) {
|
||||
return t('bots.sessionMonitor.userMessage', {
|
||||
defaultValue: 'User',
|
||||
});
|
||||
}
|
||||
return t('bots.sessionMonitor.botMessage', {
|
||||
defaultValue: 'Assistant',
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col md:flex-row h-full min-h-0 rounded-lg border overflow-hidden">
|
||||
{/* Left Panel: Session List */}
|
||||
@@ -355,6 +502,9 @@ const BotSessionMonitor = forwardRef<
|
||||
<div className="p-1.5">
|
||||
{sessions.map((session) => {
|
||||
const isSelected = selectedSessionId === session.session_id;
|
||||
const sessionTakenOver = takenOverSessions.has(
|
||||
session.session_id,
|
||||
);
|
||||
return (
|
||||
<button
|
||||
key={session.session_id}
|
||||
@@ -391,7 +541,12 @@ const BotSessionMonitor = forwardRef<
|
||||
{abbreviateId(session.user_id)}
|
||||
</span>
|
||||
)}
|
||||
{session.is_active && (
|
||||
{sessionTakenOver && (
|
||||
<span className="flex items-center gap-0.5 text-orange-600 dark:text-orange-400">
|
||||
<UserCheck className="w-3 h-3" />
|
||||
</span>
|
||||
)}
|
||||
{session.is_active && !sessionTakenOver && (
|
||||
<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>
|
||||
@@ -415,50 +570,92 @@ const BotSessionMonitor = forwardRef<
|
||||
<>
|
||||
{/* Chat Header */}
|
||||
<div className="px-4 py-2.5 border-b shrink-0">
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium truncate">
|
||||
{selectedSession?.user_name ||
|
||||
selectedSession?.user_id ||
|
||||
selectedSessionId.slice(0, 20)}
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium truncate">
|
||||
{selectedSession?.user_name ||
|
||||
selectedSession?.user_id ||
|
||||
selectedSessionId.slice(0, 20)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground mt-0.5">
|
||||
{parseSessionType(selectedSessionId) && (
|
||||
<span>{parseSessionType(selectedSessionId)}</span>
|
||||
)}
|
||||
{selectedSession?.platform && (
|
||||
<>
|
||||
{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>
|
||||
</>
|
||||
)}
|
||||
{isTakenOver ? (
|
||||
<>
|
||||
<span>·</span>
|
||||
<span className="flex items-center gap-1 text-orange-600 dark:text-orange-400">
|
||||
<UserCheck className="w-3 h-3" />
|
||||
{t('bots.sessionMonitor.takenOver', {
|
||||
defaultValue: 'Taken Over',
|
||||
})}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
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 className="flex items-center gap-1.5 text-xs text-muted-foreground mt-0.5">
|
||||
{parseSessionType(selectedSessionId) && (
|
||||
<span>{parseSessionType(selectedSessionId)}</span>
|
||||
)}
|
||||
{selectedSession?.platform && (
|
||||
<>
|
||||
{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>
|
||||
</>
|
||||
{/* Takeover / Release button */}
|
||||
<div className="flex-shrink-0">
|
||||
{isTakenOver ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRelease}
|
||||
disabled={takeoverLoading}
|
||||
className="inline-flex items-center gap-1 px-2.5 py-1.5 text-xs font-medium rounded-md bg-orange-100 text-orange-700 hover:bg-orange-200 dark:bg-orange-900/30 dark:text-orange-400 dark:hover:bg-orange-900/50 transition-colors disabled:opacity-50"
|
||||
>
|
||||
<UserCheck className="w-3.5 h-3.5" />
|
||||
{t('bots.sessionMonitor.releaseBtn', {
|
||||
defaultValue: 'Release',
|
||||
})}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleTakeover}
|
||||
disabled={takeoverLoading}
|
||||
className="inline-flex items-center gap-1 px-2.5 py-1.5 text-xs font-medium rounded-md bg-primary/10 text-primary hover:bg-primary/20 transition-colors disabled:opacity-50"
|
||||
>
|
||||
<UserCheck className="w-3.5 h-3.5" />
|
||||
{t('bots.sessionMonitor.takeoverBtn', {
|
||||
defaultValue: 'Take Over',
|
||||
})}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -481,6 +678,7 @@ const BotSessionMonitor = forwardRef<
|
||||
) : (
|
||||
messages.map((msg) => {
|
||||
const isUser = isUserMessage(msg);
|
||||
const isOperator = isOperatorMessage(msg);
|
||||
const isDiscarded =
|
||||
msg.status === 'discarded' ||
|
||||
msg.pipeline_id === PIPELINE_DISCARD;
|
||||
@@ -497,7 +695,9 @@ const BotSessionMonitor = forwardRef<
|
||||
'max-w-3xl px-4 py-2.5 rounded-2xl text-sm',
|
||||
isUser
|
||||
? 'bg-primary/10 rounded-br-sm'
|
||||
: 'bg-muted rounded-bl-sm',
|
||||
: isOperator
|
||||
? 'bg-orange-100/80 dark:bg-orange-900/30 rounded-bl-sm'
|
||||
: 'bg-muted rounded-bl-sm',
|
||||
msg.status === 'error' && 'ring-1 ring-red-400/50',
|
||||
isDiscarded && 'opacity-60',
|
||||
)}
|
||||
@@ -509,14 +709,13 @@ const BotSessionMonitor = forwardRef<
|
||||
'text-[11px] mt-1.5 flex items-center gap-1.5 text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
<span>
|
||||
{isUser
|
||||
? t('bots.sessionMonitor.userMessage', {
|
||||
defaultValue: 'User',
|
||||
})
|
||||
: t('bots.sessionMonitor.botMessage', {
|
||||
defaultValue: 'Assistant',
|
||||
})}
|
||||
<span
|
||||
className={cn(
|
||||
isOperator &&
|
||||
'text-orange-600 dark:text-orange-400 font-medium',
|
||||
)}
|
||||
>
|
||||
{getMessageRoleLabel(msg)}
|
||||
</span>
|
||||
<span className="tabular-nums">
|
||||
{formatTime(msg.timestamp)}
|
||||
@@ -528,12 +727,21 @@ const BotSessionMonitor = forwardRef<
|
||||
defaultValue: 'Discarded',
|
||||
})}
|
||||
</span>
|
||||
) : msg.pipeline_name ? (
|
||||
) : msg.pipeline_name &&
|
||||
msg.pipeline_name !== 'Human Takeover' ? (
|
||||
<span className="inline-flex items-center gap-0.5 opacity-70">
|
||||
<Workflow className="w-3 h-3" />
|
||||
{msg.pipeline_name}
|
||||
</span>
|
||||
) : null}
|
||||
{isOperator && (
|
||||
<span className="inline-flex items-center gap-0.5 text-orange-600/70 dark:text-orange-400/70">
|
||||
<UserCheck className="w-3 h-3" />
|
||||
{t('bots.sessionMonitor.humanTakeover', {
|
||||
defaultValue: 'Human Takeover',
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
{msg.status === 'error' && (
|
||||
<span className="text-red-500">error</span>
|
||||
)}
|
||||
@@ -551,6 +759,33 @@ const BotSessionMonitor = forwardRef<
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
{/* Operator Message Input (only shown when session is taken over) */}
|
||||
{isTakenOver && (
|
||||
<div className="px-4 py-3 border-t shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={operatorMessage}
|
||||
onChange={(e) => setOperatorMessage(e.target.value)}
|
||||
onKeyDown={handleMessageKeyDown}
|
||||
placeholder={t('bots.sessionMonitor.sendMessage', {
|
||||
defaultValue: 'Send message as operator...',
|
||||
})}
|
||||
disabled={sendingMessage}
|
||||
className="flex-1 h-9 px-3 rounded-md border bg-background text-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:opacity-50"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSendMessage}
|
||||
disabled={sendingMessage || !operatorMessage.trim()}
|
||||
className="inline-flex items-center justify-center h-9 px-3 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:pointer-events-none"
|
||||
>
|
||||
<Send className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1036,6 +1036,92 @@ export class BackendClient extends BaseHttpClient {
|
||||
return this.get(`/api/v1/monitoring/overview?${queryParams.toString()}`);
|
||||
}
|
||||
|
||||
// ============ Human Takeover API ============
|
||||
|
||||
public getHumanTakeoverSessions(params: {
|
||||
botUuid?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
}): Promise<{
|
||||
sessions: Array<{
|
||||
id: string;
|
||||
session_id: string;
|
||||
bot_uuid: string;
|
||||
status: string;
|
||||
taken_by: string | null;
|
||||
taken_at: string;
|
||||
released_at: string | null;
|
||||
platform: string | null;
|
||||
user_id: string | null;
|
||||
user_name: string | null;
|
||||
}>;
|
||||
total: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
}> {
|
||||
const queryParams = new URLSearchParams();
|
||||
if (params.botUuid) queryParams.append('botUuid', params.botUuid);
|
||||
if (params.limit) queryParams.append('limit', params.limit.toString());
|
||||
if (params.offset) queryParams.append('offset', params.offset.toString());
|
||||
return this.get(
|
||||
`/api/v1/human-takeover/sessions?${queryParams.toString()}`,
|
||||
);
|
||||
}
|
||||
|
||||
public getHumanTakeoverSessionDetail(sessionId: string): Promise<{
|
||||
found: boolean;
|
||||
session_id?: string;
|
||||
session?: {
|
||||
id: string;
|
||||
session_id: string;
|
||||
bot_uuid: string;
|
||||
status: string;
|
||||
taken_by: string | null;
|
||||
taken_at: string;
|
||||
released_at: string | null;
|
||||
platform: string | null;
|
||||
user_id: string | null;
|
||||
user_name: string | null;
|
||||
};
|
||||
}> {
|
||||
return this.get(`/api/v1/human-takeover/sessions/${sessionId}`);
|
||||
}
|
||||
|
||||
public takeoverSession(
|
||||
sessionId: string,
|
||||
params: {
|
||||
bot_uuid: string;
|
||||
platform?: string;
|
||||
user_id?: string;
|
||||
user_name?: string;
|
||||
},
|
||||
): Promise<object> {
|
||||
return this.post(
|
||||
`/api/v1/human-takeover/sessions/${sessionId}/takeover`,
|
||||
params,
|
||||
);
|
||||
}
|
||||
|
||||
public releaseSession(sessionId: string): Promise<object> {
|
||||
return this.post(
|
||||
`/api/v1/human-takeover/sessions/${sessionId}/release`,
|
||||
{},
|
||||
);
|
||||
}
|
||||
|
||||
public sendTakeoverMessage(
|
||||
sessionId: string,
|
||||
message: string,
|
||||
): Promise<{
|
||||
session_id: string;
|
||||
message_sent: boolean;
|
||||
}> {
|
||||
return this.post(
|
||||
`/api/v1/human-takeover/sessions/${sessionId}/message`,
|
||||
{ message },
|
||||
);
|
||||
}
|
||||
|
||||
// ============ Survey API ============
|
||||
public getSurveyPending(): Promise<{
|
||||
survey: {
|
||||
|
||||
@@ -391,6 +391,20 @@ const enUS = {
|
||||
discarded: 'Discarded',
|
||||
userMessage: 'User',
|
||||
botMessage: 'Assistant',
|
||||
operatorMessage: 'Operator',
|
||||
humanTakeover: 'Human Takeover',
|
||||
takeoverBtn: 'Take Over',
|
||||
releaseBtn: 'Release',
|
||||
takeoverConfirm: 'Take over this session? The AI bot will stop responding until released.',
|
||||
releaseConfirm: 'Release this session? The AI bot will resume responding.',
|
||||
takeoverSuccess: 'Session taken over successfully',
|
||||
releaseSuccess: 'Session released successfully',
|
||||
takeoverFailed: 'Failed to take over session',
|
||||
releaseFailed: 'Failed to release session',
|
||||
sendMessage: 'Send message as operator...',
|
||||
sendBtn: 'Send',
|
||||
sendFailed: 'Failed to send message',
|
||||
takenOver: 'Taken Over',
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
|
||||
@@ -401,6 +401,20 @@ const esES = {
|
||||
discarded: 'Descartado',
|
||||
userMessage: 'Usuario',
|
||||
botMessage: 'Asistente',
|
||||
operatorMessage: 'Operador',
|
||||
humanTakeover: 'Toma de control humana',
|
||||
takeoverBtn: 'Tomar control',
|
||||
releaseBtn: 'Liberar',
|
||||
takeoverConfirm: '¿Tomar control de esta sesión? El bot de IA dejará de responder hasta que se libere.',
|
||||
releaseConfirm: '¿Liberar esta sesión? El bot de IA reanudará las respuestas.',
|
||||
takeoverSuccess: 'Sesión tomada exitosamente',
|
||||
releaseSuccess: 'Sesión liberada exitosamente',
|
||||
takeoverFailed: 'Error al tomar control de la sesión',
|
||||
releaseFailed: 'Error al liberar la sesión',
|
||||
sendMessage: 'Enviar mensaje como operador...',
|
||||
sendBtn: 'Enviar',
|
||||
sendFailed: 'Error al enviar el mensaje',
|
||||
takenOver: 'Tomada',
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
|
||||
@@ -392,6 +392,20 @@
|
||||
discarded: '破棄済み',
|
||||
userMessage: 'ユーザー',
|
||||
botMessage: 'アシスタント',
|
||||
operatorMessage: 'オペレーター',
|
||||
humanTakeover: '有人対応',
|
||||
takeoverBtn: '引き継ぐ',
|
||||
releaseBtn: '解除',
|
||||
takeoverConfirm: 'このセッションを引き継ぎますか?解除するまでAIボットは応答を停止します。',
|
||||
releaseConfirm: 'このセッションを解除しますか?AIボットが応答を再開します。',
|
||||
takeoverSuccess: 'セッションの引き継ぎに成功しました',
|
||||
releaseSuccess: 'セッションの解除に成功しました',
|
||||
takeoverFailed: 'セッションの引き継ぎに失敗しました',
|
||||
releaseFailed: 'セッションの解除に失敗しました',
|
||||
sendMessage: 'オペレーターとしてメッセージを送信...',
|
||||
sendBtn: '送信',
|
||||
sendFailed: 'メッセージの送信に失敗しました',
|
||||
takenOver: '引き継ぎ中',
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
|
||||
@@ -386,6 +386,20 @@ const thTH = {
|
||||
discarded: 'ถูกละทิ้ง',
|
||||
userMessage: 'ผู้ใช้',
|
||||
botMessage: 'ผู้ช่วย',
|
||||
operatorMessage: 'เจ้าหน้าที่',
|
||||
humanTakeover: 'เจ้าหน้าที่รับช่วง',
|
||||
takeoverBtn: 'รับช่วง',
|
||||
releaseBtn: 'ปล่อย',
|
||||
takeoverConfirm: 'รับช่วงเซสชันนี้หรือไม่? บอท AI จะหยุดตอบจนกว่าจะปล่อย',
|
||||
releaseConfirm: 'ปล่อยเซสชันนี้หรือไม่? บอท AI จะกลับมาตอบอีกครั้ง',
|
||||
takeoverSuccess: 'รับช่วงเซสชันสำเร็จ',
|
||||
releaseSuccess: 'ปล่อยเซสชันสำเร็จ',
|
||||
takeoverFailed: 'รับช่วงเซสชันล้มเหลว',
|
||||
releaseFailed: 'ปล่อยเซสชันล้มเหลว',
|
||||
sendMessage: 'ส่งข้อความในฐานะเจ้าหน้าที่...',
|
||||
sendBtn: 'ส่ง',
|
||||
sendFailed: 'ส่งข้อความล้มเหลว',
|
||||
takenOver: 'ถูกรับช่วงแล้ว',
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
|
||||
@@ -395,6 +395,20 @@ const viVN = {
|
||||
discarded: 'Đã loại bỏ',
|
||||
userMessage: 'Người dùng',
|
||||
botMessage: 'Trợ lý',
|
||||
operatorMessage: 'Nhân viên',
|
||||
humanTakeover: 'Tiếp nhận thủ công',
|
||||
takeoverBtn: 'Tiếp nhận',
|
||||
releaseBtn: 'Giải phóng',
|
||||
takeoverConfirm: 'Tiếp nhận phiên này? Bot AI sẽ ngừng phản hồi cho đến khi được giải phóng.',
|
||||
releaseConfirm: 'Giải phóng phiên này? Bot AI sẽ tiếp tục phản hồi.',
|
||||
takeoverSuccess: 'Tiếp nhận phiên thành công',
|
||||
releaseSuccess: 'Giải phóng phiên thành công',
|
||||
takeoverFailed: 'Tiếp nhận phiên thất bại',
|
||||
releaseFailed: 'Giải phóng phiên thất bại',
|
||||
sendMessage: 'Gửi tin nhắn với tư cách nhân viên...',
|
||||
sendBtn: 'Gửi',
|
||||
sendFailed: 'Gửi tin nhắn thất bại',
|
||||
takenOver: 'Đã tiếp nhận',
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
|
||||
@@ -376,6 +376,20 @@ const zhHans = {
|
||||
discarded: '已丢弃',
|
||||
userMessage: '用户',
|
||||
botMessage: '助手',
|
||||
operatorMessage: '人工客服',
|
||||
humanTakeover: '人工接管',
|
||||
takeoverBtn: '接管',
|
||||
releaseBtn: '释放',
|
||||
takeoverConfirm: '确定接管此会话?AI 机器人将停止回复,直到释放。',
|
||||
releaseConfirm: '确定释放此会话?AI 机器人将恢复回复。',
|
||||
takeoverSuccess: '会话接管成功',
|
||||
releaseSuccess: '会话释放成功',
|
||||
takeoverFailed: '会话接管失败',
|
||||
releaseFailed: '会话释放失败',
|
||||
sendMessage: '以人工客服身份发送消息...',
|
||||
sendBtn: '发送',
|
||||
sendFailed: '消息发送失败',
|
||||
takenOver: '已接管',
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
|
||||
@@ -371,6 +371,20 @@ const zhHant = {
|
||||
discarded: '已丟棄',
|
||||
userMessage: '使用者',
|
||||
botMessage: '助手',
|
||||
operatorMessage: '人工客服',
|
||||
humanTakeover: '人工接管',
|
||||
takeoverBtn: '接管',
|
||||
releaseBtn: '釋放',
|
||||
takeoverConfirm: '確定接管此會話?AI 機器人將停止回覆,直到釋放。',
|
||||
releaseConfirm: '確定釋放此會話?AI 機器人將恢復回覆。',
|
||||
takeoverSuccess: '會話接管成功',
|
||||
releaseSuccess: '會話釋放成功',
|
||||
takeoverFailed: '會話接管失敗',
|
||||
releaseFailed: '會話釋放失敗',
|
||||
sendMessage: '以人工客服身份發送訊息...',
|
||||
sendBtn: '發送',
|
||||
sendFailed: '訊息發送失敗',
|
||||
takenOver: '已接管',
|
||||
},
|
||||
},
|
||||
plugins: {
|
||||
|
||||
Reference in New Issue
Block a user