Compare commits

..

2 Commits

Author SHA1 Message Date
RockChinQ
d3d366b569 feat: update langbot-plugin to version 0.3.7 2026-04-06 17:09:26 +08:00
RockChinQ
db68c5d0c9 feat(human-takeover): add human customer service takeover feature
Add ability for admin operators to take over user conversation sessions
from the AI bot, manually reply as the bot, and release sessions back to
AI processing.

Backend:
- New HumanTakeoverSession DB model and migration (v26)
- HumanTakeoverService with in-memory cache for hot-path performance
- REST API endpoints for takeover/release/send-message/list/detail
- Message interception in botmgr.py (after webhook, before pipeline)

Frontend:
- Takeover/release controls in BotSessionMonitor chat header
- Operator message input bar with visual distinction (orange theme)
- Taken-over session indicators in session list
- 3-second auto-refresh polling during active takeover
- Full i18n coverage across all 7 locales
2026-04-04 23:47:46 +08:00
51 changed files with 2192 additions and 4198 deletions

View File

@@ -1,171 +0,0 @@
name: Test Migrations
on:
push:
branches:
- main
- master
- dev
paths:
- 'src/langbot/pkg/persistence/**'
- 'src/langbot/pkg/entity/persistence/**'
pull_request:
types: [opened, synchronize, reopened, ready_for_review]
paths:
- 'src/langbot/pkg/persistence/**'
- 'src/langbot/pkg/entity/persistence/**'
jobs:
test-migrations-sqlite:
name: Migrations (SQLite)
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Install uv
uses: astral-sh/setup-uv@v4
- name: Install dependencies
run: uv sync --dev
- name: Test Alembic upgrade (SQLite)
run: |
uv run python -c "
import asyncio
from sqlalchemy.ext.asyncio import create_async_engine
from langbot.pkg.entity.persistence.base import Base
from langbot.pkg.persistence.alembic_runner import run_alembic_upgrade, run_alembic_stamp, get_alembic_current
async def main():
engine = create_async_engine('sqlite+aiosqlite:///test_migrations.db')
# Create all tables (simulates existing DB)
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
# Stamp baseline
await run_alembic_stamp(engine, '0001_baseline')
rev = await get_alembic_current(engine)
assert rev == '0001_baseline', f'Expected 0001_baseline, got {rev}'
print(f'Stamped: {rev}')
# Upgrade to head
await run_alembic_upgrade(engine, 'head')
rev = await get_alembic_current(engine)
print(f'After upgrade: {rev}')
assert rev is not None, 'Expected a revision after upgrade'
# Verify idempotent
await run_alembic_upgrade(engine, 'head')
rev2 = await get_alembic_current(engine)
assert rev2 == rev, f'Expected {rev}, got {rev2}'
print(f'Idempotent check passed: {rev2}')
# Fresh DB: upgrade from scratch
engine2 = create_async_engine('sqlite+aiosqlite:///test_migrations_fresh.db')
async with engine2.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
await run_alembic_upgrade(engine2, 'head')
rev3 = await get_alembic_current(engine2)
print(f'Fresh DB upgrade: {rev3}')
assert rev3 is not None
print('All SQLite migration tests passed!')
asyncio.run(main())
"
test-migrations-postgres:
name: Migrations (PostgreSQL)
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16
env:
POSTGRES_USER: langbot
POSTGRES_PASSWORD: langbot
POSTGRES_DB: langbot_test
ports:
- 5432:5432
options: >-
--health-cmd="pg_isready -U langbot"
--health-interval=5s
--health-timeout=5s
--health-retries=5
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.12'
- name: Install uv
uses: astral-sh/setup-uv@v4
- name: Install dependencies
run: uv sync --dev
- name: Test Alembic upgrade (PostgreSQL)
run: |
uv run python -c "
import asyncio
from sqlalchemy.ext.asyncio import create_async_engine
from langbot.pkg.entity.persistence.base import Base
from langbot.pkg.persistence.alembic_runner import run_alembic_upgrade, run_alembic_stamp, get_alembic_current
DB_URL = 'postgresql+asyncpg://langbot:langbot@localhost:5432/langbot_test'
async def main():
engine = create_async_engine(DB_URL)
# Create all tables
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
# Stamp baseline
await run_alembic_stamp(engine, '0001_baseline')
rev = await get_alembic_current(engine)
assert rev == '0001_baseline', f'Expected 0001_baseline, got {rev}'
print(f'Stamped: {rev}')
# Upgrade to head
await run_alembic_upgrade(engine, 'head')
rev = await get_alembic_current(engine)
print(f'After upgrade: {rev}')
assert rev is not None
# Verify idempotent
await run_alembic_upgrade(engine, 'head')
rev2 = await get_alembic_current(engine)
assert rev2 == rev, f'Expected {rev}, got {rev2}'
print(f'Idempotent check passed: {rev2}')
# Fresh DB: drop all and upgrade from scratch
engine2 = create_async_engine(DB_URL.replace('langbot_test', 'langbot_fresh'))
# Create fresh database
from sqlalchemy import text
async with engine.connect() as conn:
await conn.execute(text('COMMIT'))
await conn.execute(text('CREATE DATABASE langbot_fresh'))
async with engine2.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
await run_alembic_upgrade(engine2, 'head')
rev3 = await get_alembic_current(engine2)
print(f'Fresh DB upgrade: {rev3}')
assert rev3 is not None
print('All PostgreSQL migration tests passed!')
asyncio.run(main())
"

View File

@@ -1,6 +1,6 @@
[project]
name = "langbot"
version = "4.9.6"
version = "4.9.5"
description = "Production-grade platform for building agentic IM bots"
readme = "README.md"
license-files = ["LICENSE"]
@@ -39,7 +39,6 @@ dependencies = [
"quart-cors>=0.8.0",
"requests>=2.32.3",
"slack-sdk>=3.35.0",
"alembic>=1.15.0",
"sqlalchemy[asyncio]>=2.0.40",
"sqlmodel>=0.0.24",
"telegramify-markdown>=0.5.1",
@@ -65,7 +64,7 @@ dependencies = [
"chromadb>=1.0.0,<2.0.0",
"qdrant-client (>=1.15.1,<2.0.0)",
"pyseekdb==1.1.0.post3",
"langbot-plugin==0.3.8",
"langbot-plugin==0.3.7",
"asyncpg>=0.30.0",
"line-bot-sdk>=3.19.0",
"tboxsdk>=0.0.10",
@@ -112,7 +111,7 @@ requires = ["setuptools>=61.0", "wheel"]
build-backend = "setuptools.build_meta"
[tool.setuptools]
package-data = { "langbot" = ["templates/**", "pkg/provider/modelmgr/requesters/*", "pkg/platform/sources/*", "web/dist/**", "pkg/persistence/alembic/**"] }
package-data = { "langbot" = ["templates/**", "pkg/provider/modelmgr/requesters/*", "pkg/platform/sources/*", "web/dist/**"] }
[dependency-groups]
dev = [

View File

@@ -1,3 +1,3 @@
"""LangBot - Production-grade platform for building agentic IM bots"""
__version__ = '4.9.6'
__version__ = '4.9.5'

View File

@@ -182,88 +182,6 @@ class DingTalkClient:
for handler in self._message_handlers[msg_type]:
await handler(event)
async def _parse_quoted_message(self, replied_msg: dict) -> dict:
"""Parse the quoted/replied message and extract its content.
Args:
replied_msg: The repliedMsg object from DingTalk message
Returns:
A dict containing the quoted message info with keys:
- message_id: The original message ID
- msg_type: The message type (text, file, picture, audio, etc.)
- content: The text content (if any)
- file_url: The file download URL (if file type)
- file_name: The file name (if file type)
- picture: The picture base64 (if picture type)
- audio: The audio base64 (if audio type)
"""
quote_info = {
'message_id': replied_msg.get('msgId', ''),
'msg_type': replied_msg.get('msgType', ''),
'sender_id': replied_msg.get('senderId', ''),
}
msg_type = replied_msg.get('msgType', '')
content = replied_msg.get('content', {})
# Handle content as string (JSON) or dict
if isinstance(content, str):
try:
content = json.loads(content)
except (json.JSONDecodeError, TypeError):
content = {}
if msg_type == 'text':
# Text message
if isinstance(content, dict):
quote_info['content'] = content.get('content', '')
else:
quote_info['content'] = str(content)
elif msg_type == 'file':
# File message
download_code = content.get('downloadCode')
file_name = content.get('fileName')
if download_code and file_name:
try:
quote_info['file_url'] = await self.get_file_url(download_code)
quote_info['file_name'] = file_name
except Exception as e:
if self.logger:
await self.logger.error(f'Failed to get quoted file URL: {e}')
elif msg_type == 'picture':
# Picture message
download_code = content.get('downloadCode')
if download_code:
try:
quote_info['picture'] = await self.download_image(download_code)
except Exception as e:
if self.logger:
await self.logger.error(f'Failed to download quoted image: {e}')
elif msg_type == 'audio':
# Audio message
download_code = content.get('downloadCode')
if download_code:
try:
quote_info['audio'] = await self.get_audio_url(download_code)
except Exception as e:
if self.logger:
await self.logger.error(f'Failed to get quoted audio: {e}')
elif msg_type == 'richText':
# Rich text message - extract text content
rich_text = content.get('richText', [])
texts = []
for item in rich_text:
if 'text' in item and item['text'] != '\n':
texts.append(item['text'])
quote_info['content'] = '\n'.join(texts)
return quote_info
async def get_message(self, incoming_message: dingtalk_stream.chatbot.ChatbotMessage):
try:
# print(json.dumps(incoming_message.to_dict(), indent=4, ensure_ascii=False))
@@ -275,15 +193,6 @@ class DingTalkClient:
elif str(incoming_message.conversation_type) == '2':
message_data['conversation_type'] = 'GroupMessage'
# Check for quoted/replied message
raw_data = incoming_message.to_dict()
text_data = raw_data.get('text', {})
if isinstance(text_data, dict) and text_data.get('isReplyMsg'):
replied_msg = text_data.get('repliedMsg', {})
if replied_msg:
quote_info = await self._parse_quoted_message(replied_msg)
message_data['QuotedMessage'] = quote_info
if incoming_message.message_type == 'richText':
data = incoming_message.rich_text_content.to_dict()
@@ -359,25 +268,7 @@ class DingTalkClient:
message_data['Type'] = 'image'
elif incoming_message.message_type == 'audio':
raw_content = incoming_message.to_dict().get('content', {})
# 兼容处理:如果 content 仍为 JSON 字符串则进行解析
if isinstance(raw_content, str):
try:
raw_content = json.loads(raw_content)
except (json.JSONDecodeError, TypeError):
raw_content = {}
if self.logger:
await self.logger.info(f'DingTalk audio raw content: {json.dumps(raw_content, ensure_ascii=False)}')
# 提取钉钉自带的语音转写文字Powered by Qwen
recognition = raw_content.get('recognition', '')
if recognition:
message_data['Content'] = recognition
download_code = raw_content.get('downloadCode')
if download_code:
message_data['Audio'] = await self.get_audio_url(download_code)
message_data['Audio'] = await self.get_audio_url(incoming_message.to_dict()['content']['downloadCode'])
message_data['Type'] = 'audio'
elif incoming_message.message_type == 'file':

View File

@@ -47,22 +47,6 @@ class DingTalkEvent(dict):
def conversation(self):
return self.get('conversation_type', '')
@property
def quoted_message(self) -> Optional[Dict[str, Any]]:
"""Get the quoted/replied message info if this is a reply message.
Returns:
A dict containing:
- message_id: The original message ID
- msg_type: The message type (text, file, picture, audio, etc.)
- content: The text content (if any)
- file_url: The file download URL (if file type)
- file_name: The file name (if file type)
- picture: The picture base64 (if picture type)
- audio: The audio base64 (if audio type)
"""
return self.get('QuotedMessage')
def __getattr__(self, key: str) -> Optional[Any]:
"""
允许通过属性访问数据中的任意字段。

View File

@@ -228,9 +228,6 @@ class StreamSessionManager:
msg_id = session.msg_id
if msg_id and self._msg_index.get(msg_id) == stream_id:
self._msg_index.pop(msg_id, None)
# Clean up feedback index for expired sessions
if session.feedback_id:
self._feedback_index.pop(session.feedback_id, None)
def _decrypt_file(encrypted_data: bytes, aes_key_str: str) -> bytes:
@@ -595,120 +592,6 @@ async def parse_wecom_bot_message(
if msg_json.get('aibotid'):
message_data['aibotid'] = msg_json.get('aibotid', '')
# Handle quote (referenced message) - important for group chat file references
quote_info = msg_json.get('quote')
if quote_info:
quote_data: dict[str, Any] = {}
quote_type = quote_info.get('msgtype', '')
quote_data['msgtype'] = quote_type
if quote_type == 'text':
quote_data['content'] = quote_info.get('text', {}).get('content', '')
elif quote_type == 'image':
img_info = quote_info.get('image', {})
img_url = img_info.get('url', '')
img_aeskey = img_info.get('aeskey', '')
base64_data = await _safe_download_as_data_uri(img_url, img_aeskey)
if base64_data:
quote_data['picurl'] = base64_data
quote_data['images'] = [base64_data]
elif quote_type == 'file':
file_info = quote_info.get('file', {}) or {}
download_url = file_info.get('url') or file_info.get('fileurl')
item_aeskey = file_info.get('aeskey', '')
file_data = {
'filename': file_info.get('filename') or file_info.get('name'),
'filesize': file_info.get('filesize') or file_info.get('size'),
'md5sum': file_info.get('md5sum') or file_info.get('md5'),
'sdkfileid': file_info.get('sdkfileid') or file_info.get('fileid'),
'download_url': download_url,
'extra': file_info,
}
# Same as private chat: append aeskey to download_url for plugin processing
if download_url and item_aeskey:
file_data['download_url'] = download_url + f'?aeskey={item_aeskey}'
quote_data['file'] = file_data
elif quote_type == 'voice':
voice_info = quote_info.get('voice', {}) or {}
download_url = voice_info.get('url')
item_aeskey = voice_info.get('aeskey', '')
voice_data = {
'url': download_url,
'md5sum': voice_info.get('md5sum') or voice_info.get('md5'),
'filesize': voice_info.get('filesize') or voice_info.get('size'),
'sdkfileid': voice_info.get('sdkfileid') or voice_info.get('fileid'),
}
if voice_info.get('content'):
quote_data['content'] = voice_info.get('content')
# Same as private chat: append aeskey to url for plugin processing
if download_url and item_aeskey:
voice_data['url'] = download_url + f'?aeskey={item_aeskey}'
quote_data['voice'] = voice_data
elif quote_type == 'video':
video_info = quote_info.get('video', {}) or {}
download_url = video_info.get('url')
item_aeskey = video_info.get('aeskey', '')
video_data = {
'url': download_url,
'filesize': video_info.get('filesize') or video_info.get('size'),
'sdkfileid': video_info.get('sdkfileid') or video_info.get('fileid'),
'md5sum': video_info.get('md5sum') or video_info.get('md5'),
'filename': video_info.get('filename') or video_info.get('name'),
}
# Same as private chat: append aeskey to download_url for plugin processing
if download_url and item_aeskey:
video_data['download_url'] = download_url + f'?aeskey={item_aeskey}'
quote_data['video'] = video_data
elif quote_type == 'link':
quote_data['link'] = quote_info.get('link', {})
link = quote_data['link']
title = link.get('title', '')
desc = link.get('description') or link.get('digest', '')
quote_data['content'] = '\n'.join(filter(None, [title, desc]))
elif quote_type == 'mixed':
# Handle mixed type in quote (text + images + files etc.)
items = quote_info.get('mixed', {}).get('msg_item', [])
texts = []
images = []
files = []
for item in items:
item_type = item.get('msgtype')
if item_type == 'text':
texts.append(item.get('text', {}).get('content', ''))
elif item_type == 'image':
img_info = item.get('image', {})
img_url = img_info.get('url')
img_aeskey = img_info.get('aeskey', '')
base64_data = await _safe_download_as_data_uri(img_url, img_aeskey)
if base64_data:
images.append(base64_data)
elif item_type == 'file':
file_info = item.get('file', {}) or {}
download_url = file_info.get('url') or file_info.get('fileurl')
item_aeskey = file_info.get('aeskey', '')
file_data = {
'filename': file_info.get('filename') or file_info.get('name'),
'filesize': file_info.get('filesize') or file_info.get('size'),
'md5sum': file_info.get('md5sum') or file_info.get('md5'),
'sdkfileid': file_info.get('sdkfileid') or file_info.get('fileid'),
'download_url': download_url,
'extra': file_info,
}
# Same as private chat: append aeskey to download_url for plugin processing
if download_url and item_aeskey:
file_data['download_url'] = download_url + f'?aeskey={item_aeskey}'
files.append(file_data)
if texts:
quote_data['content'] = ' '.join(texts)
if images:
quote_data['images'] = images
quote_data['picurl'] = images[0]
if files:
quote_data['files'] = files
quote_data['file'] = files[0]
message_data['quote'] = quote_data
return message_data
@@ -1020,38 +903,35 @@ class WecomBotClient:
)
session = self.stream_sessions.get_session_by_feedback_id(feedback_id)
if session:
await self.logger.info(
f'反馈关联到会话: stream_id={session.stream_id}, msg_id={session.msg_id}, user_id={session.user_id}'
)
for handler in self._message_handlers.get('feedback', []):
try:
await handler(
feedback_id=feedback_id,
feedback_type=feedback_type,
feedback_content=feedback_content,
inaccurate_reasons=inaccurate_reasons,
session=session,
)
except Exception:
await self.logger.error(traceback.format_exc())
if self._feedback_callback:
try:
await self._feedback_callback(
feedback_id=feedback_id,
feedback_type=feedback_type,
feedback_content=feedback_content,
inaccurate_reasons=inaccurate_reasons,
session=session,
)
except Exception:
await self.logger.error(traceback.format_exc())
else:
await self.logger.warning(f'未找到 feedback_id={feedback_id} 对应的会话,仍将记录反馈')
# Dispatch feedback event regardless of session availability
for handler in self._message_handlers.get('feedback', []):
try:
await handler(
feedback_id=feedback_id,
feedback_type=feedback_type,
feedback_content=feedback_content,
inaccurate_reasons=inaccurate_reasons,
session=session,
)
except Exception:
await self.logger.error(traceback.format_exc())
if self._feedback_callback:
try:
await self._feedback_callback(
feedback_id=feedback_id,
feedback_type=feedback_type,
feedback_content=feedback_content,
inaccurate_reasons=inaccurate_reasons,
session=session,
)
except Exception:
await self.logger.error(traceback.format_exc())
await self.logger.warning(f'未找到 feedback_id={feedback_id} 对应的会话')
except Exception:
await self.logger.error(traceback.format_exc())

View File

@@ -147,10 +147,3 @@ class WecomBotEvent(dict):
流式消息 ID
"""
return self.get('stream_id', '')
@property
def quote(self):
"""
引用消息信息(群聊中用户引用其他消息时返回)
"""
return self.get('quote', {})

View 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))

View File

@@ -105,24 +105,23 @@ class HTTPController:
):
if os.path.exists(os.path.join(frontend_path, path + '.html')):
path += '.html'
elif not path.startswith('api/'):
# SPA fallback: serve index.html for all non-API, non-static routes
# so that React Router can handle client-side routing (Vite SPA).
# For /home/* sub-routes, first try parent .html files (pre-rendered pages).
if path.startswith('home/'):
segments = path.rstrip('/').split('/')
for i in range(len(segments) - 1, 0, -1):
parent_path = '/'.join(segments[:i]) + '.html'
if os.path.exists(os.path.join(frontend_path, parent_path)):
response = await quart.send_from_directory(
frontend_path, parent_path, mimetype='text/html'
)
response.headers['Cache-Control'] = 'no-cache, no-store, must-revalidate'
response.headers['Pragma'] = 'no-cache'
response.headers['Expires'] = '0'
return response
elif path.startswith('home/'):
# SPA fallback for /home/* sub-routes.
# Entity detail views use query params (e.g. /home/bots?id=uuid),
# so the pre-rendered list page is served directly via path + '.html'.
# This fallback handles any remaining unmatched sub-paths.
segments = path.rstrip('/').split('/')
# Fallback to index.html for SPA client-side routing
# Walk up parent segments looking for matching .html files
for i in range(len(segments) - 1, 0, -1):
parent_path = '/'.join(segments[:i]) + '.html'
if os.path.exists(os.path.join(frontend_path, parent_path)):
response = await quart.send_from_directory(frontend_path, parent_path, mimetype='text/html')
response.headers['Cache-Control'] = 'no-cache, no-store, must-revalidate'
response.headers['Pragma'] = 'no-cache'
response.headers['Expires'] = '0'
return response
# Final fallback to index.html for /home/* routes
response = await quart.send_from_directory(frontend_path, 'index.html', mimetype='text/html')
response.headers['Cache-Control'] = 'no-cache, no-store, must-revalidate'
response.headers['Pragma'] = 'no-cache'

View 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)

View File

@@ -1224,83 +1224,30 @@ class MonitoringService:
"""
import json
now = datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None)
reasons_json = json.dumps(inaccurate_reasons, ensure_ascii=False) if inaccurate_reasons else None
record_id = str(uuid.uuid4())
record_data = {
'id': record_id,
'timestamp': datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None),
'feedback_id': feedback_id,
'feedback_type': feedback_type,
'feedback_content': feedback_content,
'inaccurate_reasons': json.dumps(inaccurate_reasons, ensure_ascii=False) if inaccurate_reasons else None,
'bot_id': bot_id,
'bot_name': bot_name,
'pipeline_id': pipeline_id,
'pipeline_name': pipeline_name,
'session_id': session_id,
'message_id': message_id,
'stream_id': stream_id,
'user_id': user_id,
'platform': platform,
}
MonitoringFeedback = persistence_monitoring.MonitoringFeedback
# Handle cancel feedback (type=3): delete existing record
if feedback_type == 3:
await self.ap.persistence_mgr.execute_async(
sqlalchemy.delete(MonitoringFeedback).where(MonitoringFeedback.feedback_id == feedback_id)
)
return None
# Check if record with this feedback_id already exists
existing_result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(MonitoringFeedback).where(MonitoringFeedback.feedback_id == feedback_id)
await self.ap.persistence_mgr.execute_async(
sqlalchemy.insert(persistence_monitoring.MonitoringFeedback).values(record_data)
)
existing_row = existing_result.first()
if existing_row:
# UPDATE existing record
existing = existing_row[0] if isinstance(existing_row, tuple) else existing_row
await self.ap.persistence_mgr.execute_async(
sqlalchemy.update(MonitoringFeedback)
.where(MonitoringFeedback.feedback_id == feedback_id)
.values(
timestamp=now,
feedback_type=feedback_type,
feedback_content=feedback_content,
inaccurate_reasons=reasons_json,
bot_id=bot_id or existing.bot_id,
bot_name=bot_name or existing.bot_name,
pipeline_id=pipeline_id or existing.pipeline_id,
pipeline_name=pipeline_name or existing.pipeline_name,
session_id=session_id or existing.session_id,
message_id=message_id or existing.message_id,
stream_id=stream_id or existing.stream_id,
user_id=user_id or existing.user_id,
platform=platform or existing.platform,
)
)
return existing.id
else:
# INSERT new record with IntegrityError defense
record_id = str(uuid.uuid4())
record_data = {
'id': record_id,
'timestamp': now,
'feedback_id': feedback_id,
'feedback_type': feedback_type,
'feedback_content': feedback_content,
'inaccurate_reasons': reasons_json,
'bot_id': bot_id,
'bot_name': bot_name,
'pipeline_id': pipeline_id,
'pipeline_name': pipeline_name,
'session_id': session_id,
'message_id': message_id,
'stream_id': stream_id,
'user_id': user_id,
'platform': platform,
}
try:
await self.ap.persistence_mgr.execute_async(sqlalchemy.insert(MonitoringFeedback).values(record_data))
return record_id
except Exception:
# UNIQUE constraint conflict (concurrent feedback for same feedback_id)
await self.ap.persistence_mgr.execute_async(
sqlalchemy.update(MonitoringFeedback)
.where(MonitoringFeedback.feedback_id == feedback_id)
.values(
timestamp=now,
feedback_type=feedback_type,
feedback_content=feedback_content,
inaccurate_reasons=reasons_json,
)
)
return feedback_id
return record_id
async def get_feedback_stats(
self,

View File

@@ -65,8 +65,8 @@ class UserService:
user_obj = result_list[0]
# Check if this user has a local password set
if not user_obj.password:
# Check if this is a Space account
if user_obj.account_type == 'space':
raise ValueError('请使用 Space 账户登录')
ph = argon2.PasswordHasher()
@@ -108,8 +108,9 @@ class UserService:
if user_obj is None:
raise ValueError('User not found')
if not user_obj.password:
raise ValueError('No local password set, please set a password first')
# Space accounts cannot change password locally
if user_obj.account_type == 'space':
raise ValueError('Space account cannot change password locally')
ph.verify(user_obj.password, current_password)

View File

@@ -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

View File

@@ -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()

View 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)

View File

@@ -1,51 +0,0 @@
"""Alembic environment for LangBot.
This env.py is designed to be called programmatically (not via CLI).
It supports both SQLite and PostgreSQL.
The sync connection is passed via config attributes by the runner.
"""
from __future__ import annotations
from alembic import context
from sqlalchemy.engine import Connection
from langbot.pkg.entity.persistence.base import Base
target_metadata = Base.metadata
def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode — emit SQL without a live connection."""
url = context.config.get_main_option('sqlalchemy.url')
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={'paramstyle': 'named'},
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
"""Run migrations with a live sync connection passed via config attributes."""
connection: Connection = context.config.attributes.get('connection')
if connection is None:
raise RuntimeError('connection not provided in alembic config attributes')
context.configure(
connection=connection,
target_metadata=target_metadata,
# render_as_batch=True is critical for SQLite ALTER TABLE support
render_as_batch=True,
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

View File

@@ -1,24 +0,0 @@
# Alembic script.py.mako — template for auto-generated revisions
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers
revision = ${repr(up_revision)}
down_revision = ${repr(down_revision)}
branch_labels = ${repr(branch_labels)}
depends_on = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}

View File

@@ -1,24 +0,0 @@
"""baseline: stamp existing schema (db version 25)
This is a no-op migration that marks the starting point for Alembic.
All tables already exist via create_all() + legacy DBMigration system.
Revision ID: 0001_baseline
Revises: None
Create Date: 2026-04-08
"""
revision = '0001_baseline'
down_revision = None
branch_labels = None
depends_on = None
def upgrade() -> None:
# No-op: existing schema is already at database_version=25
# This revision serves as the Alembic baseline.
pass
def downgrade() -> None:
pass

View File

@@ -1,62 +0,0 @@
"""example: sample migration demonstrating Alembic patterns
This is a SAMPLE showing how to write migrations that work
seamlessly across SQLite and PostgreSQL. Delete or adapt as needed.
Revision ID: 0002_sample
Revises: 0001_baseline
Create Date: 2026-04-08
Patterns demonstrated:
1. Schema change (add column) — works on both DBs via render_as_batch
2. Data migration (read + modify JSON) — pure SQLAlchemy, no dialect branching
"""
revision = '0002_sample'
down_revision = '0001_baseline'
branch_labels = None
depends_on = None
def upgrade() -> None:
"""
EXAMPLE: Uncomment to use. This shows the patterns.
# --- Pattern 1: Schema change (add/drop column) ---
# render_as_batch=True in env.py makes this work on SQLite too.
#
# op.add_column('pipelines', sa.Column('description', sa.String(512), server_default=''))
# --- Pattern 2: Data migration (read + modify JSON field) ---
# No if/else for sqlite vs postgres needed!
#
# conn = op.get_bind()
# rows = conn.execute(sa.text("SELECT uuid, config FROM pipelines")).fetchall()
# for row in rows:
# config = json.loads(row[1]) if isinstance(row[1], str) else row[1]
# # Modify the config
# config.setdefault('ai', {}).setdefault('some_new_key', 'default_value')
# conn.execute(
# sa.text("UPDATE pipelines SET config = :cfg WHERE uuid = :uuid"),
# {"cfg": json.dumps(config), "uuid": row[0]}
# )
# --- Pattern 3: Create a new table ---
#
# op.create_table(
# 'audit_log',
# sa.Column('id', sa.Integer, primary_key=True, autoincrement=True),
# sa.Column('action', sa.String(255), nullable=False),
# sa.Column('detail', sa.Text),
# sa.Column('created_at', sa.DateTime, server_default=sa.func.now()),
# )
"""
pass
def downgrade() -> None:
"""
# op.drop_column('pipelines', 'description')
# op.drop_table('audit_log')
"""
pass

View File

@@ -1,150 +0,0 @@
"""Programmatic Alembic runner for LangBot.
Usage from async code:
from langbot.pkg.persistence.alembic_runner import run_alembic_upgrade
await run_alembic_upgrade(async_engine)
CLI usage (autogenerate):
python -m langbot.pkg.persistence.alembic_runner autogenerate "add description column"
python -m langbot.pkg.persistence.alembic_runner upgrade
python -m langbot.pkg.persistence.alembic_runner current
"""
from __future__ import annotations
import os
from typing import TYPE_CHECKING
from alembic.config import Config
from alembic import command
from alembic.runtime.migration import MigrationContext
if TYPE_CHECKING:
from sqlalchemy.ext.asyncio import AsyncEngine
from sqlalchemy.engine import Connection
_ALEMBIC_DIR = os.path.join(os.path.dirname(__file__), 'alembic')
def _build_config(connection: Connection) -> Config:
"""Build an Alembic Config with sync connection attached."""
cfg = Config()
cfg.set_main_option('script_location', _ALEMBIC_DIR)
cfg.attributes['connection'] = connection
return cfg
def _do_upgrade(connection: Connection, revision: str = 'head') -> None:
"""Synchronous upgrade — runs inside run_sync."""
cfg = _build_config(connection)
command.upgrade(cfg, revision)
def _do_stamp(connection: Connection, revision: str = 'head') -> None:
"""Synchronous stamp — runs inside run_sync."""
cfg = _build_config(connection)
command.stamp(cfg, revision)
def _do_get_current(connection: Connection) -> str | None:
"""Get current alembic revision synchronously."""
ctx = MigrationContext.configure(connection)
return ctx.get_current_revision()
def _do_autogenerate(connection: Connection, message: str = 'auto migration') -> None:
"""Synchronous autogenerate — runs inside run_sync."""
cfg = _build_config(connection)
command.revision(cfg, message=message, autogenerate=True)
async def run_alembic_upgrade(async_engine: AsyncEngine, revision: str = 'head') -> None:
"""Run Alembic upgrade to the given revision."""
async with async_engine.connect() as conn:
await conn.run_sync(_do_upgrade, revision)
await conn.commit()
async def run_alembic_stamp(async_engine: AsyncEngine, revision: str = 'head') -> None:
"""Stamp the database with a revision without running migrations."""
async with async_engine.connect() as conn:
await conn.run_sync(_do_stamp, revision)
await conn.commit()
async def get_alembic_current(async_engine: AsyncEngine) -> str | None:
"""Get current alembic revision, or None if not stamped."""
async with async_engine.connect() as conn:
return await conn.run_sync(_do_get_current)
async def run_alembic_autogenerate(async_engine: AsyncEngine, message: str = 'auto migration') -> None:
"""Compare ORM models against DB schema and generate a migration script."""
async with async_engine.connect() as conn:
await conn.run_sync(_do_autogenerate, message)
# CLI entrypoint: python -m langbot.pkg.persistence.alembic_runner <command> [args]
if __name__ == '__main__':
import sys
import asyncio
def _get_engine():
"""Create engine from data/config.yaml or default SQLite."""
from sqlalchemy.ext.asyncio import create_async_engine
try:
import yaml
with open('data/config.yaml') as f:
config = yaml.safe_load(f)
db_cfg = config.get('database', {})
db_type = db_cfg.get('use', 'sqlite')
if db_type == 'postgresql':
pg = db_cfg.get('postgresql', {})
url = (
f'postgresql+asyncpg://{pg.get("user", "postgres")}:{pg.get("password", "postgres")}'
f'@{pg.get("host", "127.0.0.1")}:{pg.get("port", 5432)}/{pg.get("database", "postgres")}'
)
else:
path = db_cfg.get('sqlite', {}).get('path', 'data/langbot.db')
url = f'sqlite+aiosqlite:///{path}'
except Exception:
url = 'sqlite+aiosqlite:///data/langbot.db'
return create_async_engine(url)
def main():
if len(sys.argv) < 2:
print('Usage: python -m langbot.pkg.persistence.alembic_runner <command> [args]')
print('Commands:')
print(' autogenerate "message" — Generate migration from ORM model diff')
print(' upgrade [revision] — Upgrade database (default: head)')
print(' stamp [revision] — Stamp revision without running (default: head)')
print(' current — Show current revision')
sys.exit(1)
cmd = sys.argv[1]
engine = _get_engine()
if cmd == 'autogenerate':
msg = sys.argv[2] if len(sys.argv) > 2 else 'auto migration'
asyncio.run(run_alembic_autogenerate(engine, msg))
print(f'Migration generated: {msg}')
elif cmd == 'upgrade':
rev = sys.argv[2] if len(sys.argv) > 2 else 'head'
asyncio.run(run_alembic_upgrade(engine, rev))
print(f'Upgraded to: {rev}')
elif cmd == 'stamp':
rev = sys.argv[2] if len(sys.argv) > 2 else 'head'
asyncio.run(run_alembic_stamp(engine, rev))
print(f'Stamped: {rev}')
elif cmd == 'current':
rev = asyncio.run(get_alembic_current(engine))
print(f'Current revision: {rev}')
else:
print(f'Unknown command: {cmd}')
sys.exit(1)
main()

View File

@@ -76,9 +76,6 @@ class PersistenceManager:
self.ap.logger.info(f'Successfully upgraded database to version {last_migration_number}.')
# Run Alembic migrations (new migration system)
await self._run_alembic_migrations()
await self.write_space_model_providers()
async def create_tables(self):
@@ -138,28 +135,6 @@ class PersistenceManager:
# =================================
async def _run_alembic_migrations(self):
"""Run Alembic-based migrations after legacy migrations complete."""
from . import alembic_runner
engine = self.get_db_engine()
try:
current_rev = await alembic_runner.get_alembic_current(engine)
if current_rev is None:
# First time: stamp baseline so Alembic knows existing schema is up-to-date
self.ap.logger.info('Alembic: no revision found, stamping baseline...')
await alembic_runner.run_alembic_stamp(engine, '0001_baseline')
current_rev = '0001_baseline'
# Upgrade to head
await alembic_runner.run_alembic_upgrade(engine, 'head')
self.ap.logger.info('Alembic migrations completed.')
except Exception as e:
self.ap.logger.error(f'Alembic migration failed: {e}', exc_info=True)
raise
async def execute_async(self, *args, **kwargs) -> sqlalchemy.engine.cursor.CursorResult:
async with self.get_db_engine().connect() as conn:
result = await conn.execute(*args, **kwargs)

View File

@@ -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)

View File

@@ -160,6 +160,7 @@ class PreProcessor(stage.PipelineStage):
elif me.url:
content_list.append(provider_message.ContentElement.from_file_url(me.url, 'voice'))
elif isinstance(me, platform_message.File):
# if me.url is not None:
content_list.append(provider_message.ContentElement.from_file_url(me.url, me.name))
elif isinstance(me, platform_message.Quote) and quote_msg:
for msg in me.origin:
@@ -171,15 +172,6 @@ class PreProcessor(stage.PipelineStage):
):
if msg.base64 is not None:
content_list.append(provider_message.ContentElement.from_image_base64(msg.base64))
elif isinstance(msg, platform_message.File):
content_list.append(provider_message.ContentElement.from_file_url(msg.url, msg.name))
elif isinstance(msg, platform_message.Voice):
if msg.base64:
content_list.append(
provider_message.ContentElement.from_file_base64(msg.base64, 'voice.silk')
)
elif msg.url:
content_list.append(provider_message.ContentElement.from_file_url(msg.url, 'voice'))
query.variables['user_message_text'] = plain_text

View File

@@ -208,7 +208,6 @@ class ChatMessageHandler(handler.MessageHandler):
'model_name': model_name,
'version': constants.semantic_version,
'instance_id': constants.instance_id,
'edition': constants.edition,
'pipeline_plugins': pipeline_plugins,
'error': locals().get('error_info', None),
'timestamp': datetime.utcnow().isoformat(),

View File

@@ -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'):

View File

@@ -71,8 +71,7 @@ class DingTalkMessageConverter(abstract_platform_adapter.AbstractMessageConverte
yiri_msg_list.append(platform_message.Image(base64=element['Picture']))
else:
# 回退到原有简单逻辑
# 对于音频消息content 来自 recognition 转写文字,在下方音频处理块中统一处理
if event.content and event.type != 'audio':
if event.content:
text_content = event.content.replace('@' + bot_name, '')
yiri_msg_list.append(platform_message.Plain(text=text_content))
if event.picture:
@@ -82,38 +81,7 @@ class DingTalkMessageConverter(abstract_platform_adapter.AbstractMessageConverte
if event.file:
yiri_msg_list.append(platform_message.File(url=event.file, name=event.name))
if event.audio:
# 优先使用钉钉自带的语音转写文字recognition字段
if event.content and event.type == 'audio':
yiri_msg_list.append(platform_message.Plain(text=event.content))
else:
yiri_msg_list.append(platform_message.Voice(base64=event.audio))
# Handle quoted/replied message - extract content as top-level components
# so that plugins like FileReader can process them the same way as direct messages
if event.quoted_message:
quote_info = event.quoted_message
msg_type = quote_info.get('msg_type', '')
# Process quoted file - add as top-level File component (same as private chat)
if msg_type == 'file' and quote_info.get('file_url'):
file_name = quote_info.get('file_name', 'file')
yiri_msg_list.append(platform_message.File(url=quote_info['file_url'], name=file_name))
# Process quoted image - add as top-level Image component
elif msg_type == 'picture' and quote_info.get('picture'):
yiri_msg_list.append(platform_message.Image(base64=quote_info['picture']))
# Process quoted audio - add as top-level Voice component
elif msg_type == 'audio' and quote_info.get('audio'):
yiri_msg_list.append(platform_message.Voice(base64=quote_info['audio']))
# Process quoted text - add as Plain text with context prefix
elif msg_type == 'text' and quote_info.get('content'):
yiri_msg_list.append(platform_message.Plain(text=f'[引用消息] {quote_info["content"]}'))
# Process quoted rich text - add as Plain text with context prefix
elif msg_type == 'richText' and quote_info.get('content'):
yiri_msg_list.append(platform_message.Plain(text=f'[引用消息] {quote_info["content"]}'))
yiri_msg_list.append(platform_message.Voice(base64=event.audio))
chain = platform_message.MessageChain(yiri_msg_list)

View File

@@ -709,29 +709,21 @@ class LarkEventConverter(abstract_platform_adapter.AbstractEventConverter):
message_chain = await LarkMessageConverter.target2yiri(event.event.message, api_client)
# Check for quote/reply message
# Extract files/images/voice from quote and add them as top-level components
# so that plugins like FileReader can process them the same way as direct messages
quote_message_id = LarkEventConverter._extract_quote_message_id(event.event.message)
if quote_message_id:
quote_chain = await LarkEventConverter._fetch_quoted_message(quote_message_id, api_client)
if quote_chain:
# Filter out Source component from quoted chain, keep only content
quote_components = [comp for comp in quote_chain if not isinstance(comp, platform_message.Source)]
# Add quoted content as top-level components instead of wrapping in Quote
for comp in quote_components:
if isinstance(comp, platform_message.File):
# Add file as top-level component (same as direct message)
message_chain.append(comp)
elif isinstance(comp, platform_message.Image):
# Add image as top-level component
message_chain.append(comp)
elif isinstance(comp, platform_message.Voice):
# Add voice as top-level component
message_chain.append(comp)
elif isinstance(comp, platform_message.Plain):
# Add text with context prefix
message_chain.append(platform_message.Plain(text=f'[引用消息] {comp.text}'))
quote_origin = platform_message.MessageChain(
[comp for comp in quote_chain if not isinstance(comp, platform_message.Source)]
)
if quote_origin:
message_chain.append(
platform_message.Quote(
message_id=quote_message_id,
origin=quote_origin,
)
)
if event.event.message.chat_type == 'p2p':
return platform_events.FriendMessage(

View File

@@ -126,107 +126,6 @@ class WecomBotMessageConverter(abstract_platform_adapter.AbstractMessageConverte
if summary:
yiri_msg_list.append(platform_message.Plain(text=summary))
# Handle quoted message (引用消息) - important for group chat file references
# Extract files/images/voice from quote and add them as top-level components
# so that plugins like FileReader can process them the same way as direct messages
quote_info = event.quote or {}
if quote_info:
# Process quote text content - add as Plain for context
if quote_info.get('content'):
yiri_msg_list.append(platform_message.Plain(text=f'[引用消息] {quote_info.get("content")}'))
# Process quote images - add as top-level Image components
quote_images = quote_info.get('images', [])
if not quote_images and quote_info.get('picurl'):
quote_images = [quote_info.get('picurl')]
for img_data in quote_images:
if img_data:
yiri_msg_list.append(platform_message.Image(base64=img_data))
# Process quote file - add as top-level File component (same as private chat)
quote_file = quote_info.get('file') or {}
if quote_file:
file_url = (
quote_file.get('base64')
or quote_file.get('download_url')
or quote_file.get('url')
or quote_file.get('fileurl')
)
file_name = quote_file.get('filename') or quote_file.get('name')
file_size = quote_file.get('filesize') or quote_file.get('size')
if file_url or file_name:
file_kwargs = {}
if file_url:
file_kwargs['url'] = file_url
if file_name:
file_kwargs['name'] = file_name
if file_size is not None:
file_kwargs['size'] = file_size
try:
yiri_msg_list.append(platform_message.File(**file_kwargs))
except Exception:
yiri_msg_list.append(platform_message.Unknown(text='[quoted file unsupported]'))
# Process quote voice - add as top-level Voice/File component
quote_voice = quote_info.get('voice') or {}
if quote_voice:
voice_payload = quote_voice.get('base64') or quote_voice.get('url')
if voice_payload:
if quote_voice.get('base64') and not voice_payload.startswith('data:'):
voice_payload = f'data:audio/mpeg;base64,{quote_voice.get("base64")}'
try:
yiri_msg_list.append(platform_message.Voice(base64=voice_payload))
except Exception:
try:
voice_kwargs = {'url': voice_payload}
voice_name = quote_voice.get('filename') or quote_voice.get('name')
voice_size = quote_voice.get('filesize') or quote_voice.get('size')
if voice_name:
voice_kwargs['name'] = voice_name
if voice_size is not None:
voice_kwargs['size'] = voice_size
yiri_msg_list.append(platform_message.File(**voice_kwargs))
except Exception:
yiri_msg_list.append(platform_message.Unknown(text='[quoted voice unsupported]'))
# Process quote video - add as top-level File component
quote_video = quote_info.get('video') or {}
if quote_video:
video_payload = (
quote_video.get('base64')
or quote_video.get('url')
or quote_video.get('download_url')
or quote_video.get('fileurl')
)
if video_payload:
video_kwargs = {'url': video_payload}
video_name = quote_video.get('filename') or quote_video.get('name')
video_size = quote_video.get('filesize') or quote_video.get('size')
if video_name:
video_kwargs['name'] = video_name
if video_size is not None:
video_kwargs['size'] = video_size
try:
yiri_msg_list.append(platform_message.File(**video_kwargs))
except Exception:
yiri_msg_list.append(platform_message.Unknown(text='[quoted video unsupported]'))
# Process quote link - add as Plain text
quote_link = quote_info.get('link') or {}
if quote_link:
link_summary = '\n'.join(
filter(
None,
[
quote_link.get('title', ''),
quote_link.get('description') or quote_link.get('digest', ''),
quote_link.get('url', ''),
],
)
)
if link_summary:
yiri_msg_list.append(platform_message.Plain(text=f'[引用链接] {link_summary}'))
has_content_element = any(
not isinstance(element, (platform_message.Source, platform_message.At)) for element in yiri_msg_list
)
@@ -429,9 +328,6 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
feedback_type = kwargs.get('feedback_type', 0)
feedback_content = kwargs.get('feedback_content', '') or None
inaccurate_reasons = kwargs.get('inaccurate_reasons', []) or None
# WeChat Work returns integer reason codes, but FeedbackEvent expects strings
if inaccurate_reasons:
inaccurate_reasons = [str(r) for r in inaccurate_reasons]
session = kwargs.get('session')
session_id = None

View File

@@ -60,16 +60,7 @@ class TelemetryManager:
except Exception:
sanitized['query_id'] = str(sanitized.get('query_id', ''))
for sfield in (
'adapter',
'runner',
'runner_category',
'model_name',
'version',
'edition',
'error',
'timestamp',
):
for sfield in ('adapter', 'runner', 'runner_category', 'model_name', 'version', 'error', 'timestamp'):
v = sanitized.get(sfield)
sanitized[sfield] = '' if v is None else str(v)

View File

@@ -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

38
uv.lock generated
View File

@@ -186,20 +186,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/00/b7/e3bf5133d697a08128598c8d0abc5e16377b51465a33756de24fa7dee953/aiosqlite-0.22.1-py3-none-any.whl", hash = "sha256:21c002eb13823fad740196c5a2e9d8e62f6243bd9e7e4a1f87fb5e44ecb4fceb", size = 17405, upload-time = "2025-12-23T19:25:42.139Z" },
]
[[package]]
name = "alembic"
version = "1.18.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "mako" },
{ name = "sqlalchemy" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/94/13/8b084e0f2efb0275a1d534838844926f798bd766566b1375174e2448cd31/alembic-1.18.4.tar.gz", hash = "sha256:cb6e1fd84b6174ab8dbb2329f86d631ba9559dd78df550b57804d607672cedbc", size = 2056725, upload-time = "2026-02-10T16:00:47.195Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d2/29/6533c317b74f707ea28f8d633734dbda2119bbadfc61b2f3640ba835d0f7/alembic-1.18.4-py3-none-any.whl", hash = "sha256:a5ed4adcf6d8a4cb575f3d759f071b03cd6e5c7618eb796cb52497be25bfe19a", size = 263893, upload-time = "2026-02-10T16:00:49.997Z" },
]
[[package]]
name = "annotated-types"
version = "0.7.0"
@@ -1846,7 +1832,7 @@ wheels = [
[[package]]
name = "langbot"
version = "4.9.6"
version = "4.9.5"
source = { editable = "." }
dependencies = [
{ name = "aiocqhttp" },
@@ -1854,7 +1840,6 @@ dependencies = [
{ name = "aiohttp" },
{ name = "aioshutil" },
{ name = "aiosqlite" },
{ name = "alembic" },
{ name = "anthropic" },
{ name = "argon2-cffi" },
{ name = "async-lru" },
@@ -1934,7 +1919,6 @@ requires-dist = [
{ name = "aiohttp", specifier = ">=3.11.18" },
{ name = "aioshutil", specifier = ">=1.5" },
{ name = "aiosqlite", specifier = ">=0.21.0" },
{ name = "alembic", specifier = ">=1.15.0" },
{ name = "anthropic", specifier = ">=0.51.0" },
{ name = "argon2-cffi", specifier = ">=23.1.0" },
{ name = "async-lru", specifier = ">=2.0.5" },
@@ -1953,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.8" },
{ 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" },
@@ -2009,7 +1993,7 @@ dev = [
[[package]]
name = "langbot-plugin"
version = "0.3.8"
version = "0.3.7"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "aiofiles" },
@@ -2027,9 +2011,9 @@ dependencies = [
{ name = "watchdog" },
{ name = "websockets" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b8/d8/7c8ac9516e35d69ead3e934b408e48541f5772eb88fbed19cd216af4b6c2/langbot_plugin-0.3.8.tar.gz", hash = "sha256:e8e420c3b2f167c9635e3e0af46fb452895be9d68ec05bf112ac5f221c3316f3", size = 179803, upload-time = "2026-04-10T11:05:42.791Z" }
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/81/63/4a61b67d4886522647e0b60063da155279b943a6b2e6cd004e29aedf67d1/langbot_plugin-0.3.8-py3-none-any.whl", hash = "sha256:2246f343b4735cb4004cf44462ffb47531222c21efeef163a4acd758ebbec2cd", size = 157354, upload-time = "2026-04-10T11:05:41.525Z" },
{ 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]]
@@ -2425,18 +2409,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/6c/77/d7f491cbc05303ac6801651aabeb262d43f319288c1ea96c66b1d2692ff3/lxml-6.0.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:27220da5be049e936c3aca06f174e8827ca6445a4353a1995584311487fc4e3e", size = 3518768, upload-time = "2025-09-22T04:04:57.097Z" },
]
[[package]]
name = "mako"
version = "1.3.10"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markupsafe" },
]
sdist = { url = "https://files.pythonhosted.org/packages/9e/38/bd5b78a920a64d708fe6bc8e0a2c075e1389d53bef8413725c63ba041535/mako-1.3.10.tar.gz", hash = "sha256:99579a6f39583fa7e5630a28c3c1f440e4e97a414b80372649c0ce338da2ea28", size = 392474, upload-time = "2025-04-10T12:44:31.16Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/87/fb/99f81ac72ae23375f22b7afdb7642aba97c00a713c217124420147681a2f/mako-1.3.10-py3-none-any.whl", hash = "sha256:baef24a52fc4fc514a0887ac600f9f1cff3d82c61d4d700a1fa84d597b88db59", size = 78509, upload-time = "2025-04-10T12:50:53.297Z" },
]
[[package]]
name = "markdown"
version = "3.10.1"

View File

@@ -1,13 +1,10 @@
<!doctype html>
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>LangBot</title>
<meta
name="description"
content="Production-grade platform for building agentic IM bots"
/>
<meta name="description" content="Production-grade platform for building agentic IM bots" />
</head>
<body>
<div id="root"></div>

4019
web/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,5 +1,5 @@
@import 'tailwindcss';
@import 'tw-animate-css';
@import "tailwindcss";
@import "tw-animate-css";
:root {
/* 适用于 Firefox 的滚动条 */
scrollbar-color: rgba(0, 0, 0, 0.2) transparent; /* 滑块颜色 + 轨道颜色 */
@@ -74,6 +74,8 @@
}
}
@custom-variant dark (&:is(.dark *));
@theme inline {

View File

@@ -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>

View File

@@ -719,7 +719,7 @@ function NavItems({
<DropdownMenuTrigger asChild>
<button
type="button"
className="p-1 rounded-sm text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground [@media(hover:hover)]:opacity-0 group-hover/category-header:opacity-100 transition-all"
className="p-1 rounded-sm text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground opacity-0 group-hover/category-header:opacity-100 transition-all"
onClick={(e) => e.stopPropagation()}
>
<Plus className="size-3.5" />
@@ -762,7 +762,7 @@ function NavItems({
) : (
<button
type="button"
className="p-1 rounded-sm text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground [@media(hover:hover)]:opacity-0 group-hover/category-header:opacity-100 transition-all"
className="p-1 rounded-sm text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground opacity-0 group-hover/category-header:opacity-100 transition-all"
onClick={(e) => {
e.stopPropagation();
navigate(`${routePrefix}?id=new`);

View File

@@ -1,5 +1,4 @@
import React, { useCallback, useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { Card, CardContent } from '@/components/ui/card';
import {
Select,
@@ -220,12 +219,6 @@ export default function FileUploadZone({
<p className="text-sm text-yellow-800 dark:text-yellow-200">
{t('knowledge.documentsTab.noParserAvailable')}
</p>
<Link
to="/home/market?category=Parser"
className="text-sm text-primary hover:underline mt-1 inline-block"
>
{t('knowledge.documentsTab.installParserHint')}
</Link>
</div>
) : (
<div className="space-y-2">

View File

@@ -1,7 +1,6 @@
import { useState, useEffect, useCallback, useMemo } from 'react';
import { httpClient } from '@/app/infra/http';
import { FeedbackRecord, FeedbackStats } from '../types/monitoring';
import { parseUTCTimestamp } from '../utils/dateUtils';
interface UseFeedbackDataParams {
botIds?: string[];
@@ -143,7 +142,7 @@ export function useFeedbackData(params: UseFeedbackDataParams = {}) {
const transformedFeedback: FeedbackRecord[] = result.feedback.map(
(item) => ({
id: item.id,
timestamp: parseUTCTimestamp(item.timestamp),
timestamp: new Date(item.timestamp),
feedbackId: item.feedback_id,
feedbackType: item.feedback_type === 1 ? 'like' : 'dislike',
feedbackContent: item.feedback_content,

View File

@@ -405,10 +405,7 @@ export default function PluginInstallProgressDialog() {
return (
<Dialog open={open} onOpenChange={(o) => !o && handleClose()}>
<DialogContent
className="w-[460px] max-h-[80vh] p-6 bg-white dark:bg-[#1a1a1e] overflow-y-auto"
hideCloseButton
>
<DialogContent className="w-[460px] max-h-[80vh] p-6 bg-white dark:bg-[#1a1a1e] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-3">
<Download className="size-5" />
@@ -425,16 +422,14 @@ export default function PluginInstallProgressDialog() {
{selectedTask && <TaskProgressContent task={selectedTask} />}
<div className="flex justify-end gap-2 mt-2">
<Button
variant="default"
size="sm"
onClick={
selectedTask?.stage === InstallStage.DONE ||
selectedTask?.stage === InstallStage.ERROR
? handleDismiss
: handleClose
}
>
{selectedTask &&
(selectedTask.stage === InstallStage.DONE ||
selectedTask.stage === InstallStage.ERROR) && (
<Button variant="outline" size="sm" onClick={handleDismiss}>
{t('plugins.installProgress.dismiss')}
</Button>
)}
<Button variant="default" size="sm" onClick={handleClose}>
{selectedTask?.stage === InstallStage.DONE ||
selectedTask?.stage === InstallStage.ERROR
? t('common.close')

View File

@@ -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: {

View File

@@ -109,11 +109,8 @@ function DialogOverlay({
function DialogContent({
className,
children,
hideCloseButton = false,
...props
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
hideCloseButton?: boolean;
}) {
}: React.ComponentProps<typeof DialogPrimitive.Content>) {
return (
<DialogPortal data-slot="dialog-portal">
<DialogOverlay />
@@ -126,12 +123,10 @@ function DialogContent({
{...props}
>
{children}
{!hideCloseButton && (
<DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
)}
<DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">
<XIcon />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
);

View File

@@ -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: {
@@ -872,7 +886,6 @@ const enUS = {
builtInParser: 'Provided by Knowledge engine',
noParserAvailable:
'No parser supports this file type. Please install a parser plugin that can handle this format.',
installParserHint: 'Browse parser plugins in Marketplace →',
confirmUpload: 'Upload',
cancelUpload: 'Cancel',
},

View File

@@ -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: {

View File

@@ -392,6 +392,20 @@
discarded: '破棄済み',
userMessage: 'ユーザー',
botMessage: 'アシスタント',
operatorMessage: 'オペレーター',
humanTakeover: '有人対応',
takeoverBtn: '引き継ぐ',
releaseBtn: '解除',
takeoverConfirm: 'このセッションを引き継ぎますか解除するまでAIボットは応答を停止します。',
releaseConfirm: 'このセッションを解除しますかAIボットが応答を再開します。',
takeoverSuccess: 'セッションの引き継ぎに成功しました',
releaseSuccess: 'セッションの解除に成功しました',
takeoverFailed: 'セッションの引き継ぎに失敗しました',
releaseFailed: 'セッションの解除に失敗しました',
sendMessage: 'オペレーターとしてメッセージを送信...',
sendBtn: '送信',
sendFailed: 'メッセージの送信に失敗しました',
takenOver: '引き継ぎ中',
},
},
plugins: {

View File

@@ -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: {

View File

@@ -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: {

View File

@@ -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: {
@@ -832,7 +846,6 @@ const zhHans = {
builtInParser: '由知识引擎提供',
noParserAvailable:
'没有解析器支持此文件类型,请安装支持该格式的解析器插件。',
installParserHint: '前往插件市场安装解析器 →',
confirmUpload: '上传',
cancelUpload: '取消',
},

View File

@@ -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: {
@@ -825,7 +839,6 @@ const zhHant = {
builtInParser: '由知識引擎提供',
noParserAvailable:
'沒有解析器支援此檔案類型,請安裝支援該格式的解析器插件。',
installParserHint: '前往插件市場安裝解析器 →',
confirmUpload: '上傳',
cancelUpload: '取消',
},

View File

@@ -17,6 +17,9 @@
"@/*": ["./src/*"]
}
},
"include": ["**/*.ts", "**/*.tsx"],
"include": [
"**/*.ts",
"**/*.tsx"
],
"exclude": ["node_modules"]
}