mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-17 11:14:19 +00:00
Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cc4d8838eb | |||
| fa0a77f09f | |||
| fd6a7b73d4 | |||
| bf0848d60b | |||
| e06fac2bb7 | |||
| bec61427a0 | |||
| 5fae7b2eb0 | |||
| 2eebdfe16a | |||
| 9cd3544d59 | |||
| de4d14fee3 | |||
| f29c568381 | |||
| af3f557055 | |||
| b894842736 | |||
| e190029e1f | |||
| e4940a8050 | |||
| 617c95ebc4 | |||
| 1cdd428bcc | |||
| 71ac719aee | |||
| 4621e6cc9f | |||
| 66087f83e1 | |||
| 25f9330491 | |||
| 14b1e0d33b | |||
| 83ccb33fd3 | |||
| 05bcf543ba | |||
| 7cd063bb5d |
@@ -0,0 +1,171 @@
|
|||||||
|
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())
|
||||||
|
"
|
||||||
+4
-3
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "langbot"
|
name = "langbot"
|
||||||
version = "4.9.5"
|
version = "4.9.6"
|
||||||
description = "Production-grade platform for building agentic IM bots"
|
description = "Production-grade platform for building agentic IM bots"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
license-files = ["LICENSE"]
|
license-files = ["LICENSE"]
|
||||||
@@ -39,6 +39,7 @@ dependencies = [
|
|||||||
"quart-cors>=0.8.0",
|
"quart-cors>=0.8.0",
|
||||||
"requests>=2.32.3",
|
"requests>=2.32.3",
|
||||||
"slack-sdk>=3.35.0",
|
"slack-sdk>=3.35.0",
|
||||||
|
"alembic>=1.15.0",
|
||||||
"sqlalchemy[asyncio]>=2.0.40",
|
"sqlalchemy[asyncio]>=2.0.40",
|
||||||
"sqlmodel>=0.0.24",
|
"sqlmodel>=0.0.24",
|
||||||
"telegramify-markdown>=0.5.1",
|
"telegramify-markdown>=0.5.1",
|
||||||
@@ -64,7 +65,7 @@ dependencies = [
|
|||||||
"chromadb>=1.0.0,<2.0.0",
|
"chromadb>=1.0.0,<2.0.0",
|
||||||
"qdrant-client (>=1.15.1,<2.0.0)",
|
"qdrant-client (>=1.15.1,<2.0.0)",
|
||||||
"pyseekdb==1.1.0.post3",
|
"pyseekdb==1.1.0.post3",
|
||||||
"langbot-plugin==0.3.7",
|
"langbot-plugin==0.3.8",
|
||||||
"asyncpg>=0.30.0",
|
"asyncpg>=0.30.0",
|
||||||
"line-bot-sdk>=3.19.0",
|
"line-bot-sdk>=3.19.0",
|
||||||
"tboxsdk>=0.0.10",
|
"tboxsdk>=0.0.10",
|
||||||
@@ -111,7 +112,7 @@ requires = ["setuptools>=61.0", "wheel"]
|
|||||||
build-backend = "setuptools.build_meta"
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
[tool.setuptools]
|
[tool.setuptools]
|
||||||
package-data = { "langbot" = ["templates/**", "pkg/provider/modelmgr/requesters/*", "pkg/platform/sources/*", "web/dist/**"] }
|
package-data = { "langbot" = ["templates/**", "pkg/provider/modelmgr/requesters/*", "pkg/platform/sources/*", "web/dist/**", "pkg/persistence/alembic/**"] }
|
||||||
|
|
||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
dev = [
|
dev = [
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
"""LangBot - Production-grade platform for building agentic IM bots"""
|
"""LangBot - Production-grade platform for building agentic IM bots"""
|
||||||
|
|
||||||
__version__ = '4.9.5'
|
__version__ = '4.9.6'
|
||||||
|
|||||||
@@ -182,6 +182,88 @@ class DingTalkClient:
|
|||||||
for handler in self._message_handlers[msg_type]:
|
for handler in self._message_handlers[msg_type]:
|
||||||
await handler(event)
|
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):
|
async def get_message(self, incoming_message: dingtalk_stream.chatbot.ChatbotMessage):
|
||||||
try:
|
try:
|
||||||
# print(json.dumps(incoming_message.to_dict(), indent=4, ensure_ascii=False))
|
# print(json.dumps(incoming_message.to_dict(), indent=4, ensure_ascii=False))
|
||||||
@@ -193,6 +275,15 @@ class DingTalkClient:
|
|||||||
elif str(incoming_message.conversation_type) == '2':
|
elif str(incoming_message.conversation_type) == '2':
|
||||||
message_data['conversation_type'] = 'GroupMessage'
|
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':
|
if incoming_message.message_type == 'richText':
|
||||||
data = incoming_message.rich_text_content.to_dict()
|
data = incoming_message.rich_text_content.to_dict()
|
||||||
|
|
||||||
@@ -268,7 +359,25 @@ class DingTalkClient:
|
|||||||
|
|
||||||
message_data['Type'] = 'image'
|
message_data['Type'] = 'image'
|
||||||
elif incoming_message.message_type == 'audio':
|
elif incoming_message.message_type == 'audio':
|
||||||
message_data['Audio'] = await self.get_audio_url(incoming_message.to_dict()['content']['downloadCode'])
|
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['Type'] = 'audio'
|
message_data['Type'] = 'audio'
|
||||||
elif incoming_message.message_type == 'file':
|
elif incoming_message.message_type == 'file':
|
||||||
|
|||||||
@@ -47,6 +47,22 @@ class DingTalkEvent(dict):
|
|||||||
def conversation(self):
|
def conversation(self):
|
||||||
return self.get('conversation_type', '')
|
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]:
|
def __getattr__(self, key: str) -> Optional[Any]:
|
||||||
"""
|
"""
|
||||||
允许通过属性访问数据中的任意字段。
|
允许通过属性访问数据中的任意字段。
|
||||||
|
|||||||
@@ -228,6 +228,9 @@ class StreamSessionManager:
|
|||||||
msg_id = session.msg_id
|
msg_id = session.msg_id
|
||||||
if msg_id and self._msg_index.get(msg_id) == stream_id:
|
if msg_id and self._msg_index.get(msg_id) == stream_id:
|
||||||
self._msg_index.pop(msg_id, None)
|
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:
|
def _decrypt_file(encrypted_data: bytes, aes_key_str: str) -> bytes:
|
||||||
@@ -592,6 +595,120 @@ async def parse_wecom_bot_message(
|
|||||||
if msg_json.get('aibotid'):
|
if msg_json.get('aibotid'):
|
||||||
message_data['aibotid'] = 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
|
return message_data
|
||||||
|
|
||||||
|
|
||||||
@@ -903,35 +1020,38 @@ class WecomBotClient:
|
|||||||
)
|
)
|
||||||
|
|
||||||
session = self.stream_sessions.get_session_by_feedback_id(feedback_id)
|
session = self.stream_sessions.get_session_by_feedback_id(feedback_id)
|
||||||
|
|
||||||
if session:
|
if session:
|
||||||
await self.logger.info(
|
await self.logger.info(
|
||||||
f'反馈关联到会话: stream_id={session.stream_id}, msg_id={session.msg_id}, user_id={session.user_id}'
|
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:
|
else:
|
||||||
await self.logger.warning(f'未找到 feedback_id={feedback_id} 对应的会话')
|
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())
|
||||||
|
|
||||||
except Exception:
|
except Exception:
|
||||||
await self.logger.error(traceback.format_exc())
|
await self.logger.error(traceback.format_exc())
|
||||||
|
|||||||
@@ -147,3 +147,10 @@ class WecomBotEvent(dict):
|
|||||||
流式消息 ID
|
流式消息 ID
|
||||||
"""
|
"""
|
||||||
return self.get('stream_id', '')
|
return self.get('stream_id', '')
|
||||||
|
|
||||||
|
@property
|
||||||
|
def quote(self):
|
||||||
|
"""
|
||||||
|
引用消息信息(群聊中用户引用其他消息时返回)
|
||||||
|
"""
|
||||||
|
return self.get('quote', {})
|
||||||
|
|||||||
@@ -1,97 +0,0 @@
|
|||||||
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))
|
|
||||||
@@ -105,23 +105,24 @@ class HTTPController:
|
|||||||
):
|
):
|
||||||
if os.path.exists(os.path.join(frontend_path, path + '.html')):
|
if os.path.exists(os.path.join(frontend_path, path + '.html')):
|
||||||
path += '.html'
|
path += '.html'
|
||||||
elif path.startswith('home/'):
|
elif not path.startswith('api/'):
|
||||||
# SPA fallback for /home/* sub-routes.
|
# SPA fallback: serve index.html for all non-API, non-static routes
|
||||||
# Entity detail views use query params (e.g. /home/bots?id=uuid),
|
# so that React Router can handle client-side routing (Vite SPA).
|
||||||
# so the pre-rendered list page is served directly via path + '.html'.
|
# For /home/* sub-routes, first try parent .html files (pre-rendered pages).
|
||||||
# This fallback handles any remaining unmatched sub-paths.
|
if path.startswith('home/'):
|
||||||
segments = path.rstrip('/').split('/')
|
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
|
||||||
|
|
||||||
# Walk up parent segments looking for matching .html files
|
# Fallback to index.html for SPA client-side routing
|
||||||
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 = await quart.send_from_directory(frontend_path, 'index.html', mimetype='text/html')
|
||||||
response.headers['Cache-Control'] = 'no-cache, no-store, must-revalidate'
|
response.headers['Cache-Control'] = 'no-cache, no-store, must-revalidate'
|
||||||
response.headers['Pragma'] = 'no-cache'
|
response.headers['Pragma'] = 'no-cache'
|
||||||
|
|||||||
@@ -1,314 +0,0 @@
|
|||||||
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)
|
|
||||||
@@ -1224,30 +1224,83 @@ class MonitoringService:
|
|||||||
"""
|
"""
|
||||||
import json
|
import json
|
||||||
|
|
||||||
record_id = str(uuid.uuid4())
|
now = datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None)
|
||||||
record_data = {
|
reasons_json = json.dumps(inaccurate_reasons, ensure_ascii=False) if inaccurate_reasons else None
|
||||||
'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,
|
|
||||||
}
|
|
||||||
|
|
||||||
await self.ap.persistence_mgr.execute_async(
|
MonitoringFeedback = persistence_monitoring.MonitoringFeedback
|
||||||
sqlalchemy.insert(persistence_monitoring.MonitoringFeedback).values(record_data)
|
|
||||||
|
# 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)
|
||||||
)
|
)
|
||||||
|
existing_row = existing_result.first()
|
||||||
|
|
||||||
return record_id
|
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
|
||||||
|
|
||||||
async def get_feedback_stats(
|
async def get_feedback_stats(
|
||||||
self,
|
self,
|
||||||
|
|||||||
@@ -65,8 +65,8 @@ class UserService:
|
|||||||
|
|
||||||
user_obj = result_list[0]
|
user_obj = result_list[0]
|
||||||
|
|
||||||
# Check if this is a Space account
|
# Check if this user has a local password set
|
||||||
if user_obj.account_type == 'space':
|
if not user_obj.password:
|
||||||
raise ValueError('请使用 Space 账户登录')
|
raise ValueError('请使用 Space 账户登录')
|
||||||
|
|
||||||
ph = argon2.PasswordHasher()
|
ph = argon2.PasswordHasher()
|
||||||
@@ -108,9 +108,8 @@ class UserService:
|
|||||||
if user_obj is None:
|
if user_obj is None:
|
||||||
raise ValueError('User not found')
|
raise ValueError('User not found')
|
||||||
|
|
||||||
# Space accounts cannot change password locally
|
if not user_obj.password:
|
||||||
if user_obj.account_type == 'space':
|
raise ValueError('No local password set, please set a password first')
|
||||||
raise ValueError('Space account cannot change password locally')
|
|
||||||
|
|
||||||
ph.verify(user_obj.password, current_password)
|
ph.verify(user_obj.password, current_password)
|
||||||
|
|
||||||
|
|||||||
@@ -31,7 +31,6 @@ from ..api.http.service import mcp as mcp_service
|
|||||||
from ..api.http.service import apikey as apikey_service
|
from ..api.http.service import apikey as apikey_service
|
||||||
from ..api.http.service import webhook as webhook_service
|
from ..api.http.service import webhook as webhook_service
|
||||||
from ..api.http.service import monitoring as monitoring_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 ..discover import engine as discover_engine
|
||||||
from ..storage import mgr as storagemgr
|
from ..storage import mgr as storagemgr
|
||||||
@@ -154,8 +153,6 @@ class Application:
|
|||||||
|
|
||||||
monitoring_service: monitoring_service.MonitoringService = None
|
monitoring_service: monitoring_service.MonitoringService = None
|
||||||
|
|
||||||
human_takeover_service: human_takeover_service.HumanTakeoverService = None
|
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,6 @@ from ...api.http.service import mcp as mcp_service
|
|||||||
from ...api.http.service import apikey as apikey_service
|
from ...api.http.service import apikey as apikey_service
|
||||||
from ...api.http.service import webhook as webhook_service
|
from ...api.http.service import webhook as webhook_service
|
||||||
from ...api.http.service import monitoring as monitoring_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 ...discover import engine as discover_engine
|
||||||
from ...storage import mgr as storagemgr
|
from ...storage import mgr as storagemgr
|
||||||
from ...utils import logcache
|
from ...utils import logcache
|
||||||
@@ -165,10 +164,6 @@ class BuildAppStage(stage.BootingStage):
|
|||||||
monitoring_service_inst = monitoring_service.MonitoringService(ap)
|
monitoring_service_inst = monitoring_service.MonitoringService(ap)
|
||||||
ap.monitoring_service = monitoring_service_inst
|
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:
|
async def runtime_disconnect_callback(connector: plugin_connector.PluginRuntimeConnector) -> None:
|
||||||
await asyncio.sleep(3)
|
await asyncio.sleep(3)
|
||||||
await plugin_connector_inst.initialize()
|
await plugin_connector_inst.initialize()
|
||||||
|
|||||||
@@ -1,36 +0,0 @@
|
|||||||
import sqlalchemy
|
|
||||||
|
|
||||||
from .base import Base
|
|
||||||
|
|
||||||
|
|
||||||
class HumanTakeoverSession(Base):
|
|
||||||
"""Human takeover session records.
|
|
||||||
|
|
||||||
Tracks which conversation sessions are currently under human operator control,
|
|
||||||
bypassing the normal AI pipeline processing.
|
|
||||||
"""
|
|
||||||
|
|
||||||
__tablename__ = 'human_takeover_sessions'
|
|
||||||
|
|
||||||
id = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True)
|
|
||||||
session_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, unique=True, index=True)
|
|
||||||
"""Corresponds to monitoring_sessions.session_id, format: 'person_{id}' or 'group_{id}'"""
|
|
||||||
|
|
||||||
bot_uuid = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, index=True)
|
|
||||||
"""UUID of the bot whose session is being taken over"""
|
|
||||||
|
|
||||||
status = sqlalchemy.Column(sqlalchemy.String(50), nullable=False, default='active', index=True)
|
|
||||||
"""Takeover status: 'active' or 'released'"""
|
|
||||||
|
|
||||||
taken_by = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
|
|
||||||
"""Email/username of the admin who took over the session"""
|
|
||||||
|
|
||||||
taken_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False)
|
|
||||||
"""Timestamp when the takeover started"""
|
|
||||||
|
|
||||||
released_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=True)
|
|
||||||
"""Timestamp when the takeover was released (null if still active)"""
|
|
||||||
|
|
||||||
platform = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
|
|
||||||
user_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
|
|
||||||
user_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
|
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
"""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()
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# 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"}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
"""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
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
"""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
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
"""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()
|
||||||
@@ -76,6 +76,9 @@ class PersistenceManager:
|
|||||||
|
|
||||||
self.ap.logger.info(f'Successfully upgraded database to version {last_migration_number}.')
|
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()
|
await self.write_space_model_providers()
|
||||||
|
|
||||||
async def create_tables(self):
|
async def create_tables(self):
|
||||||
@@ -135,6 +138,28 @@ 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 def execute_async(self, *args, **kwargs) -> sqlalchemy.engine.cursor.CursorResult:
|
||||||
async with self.get_db_engine().connect() as conn:
|
async with self.get_db_engine().connect() as conn:
|
||||||
result = await conn.execute(*args, **kwargs)
|
result = await conn.execute(*args, **kwargs)
|
||||||
|
|||||||
@@ -1,36 +0,0 @@
|
|||||||
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)
|
|
||||||
@@ -160,7 +160,6 @@ class PreProcessor(stage.PipelineStage):
|
|||||||
elif me.url:
|
elif me.url:
|
||||||
content_list.append(provider_message.ContentElement.from_file_url(me.url, 'voice'))
|
content_list.append(provider_message.ContentElement.from_file_url(me.url, 'voice'))
|
||||||
elif isinstance(me, platform_message.File):
|
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))
|
content_list.append(provider_message.ContentElement.from_file_url(me.url, me.name))
|
||||||
elif isinstance(me, platform_message.Quote) and quote_msg:
|
elif isinstance(me, platform_message.Quote) and quote_msg:
|
||||||
for msg in me.origin:
|
for msg in me.origin:
|
||||||
@@ -172,6 +171,15 @@ class PreProcessor(stage.PipelineStage):
|
|||||||
):
|
):
|
||||||
if msg.base64 is not None:
|
if msg.base64 is not None:
|
||||||
content_list.append(provider_message.ContentElement.from_image_base64(msg.base64))
|
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
|
query.variables['user_message_text'] = plain_text
|
||||||
|
|
||||||
|
|||||||
@@ -208,6 +208,7 @@ class ChatMessageHandler(handler.MessageHandler):
|
|||||||
'model_name': model_name,
|
'model_name': model_name,
|
||||||
'version': constants.semantic_version,
|
'version': constants.semantic_version,
|
||||||
'instance_id': constants.instance_id,
|
'instance_id': constants.instance_id,
|
||||||
|
'edition': constants.edition,
|
||||||
'pipeline_plugins': pipeline_plugins,
|
'pipeline_plugins': pipeline_plugins,
|
||||||
'error': locals().get('error_info', None),
|
'error': locals().get('error_info', None),
|
||||||
'timestamp': datetime.utcnow().isoformat(),
|
'timestamp': datetime.utcnow().isoformat(),
|
||||||
|
|||||||
@@ -220,47 +220,6 @@ class RuntimeBot:
|
|||||||
|
|
||||||
# Only add to query pool if no webhook requested to skip pipeline
|
# Only add to query pool if no webhook requested to skip pipeline
|
||||||
if not 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
|
launcher_id = event.sender.id
|
||||||
|
|
||||||
if hasattr(adapter, 'get_launcher_id'):
|
if hasattr(adapter, 'get_launcher_id'):
|
||||||
@@ -322,50 +281,6 @@ class RuntimeBot:
|
|||||||
|
|
||||||
# Only add to query pool if no webhook requested to skip pipeline
|
# Only add to query pool if no webhook requested to skip pipeline
|
||||||
if not 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
|
launcher_id = event.group.id
|
||||||
|
|
||||||
if hasattr(adapter, 'get_launcher_id'):
|
if hasattr(adapter, 'get_launcher_id'):
|
||||||
|
|||||||
@@ -71,7 +71,8 @@ class DingTalkMessageConverter(abstract_platform_adapter.AbstractMessageConverte
|
|||||||
yiri_msg_list.append(platform_message.Image(base64=element['Picture']))
|
yiri_msg_list.append(platform_message.Image(base64=element['Picture']))
|
||||||
else:
|
else:
|
||||||
# 回退到原有简单逻辑
|
# 回退到原有简单逻辑
|
||||||
if event.content:
|
# 对于音频消息,content 来自 recognition 转写文字,在下方音频处理块中统一处理
|
||||||
|
if event.content and event.type != 'audio':
|
||||||
text_content = event.content.replace('@' + bot_name, '')
|
text_content = event.content.replace('@' + bot_name, '')
|
||||||
yiri_msg_list.append(platform_message.Plain(text=text_content))
|
yiri_msg_list.append(platform_message.Plain(text=text_content))
|
||||||
if event.picture:
|
if event.picture:
|
||||||
@@ -81,7 +82,38 @@ class DingTalkMessageConverter(abstract_platform_adapter.AbstractMessageConverte
|
|||||||
if event.file:
|
if event.file:
|
||||||
yiri_msg_list.append(platform_message.File(url=event.file, name=event.name))
|
yiri_msg_list.append(platform_message.File(url=event.file, name=event.name))
|
||||||
if event.audio:
|
if event.audio:
|
||||||
yiri_msg_list.append(platform_message.Voice(base64=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"]}'))
|
||||||
|
|
||||||
chain = platform_message.MessageChain(yiri_msg_list)
|
chain = platform_message.MessageChain(yiri_msg_list)
|
||||||
|
|
||||||
|
|||||||
@@ -709,21 +709,29 @@ class LarkEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
|||||||
message_chain = await LarkMessageConverter.target2yiri(event.event.message, api_client)
|
message_chain = await LarkMessageConverter.target2yiri(event.event.message, api_client)
|
||||||
|
|
||||||
# Check for quote/reply message
|
# 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)
|
quote_message_id = LarkEventConverter._extract_quote_message_id(event.event.message)
|
||||||
if quote_message_id:
|
if quote_message_id:
|
||||||
quote_chain = await LarkEventConverter._fetch_quoted_message(quote_message_id, api_client)
|
quote_chain = await LarkEventConverter._fetch_quoted_message(quote_message_id, api_client)
|
||||||
if quote_chain:
|
if quote_chain:
|
||||||
# Filter out Source component from quoted chain, keep only content
|
# Filter out Source component from quoted chain, keep only content
|
||||||
quote_origin = platform_message.MessageChain(
|
quote_components = [comp for comp in quote_chain if not isinstance(comp, platform_message.Source)]
|
||||||
[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
|
||||||
if quote_origin:
|
for comp in quote_components:
|
||||||
message_chain.append(
|
if isinstance(comp, platform_message.File):
|
||||||
platform_message.Quote(
|
# Add file as top-level component (same as direct message)
|
||||||
message_id=quote_message_id,
|
message_chain.append(comp)
|
||||||
origin=quote_origin,
|
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}'))
|
||||||
|
|
||||||
if event.event.message.chat_type == 'p2p':
|
if event.event.message.chat_type == 'p2p':
|
||||||
return platform_events.FriendMessage(
|
return platform_events.FriendMessage(
|
||||||
|
|||||||
@@ -126,6 +126,107 @@ class WecomBotMessageConverter(abstract_platform_adapter.AbstractMessageConverte
|
|||||||
if summary:
|
if summary:
|
||||||
yiri_msg_list.append(platform_message.Plain(text=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(
|
has_content_element = any(
|
||||||
not isinstance(element, (platform_message.Source, platform_message.At)) for element in yiri_msg_list
|
not isinstance(element, (platform_message.Source, platform_message.At)) for element in yiri_msg_list
|
||||||
)
|
)
|
||||||
@@ -328,6 +429,9 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
feedback_type = kwargs.get('feedback_type', 0)
|
feedback_type = kwargs.get('feedback_type', 0)
|
||||||
feedback_content = kwargs.get('feedback_content', '') or None
|
feedback_content = kwargs.get('feedback_content', '') or None
|
||||||
inaccurate_reasons = kwargs.get('inaccurate_reasons', []) 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 = kwargs.get('session')
|
||||||
|
|
||||||
session_id = None
|
session_id = None
|
||||||
|
|||||||
@@ -60,7 +60,16 @@ class TelemetryManager:
|
|||||||
except Exception:
|
except Exception:
|
||||||
sanitized['query_id'] = str(sanitized.get('query_id', ''))
|
sanitized['query_id'] = str(sanitized.get('query_id', ''))
|
||||||
|
|
||||||
for sfield in ('adapter', 'runner', 'runner_category', 'model_name', 'version', 'error', 'timestamp'):
|
for sfield in (
|
||||||
|
'adapter',
|
||||||
|
'runner',
|
||||||
|
'runner_category',
|
||||||
|
'model_name',
|
||||||
|
'version',
|
||||||
|
'edition',
|
||||||
|
'error',
|
||||||
|
'timestamp',
|
||||||
|
):
|
||||||
v = sanitized.get(sfield)
|
v = sanitized.get(sfield)
|
||||||
sanitized[sfield] = '' if v is None else str(v)
|
sanitized[sfield] = '' if v is None else str(v)
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import langbot
|
|||||||
|
|
||||||
semantic_version = f'v{langbot.__version__}'
|
semantic_version = f'v{langbot.__version__}'
|
||||||
|
|
||||||
required_database_version = 26
|
required_database_version = 25
|
||||||
"""Tag the version of the database schema, used to check if the database needs to be migrated"""
|
"""Tag the version of the database schema, used to check if the database needs to be migrated"""
|
||||||
|
|
||||||
debug_mode = False
|
debug_mode = False
|
||||||
|
|||||||
@@ -186,6 +186,20 @@ 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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "annotated-types"
|
name = "annotated-types"
|
||||||
version = "0.7.0"
|
version = "0.7.0"
|
||||||
@@ -1832,7 +1846,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "langbot"
|
name = "langbot"
|
||||||
version = "4.9.5"
|
version = "4.9.6"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "aiocqhttp" },
|
{ name = "aiocqhttp" },
|
||||||
@@ -1840,6 +1854,7 @@ dependencies = [
|
|||||||
{ name = "aiohttp" },
|
{ name = "aiohttp" },
|
||||||
{ name = "aioshutil" },
|
{ name = "aioshutil" },
|
||||||
{ name = "aiosqlite" },
|
{ name = "aiosqlite" },
|
||||||
|
{ name = "alembic" },
|
||||||
{ name = "anthropic" },
|
{ name = "anthropic" },
|
||||||
{ name = "argon2-cffi" },
|
{ name = "argon2-cffi" },
|
||||||
{ name = "async-lru" },
|
{ name = "async-lru" },
|
||||||
@@ -1919,6 +1934,7 @@ requires-dist = [
|
|||||||
{ name = "aiohttp", specifier = ">=3.11.18" },
|
{ name = "aiohttp", specifier = ">=3.11.18" },
|
||||||
{ name = "aioshutil", specifier = ">=1.5" },
|
{ name = "aioshutil", specifier = ">=1.5" },
|
||||||
{ name = "aiosqlite", specifier = ">=0.21.0" },
|
{ name = "aiosqlite", specifier = ">=0.21.0" },
|
||||||
|
{ name = "alembic", specifier = ">=1.15.0" },
|
||||||
{ name = "anthropic", specifier = ">=0.51.0" },
|
{ name = "anthropic", specifier = ">=0.51.0" },
|
||||||
{ name = "argon2-cffi", specifier = ">=23.1.0" },
|
{ name = "argon2-cffi", specifier = ">=23.1.0" },
|
||||||
{ name = "async-lru", specifier = ">=2.0.5" },
|
{ name = "async-lru", specifier = ">=2.0.5" },
|
||||||
@@ -1937,7 +1953,7 @@ requires-dist = [
|
|||||||
{ name = "ebooklib", specifier = ">=0.18" },
|
{ name = "ebooklib", specifier = ">=0.18" },
|
||||||
{ name = "gewechat-client", specifier = ">=0.1.5" },
|
{ name = "gewechat-client", specifier = ">=0.1.5" },
|
||||||
{ name = "html2text", specifier = ">=2024.2.26" },
|
{ name = "html2text", specifier = ">=2024.2.26" },
|
||||||
{ name = "langbot-plugin", specifier = "==0.3.7" },
|
{ name = "langbot-plugin", specifier = "==0.3.8" },
|
||||||
{ name = "langchain", specifier = ">=0.2.0" },
|
{ name = "langchain", specifier = ">=0.2.0" },
|
||||||
{ name = "langchain-text-splitters", specifier = ">=0.0.1" },
|
{ name = "langchain-text-splitters", specifier = ">=0.0.1" },
|
||||||
{ name = "lark-oapi", specifier = ">=1.4.15" },
|
{ name = "lark-oapi", specifier = ">=1.4.15" },
|
||||||
@@ -1993,7 +2009,7 @@ dev = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "langbot-plugin"
|
name = "langbot-plugin"
|
||||||
version = "0.3.7"
|
version = "0.3.8"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "aiofiles" },
|
{ name = "aiofiles" },
|
||||||
@@ -2011,9 +2027,9 @@ dependencies = [
|
|||||||
{ name = "watchdog" },
|
{ name = "watchdog" },
|
||||||
{ name = "websockets" },
|
{ name = "websockets" },
|
||||||
]
|
]
|
||||||
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" }
|
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" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ 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" },
|
{ 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" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2409,6 +2425,18 @@ 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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "markdown"
|
name = "markdown"
|
||||||
version = "3.10.1"
|
version = "3.10.1"
|
||||||
|
|||||||
+5
-2
@@ -1,10 +1,13 @@
|
|||||||
<!DOCTYPE html>
|
<!doctype html>
|
||||||
<html lang="zh">
|
<html lang="zh">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>LangBot</title>
|
<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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
Generated
+2999
-1020
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,5 @@
|
|||||||
@import "tailwindcss";
|
@import 'tailwindcss';
|
||||||
@import "tw-animate-css";
|
@import 'tw-animate-css';
|
||||||
:root {
|
:root {
|
||||||
/* 适用于 Firefox 的滚动条 */
|
/* 适用于 Firefox 的滚动条 */
|
||||||
scrollbar-color: rgba(0, 0, 0, 0.2) transparent; /* 滑块颜色 + 轨道颜色 */
|
scrollbar-color: rgba(0, 0, 0, 0.2) transparent; /* 滑块颜色 + 轨道颜色 */
|
||||||
@@ -74,8 +74,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@custom-variant dark (&:is(.dark *));
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
@theme inline {
|
@theme inline {
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { Ban, Bot, Copy, Check, Workflow, UserCheck, Send } from 'lucide-react';
|
import { Ban, Bot, Copy, Check, Workflow } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
MessageChainComponent,
|
MessageChainComponent,
|
||||||
Plain,
|
Plain,
|
||||||
@@ -77,16 +77,6 @@ const BotSessionMonitor = forwardRef<
|
|||||||
const [copiedUserId, setCopiedUserId] = useState(false);
|
const [copiedUserId, setCopiedUserId] = useState(false);
|
||||||
const messagesContainerRef = useRef<HTMLDivElement>(null);
|
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 parseSessionType = (sessionId: string): string | null => {
|
||||||
const idx = sessionId.indexOf('_');
|
const idx = sessionId.indexOf('_');
|
||||||
if (idx === -1) return null;
|
if (idx === -1) return null;
|
||||||
@@ -119,24 +109,6 @@ const BotSessionMonitor = forwardRef<
|
|||||||
}
|
}
|
||||||
}, [botId]);
|
}, [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(
|
useImperativeHandle(
|
||||||
ref,
|
ref,
|
||||||
() => ({
|
() => ({
|
||||||
@@ -161,45 +133,17 @@ 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(() => {
|
useEffect(() => {
|
||||||
loadSessions();
|
loadSessions();
|
||||||
loadTakeoverStatus();
|
}, [loadSessions]);
|
||||||
}, [loadSessions, loadTakeoverStatus]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (selectedSessionId) {
|
if (selectedSessionId) {
|
||||||
loadMessages(selectedSessionId);
|
loadMessages(selectedSessionId);
|
||||||
checkTakeoverStatus(selectedSessionId);
|
|
||||||
} else {
|
} else {
|
||||||
setMessages([]);
|
setMessages([]);
|
||||||
setIsTakenOver(false);
|
|
||||||
}
|
}
|
||||||
}, [selectedSessionId, loadMessages, checkTakeoverStatus]);
|
}, [selectedSessionId, loadMessages]);
|
||||||
|
|
||||||
// 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(() => {
|
useEffect(() => {
|
||||||
if (messages.length === 0) return;
|
if (messages.length === 0) return;
|
||||||
@@ -216,76 +160,6 @@ const BotSessionMonitor = forwardRef<
|
|||||||
});
|
});
|
||||||
}, [messages]);
|
}, [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[] => {
|
const parseMessageChain = (content: string): MessageChainComponent[] => {
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(content);
|
const parsed = JSON.parse(content);
|
||||||
@@ -299,16 +173,11 @@ const BotSessionMonitor = forwardRef<
|
|||||||
};
|
};
|
||||||
|
|
||||||
const isUserMessage = (msg: SessionMessage): boolean => {
|
const isUserMessage = (msg: SessionMessage): boolean => {
|
||||||
if (msg.role === 'operator') return false;
|
|
||||||
if (msg.role === 'assistant') return false;
|
if (msg.role === 'assistant') return false;
|
||||||
if (msg.role === 'user') return true;
|
if (msg.role === 'user') return true;
|
||||||
return !msg.runner_name;
|
return !msg.runner_name;
|
||||||
};
|
};
|
||||||
|
|
||||||
const isOperatorMessage = (msg: SessionMessage): boolean => {
|
|
||||||
return msg.role === 'operator';
|
|
||||||
};
|
|
||||||
|
|
||||||
const renderMessageComponent = (
|
const renderMessageComponent = (
|
||||||
component: MessageChainComponent,
|
component: MessageChainComponent,
|
||||||
index: number,
|
index: number,
|
||||||
@@ -374,7 +243,7 @@ const BotSessionMonitor = forwardRef<
|
|||||||
key={index}
|
key={index}
|
||||||
className="inline-flex items-center gap-1 text-muted-foreground text-xs"
|
className="inline-flex items-center gap-1 text-muted-foreground text-xs"
|
||||||
>
|
>
|
||||||
[Voice]
|
🎙 [Voice]
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -408,7 +277,7 @@ const BotSessionMonitor = forwardRef<
|
|||||||
const file = component as MessageChainComponent & { name?: string };
|
const file = component as MessageChainComponent & { name?: string };
|
||||||
return (
|
return (
|
||||||
<span key={index} className="text-muted-foreground text-xs">
|
<span key={index} className="text-muted-foreground text-xs">
|
||||||
[{file.name || 'File'}]
|
📎 {file.name || 'File'}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -468,22 +337,6 @@ const BotSessionMonitor = forwardRef<
|
|||||||
(s) => s.session_id === selectedSessionId,
|
(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 (
|
return (
|
||||||
<div className="flex flex-col md:flex-row h-full min-h-0 rounded-lg border overflow-hidden">
|
<div className="flex flex-col md:flex-row h-full min-h-0 rounded-lg border overflow-hidden">
|
||||||
{/* Left Panel: Session List */}
|
{/* Left Panel: Session List */}
|
||||||
@@ -502,9 +355,6 @@ const BotSessionMonitor = forwardRef<
|
|||||||
<div className="p-1.5">
|
<div className="p-1.5">
|
||||||
{sessions.map((session) => {
|
{sessions.map((session) => {
|
||||||
const isSelected = selectedSessionId === session.session_id;
|
const isSelected = selectedSessionId === session.session_id;
|
||||||
const sessionTakenOver = takenOverSessions.has(
|
|
||||||
session.session_id,
|
|
||||||
);
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
key={session.session_id}
|
key={session.session_id}
|
||||||
@@ -541,12 +391,7 @@ const BotSessionMonitor = forwardRef<
|
|||||||
{abbreviateId(session.user_id)}
|
{abbreviateId(session.user_id)}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{sessionTakenOver && (
|
{session.is_active && (
|
||||||
<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="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 className="w-1.5 h-1.5 rounded-full bg-green-500 inline-block" />
|
||||||
</span>
|
</span>
|
||||||
@@ -570,92 +415,50 @@ const BotSessionMonitor = forwardRef<
|
|||||||
<>
|
<>
|
||||||
{/* Chat Header */}
|
{/* Chat Header */}
|
||||||
<div className="px-4 py-2.5 border-b shrink-0">
|
<div className="px-4 py-2.5 border-b shrink-0">
|
||||||
<div className="flex items-start justify-between gap-2">
|
<div className="min-w-0">
|
||||||
<div className="min-w-0">
|
<div className="text-sm font-medium truncate">
|
||||||
<div className="text-sm font-medium truncate">
|
{selectedSession?.user_name ||
|
||||||
{selectedSession?.user_name ||
|
selectedSession?.user_id ||
|
||||||
selectedSession?.user_id ||
|
selectedSessionId.slice(0, 20)}
|
||||||
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>
|
||||||
{/* Takeover / Release button */}
|
<div className="flex items-center gap-1.5 text-xs text-muted-foreground mt-0.5">
|
||||||
<div className="flex-shrink-0">
|
{parseSessionType(selectedSessionId) && (
|
||||||
{isTakenOver ? (
|
<span>{parseSessionType(selectedSessionId)}</span>
|
||||||
<button
|
)}
|
||||||
type="button"
|
{selectedSession?.platform && (
|
||||||
onClick={handleRelease}
|
<>
|
||||||
disabled={takeoverLoading}
|
{parseSessionType(selectedSessionId) && <span>·</span>}
|
||||||
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"
|
<span>{selectedSession.platform}</span>
|
||||||
>
|
</>
|
||||||
<UserCheck className="w-3.5 h-3.5" />
|
)}
|
||||||
{t('bots.sessionMonitor.releaseBtn', {
|
{selectedSession?.user_id && (
|
||||||
defaultValue: 'Release',
|
<>
|
||||||
})}
|
<span>·</span>
|
||||||
</button>
|
<span className="font-mono">
|
||||||
) : (
|
{selectedSession.user_id}
|
||||||
<button
|
</span>
|
||||||
type="button"
|
<button
|
||||||
onClick={handleTakeover}
|
type="button"
|
||||||
disabled={takeoverLoading}
|
onClick={() => copyUserId(selectedSession.user_id!)}
|
||||||
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"
|
className="inline-flex items-center text-muted-foreground hover:text-foreground transition-colors"
|
||||||
>
|
title={t('common.copy')}
|
||||||
<UserCheck className="w-3.5 h-3.5" />
|
>
|
||||||
{t('bots.sessionMonitor.takeoverBtn', {
|
{copiedUserId ? (
|
||||||
defaultValue: 'Take Over',
|
<Check className="w-3 h-3 text-green-600" />
|
||||||
})}
|
) : (
|
||||||
</button>
|
<Copy className="w-3 h-3" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{selectedSession?.is_active && (
|
||||||
|
<>
|
||||||
|
<span>·</span>
|
||||||
|
<span className="flex items-center gap-1 text-green-600 dark:text-green-400">
|
||||||
|
<span className="w-1.5 h-1.5 rounded-full bg-green-500 inline-block" />
|
||||||
|
Active
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -678,7 +481,6 @@ const BotSessionMonitor = forwardRef<
|
|||||||
) : (
|
) : (
|
||||||
messages.map((msg) => {
|
messages.map((msg) => {
|
||||||
const isUser = isUserMessage(msg);
|
const isUser = isUserMessage(msg);
|
||||||
const isOperator = isOperatorMessage(msg);
|
|
||||||
const isDiscarded =
|
const isDiscarded =
|
||||||
msg.status === 'discarded' ||
|
msg.status === 'discarded' ||
|
||||||
msg.pipeline_id === PIPELINE_DISCARD;
|
msg.pipeline_id === PIPELINE_DISCARD;
|
||||||
@@ -695,9 +497,7 @@ const BotSessionMonitor = forwardRef<
|
|||||||
'max-w-3xl px-4 py-2.5 rounded-2xl text-sm',
|
'max-w-3xl px-4 py-2.5 rounded-2xl text-sm',
|
||||||
isUser
|
isUser
|
||||||
? 'bg-primary/10 rounded-br-sm'
|
? 'bg-primary/10 rounded-br-sm'
|
||||||
: isOperator
|
: 'bg-muted rounded-bl-sm',
|
||||||
? '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',
|
msg.status === 'error' && 'ring-1 ring-red-400/50',
|
||||||
isDiscarded && 'opacity-60',
|
isDiscarded && 'opacity-60',
|
||||||
)}
|
)}
|
||||||
@@ -709,13 +509,14 @@ const BotSessionMonitor = forwardRef<
|
|||||||
'text-[11px] mt-1.5 flex items-center gap-1.5 text-muted-foreground',
|
'text-[11px] mt-1.5 flex items-center gap-1.5 text-muted-foreground',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span
|
<span>
|
||||||
className={cn(
|
{isUser
|
||||||
isOperator &&
|
? t('bots.sessionMonitor.userMessage', {
|
||||||
'text-orange-600 dark:text-orange-400 font-medium',
|
defaultValue: 'User',
|
||||||
)}
|
})
|
||||||
>
|
: t('bots.sessionMonitor.botMessage', {
|
||||||
{getMessageRoleLabel(msg)}
|
defaultValue: 'Assistant',
|
||||||
|
})}
|
||||||
</span>
|
</span>
|
||||||
<span className="tabular-nums">
|
<span className="tabular-nums">
|
||||||
{formatTime(msg.timestamp)}
|
{formatTime(msg.timestamp)}
|
||||||
@@ -727,21 +528,12 @@ const BotSessionMonitor = forwardRef<
|
|||||||
defaultValue: 'Discarded',
|
defaultValue: 'Discarded',
|
||||||
})}
|
})}
|
||||||
</span>
|
</span>
|
||||||
) : msg.pipeline_name &&
|
) : msg.pipeline_name ? (
|
||||||
msg.pipeline_name !== 'Human Takeover' ? (
|
|
||||||
<span className="inline-flex items-center gap-0.5 opacity-70">
|
<span className="inline-flex items-center gap-0.5 opacity-70">
|
||||||
<Workflow className="w-3 h-3" />
|
<Workflow className="w-3 h-3" />
|
||||||
{msg.pipeline_name}
|
{msg.pipeline_name}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : 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' && (
|
{msg.status === 'error' && (
|
||||||
<span className="text-red-500">error</span>
|
<span className="text-red-500">error</span>
|
||||||
)}
|
)}
|
||||||
@@ -759,33 +551,6 @@ const BotSessionMonitor = forwardRef<
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</ScrollArea>
|
</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>
|
</div>
|
||||||
|
|||||||
@@ -719,7 +719,7 @@ function NavItems({
|
|||||||
<DropdownMenuTrigger asChild>
|
<DropdownMenuTrigger asChild>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
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"
|
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"
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<Plus className="size-3.5" />
|
<Plus className="size-3.5" />
|
||||||
@@ -762,7 +762,7 @@ function NavItems({
|
|||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
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"
|
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"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
navigate(`${routePrefix}?id=new`);
|
navigate(`${routePrefix}?id=new`);
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import React, { useCallback, useEffect, useState } from 'react';
|
import React, { useCallback, useEffect, useState } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
@@ -219,6 +220,12 @@ export default function FileUploadZone({
|
|||||||
<p className="text-sm text-yellow-800 dark:text-yellow-200">
|
<p className="text-sm text-yellow-800 dark:text-yellow-200">
|
||||||
{t('knowledge.documentsTab.noParserAvailable')}
|
{t('knowledge.documentsTab.noParserAvailable')}
|
||||||
</p>
|
</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>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||||
import { httpClient } from '@/app/infra/http';
|
import { httpClient } from '@/app/infra/http';
|
||||||
import { FeedbackRecord, FeedbackStats } from '../types/monitoring';
|
import { FeedbackRecord, FeedbackStats } from '../types/monitoring';
|
||||||
|
import { parseUTCTimestamp } from '../utils/dateUtils';
|
||||||
|
|
||||||
interface UseFeedbackDataParams {
|
interface UseFeedbackDataParams {
|
||||||
botIds?: string[];
|
botIds?: string[];
|
||||||
@@ -142,7 +143,7 @@ export function useFeedbackData(params: UseFeedbackDataParams = {}) {
|
|||||||
const transformedFeedback: FeedbackRecord[] = result.feedback.map(
|
const transformedFeedback: FeedbackRecord[] = result.feedback.map(
|
||||||
(item) => ({
|
(item) => ({
|
||||||
id: item.id,
|
id: item.id,
|
||||||
timestamp: new Date(item.timestamp),
|
timestamp: parseUTCTimestamp(item.timestamp),
|
||||||
feedbackId: item.feedback_id,
|
feedbackId: item.feedback_id,
|
||||||
feedbackType: item.feedback_type === 1 ? 'like' : 'dislike',
|
feedbackType: item.feedback_type === 1 ? 'like' : 'dislike',
|
||||||
feedbackContent: item.feedback_content,
|
feedbackContent: item.feedback_content,
|
||||||
|
|||||||
+14
-9
@@ -405,7 +405,10 @@ export default function PluginInstallProgressDialog() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={(o) => !o && handleClose()}>
|
<Dialog open={open} onOpenChange={(o) => !o && handleClose()}>
|
||||||
<DialogContent className="w-[460px] max-h-[80vh] p-6 bg-white dark:bg-[#1a1a1e] overflow-y-auto">
|
<DialogContent
|
||||||
|
className="w-[460px] max-h-[80vh] p-6 bg-white dark:bg-[#1a1a1e] overflow-y-auto"
|
||||||
|
hideCloseButton
|
||||||
|
>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="flex items-center gap-3">
|
<DialogTitle className="flex items-center gap-3">
|
||||||
<Download className="size-5" />
|
<Download className="size-5" />
|
||||||
@@ -422,14 +425,16 @@ export default function PluginInstallProgressDialog() {
|
|||||||
{selectedTask && <TaskProgressContent task={selectedTask} />}
|
{selectedTask && <TaskProgressContent task={selectedTask} />}
|
||||||
|
|
||||||
<div className="flex justify-end gap-2 mt-2">
|
<div className="flex justify-end gap-2 mt-2">
|
||||||
{selectedTask &&
|
<Button
|
||||||
(selectedTask.stage === InstallStage.DONE ||
|
variant="default"
|
||||||
selectedTask.stage === InstallStage.ERROR) && (
|
size="sm"
|
||||||
<Button variant="outline" size="sm" onClick={handleDismiss}>
|
onClick={
|
||||||
{t('plugins.installProgress.dismiss')}
|
selectedTask?.stage === InstallStage.DONE ||
|
||||||
</Button>
|
selectedTask?.stage === InstallStage.ERROR
|
||||||
)}
|
? handleDismiss
|
||||||
<Button variant="default" size="sm" onClick={handleClose}>
|
: handleClose
|
||||||
|
}
|
||||||
|
>
|
||||||
{selectedTask?.stage === InstallStage.DONE ||
|
{selectedTask?.stage === InstallStage.DONE ||
|
||||||
selectedTask?.stage === InstallStage.ERROR
|
selectedTask?.stage === InstallStage.ERROR
|
||||||
? t('common.close')
|
? t('common.close')
|
||||||
|
|||||||
@@ -1036,92 +1036,6 @@ export class BackendClient extends BaseHttpClient {
|
|||||||
return this.get(`/api/v1/monitoring/overview?${queryParams.toString()}`);
|
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 ============
|
// ============ Survey API ============
|
||||||
public getSurveyPending(): Promise<{
|
public getSurveyPending(): Promise<{
|
||||||
survey: {
|
survey: {
|
||||||
|
|||||||
@@ -109,8 +109,11 @@ function DialogOverlay({
|
|||||||
function DialogContent({
|
function DialogContent({
|
||||||
className,
|
className,
|
||||||
children,
|
children,
|
||||||
|
hideCloseButton = false,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof DialogPrimitive.Content>) {
|
}: React.ComponentProps<typeof DialogPrimitive.Content> & {
|
||||||
|
hideCloseButton?: boolean;
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<DialogPortal data-slot="dialog-portal">
|
<DialogPortal data-slot="dialog-portal">
|
||||||
<DialogOverlay />
|
<DialogOverlay />
|
||||||
@@ -123,10 +126,12 @@ function DialogContent({
|
|||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
<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">
|
{!hideCloseButton && (
|
||||||
<XIcon />
|
<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">
|
||||||
<span className="sr-only">Close</span>
|
<XIcon />
|
||||||
</DialogPrimitive.Close>
|
<span className="sr-only">Close</span>
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
)}
|
||||||
</DialogPrimitive.Content>
|
</DialogPrimitive.Content>
|
||||||
</DialogPortal>
|
</DialogPortal>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -391,20 +391,6 @@ const enUS = {
|
|||||||
discarded: 'Discarded',
|
discarded: 'Discarded',
|
||||||
userMessage: 'User',
|
userMessage: 'User',
|
||||||
botMessage: 'Assistant',
|
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: {
|
plugins: {
|
||||||
@@ -886,6 +872,7 @@ const enUS = {
|
|||||||
builtInParser: 'Provided by Knowledge engine',
|
builtInParser: 'Provided by Knowledge engine',
|
||||||
noParserAvailable:
|
noParserAvailable:
|
||||||
'No parser supports this file type. Please install a parser plugin that can handle this format.',
|
'No parser supports this file type. Please install a parser plugin that can handle this format.',
|
||||||
|
installParserHint: 'Browse parser plugins in Marketplace →',
|
||||||
confirmUpload: 'Upload',
|
confirmUpload: 'Upload',
|
||||||
cancelUpload: 'Cancel',
|
cancelUpload: 'Cancel',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -401,20 +401,6 @@ const esES = {
|
|||||||
discarded: 'Descartado',
|
discarded: 'Descartado',
|
||||||
userMessage: 'Usuario',
|
userMessage: 'Usuario',
|
||||||
botMessage: 'Asistente',
|
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: {
|
plugins: {
|
||||||
|
|||||||
@@ -392,20 +392,6 @@
|
|||||||
discarded: '破棄済み',
|
discarded: '破棄済み',
|
||||||
userMessage: 'ユーザー',
|
userMessage: 'ユーザー',
|
||||||
botMessage: 'アシスタント',
|
botMessage: 'アシスタント',
|
||||||
operatorMessage: 'オペレーター',
|
|
||||||
humanTakeover: '有人対応',
|
|
||||||
takeoverBtn: '引き継ぐ',
|
|
||||||
releaseBtn: '解除',
|
|
||||||
takeoverConfirm: 'このセッションを引き継ぎますか?解除するまでAIボットは応答を停止します。',
|
|
||||||
releaseConfirm: 'このセッションを解除しますか?AIボットが応答を再開します。',
|
|
||||||
takeoverSuccess: 'セッションの引き継ぎに成功しました',
|
|
||||||
releaseSuccess: 'セッションの解除に成功しました',
|
|
||||||
takeoverFailed: 'セッションの引き継ぎに失敗しました',
|
|
||||||
releaseFailed: 'セッションの解除に失敗しました',
|
|
||||||
sendMessage: 'オペレーターとしてメッセージを送信...',
|
|
||||||
sendBtn: '送信',
|
|
||||||
sendFailed: 'メッセージの送信に失敗しました',
|
|
||||||
takenOver: '引き継ぎ中',
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
plugins: {
|
plugins: {
|
||||||
|
|||||||
@@ -386,20 +386,6 @@ const thTH = {
|
|||||||
discarded: 'ถูกละทิ้ง',
|
discarded: 'ถูกละทิ้ง',
|
||||||
userMessage: 'ผู้ใช้',
|
userMessage: 'ผู้ใช้',
|
||||||
botMessage: 'ผู้ช่วย',
|
botMessage: 'ผู้ช่วย',
|
||||||
operatorMessage: 'เจ้าหน้าที่',
|
|
||||||
humanTakeover: 'เจ้าหน้าที่รับช่วง',
|
|
||||||
takeoverBtn: 'รับช่วง',
|
|
||||||
releaseBtn: 'ปล่อย',
|
|
||||||
takeoverConfirm: 'รับช่วงเซสชันนี้หรือไม่? บอท AI จะหยุดตอบจนกว่าจะปล่อย',
|
|
||||||
releaseConfirm: 'ปล่อยเซสชันนี้หรือไม่? บอท AI จะกลับมาตอบอีกครั้ง',
|
|
||||||
takeoverSuccess: 'รับช่วงเซสชันสำเร็จ',
|
|
||||||
releaseSuccess: 'ปล่อยเซสชันสำเร็จ',
|
|
||||||
takeoverFailed: 'รับช่วงเซสชันล้มเหลว',
|
|
||||||
releaseFailed: 'ปล่อยเซสชันล้มเหลว',
|
|
||||||
sendMessage: 'ส่งข้อความในฐานะเจ้าหน้าที่...',
|
|
||||||
sendBtn: 'ส่ง',
|
|
||||||
sendFailed: 'ส่งข้อความล้มเหลว',
|
|
||||||
takenOver: 'ถูกรับช่วงแล้ว',
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
plugins: {
|
plugins: {
|
||||||
|
|||||||
@@ -395,20 +395,6 @@ const viVN = {
|
|||||||
discarded: 'Đã loại bỏ',
|
discarded: 'Đã loại bỏ',
|
||||||
userMessage: 'Người dùng',
|
userMessage: 'Người dùng',
|
||||||
botMessage: 'Trợ lý',
|
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: {
|
plugins: {
|
||||||
|
|||||||
@@ -376,20 +376,6 @@ const zhHans = {
|
|||||||
discarded: '已丢弃',
|
discarded: '已丢弃',
|
||||||
userMessage: '用户',
|
userMessage: '用户',
|
||||||
botMessage: '助手',
|
botMessage: '助手',
|
||||||
operatorMessage: '人工客服',
|
|
||||||
humanTakeover: '人工接管',
|
|
||||||
takeoverBtn: '接管',
|
|
||||||
releaseBtn: '释放',
|
|
||||||
takeoverConfirm: '确定接管此会话?AI 机器人将停止回复,直到释放。',
|
|
||||||
releaseConfirm: '确定释放此会话?AI 机器人将恢复回复。',
|
|
||||||
takeoverSuccess: '会话接管成功',
|
|
||||||
releaseSuccess: '会话释放成功',
|
|
||||||
takeoverFailed: '会话接管失败',
|
|
||||||
releaseFailed: '会话释放失败',
|
|
||||||
sendMessage: '以人工客服身份发送消息...',
|
|
||||||
sendBtn: '发送',
|
|
||||||
sendFailed: '消息发送失败',
|
|
||||||
takenOver: '已接管',
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
plugins: {
|
plugins: {
|
||||||
@@ -846,6 +832,7 @@ const zhHans = {
|
|||||||
builtInParser: '由知识引擎提供',
|
builtInParser: '由知识引擎提供',
|
||||||
noParserAvailable:
|
noParserAvailable:
|
||||||
'没有解析器支持此文件类型,请安装支持该格式的解析器插件。',
|
'没有解析器支持此文件类型,请安装支持该格式的解析器插件。',
|
||||||
|
installParserHint: '前往插件市场安装解析器 →',
|
||||||
confirmUpload: '上传',
|
confirmUpload: '上传',
|
||||||
cancelUpload: '取消',
|
cancelUpload: '取消',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -371,20 +371,6 @@ const zhHant = {
|
|||||||
discarded: '已丟棄',
|
discarded: '已丟棄',
|
||||||
userMessage: '使用者',
|
userMessage: '使用者',
|
||||||
botMessage: '助手',
|
botMessage: '助手',
|
||||||
operatorMessage: '人工客服',
|
|
||||||
humanTakeover: '人工接管',
|
|
||||||
takeoverBtn: '接管',
|
|
||||||
releaseBtn: '釋放',
|
|
||||||
takeoverConfirm: '確定接管此會話?AI 機器人將停止回覆,直到釋放。',
|
|
||||||
releaseConfirm: '確定釋放此會話?AI 機器人將恢復回覆。',
|
|
||||||
takeoverSuccess: '會話接管成功',
|
|
||||||
releaseSuccess: '會話釋放成功',
|
|
||||||
takeoverFailed: '會話接管失敗',
|
|
||||||
releaseFailed: '會話釋放失敗',
|
|
||||||
sendMessage: '以人工客服身份發送訊息...',
|
|
||||||
sendBtn: '發送',
|
|
||||||
sendFailed: '訊息發送失敗',
|
|
||||||
takenOver: '已接管',
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
plugins: {
|
plugins: {
|
||||||
@@ -839,6 +825,7 @@ const zhHant = {
|
|||||||
builtInParser: '由知識引擎提供',
|
builtInParser: '由知識引擎提供',
|
||||||
noParserAvailable:
|
noParserAvailable:
|
||||||
'沒有解析器支援此檔案類型,請安裝支援該格式的解析器插件。',
|
'沒有解析器支援此檔案類型,請安裝支援該格式的解析器插件。',
|
||||||
|
installParserHint: '前往插件市場安裝解析器 →',
|
||||||
confirmUpload: '上傳',
|
confirmUpload: '上傳',
|
||||||
cancelUpload: '取消',
|
cancelUpload: '取消',
|
||||||
},
|
},
|
||||||
|
|||||||
+1
-4
@@ -17,9 +17,6 @@
|
|||||||
"@/*": ["./src/*"]
|
"@/*": ["./src/*"]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": [
|
"include": ["**/*.ts", "**/*.tsx"],
|
||||||
"**/*.ts",
|
|
||||||
"**/*.tsx"
|
|
||||||
],
|
|
||||||
"exclude": ["node_modules"]
|
"exclude": ["node_modules"]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user