mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-07 14:26:03 +00:00
Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6896a55485 | ||
|
|
4b0fad233e | ||
|
|
52eb991a70 | ||
|
|
10c716be0c | ||
|
|
6e77351eda | ||
|
|
20f5ebd9b8 | ||
|
|
d2c75329cf | ||
|
|
7e2fe082f0 | ||
|
|
d451b059fd | ||
|
|
93c52fcd4c | ||
|
|
f1608682e6 | ||
|
|
077e631c13 | ||
|
|
d7df1f05d1 | ||
|
|
8b8cfb76de | ||
|
|
79311ccde3 | ||
|
|
89064a9d5b | ||
|
|
8c2aef3734 | ||
|
|
3fb9e542b6 | ||
|
|
01844d8687 | ||
|
|
2655425fbe | ||
|
|
bd15b630b0 | ||
|
|
fe5ce68436 | ||
|
|
0541b05966 | ||
|
|
13cb0aa9be | ||
|
|
a048369b38 |
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "langbot"
|
name = "langbot"
|
||||||
version = "4.9.0"
|
version = "4.9.2"
|
||||||
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"]
|
||||||
@@ -61,10 +61,10 @@ dependencies = [
|
|||||||
"html2text>=2024.2.26",
|
"html2text>=2024.2.26",
|
||||||
"langchain>=0.2.0",
|
"langchain>=0.2.0",
|
||||||
"langchain-text-splitters>=0.0.1",
|
"langchain-text-splitters>=0.0.1",
|
||||||
"chromadb>=0.4.24",
|
"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.0",
|
"langbot-plugin==0.3.1",
|
||||||
"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",
|
||||||
|
|||||||
@@ -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.0'
|
__version__ = '4.9.2'
|
||||||
|
|||||||
@@ -199,6 +199,253 @@ class StreamSessionManager:
|
|||||||
self._msg_index.pop(msg_id, None)
|
self._msg_index.pop(msg_id, None)
|
||||||
|
|
||||||
|
|
||||||
|
async def download_encrypted_file(download_url: str, encoding_aes_key: str, logger: EventLogger) -> Optional[str]:
|
||||||
|
"""Download an AES-encrypted file from WeChat Work and return as data URI.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
download_url: The encrypted file download URL.
|
||||||
|
encoding_aes_key: The AES key used for decryption (base64-encoded, without trailing '=').
|
||||||
|
logger: Logger instance.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A data URI string (e.g. 'data:image/jpeg;base64,...') or None on failure.
|
||||||
|
"""
|
||||||
|
if not download_url:
|
||||||
|
return None
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
response = await client.get(download_url)
|
||||||
|
if response.status_code != 200:
|
||||||
|
await logger.error(f'failed to get file: {response.text}')
|
||||||
|
return None
|
||||||
|
encrypted_bytes = response.content
|
||||||
|
|
||||||
|
aes_key = base64.b64decode(encoding_aes_key + '=')
|
||||||
|
iv = aes_key[:16]
|
||||||
|
|
||||||
|
cipher = AES.new(aes_key, AES.MODE_CBC, iv)
|
||||||
|
decrypted = cipher.decrypt(encrypted_bytes)
|
||||||
|
|
||||||
|
pad_len = decrypted[-1]
|
||||||
|
decrypted = decrypted[:-pad_len]
|
||||||
|
|
||||||
|
if decrypted.startswith(b'\xff\xd8'):
|
||||||
|
mime_type = 'image/jpeg'
|
||||||
|
elif decrypted.startswith(b'\x89PNG'):
|
||||||
|
mime_type = 'image/png'
|
||||||
|
elif decrypted.startswith((b'GIF87a', b'GIF89a')):
|
||||||
|
mime_type = 'image/gif'
|
||||||
|
elif decrypted.startswith(b'BM'):
|
||||||
|
mime_type = 'image/bmp'
|
||||||
|
elif decrypted.startswith(b'II*\x00') or decrypted.startswith(b'MM\x00*'):
|
||||||
|
mime_type = 'image/tiff'
|
||||||
|
else:
|
||||||
|
mime_type = 'application/octet-stream'
|
||||||
|
|
||||||
|
base64_str = base64.b64encode(decrypted).decode('utf-8')
|
||||||
|
return f'data:{mime_type};base64,{base64_str}'
|
||||||
|
|
||||||
|
|
||||||
|
async def parse_wecom_bot_message(
|
||||||
|
msg_json: dict[str, Any], encoding_aes_key: str, logger: EventLogger
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Parse a decrypted WeChat Work AI Bot message JSON into a unified message dict.
|
||||||
|
|
||||||
|
This is the shared message parsing logic used by both webhook and WebSocket modes.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
msg_json: The decrypted message JSON from WeChat Work.
|
||||||
|
encoding_aes_key: AES key for file decryption.
|
||||||
|
logger: Logger instance.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A dict suitable for constructing a WecomBotEvent.
|
||||||
|
"""
|
||||||
|
message_data: dict[str, Any] = {}
|
||||||
|
|
||||||
|
msg_type = msg_json.get('msgtype', '')
|
||||||
|
if msg_type:
|
||||||
|
message_data['msgtype'] = msg_type
|
||||||
|
|
||||||
|
if msg_json.get('chattype', '') == 'single':
|
||||||
|
message_data['type'] = 'single'
|
||||||
|
elif msg_json.get('chattype', '') == 'group':
|
||||||
|
message_data['type'] = 'group'
|
||||||
|
|
||||||
|
max_inline_file_size = 5 * 1024 * 1024
|
||||||
|
|
||||||
|
async def _safe_download(url: str):
|
||||||
|
if not url:
|
||||||
|
return None
|
||||||
|
return await download_encrypted_file(url, encoding_aes_key, logger)
|
||||||
|
|
||||||
|
if msg_type == 'text':
|
||||||
|
message_data['content'] = msg_json.get('text', {}).get('content')
|
||||||
|
elif msg_type == 'markdown':
|
||||||
|
message_data['content'] = msg_json.get('markdown', {}).get('content') or msg_json.get('text', {}).get(
|
||||||
|
'content', ''
|
||||||
|
)
|
||||||
|
elif msg_type == 'image':
|
||||||
|
picurl = msg_json.get('image', {}).get('url', '')
|
||||||
|
base64_data = await _safe_download(picurl)
|
||||||
|
if base64_data:
|
||||||
|
message_data['picurl'] = base64_data
|
||||||
|
message_data['images'] = [base64_data]
|
||||||
|
elif msg_type == 'voice':
|
||||||
|
voice_info = msg_json.get('voice', {}) or {}
|
||||||
|
download_url = voice_info.get('url')
|
||||||
|
message_data['voice'] = {
|
||||||
|
'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'):
|
||||||
|
message_data['content'] = voice_info.get('content')
|
||||||
|
if (message_data['voice'].get('filesize') or 0) <= max_inline_file_size:
|
||||||
|
voice_base64 = await _safe_download(download_url)
|
||||||
|
if voice_base64:
|
||||||
|
message_data['voice']['base64'] = voice_base64
|
||||||
|
elif msg_type == 'video':
|
||||||
|
video_info = msg_json.get('video', {}) or {}
|
||||||
|
download_url = video_info.get('url')
|
||||||
|
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'),
|
||||||
|
}
|
||||||
|
if (video_data.get('filesize') or 0) <= max_inline_file_size:
|
||||||
|
video_base64 = await _safe_download(download_url)
|
||||||
|
if video_base64:
|
||||||
|
video_data['base64'] = video_base64
|
||||||
|
message_data['video'] = video_data
|
||||||
|
elif msg_type == 'file':
|
||||||
|
file_info = msg_json.get('file', {}) or {}
|
||||||
|
download_url = file_info.get('url') or file_info.get('fileurl')
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
if (file_data.get('filesize') or 0) <= max_inline_file_size:
|
||||||
|
file_base64 = await _safe_download(download_url)
|
||||||
|
if file_base64:
|
||||||
|
file_data['base64'] = file_base64
|
||||||
|
message_data['file'] = file_data
|
||||||
|
elif msg_type == 'link':
|
||||||
|
message_data['link'] = msg_json.get('link', {})
|
||||||
|
if not message_data.get('content'):
|
||||||
|
title = message_data['link'].get('title', '')
|
||||||
|
desc = message_data['link'].get('description') or message_data['link'].get('digest', '')
|
||||||
|
message_data['content'] = '\n'.join(filter(None, [title, desc]))
|
||||||
|
elif msg_type == 'mixed':
|
||||||
|
items = msg_json.get('mixed', {}).get('msg_item', [])
|
||||||
|
texts = []
|
||||||
|
images = []
|
||||||
|
files = []
|
||||||
|
voices = []
|
||||||
|
videos = []
|
||||||
|
links = []
|
||||||
|
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_url = item.get('image', {}).get('url')
|
||||||
|
base64_data = await _safe_download(img_url)
|
||||||
|
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')
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
if (file_data.get('filesize') or 0) <= max_inline_file_size:
|
||||||
|
file_base64 = await _safe_download(download_url)
|
||||||
|
if file_base64:
|
||||||
|
file_data['base64'] = file_base64
|
||||||
|
files.append(file_data)
|
||||||
|
elif item_type == 'voice':
|
||||||
|
voice_info = item.get('voice', {}) or {}
|
||||||
|
download_url = voice_info.get('url')
|
||||||
|
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'):
|
||||||
|
texts.append(voice_info.get('content'))
|
||||||
|
if (voice_data.get('filesize') or 0) <= max_inline_file_size:
|
||||||
|
voice_base64 = await _safe_download(download_url)
|
||||||
|
if voice_base64:
|
||||||
|
voice_data['base64'] = voice_base64
|
||||||
|
voices.append(voice_data)
|
||||||
|
elif item_type == 'video':
|
||||||
|
video_info = item.get('video', {}) or {}
|
||||||
|
download_url = video_info.get('url')
|
||||||
|
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'),
|
||||||
|
}
|
||||||
|
if (video_data.get('filesize') or 0) <= max_inline_file_size:
|
||||||
|
video_base64 = await _safe_download(download_url)
|
||||||
|
if video_base64:
|
||||||
|
video_data['base64'] = video_base64
|
||||||
|
videos.append(video_data)
|
||||||
|
elif item_type == 'link':
|
||||||
|
links.append(item.get('link', {}))
|
||||||
|
|
||||||
|
if texts:
|
||||||
|
message_data['content'] = ' '.join(texts)
|
||||||
|
if images:
|
||||||
|
message_data['images'] = images
|
||||||
|
message_data['picurl'] = images[0]
|
||||||
|
if files:
|
||||||
|
message_data['files'] = files
|
||||||
|
message_data['file'] = files[0]
|
||||||
|
if voices:
|
||||||
|
message_data['voices'] = voices
|
||||||
|
message_data['voice'] = voices[0]
|
||||||
|
if videos:
|
||||||
|
message_data['videos'] = videos
|
||||||
|
message_data['video'] = videos[0]
|
||||||
|
if links:
|
||||||
|
message_data['link'] = links[0]
|
||||||
|
if items:
|
||||||
|
message_data['attachments'] = items
|
||||||
|
else:
|
||||||
|
message_data['raw_msg'] = msg_json
|
||||||
|
|
||||||
|
from_info = msg_json.get('from', {})
|
||||||
|
message_data['userid'] = from_info.get('userid', '')
|
||||||
|
message_data['username'] = from_info.get('alias', '') or from_info.get('name', '') or from_info.get('userid', '')
|
||||||
|
|
||||||
|
if msg_json.get('chattype', '') == 'group':
|
||||||
|
message_data['chatid'] = msg_json.get('chatid', '')
|
||||||
|
message_data['chatname'] = msg_json.get('chatname', '') or msg_json.get('chatid', '')
|
||||||
|
|
||||||
|
message_data['msgid'] = msg_json.get('msgid', '')
|
||||||
|
|
||||||
|
if msg_json.get('aibotid'):
|
||||||
|
message_data['aibotid'] = msg_json.get('aibotid', '')
|
||||||
|
|
||||||
|
return message_data
|
||||||
|
|
||||||
|
|
||||||
class WecomBotClient:
|
class WecomBotClient:
|
||||||
def __init__(self, Token: str, EnCodingAESKey: str, Corpid: str, logger: EventLogger, unified_mode: bool = False):
|
def __init__(self, Token: str, EnCodingAESKey: str, Corpid: str, logger: EventLogger, unified_mode: bool = False):
|
||||||
"""企业微信智能机器人客户端。
|
"""企业微信智能机器人客户端。
|
||||||
@@ -455,196 +702,7 @@ class WecomBotClient:
|
|||||||
return await self._handle_post_initial_response(msg_json, nonce)
|
return await self._handle_post_initial_response(msg_json, nonce)
|
||||||
|
|
||||||
async def get_message(self, msg_json):
|
async def get_message(self, msg_json):
|
||||||
message_data = {}
|
return await parse_wecom_bot_message(msg_json, self.EnCodingAESKey, self.logger)
|
||||||
|
|
||||||
msg_type = msg_json.get('msgtype', '')
|
|
||||||
if msg_type:
|
|
||||||
message_data['msgtype'] = msg_type
|
|
||||||
|
|
||||||
if msg_json.get('chattype', '') == 'single':
|
|
||||||
message_data['type'] = 'single'
|
|
||||||
elif msg_json.get('chattype', '') == 'group':
|
|
||||||
message_data['type'] = 'group'
|
|
||||||
|
|
||||||
max_inline_file_size = 5 * 1024 * 1024 # avoid decoding very large payloads by default
|
|
||||||
|
|
||||||
async def _safe_download(url: str):
|
|
||||||
if not url:
|
|
||||||
return None
|
|
||||||
return await self.download_url_to_base64(url, self.EnCodingAESKey)
|
|
||||||
|
|
||||||
if msg_type == 'text':
|
|
||||||
message_data['content'] = msg_json.get('text', {}).get('content')
|
|
||||||
elif msg_type == 'markdown':
|
|
||||||
message_data['content'] = msg_json.get('markdown', {}).get('content') or msg_json.get('text', {}).get(
|
|
||||||
'content', ''
|
|
||||||
)
|
|
||||||
elif msg_type == 'image':
|
|
||||||
picurl = msg_json.get('image', {}).get('url', '')
|
|
||||||
base64_data = await _safe_download(picurl)
|
|
||||||
if base64_data:
|
|
||||||
message_data['picurl'] = base64_data
|
|
||||||
message_data['images'] = [base64_data]
|
|
||||||
elif msg_type == 'voice':
|
|
||||||
voice_info = msg_json.get('voice', {}) or {}
|
|
||||||
download_url = voice_info.get('url')
|
|
||||||
message_data['voice'] = {
|
|
||||||
'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'):
|
|
||||||
message_data['content'] = voice_info.get('content')
|
|
||||||
if (message_data['voice'].get('filesize') or 0) <= max_inline_file_size:
|
|
||||||
voice_base64 = await _safe_download(download_url)
|
|
||||||
if voice_base64:
|
|
||||||
message_data['voice']['base64'] = voice_base64
|
|
||||||
elif msg_type == 'video':
|
|
||||||
video_info = msg_json.get('video', {}) or {}
|
|
||||||
download_url = video_info.get('url')
|
|
||||||
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'),
|
|
||||||
}
|
|
||||||
if (video_data.get('filesize') or 0) <= max_inline_file_size:
|
|
||||||
video_base64 = await _safe_download(download_url)
|
|
||||||
if video_base64:
|
|
||||||
video_data['base64'] = video_base64
|
|
||||||
message_data['video'] = video_data
|
|
||||||
elif msg_type == 'file':
|
|
||||||
file_info = msg_json.get('file', {}) or {}
|
|
||||||
download_url = file_info.get('url') or file_info.get('fileurl')
|
|
||||||
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,
|
|
||||||
}
|
|
||||||
if (file_data.get('filesize') or 0) <= max_inline_file_size:
|
|
||||||
file_base64 = await _safe_download(download_url)
|
|
||||||
if file_base64:
|
|
||||||
file_data['base64'] = file_base64
|
|
||||||
message_data['file'] = file_data
|
|
||||||
elif msg_type == 'link':
|
|
||||||
message_data['link'] = msg_json.get('link', {})
|
|
||||||
if not message_data.get('content'):
|
|
||||||
title = message_data['link'].get('title', '')
|
|
||||||
desc = message_data['link'].get('description') or message_data['link'].get('digest', '')
|
|
||||||
message_data['content'] = '\n'.join(filter(None, [title, desc]))
|
|
||||||
elif msg_type == 'mixed':
|
|
||||||
items = msg_json.get('mixed', {}).get('msg_item', [])
|
|
||||||
texts = []
|
|
||||||
images = []
|
|
||||||
files = []
|
|
||||||
voices = []
|
|
||||||
videos = []
|
|
||||||
links = []
|
|
||||||
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_url = item.get('image', {}).get('url')
|
|
||||||
base64_data = await _safe_download(img_url)
|
|
||||||
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')
|
|
||||||
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,
|
|
||||||
}
|
|
||||||
if (file_data.get('filesize') or 0) <= max_inline_file_size:
|
|
||||||
file_base64 = await _safe_download(download_url)
|
|
||||||
if file_base64:
|
|
||||||
file_data['base64'] = file_base64
|
|
||||||
files.append(file_data)
|
|
||||||
elif item_type == 'voice':
|
|
||||||
voice_info = item.get('voice', {}) or {}
|
|
||||||
download_url = voice_info.get('url')
|
|
||||||
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'):
|
|
||||||
texts.append(voice_info.get('content'))
|
|
||||||
if (voice_data.get('filesize') or 0) <= max_inline_file_size:
|
|
||||||
voice_base64 = await _safe_download(download_url)
|
|
||||||
if voice_base64:
|
|
||||||
voice_data['base64'] = voice_base64
|
|
||||||
voices.append(voice_data)
|
|
||||||
elif item_type == 'video':
|
|
||||||
video_info = item.get('video', {}) or {}
|
|
||||||
download_url = video_info.get('url')
|
|
||||||
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'),
|
|
||||||
}
|
|
||||||
if (video_data.get('filesize') or 0) <= max_inline_file_size:
|
|
||||||
video_base64 = await _safe_download(download_url)
|
|
||||||
if video_base64:
|
|
||||||
video_data['base64'] = video_base64
|
|
||||||
videos.append(video_data)
|
|
||||||
elif item_type == 'link':
|
|
||||||
links.append(item.get('link', {}))
|
|
||||||
|
|
||||||
if texts:
|
|
||||||
message_data['content'] = ' '.join(texts) # 拼接所有 text
|
|
||||||
if images:
|
|
||||||
message_data['images'] = images
|
|
||||||
message_data['picurl'] = images[0] # 只保留第一个 image
|
|
||||||
if files:
|
|
||||||
message_data['files'] = files
|
|
||||||
message_data['file'] = files[0]
|
|
||||||
if voices:
|
|
||||||
message_data['voices'] = voices
|
|
||||||
message_data['voice'] = voices[0]
|
|
||||||
if videos:
|
|
||||||
message_data['videos'] = videos
|
|
||||||
message_data['video'] = videos[0]
|
|
||||||
if links:
|
|
||||||
message_data['link'] = links[0]
|
|
||||||
if items:
|
|
||||||
message_data['attachments'] = items
|
|
||||||
else:
|
|
||||||
message_data['raw_msg'] = msg_json
|
|
||||||
|
|
||||||
# Extract user information
|
|
||||||
from_info = msg_json.get('from', {})
|
|
||||||
message_data['userid'] = from_info.get('userid', '')
|
|
||||||
message_data['username'] = (
|
|
||||||
from_info.get('alias', '') or from_info.get('name', '') or from_info.get('userid', '')
|
|
||||||
)
|
|
||||||
|
|
||||||
# Extract chat/group information
|
|
||||||
if msg_json.get('chattype', '') == 'group':
|
|
||||||
message_data['chatid'] = msg_json.get('chatid', '')
|
|
||||||
# Try to get group name if available
|
|
||||||
message_data['chatname'] = msg_json.get('chatname', '') or msg_json.get('chatid', '')
|
|
||||||
|
|
||||||
message_data['msgid'] = msg_json.get('msgid', '')
|
|
||||||
|
|
||||||
if msg_json.get('aibotid'):
|
|
||||||
message_data['aibotid'] = msg_json.get('aibotid', '')
|
|
||||||
|
|
||||||
return message_data
|
|
||||||
|
|
||||||
async def _handle_message(self, event: wecombotevent.WecomBotEvent):
|
async def _handle_message(self, event: wecombotevent.WecomBotEvent):
|
||||||
"""
|
"""
|
||||||
@@ -712,39 +770,7 @@ class WecomBotClient:
|
|||||||
return decorator
|
return decorator
|
||||||
|
|
||||||
async def download_url_to_base64(self, download_url, encoding_aes_key):
|
async def download_url_to_base64(self, download_url, encoding_aes_key):
|
||||||
async with httpx.AsyncClient() as client:
|
return await download_encrypted_file(download_url, encoding_aes_key, self.logger)
|
||||||
response = await client.get(download_url)
|
|
||||||
if response.status_code != 200:
|
|
||||||
await self.logger.error(f'failed to get file: {response.text}')
|
|
||||||
return None
|
|
||||||
|
|
||||||
encrypted_bytes = response.content
|
|
||||||
|
|
||||||
aes_key = base64.b64decode(encoding_aes_key + '=') # base64 补齐
|
|
||||||
iv = aes_key[:16]
|
|
||||||
|
|
||||||
cipher = AES.new(aes_key, AES.MODE_CBC, iv)
|
|
||||||
decrypted = cipher.decrypt(encrypted_bytes)
|
|
||||||
|
|
||||||
pad_len = decrypted[-1]
|
|
||||||
decrypted = decrypted[:-pad_len]
|
|
||||||
|
|
||||||
if decrypted.startswith(b'\xff\xd8'): # JPEG
|
|
||||||
mime_type = 'image/jpeg'
|
|
||||||
elif decrypted.startswith(b'\x89PNG'): # PNG
|
|
||||||
mime_type = 'image/png'
|
|
||||||
elif decrypted.startswith((b'GIF87a', b'GIF89a')): # GIF
|
|
||||||
mime_type = 'image/gif'
|
|
||||||
elif decrypted.startswith(b'BM'): # BMP
|
|
||||||
mime_type = 'image/bmp'
|
|
||||||
elif decrypted.startswith(b'II*\x00') or decrypted.startswith(b'MM\x00*'): # TIFF
|
|
||||||
mime_type = 'image/tiff'
|
|
||||||
else:
|
|
||||||
mime_type = 'application/octet-stream'
|
|
||||||
|
|
||||||
# 转 base64
|
|
||||||
base64_str = base64.b64encode(decrypted).decode('utf-8')
|
|
||||||
return f'data:{mime_type};base64,{base64_str}'
|
|
||||||
|
|
||||||
async def run_task(self, host: str, port: int, *args, **kwargs):
|
async def run_task(self, host: str, port: int, *args, **kwargs):
|
||||||
"""
|
"""
|
||||||
|
|||||||
596
src/langbot/libs/wecom_ai_bot_api/ws_client.py
Normal file
596
src/langbot/libs/wecom_ai_bot_api/ws_client.py
Normal file
@@ -0,0 +1,596 @@
|
|||||||
|
"""WeChat Work AI Bot WebSocket long connection client.
|
||||||
|
|
||||||
|
Implements the WebSocket protocol for receiving messages and sending replies
|
||||||
|
via a persistent connection to wss://openws.work.weixin.qq.com, as an
|
||||||
|
alternative to the HTTP callback (webhook) mode.
|
||||||
|
|
||||||
|
Protocol reference: https://developer.work.weixin.qq.com/document/path/101463
|
||||||
|
Official Node.js SDK: https://github.com/WecomTeam/aibot-node-sdk
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
import secrets
|
||||||
|
import time
|
||||||
|
import traceback
|
||||||
|
from typing import Any, Callable, Optional
|
||||||
|
|
||||||
|
import aiohttp
|
||||||
|
|
||||||
|
from langbot.libs.wecom_ai_bot_api import wecombotevent
|
||||||
|
from langbot.libs.wecom_ai_bot_api.api import parse_wecom_bot_message
|
||||||
|
from langbot.pkg.platform.logger import EventLogger
|
||||||
|
|
||||||
|
DEFAULT_WS_URL = 'wss://openws.work.weixin.qq.com'
|
||||||
|
|
||||||
|
# WebSocket frame command constants
|
||||||
|
CMD_SUBSCRIBE = 'aibot_subscribe'
|
||||||
|
CMD_HEARTBEAT = 'ping'
|
||||||
|
CMD_MSG_CALLBACK = 'aibot_msg_callback'
|
||||||
|
CMD_EVENT_CALLBACK = 'aibot_event_callback'
|
||||||
|
CMD_RESPOND_MSG = 'aibot_respond_msg'
|
||||||
|
CMD_RESPOND_WELCOME = 'aibot_respond_welcome_msg'
|
||||||
|
CMD_RESPOND_UPDATE = 'aibot_respond_update_msg'
|
||||||
|
CMD_SEND_MSG = 'aibot_send_msg'
|
||||||
|
|
||||||
|
|
||||||
|
def _generate_req_id(prefix: str) -> str:
|
||||||
|
"""Generate a unique request ID in the format: {prefix}_{timestamp}_{random}."""
|
||||||
|
ts = int(time.time() * 1000)
|
||||||
|
rand = secrets.token_hex(4)
|
||||||
|
return f'{prefix}_{ts}_{rand}'
|
||||||
|
|
||||||
|
|
||||||
|
class WecomBotWsClient:
|
||||||
|
"""WeChat Work AI Bot WebSocket long connection client.
|
||||||
|
|
||||||
|
Provides message receiving, streaming reply, proactive message sending,
|
||||||
|
and event callback handling over a persistent WebSocket connection.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
bot_id: str,
|
||||||
|
secret: str,
|
||||||
|
logger: EventLogger,
|
||||||
|
encoding_aes_key: str = '',
|
||||||
|
ws_url: str = DEFAULT_WS_URL,
|
||||||
|
heartbeat_interval: float = 30.0,
|
||||||
|
max_reconnect_attempts: int = -1,
|
||||||
|
reconnect_base_delay: float = 1.0,
|
||||||
|
reconnect_max_delay: float = 30.0,
|
||||||
|
):
|
||||||
|
self.bot_id = bot_id
|
||||||
|
self.secret = secret
|
||||||
|
self.logger = logger
|
||||||
|
self.encoding_aes_key = encoding_aes_key
|
||||||
|
self.ws_url = ws_url
|
||||||
|
self.heartbeat_interval = heartbeat_interval
|
||||||
|
self.max_reconnect_attempts = max_reconnect_attempts
|
||||||
|
self.reconnect_base_delay = reconnect_base_delay
|
||||||
|
self.reconnect_max_delay = reconnect_max_delay
|
||||||
|
|
||||||
|
self._ws: Optional[aiohttp.ClientWebSocketResponse] = None
|
||||||
|
self._session: Optional[aiohttp.ClientSession] = None
|
||||||
|
self._running = False
|
||||||
|
self._heartbeat_task: Optional[asyncio.Task] = None
|
||||||
|
self._missed_pong_count = 0
|
||||||
|
self._max_missed_pong = 2
|
||||||
|
self._reconnect_attempts = 0
|
||||||
|
|
||||||
|
# Message handler registry (same pattern as WecomBotClient)
|
||||||
|
self._message_handlers: dict[str, list[Callable]] = {}
|
||||||
|
# Message deduplication
|
||||||
|
self._msg_id_map: dict[str, int] = {}
|
||||||
|
|
||||||
|
# Pending ACK futures: req_id -> Future[dict]
|
||||||
|
self._pending_acks: dict[str, asyncio.Future] = {}
|
||||||
|
# Per-req_id serial reply queues
|
||||||
|
self._reply_queues: dict[str, asyncio.Queue] = {}
|
||||||
|
self._reply_workers: dict[str, asyncio.Task] = {}
|
||||||
|
self._reply_ack_timeout = 5.0
|
||||||
|
|
||||||
|
# Stream ID tracking for WebSocket mode
|
||||||
|
self._stream_ids: dict[str, str] = {} # msg_id -> req_id|stream_id
|
||||||
|
# Dedup: skip sending when content hasn't changed
|
||||||
|
self._stream_last_content: dict[str, str] = {} # msg_id -> last content sent
|
||||||
|
|
||||||
|
# ── Public API ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def connect(self):
|
||||||
|
"""Connect to WebSocket server with automatic reconnection.
|
||||||
|
|
||||||
|
This method blocks until disconnect() is called or max reconnect
|
||||||
|
attempts are exhausted.
|
||||||
|
"""
|
||||||
|
self._running = True
|
||||||
|
self._reconnect_attempts = 0
|
||||||
|
|
||||||
|
while self._running:
|
||||||
|
try:
|
||||||
|
await self._connect_once()
|
||||||
|
except Exception:
|
||||||
|
if not self._running:
|
||||||
|
break
|
||||||
|
await self.logger.error(f'WebSocket connection error: {traceback.format_exc()}')
|
||||||
|
|
||||||
|
if not self._running:
|
||||||
|
break
|
||||||
|
|
||||||
|
# Reconnect with exponential backoff
|
||||||
|
if self.max_reconnect_attempts != -1 and self._reconnect_attempts >= self.max_reconnect_attempts:
|
||||||
|
await self.logger.error(f'Max reconnect attempts reached ({self.max_reconnect_attempts}), giving up')
|
||||||
|
break
|
||||||
|
|
||||||
|
self._reconnect_attempts += 1
|
||||||
|
delay = min(
|
||||||
|
self.reconnect_base_delay * (2 ** (self._reconnect_attempts - 1)),
|
||||||
|
self.reconnect_max_delay,
|
||||||
|
)
|
||||||
|
await self.logger.info(f'Reconnecting in {delay:.1f}s (attempt {self._reconnect_attempts})...')
|
||||||
|
await asyncio.sleep(delay)
|
||||||
|
|
||||||
|
async def disconnect(self):
|
||||||
|
"""Gracefully disconnect from the WebSocket server."""
|
||||||
|
self._running = False
|
||||||
|
if self._heartbeat_task and not self._heartbeat_task.done():
|
||||||
|
self._heartbeat_task.cancel()
|
||||||
|
for task in self._reply_workers.values():
|
||||||
|
if not task.done():
|
||||||
|
task.cancel()
|
||||||
|
if self._ws and not self._ws.closed:
|
||||||
|
await self._ws.close()
|
||||||
|
self._ws = None
|
||||||
|
if self._session and not self._session.closed:
|
||||||
|
await self._session.close()
|
||||||
|
self._session = None
|
||||||
|
|
||||||
|
def on_message(self, msg_type: str) -> Callable:
|
||||||
|
"""Decorator to register a message handler.
|
||||||
|
|
||||||
|
Same interface as WecomBotClient.on_message for compatibility.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
msg_type: 'single', 'group', or specific message type.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def decorator(func: Callable[[wecombotevent.WecomBotEvent], Any]):
|
||||||
|
if msg_type not in self._message_handlers:
|
||||||
|
self._message_handlers[msg_type] = []
|
||||||
|
self._message_handlers[msg_type].append(func)
|
||||||
|
return func
|
||||||
|
|
||||||
|
return decorator
|
||||||
|
|
||||||
|
async def reply_stream(
|
||||||
|
self,
|
||||||
|
req_id: str,
|
||||||
|
stream_id: str,
|
||||||
|
content: str,
|
||||||
|
finish: bool = False,
|
||||||
|
) -> Optional[dict]:
|
||||||
|
"""Send a streaming reply frame.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
req_id: The req_id from the original message frame (must be passed through).
|
||||||
|
stream_id: The stream ID for this streaming session.
|
||||||
|
content: The content to send (supports Markdown).
|
||||||
|
finish: Whether this is the final chunk.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The ACK frame dict, or None on failure.
|
||||||
|
"""
|
||||||
|
body = {
|
||||||
|
'msgtype': 'stream',
|
||||||
|
'stream': {
|
||||||
|
'id': stream_id,
|
||||||
|
'finish': finish,
|
||||||
|
'content': content,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return await self._send_reply(req_id, body)
|
||||||
|
|
||||||
|
async def reply_text(self, req_id: str, content: str) -> Optional[dict]:
|
||||||
|
"""Send a non-streaming text reply.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
req_id: The req_id from the original message frame.
|
||||||
|
content: The text content to reply.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The ACK frame dict, or None on failure.
|
||||||
|
"""
|
||||||
|
body = {
|
||||||
|
'msgtype': 'markdown',
|
||||||
|
'markdown': {
|
||||||
|
'content': content,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
return await self._send_reply(req_id, body)
|
||||||
|
|
||||||
|
async def send_message(self, chat_id: str, content: str, msgtype: str = 'markdown') -> Optional[dict]:
|
||||||
|
"""Proactively send a message to a specified chat.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
chat_id: The chat ID (userid for single chat, chatid for group chat).
|
||||||
|
content: The message content.
|
||||||
|
msgtype: Message type, 'markdown' by default.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
The ACK frame dict, or None on failure.
|
||||||
|
"""
|
||||||
|
req_id = _generate_req_id(CMD_SEND_MSG)
|
||||||
|
body: dict[str, Any] = {
|
||||||
|
'chatid': chat_id,
|
||||||
|
'msgtype': msgtype,
|
||||||
|
}
|
||||||
|
if msgtype == 'markdown':
|
||||||
|
body['markdown'] = {'content': content}
|
||||||
|
elif msgtype == 'text':
|
||||||
|
body['text'] = {'content': content}
|
||||||
|
return await self._send_reply(req_id, body, cmd=CMD_SEND_MSG)
|
||||||
|
|
||||||
|
async def push_stream_chunk(self, msg_id: str, content: str, is_final: bool = False) -> bool:
|
||||||
|
"""Push a streaming chunk for a given message ID.
|
||||||
|
|
||||||
|
Compatible interface with WecomBotClient.push_stream_chunk.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
msg_id: The original message ID.
|
||||||
|
content: The cumulative content from the pipeline.
|
||||||
|
is_final: Whether this is the final chunk.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if the stream session exists and chunk was sent.
|
||||||
|
"""
|
||||||
|
key = self._stream_ids.get(msg_id)
|
||||||
|
if not key:
|
||||||
|
return False
|
||||||
|
req_id, stream_id = key.split('|', 1)
|
||||||
|
try:
|
||||||
|
# Skip sending if content hasn't changed (e.g. during tool call argument streaming)
|
||||||
|
if not is_final and content == self._stream_last_content.get(msg_id):
|
||||||
|
return True
|
||||||
|
await self.reply_stream(req_id, stream_id, content, finish=is_final)
|
||||||
|
self._stream_last_content[msg_id] = content
|
||||||
|
if is_final:
|
||||||
|
self._stream_ids.pop(msg_id, None)
|
||||||
|
self._stream_last_content.pop(msg_id, None)
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
await self.logger.error(f'Failed to push stream chunk: {traceback.format_exc()}')
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def set_message(self, msg_id: str, content: str):
|
||||||
|
"""Fallback: send content as a final stream chunk or direct reply.
|
||||||
|
|
||||||
|
Compatible interface with WecomBotClient.set_message.
|
||||||
|
"""
|
||||||
|
handled = await self.push_stream_chunk(msg_id, content, is_final=True)
|
||||||
|
if not handled:
|
||||||
|
await self.logger.warning(f'No active stream for msg_id={msg_id}, message dropped')
|
||||||
|
|
||||||
|
# ── Connection lifecycle ────────────────────────────────────────
|
||||||
|
|
||||||
|
async def _connect_once(self):
|
||||||
|
"""Establish a single WebSocket connection, authenticate, and listen."""
|
||||||
|
await self.logger.info(f'Connecting to {self.ws_url}...')
|
||||||
|
|
||||||
|
self._session = aiohttp.ClientSession()
|
||||||
|
try:
|
||||||
|
self._ws = await self._session.ws_connect(self.ws_url)
|
||||||
|
self._missed_pong_count = 0
|
||||||
|
self._reconnect_attempts = 0
|
||||||
|
await self.logger.info('WebSocket connected, sending auth...')
|
||||||
|
|
||||||
|
await self._send_auth()
|
||||||
|
|
||||||
|
# Wait for auth response
|
||||||
|
auth_ok = await self._wait_for_auth()
|
||||||
|
if not auth_ok:
|
||||||
|
await self.logger.error('Authentication failed')
|
||||||
|
return
|
||||||
|
|
||||||
|
await self.logger.info('Authenticated successfully')
|
||||||
|
|
||||||
|
# Start heartbeat
|
||||||
|
self._heartbeat_task = asyncio.create_task(self._heartbeat_loop())
|
||||||
|
|
||||||
|
try:
|
||||||
|
await self._listen_loop()
|
||||||
|
finally:
|
||||||
|
if self._heartbeat_task and not self._heartbeat_task.done():
|
||||||
|
self._heartbeat_task.cancel()
|
||||||
|
self._clear_pending_acks('Connection closed')
|
||||||
|
finally:
|
||||||
|
if self._ws and not self._ws.closed:
|
||||||
|
await self._ws.close()
|
||||||
|
self._ws = None
|
||||||
|
if self._session and not self._session.closed:
|
||||||
|
await self._session.close()
|
||||||
|
self._session = None
|
||||||
|
|
||||||
|
async def _send_auth(self):
|
||||||
|
"""Send the authentication frame."""
|
||||||
|
frame = {
|
||||||
|
'cmd': CMD_SUBSCRIBE,
|
||||||
|
'headers': {'req_id': _generate_req_id(CMD_SUBSCRIBE)},
|
||||||
|
'body': {
|
||||||
|
'bot_id': self.bot_id,
|
||||||
|
'secret': self.secret,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
await self._send_frame(frame)
|
||||||
|
|
||||||
|
async def _wait_for_auth(self) -> bool:
|
||||||
|
"""Wait for and validate the authentication response."""
|
||||||
|
try:
|
||||||
|
msg = await asyncio.wait_for(self._ws.receive(), timeout=10.0)
|
||||||
|
if msg.type in (aiohttp.WSMsgType.TEXT,):
|
||||||
|
frame = json.loads(msg.data)
|
||||||
|
req_id = frame.get('headers', {}).get('req_id', '')
|
||||||
|
if req_id.startswith(CMD_SUBSCRIBE) and frame.get('errcode') == 0:
|
||||||
|
return True
|
||||||
|
await self.logger.error(f'Auth response: errcode={frame.get("errcode")}, errmsg={frame.get("errmsg")}')
|
||||||
|
return False
|
||||||
|
elif msg.type in (aiohttp.WSMsgType.ERROR, aiohttp.WSMsgType.CLOSED, aiohttp.WSMsgType.CLOSING):
|
||||||
|
await self.logger.error(f'WebSocket closed during auth: {msg.type}')
|
||||||
|
return False
|
||||||
|
await self.logger.error(f'Unexpected message type during auth: {msg.type}')
|
||||||
|
return False
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
await self.logger.error('Auth response timeout')
|
||||||
|
return False
|
||||||
|
|
||||||
|
async def _heartbeat_loop(self):
|
||||||
|
"""Periodically send heartbeat pings."""
|
||||||
|
try:
|
||||||
|
while self._running and self._ws and not self._ws.closed:
|
||||||
|
await asyncio.sleep(self.heartbeat_interval)
|
||||||
|
if not self._running or not self._ws or self._ws.closed:
|
||||||
|
break
|
||||||
|
|
||||||
|
if self._missed_pong_count >= self._max_missed_pong:
|
||||||
|
await self.logger.warning(
|
||||||
|
f'No heartbeat ack for {self._missed_pong_count} consecutive pings, connection considered dead'
|
||||||
|
)
|
||||||
|
await self._ws.close()
|
||||||
|
break
|
||||||
|
|
||||||
|
self._missed_pong_count += 1
|
||||||
|
frame = {
|
||||||
|
'cmd': CMD_HEARTBEAT,
|
||||||
|
'headers': {'req_id': _generate_req_id(CMD_HEARTBEAT)},
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
await self._send_frame(frame)
|
||||||
|
except Exception:
|
||||||
|
break
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
async def _listen_loop(self):
|
||||||
|
"""Listen for incoming WebSocket frames and dispatch them."""
|
||||||
|
async for msg in self._ws:
|
||||||
|
if not self._running:
|
||||||
|
break
|
||||||
|
if msg.type == aiohttp.WSMsgType.TEXT:
|
||||||
|
try:
|
||||||
|
frame = json.loads(msg.data)
|
||||||
|
await self._handle_frame(frame)
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
await self.logger.error(f'Failed to parse WebSocket message: {str(msg.data)[:200]}')
|
||||||
|
except Exception:
|
||||||
|
await self.logger.error(f'Error handling frame: {traceback.format_exc()}')
|
||||||
|
elif msg.type == aiohttp.WSMsgType.BINARY:
|
||||||
|
try:
|
||||||
|
frame = json.loads(msg.data)
|
||||||
|
await self._handle_frame(frame)
|
||||||
|
except Exception:
|
||||||
|
await self.logger.error(f'Error handling binary frame: {traceback.format_exc()}')
|
||||||
|
elif msg.type in (aiohttp.WSMsgType.ERROR, aiohttp.WSMsgType.CLOSED, aiohttp.WSMsgType.CLOSING):
|
||||||
|
await self.logger.warning(f'WebSocket connection closed: {msg.type}')
|
||||||
|
break
|
||||||
|
|
||||||
|
# ── Frame handling ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
async def _handle_frame(self, frame: dict):
|
||||||
|
"""Route an incoming frame to the appropriate handler."""
|
||||||
|
cmd = frame.get('cmd', '')
|
||||||
|
|
||||||
|
# Message push
|
||||||
|
if cmd == CMD_MSG_CALLBACK:
|
||||||
|
asyncio.create_task(self._handle_message_callback(frame))
|
||||||
|
return
|
||||||
|
|
||||||
|
# Event push
|
||||||
|
if cmd == CMD_EVENT_CALLBACK:
|
||||||
|
asyncio.create_task(self._handle_event_callback(frame))
|
||||||
|
return
|
||||||
|
|
||||||
|
# No cmd → response/ACK frame, dispatch by req_id prefix
|
||||||
|
req_id = frame.get('headers', {}).get('req_id', '')
|
||||||
|
|
||||||
|
# Check pending ACKs first
|
||||||
|
if req_id in self._pending_acks:
|
||||||
|
future = self._pending_acks.pop(req_id)
|
||||||
|
if not future.done():
|
||||||
|
future.set_result(frame)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Heartbeat response
|
||||||
|
if req_id.startswith(CMD_HEARTBEAT):
|
||||||
|
if frame.get('errcode') == 0:
|
||||||
|
self._missed_pong_count = 0
|
||||||
|
return
|
||||||
|
|
||||||
|
# Unknown frame
|
||||||
|
await self.logger.warning(f'Unknown frame: {json.dumps(frame, ensure_ascii=False)[:200]}')
|
||||||
|
|
||||||
|
async def _handle_message_callback(self, frame: dict):
|
||||||
|
"""Handle an incoming message callback frame."""
|
||||||
|
try:
|
||||||
|
body = frame.get('body', {})
|
||||||
|
req_id = frame.get('headers', {}).get('req_id', '')
|
||||||
|
|
||||||
|
# Parse message using shared logic
|
||||||
|
message_data = await parse_wecom_bot_message(body, self.encoding_aes_key, self.logger)
|
||||||
|
if not message_data:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Generate stream_id for this message and store the mapping
|
||||||
|
stream_id = _generate_req_id('stream')
|
||||||
|
msg_id = message_data.get('msgid', '')
|
||||||
|
if msg_id:
|
||||||
|
self._stream_ids[msg_id] = f'{req_id}|{stream_id}'
|
||||||
|
message_data['stream_id'] = stream_id
|
||||||
|
message_data['req_id'] = req_id
|
||||||
|
|
||||||
|
event = wecombotevent.WecomBotEvent(message_data)
|
||||||
|
await self._dispatch_event(event)
|
||||||
|
except Exception:
|
||||||
|
await self.logger.error(f'Error in message callback: {traceback.format_exc()}')
|
||||||
|
|
||||||
|
async def _handle_event_callback(self, frame: dict):
|
||||||
|
"""Handle an incoming event callback frame (enter_chat, template_card_event, etc.)."""
|
||||||
|
try:
|
||||||
|
body = frame.get('body', {})
|
||||||
|
req_id = frame.get('headers', {}).get('req_id', '')
|
||||||
|
|
||||||
|
event_info = body.get('event', {})
|
||||||
|
event_type = event_info.get('eventtype', '')
|
||||||
|
|
||||||
|
message_data = {
|
||||||
|
'msgtype': 'event',
|
||||||
|
'type': body.get('chattype', 'single'),
|
||||||
|
'event': event_info,
|
||||||
|
'eventtype': event_type,
|
||||||
|
'msgid': body.get('msgid', ''),
|
||||||
|
'aibotid': body.get('aibotid', ''),
|
||||||
|
'req_id': req_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
from_info = body.get('from', {})
|
||||||
|
message_data['userid'] = from_info.get('userid', '')
|
||||||
|
message_data['username'] = from_info.get('alias', '') or from_info.get('userid', '')
|
||||||
|
|
||||||
|
if body.get('chatid'):
|
||||||
|
message_data['chatid'] = body.get('chatid', '')
|
||||||
|
|
||||||
|
event = wecombotevent.WecomBotEvent(message_data)
|
||||||
|
|
||||||
|
# Dispatch to event-specific handlers
|
||||||
|
if event_type in self._message_handlers:
|
||||||
|
for handler in self._message_handlers[event_type]:
|
||||||
|
await handler(event)
|
||||||
|
|
||||||
|
# Also dispatch to generic 'event' handlers
|
||||||
|
if 'event' in self._message_handlers:
|
||||||
|
for handler in self._message_handlers['event']:
|
||||||
|
await handler(event)
|
||||||
|
|
||||||
|
except Exception:
|
||||||
|
await self.logger.error(f'Error in event callback: {traceback.format_exc()}')
|
||||||
|
|
||||||
|
async def _dispatch_event(self, event: wecombotevent.WecomBotEvent):
|
||||||
|
"""Dispatch a message event to registered handlers with deduplication."""
|
||||||
|
try:
|
||||||
|
message_id = event.message_id
|
||||||
|
if message_id in self._msg_id_map:
|
||||||
|
self._msg_id_map[message_id] += 1
|
||||||
|
return
|
||||||
|
self._msg_id_map[message_id] = 1
|
||||||
|
|
||||||
|
msg_type = event.type
|
||||||
|
if msg_type in self._message_handlers:
|
||||||
|
for handler in self._message_handlers[msg_type]:
|
||||||
|
await handler(event)
|
||||||
|
except Exception:
|
||||||
|
await self.logger.error(f'Error dispatching event: {traceback.format_exc()}')
|
||||||
|
|
||||||
|
# ── Reply sending with serial queue ─────────────────────────────
|
||||||
|
|
||||||
|
async def _send_reply(
|
||||||
|
self,
|
||||||
|
req_id: str,
|
||||||
|
body: dict,
|
||||||
|
cmd: str = CMD_RESPOND_MSG,
|
||||||
|
) -> Optional[dict]:
|
||||||
|
"""Send a reply frame and wait for ACK.
|
||||||
|
|
||||||
|
Replies with the same req_id are serialized to maintain ordering.
|
||||||
|
"""
|
||||||
|
if not self._ws or self._ws.closed:
|
||||||
|
return None
|
||||||
|
|
||||||
|
frame = {
|
||||||
|
'cmd': cmd,
|
||||||
|
'headers': {'req_id': req_id},
|
||||||
|
'body': body,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Ensure serial delivery per req_id
|
||||||
|
if req_id not in self._reply_queues:
|
||||||
|
self._reply_queues[req_id] = asyncio.Queue()
|
||||||
|
self._reply_workers[req_id] = asyncio.create_task(self._reply_queue_worker(req_id))
|
||||||
|
|
||||||
|
future: asyncio.Future = asyncio.get_event_loop().create_future()
|
||||||
|
await self._reply_queues[req_id].put((frame, future))
|
||||||
|
return await future
|
||||||
|
|
||||||
|
async def _reply_queue_worker(self, req_id: str):
|
||||||
|
"""Process reply queue items serially for a given req_id."""
|
||||||
|
queue = self._reply_queues[req_id]
|
||||||
|
try:
|
||||||
|
while self._running:
|
||||||
|
try:
|
||||||
|
frame, future = await asyncio.wait_for(queue.get(), timeout=60.0)
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
# Queue idle, clean up worker
|
||||||
|
break
|
||||||
|
|
||||||
|
try:
|
||||||
|
ack = await self._send_and_wait_ack(frame)
|
||||||
|
if not future.done():
|
||||||
|
future.set_result(ack)
|
||||||
|
except Exception as e:
|
||||||
|
if not future.done():
|
||||||
|
future.set_exception(e)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
pass
|
||||||
|
finally:
|
||||||
|
self._reply_queues.pop(req_id, None)
|
||||||
|
self._reply_workers.pop(req_id, None)
|
||||||
|
|
||||||
|
async def _send_and_wait_ack(self, frame: dict) -> Optional[dict]:
|
||||||
|
"""Send a frame and wait for the corresponding ACK."""
|
||||||
|
req_id = frame['headers']['req_id']
|
||||||
|
ack_future: asyncio.Future = asyncio.get_event_loop().create_future()
|
||||||
|
self._pending_acks[req_id] = ack_future
|
||||||
|
|
||||||
|
try:
|
||||||
|
await self._send_frame(frame)
|
||||||
|
result = await asyncio.wait_for(ack_future, timeout=self._reply_ack_timeout)
|
||||||
|
if result.get('errcode', 0) != 0:
|
||||||
|
await self.logger.warning(
|
||||||
|
f'Reply ACK error: errcode={result.get("errcode")}, errmsg={result.get("errmsg")}'
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
except asyncio.TimeoutError:
|
||||||
|
self._pending_acks.pop(req_id, None)
|
||||||
|
await self.logger.warning(f'Reply ACK timeout ({self._reply_ack_timeout}s) for req_id={req_id}')
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def _send_frame(self, frame: dict):
|
||||||
|
"""Send a JSON frame over the WebSocket connection."""
|
||||||
|
if self._ws and not self._ws.closed:
|
||||||
|
await self._ws.send_str(json.dumps(frame, ensure_ascii=False))
|
||||||
|
|
||||||
|
def _clear_pending_acks(self, reason: str):
|
||||||
|
"""Reject all pending ACK futures on disconnection."""
|
||||||
|
for req_id, future in self._pending_acks.items():
|
||||||
|
if not future.done():
|
||||||
|
future.set_exception(ConnectionError(reason))
|
||||||
|
self._pending_acks.clear()
|
||||||
@@ -10,6 +10,7 @@ from typing import Callable
|
|||||||
from .wecomcsevent import WecomCSEvent
|
from .wecomcsevent import WecomCSEvent
|
||||||
import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
||||||
import aiofiles
|
import aiofiles
|
||||||
|
import time
|
||||||
|
|
||||||
|
|
||||||
class WecomCSClient:
|
class WecomCSClient:
|
||||||
@@ -34,6 +35,10 @@ class WecomCSClient:
|
|||||||
self.unified_mode = unified_mode
|
self.unified_mode = unified_mode
|
||||||
self.app = Quart(__name__)
|
self.app = Quart(__name__)
|
||||||
|
|
||||||
|
# Customer info cache: {external_userid: (info_dict, timestamp)}
|
||||||
|
self._customer_cache: dict[str, tuple[dict, float]] = {}
|
||||||
|
self._cache_ttl = 60 # Cache TTL in seconds (1 minute)
|
||||||
|
|
||||||
# 只有在非统一模式下才注册独立路由
|
# 只有在非统一模式下才注册独立路由
|
||||||
if not self.unified_mode:
|
if not self.unified_mode:
|
||||||
self.app.add_url_rule(
|
self.app.add_url_rule(
|
||||||
@@ -378,3 +383,53 @@ class WecomCSClient:
|
|||||||
async def get_media_id(self, image: platform_message.Image):
|
async def get_media_id(self, image: platform_message.Image):
|
||||||
media_id = await self.upload_to_work(image=image)
|
media_id = await self.upload_to_work(image=image)
|
||||||
return media_id
|
return media_id
|
||||||
|
|
||||||
|
async def get_customer_info(self, external_userid: str) -> dict | None:
|
||||||
|
"""
|
||||||
|
Get customer information by external_userid with caching.
|
||||||
|
|
||||||
|
Uses a 1-minute cache to avoid repeated API calls for the same user.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
external_userid: The external user ID of the customer.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Customer info dict with 'nickname', 'avatar', etc., or None if not found.
|
||||||
|
"""
|
||||||
|
# Check cache first
|
||||||
|
current_time = time.time()
|
||||||
|
if external_userid in self._customer_cache:
|
||||||
|
cached_info, cached_time = self._customer_cache[external_userid]
|
||||||
|
if current_time - cached_time < self._cache_ttl:
|
||||||
|
return cached_info
|
||||||
|
|
||||||
|
# Cache miss or expired, fetch from API
|
||||||
|
if not await self.check_access_token():
|
||||||
|
self.access_token = await self.get_access_token(self.secret)
|
||||||
|
|
||||||
|
url = f'{self.base_url}/kf/customer/batchget?access_token={self.access_token}'
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
'external_userid_list': [external_userid],
|
||||||
|
}
|
||||||
|
|
||||||
|
async with httpx.AsyncClient() as client:
|
||||||
|
response = await client.post(url, json=payload)
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
if data.get('errcode') in [40014, 42001]:
|
||||||
|
self.access_token = await self.get_access_token(self.secret)
|
||||||
|
return await self.get_customer_info(external_userid)
|
||||||
|
|
||||||
|
if data.get('errcode', 0) != 0:
|
||||||
|
if self.logger:
|
||||||
|
await self.logger.warning(f'Failed to get customer info: {data}')
|
||||||
|
return None
|
||||||
|
|
||||||
|
customer_list = data.get('customer_list', [])
|
||||||
|
if customer_list:
|
||||||
|
customer_info = customer_list[0]
|
||||||
|
# Store in cache
|
||||||
|
self._customer_cache[external_userid] = (customer_info, current_time)
|
||||||
|
return customer_info
|
||||||
|
return None
|
||||||
|
|||||||
@@ -70,12 +70,17 @@ class BotService:
|
|||||||
'lark',
|
'lark',
|
||||||
]:
|
]:
|
||||||
webhook_prefix = self.ap.instance_config.data['api'].get('webhook_prefix', 'http://127.0.0.1:5300')
|
webhook_prefix = self.ap.instance_config.data['api'].get('webhook_prefix', 'http://127.0.0.1:5300')
|
||||||
|
extra_webhook_prefix = self.ap.instance_config.data['api'].get('extra_webhook_prefix', '')
|
||||||
webhook_url = f'/bots/{bot_uuid}'
|
webhook_url = f'/bots/{bot_uuid}'
|
||||||
adapter_runtime_values['webhook_url'] = webhook_url
|
adapter_runtime_values['webhook_url'] = webhook_url
|
||||||
adapter_runtime_values['webhook_full_url'] = f'{webhook_prefix}{webhook_url}'
|
adapter_runtime_values['webhook_full_url'] = f'{webhook_prefix}{webhook_url}'
|
||||||
|
adapter_runtime_values['extra_webhook_full_url'] = (
|
||||||
|
f'{extra_webhook_prefix}{webhook_url}' if extra_webhook_prefix else ''
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
adapter_runtime_values['webhook_url'] = None
|
adapter_runtime_values['webhook_url'] = None
|
||||||
adapter_runtime_values['webhook_full_url'] = None
|
adapter_runtime_values['webhook_full_url'] = None
|
||||||
|
adapter_runtime_values['extra_webhook_full_url'] = None
|
||||||
|
|
||||||
persistence_bot['adapter_runtime_values'] = adapter_runtime_values
|
persistence_bot['adapter_runtime_values'] = adapter_runtime_values
|
||||||
|
|
||||||
|
|||||||
@@ -105,11 +105,16 @@ class LLMModelsService:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
pipeline = result.first()
|
pipeline = result.first()
|
||||||
if pipeline is not None and pipeline.config['ai']['local-agent']['model'] == '':
|
if pipeline is not None:
|
||||||
pipeline_config = pipeline.config
|
model_config = pipeline.config.get('ai', {}).get('local-agent', {}).get('model', {})
|
||||||
pipeline_config['ai']['local-agent']['model'] = model_data['uuid']
|
if not model_config.get('primary', ''):
|
||||||
pipeline_data = {'config': pipeline_config}
|
pipeline_config = pipeline.config
|
||||||
await self.ap.pipeline_service.update_pipeline(pipeline.uuid, pipeline_data)
|
pipeline_config['ai']['local-agent']['model'] = {
|
||||||
|
'primary': model_data['uuid'],
|
||||||
|
'fallbacks': [],
|
||||||
|
}
|
||||||
|
pipeline_data = {'config': pipeline_config}
|
||||||
|
await self.ap.pipeline_service.update_pipeline(pipeline.uuid, pipeline_data)
|
||||||
|
|
||||||
return model_data['uuid']
|
return model_data['uuid']
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ class MonitoringService:
|
|||||||
level: str = 'info',
|
level: str = 'info',
|
||||||
platform: str | None = None,
|
platform: str | None = None,
|
||||||
user_id: str | None = None,
|
user_id: str | None = None,
|
||||||
|
user_name: str | None = None,
|
||||||
runner_name: str | None = None,
|
runner_name: str | None = None,
|
||||||
variables: str | None = None,
|
variables: str | None = None,
|
||||||
role: str = 'user',
|
role: str = 'user',
|
||||||
@@ -49,6 +50,7 @@ class MonitoringService:
|
|||||||
'level': level,
|
'level': level,
|
||||||
'platform': platform,
|
'platform': platform,
|
||||||
'user_id': user_id,
|
'user_id': user_id,
|
||||||
|
'user_name': user_name,
|
||||||
'runner_name': runner_name,
|
'runner_name': runner_name,
|
||||||
'variables': variables,
|
'variables': variables,
|
||||||
'role': role,
|
'role': role,
|
||||||
@@ -152,6 +154,7 @@ class MonitoringService:
|
|||||||
pipeline_name: str,
|
pipeline_name: str,
|
||||||
platform: str | None = None,
|
platform: str | None = None,
|
||||||
user_id: str | None = None,
|
user_id: str | None = None,
|
||||||
|
user_name: str | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Record a new session"""
|
"""Record a new session"""
|
||||||
session_data = {
|
session_data = {
|
||||||
@@ -166,6 +169,7 @@ class MonitoringService:
|
|||||||
'is_active': True,
|
'is_active': True,
|
||||||
'platform': platform,
|
'platform': platform,
|
||||||
'user_id': user_id,
|
'user_id': user_id,
|
||||||
|
'user_name': user_name,
|
||||||
}
|
}
|
||||||
|
|
||||||
await self.ap.persistence_mgr.execute_async(
|
await self.ap.persistence_mgr.execute_async(
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from ..platform import botmgr as im_mgr
|
|||||||
from ..platform.webhook_pusher import WebhookPusher
|
from ..platform.webhook_pusher import WebhookPusher
|
||||||
from ..provider.session import sessionmgr as llm_session_mgr
|
from ..provider.session import sessionmgr as llm_session_mgr
|
||||||
from ..provider.modelmgr import modelmgr as llm_model_mgr
|
from ..provider.modelmgr import modelmgr as llm_model_mgr
|
||||||
|
|
||||||
from langbot.pkg.provider.tools import toolmgr as llm_tool_mgr
|
from langbot.pkg.provider.tools import toolmgr as llm_tool_mgr
|
||||||
from ..config import manager as config_mgr
|
from ..config import manager as config_mgr
|
||||||
from ..command import cmdmgr
|
from ..command import cmdmgr
|
||||||
@@ -30,6 +31,7 @@ from ..api.http.service import mcp as mcp_service
|
|||||||
from ..api.http.service import apikey as apikey_service
|
from ..api.http.service import 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 ..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
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ class MonitoringMessage(Base):
|
|||||||
level = sqlalchemy.Column(sqlalchemy.String(50), nullable=False) # info, warning, error, debug
|
level = sqlalchemy.Column(sqlalchemy.String(50), nullable=False) # info, warning, error, debug
|
||||||
platform = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
|
platform = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
|
||||||
user_id = 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) # User display name
|
||||||
runner_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=True) # Runner name for this query
|
runner_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=True) # Runner name for this query
|
||||||
variables = sqlalchemy.Column(sqlalchemy.Text, nullable=True) # Query variables as JSON string
|
variables = sqlalchemy.Column(sqlalchemy.Text, nullable=True) # Query variables as JSON string
|
||||||
role = sqlalchemy.Column(sqlalchemy.String(50), nullable=True, default='user') # user, assistant
|
role = sqlalchemy.Column(sqlalchemy.String(50), nullable=True, default='user') # user, assistant
|
||||||
@@ -64,6 +65,7 @@ class MonitoringSession(Base):
|
|||||||
is_active = sqlalchemy.Column(sqlalchemy.Boolean, nullable=False, default=True, index=True)
|
is_active = sqlalchemy.Column(sqlalchemy.Boolean, nullable=False, default=True, index=True)
|
||||||
platform = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
|
platform = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
|
||||||
user_id = 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) # User display name
|
||||||
|
|
||||||
|
|
||||||
class MonitoringError(Base):
|
class MonitoringError(Base):
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
from .. import migration
|
||||||
|
|
||||||
|
import sqlalchemy
|
||||||
|
import json
|
||||||
|
|
||||||
|
|
||||||
|
@migration.migration_class(21)
|
||||||
|
class DBMigrateMergeExceptionHandling(migration.DBMigration):
|
||||||
|
"""Merge hide-exception and block-failed-request-output into a single exception-handling select option,
|
||||||
|
and add failure-hint field.
|
||||||
|
|
||||||
|
Conversion logic:
|
||||||
|
- block-failed-request-output=true -> exception-handling: hide
|
||||||
|
- hide-exception=true -> exception-handling: show-hint
|
||||||
|
- hide-exception=false -> exception-handling: show-error
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def upgrade(self):
|
||||||
|
"""Upgrade"""
|
||||||
|
result = await self.ap.persistence_mgr.execute_async(
|
||||||
|
sqlalchemy.text('SELECT uuid, config FROM legacy_pipelines')
|
||||||
|
)
|
||||||
|
pipelines = result.fetchall()
|
||||||
|
|
||||||
|
current_version = self.ap.ver_mgr.get_current_version()
|
||||||
|
|
||||||
|
for pipeline_row in pipelines:
|
||||||
|
uuid = pipeline_row[0]
|
||||||
|
config = json.loads(pipeline_row[1]) if isinstance(pipeline_row[1], str) else pipeline_row[1]
|
||||||
|
|
||||||
|
if 'output' not in config:
|
||||||
|
config['output'] = {}
|
||||||
|
if 'misc' not in config['output']:
|
||||||
|
config['output']['misc'] = {}
|
||||||
|
|
||||||
|
misc = config['output']['misc']
|
||||||
|
|
||||||
|
# Determine new exception-handling value from legacy fields
|
||||||
|
hide_exception = misc.get('hide-exception', True)
|
||||||
|
block_failed = misc.get('block-failed-request-output', False)
|
||||||
|
|
||||||
|
if block_failed:
|
||||||
|
exception_handling = 'hide'
|
||||||
|
elif hide_exception:
|
||||||
|
exception_handling = 'show-hint'
|
||||||
|
else:
|
||||||
|
exception_handling = 'show-error'
|
||||||
|
|
||||||
|
misc['exception-handling'] = exception_handling
|
||||||
|
|
||||||
|
# Add failure-hint with default value
|
||||||
|
misc['failure-hint'] = 'Request failed.'
|
||||||
|
|
||||||
|
# Remove legacy fields
|
||||||
|
misc.pop('hide-exception', None)
|
||||||
|
|
||||||
|
if self.ap.persistence_mgr.db.name == 'postgresql':
|
||||||
|
await self.ap.persistence_mgr.execute_async(
|
||||||
|
sqlalchemy.text(
|
||||||
|
'UPDATE legacy_pipelines SET config = :config::jsonb, for_version = :for_version WHERE uuid = :uuid'
|
||||||
|
),
|
||||||
|
{'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid},
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
await self.ap.persistence_mgr.execute_async(
|
||||||
|
sqlalchemy.text(
|
||||||
|
'UPDATE legacy_pipelines SET config = :config, for_version = :for_version WHERE uuid = :uuid'
|
||||||
|
),
|
||||||
|
{'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def downgrade(self):
|
||||||
|
"""Downgrade"""
|
||||||
|
pass
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import sqlalchemy
|
||||||
|
from .. import migration
|
||||||
|
|
||||||
|
|
||||||
|
@migration.migration_class(22)
|
||||||
|
class DBMigrateMonitoringUserId(migration.DBMigration):
|
||||||
|
"""Add user_id and user_name columns to monitoring_sessions table
|
||||||
|
|
||||||
|
This migration adds the missing user_id column and also ensures user_name
|
||||||
|
column exists (in case migration 21 failed or was skipped).
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def _table_exists(self, table_name: str) -> bool:
|
||||||
|
"""Check if a table exists (works for both SQLite and PostgreSQL)."""
|
||||||
|
if self.ap.persistence_mgr.db.name == 'postgresql':
|
||||||
|
result = await self.ap.persistence_mgr.execute_async(
|
||||||
|
sqlalchemy.text(
|
||||||
|
'SELECT EXISTS (SELECT FROM information_schema.tables WHERE table_name = :table_name);'
|
||||||
|
).bindparams(table_name=table_name)
|
||||||
|
)
|
||||||
|
return bool(result.scalar())
|
||||||
|
else:
|
||||||
|
result = await self.ap.persistence_mgr.execute_async(
|
||||||
|
sqlalchemy.text("SELECT name FROM sqlite_master WHERE type='table' AND name=:table_name;").bindparams(
|
||||||
|
table_name=table_name
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return result.first() is not None
|
||||||
|
|
||||||
|
async def _get_table_columns(self, table_name: str) -> list[str]:
|
||||||
|
"""Get column names from a table (works for both SQLite and PostgreSQL)."""
|
||||||
|
if self.ap.persistence_mgr.db.name == 'postgresql':
|
||||||
|
result = await self.ap.persistence_mgr.execute_async(
|
||||||
|
sqlalchemy.text(
|
||||||
|
'SELECT column_name FROM information_schema.columns WHERE table_name = :table_name;'
|
||||||
|
).bindparams(table_name=table_name)
|
||||||
|
)
|
||||||
|
return [row[0] for row in result.fetchall()]
|
||||||
|
else:
|
||||||
|
if not table_name.isidentifier():
|
||||||
|
raise ValueError(f'Invalid table name: {table_name}')
|
||||||
|
result = await self.ap.persistence_mgr.execute_async(sqlalchemy.text(f'PRAGMA table_info({table_name});'))
|
||||||
|
return [row[1] for row in result.fetchall()]
|
||||||
|
|
||||||
|
async def _add_column_if_not_exists(self, table_name: str, column_name: str, column_type: str):
|
||||||
|
"""Add a column to a table if it does not already exist."""
|
||||||
|
columns = await self._get_table_columns(table_name)
|
||||||
|
if column_name in columns:
|
||||||
|
self.ap.logger.debug('%s column already exists in %s.', column_name, table_name)
|
||||||
|
return
|
||||||
|
await self.ap.persistence_mgr.execute_async(
|
||||||
|
sqlalchemy.text(f'ALTER TABLE {table_name} ADD COLUMN {column_name} {column_type};')
|
||||||
|
)
|
||||||
|
self.ap.logger.info('Added %s column to %s table.', column_name, table_name)
|
||||||
|
|
||||||
|
async def upgrade(self):
|
||||||
|
# Check if monitoring_sessions table exists
|
||||||
|
if not await self._table_exists('monitoring_sessions'):
|
||||||
|
self.ap.logger.warning('monitoring_sessions table does not exist, skipping migration.')
|
||||||
|
return
|
||||||
|
|
||||||
|
# Add user_id column to monitoring_sessions table
|
||||||
|
await self._add_column_if_not_exists('monitoring_sessions', 'user_id', 'VARCHAR(255)')
|
||||||
|
|
||||||
|
# Add user_name column to monitoring_sessions table (in case migration 21 failed)
|
||||||
|
await self._add_column_if_not_exists('monitoring_sessions', 'user_name', 'VARCHAR(255)')
|
||||||
|
|
||||||
|
# Add user_name column to monitoring_messages table (in case migration 21 failed)
|
||||||
|
if await self._table_exists('monitoring_messages'):
|
||||||
|
await self._add_column_if_not_exists('monitoring_messages', 'user_name', 'VARCHAR(255)')
|
||||||
|
|
||||||
|
async def downgrade(self):
|
||||||
|
pass
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
from .. import migration
|
||||||
|
|
||||||
|
import sqlalchemy
|
||||||
|
import json
|
||||||
|
|
||||||
|
|
||||||
|
@migration.migration_class(23)
|
||||||
|
class DBMigrateModelFallbackConfig(migration.DBMigration):
|
||||||
|
"""Convert model field from plain UUID string to object with primary/fallbacks"""
|
||||||
|
|
||||||
|
async def upgrade(self):
|
||||||
|
"""Upgrade"""
|
||||||
|
result = await self.ap.persistence_mgr.execute_async(
|
||||||
|
sqlalchemy.text('SELECT uuid, config FROM legacy_pipelines')
|
||||||
|
)
|
||||||
|
pipelines = result.fetchall()
|
||||||
|
|
||||||
|
current_version = self.ap.ver_mgr.get_current_version()
|
||||||
|
|
||||||
|
for pipeline_row in pipelines:
|
||||||
|
uuid = pipeline_row[0]
|
||||||
|
config = json.loads(pipeline_row[1]) if isinstance(pipeline_row[1], str) else pipeline_row[1]
|
||||||
|
|
||||||
|
if 'ai' not in config or 'local-agent' not in config['ai']:
|
||||||
|
continue
|
||||||
|
|
||||||
|
local_agent = config['ai']['local-agent']
|
||||||
|
changed = False
|
||||||
|
|
||||||
|
# Convert model from string to object
|
||||||
|
model_value = local_agent.get('model', '')
|
||||||
|
if isinstance(model_value, str):
|
||||||
|
local_agent['model'] = {
|
||||||
|
'primary': model_value,
|
||||||
|
'fallbacks': [],
|
||||||
|
}
|
||||||
|
changed = True
|
||||||
|
|
||||||
|
# Remove leftover fallback-models field if present
|
||||||
|
if 'fallback-models' in local_agent:
|
||||||
|
del local_agent['fallback-models']
|
||||||
|
changed = True
|
||||||
|
|
||||||
|
if not changed:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Update using raw SQL with compatibility for both SQLite and PostgreSQL
|
||||||
|
if self.ap.persistence_mgr.db.name == 'postgresql':
|
||||||
|
await self.ap.persistence_mgr.execute_async(
|
||||||
|
sqlalchemy.text(
|
||||||
|
'UPDATE legacy_pipelines SET config = :config::jsonb, for_version = :for_version WHERE uuid = :uuid'
|
||||||
|
),
|
||||||
|
{'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid},
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
await self.ap.persistence_mgr.execute_async(
|
||||||
|
sqlalchemy.text(
|
||||||
|
'UPDATE legacy_pipelines SET config = :config, for_version = :for_version WHERE uuid = :uuid'
|
||||||
|
),
|
||||||
|
{'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def downgrade(self):
|
||||||
|
"""Downgrade"""
|
||||||
|
result = await self.ap.persistence_mgr.execute_async(
|
||||||
|
sqlalchemy.text('SELECT uuid, config FROM legacy_pipelines')
|
||||||
|
)
|
||||||
|
pipelines = result.fetchall()
|
||||||
|
|
||||||
|
current_version = self.ap.ver_mgr.get_current_version()
|
||||||
|
|
||||||
|
for pipeline_row in pipelines:
|
||||||
|
uuid = pipeline_row[0]
|
||||||
|
config = json.loads(pipeline_row[1]) if isinstance(pipeline_row[1], str) else pipeline_row[1]
|
||||||
|
|
||||||
|
if 'ai' not in config or 'local-agent' not in config['ai']:
|
||||||
|
continue
|
||||||
|
|
||||||
|
local_agent = config['ai']['local-agent']
|
||||||
|
|
||||||
|
# Convert model from object back to string
|
||||||
|
model_value = local_agent.get('model', '')
|
||||||
|
if isinstance(model_value, dict):
|
||||||
|
local_agent['model'] = model_value.get('primary', '')
|
||||||
|
else:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Update using raw SQL with compatibility for both SQLite and PostgreSQL
|
||||||
|
if self.ap.persistence_mgr.db.name == 'postgresql':
|
||||||
|
await self.ap.persistence_mgr.execute_async(
|
||||||
|
sqlalchemy.text(
|
||||||
|
'UPDATE legacy_pipelines SET config = :config::jsonb, for_version = :for_version WHERE uuid = :uuid'
|
||||||
|
),
|
||||||
|
{'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid},
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
await self.ap.persistence_mgr.execute_async(
|
||||||
|
sqlalchemy.text(
|
||||||
|
'UPDATE legacy_pipelines SET config = :config, for_version = :for_version WHERE uuid = :uuid'
|
||||||
|
),
|
||||||
|
{'config': json.dumps(config), 'for_version': current_version, 'uuid': uuid},
|
||||||
|
)
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
from .. import migration
|
||||||
|
|
||||||
|
import sqlalchemy
|
||||||
|
import json
|
||||||
|
|
||||||
|
|
||||||
|
@migration.migration_class(24)
|
||||||
|
class DBMigrateWecomBotWebSocketMode(migration.DBMigration):
|
||||||
|
"""Add enable-webhook field to existing wecombot adapter configs.
|
||||||
|
|
||||||
|
Existing wecombot bots were all using webhook mode, so we set
|
||||||
|
enable-webhook=true to preserve their behavior after the new
|
||||||
|
WebSocket long connection mode is introduced as default.
|
||||||
|
"""
|
||||||
|
|
||||||
|
async def upgrade(self):
|
||||||
|
"""Upgrade"""
|
||||||
|
result = await self.ap.persistence_mgr.execute_async(
|
||||||
|
sqlalchemy.text("SELECT uuid, adapter_config FROM bots WHERE adapter = 'wecombot'")
|
||||||
|
)
|
||||||
|
bots = result.fetchall()
|
||||||
|
|
||||||
|
for bot_row in bots:
|
||||||
|
bot_uuid = bot_row[0]
|
||||||
|
adapter_config = json.loads(bot_row[1]) if isinstance(bot_row[1], str) else bot_row[1]
|
||||||
|
|
||||||
|
if 'enable-webhook' in adapter_config:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Determine mode based on existing config: if webhook fields are present, keep webhook mode
|
||||||
|
has_webhook_config = bool(
|
||||||
|
adapter_config.get('Token') and adapter_config.get('EncodingAESKey') and adapter_config.get('Corpid')
|
||||||
|
)
|
||||||
|
adapter_config['enable-webhook'] = has_webhook_config
|
||||||
|
|
||||||
|
if self.ap.persistence_mgr.db.name == 'postgresql':
|
||||||
|
await self.ap.persistence_mgr.execute_async(
|
||||||
|
sqlalchemy.text('UPDATE bots SET adapter_config = :config::jsonb WHERE uuid = :uuid'),
|
||||||
|
{'config': json.dumps(adapter_config), 'uuid': bot_uuid},
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
await self.ap.persistence_mgr.execute_async(
|
||||||
|
sqlalchemy.text('UPDATE bots SET adapter_config = :config WHERE uuid = :uuid'),
|
||||||
|
{'config': json.dumps(adapter_config), 'uuid': bot_uuid},
|
||||||
|
)
|
||||||
|
|
||||||
|
async def downgrade(self):
|
||||||
|
"""Downgrade"""
|
||||||
|
pass
|
||||||
@@ -34,6 +34,15 @@ class MonitoringHelper:
|
|||||||
# Check if session exists, if not, record session start
|
# Check if session exists, if not, record session start
|
||||||
session_id = f'{query.launcher_type}_{query.launcher_id}'
|
session_id = f'{query.launcher_type}_{query.launcher_id}'
|
||||||
|
|
||||||
|
# Get sender name from message event
|
||||||
|
sender_name = None
|
||||||
|
if hasattr(query, 'message_event'):
|
||||||
|
if hasattr(query.message_event, 'sender'):
|
||||||
|
if hasattr(query.message_event.sender, 'nickname'):
|
||||||
|
sender_name = query.message_event.sender.nickname
|
||||||
|
elif hasattr(query.message_event.sender, 'member_name'):
|
||||||
|
sender_name = query.message_event.sender.member_name
|
||||||
|
|
||||||
# Try to record message
|
# Try to record message
|
||||||
# Use JSON serialization to preserve message chain structure (including image URLs, etc.)
|
# Use JSON serialization to preserve message chain structure (including image URLs, etc.)
|
||||||
if hasattr(query, 'message_chain') and hasattr(query.message_chain, 'model_dump'):
|
if hasattr(query, 'message_chain') and hasattr(query.message_chain, 'model_dump'):
|
||||||
@@ -57,6 +66,7 @@ class MonitoringHelper:
|
|||||||
if hasattr(query.launcher_type, 'value')
|
if hasattr(query.launcher_type, 'value')
|
||||||
else str(query.launcher_type),
|
else str(query.launcher_type),
|
||||||
user_id=query.sender_id,
|
user_id=query.sender_id,
|
||||||
|
user_name=sender_name,
|
||||||
runner_name=runner_name,
|
runner_name=runner_name,
|
||||||
variables=None, # Will be updated in record_query_success
|
variables=None, # Will be updated in record_query_success
|
||||||
)
|
)
|
||||||
@@ -80,6 +90,7 @@ class MonitoringHelper:
|
|||||||
if hasattr(query.launcher_type, 'value')
|
if hasattr(query.launcher_type, 'value')
|
||||||
else str(query.launcher_type),
|
else str(query.launcher_type),
|
||||||
user_id=query.sender_id,
|
user_id=query.sender_id,
|
||||||
|
user_name=sender_name,
|
||||||
)
|
)
|
||||||
|
|
||||||
return message_id
|
return message_id
|
||||||
@@ -128,6 +139,15 @@ class MonitoringHelper:
|
|||||||
try:
|
try:
|
||||||
session_id = f'{query.launcher_type}_{query.launcher_id}'
|
session_id = f'{query.launcher_type}_{query.launcher_id}'
|
||||||
|
|
||||||
|
# Get sender name from message event
|
||||||
|
sender_name = None
|
||||||
|
if hasattr(query, 'message_event'):
|
||||||
|
if hasattr(query.message_event, 'sender'):
|
||||||
|
if hasattr(query.message_event.sender, 'nickname'):
|
||||||
|
sender_name = query.message_event.sender.nickname
|
||||||
|
elif hasattr(query.message_event.sender, 'member_name'):
|
||||||
|
sender_name = query.message_event.sender.member_name
|
||||||
|
|
||||||
# Extract response content from resp_message_chain
|
# Extract response content from resp_message_chain
|
||||||
if hasattr(query, 'resp_message_chain') and query.resp_message_chain:
|
if hasattr(query, 'resp_message_chain') and query.resp_message_chain:
|
||||||
# Serialize the last response message chain
|
# Serialize the last response message chain
|
||||||
@@ -162,6 +182,7 @@ class MonitoringHelper:
|
|||||||
if hasattr(query.launcher_type, 'value')
|
if hasattr(query.launcher_type, 'value')
|
||||||
else str(query.launcher_type),
|
else str(query.launcher_type),
|
||||||
user_id=query.sender_id,
|
user_id=query.sender_id,
|
||||||
|
user_name=sender_name,
|
||||||
runner_name=runner_name,
|
runner_name=runner_name,
|
||||||
role='assistant',
|
role='assistant',
|
||||||
)
|
)
|
||||||
@@ -183,6 +204,15 @@ class MonitoringHelper:
|
|||||||
try:
|
try:
|
||||||
session_id = f'{query.launcher_type}_{query.launcher_id}'
|
session_id = f'{query.launcher_type}_{query.launcher_id}'
|
||||||
|
|
||||||
|
# Get sender name from message event
|
||||||
|
sender_name = None
|
||||||
|
if hasattr(query, 'message_event'):
|
||||||
|
if hasattr(query.message_event, 'sender'):
|
||||||
|
if hasattr(query.message_event.sender, 'nickname'):
|
||||||
|
sender_name = query.message_event.sender.nickname
|
||||||
|
elif hasattr(query.message_event.sender, 'member_name'):
|
||||||
|
sender_name = query.message_event.sender.member_name
|
||||||
|
|
||||||
# Record error message
|
# Record error message
|
||||||
message_id = await ap.monitoring_service.record_message(
|
message_id = await ap.monitoring_service.record_message(
|
||||||
bot_id=bot_id,
|
bot_id=bot_id,
|
||||||
@@ -197,6 +227,7 @@ class MonitoringHelper:
|
|||||||
if hasattr(query.launcher_type, 'value')
|
if hasattr(query.launcher_type, 'value')
|
||||||
else str(query.launcher_type),
|
else str(query.launcher_type),
|
||||||
user_id=query.sender_id,
|
user_id=query.sender_id,
|
||||||
|
user_name=sender_name,
|
||||||
runner_name=runner_name,
|
runner_name=runner_name,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -36,17 +36,36 @@ class PreProcessor(stage.PipelineStage):
|
|||||||
session = await self.ap.sess_mgr.get_session(query)
|
session = await self.ap.sess_mgr.get_session(query)
|
||||||
|
|
||||||
# When not local-agent, llm_model is None
|
# When not local-agent, llm_model is None
|
||||||
try:
|
llm_model = None
|
||||||
llm_model = (
|
if selected_runner == 'local-agent':
|
||||||
await self.ap.model_mgr.get_model_by_uuid(query.pipeline_config['ai']['local-agent']['model'])
|
# Read model config — new format is { primary: str, fallbacks: [str] },
|
||||||
if selected_runner == 'local-agent'
|
# but handle legacy plain string for backward compatibility
|
||||||
else None
|
model_config = query.pipeline_config['ai']['local-agent'].get('model', {})
|
||||||
)
|
if isinstance(model_config, str):
|
||||||
except ValueError:
|
# Legacy format: plain UUID string
|
||||||
self.ap.logger.warning(
|
primary_uuid = model_config
|
||||||
f'LLM model {query.pipeline_config["ai"]["local-agent"]["model"] + " "}not found or not configured'
|
fallback_uuids = []
|
||||||
)
|
else:
|
||||||
llm_model = None
|
primary_uuid = model_config.get('primary', '')
|
||||||
|
fallback_uuids = model_config.get('fallbacks', [])
|
||||||
|
|
||||||
|
if primary_uuid:
|
||||||
|
try:
|
||||||
|
llm_model = await self.ap.model_mgr.get_model_by_uuid(primary_uuid)
|
||||||
|
except ValueError:
|
||||||
|
self.ap.logger.warning(f'LLM model {primary_uuid} not found or not configured')
|
||||||
|
|
||||||
|
# Resolve fallback model UUIDs
|
||||||
|
if fallback_uuids:
|
||||||
|
valid_fallbacks = []
|
||||||
|
for fb_uuid in fallback_uuids:
|
||||||
|
try:
|
||||||
|
await self.ap.model_mgr.get_model_by_uuid(fb_uuid)
|
||||||
|
valid_fallbacks.append(fb_uuid)
|
||||||
|
except ValueError:
|
||||||
|
self.ap.logger.warning(f'Fallback model {fb_uuid} not found, skipping')
|
||||||
|
if valid_fallbacks:
|
||||||
|
query.variables['_fallback_model_uuids'] = valid_fallbacks
|
||||||
|
|
||||||
conversation = await self.ap.sess_mgr.get_conversation(
|
conversation = await self.ap.sess_mgr.get_conversation(
|
||||||
query,
|
query,
|
||||||
@@ -61,20 +80,28 @@ class PreProcessor(stage.PipelineStage):
|
|||||||
query.prompt = conversation.prompt.copy()
|
query.prompt = conversation.prompt.copy()
|
||||||
query.messages = conversation.messages.copy()
|
query.messages = conversation.messages.copy()
|
||||||
|
|
||||||
if selected_runner == 'local-agent' and llm_model:
|
if selected_runner == 'local-agent':
|
||||||
query.use_funcs = []
|
query.use_funcs = []
|
||||||
query.use_llm_model_uuid = llm_model.model_entity.uuid
|
if llm_model:
|
||||||
|
query.use_llm_model_uuid = llm_model.model_entity.uuid
|
||||||
|
|
||||||
if llm_model.model_entity.abilities.__contains__('func_call'):
|
if llm_model.model_entity.abilities.__contains__('func_call'):
|
||||||
# Get bound plugins and MCP servers for filtering tools
|
# Get bound plugins and MCP servers for filtering tools
|
||||||
|
bound_plugins = query.variables.get('_pipeline_bound_plugins', None)
|
||||||
|
bound_mcp_servers = query.variables.get('_pipeline_bound_mcp_servers', None)
|
||||||
|
query.use_funcs = await self.ap.tool_mgr.get_all_tools(bound_plugins, bound_mcp_servers)
|
||||||
|
|
||||||
|
self.ap.logger.debug(f'Bound plugins: {bound_plugins}')
|
||||||
|
self.ap.logger.debug(f'Bound MCP servers: {bound_mcp_servers}')
|
||||||
|
self.ap.logger.debug(f'Use funcs: {query.use_funcs}')
|
||||||
|
|
||||||
|
# If primary model doesn't support func_call but fallback models exist,
|
||||||
|
# load tools anyway since fallback models may support them
|
||||||
|
if not query.use_funcs and query.variables.get('_fallback_model_uuids'):
|
||||||
bound_plugins = query.variables.get('_pipeline_bound_plugins', None)
|
bound_plugins = query.variables.get('_pipeline_bound_plugins', None)
|
||||||
bound_mcp_servers = query.variables.get('_pipeline_bound_mcp_servers', None)
|
bound_mcp_servers = query.variables.get('_pipeline_bound_mcp_servers', None)
|
||||||
query.use_funcs = await self.ap.tool_mgr.get_all_tools(bound_plugins, bound_mcp_servers)
|
query.use_funcs = await self.ap.tool_mgr.get_all_tools(bound_plugins, bound_mcp_servers)
|
||||||
|
|
||||||
self.ap.logger.debug(f'Bound plugins: {bound_plugins}')
|
|
||||||
self.ap.logger.debug(f'Bound MCP servers: {bound_mcp_servers}')
|
|
||||||
self.ap.logger.debug(f'Use funcs: {query.use_funcs}')
|
|
||||||
|
|
||||||
sender_name = ''
|
sender_name = ''
|
||||||
|
|
||||||
if isinstance(query.message_event, platform_events.GroupMessage):
|
if isinstance(query.message_event, platform_events.GroupMessage):
|
||||||
|
|||||||
@@ -149,12 +149,19 @@ class ChatMessageHandler(handler.MessageHandler):
|
|||||||
self.ap.logger.error(f'Conversation({query.query_id}) Request Failed: {error_info}')
|
self.ap.logger.error(f'Conversation({query.query_id}) Request Failed: {error_info}')
|
||||||
traceback.print_exc()
|
traceback.print_exc()
|
||||||
|
|
||||||
hide_exception_info = query.pipeline_config['output']['misc']['hide-exception']
|
exception_handling = query.pipeline_config['output']['misc'].get('exception-handling', 'show-hint')
|
||||||
|
|
||||||
|
if exception_handling == 'show-error':
|
||||||
|
user_notice = f'{e}'
|
||||||
|
elif exception_handling == 'show-hint':
|
||||||
|
user_notice = query.pipeline_config['output']['misc'].get('failure-hint', 'Request failed.')
|
||||||
|
else: # hide
|
||||||
|
user_notice = None
|
||||||
|
|
||||||
yield entities.StageProcessResult(
|
yield entities.StageProcessResult(
|
||||||
result_type=entities.ResultType.INTERRUPT,
|
result_type=entities.ResultType.INTERRUPT,
|
||||||
new_query=query,
|
new_query=query,
|
||||||
user_notice='请求失败' if hide_exception_info else f'{e}',
|
user_notice=user_notice,
|
||||||
error_notice=f'{e}',
|
error_notice=f'{e}',
|
||||||
debug_notice=traceback.format_exc(),
|
debug_notice=traceback.format_exc(),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -575,6 +575,127 @@ class LarkMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
|
|||||||
|
|
||||||
|
|
||||||
class LarkEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
class LarkEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||||
|
_processed_thread_quote_cache: typing.ClassVar[dict[str, float]] = {}
|
||||||
|
_processed_thread_quote_cache_max_size: typing.ClassVar[int] = 4096
|
||||||
|
_processed_thread_quote_cache_ttl_seconds: typing.ClassVar[int] = 86400
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _prune_processed_thread_quote_cache(cls, now: typing.Optional[float] = None) -> None:
|
||||||
|
if now is None:
|
||||||
|
now = time.time()
|
||||||
|
|
||||||
|
expire_before = now - cls._processed_thread_quote_cache_ttl_seconds
|
||||||
|
while cls._processed_thread_quote_cache:
|
||||||
|
oldest_key, oldest_ts = next(iter(cls._processed_thread_quote_cache.items()))
|
||||||
|
if oldest_ts >= expire_before:
|
||||||
|
break
|
||||||
|
cls._processed_thread_quote_cache.pop(oldest_key, None)
|
||||||
|
|
||||||
|
while len(cls._processed_thread_quote_cache) > cls._processed_thread_quote_cache_max_size:
|
||||||
|
oldest_key = next(iter(cls._processed_thread_quote_cache))
|
||||||
|
cls._processed_thread_quote_cache.pop(oldest_key, None)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _mark_thread_quote_processed(cls, thread_id: str) -> None:
|
||||||
|
now = time.time()
|
||||||
|
cls._prune_processed_thread_quote_cache(now)
|
||||||
|
cls._processed_thread_quote_cache[thread_id] = now
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def _extract_quote_message_id(cls, message: EventMessage) -> typing.Optional[str]:
|
||||||
|
"""
|
||||||
|
Extract the message ID to quote from the given message.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- First thread reply in a topic: return parent_id and mark topic as processed
|
||||||
|
- Follow-up thread replies in the same topic: return None
|
||||||
|
- Non-thread message: return parent_id if valid (non-empty, different from message_id)
|
||||||
|
|
||||||
|
Thread reply state is kept in a bounded TTL cache to avoid unbounded memory growth.
|
||||||
|
"""
|
||||||
|
parent_id = getattr(message, 'parent_id', None)
|
||||||
|
if not parent_id:
|
||||||
|
return None
|
||||||
|
|
||||||
|
message_id = getattr(message, 'message_id', None)
|
||||||
|
if parent_id == message_id:
|
||||||
|
return None
|
||||||
|
|
||||||
|
thread_id = getattr(message, 'thread_id', None)
|
||||||
|
if thread_id:
|
||||||
|
cls._prune_processed_thread_quote_cache()
|
||||||
|
if thread_id in cls._processed_thread_quote_cache:
|
||||||
|
return None
|
||||||
|
cls._mark_thread_quote_processed(thread_id)
|
||||||
|
|
||||||
|
return parent_id
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _build_event_message_from_message_item(message_item: Message) -> typing.Optional[EventMessage]:
|
||||||
|
"""
|
||||||
|
Build EventMessage from SDK typed Message item.
|
||||||
|
|
||||||
|
Returns None if body or content is missing.
|
||||||
|
"""
|
||||||
|
body = getattr(message_item, 'body', None)
|
||||||
|
if not body:
|
||||||
|
return None
|
||||||
|
|
||||||
|
content = getattr(body, 'content', None)
|
||||||
|
if not content:
|
||||||
|
return None
|
||||||
|
|
||||||
|
event_data = {
|
||||||
|
'message_id': message_item.message_id,
|
||||||
|
'message_type': message_item.msg_type,
|
||||||
|
'content': content,
|
||||||
|
'create_time': message_item.create_time,
|
||||||
|
'mentions': getattr(message_item, 'mentions', []) or [],
|
||||||
|
}
|
||||||
|
|
||||||
|
# Preserve thread-related fields
|
||||||
|
if hasattr(message_item, 'parent_id') and message_item.parent_id:
|
||||||
|
event_data['parent_id'] = message_item.parent_id
|
||||||
|
if hasattr(message_item, 'root_id') and message_item.root_id:
|
||||||
|
event_data['root_id'] = message_item.root_id
|
||||||
|
if hasattr(message_item, 'thread_id') and message_item.thread_id:
|
||||||
|
event_data['thread_id'] = message_item.thread_id
|
||||||
|
if hasattr(message_item, 'chat_id') and message_item.chat_id:
|
||||||
|
event_data['chat_id'] = message_item.chat_id
|
||||||
|
|
||||||
|
return EventMessage(event_data)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def _fetch_quoted_message(
|
||||||
|
quote_message_id: str,
|
||||||
|
api_client: lark_oapi.Client,
|
||||||
|
) -> typing.Optional[platform_message.MessageChain]:
|
||||||
|
"""
|
||||||
|
Fetch the quoted message and convert to MessageChain.
|
||||||
|
|
||||||
|
Returns None if:
|
||||||
|
- API call fails
|
||||||
|
- Response items is empty
|
||||||
|
- Message item normalization fails
|
||||||
|
"""
|
||||||
|
request = GetMessageRequest.builder().message_id(quote_message_id).build()
|
||||||
|
response = await api_client.im.v1.message.aget(request)
|
||||||
|
|
||||||
|
if not response.success():
|
||||||
|
return None
|
||||||
|
|
||||||
|
items = getattr(response.data, 'items', None)
|
||||||
|
if not items:
|
||||||
|
return None
|
||||||
|
|
||||||
|
message_item = items[0]
|
||||||
|
event_message = LarkEventConverter._build_event_message_from_message_item(message_item)
|
||||||
|
if event_message is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
quote_chain = await LarkMessageConverter.target2yiri(event_message, api_client)
|
||||||
|
return quote_chain
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def yiri2target(
|
async def yiri2target(
|
||||||
event: platform_events.MessageEvent,
|
event: platform_events.MessageEvent,
|
||||||
@@ -587,6 +708,23 @@ class LarkEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
|||||||
) -> platform_events.Event:
|
) -> platform_events.Event:
|
||||||
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
|
||||||
|
quote_message_id = LarkEventConverter._extract_quote_message_id(event.event.message)
|
||||||
|
if quote_message_id:
|
||||||
|
quote_chain = await LarkEventConverter._fetch_quoted_message(quote_message_id, api_client)
|
||||||
|
if quote_chain:
|
||||||
|
# Filter out Source component from quoted chain, keep only content
|
||||||
|
quote_origin = platform_message.MessageChain(
|
||||||
|
[comp for comp in quote_chain if not isinstance(comp, platform_message.Source)]
|
||||||
|
)
|
||||||
|
if quote_origin:
|
||||||
|
message_chain.append(
|
||||||
|
platform_message.Quote(
|
||||||
|
message_id=quote_message_id,
|
||||||
|
origin=quote_origin,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
if event.event.message.chat_type == 'p2p':
|
if event.event.message.chat_type == 'p2p':
|
||||||
return platform_events.FriendMessage(
|
return platform_events.FriendMessage(
|
||||||
sender=platform_entities.Friend(
|
sender=platform_entities.Friend(
|
||||||
@@ -770,6 +908,32 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
self.request_tenant_access_token(tenant_key)
|
self.request_tenant_access_token(tenant_key)
|
||||||
return self.tenant_access_tokens.get(tenant_key)['token'] if self.tenant_access_tokens.get(tenant_key) else None
|
return self.tenant_access_tokens.get(tenant_key)['token'] if self.tenant_access_tokens.get(tenant_key) else None
|
||||||
|
|
||||||
|
def get_launcher_id(self, event: platform_events.MessageEvent) -> str | None:
|
||||||
|
"""
|
||||||
|
Get topic-scoped launcher_id for thread-aware session isolation.
|
||||||
|
|
||||||
|
For group thread messages, returns "{group_id}_{thread_id}"
|
||||||
|
to ensure conversation context stays stable per topic.
|
||||||
|
|
||||||
|
Returns None for non-thread messages or P2P messages.
|
||||||
|
"""
|
||||||
|
source_event = getattr(event.source_platform_object, 'event', None)
|
||||||
|
if not source_event:
|
||||||
|
return None
|
||||||
|
|
||||||
|
message = getattr(source_event, 'message', None)
|
||||||
|
if not message:
|
||||||
|
return None
|
||||||
|
|
||||||
|
thread_id = getattr(message, 'thread_id', None)
|
||||||
|
if not thread_id:
|
||||||
|
return None
|
||||||
|
|
||||||
|
if isinstance(event, platform_events.GroupMessage):
|
||||||
|
return f'{event.group.id}_{thread_id}'
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
def build_api_client(self, config):
|
def build_api_client(self, config):
|
||||||
app_id = config['app_id']
|
app_id = config['app_id']
|
||||||
app_secret = config['app_secret']
|
app_secret = config['app_secret']
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import langbot_plugin.api.entities.builtin.platform.entities as platform_entitie
|
|||||||
from ..logger import EventLogger
|
from ..logger import EventLogger
|
||||||
from langbot.libs.wecom_ai_bot_api.wecombotevent import WecomBotEvent
|
from langbot.libs.wecom_ai_bot_api.wecombotevent import WecomBotEvent
|
||||||
from langbot.libs.wecom_ai_bot_api.api import WecomBotClient
|
from langbot.libs.wecom_ai_bot_api.api import WecomBotClient
|
||||||
|
from langbot.libs.wecom_ai_bot_api.ws_client import WecomBotWsClient
|
||||||
|
|
||||||
|
|
||||||
class WecomBotMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
|
class WecomBotMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
|
||||||
@@ -176,27 +177,42 @@ class WecomBotEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
|||||||
|
|
||||||
|
|
||||||
class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||||
bot: WecomBotClient
|
bot: typing.Union[WecomBotClient, WecomBotWsClient]
|
||||||
bot_account_id: str
|
bot_account_id: str
|
||||||
message_converter: WecomBotMessageConverter = WecomBotMessageConverter()
|
message_converter: WecomBotMessageConverter = WecomBotMessageConverter()
|
||||||
event_converter: WecomBotEventConverter = WecomBotEventConverter()
|
event_converter: WecomBotEventConverter = WecomBotEventConverter()
|
||||||
config: dict
|
config: dict
|
||||||
bot_uuid: str = None
|
bot_uuid: str = None
|
||||||
|
_ws_mode: bool = False
|
||||||
|
|
||||||
def __init__(self, config: dict, logger: EventLogger):
|
def __init__(self, config: dict, logger: EventLogger):
|
||||||
required_keys = ['Token', 'EncodingAESKey', 'Corpid', 'BotId']
|
enable_webhook = config.get('enable-webhook', False)
|
||||||
missing_keys = [key for key in required_keys if key not in config]
|
|
||||||
if missing_keys:
|
|
||||||
raise Exception(f'WecomBot 缺少配置项: {missing_keys}')
|
|
||||||
|
|
||||||
bot = WecomBotClient(
|
if not enable_webhook:
|
||||||
Token=config['Token'],
|
bot = WecomBotWsClient(
|
||||||
EnCodingAESKey=config['EncodingAESKey'],
|
bot_id=config['BotId'],
|
||||||
Corpid=config['Corpid'],
|
secret=config['Secret'],
|
||||||
logger=logger,
|
logger=logger,
|
||||||
unified_mode=True,
|
encoding_aes_key=config.get('EncodingAESKey', ''),
|
||||||
)
|
)
|
||||||
bot_account_id = config['BotId']
|
ws_mode = True
|
||||||
|
else:
|
||||||
|
# Webhook callback mode
|
||||||
|
required_keys = ['Token', 'EncodingAESKey', 'Corpid']
|
||||||
|
missing_keys = [key for key in required_keys if key not in config or not config[key]]
|
||||||
|
if missing_keys:
|
||||||
|
raise Exception(f'WecomBot webhook mode missing config: {missing_keys}')
|
||||||
|
|
||||||
|
bot = WecomBotClient(
|
||||||
|
Token=config['Token'],
|
||||||
|
EnCodingAESKey=config['EncodingAESKey'],
|
||||||
|
Corpid=config['Corpid'],
|
||||||
|
logger=logger,
|
||||||
|
unified_mode=True,
|
||||||
|
)
|
||||||
|
ws_mode = False
|
||||||
|
|
||||||
|
bot_account_id = config.get('BotId', '')
|
||||||
|
|
||||||
super().__init__(
|
super().__init__(
|
||||||
config=config,
|
config=config,
|
||||||
@@ -204,6 +220,7 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
bot=bot,
|
bot=bot,
|
||||||
bot_account_id=bot_account_id,
|
bot_account_id=bot_account_id,
|
||||||
)
|
)
|
||||||
|
self._ws_mode = ws_mode
|
||||||
|
|
||||||
async def reply_message(
|
async def reply_message(
|
||||||
self,
|
self,
|
||||||
@@ -212,7 +229,15 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
quote_origin: bool = False,
|
quote_origin: bool = False,
|
||||||
):
|
):
|
||||||
content = await self.message_converter.yiri2target(message)
|
content = await self.message_converter.yiri2target(message)
|
||||||
await self.bot.set_message(message_source.source_platform_object.message_id, content)
|
if self._ws_mode:
|
||||||
|
event = message_source.source_platform_object
|
||||||
|
req_id = event.get('req_id', '')
|
||||||
|
if req_id:
|
||||||
|
await self.bot.reply_text(req_id, content)
|
||||||
|
else:
|
||||||
|
await self.bot.set_message(event.message_id, content)
|
||||||
|
else:
|
||||||
|
await self.bot.set_message(message_source.source_platform_object.message_id, content)
|
||||||
|
|
||||||
async def reply_message_chunk(
|
async def reply_message_chunk(
|
||||||
self,
|
self,
|
||||||
@@ -222,31 +247,22 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
quote_origin: bool = False,
|
quote_origin: bool = False,
|
||||||
is_final: bool = False,
|
is_final: bool = False,
|
||||||
):
|
):
|
||||||
"""将流水线增量输出写入企业微信 stream 会话。
|
|
||||||
|
|
||||||
Args:
|
|
||||||
message_source: 流水线提供的原始消息事件。
|
|
||||||
bot_message: 当前片段对应的模型元信息(未使用)。
|
|
||||||
message: 需要回复的消息链。
|
|
||||||
quote_origin: 是否引用原消息(企业微信暂不支持)。
|
|
||||||
is_final: 标记当前片段是否为最终回复。
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
dict: 包含 `stream` 键,标识写入是否成功。
|
|
||||||
|
|
||||||
Example:
|
|
||||||
在流水线 `reply_message_chunk` 调用中自动触发,无需手动调用。
|
|
||||||
"""
|
|
||||||
# 转换为纯文本(智能机器人当前协议仅支持文本流)
|
|
||||||
content = await self.message_converter.yiri2target(message)
|
content = await self.message_converter.yiri2target(message)
|
||||||
msg_id = message_source.source_platform_object.message_id
|
msg_id = message_source.source_platform_object.message_id
|
||||||
|
|
||||||
# 将片段推送到 WecomBotClient 中的队列,返回值用于判断是否走降级逻辑
|
if self._ws_mode:
|
||||||
success = await self.bot.push_stream_chunk(msg_id, content, is_final=is_final)
|
success = await self.bot.push_stream_chunk(msg_id, content, is_final=is_final)
|
||||||
if not success and is_final:
|
if not success and is_final:
|
||||||
# 未命中流式队列时使用旧有 set_message 兜底
|
event = message_source.source_platform_object
|
||||||
await self.bot.set_message(msg_id, content)
|
req_id = event.get('req_id', '')
|
||||||
return {'stream': success}
|
if req_id:
|
||||||
|
await self.bot.reply_text(req_id, content)
|
||||||
|
return {'stream': success}
|
||||||
|
else:
|
||||||
|
success = await self.bot.push_stream_chunk(msg_id, content, is_final=is_final)
|
||||||
|
if not success and is_final:
|
||||||
|
await self.bot.set_message(msg_id, content)
|
||||||
|
return {'stream': success}
|
||||||
|
|
||||||
async def is_stream_output_supported(self) -> bool:
|
async def is_stream_output_supported(self) -> bool:
|
||||||
"""智能机器人侧默认开启流式能力。
|
"""智能机器人侧默认开启流式能力。
|
||||||
@@ -259,7 +275,11 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
return True
|
return True
|
||||||
|
|
||||||
async def send_message(self, target_type, target_id, message):
|
async def send_message(self, target_type, target_id, message):
|
||||||
pass
|
if self._ws_mode:
|
||||||
|
content = await self.message_converter.yiri2target(message)
|
||||||
|
await self.bot.send_message(target_id, content)
|
||||||
|
else:
|
||||||
|
pass
|
||||||
|
|
||||||
def register_listener(
|
def register_listener(
|
||||||
self,
|
self,
|
||||||
@@ -288,29 +308,25 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
self.bot_uuid = bot_uuid
|
self.bot_uuid = bot_uuid
|
||||||
|
|
||||||
async def handle_unified_webhook(self, bot_uuid: str, path: str, request):
|
async def handle_unified_webhook(self, bot_uuid: str, path: str, request):
|
||||||
"""处理统一 webhook 请求。
|
if self._ws_mode:
|
||||||
|
return None
|
||||||
Args:
|
|
||||||
bot_uuid: Bot 的 UUID
|
|
||||||
path: 子路径(如果有的话)
|
|
||||||
request: Quart Request 对象
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
响应数据
|
|
||||||
"""
|
|
||||||
return await self.bot.handle_unified_webhook(request)
|
return await self.bot.handle_unified_webhook(request)
|
||||||
|
|
||||||
async def run_async(self):
|
async def run_async(self):
|
||||||
# 统一 webhook 模式下,不启动独立的 Quart 应用
|
if self._ws_mode:
|
||||||
# 保持运行但不启动独立端口
|
await self.bot.connect()
|
||||||
|
else:
|
||||||
|
|
||||||
async def keep_alive():
|
async def keep_alive():
|
||||||
while True:
|
while True:
|
||||||
await asyncio.sleep(1)
|
await asyncio.sleep(1)
|
||||||
|
|
||||||
await keep_alive()
|
await keep_alive()
|
||||||
|
|
||||||
async def kill(self) -> bool:
|
async def kill(self) -> bool:
|
||||||
|
if self._ws_mode:
|
||||||
|
await self.bot.disconnect()
|
||||||
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
async def unregister_listener(
|
async def unregister_listener(
|
||||||
|
|||||||
@@ -11,35 +11,64 @@ metadata:
|
|||||||
icon: wecombot.png
|
icon: wecombot.png
|
||||||
spec:
|
spec:
|
||||||
config:
|
config:
|
||||||
|
- name: BotId
|
||||||
|
label:
|
||||||
|
en_US: BotId
|
||||||
|
zh_Hans: 机器人ID (BotId)
|
||||||
|
type: string
|
||||||
|
required: true
|
||||||
|
default: ""
|
||||||
|
- name: enable-webhook
|
||||||
|
label:
|
||||||
|
en_US: Enable Webhook Mode
|
||||||
|
zh_Hans: 启用Webhook模式
|
||||||
|
description:
|
||||||
|
en_US: If enabled, the bot will use webhook mode to receive messages. Otherwise, it will use WS long connection mode
|
||||||
|
zh_Hans: 如果启用,机器人将使用 Webhook 模式接收消息。否则,将使用 WS 长连接模式
|
||||||
|
type: boolean
|
||||||
|
required: true
|
||||||
|
default: false
|
||||||
|
- name: Secret
|
||||||
|
label:
|
||||||
|
en_US: Secret
|
||||||
|
zh_Hans: 机器人密钥 (Secret)
|
||||||
|
description:
|
||||||
|
en_US: Required for WebSocket long connection mode
|
||||||
|
zh_Hans: 使用 WS 长连接模式时必填
|
||||||
|
type: string
|
||||||
|
required: false
|
||||||
|
default: ""
|
||||||
- name: Corpid
|
- name: Corpid
|
||||||
label:
|
label:
|
||||||
en_US: Corpid
|
en_US: Corpid
|
||||||
zh_Hans: 企业ID
|
zh_Hans: 企业ID
|
||||||
|
description:
|
||||||
|
en_US: Required for Webhook mode
|
||||||
|
zh_Hans: 使用 Webhook 模式时必填
|
||||||
type: string
|
type: string
|
||||||
required: true
|
required: false
|
||||||
default: ""
|
default: ""
|
||||||
- name: Token
|
- name: Token
|
||||||
label:
|
label:
|
||||||
en_US: Token
|
en_US: Token
|
||||||
zh_Hans: 令牌 (Token)
|
zh_Hans: 令牌 (Token)
|
||||||
|
description:
|
||||||
|
en_US: Required for Webhook mode
|
||||||
|
zh_Hans: 使用 Webhook 模式时必填
|
||||||
type: string
|
type: string
|
||||||
required: true
|
required: false
|
||||||
default: ""
|
default: ""
|
||||||
- name: EncodingAESKey
|
- name: EncodingAESKey
|
||||||
label:
|
label:
|
||||||
en_US: EncodingAESKey
|
en_US: EncodingAESKey
|
||||||
zh_Hans: 消息加解密密钥 (EncodingAESKey)
|
zh_Hans: 消息加解密密钥 (EncodingAESKey)
|
||||||
type: string
|
description:
|
||||||
required: true
|
en_US: Required for Webhook mode. Optional for WebSocket mode (used for file decryption)
|
||||||
default: ""
|
zh_Hans: 使用 Webhook 模式时必填。WebSocket 模式下可选(用于文件解密)
|
||||||
- name: BotId
|
|
||||||
label:
|
|
||||||
en_US: BotId
|
|
||||||
zh_Hans: 机器人ID
|
|
||||||
type: string
|
type: string
|
||||||
required: false
|
required: false
|
||||||
default: ""
|
default: ""
|
||||||
execution:
|
execution:
|
||||||
python:
|
python:
|
||||||
path: ./wecombot.py
|
path: ./wecombot.py
|
||||||
attr: WecomBotAdapter
|
attr: WecomBotAdapter
|
||||||
|
|||||||
@@ -81,22 +81,33 @@ class WecomEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
|||||||
return event.source_platform_object
|
return event.source_platform_object
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def target2yiri(event: WecomCSEvent):
|
async def target2yiri(event: WecomCSEvent, bot: WecomCSClient = None):
|
||||||
"""
|
"""
|
||||||
将 WecomEvent 转换为平台的 FriendMessage 对象。
|
将 WecomEvent 转换为平台的 FriendMessage 对象。
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
event (WecomEvent): 企业微信客服事件。
|
event (WecomEvent): 企业微信客服事件。
|
||||||
|
bot (WecomCSClient): 企业微信客服客户端,用于获取用户信息。
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
platform_events.FriendMessage: 转换后的 FriendMessage 对象。
|
platform_events.FriendMessage: 转换后的 FriendMessage 对象。
|
||||||
"""
|
"""
|
||||||
|
# Try to get customer nickname from WeChat API
|
||||||
|
nickname = str(event.user_id)
|
||||||
|
if bot and event.user_id:
|
||||||
|
try:
|
||||||
|
customer_info = await bot.get_customer_info(event.user_id)
|
||||||
|
if customer_info and customer_info.get('nickname'):
|
||||||
|
nickname = customer_info.get('nickname')
|
||||||
|
except Exception:
|
||||||
|
pass # Fall back to user_id as nickname
|
||||||
|
|
||||||
# 转换消息链
|
# 转换消息链
|
||||||
if event.type == 'text':
|
if event.type == 'text':
|
||||||
yiri_chain = await WecomMessageConverter.target2yiri(event.message, event.message_id)
|
yiri_chain = await WecomMessageConverter.target2yiri(event.message, event.message_id)
|
||||||
friend = platform_entities.Friend(
|
friend = platform_entities.Friend(
|
||||||
id=f'u{event.user_id}',
|
id=f'u{event.user_id}',
|
||||||
nickname=str(event.user_id),
|
nickname=nickname,
|
||||||
remark='',
|
remark='',
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -106,7 +117,7 @@ class WecomEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
|||||||
elif event.type == 'image':
|
elif event.type == 'image':
|
||||||
friend = platform_entities.Friend(
|
friend = platform_entities.Friend(
|
||||||
id=f'u{event.user_id}',
|
id=f'u{event.user_id}',
|
||||||
nickname=str(event.user_id),
|
nickname=nickname,
|
||||||
remark='',
|
remark='',
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -187,7 +198,7 @@ class WecomCSAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
async def on_message(event: WecomCSEvent):
|
async def on_message(event: WecomCSEvent):
|
||||||
self.bot_account_id = event.receiver_id
|
self.bot_account_id = event.receiver_id
|
||||||
try:
|
try:
|
||||||
return await callback(await self.event_converter.target2yiri(event), self)
|
return await callback(await self.event_converter.target2yiri(event, self.bot), self)
|
||||||
except Exception:
|
except Exception:
|
||||||
await self.logger.error(f'Error in wecomcs callback: {traceback.format_exc()}')
|
await self.logger.error(f'Error in wecomcs callback: {traceback.format_exc()}')
|
||||||
|
|
||||||
|
|||||||
@@ -565,6 +565,16 @@ class RuntimeConnectionHandler(handler.Handler):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
return _make_rag_error_response(e, 'FileServiceError', storage_path=storage_path)
|
return _make_rag_error_response(e, 'FileServiceError', storage_path=storage_path)
|
||||||
|
|
||||||
|
@self.action(PluginToRuntimeAction.LIST_PARSERS)
|
||||||
|
async def list_parsers(data: dict[str, Any]) -> handler.ActionResponse:
|
||||||
|
"""Plugin requests host to list available parser plugins."""
|
||||||
|
mime_type = data.get('mime_type')
|
||||||
|
try:
|
||||||
|
parsers = await self.ap.knowledge_service.list_parsers(mime_type)
|
||||||
|
return handler.ActionResponse.success(data={'parsers': parsers})
|
||||||
|
except Exception as e:
|
||||||
|
return _make_rag_error_response(e, 'ParserDiscoveryError', mime_type=mime_type)
|
||||||
|
|
||||||
@self.action(PluginToRuntimeAction.INVOKE_PARSER)
|
@self.action(PluginToRuntimeAction.INVOKE_PARSER)
|
||||||
async def invoke_parser(data: dict[str, Any]) -> handler.ActionResponse:
|
async def invoke_parser(data: dict[str, Any]) -> handler.ActionResponse:
|
||||||
"""Plugin requests host to invoke a parser plugin."""
|
"""Plugin requests host to invoke a parser plugin."""
|
||||||
@@ -589,6 +599,94 @@ class RuntimeConnectionHandler(handler.Handler):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
return _make_rag_error_response(e, 'ParserError')
|
return _make_rag_error_response(e, 'ParserError')
|
||||||
|
|
||||||
|
# ================= Knowledge Base Query APIs =================
|
||||||
|
|
||||||
|
@self.action(PluginToRuntimeAction.LIST_PIPELINE_KNOWLEDGE_BASES)
|
||||||
|
async def list_pipeline_knowledge_bases(data: dict[str, Any]) -> handler.ActionResponse:
|
||||||
|
"""List knowledge bases configured for the current query's pipeline."""
|
||||||
|
query_id = data['query_id']
|
||||||
|
|
||||||
|
if query_id not in self.ap.query_pool.cached_queries:
|
||||||
|
return handler.ActionResponse.error(
|
||||||
|
message=f'Query with query_id {query_id} not found',
|
||||||
|
)
|
||||||
|
|
||||||
|
query = self.ap.query_pool.cached_queries[query_id]
|
||||||
|
|
||||||
|
kb_uuids = []
|
||||||
|
if query.pipeline_config:
|
||||||
|
local_agent_config = query.pipeline_config.get('ai', {}).get('local-agent', {})
|
||||||
|
kb_uuids = local_agent_config.get('knowledge-bases', [])
|
||||||
|
# Backward compatibility
|
||||||
|
if not kb_uuids:
|
||||||
|
old_kb_uuid = local_agent_config.get('knowledge-base', '')
|
||||||
|
if old_kb_uuid and old_kb_uuid != '__none__':
|
||||||
|
kb_uuids = [old_kb_uuid]
|
||||||
|
|
||||||
|
knowledge_bases = []
|
||||||
|
for kb_uuid in kb_uuids:
|
||||||
|
kb = await self.ap.rag_mgr.get_knowledge_base_by_uuid(kb_uuid)
|
||||||
|
if kb:
|
||||||
|
knowledge_bases.append(
|
||||||
|
{
|
||||||
|
'uuid': kb.get_uuid(),
|
||||||
|
'name': kb.get_name(),
|
||||||
|
'description': kb.knowledge_base_entity.description or '',
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return handler.ActionResponse.success(data={'knowledge_bases': knowledge_bases})
|
||||||
|
|
||||||
|
@self.action(PluginToRuntimeAction.RETRIEVE_KNOWLEDGE_BASE)
|
||||||
|
async def retrieve_knowledge_base(data: dict[str, Any]) -> handler.ActionResponse:
|
||||||
|
"""Retrieve documents from a knowledge base within the pipeline's scope."""
|
||||||
|
query_id = data['query_id']
|
||||||
|
kb_id = data['kb_id']
|
||||||
|
query_text = data['query_text']
|
||||||
|
top_k = data.get('top_k', 5)
|
||||||
|
filters = data.get('filters', {})
|
||||||
|
|
||||||
|
if query_id not in self.ap.query_pool.cached_queries:
|
||||||
|
return handler.ActionResponse.error(
|
||||||
|
message=f'Query with query_id {query_id} not found',
|
||||||
|
)
|
||||||
|
|
||||||
|
query = self.ap.query_pool.cached_queries[query_id]
|
||||||
|
|
||||||
|
# Validate kb_id is in pipeline's allowed list
|
||||||
|
allowed_kb_uuids = []
|
||||||
|
if query.pipeline_config:
|
||||||
|
local_agent_config = query.pipeline_config.get('ai', {}).get('local-agent', {})
|
||||||
|
allowed_kb_uuids = local_agent_config.get('knowledge-bases', [])
|
||||||
|
if not allowed_kb_uuids:
|
||||||
|
old_kb_uuid = local_agent_config.get('knowledge-base', '')
|
||||||
|
if old_kb_uuid and old_kb_uuid != '__none__':
|
||||||
|
allowed_kb_uuids = [old_kb_uuid]
|
||||||
|
|
||||||
|
if kb_id not in allowed_kb_uuids:
|
||||||
|
return handler.ActionResponse.error(
|
||||||
|
message=f'Knowledge base {kb_id} is not configured for this pipeline',
|
||||||
|
)
|
||||||
|
|
||||||
|
kb = await self.ap.rag_mgr.get_knowledge_base_by_uuid(kb_id)
|
||||||
|
if not kb:
|
||||||
|
return handler.ActionResponse.error(
|
||||||
|
message=f'Knowledge base {kb_id} not found',
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
entries = await kb.retrieve(
|
||||||
|
query_text,
|
||||||
|
settings={
|
||||||
|
'top_k': top_k,
|
||||||
|
'filters': filters,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
results = [entry.model_dump(mode='json') for entry in entries]
|
||||||
|
return handler.ActionResponse.success(data={'results': results})
|
||||||
|
except Exception as e:
|
||||||
|
return _make_rag_error_response(e, 'RetrievalError', kb_id=kb_id)
|
||||||
|
|
||||||
@self.action(CommonAction.PING)
|
@self.action(CommonAction.PING)
|
||||||
async def ping(data: dict[str, Any]) -> handler.ActionResponse:
|
async def ping(data: dict[str, Any]) -> handler.ActionResponse:
|
||||||
"""Ping"""
|
"""Ping"""
|
||||||
@@ -895,7 +993,7 @@ class RuntimeConnectionHandler(handler.Handler):
|
|||||||
result = await self.call_action(
|
result = await self.call_action(
|
||||||
LangBotToRuntimeAction.RAG_INGEST_DOCUMENT,
|
LangBotToRuntimeAction.RAG_INGEST_DOCUMENT,
|
||||||
{'plugin_author': plugin_author, 'plugin_name': plugin_name, 'context': context_data},
|
{'plugin_author': plugin_author, 'plugin_name': plugin_name, 'context': context_data},
|
||||||
timeout=300, # Ingestion can be slow
|
timeout=1200, # Ingestion can be slow for large documents
|
||||||
)
|
)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|||||||
@@ -441,6 +441,7 @@ class DifyServiceAPIRunner(runner.RequestRunner):
|
|||||||
is_final = False
|
is_final = False
|
||||||
think_start = False
|
think_start = False
|
||||||
think_end = False
|
think_end = False
|
||||||
|
yielded_final = False
|
||||||
|
|
||||||
remove_think = self.pipeline_config['output'].get('misc', '').get('remove-think')
|
remove_think = self.pipeline_config['output'].get('misc', '').get('remove-think')
|
||||||
|
|
||||||
@@ -493,13 +494,19 @@ class DifyServiceAPIRunner(runner.RequestRunner):
|
|||||||
if answer:
|
if answer:
|
||||||
basic_mode_pending_chunk = answer
|
basic_mode_pending_chunk = answer
|
||||||
|
|
||||||
if (is_final or message_idx % 8 == 0) and (basic_mode_pending_chunk != '' or is_final):
|
if (
|
||||||
|
not yielded_final
|
||||||
|
and (is_final or message_idx % 8 == 0)
|
||||||
|
and (basic_mode_pending_chunk != '' or is_final)
|
||||||
|
):
|
||||||
# content, _ = self._process_thinking_content(basic_mode_pending_chunk)
|
# content, _ = self._process_thinking_content(basic_mode_pending_chunk)
|
||||||
yield provider_message.MessageChunk(
|
yield provider_message.MessageChunk(
|
||||||
role='assistant',
|
role='assistant',
|
||||||
content=basic_mode_pending_chunk,
|
content=basic_mode_pending_chunk,
|
||||||
is_final=is_final,
|
is_final=is_final,
|
||||||
)
|
)
|
||||||
|
if is_final:
|
||||||
|
yielded_final = True
|
||||||
|
|
||||||
if chunk is None:
|
if chunk is None:
|
||||||
raise errors.DifyAPIError('Dify API 没有返回任何响应,请检查网络连接和API配置')
|
raise errors.DifyAPIError('Dify API 没有返回任何响应,请检查网络连接和API配置')
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import json
|
|||||||
import copy
|
import copy
|
||||||
import typing
|
import typing
|
||||||
from .. import runner
|
from .. import runner
|
||||||
|
from ..modelmgr import requester as modelmgr_requester
|
||||||
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
|
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
|
||||||
import langbot_plugin.api.entities.builtin.provider.message as provider_message
|
import langbot_plugin.api.entities.builtin.provider.message as provider_message
|
||||||
import langbot_plugin.api.entities.builtin.rag.context as rag_context
|
import langbot_plugin.api.entities.builtin.rag.context as rag_context
|
||||||
@@ -26,19 +27,109 @@ Respond in the same language as the user's input.
|
|||||||
|
|
||||||
@runner.runner_class('local-agent')
|
@runner.runner_class('local-agent')
|
||||||
class LocalAgentRunner(runner.RequestRunner):
|
class LocalAgentRunner(runner.RequestRunner):
|
||||||
"""本地Agent请求运行器"""
|
"""Local agent request runner"""
|
||||||
|
|
||||||
class ToolCallTracker:
|
async def _get_model_candidates(
|
||||||
"""工具调用追踪器"""
|
self,
|
||||||
|
query: pipeline_query.Query,
|
||||||
|
) -> list[modelmgr_requester.RuntimeLLMModel]:
|
||||||
|
"""Build ordered list of models to try: primary model + fallback models."""
|
||||||
|
candidates = []
|
||||||
|
|
||||||
def __init__(self):
|
# Primary model
|
||||||
self.active_calls: dict[str, dict] = {}
|
if query.use_llm_model_uuid:
|
||||||
self.completed_calls: list[provider_message.ToolCall] = []
|
try:
|
||||||
|
primary = await self.ap.model_mgr.get_model_by_uuid(query.use_llm_model_uuid)
|
||||||
|
candidates.append(primary)
|
||||||
|
except ValueError:
|
||||||
|
self.ap.logger.warning(f'Primary model {query.use_llm_model_uuid} not found')
|
||||||
|
|
||||||
|
# Fallback models
|
||||||
|
fallback_uuids = (query.variables or {}).get('_fallback_model_uuids', [])
|
||||||
|
for fb_uuid in fallback_uuids:
|
||||||
|
try:
|
||||||
|
fb_model = await self.ap.model_mgr.get_model_by_uuid(fb_uuid)
|
||||||
|
candidates.append(fb_model)
|
||||||
|
except ValueError:
|
||||||
|
self.ap.logger.warning(f'Fallback model {fb_uuid} not found, skipping')
|
||||||
|
|
||||||
|
return candidates
|
||||||
|
|
||||||
|
async def _invoke_with_fallback(
|
||||||
|
self,
|
||||||
|
query: pipeline_query.Query,
|
||||||
|
candidates: list[modelmgr_requester.RuntimeLLMModel],
|
||||||
|
messages: list,
|
||||||
|
funcs: list,
|
||||||
|
remove_think: bool,
|
||||||
|
) -> tuple[provider_message.Message, modelmgr_requester.RuntimeLLMModel]:
|
||||||
|
"""Try non-streaming invocation with sequential fallback. Returns (message, model_used)."""
|
||||||
|
last_error = None
|
||||||
|
for model in candidates:
|
||||||
|
try:
|
||||||
|
msg = await model.provider.invoke_llm(
|
||||||
|
query,
|
||||||
|
model,
|
||||||
|
messages,
|
||||||
|
funcs if model.model_entity.abilities.__contains__('func_call') else [],
|
||||||
|
extra_args=model.model_entity.extra_args,
|
||||||
|
remove_think=remove_think,
|
||||||
|
)
|
||||||
|
return msg, model
|
||||||
|
except Exception as e:
|
||||||
|
last_error = e
|
||||||
|
self.ap.logger.warning(f'Model {model.model_entity.name} failed: {e}, trying next fallback...')
|
||||||
|
raise last_error or RuntimeError('No model candidates available')
|
||||||
|
|
||||||
|
async def _invoke_stream_with_fallback(
|
||||||
|
self,
|
||||||
|
query: pipeline_query.Query,
|
||||||
|
candidates: list[modelmgr_requester.RuntimeLLMModel],
|
||||||
|
messages: list,
|
||||||
|
funcs: list,
|
||||||
|
remove_think: bool,
|
||||||
|
) -> tuple[typing.AsyncGenerator, modelmgr_requester.RuntimeLLMModel]:
|
||||||
|
"""Try streaming invocation with sequential fallback. Returns (stream_generator, model_used).
|
||||||
|
|
||||||
|
Fallback is only possible before any chunks have been yielded to the client.
|
||||||
|
Once streaming starts, the model is committed.
|
||||||
|
"""
|
||||||
|
last_error = None
|
||||||
|
for model in candidates:
|
||||||
|
try:
|
||||||
|
stream = model.provider.invoke_llm_stream(
|
||||||
|
query,
|
||||||
|
model,
|
||||||
|
messages,
|
||||||
|
funcs if model.model_entity.abilities.__contains__('func_call') else [],
|
||||||
|
extra_args=model.model_entity.extra_args,
|
||||||
|
remove_think=remove_think,
|
||||||
|
)
|
||||||
|
# Attempt to get the first chunk to verify the stream works
|
||||||
|
first_chunk = await stream.__anext__()
|
||||||
|
|
||||||
|
async def _chain_stream(first, rest):
|
||||||
|
yield first
|
||||||
|
async for chunk in rest:
|
||||||
|
yield chunk
|
||||||
|
|
||||||
|
return _chain_stream(first_chunk, stream), model
|
||||||
|
except StopAsyncIteration:
|
||||||
|
# Empty stream — treat as success (model returned nothing)
|
||||||
|
async def _empty_stream():
|
||||||
|
return
|
||||||
|
yield # make it a generator
|
||||||
|
|
||||||
|
return _empty_stream(), model
|
||||||
|
except Exception as e:
|
||||||
|
last_error = e
|
||||||
|
self.ap.logger.warning(f'Model {model.model_entity.name} stream failed: {e}, trying next fallback...')
|
||||||
|
raise last_error or RuntimeError('No model candidates available')
|
||||||
|
|
||||||
async def run(
|
async def run(
|
||||||
self, query: pipeline_query.Query
|
self, query: pipeline_query.Query
|
||||||
) -> typing.AsyncGenerator[provider_message.Message | provider_message.MessageChunk, None]:
|
) -> typing.AsyncGenerator[provider_message.Message | provider_message.MessageChunk, None]:
|
||||||
"""运行请求"""
|
"""Run request"""
|
||||||
pending_tool_calls = []
|
pending_tool_calls = []
|
||||||
|
|
||||||
# Get knowledge bases list (new field)
|
# Get knowledge bases list (new field)
|
||||||
@@ -74,7 +165,14 @@ class LocalAgentRunner(runner.RequestRunner):
|
|||||||
self.ap.logger.warning(f'Knowledge base {kb_uuid} not found, skipping')
|
self.ap.logger.warning(f'Knowledge base {kb_uuid} not found, skipping')
|
||||||
continue
|
continue
|
||||||
|
|
||||||
result = await kb.retrieve(user_message_text)
|
result = await kb.retrieve(
|
||||||
|
user_message_text,
|
||||||
|
settings={
|
||||||
|
'bot_uuid': query.bot_uuid or '',
|
||||||
|
'sender_id': str(query.sender_id),
|
||||||
|
'session_name': f'{query.session.launcher_type.value}_{query.session.launcher_id}',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
if result:
|
if result:
|
||||||
all_results.extend(result)
|
all_results.extend(result)
|
||||||
@@ -113,51 +211,51 @@ class LocalAgentRunner(runner.RequestRunner):
|
|||||||
|
|
||||||
remove_think = query.pipeline_config['output'].get('misc', '').get('remove-think')
|
remove_think = query.pipeline_config['output'].get('misc', '').get('remove-think')
|
||||||
|
|
||||||
use_llm_model = await self.ap.model_mgr.get_model_by_uuid(query.use_llm_model_uuid)
|
# Build ordered candidate list (primary + fallbacks)
|
||||||
|
candidates = await self._get_model_candidates(query)
|
||||||
|
if not candidates:
|
||||||
|
raise RuntimeError('No LLM model configured for local-agent runner')
|
||||||
|
|
||||||
self.ap.logger.debug(
|
self.ap.logger.debug(
|
||||||
f'localagent req: query={query.query_id} req_messages={req_messages} use_llm_model={query.use_llm_model_uuid}'
|
f'localagent req: query={query.query_id} req_messages={req_messages} '
|
||||||
|
f'candidates={[m.model_entity.name for m in candidates]}'
|
||||||
)
|
)
|
||||||
|
|
||||||
if not is_stream:
|
if not is_stream:
|
||||||
# 非流式输出,直接请求
|
# Non-streaming: invoke with fallback
|
||||||
|
msg, use_llm_model = await self._invoke_with_fallback(
|
||||||
msg = await use_llm_model.provider.invoke_llm(
|
|
||||||
query,
|
query,
|
||||||
use_llm_model,
|
candidates,
|
||||||
req_messages,
|
req_messages,
|
||||||
query.use_funcs,
|
query.use_funcs,
|
||||||
extra_args=use_llm_model.model_entity.extra_args,
|
remove_think,
|
||||||
remove_think=remove_think,
|
|
||||||
)
|
)
|
||||||
yield msg
|
yield msg
|
||||||
final_msg = msg
|
final_msg = msg
|
||||||
else:
|
else:
|
||||||
# 流式输出,需要处理工具调用
|
# Streaming: invoke with fallback
|
||||||
tool_calls_map: dict[str, provider_message.ToolCall] = {}
|
tool_calls_map: dict[str, provider_message.ToolCall] = {}
|
||||||
msg_idx = 0
|
msg_idx = 0
|
||||||
accumulated_content = '' # 从开始累积的所有内容
|
accumulated_content = ''
|
||||||
last_role = 'assistant'
|
last_role = 'assistant'
|
||||||
msg_sequence = 1
|
msg_sequence = 1
|
||||||
async for msg in use_llm_model.provider.invoke_llm_stream(
|
|
||||||
|
stream_src, use_llm_model = await self._invoke_stream_with_fallback(
|
||||||
query,
|
query,
|
||||||
use_llm_model,
|
candidates,
|
||||||
req_messages,
|
req_messages,
|
||||||
query.use_funcs,
|
query.use_funcs,
|
||||||
extra_args=use_llm_model.model_entity.extra_args,
|
remove_think,
|
||||||
remove_think=remove_think,
|
)
|
||||||
):
|
async for msg in stream_src:
|
||||||
msg_idx = msg_idx + 1
|
msg_idx = msg_idx + 1
|
||||||
|
|
||||||
# 记录角色
|
|
||||||
if msg.role:
|
if msg.role:
|
||||||
last_role = msg.role
|
last_role = msg.role
|
||||||
|
|
||||||
# 累积内容
|
|
||||||
if msg.content:
|
if msg.content:
|
||||||
accumulated_content += msg.content
|
accumulated_content += msg.content
|
||||||
|
|
||||||
# 处理工具调用
|
|
||||||
if msg.tool_calls:
|
if msg.tool_calls:
|
||||||
for tool_call in msg.tool_calls:
|
for tool_call in msg.tool_calls:
|
||||||
if tool_call.id not in tool_calls_map:
|
if tool_call.id not in tool_calls_map:
|
||||||
@@ -169,21 +267,18 @@ class LocalAgentRunner(runner.RequestRunner):
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
if tool_call.function and tool_call.function.arguments:
|
if tool_call.function and tool_call.function.arguments:
|
||||||
# 流式处理中,工具调用参数可能分多个chunk返回,需要追加而不是覆盖
|
|
||||||
tool_calls_map[tool_call.id].function.arguments += tool_call.function.arguments
|
tool_calls_map[tool_call.id].function.arguments += tool_call.function.arguments
|
||||||
# continue
|
|
||||||
# 每8个chunk或最后一个chunk时,输出所有累积的内容
|
|
||||||
if msg_idx % 8 == 0 or msg.is_final:
|
if msg_idx % 8 == 0 or msg.is_final:
|
||||||
msg_sequence += 1
|
msg_sequence += 1
|
||||||
yield provider_message.MessageChunk(
|
yield provider_message.MessageChunk(
|
||||||
role=last_role,
|
role=last_role,
|
||||||
content=accumulated_content, # 输出所有累积内容
|
content=accumulated_content,
|
||||||
tool_calls=list(tool_calls_map.values()) if (tool_calls_map and msg.is_final) else None,
|
tool_calls=list(tool_calls_map.values()) if (tool_calls_map and msg.is_final) else None,
|
||||||
is_final=msg.is_final,
|
is_final=msg.is_final,
|
||||||
msg_sequence=msg_sequence,
|
msg_sequence=msg_sequence,
|
||||||
)
|
)
|
||||||
|
|
||||||
# 创建最终消息用于后续处理
|
|
||||||
final_msg = provider_message.MessageChunk(
|
final_msg = provider_message.MessageChunk(
|
||||||
role=last_role,
|
role=last_role,
|
||||||
content=accumulated_content,
|
content=accumulated_content,
|
||||||
@@ -198,7 +293,8 @@ class LocalAgentRunner(runner.RequestRunner):
|
|||||||
|
|
||||||
req_messages.append(final_msg)
|
req_messages.append(final_msg)
|
||||||
|
|
||||||
# 持续请求,只要还有待处理的工具调用就继续处理调用
|
# Once a model succeeds, commit to it for the tool call loop
|
||||||
|
# (no fallback mid-conversation — different models may interpret tool results differently)
|
||||||
while pending_tool_calls:
|
while pending_tool_calls:
|
||||||
for tool_call in pending_tool_calls:
|
for tool_call in pending_tool_calls:
|
||||||
try:
|
try:
|
||||||
@@ -239,7 +335,6 @@ class LocalAgentRunner(runner.RequestRunner):
|
|||||||
|
|
||||||
req_messages.append(msg)
|
req_messages.append(msg)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# 工具调用出错,添加一个报错信息到 req_messages
|
|
||||||
err_msg = provider_message.Message(role='tool', content=f'err: {e}', tool_call_id=tool_call.id)
|
err_msg = provider_message.Message(role='tool', content=f'err: {e}', tool_call_id=tool_call.id)
|
||||||
|
|
||||||
yield err_msg
|
yield err_msg
|
||||||
@@ -247,39 +342,38 @@ class LocalAgentRunner(runner.RequestRunner):
|
|||||||
req_messages.append(err_msg)
|
req_messages.append(err_msg)
|
||||||
|
|
||||||
self.ap.logger.debug(
|
self.ap.logger.debug(
|
||||||
f'localagent req: query={query.query_id} req_messages={req_messages} use_llm_model={query.use_llm_model_uuid}'
|
f'localagent req: query={query.query_id} req_messages={req_messages} '
|
||||||
|
f'use_llm_model={use_llm_model.model_entity.name}'
|
||||||
)
|
)
|
||||||
|
|
||||||
if is_stream:
|
if is_stream:
|
||||||
tool_calls_map = {}
|
tool_calls_map = {}
|
||||||
msg_idx = 0
|
msg_idx = 0
|
||||||
accumulated_content = '' # 从开始累积的所有内容
|
accumulated_content = ''
|
||||||
last_role = 'assistant'
|
last_role = 'assistant'
|
||||||
msg_sequence = first_end_sequence
|
msg_sequence = first_end_sequence
|
||||||
|
|
||||||
async for msg in use_llm_model.provider.invoke_llm_stream(
|
tool_stream_src = use_llm_model.provider.invoke_llm_stream(
|
||||||
query,
|
query,
|
||||||
use_llm_model,
|
use_llm_model,
|
||||||
req_messages,
|
req_messages,
|
||||||
query.use_funcs,
|
query.use_funcs if use_llm_model.model_entity.abilities.__contains__('func_call') else [],
|
||||||
extra_args=use_llm_model.model_entity.extra_args,
|
extra_args=use_llm_model.model_entity.extra_args,
|
||||||
remove_think=remove_think,
|
remove_think=remove_think,
|
||||||
):
|
)
|
||||||
|
async for msg in tool_stream_src:
|
||||||
msg_idx += 1
|
msg_idx += 1
|
||||||
|
|
||||||
# 记录角色
|
|
||||||
if msg.role:
|
if msg.role:
|
||||||
last_role = msg.role
|
last_role = msg.role
|
||||||
|
|
||||||
# 第一次请求工具调用时的内容
|
# Prepend first-round content on first chunk of tool-call round
|
||||||
if msg_idx == 1:
|
if msg_idx == 1:
|
||||||
accumulated_content = first_content if first_content is not None else accumulated_content
|
accumulated_content = first_content if first_content is not None else accumulated_content
|
||||||
|
|
||||||
# 累积内容
|
|
||||||
if msg.content:
|
if msg.content:
|
||||||
accumulated_content += msg.content
|
accumulated_content += msg.content
|
||||||
|
|
||||||
# 处理工具调用
|
|
||||||
if msg.tool_calls:
|
if msg.tool_calls:
|
||||||
for tool_call in msg.tool_calls:
|
for tool_call in msg.tool_calls:
|
||||||
if tool_call.id not in tool_calls_map:
|
if tool_call.id not in tool_calls_map:
|
||||||
@@ -291,15 +385,13 @@ class LocalAgentRunner(runner.RequestRunner):
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
if tool_call.function and tool_call.function.arguments:
|
if tool_call.function and tool_call.function.arguments:
|
||||||
# 流式处理中,工具调用参数可能分多个chunk返回,需要追加而不是覆盖
|
|
||||||
tool_calls_map[tool_call.id].function.arguments += tool_call.function.arguments
|
tool_calls_map[tool_call.id].function.arguments += tool_call.function.arguments
|
||||||
|
|
||||||
# 每8个chunk或最后一个chunk时,输出所有累积的内容
|
|
||||||
if msg_idx % 8 == 0 or msg.is_final:
|
if msg_idx % 8 == 0 or msg.is_final:
|
||||||
msg_sequence += 1
|
msg_sequence += 1
|
||||||
yield provider_message.MessageChunk(
|
yield provider_message.MessageChunk(
|
||||||
role=last_role,
|
role=last_role,
|
||||||
content=accumulated_content, # 输出所有累积内容
|
content=accumulated_content,
|
||||||
tool_calls=list(tool_calls_map.values()) if (tool_calls_map and msg.is_final) else None,
|
tool_calls=list(tool_calls_map.values()) if (tool_calls_map and msg.is_final) else None,
|
||||||
is_final=msg.is_final,
|
is_final=msg.is_final,
|
||||||
msg_sequence=msg_sequence,
|
msg_sequence=msg_sequence,
|
||||||
@@ -312,12 +404,12 @@ class LocalAgentRunner(runner.RequestRunner):
|
|||||||
msg_sequence=msg_sequence,
|
msg_sequence=msg_sequence,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
# 处理完所有调用,再次请求
|
# Non-streaming: use committed model directly (no fallback in tool loop)
|
||||||
msg = await use_llm_model.provider.invoke_llm(
|
msg = await use_llm_model.provider.invoke_llm(
|
||||||
query,
|
query,
|
||||||
use_llm_model,
|
use_llm_model,
|
||||||
req_messages,
|
req_messages,
|
||||||
query.use_funcs,
|
query.use_funcs if use_llm_model.model_entity.abilities.__contains__('func_call') else [],
|
||||||
extra_args=use_llm_model.model_entity.extra_args,
|
extra_args=use_llm_model.model_entity.extra_args,
|
||||||
remove_think=remove_think,
|
remove_think=remove_think,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -321,13 +321,19 @@ class RuntimeKnowledgeBase(KnowledgeBaseInterface):
|
|||||||
if not plugin_id:
|
if not plugin_id:
|
||||||
raise ValueError(f'No RAG plugin ID configured for KB {kb.uuid}. Retrieval failed.')
|
raise ValueError(f'No RAG plugin ID configured for KB {kb.uuid}. Retrieval failed.')
|
||||||
|
|
||||||
|
# Session context (e.g. session_name) stays in retrieval_settings
|
||||||
|
# for plugins that need it. Do NOT move them into filters, as filters
|
||||||
|
# are passed directly to vector_search by some plugins (e.g. LangRAG)
|
||||||
|
# and would cause empty results when the metadata field doesn't exist.
|
||||||
|
filters = settings.pop('filters', {})
|
||||||
|
|
||||||
retrieval_context = {
|
retrieval_context = {
|
||||||
'query': query,
|
'query': query,
|
||||||
'knowledge_base_id': kb.uuid,
|
'knowledge_base_id': kb.uuid,
|
||||||
'collection_id': kb.collection_id or kb.uuid,
|
'collection_id': kb.collection_id or kb.uuid,
|
||||||
'retrieval_settings': settings,
|
'retrieval_settings': settings,
|
||||||
'creation_settings': kb.creation_settings or {},
|
'creation_settings': kb.creation_settings or {},
|
||||||
'filters': settings.pop('filters', {}),
|
'filters': filters,
|
||||||
}
|
}
|
||||||
|
|
||||||
result = await self.ap.plugin_connector.call_rag_retrieve(
|
result = await self.ap.plugin_connector.call_rag_retrieve(
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import langbot
|
|||||||
|
|
||||||
semantic_version = f'v{langbot.__version__}'
|
semantic_version = f'v{langbot.__version__}'
|
||||||
|
|
||||||
required_database_version = 20
|
required_database_version = 24
|
||||||
"""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
|
||||||
|
|||||||
@@ -100,7 +100,7 @@ class VectorDBManager:
|
|||||||
) -> list[dict]:
|
) -> list[dict]:
|
||||||
"""Proxy: Search vectors.
|
"""Proxy: Search vectors.
|
||||||
|
|
||||||
Returns a list of dicts with keys: 'id', 'score', 'metadata'.
|
Returns a list of dicts with keys: 'id', 'distance', 'metadata'.
|
||||||
The underlying VectorDatabase.search returns Chroma-style format:
|
The underlying VectorDatabase.search returns Chroma-style format:
|
||||||
{ 'ids': [['id1']], 'distances': [[0.1]], 'metadatas': [[{}]] }
|
{ 'ids': [['id1']], 'distances': [[0.1]], 'metadatas': [[{}]] }
|
||||||
"""
|
"""
|
||||||
@@ -130,7 +130,7 @@ class VectorDBManager:
|
|||||||
parsed_results.append(
|
parsed_results.append(
|
||||||
{
|
{
|
||||||
'id': id_val,
|
'id': id_val,
|
||||||
'score': r_dists[i] if r_dists and i < len(r_dists) else 0.0,
|
'distance': r_dists[i] if r_dists and i < len(r_dists) else 0.0,
|
||||||
'metadata': r_metas[i] if r_metas and i < len(r_metas) else {},
|
'metadata': r_metas[i] if r_metas and i < len(r_metas) else {},
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,11 +2,14 @@ from __future__ import annotations
|
|||||||
import asyncio
|
import asyncio
|
||||||
from typing import Any
|
from typing import Any
|
||||||
from chromadb import PersistentClient
|
from chromadb import PersistentClient
|
||||||
from langbot.pkg.vector.vdb import VectorDatabase
|
from langbot.pkg.vector.vdb import VectorDatabase, SearchType
|
||||||
from langbot.pkg.core import app
|
from langbot.pkg.core import app
|
||||||
import chromadb
|
import chromadb
|
||||||
import chromadb.errors
|
import chromadb.errors
|
||||||
|
|
||||||
|
# RRF smoothing constant (standard value from the literature)
|
||||||
|
_RRF_K = 60
|
||||||
|
|
||||||
|
|
||||||
class ChromaVectorDatabase(VectorDatabase):
|
class ChromaVectorDatabase(VectorDatabase):
|
||||||
def __init__(self, ap: app.Application, base_path: str = './data/chroma'):
|
def __init__(self, ap: app.Application, base_path: str = './data/chroma'):
|
||||||
@@ -14,6 +17,10 @@ class ChromaVectorDatabase(VectorDatabase):
|
|||||||
self.client = PersistentClient(path=base_path)
|
self.client = PersistentClient(path=base_path)
|
||||||
self._collections = {}
|
self._collections = {}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def supported_search_types(cls) -> list[SearchType]:
|
||||||
|
return [SearchType.VECTOR, SearchType.FULL_TEXT, SearchType.HYBRID]
|
||||||
|
|
||||||
async def get_or_create_collection(self, collection: str) -> chromadb.Collection:
|
async def get_or_create_collection(self, collection: str) -> chromadb.Collection:
|
||||||
if collection not in self._collections:
|
if collection not in self._collections:
|
||||||
self._collections[collection] = await asyncio.to_thread(
|
self._collections[collection] = await asyncio.to_thread(
|
||||||
@@ -34,8 +41,8 @@ class ChromaVectorDatabase(VectorDatabase):
|
|||||||
kwargs: dict[str, Any] = dict(embeddings=embeddings_list, ids=ids, metadatas=metadatas)
|
kwargs: dict[str, Any] = dict(embeddings=embeddings_list, ids=ids, metadatas=metadatas)
|
||||||
if documents is not None:
|
if documents is not None:
|
||||||
kwargs['documents'] = documents
|
kwargs['documents'] = documents
|
||||||
await asyncio.to_thread(col.add, **kwargs)
|
await asyncio.to_thread(col.upsert, **kwargs)
|
||||||
self.ap.logger.info(f"Added {len(ids)} embeddings to Chroma collection '{collection}'.")
|
self.ap.logger.info(f"Upserted {len(ids)} embeddings to Chroma collection '{collection}'.")
|
||||||
|
|
||||||
async def search(
|
async def search(
|
||||||
self,
|
self,
|
||||||
@@ -47,6 +54,23 @@ class ChromaVectorDatabase(VectorDatabase):
|
|||||||
filter: dict[str, Any] | None = None,
|
filter: dict[str, Any] | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
col = await self.get_or_create_collection(collection)
|
col = await self.get_or_create_collection(collection)
|
||||||
|
|
||||||
|
if search_type == SearchType.FULL_TEXT:
|
||||||
|
return await self._full_text_search(col, collection, k, query_text, filter)
|
||||||
|
elif search_type == SearchType.HYBRID:
|
||||||
|
return await self._hybrid_search(col, collection, query_embedding, k, query_text, filter)
|
||||||
|
|
||||||
|
# Default: vector search
|
||||||
|
return await self._vector_search(col, collection, query_embedding, k, filter)
|
||||||
|
|
||||||
|
async def _vector_search(
|
||||||
|
self,
|
||||||
|
col: chromadb.Collection,
|
||||||
|
collection: str,
|
||||||
|
query_embedding: list[float],
|
||||||
|
k: int,
|
||||||
|
filter: dict[str, Any] | None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
query_kwargs: dict[str, Any] = dict(
|
query_kwargs: dict[str, Any] = dict(
|
||||||
query_embeddings=query_embedding,
|
query_embeddings=query_embedding,
|
||||||
n_results=k,
|
n_results=k,
|
||||||
@@ -55,9 +79,137 @@ class ChromaVectorDatabase(VectorDatabase):
|
|||||||
if filter:
|
if filter:
|
||||||
query_kwargs['where'] = filter
|
query_kwargs['where'] = filter
|
||||||
results = await asyncio.to_thread(col.query, **query_kwargs)
|
results = await asyncio.to_thread(col.query, **query_kwargs)
|
||||||
self.ap.logger.info(f"Chroma search in '{collection}' returned {len(results.get('ids', [[]])[0])} results.")
|
self.ap.logger.info(
|
||||||
|
f"Chroma vector search in '{collection}' returned {len(results.get('ids', [[]])[0])} results."
|
||||||
|
)
|
||||||
return results
|
return results
|
||||||
|
|
||||||
|
async def _full_text_search(
|
||||||
|
self,
|
||||||
|
col: chromadb.Collection,
|
||||||
|
collection: str,
|
||||||
|
k: int,
|
||||||
|
query_text: str,
|
||||||
|
filter: dict[str, Any] | None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
if not query_text:
|
||||||
|
return {'ids': [[]], 'metadatas': [[]], 'distances': [[]], 'documents': [[]]}
|
||||||
|
|
||||||
|
get_kwargs: dict[str, Any] = dict(
|
||||||
|
where_document={'$contains': query_text},
|
||||||
|
include=['metadatas', 'documents'],
|
||||||
|
limit=k,
|
||||||
|
)
|
||||||
|
if filter:
|
||||||
|
get_kwargs['where'] = filter
|
||||||
|
results = await asyncio.to_thread(col.get, **get_kwargs)
|
||||||
|
|
||||||
|
# col.get returns flat lists; wrap into column-major format.
|
||||||
|
# Distances are all 0.0 because Chroma's local $contains is a boolean
|
||||||
|
# filter with no relevance scoring. Chroma's BM25 sparse embedding
|
||||||
|
# function (ChromaBm25EmbeddingFunction) can generate scored sparse
|
||||||
|
# vectors, but sparse vector *indexing* is only available on Chroma
|
||||||
|
# Cloud, not locally. For ranked results, use hybrid mode or apply a
|
||||||
|
# reranker in a downstream stage.
|
||||||
|
ids = results.get('ids', [])
|
||||||
|
metadatas = results.get('metadatas', []) or [None] * len(ids)
|
||||||
|
documents = results.get('documents', []) or [None] * len(ids)
|
||||||
|
distances = [0.0] * len(ids)
|
||||||
|
|
||||||
|
self.ap.logger.info(f"Chroma full-text search in '{collection}' returned {len(ids)} results.")
|
||||||
|
return {'ids': [ids], 'metadatas': [metadatas], 'distances': [distances], 'documents': [documents]}
|
||||||
|
|
||||||
|
async def _hybrid_search(
|
||||||
|
self,
|
||||||
|
col: chromadb.Collection,
|
||||||
|
collection: str,
|
||||||
|
query_embedding: list[float],
|
||||||
|
k: int,
|
||||||
|
query_text: str,
|
||||||
|
filter: dict[str, Any] | None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
# Fall back to pure vector search when no text is provided
|
||||||
|
if not query_text:
|
||||||
|
return await self._vector_search(col, collection, query_embedding, k, filter)
|
||||||
|
|
||||||
|
# Run vector search and full-text search in parallel
|
||||||
|
vector_task = self._vector_search(col, collection, query_embedding, k, filter)
|
||||||
|
text_task = self._full_text_search(col, collection, k, query_text, filter)
|
||||||
|
vector_results, text_results = await asyncio.gather(vector_task, text_task)
|
||||||
|
|
||||||
|
vector_ids = vector_results.get('ids', [[]])[0]
|
||||||
|
text_ids = text_results.get('ids', [[]])[0]
|
||||||
|
|
||||||
|
if not vector_ids and not text_ids:
|
||||||
|
return {'ids': [[]], 'metadatas': [[]], 'distances': [[]], 'documents': [[]]}
|
||||||
|
|
||||||
|
# RRF fusion
|
||||||
|
fused = self._rrf_fuse([vector_ids, text_ids], k)
|
||||||
|
if not fused:
|
||||||
|
return {'ids': [[]], 'metadatas': [[]], 'distances': [[]], 'documents': [[]]}
|
||||||
|
|
||||||
|
fused_ids = [doc_id for doc_id, _ in fused]
|
||||||
|
|
||||||
|
# Fetch full metadata and documents for fused results
|
||||||
|
fetched = await asyncio.to_thread(col.get, ids=fused_ids, include=['metadatas', 'documents'])
|
||||||
|
|
||||||
|
# col.get returns results in arbitrary order; re-order to match fused ranking
|
||||||
|
fetched_map: dict[str, tuple] = {}
|
||||||
|
for i, fid in enumerate(fetched.get('ids', [])):
|
||||||
|
meta = (fetched.get('metadatas') or [None] * len(fetched['ids']))[i]
|
||||||
|
doc = (fetched.get('documents') or [None] * len(fetched['ids']))[i]
|
||||||
|
fetched_map[fid] = (meta, doc)
|
||||||
|
|
||||||
|
ordered_ids = []
|
||||||
|
ordered_metas = []
|
||||||
|
ordered_docs = []
|
||||||
|
ordered_dists = []
|
||||||
|
|
||||||
|
# Normalize RRF scores to 0~1 distances via min-max scaling.
|
||||||
|
# Raw RRF scores are tiny (e.g. 0.016~0.033 with k=60) so a naive
|
||||||
|
# ``1 - score`` would compress all distances into a narrow 0.96~0.98
|
||||||
|
# band with almost no discriminative power. Min-max normalization
|
||||||
|
# spreads them across the full 0~1 range (0.0 = best match).
|
||||||
|
max_score = fused[0][1]
|
||||||
|
min_score = fused[-1][1]
|
||||||
|
score_range = max_score - min_score
|
||||||
|
|
||||||
|
for doc_id, score in fused:
|
||||||
|
if doc_id in fetched_map:
|
||||||
|
meta, doc = fetched_map[doc_id]
|
||||||
|
ordered_ids.append(doc_id)
|
||||||
|
ordered_metas.append(meta)
|
||||||
|
ordered_docs.append(doc)
|
||||||
|
if score_range > 0:
|
||||||
|
ordered_dists.append(1.0 - (score - min_score) / score_range)
|
||||||
|
else:
|
||||||
|
ordered_dists.append(0.0)
|
||||||
|
|
||||||
|
self.ap.logger.info(
|
||||||
|
f"Chroma hybrid search in '{collection}' returned {len(ordered_ids)} results "
|
||||||
|
f'(vector={len(vector_ids)}, text={len(text_ids)}).'
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
'ids': [ordered_ids],
|
||||||
|
'metadatas': [ordered_metas],
|
||||||
|
'distances': [ordered_dists],
|
||||||
|
'documents': [ordered_docs],
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _rrf_fuse(result_lists: list[list[str]], k: int) -> list[tuple[str, float]]:
|
||||||
|
"""Reciprocal Rank Fusion over multiple ranked ID lists.
|
||||||
|
|
||||||
|
Returns a list of (doc_id, rrf_score) sorted by descending score,
|
||||||
|
truncated to *k* entries.
|
||||||
|
"""
|
||||||
|
scores: dict[str, float] = {}
|
||||||
|
for ranked_ids in result_lists:
|
||||||
|
for rank, doc_id in enumerate(ranked_ids):
|
||||||
|
scores[doc_id] = scores.get(doc_id, 0.0) + 1.0 / (_RRF_K + rank + 1)
|
||||||
|
sorted_results = sorted(scores.items(), key=lambda x: x[1], reverse=True)
|
||||||
|
return sorted_results[:k]
|
||||||
|
|
||||||
async def delete_by_file_id(self, collection: str, file_id: str) -> None:
|
async def delete_by_file_id(self, collection: str, file_id: str) -> None:
|
||||||
col = await self.get_or_create_collection(collection)
|
col = await self.get_or_create_collection(collection)
|
||||||
await asyncio.to_thread(col.delete, where={'file_id': file_id})
|
await asyncio.to_thread(col.delete, where={'file_id': file_id})
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ admins: []
|
|||||||
api:
|
api:
|
||||||
port: 5300
|
port: 5300
|
||||||
webhook_prefix: 'http://127.0.0.1:5300'
|
webhook_prefix: 'http://127.0.0.1:5300'
|
||||||
|
extra_webhook_prefix: ''
|
||||||
command:
|
command:
|
||||||
enable: true
|
enable: true
|
||||||
prefix:
|
prefix:
|
||||||
|
|||||||
@@ -41,7 +41,10 @@
|
|||||||
"runner": "local-agent"
|
"runner": "local-agent"
|
||||||
},
|
},
|
||||||
"local-agent": {
|
"local-agent": {
|
||||||
"model": "",
|
"model": {
|
||||||
|
"primary": "",
|
||||||
|
"fallbacks": []
|
||||||
|
},
|
||||||
"max-round": 10,
|
"max-round": 10,
|
||||||
"prompt": [
|
"prompt": [
|
||||||
{
|
{
|
||||||
@@ -95,11 +98,12 @@
|
|||||||
"max": 0
|
"max": 0
|
||||||
},
|
},
|
||||||
"misc": {
|
"misc": {
|
||||||
"hide-exception": true,
|
"exception-handling": "show-hint",
|
||||||
|
"failure-hint": "Request failed.",
|
||||||
"at-sender": true,
|
"at-sender": true,
|
||||||
"quote-origin": true,
|
"quote-origin": true,
|
||||||
"track-function-calls": false,
|
"track-function-calls": false,
|
||||||
"remove-think": false
|
"remove-think": false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -59,8 +59,11 @@ stages:
|
|||||||
label:
|
label:
|
||||||
en_US: Model
|
en_US: Model
|
||||||
zh_Hans: 模型
|
zh_Hans: 模型
|
||||||
type: llm-model-selector
|
type: model-fallback-selector
|
||||||
required: true
|
required: true
|
||||||
|
default:
|
||||||
|
primary: ''
|
||||||
|
fallbacks: []
|
||||||
- name: max-round
|
- name: max-round
|
||||||
label:
|
label:
|
||||||
en_US: Max Round
|
en_US: Max Round
|
||||||
|
|||||||
@@ -78,13 +78,39 @@ stages:
|
|||||||
en_US: Misc
|
en_US: Misc
|
||||||
zh_Hans: 杂项
|
zh_Hans: 杂项
|
||||||
config:
|
config:
|
||||||
- name: hide-exception
|
- name: exception-handling
|
||||||
label:
|
label:
|
||||||
en_US: Hide Exception
|
en_US: Exception Handling Strategy
|
||||||
zh_Hans: 不输出异常信息给用户
|
zh_Hans: 异常处理策略
|
||||||
type: boolean
|
description:
|
||||||
|
en_US: Controls how error messages are displayed to the user when an AI request fails
|
||||||
|
zh_Hans: 控制 AI 请求失败时向用户展示错误信息的方式
|
||||||
|
type: select
|
||||||
required: true
|
required: true
|
||||||
default: true
|
default: show-hint
|
||||||
|
options:
|
||||||
|
- name: show-error
|
||||||
|
label:
|
||||||
|
en_US: Show Full Error
|
||||||
|
zh_Hans: 显示完整报错信息
|
||||||
|
- name: show-hint
|
||||||
|
label:
|
||||||
|
en_US: Show Failure Hint
|
||||||
|
zh_Hans: 仅文字提示
|
||||||
|
- name: hide
|
||||||
|
label:
|
||||||
|
en_US: Hide All
|
||||||
|
zh_Hans: 不显示任何异常信息
|
||||||
|
- name: failure-hint
|
||||||
|
label:
|
||||||
|
en_US: Failure Hint Text
|
||||||
|
zh_Hans: 失败提示文本
|
||||||
|
description:
|
||||||
|
en_US: The text to display when a request fails. Only effective when Exception Handling Strategy is set to "Show Failure Hint"
|
||||||
|
zh_Hans: 请求失败时显示的提示文本,仅在异常处理策略设置为"仅文字提示"时生效
|
||||||
|
type: string
|
||||||
|
required: false
|
||||||
|
default: 'Request failed.'
|
||||||
- name: at-sender
|
- name: at-sender
|
||||||
label:
|
label:
|
||||||
en_US: At Sender
|
en_US: At Sender
|
||||||
@@ -119,3 +145,4 @@ stages:
|
|||||||
type: boolean
|
type: boolean
|
||||||
required: true
|
required: true
|
||||||
default: false
|
default: false
|
||||||
|
|
||||||
|
|||||||
@@ -91,14 +91,15 @@ class TestWebhookDisplayPrefix:
|
|||||||
|
|
||||||
def test_default_webhook_prefix(self):
|
def test_default_webhook_prefix(self):
|
||||||
"""Test that the default webhook display prefix is correctly set"""
|
"""Test that the default webhook display prefix is correctly set"""
|
||||||
cfg = {'api': {'port': 5300, 'webhook_prefix': 'http://127.0.0.1:5300'}}
|
cfg = {'api': {'port': 5300, 'webhook_prefix': 'http://127.0.0.1:5300', 'extra_webhook_prefix': ''}}
|
||||||
|
|
||||||
# Should have the default value
|
# Should have the default value
|
||||||
assert cfg['api']['webhook_prefix'] == 'http://127.0.0.1:5300'
|
assert cfg['api']['webhook_prefix'] == 'http://127.0.0.1:5300'
|
||||||
|
assert cfg['api']['extra_webhook_prefix'] == ''
|
||||||
|
|
||||||
def test_webhook_prefix_env_override(self):
|
def test_webhook_prefix_env_override(self):
|
||||||
"""Test overriding webhook_prefix via environment variable"""
|
"""Test overriding webhook_prefix via environment variable"""
|
||||||
cfg = {'api': {'port': 5300, 'webhook_prefix': 'http://127.0.0.1:5300'}}
|
cfg = {'api': {'port': 5300, 'webhook_prefix': 'http://127.0.0.1:5300', 'extra_webhook_prefix': ''}}
|
||||||
|
|
||||||
# Set environment variable
|
# Set environment variable
|
||||||
os.environ['API__WEBHOOK_PREFIX'] = 'https://example.com:8080'
|
os.environ['API__WEBHOOK_PREFIX'] = 'https://example.com:8080'
|
||||||
@@ -112,7 +113,7 @@ class TestWebhookDisplayPrefix:
|
|||||||
|
|
||||||
def test_webhook_prefix_with_custom_domain(self):
|
def test_webhook_prefix_with_custom_domain(self):
|
||||||
"""Test webhook_prefix with custom domain"""
|
"""Test webhook_prefix with custom domain"""
|
||||||
cfg = {'api': {'port': 5300, 'webhook_prefix': 'http://127.0.0.1:5300'}}
|
cfg = {'api': {'port': 5300, 'webhook_prefix': 'http://127.0.0.1:5300', 'extra_webhook_prefix': ''}}
|
||||||
|
|
||||||
# Set to a custom domain
|
# Set to a custom domain
|
||||||
os.environ['API__WEBHOOK_PREFIX'] = 'https://bot.mycompany.com'
|
os.environ['API__WEBHOOK_PREFIX'] = 'https://bot.mycompany.com'
|
||||||
@@ -126,7 +127,7 @@ class TestWebhookDisplayPrefix:
|
|||||||
|
|
||||||
def test_webhook_prefix_with_subdirectory(self):
|
def test_webhook_prefix_with_subdirectory(self):
|
||||||
"""Test webhook_prefix with subdirectory path"""
|
"""Test webhook_prefix with subdirectory path"""
|
||||||
cfg = {'api': {'port': 5300, 'webhook_prefix': 'http://127.0.0.1:5300'}}
|
cfg = {'api': {'port': 5300, 'webhook_prefix': 'http://127.0.0.1:5300', 'extra_webhook_prefix': ''}}
|
||||||
|
|
||||||
# Set to a URL with subdirectory
|
# Set to a URL with subdirectory
|
||||||
os.environ['API__WEBHOOK_PREFIX'] = 'https://example.com/langbot'
|
os.environ['API__WEBHOOK_PREFIX'] = 'https://example.com/langbot'
|
||||||
@@ -138,6 +139,37 @@ class TestWebhookDisplayPrefix:
|
|||||||
# Cleanup
|
# Cleanup
|
||||||
del os.environ['API__WEBHOOK_PREFIX']
|
del os.environ['API__WEBHOOK_PREFIX']
|
||||||
|
|
||||||
|
def test_extra_webhook_prefix_default_empty(self):
|
||||||
|
"""Test that extra_webhook_prefix defaults to empty string"""
|
||||||
|
cfg = {'api': {'port': 5300, 'webhook_prefix': 'http://127.0.0.1:5300', 'extra_webhook_prefix': ''}}
|
||||||
|
|
||||||
|
bot_uuid = 'test-bot-uuid'
|
||||||
|
webhook_prefix = cfg['api'].get('webhook_prefix', 'http://127.0.0.1:5300')
|
||||||
|
extra_webhook_prefix = cfg['api'].get('extra_webhook_prefix', '')
|
||||||
|
webhook_url = f'/bots/{bot_uuid}'
|
||||||
|
|
||||||
|
assert f'{webhook_prefix}{webhook_url}' == 'http://127.0.0.1:5300/bots/test-bot-uuid'
|
||||||
|
# extra should be empty when not configured
|
||||||
|
assert extra_webhook_prefix == ''
|
||||||
|
|
||||||
|
def test_extra_webhook_prefix_env_override(self):
|
||||||
|
"""Test overriding extra_webhook_prefix via environment variable"""
|
||||||
|
cfg = {'api': {'port': 5300, 'webhook_prefix': 'http://127.0.0.1:5300', 'extra_webhook_prefix': ''}}
|
||||||
|
|
||||||
|
os.environ['API__EXTRA_WEBHOOK_PREFIX'] = 'https://extra.example.com'
|
||||||
|
|
||||||
|
result = _apply_env_overrides_to_config(cfg)
|
||||||
|
|
||||||
|
assert result['api']['extra_webhook_prefix'] == 'https://extra.example.com'
|
||||||
|
|
||||||
|
bot_uuid = 'test-bot-uuid'
|
||||||
|
extra_prefix = result['api']['extra_webhook_prefix']
|
||||||
|
webhook_url = f'/bots/{bot_uuid}'
|
||||||
|
assert f'{extra_prefix}{webhook_url}' == 'https://extra.example.com/bots/test-bot-uuid'
|
||||||
|
|
||||||
|
# Cleanup
|
||||||
|
del os.environ['API__EXTRA_WEBHOOK_PREFIX']
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
pytest.main([__file__, '-v'])
|
pytest.main([__file__, '-v'])
|
||||||
|
|||||||
@@ -194,7 +194,7 @@ def sample_query(sample_message_chain, sample_message_event, mock_adapter):
|
|||||||
pipeline_config={
|
pipeline_config={
|
||||||
'ai': {
|
'ai': {
|
||||||
'runner': {'runner': 'local-agent'},
|
'runner': {'runner': 'local-agent'},
|
||||||
'local-agent': {'model': 'test-model-uuid', 'prompt': 'test-prompt'},
|
'local-agent': {'model': {'primary': 'test-model-uuid', 'fallbacks': []}, 'prompt': 'test-prompt'},
|
||||||
},
|
},
|
||||||
'output': {'misc': {'at-sender': False, 'quote-origin': False}},
|
'output': {'misc': {'at-sender': False, 'quote-origin': False}},
|
||||||
'trigger': {'misc': {'combine-quote-message': False}},
|
'trigger': {'misc': {'combine-quote-message': False}},
|
||||||
@@ -219,7 +219,7 @@ def sample_pipeline_config():
|
|||||||
return {
|
return {
|
||||||
'ai': {
|
'ai': {
|
||||||
'runner': {'runner': 'local-agent'},
|
'runner': {'runner': 'local-agent'},
|
||||||
'local-agent': {'model': 'test-model-uuid', 'prompt': 'test-prompt'},
|
'local-agent': {'model': {'primary': 'test-model-uuid', 'fallbacks': []}, 'prompt': 'test-prompt'},
|
||||||
},
|
},
|
||||||
'output': {'misc': {'at-sender': False, 'quote-origin': False}},
|
'output': {'misc': {'at-sender': False, 'quote-origin': False}},
|
||||||
'trigger': {'misc': {'combine-quote-message': False}},
|
'trigger': {'misc': {'combine-quote-message': False}},
|
||||||
|
|||||||
54
uv.lock
generated
54
uv.lock
generated
@@ -1112,7 +1112,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "flask"
|
name = "flask"
|
||||||
version = "3.1.2"
|
version = "3.1.3"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "blinker" },
|
{ name = "blinker" },
|
||||||
@@ -1122,9 +1122,9 @@ dependencies = [
|
|||||||
{ name = "markupsafe" },
|
{ name = "markupsafe" },
|
||||||
{ name = "werkzeug" },
|
{ name = "werkzeug" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/dc/6d/cfe3c0fcc5e477df242b98bfe186a4c34357b4847e87ecaef04507332dab/flask-3.1.2.tar.gz", hash = "sha256:bf656c15c80190ed628ad08cdfd3aaa35beb087855e2f494910aa3774cc4fd87", size = 720160, upload-time = "2025-08-19T21:03:21.205Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/26/00/35d85dcce6c57fdc871f3867d465d780f302a175ea360f62533f12b27e2b/flask-3.1.3.tar.gz", hash = "sha256:0ef0e52b8a9cd932855379197dd8f94047b359ca0a78695144304cb45f87c9eb", size = 759004, upload-time = "2026-02-19T05:00:57.678Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/ec/f9/7f9263c5695f4bd0023734af91bedb2ff8209e8de6ead162f35d8dc762fd/flask-3.1.2-py3-none-any.whl", hash = "sha256:ca1d8112ec8a6158cc29ea4858963350011b5c846a414cdb7a954aa9e967d03c", size = 103308, upload-time = "2025-08-19T21:03:19.499Z" },
|
{ url = "https://files.pythonhosted.org/packages/7f/9c/34f6962f9b9e9c71f6e5ed806e0d0ff03c9d1b0b2340088a0cf4bce09b18/flask-3.1.3-py3-none-any.whl", hash = "sha256:f4bcbefc124291925f1a26446da31a5178f9483862233b23c0c96a20701f670c", size = 103424, upload-time = "2026-02-19T05:00:56.027Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1832,7 +1832,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "langbot"
|
name = "langbot"
|
||||||
version = "4.9.0"
|
version = "4.9.2"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "aiocqhttp" },
|
{ name = "aiocqhttp" },
|
||||||
@@ -1928,7 +1928,7 @@ requires-dist = [
|
|||||||
{ name = "botocore", specifier = ">=1.42.39" },
|
{ name = "botocore", specifier = ">=1.42.39" },
|
||||||
{ name = "certifi", specifier = ">=2025.4.26" },
|
{ name = "certifi", specifier = ">=2025.4.26" },
|
||||||
{ name = "chardet", specifier = ">=5.2.0" },
|
{ name = "chardet", specifier = ">=5.2.0" },
|
||||||
{ name = "chromadb", specifier = ">=0.4.24" },
|
{ name = "chromadb", specifier = ">=1.0.0,<2.0.0" },
|
||||||
{ name = "colorlog", specifier = "~=6.6.0" },
|
{ name = "colorlog", specifier = "~=6.6.0" },
|
||||||
{ name = "cryptography", specifier = ">=44.0.3" },
|
{ name = "cryptography", specifier = ">=44.0.3" },
|
||||||
{ name = "dashscope", specifier = ">=1.25.10" },
|
{ name = "dashscope", specifier = ">=1.25.10" },
|
||||||
@@ -1937,7 +1937,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.0" },
|
{ name = "langbot-plugin", specifier = "==0.3.1" },
|
||||||
{ 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 +1993,7 @@ dev = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "langbot-plugin"
|
name = "langbot-plugin"
|
||||||
version = "0.3.0"
|
version = "0.3.1"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "aiofiles" },
|
{ name = "aiofiles" },
|
||||||
@@ -2011,28 +2011,28 @@ dependencies = [
|
|||||||
{ name = "watchdog" },
|
{ name = "watchdog" },
|
||||||
{ name = "websockets" },
|
{ name = "websockets" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/8d/e5/3686b3225e5f2ee6e19a6050bb981b49a91f2450dff83deb5dfba13b3a2a/langbot_plugin-0.3.0.tar.gz", hash = "sha256:9add2d6e81c8cc7281863e4a92a33ed6228dcc0243f4327ac4062edc962dbf98", size = 169751, upload-time = "2026-03-08T09:54:27.102Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/4e/ed/b440e26ebc40983abf00dd343338101ada3381065fb3347401ba75f873fe/langbot_plugin-0.3.1.tar.gz", hash = "sha256:0839dcb4cfe689fc670d0ded29b57e6a3f683d8f7326eaa771a5b753675459ac", size = 170285, upload-time = "2026-03-12T15:07:01.918Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/72/51/18f0c1446bcb6712ff3d31d81ea708e3f0e671fde5da69598204a1df977d/langbot_plugin-0.3.0-py3-none-any.whl", hash = "sha256:37bfd3ce507448a6ec4444bec1bc6da1c9911c9df144dfd428febb71122077a6", size = 144096, upload-time = "2026-03-08T09:54:25.581Z" },
|
{ url = "https://files.pythonhosted.org/packages/13/81/d3c4142911792838b90384a28f7dd1540d0862303293c53ba77e69fc0e15/langbot_plugin-0.3.1-py3-none-any.whl", hash = "sha256:8139796926fe8385b7b546ef865e29b1b8d8e28249e20f3b5417d42d3181ec62", size = 144813, upload-time = "2026-03-12T15:07:03.69Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "langchain"
|
name = "langchain"
|
||||||
version = "1.2.7"
|
version = "1.2.12"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "langchain-core" },
|
{ name = "langchain-core" },
|
||||||
{ name = "langgraph" },
|
{ name = "langgraph" },
|
||||||
{ name = "pydantic" },
|
{ name = "pydantic" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/47/f2/478ca9f3455b5d66402066d287eae7e8d6c722acfb8553937e06af708334/langchain-1.2.7.tar.gz", hash = "sha256:ba40e8d5b069a22f7085f54f405973da3d87cfdebf116282e77c692271432ecb", size = 556837, upload-time = "2026-01-23T15:22:10.817Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/d8/1d/1af2fc0ac084d4781778b7846b1aed62e05006bf2d73fdf84ac3a8f5225c/langchain-1.2.12.tar.gz", hash = "sha256:ed705b5b293799f7e3e394387f398a1b71707542758283206c8c21415759d991", size = 566444, upload-time = "2026-03-11T22:21:00.712Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/dd/c8/9ce37ae34870834c7d00bb14ff4876b700db31b928635e3307804dc41d74/langchain-1.2.7-py3-none-any.whl", hash = "sha256:1d643c8ca569bcde2470b853807f74f0768b3982d25d66d57db21a166aabda72", size = 108827, upload-time = "2026-01-23T15:22:09.771Z" },
|
{ url = "https://files.pythonhosted.org/packages/ca/51/09bb1cfb0b57ae9440ca56cc576e4dc792f83d030eef7637d2c516dcb0a0/langchain-1.2.12-py3-none-any.whl", hash = "sha256:60eff184b8f92c2610f5a4c9a97ad339a891adb01901e83e4df8e6c9c69cf852", size = 112373, upload-time = "2026-03-11T22:20:59.508Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "langchain-core"
|
name = "langchain-core"
|
||||||
version = "1.2.7"
|
version = "1.2.18"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "jsonpatch" },
|
{ name = "jsonpatch" },
|
||||||
@@ -2044,9 +2044,9 @@ dependencies = [
|
|||||||
{ name = "typing-extensions" },
|
{ name = "typing-extensions" },
|
||||||
{ name = "uuid-utils" },
|
{ name = "uuid-utils" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/a2/0e/664d8d81b3493e09cbab72448d2f9d693d1fa5aa2bcc488602203a9b6da0/langchain_core-1.2.7.tar.gz", hash = "sha256:e1460639f96c352b4a41c375f25aeb8d16ffc1769499fb1c20503aad59305ced", size = 837039, upload-time = "2026-01-09T17:44:25.505Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/18/b7/8bbd0d99a6441b35d891e4b79e7d24c67722cdd363893ae650f24808cf5a/langchain_core-1.2.18.tar.gz", hash = "sha256:ffe53eec44636d092895b9fe25d28af3aaf79060e293fa7cda2a5aaa50c80d21", size = 836725, upload-time = "2026-03-09T20:40:07.229Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/6e/6f/34a9fba14d191a67f7e2ee3dbce3e9b86d2fa7310e2c7f2c713583481bd2/langchain_core-1.2.7-py3-none-any.whl", hash = "sha256:452f4fef7a3d883357b22600788d37e3d8854ef29da345b7ac7099f33c31828b", size = 490232, upload-time = "2026-01-09T17:44:24.236Z" },
|
{ url = "https://files.pythonhosted.org/packages/1f/d8/9418564ed4ab4f150668b25cf8c188266267d829362e9c9106946afa628b/langchain_core-1.2.18-py3-none-any.whl", hash = "sha256:cccb79523e0045174ab826054e555fddc973266770e427588c8f1ec9d9d6212b", size = 503048, upload-time = "2026-03-09T20:40:06.115Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2063,7 +2063,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "langgraph"
|
name = "langgraph"
|
||||||
version = "1.0.7"
|
version = "1.1.1"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "langchain-core" },
|
{ name = "langchain-core" },
|
||||||
@@ -2073,9 +2073,9 @@ dependencies = [
|
|||||||
{ name = "pydantic" },
|
{ name = "pydantic" },
|
||||||
{ name = "xxhash" },
|
{ name = "xxhash" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/72/5b/f72655717c04e33d3b62f21b166dc063d192b53980e9e3be0e2a117f1c9f/langgraph-1.0.7.tar.gz", hash = "sha256:0cfdfee51e6e8cfe503ecc7367c73933437c505b03fa10a85c710975c8182d9a", size = 497098, upload-time = "2026-01-22T16:57:47.303Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/6d/1a/6dbad0c87fb39a58e5ced85297511cc4bcad06cc420b20898eecafece2a2/langgraph-1.1.1.tar.gz", hash = "sha256:cd6282efc657c955b41bff6bd9693de58137ad18f7e7f16b4d17c7d2118d53e1", size = 544040, upload-time = "2026-03-11T22:14:47.845Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/7e/0e/fe80144e3e4048e5d19ccdb91ac547c1a7dc3da8dbd1443e210048194c14/langgraph-1.0.7-py3-none-any.whl", hash = "sha256:9d68e8f8dd8f3de2fec45f9a06de05766d9b075b78fb03171779893b7a52c4d2", size = 157353, upload-time = "2026-01-22T16:57:45.997Z" },
|
{ url = "https://files.pythonhosted.org/packages/dc/c1/572187bb61a534050ef2d5030e7abe46b19694ec106604fe12ddcb8672c7/langgraph-1.1.1-py3-none-any.whl", hash = "sha256:d0cc8d347131cbfc010e65aad9b0f1afbd0e151f470c288bec1f3df8336c50c6", size = 167502, upload-time = "2026-03-11T22:14:46.121Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2093,15 +2093,15 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "langgraph-prebuilt"
|
name = "langgraph-prebuilt"
|
||||||
version = "1.0.7"
|
version = "1.0.8"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "langchain-core" },
|
{ name = "langchain-core" },
|
||||||
{ name = "langgraph-checkpoint" },
|
{ name = "langgraph-checkpoint" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/a7/59/711aecd1a50999456850dc328f3cad72b4372d8218838d8d5326f80cb76f/langgraph_prebuilt-1.0.7.tar.gz", hash = "sha256:38e097e06de810de4d0e028ffc0e432bb56d1fb417620fb1dfdc76c5e03e4bf9", size = 163692, upload-time = "2026-01-22T16:45:22.801Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/0d/06/dd61a5c2dce009d1b03b1d56f2a85b3127659fdddf5b3be5d8f1d60820fb/langgraph_prebuilt-1.0.8.tar.gz", hash = "sha256:0cd3cf5473ced8a6cd687cc5294e08d3de57529d8dd14fdc6ae4899549efcf69", size = 164442, upload-time = "2026-02-19T18:14:39.083Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/47/49/5e37abb3f38a17a3487634abc2a5da87c208cc1d14577eb8d7184b25c886/langgraph_prebuilt-1.0.7-py3-none-any.whl", hash = "sha256:e14923516504405bb5edc3977085bc9622c35476b50c1808544490e13871fe7c", size = 35324, upload-time = "2026-01-22T16:45:21.784Z" },
|
{ url = "https://files.pythonhosted.org/packages/dc/41/ec966424ad3f2ed3996d24079d3342c8cd6c0bd0653c12b2a917a685ec6c/langgraph_prebuilt-1.0.8-py3-none-any.whl", hash = "sha256:d16a731e591ba4470f3e313a319c7eee7dbc40895bcf15c821f985a3522a7ce0", size = 35648, upload-time = "2026-02-19T18:14:37.611Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -5374,6 +5374,12 @@ wheels = [
|
|||||||
{ url = "https://files.pythonhosted.org/packages/0f/8b/4b61d6e13f7108f36910df9ab4b58fd389cc2520d54d81b88660804aad99/torch-2.10.0-2-cp311-none-macosx_11_0_arm64.whl", hash = "sha256:418997cb02d0a0f1497cf6a09f63166f9f5df9f3e16c8a716ab76a72127c714f", size = 79423467, upload-time = "2026-02-10T21:44:48.711Z" },
|
{ url = "https://files.pythonhosted.org/packages/0f/8b/4b61d6e13f7108f36910df9ab4b58fd389cc2520d54d81b88660804aad99/torch-2.10.0-2-cp311-none-macosx_11_0_arm64.whl", hash = "sha256:418997cb02d0a0f1497cf6a09f63166f9f5df9f3e16c8a716ab76a72127c714f", size = 79423467, upload-time = "2026-02-10T21:44:48.711Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/d3/54/a2ba279afcca44bbd320d4e73675b282fcee3d81400ea1b53934efca6462/torch-2.10.0-2-cp312-none-macosx_11_0_arm64.whl", hash = "sha256:13ec4add8c3faaed8d13e0574f5cd4a323c11655546f91fbe6afa77b57423574", size = 79498202, upload-time = "2026-02-10T21:44:52.603Z" },
|
{ url = "https://files.pythonhosted.org/packages/d3/54/a2ba279afcca44bbd320d4e73675b282fcee3d81400ea1b53934efca6462/torch-2.10.0-2-cp312-none-macosx_11_0_arm64.whl", hash = "sha256:13ec4add8c3faaed8d13e0574f5cd4a323c11655546f91fbe6afa77b57423574", size = 79498202, upload-time = "2026-02-10T21:44:52.603Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ec/23/2c9fe0c9c27f7f6cb865abcea8a4568f29f00acaeadfc6a37f6801f84cb4/torch-2.10.0-2-cp313-none-macosx_11_0_arm64.whl", hash = "sha256:e521c9f030a3774ed770a9c011751fb47c4d12029a3d6522116e48431f2ff89e", size = 79498254, upload-time = "2026-02-10T21:44:44.095Z" },
|
{ url = "https://files.pythonhosted.org/packages/ec/23/2c9fe0c9c27f7f6cb865abcea8a4568f29f00acaeadfc6a37f6801f84cb4/torch-2.10.0-2-cp313-none-macosx_11_0_arm64.whl", hash = "sha256:e521c9f030a3774ed770a9c011751fb47c4d12029a3d6522116e48431f2ff89e", size = 79498254, upload-time = "2026-02-10T21:44:44.095Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/36/ab/7b562f1808d3f65414cd80a4f7d4bb00979d9355616c034c171249e1a303/torch-2.10.0-3-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:ac5bdcbb074384c66fa160c15b1ead77839e3fe7ed117d667249afce0acabfac", size = 915518691, upload-time = "2026-03-11T14:15:43.147Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b3/7a/abada41517ce0011775f0f4eacc79659bc9bc6c361e6bfe6f7052a6b9363/torch-2.10.0-3-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:98c01b8bb5e3240426dcde1446eed6f40c778091c8544767ef1168fc663a05a6", size = 915622781, upload-time = "2026-03-11T14:17:11.354Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ab/c6/4dfe238342ffdcec5aef1c96c457548762d33c40b45a1ab7033bb26d2ff2/torch-2.10.0-3-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:80b1b5bfe38eb0e9f5ff09f206dcac0a87aadd084230d4a36eea5ec5232c115b", size = 915627275, upload-time = "2026-03-11T14:16:11.325Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d8/f0/72bf18847f58f877a6a8acf60614b14935e2f156d942483af1ffc081aea0/torch-2.10.0-3-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:46b3574d93a2a8134b3f5475cfb98e2eb46771794c57015f6ad1fb795ec25e49", size = 915523474, upload-time = "2026-03-11T14:17:44.422Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f4/39/590742415c3030551944edc2ddc273ea1fdfe8ffb2780992e824f1ebee98/torch-2.10.0-3-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:b1d5e2aba4eb7f8e87fbe04f86442887f9167a35f092afe4c237dfcaaef6e328", size = 915632474, upload-time = "2026-03-11T14:15:13.666Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b6/8e/34949484f764dde5b222b7fe3fede43e4a6f0da9d7f8c370bb617d629ee2/torch-2.10.0-3-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:0228d20b06701c05a8f978357f657817a4a63984b0c90745def81c18aedfa591", size = 915523882, upload-time = "2026-03-11T14:14:46.311Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/78/89/f5554b13ebd71e05c0b002f95148033e730d3f7067f67423026cc9c69410/torch-2.10.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:3282d9febd1e4e476630a099692b44fdc214ee9bf8ee5377732d9d9dfe5712e4", size = 145992610, upload-time = "2026-01-21T16:25:26.327Z" },
|
{ url = "https://files.pythonhosted.org/packages/78/89/f5554b13ebd71e05c0b002f95148033e730d3f7067f67423026cc9c69410/torch-2.10.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:3282d9febd1e4e476630a099692b44fdc214ee9bf8ee5377732d9d9dfe5712e4", size = 145992610, upload-time = "2026-01-21T16:25:26.327Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/ae/30/a3a2120621bf9c17779b169fc17e3dc29b230c29d0f8222f499f5e159aa8/torch-2.10.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:a2f9edd8dbc99f62bc4dfb78af7bf89499bca3d753423ac1b4e06592e467b763", size = 915607863, upload-time = "2026-01-21T16:25:06.696Z" },
|
{ url = "https://files.pythonhosted.org/packages/ae/30/a3a2120621bf9c17779b169fc17e3dc29b230c29d0f8222f499f5e159aa8/torch-2.10.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:a2f9edd8dbc99f62bc4dfb78af7bf89499bca3d753423ac1b4e06592e467b763", size = 915607863, upload-time = "2026-01-21T16:25:06.696Z" },
|
||||||
{ url = "https://files.pythonhosted.org/packages/6f/3d/c87b33c5f260a2a8ad68da7147e105f05868c281c63d65ed85aa4da98c66/torch-2.10.0-cp311-cp311-win_amd64.whl", hash = "sha256:29b7009dba4b7a1c960260fc8ac85022c784250af43af9fb0ebafc9883782ebd", size = 113723116, upload-time = "2026-01-21T16:25:21.916Z" },
|
{ url = "https://files.pythonhosted.org/packages/6f/3d/c87b33c5f260a2a8ad68da7147e105f05868c281c63d65ed85aa4da98c66/torch-2.10.0-cp311-cp311-win_amd64.whl", hash = "sha256:29b7009dba4b7a1c960260fc8ac85022c784250af43af9fb0ebafc9883782ebd", size = 113723116, upload-time = "2026-01-21T16:25:21.916Z" },
|
||||||
@@ -5866,14 +5872,14 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "werkzeug"
|
name = "werkzeug"
|
||||||
version = "3.1.5"
|
version = "3.1.6"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "markupsafe" },
|
{ name = "markupsafe" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/5a/70/1469ef1d3542ae7c2c7b72bd5e3a4e6ee69d7978fa8a3af05a38eca5becf/werkzeug-3.1.5.tar.gz", hash = "sha256:6a548b0e88955dd07ccb25539d7d0cc97417ee9e179677d22c7041c8f078ce67", size = 864754, upload-time = "2026-01-08T17:49:23.247Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/61/f1/ee81806690a87dab5f5653c1f146c92bc066d7f4cebc603ef88eb9e13957/werkzeug-3.1.6.tar.gz", hash = "sha256:210c6bede5a420a913956b4791a7f4d6843a43b6fcee4dfa08a65e93007d0d25", size = 864736, upload-time = "2026-02-19T15:17:18.884Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/ad/e4/8d97cca767bcc1be76d16fb76951608305561c6e056811587f36cb1316a8/werkzeug-3.1.5-py3-none-any.whl", hash = "sha256:5111e36e91086ece91f93268bb39b4a35c1e6f1feac762c9c822ded0a4e322dc", size = 225025, upload-time = "2026-01-08T17:49:21.859Z" },
|
{ url = "https://files.pythonhosted.org/packages/4d/ec/d58832f89ede95652fd01f4f24236af7d32b70cab2196dfcc2d2fd13c5c2/werkzeug-3.1.6-py3-none-any.whl", hash = "sha256:7ddf3357bb9564e407607f988f683d72038551200c704012bb9a4c523d42f131", size = 225166, upload-time = "2026-02-19T15:17:17.475Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
@@ -102,5 +102,10 @@
|
|||||||
"typescript": "^5.8.3",
|
"typescript": "^5.8.3",
|
||||||
"typescript-eslint": "^8.31.1"
|
"typescript-eslint": "^8.31.1"
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@8.9.2+sha512.b9d35fe91b2a5854dadc43034a3e7b2e675fa4b56e20e8e09ef078fa553c18f8aed44051e7b36e8b8dd435f97eb0c44c4ff3b44fc7c6fa7d21e1fac17bbe661e"
|
"packageManager": "pnpm@8.9.2+sha512.b9d35fe91b2a5854dadc43034a3e7b2e675fa4b56e20e8e09ef078fa553c18f8aed44051e7b36e8b8dd435f97eb0c44c4ff3b44fc7c6fa7d21e1fac17bbe661e",
|
||||||
}
|
"pnpm": {
|
||||||
|
"overrides": {
|
||||||
|
"minimatch": "3.1.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
34
web/pnpm-lock.yaml
generated
34
web/pnpm-lock.yaml
generated
@@ -4,6 +4,9 @@ settings:
|
|||||||
autoInstallPeers: true
|
autoInstallPeers: true
|
||||||
excludeLinksFromLockfile: false
|
excludeLinksFromLockfile: false
|
||||||
|
|
||||||
|
overrides:
|
||||||
|
minimatch: 3.1.3
|
||||||
|
|
||||||
dependencies:
|
dependencies:
|
||||||
'@dnd-kit/core':
|
'@dnd-kit/core':
|
||||||
specifier: ^6.3.1
|
specifier: ^6.3.1
|
||||||
@@ -345,7 +348,7 @@ packages:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@eslint/object-schema': 2.1.7
|
'@eslint/object-schema': 2.1.7
|
||||||
debug: 4.4.3
|
debug: 4.4.3
|
||||||
minimatch: 3.1.2
|
minimatch: 3.1.3
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
dev: true
|
dev: true
|
||||||
@@ -375,7 +378,7 @@ packages:
|
|||||||
ignore: 5.3.2
|
ignore: 5.3.2
|
||||||
import-fresh: 3.3.1
|
import-fresh: 3.3.1
|
||||||
js-yaml: 4.1.1
|
js-yaml: 4.1.1
|
||||||
minimatch: 3.1.2
|
minimatch: 3.1.3
|
||||||
strip-json-comments: 3.1.1
|
strip-json-comments: 3.1.1
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
@@ -2260,7 +2263,7 @@ packages:
|
|||||||
'@typescript-eslint/types': 8.54.0
|
'@typescript-eslint/types': 8.54.0
|
||||||
'@typescript-eslint/visitor-keys': 8.54.0
|
'@typescript-eslint/visitor-keys': 8.54.0
|
||||||
debug: 4.4.3
|
debug: 4.4.3
|
||||||
minimatch: 9.0.5
|
minimatch: 3.1.3
|
||||||
semver: 7.7.3
|
semver: 7.7.3
|
||||||
tinyglobby: 0.2.15
|
tinyglobby: 0.2.15
|
||||||
ts-api-utils: 2.4.0(typescript@5.9.3)
|
ts-api-utils: 2.4.0(typescript@5.9.3)
|
||||||
@@ -2678,12 +2681,6 @@ packages:
|
|||||||
concat-map: 0.0.1
|
concat-map: 0.0.1
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
/brace-expansion@2.0.2:
|
|
||||||
resolution: {integrity: sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==}
|
|
||||||
dependencies:
|
|
||||||
balanced-match: 1.0.2
|
|
||||||
dev: true
|
|
||||||
|
|
||||||
/braces@3.0.3:
|
/braces@3.0.3:
|
||||||
resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==}
|
resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
@@ -3345,7 +3342,7 @@ packages:
|
|||||||
hasown: 2.0.2
|
hasown: 2.0.2
|
||||||
is-core-module: 2.16.1
|
is-core-module: 2.16.1
|
||||||
is-glob: 4.0.3
|
is-glob: 4.0.3
|
||||||
minimatch: 3.1.2
|
minimatch: 3.1.3
|
||||||
object.fromentries: 2.0.8
|
object.fromentries: 2.0.8
|
||||||
object.groupby: 1.0.3
|
object.groupby: 1.0.3
|
||||||
object.values: 1.2.1
|
object.values: 1.2.1
|
||||||
@@ -3376,7 +3373,7 @@ packages:
|
|||||||
hasown: 2.0.2
|
hasown: 2.0.2
|
||||||
jsx-ast-utils: 3.3.5
|
jsx-ast-utils: 3.3.5
|
||||||
language-tags: 1.0.9
|
language-tags: 1.0.9
|
||||||
minimatch: 3.1.2
|
minimatch: 3.1.3
|
||||||
object.fromentries: 2.0.8
|
object.fromentries: 2.0.8
|
||||||
safe-regex-test: 1.1.0
|
safe-regex-test: 1.1.0
|
||||||
string.prototype.includes: 2.0.1
|
string.prototype.includes: 2.0.1
|
||||||
@@ -3428,7 +3425,7 @@ packages:
|
|||||||
estraverse: 5.3.0
|
estraverse: 5.3.0
|
||||||
hasown: 2.0.2
|
hasown: 2.0.2
|
||||||
jsx-ast-utils: 3.3.5
|
jsx-ast-utils: 3.3.5
|
||||||
minimatch: 3.1.2
|
minimatch: 3.1.3
|
||||||
object.entries: 1.1.9
|
object.entries: 1.1.9
|
||||||
object.fromentries: 2.0.8
|
object.fromentries: 2.0.8
|
||||||
object.values: 1.2.1
|
object.values: 1.2.1
|
||||||
@@ -3498,7 +3495,7 @@ packages:
|
|||||||
is-glob: 4.0.3
|
is-glob: 4.0.3
|
||||||
json-stable-stringify-without-jsonify: 1.0.1
|
json-stable-stringify-without-jsonify: 1.0.1
|
||||||
lodash.merge: 4.6.2
|
lodash.merge: 4.6.2
|
||||||
minimatch: 3.1.2
|
minimatch: 3.1.3
|
||||||
natural-compare: 1.4.0
|
natural-compare: 1.4.0
|
||||||
optionator: 0.9.4
|
optionator: 0.9.4
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
@@ -5113,19 +5110,12 @@ packages:
|
|||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
/minimatch@3.1.2:
|
/minimatch@3.1.3:
|
||||||
resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==}
|
resolution: {integrity: sha512-M2GCs7Vk83NxkUyQV1bkABc4yxgz9kILhHImZiBPAZ9ybuvCb0/H7lEl5XvIg3g+9d4eNotkZA5IWwYl0tibaA==}
|
||||||
dependencies:
|
dependencies:
|
||||||
brace-expansion: 1.1.12
|
brace-expansion: 1.1.12
|
||||||
dev: true
|
dev: true
|
||||||
|
|
||||||
/minimatch@9.0.5:
|
|
||||||
resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==}
|
|
||||||
engines: {node: '>=16 || 14 >=14.17'}
|
|
||||||
dependencies:
|
|
||||||
brace-expansion: 2.0.2
|
|
||||||
dev: true
|
|
||||||
|
|
||||||
/minimist@1.2.8:
|
/minimist@1.2.8:
|
||||||
resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
|
resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
|
||||||
dev: true
|
dev: true
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React, { useEffect, useState } from 'react';
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
import {
|
import {
|
||||||
IChooseAdapterEntity,
|
IChooseAdapterEntity,
|
||||||
IPipelineEntity,
|
IPipelineEntity,
|
||||||
@@ -113,115 +113,73 @@ export default function BotForm({
|
|||||||
const [dynamicFormConfigList, setDynamicFormConfigList] = useState<
|
const [dynamicFormConfigList, setDynamicFormConfigList] = useState<
|
||||||
IDynamicFormItemSchema[]
|
IDynamicFormItemSchema[]
|
||||||
>([]);
|
>([]);
|
||||||
const [filteredDynamicFormConfigList, setFilteredDynamicFormConfigList] =
|
|
||||||
useState<IDynamicFormItemSchema[]>([]);
|
|
||||||
const [, setIsLoading] = useState<boolean>(false);
|
const [, setIsLoading] = useState<boolean>(false);
|
||||||
const [webhookUrl, setWebhookUrl] = useState<string>('');
|
const [webhookUrl, setWebhookUrl] = useState<string>('');
|
||||||
const webhookInputRef = React.useRef<HTMLInputElement>(null);
|
const [extraWebhookUrl, setExtraWebhookUrl] = useState<string>('');
|
||||||
const [copied, setCopied] = useState<boolean>(false);
|
const [copied, setCopied] = useState<boolean>(false);
|
||||||
|
const [extraCopied, setExtraCopied] = useState<boolean>(false);
|
||||||
|
|
||||||
// Watch adapter and adapter_config for filtering
|
// Watch adapter and adapter_config for filtering
|
||||||
const currentAdapter = form.watch('adapter');
|
const currentAdapter = form.watch('adapter');
|
||||||
const currentAdapterConfig = form.watch('adapter_config');
|
const currentAdapterConfig = form.watch('adapter_config');
|
||||||
|
|
||||||
// Serialize adapter_config to a stable string so it can be used as a
|
// Derive the filtered config list via useMemo instead of useEffect+setState
|
||||||
// useEffect dependency without triggering on every render. form.watch()
|
// to avoid creating new array references that would cause DynamicFormComponent
|
||||||
// returns a new object reference each time, which would otherwise cause
|
// to re-subscribe its form.watch, re-emit values, and trigger an infinite loop.
|
||||||
// the filtering effect below to loop indefinitely.
|
// Only depend on the specific field we care about (enable-webhook) rather than
|
||||||
const adapterConfigJson = JSON.stringify(currentAdapterConfig);
|
// the entire currentAdapterConfig object, which changes on every emission.
|
||||||
|
const enableWebhook = currentAdapterConfig?.['enable-webhook'];
|
||||||
|
const filteredDynamicFormConfigList = useMemo(() => {
|
||||||
|
if (currentAdapter === 'lark' && enableWebhook === false) {
|
||||||
|
// Hide encrypt-key field when webhook is disabled
|
||||||
|
return dynamicFormConfigList.filter(
|
||||||
|
(config) => config.name !== 'encrypt-key',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// For non-Lark adapters or when webhook is enabled/undefined, show all fields
|
||||||
|
return dynamicFormConfigList;
|
||||||
|
}, [currentAdapter, enableWebhook, dynamicFormConfigList]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setBotFormValues();
|
setBotFormValues();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Filter dynamic form config list based on enable-webhook status for Lark adapter
|
// 复制到剪贴板的辅助函数
|
||||||
useEffect(() => {
|
const copyToClipboard = (
|
||||||
if (currentAdapter === 'lark') {
|
text: string,
|
||||||
const enableWebhook = currentAdapterConfig?.['enable-webhook'];
|
setStatus: React.Dispatch<React.SetStateAction<boolean>>,
|
||||||
if (enableWebhook === false) {
|
) => {
|
||||||
// Hide encrypt-key field when webhook is disabled
|
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||||
setFilteredDynamicFormConfigList(
|
navigator.clipboard
|
||||||
dynamicFormConfigList.filter(
|
.writeText(text)
|
||||||
(config) => config.name !== 'encrypt-key',
|
.then(() => {
|
||||||
),
|
setStatus(true);
|
||||||
);
|
setTimeout(() => setStatus(false), 2000);
|
||||||
} else {
|
})
|
||||||
// Show all fields when webhook is enabled or undefined
|
.catch(() => {
|
||||||
setFilteredDynamicFormConfigList(dynamicFormConfigList);
|
// 降级:创建临时textarea复制
|
||||||
}
|
fallbackCopy(text, setStatus);
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
// For non-Lark adapters, show all fields
|
fallbackCopy(text, setStatus);
|
||||||
setFilteredDynamicFormConfigList(dynamicFormConfigList);
|
|
||||||
}
|
}
|
||||||
}, [currentAdapter, adapterConfigJson, dynamicFormConfigList]);
|
};
|
||||||
|
|
||||||
// 复制到剪贴板的辅助函数 - 使用页面上的真实input元素
|
const fallbackCopy = (
|
||||||
const copyToClipboard = () => {
|
text: string,
|
||||||
console.log('[Copy] Attempting to copy from input element');
|
setStatus: React.Dispatch<React.SetStateAction<boolean>>,
|
||||||
|
) => {
|
||||||
const inputElement = webhookInputRef.current;
|
const textarea = document.createElement('textarea');
|
||||||
if (!inputElement) {
|
textarea.value = text;
|
||||||
console.error('[Copy] Input element not found');
|
textarea.style.position = 'fixed';
|
||||||
return;
|
textarea.style.opacity = '0';
|
||||||
}
|
document.body.appendChild(textarea);
|
||||||
|
textarea.select();
|
||||||
try {
|
const successful = document.execCommand('copy');
|
||||||
// 确保input元素可见且未被禁用
|
document.body.removeChild(textarea);
|
||||||
inputElement.disabled = false;
|
if (successful) {
|
||||||
inputElement.readOnly = false;
|
setStatus(true);
|
||||||
|
setTimeout(() => setStatus(false), 2000);
|
||||||
// 聚焦并选中所有文本
|
|
||||||
inputElement.focus();
|
|
||||||
inputElement.select();
|
|
||||||
|
|
||||||
// 尝试使用现代API
|
|
||||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
|
||||||
console.log(
|
|
||||||
'[Copy] Using Clipboard API with input value:',
|
|
||||||
inputElement.value,
|
|
||||||
);
|
|
||||||
navigator.clipboard
|
|
||||||
.writeText(inputElement.value)
|
|
||||||
.then(() => {
|
|
||||||
console.log('[Copy] Clipboard API success');
|
|
||||||
inputElement.blur(); // 取消选中
|
|
||||||
inputElement.readOnly = true;
|
|
||||||
setCopied(true);
|
|
||||||
setTimeout(() => setCopied(false), 2000);
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
console.error(
|
|
||||||
'[Copy] Clipboard API failed, trying execCommand:',
|
|
||||||
err,
|
|
||||||
);
|
|
||||||
// 降级到execCommand
|
|
||||||
const successful = document.execCommand('copy');
|
|
||||||
console.log('[Copy] execCommand result:', successful);
|
|
||||||
inputElement.blur();
|
|
||||||
inputElement.readOnly = true;
|
|
||||||
if (successful) {
|
|
||||||
setCopied(true);
|
|
||||||
setTimeout(() => setCopied(false), 2000);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
// 直接使用execCommand
|
|
||||||
console.log(
|
|
||||||
'[Copy] Using execCommand with input value:',
|
|
||||||
inputElement.value,
|
|
||||||
);
|
|
||||||
const successful = document.execCommand('copy');
|
|
||||||
console.log('[Copy] execCommand result:', successful);
|
|
||||||
inputElement.blur();
|
|
||||||
inputElement.readOnly = true;
|
|
||||||
if (successful) {
|
|
||||||
setCopied(true);
|
|
||||||
setTimeout(() => setCopied(false), 2000);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error('[Copy] Copy failed:', err);
|
|
||||||
inputElement.readOnly = true;
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -246,6 +204,7 @@ export default function BotForm({
|
|||||||
} else {
|
} else {
|
||||||
setWebhookUrl('');
|
setWebhookUrl('');
|
||||||
}
|
}
|
||||||
|
setExtraWebhookUrl(val.extra_webhook_full_url || '');
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
toast.error(
|
toast.error(
|
||||||
@@ -255,6 +214,7 @@ export default function BotForm({
|
|||||||
} else {
|
} else {
|
||||||
form.reset();
|
form.reset();
|
||||||
setWebhookUrl('');
|
setWebhookUrl('');
|
||||||
|
setExtraWebhookUrl('');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -327,14 +287,20 @@ export default function BotForm({
|
|||||||
setAdapterNameToDynamicConfigMap(adapterNameToDynamicConfigMap);
|
setAdapterNameToDynamicConfigMap(adapterNameToDynamicConfigMap);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getBotConfig(
|
async function getBotConfig(botId: string): Promise<
|
||||||
botId: string,
|
z.infer<typeof formSchema> & {
|
||||||
): Promise<z.infer<typeof formSchema> & { webhook_full_url?: string }> {
|
webhook_full_url?: string;
|
||||||
|
extra_webhook_full_url?: string;
|
||||||
|
}
|
||||||
|
> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
httpClient
|
httpClient
|
||||||
.getBot(botId)
|
.getBot(botId)
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
const bot = res.bot;
|
const bot = res.bot;
|
||||||
|
const runtimeValues = bot.adapter_runtime_values as
|
||||||
|
| Record<string, unknown>
|
||||||
|
| undefined;
|
||||||
resolve({
|
resolve({
|
||||||
adapter: bot.adapter,
|
adapter: bot.adapter,
|
||||||
description: bot.description,
|
description: bot.description,
|
||||||
@@ -342,10 +308,12 @@ export default function BotForm({
|
|||||||
adapter_config: bot.adapter_config,
|
adapter_config: bot.adapter_config,
|
||||||
enable: bot.enable ?? true,
|
enable: bot.enable ?? true,
|
||||||
use_pipeline_uuid: bot.use_pipeline_uuid ?? '',
|
use_pipeline_uuid: bot.use_pipeline_uuid ?? '',
|
||||||
webhook_full_url: bot.adapter_runtime_values
|
webhook_full_url: runtimeValues?.webhook_full_url as
|
||||||
? ((bot.adapter_runtime_values as Record<string, unknown>)
|
| string
|
||||||
.webhook_full_url as string)
|
| undefined,
|
||||||
: undefined,
|
extra_webhook_full_url: runtimeValues?.extra_webhook_full_url as
|
||||||
|
| string
|
||||||
|
| undefined,
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
@@ -536,13 +504,11 @@ export default function BotForm({
|
|||||||
|
|
||||||
{/* Webhook 地址显示(统一 Webhook 模式) */}
|
{/* Webhook 地址显示(统一 Webhook 模式) */}
|
||||||
{webhookUrl &&
|
{webhookUrl &&
|
||||||
(currentAdapter !== 'lark' ||
|
(currentAdapter !== 'lark' || enableWebhook !== false) && (
|
||||||
currentAdapterConfig?.['enable-webhook'] !== false) && (
|
|
||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>{t('bots.webhookUrl')}</FormLabel>
|
<FormLabel>{t('bots.webhookUrl')}</FormLabel>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Input
|
<Input
|
||||||
ref={webhookInputRef}
|
|
||||||
value={webhookUrl}
|
value={webhookUrl}
|
||||||
readOnly
|
readOnly
|
||||||
className="flex-1 bg-gray-50 dark:bg-gray-900"
|
className="flex-1 bg-gray-50 dark:bg-gray-900"
|
||||||
@@ -555,7 +521,7 @@ export default function BotForm({
|
|||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={copyToClipboard}
|
onClick={() => copyToClipboard(webhookUrl, setCopied)}
|
||||||
>
|
>
|
||||||
{copied ? (
|
{copied ? (
|
||||||
<Check className="h-4 w-4 text-green-600 mr-2" />
|
<Check className="h-4 w-4 text-green-600 mr-2" />
|
||||||
@@ -565,8 +531,37 @@ export default function BotForm({
|
|||||||
{t('common.copy')}
|
{t('common.copy')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
{extraWebhookUrl && (
|
||||||
|
<div className="flex items-center gap-2 mt-2">
|
||||||
|
<Input
|
||||||
|
value={extraWebhookUrl}
|
||||||
|
readOnly
|
||||||
|
className="flex-1 bg-gray-50 dark:bg-gray-900"
|
||||||
|
onClick={(e) => {
|
||||||
|
(e.target as HTMLInputElement).select();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() =>
|
||||||
|
copyToClipboard(extraWebhookUrl, setExtraCopied)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{extraCopied ? (
|
||||||
|
<Check className="h-4 w-4 text-green-600 mr-2" />
|
||||||
|
) : (
|
||||||
|
<Copy className="h-4 w-4 mr-2" />
|
||||||
|
)}
|
||||||
|
{t('common.copy')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<p className="text-sm text-gray-500 mt-1">
|
<p className="text-sm text-gray-500 mt-1">
|
||||||
{t('bots.webhookUrlHint')}
|
{extraWebhookUrl
|
||||||
|
? t('bots.webhookUrlHintEither')
|
||||||
|
: t('bots.webhookUrlHint')}
|
||||||
</p>
|
</p>
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
@@ -673,7 +668,7 @@ export default function BotForm({
|
|||||||
</div>
|
</div>
|
||||||
<DynamicFormComponent
|
<DynamicFormComponent
|
||||||
itemConfigList={filteredDynamicFormConfigList}
|
itemConfigList={filteredDynamicFormConfigList}
|
||||||
initialValues={form.watch('adapter_config')}
|
initialValues={currentAdapterConfig}
|
||||||
onSubmit={(values) => {
|
onSubmit={(values) => {
|
||||||
form.setValue('adapter_config', values);
|
form.setValue('adapter_config', values);
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { httpClient } from '@/app/infra/http/HttpClient';
|
|||||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
import { Copy, Check } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
MessageChainComponent,
|
MessageChainComponent,
|
||||||
Plain,
|
Plain,
|
||||||
@@ -27,6 +28,7 @@ interface SessionInfo {
|
|||||||
is_active: boolean;
|
is_active: boolean;
|
||||||
platform?: string | null;
|
platform?: string | null;
|
||||||
user_id?: string | null;
|
user_id?: string | null;
|
||||||
|
user_name?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SessionMessage {
|
interface SessionMessage {
|
||||||
@@ -60,8 +62,29 @@ export default function BotSessionMonitor({ botId }: BotSessionMonitorProps) {
|
|||||||
const [messages, setMessages] = useState<SessionMessage[]>([]);
|
const [messages, setMessages] = useState<SessionMessage[]>([]);
|
||||||
const [loadingSessions, setLoadingSessions] = useState(false);
|
const [loadingSessions, setLoadingSessions] = useState(false);
|
||||||
const [loadingMessages, setLoadingMessages] = useState(false);
|
const [loadingMessages, setLoadingMessages] = useState(false);
|
||||||
|
const [copiedUserId, setCopiedUserId] = useState(false);
|
||||||
const messagesContainerRef = useRef<HTMLDivElement>(null);
|
const messagesContainerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const parseSessionType = (sessionId: string): string | null => {
|
||||||
|
const idx = sessionId.indexOf('_');
|
||||||
|
if (idx === -1) return null;
|
||||||
|
const type = sessionId.slice(0, idx);
|
||||||
|
if (type === 'person' || type === 'group') return type;
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const abbreviateId = (id: string): string => {
|
||||||
|
if (id.length <= 10) return id;
|
||||||
|
return `${id.slice(0, 4)}..${id.slice(-4)}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const copyUserId = (userId: string) => {
|
||||||
|
navigator.clipboard.writeText(userId).then(() => {
|
||||||
|
setCopiedUserId(true);
|
||||||
|
setTimeout(() => setCopiedUserId(false), 2000);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const loadSessions = useCallback(async () => {
|
const loadSessions = useCallback(async () => {
|
||||||
setLoadingSessions(true);
|
setLoadingSessions(true);
|
||||||
try {
|
try {
|
||||||
@@ -338,24 +361,36 @@ export default function BotSessionMonitor({ botId }: BotSessionMonitorProps) {
|
|||||||
>
|
>
|
||||||
<div className="flex items-center justify-between mb-0.5">
|
<div className="flex items-center justify-between mb-0.5">
|
||||||
<span className="text-sm font-medium truncate mr-2">
|
<span className="text-sm font-medium truncate mr-2">
|
||||||
{session.user_id || session.session_id.slice(0, 12)}
|
{session.user_name ||
|
||||||
|
session.user_id ||
|
||||||
|
session.session_id.slice(0, 12)}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-[11px] text-muted-foreground tabular-nums flex-shrink-0">
|
<span className="text-[11px] text-muted-foreground tabular-nums flex-shrink-0">
|
||||||
{formatRelativeTime(session.last_activity)}
|
{formatRelativeTime(session.last_activity)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||||
|
{parseSessionType(session.session_id) && (
|
||||||
|
<span className="px-1 py-0.5 rounded bg-muted text-[10px]">
|
||||||
|
{parseSessionType(session.session_id)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{session.platform && (
|
{session.platform && (
|
||||||
<span className="px-1 py-0.5 rounded bg-muted text-[10px]">
|
<span className="px-1 py-0.5 rounded bg-muted text-[10px]">
|
||||||
{session.platform}
|
{session.platform}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
{session.user_id && (
|
||||||
|
<span className="truncate text-[10px]">
|
||||||
|
{abbreviateId(session.user_id)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
{session.is_active && (
|
{session.is_active && (
|
||||||
<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>
|
||||||
)}
|
)}
|
||||||
<span>{session.pipeline_name}</span>
|
<span className="truncate">{session.pipeline_name}</span>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
@@ -377,15 +412,42 @@ export default function BotSessionMonitor({ botId }: BotSessionMonitorProps) {
|
|||||||
<div className="px-6 py-3 border-b shrink-0 flex items-center justify-between">
|
<div className="px-6 py-3 border-b shrink-0 flex items-center justify-between">
|
||||||
<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_id || selectedSessionId.slice(0, 20)}
|
{selectedSession?.user_name ||
|
||||||
|
selectedSession?.user_id ||
|
||||||
|
selectedSessionId.slice(0, 20)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||||
|
{parseSessionType(selectedSessionId) && (
|
||||||
|
<span>{parseSessionType(selectedSessionId)}</span>
|
||||||
|
)}
|
||||||
{selectedSession?.platform && (
|
{selectedSession?.platform && (
|
||||||
<span>{selectedSession.platform}</span>
|
<>
|
||||||
|
{parseSessionType(selectedSessionId) && <span>·</span>}
|
||||||
|
<span>{selectedSession.platform}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{selectedSession?.user_id && (
|
||||||
|
<>
|
||||||
|
<span>·</span>
|
||||||
|
<span className="font-mono">
|
||||||
|
{selectedSession.user_id}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => copyUserId(selectedSession.user_id!)}
|
||||||
|
className="inline-flex items-center text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
title={t('common.copy')}
|
||||||
|
>
|
||||||
|
{copiedUserId ? (
|
||||||
|
<Check className="w-3 h-3 text-green-600" />
|
||||||
|
) : (
|
||||||
|
<Copy className="w-3 h-3" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
{selectedSession?.pipeline_name && (
|
{selectedSession?.pipeline_name && (
|
||||||
<>
|
<>
|
||||||
{selectedSession?.platform && <span>·</span>}
|
<span>·</span>
|
||||||
<span>{selectedSession.pipeline_name}</span>
|
<span>{selectedSession.pipeline_name}</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
FormMessage,
|
FormMessage,
|
||||||
} from '@/components/ui/form';
|
} from '@/components/ui/form';
|
||||||
import DynamicFormItemComponent from '@/app/home/components/dynamic-form/DynamicFormItemComponent';
|
import DynamicFormItemComponent from '@/app/home/components/dynamic-form/DynamicFormItemComponent';
|
||||||
import { useCallback, useEffect, useRef } from 'react';
|
import { useEffect, useRef } from 'react';
|
||||||
import { extractI18nObject } from '@/i18n/I18nProvider';
|
import { extractI18nObject } from '@/i18n/I18nProvider';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
|
||||||
@@ -34,6 +34,35 @@ export default function DynamicFormComponent({
|
|||||||
const previousInitialValues = useRef(initialValues);
|
const previousInitialValues = useRef(initialValues);
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
// Normalize a form value according to its field type.
|
||||||
|
// This ensures legacy/malformed data (e.g. a plain string for
|
||||||
|
// model-fallback-selector) is coerced to the expected shape
|
||||||
|
// so that downstream components never crash.
|
||||||
|
const normalizeFieldValue = (
|
||||||
|
item: IDynamicFormItemSchema,
|
||||||
|
value: unknown,
|
||||||
|
): unknown => {
|
||||||
|
if (item.type === 'model-fallback-selector') {
|
||||||
|
if (value != null && typeof value === 'object' && !Array.isArray(value)) {
|
||||||
|
const obj = value as Record<string, unknown>;
|
||||||
|
return {
|
||||||
|
primary: typeof obj.primary === 'string' ? obj.primary : '',
|
||||||
|
fallbacks: Array.isArray(obj.fallbacks)
|
||||||
|
? (obj.fallbacks as unknown[]).filter(
|
||||||
|
(v): v is string => typeof v === 'string',
|
||||||
|
)
|
||||||
|
: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// Legacy string format or any other unexpected type
|
||||||
|
return {
|
||||||
|
primary: typeof value === 'string' ? value : '',
|
||||||
|
fallbacks: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
};
|
||||||
|
|
||||||
// 根据 itemConfigList 动态生成 zod schema
|
// 根据 itemConfigList 动态生成 zod schema
|
||||||
const formSchema = z.object(
|
const formSchema = z.object(
|
||||||
itemConfigList.reduce(
|
itemConfigList.reduce(
|
||||||
@@ -73,6 +102,12 @@ export default function DynamicFormComponent({
|
|||||||
case 'bot-selector':
|
case 'bot-selector':
|
||||||
fieldSchema = z.string();
|
fieldSchema = z.string();
|
||||||
break;
|
break;
|
||||||
|
case 'model-fallback-selector':
|
||||||
|
fieldSchema = z.object({
|
||||||
|
primary: z.string(),
|
||||||
|
fallbacks: z.array(z.string()),
|
||||||
|
});
|
||||||
|
break;
|
||||||
case 'prompt-editor':
|
case 'prompt-editor':
|
||||||
fieldSchema = z.array(
|
fieldSchema = z.array(
|
||||||
z.object({
|
z.object({
|
||||||
@@ -110,10 +145,10 @@ export default function DynamicFormComponent({
|
|||||||
resolver: zodResolver(formSchema),
|
resolver: zodResolver(formSchema),
|
||||||
defaultValues: itemConfigList.reduce((acc, item) => {
|
defaultValues: itemConfigList.reduce((acc, item) => {
|
||||||
// 优先使用 initialValues,如果没有则使用默认值
|
// 优先使用 initialValues,如果没有则使用默认值
|
||||||
const value = initialValues?.[item.name] ?? item.default;
|
const rawValue = initialValues?.[item.name] ?? item.default;
|
||||||
return {
|
return {
|
||||||
...acc,
|
...acc,
|
||||||
[item.name]: value,
|
[item.name]: normalizeFieldValue(item, rawValue),
|
||||||
};
|
};
|
||||||
}, {} as FormValues),
|
}, {} as FormValues),
|
||||||
});
|
});
|
||||||
@@ -138,7 +173,8 @@ export default function DynamicFormComponent({
|
|||||||
// 合并默认值和初始值
|
// 合并默认值和初始值
|
||||||
const mergedValues = itemConfigList.reduce(
|
const mergedValues = itemConfigList.reduce(
|
||||||
(acc, item) => {
|
(acc, item) => {
|
||||||
acc[item.name] = initialValues[item.name] ?? item.default;
|
const rawValue = initialValues[item.name] ?? item.default;
|
||||||
|
acc[item.name] = normalizeFieldValue(item, rawValue) as object;
|
||||||
return acc;
|
return acc;
|
||||||
},
|
},
|
||||||
{} as Record<string, object>,
|
{} as Record<string, object>,
|
||||||
@@ -160,39 +196,44 @@ export default function DynamicFormComponent({
|
|||||||
const onSubmitRef = useRef(onSubmit);
|
const onSubmitRef = useRef(onSubmit);
|
||||||
onSubmitRef.current = onSubmit;
|
onSubmitRef.current = onSubmit;
|
||||||
|
|
||||||
// Track the last emitted values to avoid emitting identical snapshots,
|
// 监听表单值变化
|
||||||
// which would cause the parent to call setValue with an equivalent object,
|
useEffect(() => {
|
||||||
// triggering a re-render loop.
|
// Emit initial form values immediately so the parent always has a valid snapshot,
|
||||||
const lastEmittedRef = useRef<string>('');
|
// even if the user saves without modifying any field.
|
||||||
|
// form.watch(callback) only fires on subsequent changes, not on mount.
|
||||||
const emitValues = useCallback(() => {
|
|
||||||
const formValues = form.getValues();
|
const formValues = form.getValues();
|
||||||
const finalValues = itemConfigList.reduce(
|
const initialFinalValues = itemConfigList.reduce(
|
||||||
(acc, item) => {
|
(acc, item) => {
|
||||||
acc[item.name] = formValues[item.name] ?? item.default;
|
acc[item.name] = formValues[item.name] ?? item.default;
|
||||||
return acc;
|
return acc;
|
||||||
},
|
},
|
||||||
{} as Record<string, object>,
|
{} as Record<string, object>,
|
||||||
);
|
);
|
||||||
const serialized = JSON.stringify(finalValues);
|
onSubmitRef.current?.(initialFinalValues);
|
||||||
if (serialized !== lastEmittedRef.current) {
|
|
||||||
lastEmittedRef.current = serialized;
|
|
||||||
onSubmitRef.current?.(finalValues);
|
|
||||||
}
|
|
||||||
}, [form, itemConfigList]);
|
|
||||||
|
|
||||||
// 监听表单值变化
|
// Update previousInitialValues to the emitted snapshot so that if the
|
||||||
useEffect(() => {
|
// parent writes these values back as new initialValues, the deep
|
||||||
// Emit initial form values immediately so the parent always has a valid snapshot,
|
// comparison in the initialValues-sync useEffect won't detect a change
|
||||||
// even if the user saves without modifying any field.
|
// and won't trigger an infinite update loop.
|
||||||
// form.watch(callback) only fires on subsequent changes, not on mount.
|
previousInitialValues.current = initialFinalValues as Record<
|
||||||
emitValues();
|
string,
|
||||||
|
object
|
||||||
|
>;
|
||||||
|
|
||||||
const subscription = form.watch(() => {
|
const subscription = form.watch(() => {
|
||||||
emitValues();
|
const formValues = form.getValues();
|
||||||
|
const finalValues = itemConfigList.reduce(
|
||||||
|
(acc, item) => {
|
||||||
|
acc[item.name] = formValues[item.name] ?? item.default;
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{} as Record<string, object>,
|
||||||
|
);
|
||||||
|
onSubmitRef.current?.(finalValues);
|
||||||
|
previousInitialValues.current = finalValues as Record<string, object>;
|
||||||
});
|
});
|
||||||
return () => subscription.unsubscribe();
|
return () => subscription.unsubscribe();
|
||||||
}, [form, itemConfigList, emitValues]);
|
}, [form, itemConfigList]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Form {...form}>
|
<Form {...form}>
|
||||||
@@ -231,6 +272,7 @@ export default function DynamicFormComponent({
|
|||||||
|
|
||||||
// All fields are disabled when editing (creation_settings are immutable)
|
// All fields are disabled when editing (creation_settings are immutable)
|
||||||
const isFieldDisabled = !!isEditing;
|
const isFieldDisabled = !!isEditing;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FormField
|
<FormField
|
||||||
key={config.id}
|
key={config.id}
|
||||||
|
|||||||
@@ -124,6 +124,28 @@ export default function DynamicFormItemComponent({
|
|||||||
}
|
}
|
||||||
}, [config.type]);
|
}, [config.type]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (config.type === DynamicFormItemType.MODEL_FALLBACK_SELECTOR) {
|
||||||
|
httpClient
|
||||||
|
.getProviderLLMModels()
|
||||||
|
.then((resp) => {
|
||||||
|
let models = resp.models;
|
||||||
|
if (
|
||||||
|
systemInfo.disable_models_service ||
|
||||||
|
userInfo?.account_type !== 'space'
|
||||||
|
) {
|
||||||
|
models = models.filter(
|
||||||
|
(m) => m.provider?.requester !== 'space-chat-completions',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
setLlmModels(models);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
toast.error('Failed to get LLM model list: ' + err.msg);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [config.type]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (
|
if (
|
||||||
config.type === DynamicFormItemType.KNOWLEDGE_BASE_SELECTOR ||
|
config.type === DynamicFormItemType.KNOWLEDGE_BASE_SELECTOR ||
|
||||||
@@ -171,12 +193,7 @@ export default function DynamicFormItemComponent({
|
|||||||
return <Textarea {...field} className="min-h-[120px]" />;
|
return <Textarea {...field} className="min-h-[120px]" />;
|
||||||
|
|
||||||
case DynamicFormItemType.BOOLEAN:
|
case DynamicFormItemType.BOOLEAN:
|
||||||
return (
|
return <Switch checked={field.value} onCheckedChange={field.onChange} />;
|
||||||
<Switch
|
|
||||||
checked={field.value ?? false}
|
|
||||||
onCheckedChange={field.onChange}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
case DynamicFormItemType.STRING_ARRAY:
|
case DynamicFormItemType.STRING_ARRAY:
|
||||||
return (
|
return (
|
||||||
@@ -227,7 +244,7 @@ export default function DynamicFormItemComponent({
|
|||||||
|
|
||||||
case DynamicFormItemType.SELECT:
|
case DynamicFormItemType.SELECT:
|
||||||
return (
|
return (
|
||||||
<Select value={field.value ?? ''} onValueChange={field.onChange}>
|
<Select value={field.value} onValueChange={field.onChange}>
|
||||||
<SelectTrigger className="bg-[#ffffff] dark:bg-[#2a2a2e]">
|
<SelectTrigger className="bg-[#ffffff] dark:bg-[#2a2a2e]">
|
||||||
<SelectValue placeholder={t('common.select')} />
|
<SelectValue placeholder={t('common.select')} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
@@ -318,6 +335,193 @@ export default function DynamicFormItemComponent({
|
|||||||
</Select>
|
</Select>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
case DynamicFormItemType.MODEL_FALLBACK_SELECTOR: {
|
||||||
|
// Group models by provider
|
||||||
|
const groupedModelsForFallback = llmModels.reduce(
|
||||||
|
(acc, model) => {
|
||||||
|
const providerName =
|
||||||
|
model.provider?.name || model.provider?.requester || 'Unknown';
|
||||||
|
if (!acc[providerName]) acc[providerName] = [];
|
||||||
|
acc[providerName].push(model);
|
||||||
|
return acc;
|
||||||
|
},
|
||||||
|
{} as Record<string, LLMModel[]>,
|
||||||
|
);
|
||||||
|
|
||||||
|
const rawModelValue = field.value;
|
||||||
|
const modelValue: { primary: string; fallbacks: string[] } =
|
||||||
|
rawModelValue != null &&
|
||||||
|
typeof rawModelValue === 'object' &&
|
||||||
|
!Array.isArray(rawModelValue)
|
||||||
|
? {
|
||||||
|
primary:
|
||||||
|
typeof (rawModelValue as Record<string, unknown>).primary ===
|
||||||
|
'string'
|
||||||
|
? ((rawModelValue as Record<string, unknown>)
|
||||||
|
.primary as string)
|
||||||
|
: '',
|
||||||
|
fallbacks: Array.isArray(
|
||||||
|
(rawModelValue as Record<string, unknown>).fallbacks,
|
||||||
|
)
|
||||||
|
? (
|
||||||
|
(rawModelValue as Record<string, unknown>)
|
||||||
|
.fallbacks as unknown[]
|
||||||
|
).filter((v): v is string => typeof v === 'string')
|
||||||
|
: [],
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
primary: typeof rawModelValue === 'string' ? rawModelValue : '',
|
||||||
|
fallbacks: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderModelSelect = (
|
||||||
|
value: string,
|
||||||
|
onChange: (val: string) => void,
|
||||||
|
placeholder: string,
|
||||||
|
) => (
|
||||||
|
<Select value={value} onValueChange={onChange}>
|
||||||
|
<SelectTrigger className="bg-[#ffffff] dark:bg-[#2a2a2e]">
|
||||||
|
<SelectValue placeholder={placeholder} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{Object.entries(groupedModelsForFallback).map(
|
||||||
|
([providerName, models]) => (
|
||||||
|
<SelectGroup key={providerName}>
|
||||||
|
<SelectLabel>{providerName}</SelectLabel>
|
||||||
|
{models.map((model) => (
|
||||||
|
<SelectItem key={model.uuid} value={model.uuid}>
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
|
{model.name}
|
||||||
|
{model.abilities?.includes('vision') && (
|
||||||
|
<Eye className="h-3 w-3 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
{model.abilities?.includes('func_call') && (
|
||||||
|
<Wrench className="h-3 w-3 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectGroup>
|
||||||
|
),
|
||||||
|
)}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
);
|
||||||
|
|
||||||
|
const updateValue = (patch: Partial<typeof modelValue>) => {
|
||||||
|
field.onChange({ ...modelValue, ...patch });
|
||||||
|
};
|
||||||
|
|
||||||
|
const addFallbackModel = () => {
|
||||||
|
updateValue({ fallbacks: [...modelValue.fallbacks, ''] });
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateFallbackModel = (index: number, value: string) => {
|
||||||
|
const updated = [...modelValue.fallbacks];
|
||||||
|
updated[index] = value;
|
||||||
|
updateValue({ fallbacks: updated });
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeFallbackModel = (index: number) => {
|
||||||
|
const updated = [...modelValue.fallbacks];
|
||||||
|
updated.splice(index, 1);
|
||||||
|
updateValue({ fallbacks: updated });
|
||||||
|
};
|
||||||
|
|
||||||
|
const moveFallbackModel = (index: number, direction: 'up' | 'down') => {
|
||||||
|
const updated = [...modelValue.fallbacks];
|
||||||
|
const newIndex = direction === 'up' ? index - 1 : index + 1;
|
||||||
|
if (newIndex < 0 || newIndex >= updated.length) return;
|
||||||
|
[updated[index], updated[newIndex]] = [
|
||||||
|
updated[newIndex],
|
||||||
|
updated[index],
|
||||||
|
];
|
||||||
|
updateValue({ fallbacks: updated });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{/* Primary model selector */}
|
||||||
|
<div>
|
||||||
|
<p className="text-xs text-muted-foreground mb-1">
|
||||||
|
{t('models.fallback.primary')}
|
||||||
|
</p>
|
||||||
|
{renderModelSelect(
|
||||||
|
modelValue.primary,
|
||||||
|
(val) => updateValue({ primary: val }),
|
||||||
|
t('models.selectModel'),
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Fallback models */}
|
||||||
|
{modelValue.fallbacks.length > 0 && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t('models.fallback.fallbackList')}
|
||||||
|
</p>
|
||||||
|
{modelValue.fallbacks.map((fbUuid: string, index: number) => (
|
||||||
|
<div key={index} className="flex items-center gap-2">
|
||||||
|
<span className="text-xs text-muted-foreground w-4 shrink-0">
|
||||||
|
{index + 1}.
|
||||||
|
</span>
|
||||||
|
<div className="flex-1">
|
||||||
|
{renderModelSelect(
|
||||||
|
fbUuid,
|
||||||
|
(val) => updateFallbackModel(index, val),
|
||||||
|
t('models.selectModel'),
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1 shrink-0">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 w-8 p-0"
|
||||||
|
onClick={() => moveFallbackModel(index, 'up')}
|
||||||
|
disabled={index === 0}
|
||||||
|
>
|
||||||
|
↑
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 w-8 p-0"
|
||||||
|
onClick={() => moveFallbackModel(index, 'down')}
|
||||||
|
disabled={index === modelValue.fallbacks.length - 1}
|
||||||
|
>
|
||||||
|
↓
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-8 w-8 p-0 text-destructive"
|
||||||
|
onClick={() => removeFallbackModel(index)}
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Add fallback button */}
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="w-full"
|
||||||
|
onClick={addFallbackModel}
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4 mr-1" />
|
||||||
|
{t('models.fallback.addFallback')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
case DynamicFormItemType.KNOWLEDGE_BASE_SELECTOR:
|
case DynamicFormItemType.KNOWLEDGE_BASE_SELECTOR:
|
||||||
// Group KBs by Knowledge Engine name
|
// Group KBs by Knowledge Engine name
|
||||||
const kbsByEngine = knowledgeBases.reduce(
|
const kbsByEngine = knowledgeBases.reduce(
|
||||||
|
|||||||
@@ -463,14 +463,16 @@ export default function ModelsDialog({
|
|||||||
)
|
)
|
||||||
: t('models.providerCount', { count: otherProviders.length })}
|
: t('models.providerCount', { count: otherProviders.length })}
|
||||||
</span>
|
</span>
|
||||||
<Button
|
<div className="flex gap-2">
|
||||||
size="sm"
|
<Button
|
||||||
variant="outline"
|
size="sm"
|
||||||
onClick={handleCreateProvider}
|
variant="outline"
|
||||||
>
|
onClick={handleCreateProvider}
|
||||||
<Plus className="h-4 w-4 mr-1" />
|
>
|
||||||
{t('models.addProvider')}
|
<Plus className="h-4 w-4 mr-1" />
|
||||||
</Button>
|
{t('models.addProvider')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Provider List */}
|
{/* Provider List */}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useForm } from 'react-hook-form';
|
import { useForm } from 'react-hook-form';
|
||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
@@ -242,11 +242,17 @@ export default function KBForm({
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Convert creation schema to dynamic form items (same as ExternalKBForm)
|
// Convert creation schema to dynamic form items (same as ExternalKBForm)
|
||||||
const configFormItems = parseCreationSchema(selectedEngine?.creation_schema);
|
// Memoize to avoid regenerating UUIDs on every render, which would cause
|
||||||
|
// DynamicFormComponent's useEffect to re-fire and trigger an infinite loop.
|
||||||
|
const configFormItems = useMemo(
|
||||||
|
() => parseCreationSchema(selectedEngine?.creation_schema),
|
||||||
|
[selectedEngine?.creation_schema],
|
||||||
|
);
|
||||||
|
|
||||||
// Convert retrieval schema to dynamic form items
|
// Convert retrieval schema to dynamic form items
|
||||||
const retrievalFormItems = parseCreationSchema(
|
const retrievalFormItems = useMemo(
|
||||||
selectedEngine?.retrieval_schema,
|
() => parseCreationSchema(selectedEngine?.retrieval_schema),
|
||||||
|
[selectedEngine?.retrieval_schema],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Show loading state
|
// Show loading state
|
||||||
|
|||||||
@@ -1,13 +1,6 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import {
|
import { useState, useEffect, useCallback, useRef, Suspense } from 'react';
|
||||||
useState,
|
|
||||||
useEffect,
|
|
||||||
useCallback,
|
|
||||||
useRef,
|
|
||||||
Suspense,
|
|
||||||
useMemo,
|
|
||||||
} from 'react';
|
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
@@ -70,7 +63,7 @@ function MarketPageContent({
|
|||||||
RecommendationList[]
|
RecommendationList[]
|
||||||
>([]);
|
>([]);
|
||||||
|
|
||||||
const pageSize = 16; // 每页16个,4行x4列
|
const pageSize = 12; // 每页12个
|
||||||
const searchTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
const searchTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
const scrollContainerRef = useRef<HTMLDivElement | null>(null);
|
const scrollContainerRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
@@ -330,38 +323,7 @@ function MarketPageContent({
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 计算所有推荐插件的 ID 集合
|
const visiblePlugins = plugins;
|
||||||
const recommendedPluginIds = useMemo(() => {
|
|
||||||
const ids = new Set<string>();
|
|
||||||
recommendationLists.forEach((list) => {
|
|
||||||
list.plugins.forEach((plugin) => {
|
|
||||||
ids.add(`${plugin.author} / ${plugin.name}`);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
return ids;
|
|
||||||
}, [recommendationLists]);
|
|
||||||
|
|
||||||
// 过滤掉已在推荐列表中展示的插件
|
|
||||||
// 仅在显示推荐列表的条件下(无搜索、无筛选、第一页或后续页的累积数据中)进行过滤
|
|
||||||
// 注意:如果用户翻页,我们希望一直保持去重,否则推荐过的插件会在第二页出现
|
|
||||||
// 但是推荐列表只在第一页且无筛选时显示。
|
|
||||||
// 如果用户进行了筛选/搜索,推荐列表不显示,此时不需要去重。
|
|
||||||
const visiblePlugins = useMemo(() => {
|
|
||||||
const showRecommendations =
|
|
||||||
!searchQuery && componentFilter === 'all' && selectedTags.length === 0;
|
|
||||||
|
|
||||||
if (!showRecommendations) {
|
|
||||||
return plugins;
|
|
||||||
}
|
|
||||||
|
|
||||||
return plugins.filter((p) => !recommendedPluginIds.has(p.pluginId));
|
|
||||||
}, [
|
|
||||||
plugins,
|
|
||||||
recommendedPluginIds,
|
|
||||||
searchQuery,
|
|
||||||
componentFilter,
|
|
||||||
selectedTags,
|
|
||||||
]);
|
|
||||||
|
|
||||||
// 加载更多
|
// 加载更多
|
||||||
const loadMore = useCallback(() => {
|
const loadMore = useCallback(() => {
|
||||||
|
|||||||
@@ -47,10 +47,12 @@ function RecommendationListRow({
|
|||||||
list,
|
list,
|
||||||
tagNames,
|
tagNames,
|
||||||
onInstall,
|
onInstall,
|
||||||
|
isLast,
|
||||||
}: {
|
}: {
|
||||||
list: RecommendationList;
|
list: RecommendationList;
|
||||||
tagNames: Record<string, string>;
|
tagNames: Record<string, string>;
|
||||||
onInstall: (author: string, pluginName: string) => void;
|
onInstall: (author: string, pluginName: string) => void;
|
||||||
|
isLast: boolean;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [page, setPage] = useState(0);
|
const [page, setPage] = useState(0);
|
||||||
@@ -143,7 +145,9 @@ function RecommendationListRow({
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{totalPages > 1 && <div className="border-b border-border mt-6" />}
|
{totalPages > 1 && !isLast && (
|
||||||
|
<div className="border-b border-border mt-6" />
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -161,12 +165,13 @@ export function RecommendationLists({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mt-6">
|
<div className="mt-6">
|
||||||
{lists.map((list) => (
|
{lists.map((list, index) => (
|
||||||
<RecommendationListRow
|
<RecommendationListRow
|
||||||
key={list.uuid}
|
key={list.uuid}
|
||||||
list={list}
|
list={list}
|
||||||
tagNames={tagNames}
|
tagNames={tagNames}
|
||||||
onInstall={onInstall}
|
onInstall={onInstall}
|
||||||
|
isLast={index === lists.length - 1}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
<div className="border-b border-border mb-6" />
|
<div className="border-b border-border mb-6" />
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import {
|
|||||||
FileText,
|
FileText,
|
||||||
Info,
|
Info,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useState } from 'react';
|
import { useState, useRef, useEffect } from 'react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
|
||||||
export default function PluginMarketCardComponent({
|
export default function PluginMarketCardComponent({
|
||||||
@@ -31,6 +31,43 @@ export default function PluginMarketCardComponent({
|
|||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [isHovered, setIsHovered] = useState(false);
|
const [isHovered, setIsHovered] = useState(false);
|
||||||
|
const bottomRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [visibleTags, setVisibleTags] = useState(2);
|
||||||
|
|
||||||
|
// Measure how many tags fit in the bottom row
|
||||||
|
useEffect(() => {
|
||||||
|
const tags = cardVO.tags;
|
||||||
|
if (!bottomRef.current || !tags || tags.length === 0) return;
|
||||||
|
|
||||||
|
const measure = () => {
|
||||||
|
const container = bottomRef.current;
|
||||||
|
if (!container) return;
|
||||||
|
const width = container.offsetWidth;
|
||||||
|
const availableForTags = width - 140 - 80;
|
||||||
|
if (availableForTags <= 0) {
|
||||||
|
setVisibleTags(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const tagWidth = 80;
|
||||||
|
const plusBadgeWidth = 40;
|
||||||
|
const maxTags = Math.max(
|
||||||
|
0,
|
||||||
|
Math.floor((availableForTags - plusBadgeWidth) / tagWidth),
|
||||||
|
);
|
||||||
|
if (maxTags >= tags.length) {
|
||||||
|
setVisibleTags(tags.length);
|
||||||
|
} else {
|
||||||
|
setVisibleTags(Math.max(1, maxTags));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
measure();
|
||||||
|
const observer = new ResizeObserver(measure);
|
||||||
|
observer.observe(bottomRef.current);
|
||||||
|
return () => observer.disconnect();
|
||||||
|
}, [cardVO.tags]);
|
||||||
|
|
||||||
|
const remainingTags = cardVO.tags ? cardVO.tags.length - visibleTags : 0;
|
||||||
|
|
||||||
function handleInstallClick(e: React.MouseEvent) {
|
function handleInstallClick(e: React.MouseEvent) {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
@@ -135,10 +172,13 @@ export default function PluginMarketCardComponent({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 下部分:下载量、标签和组件列表 */}
|
{/* 下部分:下载量、标签和组件列表 */}
|
||||||
<div className="w-full flex flex-row items-center justify-between gap-2 px-0 sm:px-[0.4rem] flex-shrink-0">
|
<div
|
||||||
<div className="flex flex-row items-center justify-start gap-2 flex-wrap">
|
ref={bottomRef}
|
||||||
|
className="w-full flex flex-row items-center justify-between gap-2 px-0 sm:px-[0.4rem] flex-shrink-0 overflow-hidden"
|
||||||
|
>
|
||||||
|
<div className="flex flex-row items-center justify-start gap-2 min-w-0 overflow-hidden">
|
||||||
{/* 下载数量 */}
|
{/* 下载数量 */}
|
||||||
<div className="flex flex-row items-center gap-[0.3rem] sm:gap-[0.4rem]">
|
<div className="flex flex-row items-center gap-[0.3rem] sm:gap-[0.4rem] flex-shrink-0">
|
||||||
<svg
|
<svg
|
||||||
className="w-4 h-4 sm:w-[1.2rem] sm:h-[1.2rem] text-[#2563eb] dark:text-[#5b8def] flex-shrink-0"
|
className="w-4 h-4 sm:w-[1.2rem] sm:h-[1.2rem] text-[#2563eb] dark:text-[#5b8def] flex-shrink-0"
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
@@ -156,14 +196,14 @@ export default function PluginMarketCardComponent({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Tags */}
|
{/* Tags - adaptive */}
|
||||||
{cardVO.tags && cardVO.tags.length > 0 && (
|
{cardVO.tags && cardVO.tags.length > 0 && visibleTags > 0 && (
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<div className="flex flex-row items-center gap-1.5 overflow-hidden flex-shrink min-w-0">
|
||||||
{cardVO.tags.slice(0, 2).map((tag) => (
|
{cardVO.tags.slice(0, visibleTags).map((tag) => (
|
||||||
<Badge
|
<Badge
|
||||||
key={tag}
|
key={tag}
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
className="text-[0.65rem] sm:text-[0.7rem] px-2 py-0.5 h-5 flex items-center gap-1 flex-shrink-0"
|
className="text-[0.65rem] sm:text-[0.7rem] px-2 py-0.5 h-5 flex items-center gap-1 flex-shrink-0 whitespace-nowrap"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
className="w-2.5 h-2.5 flex-shrink-0"
|
className="w-2.5 h-2.5 flex-shrink-0"
|
||||||
@@ -178,15 +218,17 @@ export default function PluginMarketCardComponent({
|
|||||||
<path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z" />
|
<path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z" />
|
||||||
<line x1="7" y1="7" x2="7.01" y2="7" />
|
<line x1="7" y1="7" x2="7.01" y2="7" />
|
||||||
</svg>
|
</svg>
|
||||||
<span className="truncate">{tagNames[tag] || tag}</span>
|
<span className="truncate max-w-[5rem]">
|
||||||
|
{tagNames[tag] || tag}
|
||||||
|
</span>
|
||||||
</Badge>
|
</Badge>
|
||||||
))}
|
))}
|
||||||
{cardVO.tags.length > 2 && (
|
{remainingTags > 0 && (
|
||||||
<Badge
|
<Badge
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="text-[0.65rem] sm:text-[0.7rem] px-2 py-0.5 h-5 flex items-center flex-shrink-0"
|
className="text-[0.65rem] sm:text-[0.7rem] px-1.5 py-0.5 h-5 flex items-center flex-shrink-0 whitespace-nowrap"
|
||||||
>
|
>
|
||||||
+{cardVO.tags.length - 2}
|
+{remainingTags}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ export enum DynamicFormItemType {
|
|||||||
SELECT = 'select',
|
SELECT = 'select',
|
||||||
LLM_MODEL_SELECTOR = 'llm-model-selector',
|
LLM_MODEL_SELECTOR = 'llm-model-selector',
|
||||||
EMBEDDING_MODEL_SELECTOR = 'embedding-model-selector',
|
EMBEDDING_MODEL_SELECTOR = 'embedding-model-selector',
|
||||||
|
MODEL_FALLBACK_SELECTOR = 'model-fallback-selector',
|
||||||
PROMPT_EDITOR = 'prompt-editor',
|
PROMPT_EDITOR = 'prompt-editor',
|
||||||
UNKNOWN = 'unknown',
|
UNKNOWN = 'unknown',
|
||||||
KNOWLEDGE_BASE_SELECTOR = 'knowledge-base-selector',
|
KNOWLEDGE_BASE_SELECTOR = 'knowledge-base-selector',
|
||||||
|
|||||||
@@ -356,6 +356,7 @@ export class BackendClient extends BaseHttpClient {
|
|||||||
is_active: boolean;
|
is_active: boolean;
|
||||||
platform: string | null;
|
platform: string | null;
|
||||||
user_id: string | null;
|
user_id: string | null;
|
||||||
|
user_name: string | null;
|
||||||
}>;
|
}>;
|
||||||
total: number;
|
total: number;
|
||||||
}> {
|
}> {
|
||||||
@@ -384,6 +385,7 @@ export class BackendClient extends BaseHttpClient {
|
|||||||
level: string;
|
level: string;
|
||||||
platform: string | null;
|
platform: string | null;
|
||||||
user_id: string | null;
|
user_id: string | null;
|
||||||
|
user_name: string | null;
|
||||||
runner_name: string | null;
|
runner_name: string | null;
|
||||||
variables: string | null;
|
variables: string | null;
|
||||||
role: string | null;
|
role: string | null;
|
||||||
|
|||||||
@@ -284,6 +284,27 @@ export default function Login() {
|
|||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<p className="text-xs text-center text-muted-foreground">
|
||||||
|
{t('common.agreementNotice')}{' '}
|
||||||
|
<a
|
||||||
|
href="https://langbot.app/privacy"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="underline hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
{t('common.privacyPolicy')}
|
||||||
|
</a>{' '}
|
||||||
|
{t('common.and')}{' '}
|
||||||
|
<a
|
||||||
|
href={t('common.dataCollectionPolicyUrl')}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="underline hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
{t('common.dataCollectionPolicy')}
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -253,6 +253,27 @@ export default function Register() {
|
|||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
|
|
||||||
|
<p className="text-xs text-center text-muted-foreground">
|
||||||
|
{t('common.agreementNotice')}{' '}
|
||||||
|
<a
|
||||||
|
href="https://langbot.app/privacy"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="underline hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
{t('common.privacyPolicy')}
|
||||||
|
</a>{' '}
|
||||||
|
{t('common.and')}{' '}
|
||||||
|
<a
|
||||||
|
href={t('common.dataCollectionPolicyUrl')}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="underline hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
{t('common.dataCollectionPolicy')}
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -47,6 +47,12 @@ const enUS = {
|
|||||||
copyFailed: 'Copy Failed',
|
copyFailed: 'Copy Failed',
|
||||||
test: 'Test',
|
test: 'Test',
|
||||||
forgotPassword: 'Forgot Password?',
|
forgotPassword: 'Forgot Password?',
|
||||||
|
agreementNotice: 'By continuing, you agree to our',
|
||||||
|
privacyPolicy: 'Privacy Policy',
|
||||||
|
and: 'and',
|
||||||
|
dataCollectionPolicy: 'Data Collection Policy',
|
||||||
|
dataCollectionPolicyUrl:
|
||||||
|
'https://docs.langbot.app/en/insight/data-collection-policy',
|
||||||
loading: 'Loading...',
|
loading: 'Loading...',
|
||||||
fieldRequired: 'This field is required',
|
fieldRequired: 'This field is required',
|
||||||
or: 'or',
|
or: 'or',
|
||||||
@@ -230,6 +236,11 @@ const enUS = {
|
|||||||
modelsCount: '{{count}} model(s)',
|
modelsCount: '{{count}} model(s)',
|
||||||
expandModels: 'Expand',
|
expandModels: 'Expand',
|
||||||
collapseModels: 'Collapse',
|
collapseModels: 'Collapse',
|
||||||
|
fallback: {
|
||||||
|
primary: 'Primary Model',
|
||||||
|
fallbackList: 'Fallback Models',
|
||||||
|
addFallback: 'Add Fallback Model',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
bots: {
|
bots: {
|
||||||
title: 'Bots',
|
title: 'Bots',
|
||||||
@@ -273,6 +284,8 @@ const enUS = {
|
|||||||
webhookUrlCopied: 'Webhook URL copied',
|
webhookUrlCopied: 'Webhook URL copied',
|
||||||
webhookUrlHint:
|
webhookUrlHint:
|
||||||
'Click the input to select all, then press Ctrl+C (Mac: Cmd+C) to copy, or click the button',
|
'Click the input to select all, then press Ctrl+C (Mac: Cmd+C) to copy, or click the button',
|
||||||
|
webhookUrlHintEither:
|
||||||
|
'Use either of the two URLs above in your platform configuration',
|
||||||
logLevel: 'Log Level',
|
logLevel: 'Log Level',
|
||||||
allLevels: 'All Levels',
|
allLevels: 'All Levels',
|
||||||
selectLevel: 'Select Level',
|
selectLevel: 'Select Level',
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
const jaJP = {
|
const jaJP = {
|
||||||
common: {
|
common: {
|
||||||
login: 'ログイン',
|
login: 'ログイン',
|
||||||
logout: 'ログアウト',
|
logout: 'ログアウト',
|
||||||
@@ -48,6 +48,12 @@ const jaJP = {
|
|||||||
copyFailed: 'コピーに失敗しました',
|
copyFailed: 'コピーに失敗しました',
|
||||||
test: 'テスト',
|
test: 'テスト',
|
||||||
forgotPassword: 'パスワードを忘れた?',
|
forgotPassword: 'パスワードを忘れた?',
|
||||||
|
agreementNotice: '続行することで、以下に同意したものとみなされます:',
|
||||||
|
privacyPolicy: 'プライバシーポリシー',
|
||||||
|
and: 'および',
|
||||||
|
dataCollectionPolicy: 'データ収集ポリシー',
|
||||||
|
dataCollectionPolicyUrl:
|
||||||
|
'https://docs.langbot.app/ja/insight/data-collection-policy',
|
||||||
loading: '読み込み中...',
|
loading: '読み込み中...',
|
||||||
fieldRequired: 'この項目は必須です',
|
fieldRequired: 'この項目は必須です',
|
||||||
or: 'または',
|
or: 'または',
|
||||||
@@ -235,6 +241,11 @@ const jaJP = {
|
|||||||
modelsCount: '{{count}} 個のモデル',
|
modelsCount: '{{count}} 個のモデル',
|
||||||
expandModels: '展開',
|
expandModels: '展開',
|
||||||
collapseModels: '折りたたむ',
|
collapseModels: '折りたたむ',
|
||||||
|
fallback: {
|
||||||
|
primary: 'プライマリモデル',
|
||||||
|
fallbackList: 'フォールバックモデル',
|
||||||
|
addFallback: 'フォールバックモデルを追加',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
bots: {
|
bots: {
|
||||||
title: 'ボット',
|
title: 'ボット',
|
||||||
@@ -278,6 +289,8 @@ const jaJP = {
|
|||||||
webhookUrlCopied: 'Webhook URL をコピーしました',
|
webhookUrlCopied: 'Webhook URL をコピーしました',
|
||||||
webhookUrlHint:
|
webhookUrlHint:
|
||||||
'入力ボックスをクリックして全選択し、Ctrl+C (Mac: Cmd+C) でコピーするか、右側のボタンをクリックしてください',
|
'入力ボックスをクリックして全選択し、Ctrl+C (Mac: Cmd+C) でコピーするか、右側のボタンをクリックしてください',
|
||||||
|
webhookUrlHintEither:
|
||||||
|
'上記の2つのURLのいずれかをプラットフォーム設定に使用してください',
|
||||||
logLevel: 'ログレベル',
|
logLevel: 'ログレベル',
|
||||||
allLevels: 'すべてのレベル',
|
allLevels: 'すべてのレベル',
|
||||||
selectLevel: 'レベルを選択',
|
selectLevel: 'レベルを選択',
|
||||||
|
|||||||
@@ -47,6 +47,12 @@ const zhHans = {
|
|||||||
copyFailed: '复制失败',
|
copyFailed: '复制失败',
|
||||||
test: '测试',
|
test: '测试',
|
||||||
forgotPassword: '忘记密码?',
|
forgotPassword: '忘记密码?',
|
||||||
|
agreementNotice: '继续即表示您同意我们的',
|
||||||
|
privacyPolicy: '隐私政策',
|
||||||
|
and: '和',
|
||||||
|
dataCollectionPolicy: '数据收集政策',
|
||||||
|
dataCollectionPolicyUrl:
|
||||||
|
'https://docs.langbot.app/zh/insight/data-collection-policy',
|
||||||
loading: '加载中...',
|
loading: '加载中...',
|
||||||
fieldRequired: '此字段为必填项',
|
fieldRequired: '此字段为必填项',
|
||||||
or: '或',
|
or: '或',
|
||||||
@@ -221,6 +227,11 @@ const zhHans = {
|
|||||||
modelsCount: '{{count}} 个模型',
|
modelsCount: '{{count}} 个模型',
|
||||||
expandModels: '展开',
|
expandModels: '展开',
|
||||||
collapseModels: '收起',
|
collapseModels: '收起',
|
||||||
|
fallback: {
|
||||||
|
primary: '主模型',
|
||||||
|
fallbackList: '备用模型',
|
||||||
|
addFallback: '添加备用模型',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
bots: {
|
bots: {
|
||||||
title: '机器人',
|
title: '机器人',
|
||||||
@@ -262,6 +273,7 @@ const zhHans = {
|
|||||||
webhookUrlCopied: 'Webhook 地址已复制',
|
webhookUrlCopied: 'Webhook 地址已复制',
|
||||||
webhookUrlHint:
|
webhookUrlHint:
|
||||||
'点击输入框自动全选,然后按 Ctrl+C (Mac: Cmd+C) 复制,或点击右侧按钮',
|
'点击输入框自动全选,然后按 Ctrl+C (Mac: Cmd+C) 复制,或点击右侧按钮',
|
||||||
|
webhookUrlHintEither: '以上两个地址任选其一填入平台配置即可',
|
||||||
logLevel: '日志级别',
|
logLevel: '日志级别',
|
||||||
allLevels: '全部级别',
|
allLevels: '全部级别',
|
||||||
selectLevel: '选择级别',
|
selectLevel: '选择级别',
|
||||||
|
|||||||
@@ -47,6 +47,12 @@ const zhHant = {
|
|||||||
copyFailed: '複製失敗',
|
copyFailed: '複製失敗',
|
||||||
test: '測試',
|
test: '測試',
|
||||||
forgotPassword: '忘記密碼?',
|
forgotPassword: '忘記密碼?',
|
||||||
|
agreementNotice: '繼續即表示您同意我們的',
|
||||||
|
privacyPolicy: '隱私政策',
|
||||||
|
and: '和',
|
||||||
|
dataCollectionPolicy: '數據收集政策',
|
||||||
|
dataCollectionPolicyUrl:
|
||||||
|
'https://docs.langbot.app/zh/insight/data-collection-policy',
|
||||||
loading: '載入中...',
|
loading: '載入中...',
|
||||||
fieldRequired: '此欄位為必填',
|
fieldRequired: '此欄位為必填',
|
||||||
or: '或',
|
or: '或',
|
||||||
@@ -220,6 +226,11 @@ const zhHant = {
|
|||||||
modelsCount: '{{count}} 個模型',
|
modelsCount: '{{count}} 個模型',
|
||||||
expandModels: '展開',
|
expandModels: '展開',
|
||||||
collapseModels: '收起',
|
collapseModels: '收起',
|
||||||
|
fallback: {
|
||||||
|
primary: '主模型',
|
||||||
|
fallbackList: '備用模型',
|
||||||
|
addFallback: '新增備用模型',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
bots: {
|
bots: {
|
||||||
title: '機器人',
|
title: '機器人',
|
||||||
@@ -261,6 +272,7 @@ const zhHant = {
|
|||||||
webhookUrlCopied: 'Webhook 位址已複製',
|
webhookUrlCopied: 'Webhook 位址已複製',
|
||||||
webhookUrlHint:
|
webhookUrlHint:
|
||||||
'點擊輸入框自動全選,然後按 Ctrl+C (Mac: Cmd+C) 複製,或點擊右側按鈕',
|
'點擊輸入框自動全選,然後按 Ctrl+C (Mac: Cmd+C) 複製,或點擊右側按鈕',
|
||||||
|
webhookUrlHintEither: '以上兩個地址任選其一填入平台配置即可',
|
||||||
logLevel: '日誌級別',
|
logLevel: '日誌級別',
|
||||||
allLevels: '全部級別',
|
allLevels: '全部級別',
|
||||||
selectLevel: '選擇級別',
|
selectLevel: '選擇級別',
|
||||||
|
|||||||
Reference in New Issue
Block a user