mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-02 12:05:54 +00:00
Compare commits
34 Commits
feat/long-
...
v4.9.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
20f5ebd9b8 | ||
|
|
d2c75329cf | ||
|
|
7e2fe082f0 | ||
|
|
d451b059fd | ||
|
|
93c52fcd4c | ||
|
|
f1608682e6 | ||
|
|
077e631c13 | ||
|
|
d7df1f05d1 | ||
|
|
8b8cfb76de | ||
|
|
79311ccde3 | ||
|
|
89064a9d5b | ||
|
|
8c2aef3734 | ||
|
|
3fb9e542b6 | ||
|
|
01844d8687 | ||
|
|
2655425fbe | ||
|
|
bd15b630b0 | ||
|
|
fe5ce68436 | ||
|
|
0541b05966 | ||
|
|
13cb0aa9be | ||
|
|
a048369b38 | ||
|
|
9ae0c263dc | ||
|
|
a4e66f6459 | ||
|
|
2a74a8d6ae | ||
|
|
d31f25c8df | ||
|
|
11c05ea8db | ||
|
|
2b8bd1cc71 | ||
|
|
9148e02679 | ||
|
|
fd15284d91 | ||
|
|
8c7a0ec027 | ||
|
|
a1cef5c9bf | ||
|
|
90438cec36 | ||
|
|
95dd19f4d7 | ||
|
|
c64eb58cf8 | ||
|
|
fbd3d7ae3a |
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "langbot"
|
||||
version = "4.8.7"
|
||||
version = "4.9.1"
|
||||
description = "Production-grade platform for building agentic IM bots"
|
||||
readme = "README.md"
|
||||
license-files = ["LICENSE"]
|
||||
@@ -61,10 +61,10 @@ dependencies = [
|
||||
"html2text>=2024.2.26",
|
||||
"langchain>=0.2.0",
|
||||
"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)",
|
||||
"pyseekdb==1.0.0b7",
|
||||
"langbot-plugin==0.3.0rc1",
|
||||
"pyseekdb==1.1.0.post3",
|
||||
"langbot-plugin==0.3.1",
|
||||
"asyncpg>=0.30.0",
|
||||
"line-bot-sdk>=3.19.0",
|
||||
"tboxsdk>=0.0.10",
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""LangBot - Production-grade platform for building agentic IM bots"""
|
||||
|
||||
__version__ = '4.8.7'
|
||||
__version__ = '4.9.1'
|
||||
|
||||
@@ -199,6 +199,253 @@ class StreamSessionManager:
|
||||
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:
|
||||
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)
|
||||
|
||||
async def get_message(self, msg_json):
|
||||
message_data = {}
|
||||
|
||||
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
|
||||
return await parse_wecom_bot_message(msg_json, self.EnCodingAESKey, self.logger)
|
||||
|
||||
async def _handle_message(self, event: wecombotevent.WecomBotEvent):
|
||||
"""
|
||||
@@ -712,39 +770,7 @@ class WecomBotClient:
|
||||
return decorator
|
||||
|
||||
async def download_url_to_base64(self, download_url, encoding_aes_key):
|
||||
async with httpx.AsyncClient() as client:
|
||||
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}'
|
||||
return await download_encrypted_file(download_url, encoding_aes_key, self.logger)
|
||||
|
||||
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
|
||||
import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
||||
import aiofiles
|
||||
import time
|
||||
|
||||
|
||||
class WecomCSClient:
|
||||
@@ -34,6 +35,10 @@ class WecomCSClient:
|
||||
self.unified_mode = unified_mode
|
||||
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:
|
||||
self.app.add_url_rule(
|
||||
@@ -378,3 +383,53 @@ class WecomCSClient:
|
||||
async def get_media_id(self, image: platform_message.Image):
|
||||
media_id = await self.upload_to_work(image=image)
|
||||
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
|
||||
|
||||
@@ -0,0 +1,372 @@
|
||||
import asyncio
|
||||
import json
|
||||
|
||||
import httpx
|
||||
import quart
|
||||
import sqlalchemy
|
||||
|
||||
from ... import group
|
||||
from ......core import taskmgr
|
||||
from ......entity.persistence import metadata as persistence_metadata
|
||||
from langbot_plugin.runtime.plugin.mgr import PluginInstallSource
|
||||
|
||||
LANGRAG_PLUGIN_AUTHOR = 'langbot-team'
|
||||
LANGRAG_PLUGIN_NAME = 'LangRAG'
|
||||
LANGRAG_PLUGIN_ID = f'{LANGRAG_PLUGIN_AUTHOR}/{LANGRAG_PLUGIN_NAME}'
|
||||
DEFAULT_SPACE_URL = 'https://space.langbot.app'
|
||||
|
||||
# Old Retriever plugin_name -> New Connector plugin_name
|
||||
EXTERNAL_PLUGIN_NAME_MAPPING = {
|
||||
'DifyDatasetsRetriever': 'DifyDatasetsConnector',
|
||||
'RAGFlowRetriever': 'RAGFlowConnector',
|
||||
'FastGPTRetriever': 'FastGPTConnector',
|
||||
}
|
||||
|
||||
# Per-plugin: which old retriever_config fields belong to creation_settings.
|
||||
# Remaining fields go to retrieval_settings.
|
||||
# None means ALL fields go to creation_settings (no retrieval_schema).
|
||||
EXTERNAL_PLUGIN_CREATION_FIELDS: dict[str, set[str] | None] = {
|
||||
'langbot-team/DifyDatasetsConnector': {'api_base_url', 'dify_apikey', 'dataset_id'},
|
||||
'langbot-team/RAGFlowConnector': {'api_base_url', 'api_key', 'dataset_ids'},
|
||||
'langbot-team/FastGPTConnector': None, # all fields -> creation_settings
|
||||
}
|
||||
|
||||
|
||||
@group.group_class('knowledge/migration', '/api/v1/knowledge/migration')
|
||||
class KnowledgeMigrationRouterGroup(group.RouterGroup):
|
||||
async def _get_migration_flag(self) -> bool:
|
||||
"""Check if rag_plugin_migration_needed flag is set."""
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.select(persistence_metadata.Metadata).where(
|
||||
persistence_metadata.Metadata.key == 'rag_plugin_migration_needed'
|
||||
)
|
||||
)
|
||||
row = result.first()
|
||||
return row is not None and row.value == 'true'
|
||||
|
||||
async def _set_migration_flag(self, value: str):
|
||||
"""Set rag_plugin_migration_needed flag."""
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.update(persistence_metadata.Metadata)
|
||||
.where(persistence_metadata.Metadata.key == 'rag_plugin_migration_needed')
|
||||
.values(value=value)
|
||||
)
|
||||
|
||||
async def _table_exists(self, table_name: str) -> bool:
|
||||
"""Check if a table exists."""
|
||||
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 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 _install_plugin_from_marketplace(
|
||||
self, plugin_id: str, task_context: taskmgr.TaskContext, space_url: str
|
||||
) -> None:
|
||||
"""Install a single plugin from the marketplace."""
|
||||
p_author, p_name = plugin_id.split('/', 1)
|
||||
self.ap.logger.info(f'RAG migration: installing plugin {plugin_id} from marketplace...')
|
||||
task_context.trace(f'Installing plugin {plugin_id} from marketplace...')
|
||||
|
||||
async with httpx.AsyncClient(trust_env=True, timeout=15) as client:
|
||||
resp = await client.get(f'{space_url}/api/v1/marketplace/plugins/{p_author}/{p_name}')
|
||||
resp.raise_for_status()
|
||||
p_data = resp.json().get('data', {}).get('plugin', {})
|
||||
p_version = p_data.get('latest_version')
|
||||
if not p_version:
|
||||
raise Exception(f'Could not determine latest version for {plugin_id}')
|
||||
|
||||
await self.ap.plugin_connector.install_plugin(
|
||||
PluginInstallSource.MARKETPLACE,
|
||||
{
|
||||
'plugin_author': p_author,
|
||||
'plugin_name': p_name,
|
||||
'plugin_version': p_version,
|
||||
},
|
||||
task_context=task_context,
|
||||
)
|
||||
self.ap.logger.info(f'RAG migration: plugin {plugin_id} install request sent.')
|
||||
|
||||
async def _execute_rag_migration(self, task_context: taskmgr.TaskContext, install_plugin: bool = True):
|
||||
"""Execute RAG migration: install required plugins and restore backup data."""
|
||||
warnings = []
|
||||
|
||||
# Collect all plugins we need: LangRAG (always) + connector plugins (from external KBs)
|
||||
needed_plugins: dict[str, str] = {
|
||||
LANGRAG_PLUGIN_ID: LANGRAG_PLUGIN_NAME,
|
||||
}
|
||||
|
||||
has_external = await self._table_exists('external_knowledge_bases')
|
||||
if has_external:
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.text('SELECT DISTINCT plugin_author, plugin_name FROM external_knowledge_bases;')
|
||||
)
|
||||
for row in result.fetchall():
|
||||
plugin_author = row[0] or ''
|
||||
plugin_name = row[1] or ''
|
||||
mapped_name = EXTERNAL_PLUGIN_NAME_MAPPING.get(plugin_name, plugin_name)
|
||||
plugin_id = f'{plugin_author}/{mapped_name}'
|
||||
if plugin_id not in needed_plugins:
|
||||
needed_plugins[plugin_id] = mapped_name
|
||||
|
||||
self.ap.logger.info(f'RAG migration: plugins needed: {list(needed_plugins.keys())}')
|
||||
|
||||
if install_plugin:
|
||||
# Step 1: Install all required plugins from marketplace
|
||||
task_context.trace('Installing required plugins...', action='install-plugin')
|
||||
space_url = self.ap.instance_config.data.get('space', {}).get('url', DEFAULT_SPACE_URL).rstrip('/')
|
||||
|
||||
for plugin_id in needed_plugins:
|
||||
try:
|
||||
await self._install_plugin_from_marketplace(plugin_id, task_context, space_url)
|
||||
except Exception as e:
|
||||
self.ap.logger.warning(f'RAG migration: plugin {plugin_id} install returned: {e}')
|
||||
task_context.trace(f'Plugin install note ({plugin_id}): {e}')
|
||||
|
||||
# Step 2: Wait for all plugins to become available as knowledge engines
|
||||
task_context.trace(
|
||||
f'Waiting for plugins to become available: {list(needed_plugins.keys())}...',
|
||||
action='wait-plugin',
|
||||
)
|
||||
max_retries = 30
|
||||
engine_id_set: set[str] = set()
|
||||
for i in range(max_retries):
|
||||
try:
|
||||
engines = await self.ap.plugin_connector.list_knowledge_engines()
|
||||
engine_id_set = {e.get('plugin_id') for e in engines}
|
||||
except Exception:
|
||||
pass
|
||||
if all(pid in engine_id_set for pid in needed_plugins):
|
||||
self.ap.logger.info(f'RAG migration: all plugins ready: {engine_id_set}')
|
||||
task_context.trace('All required plugins are ready.')
|
||||
break
|
||||
if i == max_retries - 1:
|
||||
still_missing = [pid for pid in needed_plugins if pid not in engine_id_set]
|
||||
warning = f'Plugin(s) {still_missing} did not become available after {max_retries} retries'
|
||||
self.ap.logger.warning(f'RAG migration: {warning}')
|
||||
warnings.append(warning)
|
||||
task_context.trace(warning)
|
||||
await asyncio.sleep(2)
|
||||
else:
|
||||
try:
|
||||
engines = await self.ap.plugin_connector.list_knowledge_engines()
|
||||
engine_id_set = {e.get('plugin_id') for e in engines}
|
||||
except Exception:
|
||||
engine_id_set = set()
|
||||
|
||||
# Step 3: Restore internal knowledge bases from backup
|
||||
task_context.trace('Restoring internal knowledge bases...', action='restore-internal')
|
||||
if await self._table_exists('knowledge_bases_backup'):
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.text('SELECT * FROM knowledge_bases_backup;')
|
||||
)
|
||||
rows = result.fetchall()
|
||||
columns = result.keys()
|
||||
|
||||
for row in rows:
|
||||
row_dict = dict(zip(columns, row))
|
||||
kb_uuid = row_dict.get('uuid')
|
||||
name = row_dict.get('name', 'Untitled')
|
||||
description = row_dict.get('description', '')
|
||||
emoji = row_dict.get('emoji', '\U0001f4da')
|
||||
embedding_model_uuid = row_dict.get('embedding_model_uuid', '')
|
||||
top_k = row_dict.get('top_k', 5)
|
||||
created_at = row_dict.get('created_at')
|
||||
updated_at = row_dict.get('updated_at')
|
||||
|
||||
creation_settings = json.dumps({'embedding_model_uuid': embedding_model_uuid})
|
||||
retrieval_settings = json.dumps({'top_k': top_k})
|
||||
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.text(
|
||||
'INSERT INTO knowledge_bases '
|
||||
'(uuid, name, description, emoji, created_at, updated_at, '
|
||||
'knowledge_engine_plugin_id, collection_id, creation_settings, retrieval_settings) '
|
||||
'VALUES (:uuid, :name, :description, :emoji, :created_at, :updated_at, '
|
||||
':plugin_id, :collection_id, :creation_settings, :retrieval_settings);'
|
||||
).bindparams(
|
||||
uuid=kb_uuid,
|
||||
name=name,
|
||||
description=description,
|
||||
emoji=emoji,
|
||||
created_at=created_at,
|
||||
updated_at=updated_at,
|
||||
plugin_id=LANGRAG_PLUGIN_ID,
|
||||
collection_id=kb_uuid,
|
||||
creation_settings=creation_settings,
|
||||
retrieval_settings=retrieval_settings,
|
||||
)
|
||||
)
|
||||
|
||||
try:
|
||||
config = {'embedding_model_uuid': embedding_model_uuid}
|
||||
await self.ap.plugin_connector.rag_on_kb_create(LANGRAG_PLUGIN_ID, kb_uuid, config)
|
||||
task_context.trace(f'Restored internal KB: {name} ({kb_uuid})')
|
||||
except Exception as e:
|
||||
warning = f'Failed to notify plugin for KB {name} ({kb_uuid}): {e}'
|
||||
warnings.append(warning)
|
||||
task_context.trace(warning)
|
||||
|
||||
await self.ap.rag_mgr.load_knowledge_bases_from_db()
|
||||
|
||||
# Step 4: Restore external knowledge bases
|
||||
task_context.trace('Restoring external knowledge bases...', action='restore-external')
|
||||
if has_external:
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.text('SELECT * FROM external_knowledge_bases;')
|
||||
)
|
||||
rows = result.fetchall()
|
||||
columns = result.keys()
|
||||
|
||||
self.ap.logger.info(
|
||||
f'RAG migration: {len(rows)} external KB(s) to restore. Available engines: {engine_id_set}'
|
||||
)
|
||||
task_context.trace(f'Found {len(rows)} external KB(s). Available engines: {engine_id_set}')
|
||||
|
||||
for row in rows:
|
||||
row_dict = dict(zip(columns, row))
|
||||
kb_uuid = row_dict.get('uuid')
|
||||
name = row_dict.get('name', 'Untitled')
|
||||
description = row_dict.get('description', '')
|
||||
emoji = row_dict.get('emoji', '\U0001f517')
|
||||
plugin_author = row_dict.get('plugin_author', '')
|
||||
plugin_name = row_dict.get('plugin_name', '')
|
||||
retriever_config = row_dict.get('retriever_config', {})
|
||||
created_at = row_dict.get('created_at')
|
||||
|
||||
mapped_plugin_name = EXTERNAL_PLUGIN_NAME_MAPPING.get(plugin_name, plugin_name)
|
||||
external_plugin_id = f'{plugin_author}/{mapped_plugin_name}'
|
||||
|
||||
self.ap.logger.info(
|
||||
f'RAG migration: processing external KB "{name}" ({kb_uuid}), '
|
||||
f'plugin: {plugin_author}/{plugin_name} -> {external_plugin_id}'
|
||||
)
|
||||
|
||||
if isinstance(retriever_config, str):
|
||||
try:
|
||||
retriever_config = json.loads(retriever_config)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
retriever_config = {}
|
||||
|
||||
creation_fields = EXTERNAL_PLUGIN_CREATION_FIELDS.get(external_plugin_id)
|
||||
if creation_fields is None:
|
||||
creation_settings_dict = retriever_config
|
||||
retrieval_settings_dict = {}
|
||||
else:
|
||||
creation_settings_dict = {k: v for k, v in retriever_config.items() if k in creation_fields}
|
||||
retrieval_settings_dict = {k: v for k, v in retriever_config.items() if k not in creation_fields}
|
||||
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.text(
|
||||
'INSERT INTO knowledge_bases '
|
||||
'(uuid, name, description, emoji, created_at, updated_at, '
|
||||
'knowledge_engine_plugin_id, collection_id, creation_settings, retrieval_settings) '
|
||||
'VALUES (:uuid, :name, :description, :emoji, :created_at, :updated_at, '
|
||||
':plugin_id, :collection_id, :creation_settings, :retrieval_settings);'
|
||||
).bindparams(
|
||||
uuid=kb_uuid,
|
||||
name=name,
|
||||
description=description,
|
||||
emoji=emoji,
|
||||
created_at=created_at,
|
||||
updated_at=created_at,
|
||||
plugin_id=external_plugin_id,
|
||||
collection_id=kb_uuid,
|
||||
creation_settings=json.dumps(creation_settings_dict),
|
||||
retrieval_settings=json.dumps(retrieval_settings_dict),
|
||||
)
|
||||
)
|
||||
|
||||
if external_plugin_id not in engine_id_set:
|
||||
warning = (
|
||||
f'External KB "{name}" ({kb_uuid}) record saved, but plugin {external_plugin_id} '
|
||||
f'is not installed yet. Install the connector plugin to use it.'
|
||||
)
|
||||
warnings.append(warning)
|
||||
task_context.trace(warning)
|
||||
else:
|
||||
try:
|
||||
await self.ap.plugin_connector.rag_on_kb_create(
|
||||
external_plugin_id, kb_uuid, creation_settings_dict
|
||||
)
|
||||
task_context.trace(f'Restored external KB: {name} ({kb_uuid})')
|
||||
except Exception as e:
|
||||
warning = f'Failed to notify plugin for external KB {name} ({kb_uuid}): {e}'
|
||||
warnings.append(warning)
|
||||
task_context.trace(warning)
|
||||
|
||||
await self.ap.rag_mgr.load_knowledge_bases_from_db()
|
||||
|
||||
# Step 5: Clear migration flag
|
||||
await self._set_migration_flag('false')
|
||||
task_context.trace('RAG migration completed.', action='done')
|
||||
|
||||
if warnings:
|
||||
task_context.trace(f'Completed with {len(warnings)} warning(s).')
|
||||
|
||||
async def initialize(self) -> None:
|
||||
@self.route('/status', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
|
||||
async def _() -> str:
|
||||
needed = await self._get_migration_flag()
|
||||
|
||||
internal_kb_count = 0
|
||||
external_kb_count = 0
|
||||
|
||||
if needed:
|
||||
if await self._table_exists('knowledge_bases_backup'):
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.text('SELECT COUNT(*) FROM knowledge_bases_backup;')
|
||||
)
|
||||
internal_kb_count = result.scalar() or 0
|
||||
|
||||
if await self._table_exists('external_knowledge_bases'):
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.text('SELECT COUNT(*) FROM external_knowledge_bases;')
|
||||
)
|
||||
external_kb_count = result.scalar() or 0
|
||||
|
||||
return self.success(
|
||||
data={
|
||||
'needed': needed,
|
||||
'internal_kb_count': internal_kb_count,
|
||||
'external_kb_count': external_kb_count,
|
||||
}
|
||||
)
|
||||
|
||||
@self.route('/execute', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
|
||||
async def _() -> str:
|
||||
needed = await self._get_migration_flag()
|
||||
if not needed:
|
||||
return self.http_status(400, -1, 'RAG migration is not needed')
|
||||
|
||||
data = await quart.request.get_json(silent=True) or {}
|
||||
install_plugin = data.get('install_plugin', True)
|
||||
|
||||
ctx = taskmgr.TaskContext.new()
|
||||
wrapper = self.ap.task_mgr.create_user_task(
|
||||
self._execute_rag_migration(task_context=ctx, install_plugin=install_plugin),
|
||||
kind='rag-migration',
|
||||
name='rag-migration-execute',
|
||||
label='Migrating knowledge bases to plugin architecture',
|
||||
context=ctx,
|
||||
)
|
||||
|
||||
return self.success(data={'task_id': wrapper.id})
|
||||
|
||||
@self.route('/dismiss', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
|
||||
async def _() -> str:
|
||||
needed = await self._get_migration_flag()
|
||||
if not needed:
|
||||
return self.http_status(400, -1, 'RAG migration is not needed')
|
||||
|
||||
await self._set_migration_flag('false')
|
||||
return self.success()
|
||||
@@ -30,6 +30,7 @@ class MonitoringService:
|
||||
level: str = 'info',
|
||||
platform: str | None = None,
|
||||
user_id: str | None = None,
|
||||
user_name: str | None = None,
|
||||
runner_name: str | None = None,
|
||||
variables: str | None = None,
|
||||
role: str = 'user',
|
||||
@@ -49,6 +50,7 @@ class MonitoringService:
|
||||
'level': level,
|
||||
'platform': platform,
|
||||
'user_id': user_id,
|
||||
'user_name': user_name,
|
||||
'runner_name': runner_name,
|
||||
'variables': variables,
|
||||
'role': role,
|
||||
@@ -152,6 +154,7 @@ class MonitoringService:
|
||||
pipeline_name: str,
|
||||
platform: str | None = None,
|
||||
user_id: str | None = None,
|
||||
user_name: str | None = None,
|
||||
) -> None:
|
||||
"""Record a new session"""
|
||||
session_data = {
|
||||
@@ -166,6 +169,7 @@ class MonitoringService:
|
||||
'is_active': True,
|
||||
'platform': platform,
|
||||
'user_id': user_id,
|
||||
'user_name': user_name,
|
||||
}
|
||||
|
||||
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 ..provider.session import sessionmgr as llm_session_mgr
|
||||
from ..provider.modelmgr import modelmgr as llm_model_mgr
|
||||
|
||||
from langbot.pkg.provider.tools import toolmgr as llm_tool_mgr
|
||||
from ..config import manager as config_mgr
|
||||
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 webhook as webhook_service
|
||||
from ..api.http.service import monitoring as monitoring_service
|
||||
|
||||
from ..discover import engine as discover_engine
|
||||
from ..storage import mgr as storagemgr
|
||||
from ..utils import logcache
|
||||
|
||||
@@ -20,6 +20,7 @@ class MonitoringMessage(Base):
|
||||
level = sqlalchemy.Column(sqlalchemy.String(50), nullable=False) # info, warning, error, debug
|
||||
platform = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
|
||||
user_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
|
||||
user_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=True) # User display name
|
||||
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
|
||||
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)
|
||||
platform = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
|
||||
user_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True)
|
||||
user_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=True) # User display name
|
||||
|
||||
|
||||
class MonitoringError(Base):
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import json
|
||||
|
||||
import sqlalchemy
|
||||
from .. import migration
|
||||
|
||||
@@ -9,20 +7,22 @@ class DBMigrateKnowledgeEnginePluginArchitecture(migration.DBMigration):
|
||||
"""Migrate to unified Knowledge Engine plugin architecture.
|
||||
|
||||
Changes:
|
||||
- Add knowledge_engine_plugin_id, collection_id, creation_settings, retrieval_settings columns to knowledge_bases
|
||||
- Migrate existing top_k values into retrieval_settings JSON
|
||||
- Migrate existing embedding_model_uuid into creation_settings JSON
|
||||
- Drop embedding_model_uuid and top_k columns (PostgreSQL only; SQLite leaves them unmapped)
|
||||
- Drop external_knowledge_bases table (no longer needed; external KB data is not migrated)
|
||||
- Backup existing knowledge_bases data to knowledge_bases_backup
|
||||
- Clear knowledge_bases table and add new plugin architecture columns
|
||||
- Drop old columns (PostgreSQL only; SQLite leaves them unmapped)
|
||||
- Preserve external_knowledge_bases table as-is for future migration
|
||||
- Set rag_plugin_migration_needed flag in metadata if old data exists
|
||||
"""
|
||||
|
||||
async def upgrade(self):
|
||||
"""Upgrade"""
|
||||
has_internal_data = await self._backup_knowledge_bases()
|
||||
has_external_data = await self._check_external_knowledge_bases()
|
||||
await self._clear_knowledge_bases()
|
||||
await self._add_columns_to_knowledge_bases()
|
||||
await self._migrate_top_k_to_retrieval_settings()
|
||||
await self._migrate_embedding_model_uuid_to_creation_settings()
|
||||
await self._drop_old_columns()
|
||||
await self._drop_external_knowledge_bases_table()
|
||||
if has_internal_data or has_external_data:
|
||||
await self._set_migration_flag()
|
||||
|
||||
async def _get_table_columns(self, table_name: str) -> list[str]:
|
||||
"""Get column names from a table (works for both SQLite and PostgreSQL)."""
|
||||
@@ -57,6 +57,50 @@ class DBMigrateKnowledgeEnginePluginArchitecture(migration.DBMigration):
|
||||
)
|
||||
return result.first() is not None
|
||||
|
||||
async def _backup_knowledge_bases(self) -> bool:
|
||||
"""Backup knowledge_bases data. Returns True if data was backed up."""
|
||||
result = await self.ap.persistence_mgr.execute_async(sqlalchemy.text('SELECT COUNT(*) FROM knowledge_bases;'))
|
||||
count = result.scalar()
|
||||
if count == 0:
|
||||
return False
|
||||
|
||||
# Drop backup table if it already exists (from a previous failed migration)
|
||||
if await self._table_exists('knowledge_bases_backup'):
|
||||
await self.ap.persistence_mgr.execute_async(sqlalchemy.text('DROP TABLE knowledge_bases_backup;'))
|
||||
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.text('CREATE TABLE knowledge_bases_backup AS SELECT * FROM knowledge_bases;')
|
||||
)
|
||||
self.ap.logger.info(
|
||||
'Backed up %d knowledge base(s) to knowledge_bases_backup table.',
|
||||
count,
|
||||
)
|
||||
return True
|
||||
|
||||
async def _check_external_knowledge_bases(self) -> bool:
|
||||
"""Check if external_knowledge_bases table exists and has data.
|
||||
|
||||
The table is preserved as-is (not dropped) for future migration.
|
||||
"""
|
||||
if not await self._table_exists('external_knowledge_bases'):
|
||||
return False
|
||||
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.text('SELECT COUNT(*) FROM external_knowledge_bases;')
|
||||
)
|
||||
count = result.scalar()
|
||||
if count > 0:
|
||||
self.ap.logger.info(
|
||||
'Found %d external knowledge base(s) in external_knowledge_bases table. '
|
||||
'Table preserved for future migration.',
|
||||
count,
|
||||
)
|
||||
return count > 0
|
||||
|
||||
async def _clear_knowledge_bases(self):
|
||||
"""Clear all rows from knowledge_bases table (preserve table structure)."""
|
||||
await self.ap.persistence_mgr.execute_async(sqlalchemy.text('DELETE FROM knowledge_bases;'))
|
||||
|
||||
async def _add_columns_to_knowledge_bases(self):
|
||||
"""Add new RAG plugin architecture columns to knowledge_bases table."""
|
||||
columns = await self._get_table_columns('knowledge_bases')
|
||||
@@ -74,73 +118,6 @@ class DBMigrateKnowledgeEnginePluginArchitecture(migration.DBMigration):
|
||||
sqlalchemy.text(f'ALTER TABLE knowledge_bases ADD COLUMN {col_name} {col_type};')
|
||||
)
|
||||
|
||||
# For existing knowledge bases without knowledge_engine_plugin_id,
|
||||
# set collection_id = uuid (same default as new KBs)
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.text('UPDATE knowledge_bases SET collection_id = uuid WHERE collection_id IS NULL;')
|
||||
)
|
||||
|
||||
async def _migrate_top_k_to_retrieval_settings(self):
|
||||
"""Migrate existing top_k values into retrieval_settings JSON."""
|
||||
columns = await self._get_table_columns('knowledge_bases')
|
||||
if 'top_k' not in columns:
|
||||
return
|
||||
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.text(
|
||||
'SELECT uuid, top_k FROM knowledge_bases WHERE top_k IS NOT NULL AND retrieval_settings IS NULL;'
|
||||
)
|
||||
)
|
||||
rows = result.fetchall()
|
||||
|
||||
for row in rows:
|
||||
kb_uuid = row[0]
|
||||
top_k = row[1]
|
||||
retrieval_settings = json.dumps({'top_k': top_k})
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.text('UPDATE knowledge_bases SET retrieval_settings = :rs WHERE uuid = :uuid;').bindparams(
|
||||
rs=retrieval_settings, uuid=kb_uuid
|
||||
)
|
||||
)
|
||||
|
||||
async def _migrate_embedding_model_uuid_to_creation_settings(self):
|
||||
"""Migrate existing embedding_model_uuid into creation_settings JSON."""
|
||||
columns = await self._get_table_columns('knowledge_bases')
|
||||
if 'embedding_model_uuid' not in columns:
|
||||
return
|
||||
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.text(
|
||||
'SELECT uuid, embedding_model_uuid, creation_settings FROM knowledge_bases '
|
||||
"WHERE embedding_model_uuid IS NOT NULL AND embedding_model_uuid != '';"
|
||||
)
|
||||
)
|
||||
rows = result.fetchall()
|
||||
|
||||
for row in rows:
|
||||
kb_uuid = row[0]
|
||||
emb_uuid = row[1]
|
||||
existing_settings = row[2]
|
||||
|
||||
if existing_settings and isinstance(existing_settings, str):
|
||||
try:
|
||||
settings = json.loads(existing_settings)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
settings = {}
|
||||
elif isinstance(existing_settings, dict):
|
||||
settings = existing_settings
|
||||
else:
|
||||
settings = {}
|
||||
|
||||
if 'embedding_model_uuid' not in settings:
|
||||
settings['embedding_model_uuid'] = emb_uuid
|
||||
new_settings = json.dumps(settings)
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.text(
|
||||
'UPDATE knowledge_bases SET creation_settings = :cs WHERE uuid = :uuid;'
|
||||
).bindparams(cs=new_settings, uuid=kb_uuid)
|
||||
)
|
||||
|
||||
async def _drop_old_columns(self):
|
||||
"""Drop embedding_model_uuid and top_k columns (PostgreSQL only).
|
||||
|
||||
@@ -162,22 +139,22 @@ class DBMigrateKnowledgeEnginePluginArchitecture(migration.DBMigration):
|
||||
sqlalchemy.text('ALTER TABLE knowledge_bases DROP COLUMN top_k;')
|
||||
)
|
||||
|
||||
async def _drop_external_knowledge_bases_table(self):
|
||||
"""Drop the external_knowledge_bases table if it exists."""
|
||||
if await self._table_exists('external_knowledge_bases'):
|
||||
# Log existing external KBs before dropping, so users are aware of data loss
|
||||
rows = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.text('SELECT * FROM external_knowledge_bases;')
|
||||
async def _set_migration_flag(self):
|
||||
"""Set rag_plugin_migration_needed flag in metadata table."""
|
||||
# Check if the key already exists
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.text("SELECT value FROM metadata WHERE key = 'rag_plugin_migration_needed';")
|
||||
)
|
||||
row = result.first()
|
||||
if row is not None:
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.text("UPDATE metadata SET value = 'true' WHERE key = 'rag_plugin_migration_needed';")
|
||||
)
|
||||
existing = rows.fetchall()
|
||||
if existing:
|
||||
self.ap.logger.warning(
|
||||
'Dropping external_knowledge_bases table with %d existing record(s). '
|
||||
'These external KB configurations will be removed: %s',
|
||||
len(existing),
|
||||
[dict(row._mapping) for row in existing],
|
||||
)
|
||||
await self.ap.persistence_mgr.execute_async(sqlalchemy.text('DROP TABLE external_knowledge_bases;'))
|
||||
else:
|
||||
await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.text("INSERT INTO metadata (key, value) VALUES ('rag_plugin_migration_needed', 'true');")
|
||||
)
|
||||
self.ap.logger.info('Set rag_plugin_migration_needed=true in metadata.')
|
||||
|
||||
async def downgrade(self):
|
||||
"""Downgrade"""
|
||||
|
||||
@@ -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
|
||||
105
src/langbot/pkg/pipeline/config_coercion.py
Normal file
105
src/langbot/pkg/pipeline/config_coercion.py
Normal file
@@ -0,0 +1,105 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# metadata type -> coercion function
|
||||
_COERCE_MAP = {
|
||||
'integer': lambda v: int(v),
|
||||
'number': lambda v: float(v),
|
||||
'float': lambda v: float(v),
|
||||
}
|
||||
|
||||
|
||||
def _coerce_bool(v):
|
||||
if isinstance(v, bool):
|
||||
return v
|
||||
if isinstance(v, str):
|
||||
if v.lower() == 'true':
|
||||
return True
|
||||
if v.lower() == 'false':
|
||||
return False
|
||||
raise ValueError(f'Cannot convert string {v!r} to bool')
|
||||
return bool(v)
|
||||
|
||||
|
||||
def _coerce_value(value, expected_type: str):
|
||||
"""Convert a single value to the expected type.
|
||||
|
||||
Returns the converted value, or the original value if no conversion needed.
|
||||
"""
|
||||
if value is None:
|
||||
return value
|
||||
|
||||
if expected_type == 'boolean':
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
return _coerce_bool(value)
|
||||
|
||||
coerce_fn = _COERCE_MAP.get(expected_type)
|
||||
if coerce_fn is None:
|
||||
return value
|
||||
|
||||
# Already the correct type
|
||||
if expected_type == 'integer' and isinstance(value, int) and not isinstance(value, bool):
|
||||
return value
|
||||
if expected_type in ('number', 'float') and isinstance(value, (int, float)) and not isinstance(value, bool):
|
||||
return float(value)
|
||||
|
||||
return coerce_fn(value)
|
||||
|
||||
|
||||
def coerce_pipeline_config(
|
||||
config: dict,
|
||||
*metadata_list: dict,
|
||||
) -> None:
|
||||
"""Coerce pipeline config values according to metadata type definitions.
|
||||
|
||||
Walks each metadata dict (trigger, safety, ai, output) and converts
|
||||
config values in-place so that strings coming from the JSON column are
|
||||
cast to their declared types (integer, number/float, boolean).
|
||||
|
||||
Args:
|
||||
config: The pipeline config dict to modify in-place.
|
||||
*metadata_list: Metadata dicts loaded from the YAML templates.
|
||||
"""
|
||||
for meta in metadata_list:
|
||||
section_name = meta.get('name')
|
||||
if not section_name or section_name not in config:
|
||||
continue
|
||||
|
||||
section = config[section_name]
|
||||
if not isinstance(section, dict):
|
||||
continue
|
||||
|
||||
for stage_def in meta.get('stages', []):
|
||||
stage_name = stage_def.get('name')
|
||||
if not stage_name or stage_name not in section:
|
||||
continue
|
||||
|
||||
stage_config = section[stage_name]
|
||||
if not isinstance(stage_config, dict):
|
||||
continue
|
||||
|
||||
for field_def in stage_def.get('config', []):
|
||||
field_name = field_def.get('name')
|
||||
field_type = field_def.get('type')
|
||||
if not field_name or not field_type or field_name not in stage_config:
|
||||
continue
|
||||
|
||||
old_value = stage_config[field_name]
|
||||
try:
|
||||
new_value = _coerce_value(old_value, field_type)
|
||||
if new_value is not old_value:
|
||||
stage_config[field_name] = new_value
|
||||
except (ValueError, TypeError) as e:
|
||||
logger.warning(
|
||||
'Failed to coerce config %s.%s.%s (%r) to %s: %s',
|
||||
section_name,
|
||||
stage_name,
|
||||
field_name,
|
||||
old_value,
|
||||
field_type,
|
||||
e,
|
||||
)
|
||||
@@ -34,6 +34,15 @@ class MonitoringHelper:
|
||||
# Check if session exists, if not, record session start
|
||||
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
|
||||
# Use JSON serialization to preserve message chain structure (including image URLs, etc.)
|
||||
if hasattr(query, 'message_chain') and hasattr(query.message_chain, 'model_dump'):
|
||||
@@ -57,6 +66,7 @@ class MonitoringHelper:
|
||||
if hasattr(query.launcher_type, 'value')
|
||||
else str(query.launcher_type),
|
||||
user_id=query.sender_id,
|
||||
user_name=sender_name,
|
||||
runner_name=runner_name,
|
||||
variables=None, # Will be updated in record_query_success
|
||||
)
|
||||
@@ -80,6 +90,7 @@ class MonitoringHelper:
|
||||
if hasattr(query.launcher_type, 'value')
|
||||
else str(query.launcher_type),
|
||||
user_id=query.sender_id,
|
||||
user_name=sender_name,
|
||||
)
|
||||
|
||||
return message_id
|
||||
@@ -128,6 +139,15 @@ class MonitoringHelper:
|
||||
try:
|
||||
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
|
||||
if hasattr(query, 'resp_message_chain') and query.resp_message_chain:
|
||||
# Serialize the last response message chain
|
||||
@@ -162,6 +182,7 @@ class MonitoringHelper:
|
||||
if hasattr(query.launcher_type, 'value')
|
||||
else str(query.launcher_type),
|
||||
user_id=query.sender_id,
|
||||
user_name=sender_name,
|
||||
runner_name=runner_name,
|
||||
role='assistant',
|
||||
)
|
||||
@@ -183,6 +204,15 @@ class MonitoringHelper:
|
||||
try:
|
||||
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
|
||||
message_id = await ap.monitoring_service.record_message(
|
||||
bot_id=bot_id,
|
||||
@@ -197,6 +227,7 @@ class MonitoringHelper:
|
||||
if hasattr(query.launcher_type, 'value')
|
||||
else str(query.launcher_type),
|
||||
user_id=query.sender_id,
|
||||
user_name=sender_name,
|
||||
runner_name=runner_name,
|
||||
)
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
||||
import langbot_plugin.api.entities.builtin.platform.events as platform_events
|
||||
import langbot_plugin.api.entities.events as events
|
||||
from ..utils import importutil
|
||||
from .config_coercion import coerce_pipeline_config
|
||||
|
||||
import langbot_plugin.api.entities.builtin.provider.session as provider_session
|
||||
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
|
||||
@@ -420,6 +421,14 @@ class PipelineManager:
|
||||
elif isinstance(pipeline_entity, dict):
|
||||
pipeline_entity = persistence_pipeline.LegacyPipeline(**pipeline_entity)
|
||||
|
||||
coerce_pipeline_config(
|
||||
pipeline_entity.config,
|
||||
getattr(self.ap, 'pipeline_config_meta_trigger', {'name': 'trigger', 'stages': []}),
|
||||
getattr(self.ap, 'pipeline_config_meta_safety', {'name': 'safety', 'stages': []}),
|
||||
getattr(self.ap, 'pipeline_config_meta_ai', {'name': 'ai', 'stages': []}),
|
||||
getattr(self.ap, 'pipeline_config_meta_output', {'name': 'output', 'stages': []}),
|
||||
)
|
||||
|
||||
# initialize stage containers according to pipeline_entity.stages
|
||||
stage_containers: list[StageInstContainer] = []
|
||||
for stage_name in pipeline_entity.stages:
|
||||
|
||||
@@ -36,17 +36,36 @@ class PreProcessor(stage.PipelineStage):
|
||||
session = await self.ap.sess_mgr.get_session(query)
|
||||
|
||||
# When not local-agent, llm_model is None
|
||||
try:
|
||||
llm_model = (
|
||||
await self.ap.model_mgr.get_model_by_uuid(query.pipeline_config['ai']['local-agent']['model'])
|
||||
if selected_runner == 'local-agent'
|
||||
else None
|
||||
)
|
||||
except ValueError:
|
||||
self.ap.logger.warning(
|
||||
f'LLM model {query.pipeline_config["ai"]["local-agent"]["model"] + " "}not found or not configured'
|
||||
)
|
||||
llm_model = None
|
||||
llm_model = None
|
||||
if selected_runner == 'local-agent':
|
||||
# Read model config — new format is { primary: str, fallbacks: [str] },
|
||||
# but handle legacy plain string for backward compatibility
|
||||
model_config = query.pipeline_config['ai']['local-agent'].get('model', {})
|
||||
if isinstance(model_config, str):
|
||||
# Legacy format: plain UUID string
|
||||
primary_uuid = model_config
|
||||
fallback_uuids = []
|
||||
else:
|
||||
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(
|
||||
query,
|
||||
@@ -61,20 +80,28 @@ class PreProcessor(stage.PipelineStage):
|
||||
query.prompt = conversation.prompt.copy()
|
||||
query.messages = conversation.messages.copy()
|
||||
|
||||
if selected_runner == 'local-agent' and llm_model:
|
||||
if selected_runner == 'local-agent':
|
||||
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'):
|
||||
# Get bound plugins and MCP servers for filtering tools
|
||||
if llm_model.model_entity.abilities.__contains__('func_call'):
|
||||
# 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_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}')
|
||||
|
||||
sender_name = ''
|
||||
|
||||
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}')
|
||||
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(
|
||||
result_type=entities.ResultType.INTERRUPT,
|
||||
new_query=query,
|
||||
user_notice='请求失败' if hide_exception_info else f'{e}',
|
||||
user_notice=user_notice,
|
||||
error_notice=f'{e}',
|
||||
debug_notice=traceback.format_exc(),
|
||||
)
|
||||
|
||||
@@ -282,6 +282,8 @@ class PlatformManager:
|
||||
return runtime_bot
|
||||
|
||||
async def get_bot_by_uuid(self, bot_uuid: str) -> RuntimeBot | None:
|
||||
if self.websocket_proxy_bot and self.websocket_proxy_bot.bot_entity.uuid == bot_uuid:
|
||||
return self.websocket_proxy_bot
|
||||
for bot in self.bots:
|
||||
if bot.bot_entity.uuid == bot_uuid:
|
||||
return bot
|
||||
|
||||
@@ -575,6 +575,127 @@ class LarkMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
|
||||
|
||||
|
||||
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
|
||||
async def yiri2target(
|
||||
event: platform_events.MessageEvent,
|
||||
@@ -587,6 +708,23 @@ class LarkEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||
) -> platform_events.Event:
|
||||
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':
|
||||
return platform_events.FriendMessage(
|
||||
sender=platform_entities.Friend(
|
||||
@@ -770,6 +908,32 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
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
|
||||
|
||||
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):
|
||||
app_id = config['app_id']
|
||||
app_secret = config['app_secret']
|
||||
|
||||
@@ -37,16 +37,24 @@ class WebSocketSession:
|
||||
id: str
|
||||
message_lists: dict[str, list[WebSocketMessage]] = {}
|
||||
"""消息列表 {pipeline_uuid: [messages]}"""
|
||||
stream_message_indexes: dict[str, dict[str, int]] = {}
|
||||
"""流式消息索引 {pipeline_uuid: {resp_message_id: message_index}}"""
|
||||
|
||||
def __init__(self, id: str):
|
||||
self.id = id
|
||||
self.message_lists = {}
|
||||
self.stream_message_indexes = {}
|
||||
|
||||
def get_message_list(self, pipeline_uuid: str) -> list[WebSocketMessage]:
|
||||
if pipeline_uuid not in self.message_lists:
|
||||
self.message_lists[pipeline_uuid] = []
|
||||
return self.message_lists[pipeline_uuid]
|
||||
|
||||
def get_stream_message_indexes(self, pipeline_uuid: str) -> dict[str, int]:
|
||||
if pipeline_uuid not in self.stream_message_indexes:
|
||||
self.stream_message_indexes[pipeline_uuid] = {}
|
||||
return self.stream_message_indexes[pipeline_uuid]
|
||||
|
||||
|
||||
class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
"""WebSocket适配器 - 支持双向实时通信"""
|
||||
@@ -89,20 +97,46 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
|
||||
target_id: str,
|
||||
message: platform_message.MessageChain,
|
||||
) -> dict:
|
||||
"""发送消息 - 这里用于主动推送消息到前端"""
|
||||
message_data = {
|
||||
'type': 'bot_message',
|
||||
'target_type': target_type,
|
||||
'target_id': target_id,
|
||||
'content': str(message),
|
||||
'message_chain': [component.__dict__ for component in message],
|
||||
'timestamp': datetime.now().isoformat(),
|
||||
}
|
||||
"""发送消息 - 这里用于主动推送消息到前端
|
||||
|
||||
# 推送到所有相关连接
|
||||
await self.outbound_message_queue.put(message_data)
|
||||
对于 WebSocket 适配器,我们需要将消息广播到正确的 pipeline 连接。
|
||||
target_id 可能是 launcher_id(如 websocket_xxx)或 pipeline_uuid。
|
||||
我们需要尝试两种方式来确保消息能够送达。
|
||||
"""
|
||||
# 获取当前的 pipeline_uuid
|
||||
pipeline_uuid = self.ap.platform_mgr.websocket_proxy_bot.bot_entity.use_pipeline_uuid
|
||||
session_type = 'group' if target_type == 'group' else 'person'
|
||||
|
||||
return message_data
|
||||
# 选择会话
|
||||
session = self.websocket_group_session if session_type == 'group' else self.websocket_person_session
|
||||
|
||||
# 生成唯一消息ID
|
||||
msg_id = len(session.get_message_list(pipeline_uuid)) + 1
|
||||
|
||||
message_data = WebSocketMessage(
|
||||
id=msg_id,
|
||||
role='assistant',
|
||||
content=str(message),
|
||||
message_chain=[component.__dict__ for component in message],
|
||||
timestamp=datetime.now().isoformat(),
|
||||
is_final=True,
|
||||
)
|
||||
|
||||
# 保存到历史记录
|
||||
session.get_message_list(pipeline_uuid).append(message_data)
|
||||
|
||||
# 直接广播到当前pipeline的连接
|
||||
await ws_connection_manager.broadcast_to_pipeline(
|
||||
pipeline_uuid,
|
||||
{
|
||||
'type': 'response',
|
||||
'session_type': session_type,
|
||||
'data': message_data.model_dump(),
|
||||
},
|
||||
session_type=session_type,
|
||||
)
|
||||
|
||||
return message_data.model_dump()
|
||||
|
||||
async def reply_message(
|
||||
self,
|
||||
@@ -169,10 +203,16 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
|
||||
pipeline_uuid = self.ap.platform_mgr.websocket_proxy_bot.bot_entity.use_pipeline_uuid
|
||||
session_type = 'group' if isinstance(message_source, platform_events.GroupMessage) else 'person'
|
||||
message_list = session.get_message_list(pipeline_uuid)
|
||||
stream_message_indexes = session.get_stream_message_indexes(pipeline_uuid)
|
||||
|
||||
# 检查是否是新的流式消息(通过bot_message对象判断)
|
||||
# 如果列表为空,或者最后一条消息已经is_final=True,则创建新消息
|
||||
if not message_list or message_list[-1].is_final:
|
||||
# Streaming messages in LangBot have a stable resp_message_id during the same assistant reply.
|
||||
# Use it as the primary key to avoid overwriting an old card from a previous reply.
|
||||
resp_message_id = str(getattr(bot_message, 'resp_message_id', '') or '')
|
||||
existing_index = stream_message_indexes.get(resp_message_id) if resp_message_id else None
|
||||
|
||||
message_is_final = is_final and bot_message.tool_calls is None
|
||||
|
||||
if existing_index is None or existing_index >= len(message_list):
|
||||
# 创建新消息
|
||||
msg_id = len(message_list) + 1
|
||||
message_data = WebSocketMessage(
|
||||
@@ -181,27 +221,31 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
|
||||
content=str(message),
|
||||
message_chain=[component.__dict__ for component in message],
|
||||
timestamp=datetime.now().isoformat(),
|
||||
is_final=is_final and bot_message.tool_calls is None,
|
||||
is_final=message_is_final,
|
||||
)
|
||||
|
||||
# 只有在is_final时才保存到历史记录
|
||||
if is_final and bot_message.tool_calls is None:
|
||||
message_list.append(message_data)
|
||||
# 立即添加到历史记录(即使is_final=False),以便后续块可以更新它
|
||||
message_list.append(message_data)
|
||||
if resp_message_id:
|
||||
stream_message_indexes[resp_message_id] = len(message_list) - 1
|
||||
else:
|
||||
# 更新最后一条消息
|
||||
msg_id = message_list[-1].id
|
||||
# 更新同一条流式消息
|
||||
old_message = message_list[existing_index]
|
||||
msg_id = old_message.id
|
||||
message_data = WebSocketMessage(
|
||||
id=msg_id,
|
||||
role='assistant',
|
||||
content=str(message),
|
||||
message_chain=[component.__dict__ for component in message],
|
||||
timestamp=message_list[-1].timestamp, # 保持原始时间戳
|
||||
is_final=is_final and bot_message.tool_calls is None,
|
||||
timestamp=old_message.timestamp, # 保持原始时间戳
|
||||
is_final=message_is_final,
|
||||
)
|
||||
|
||||
# 如果是final,更新历史记录中的最后一条
|
||||
if is_final and bot_message.tool_calls is None:
|
||||
message_list[-1] = message_data
|
||||
# 更新历史记录中的对应消息
|
||||
message_list[existing_index] = message_data
|
||||
|
||||
if message_is_final and resp_message_id:
|
||||
stream_message_indexes.pop(resp_message_id, None)
|
||||
|
||||
# 直接广播到所有该pipeline的连接,包含session_type信息
|
||||
await ws_connection_manager.broadcast_to_pipeline(
|
||||
@@ -410,6 +454,10 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
|
||||
if session_type == 'person':
|
||||
if pipeline_uuid in self.websocket_person_session.message_lists:
|
||||
self.websocket_person_session.message_lists[pipeline_uuid] = []
|
||||
if pipeline_uuid in self.websocket_person_session.stream_message_indexes:
|
||||
self.websocket_person_session.stream_message_indexes[pipeline_uuid] = {}
|
||||
else:
|
||||
if pipeline_uuid in self.websocket_group_session.message_lists:
|
||||
self.websocket_group_session.message_lists[pipeline_uuid] = []
|
||||
if pipeline_uuid in self.websocket_group_session.stream_message_indexes:
|
||||
self.websocket_group_session.stream_message_indexes[pipeline_uuid] = {}
|
||||
|
||||
@@ -11,6 +11,7 @@ import langbot_plugin.api.entities.builtin.platform.entities as platform_entitie
|
||||
from ..logger import EventLogger
|
||||
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.ws_client import WecomBotWsClient
|
||||
|
||||
|
||||
class WecomBotMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
|
||||
@@ -176,27 +177,42 @@ class WecomBotEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||
|
||||
|
||||
class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
bot: WecomBotClient
|
||||
bot: typing.Union[WecomBotClient, WecomBotWsClient]
|
||||
bot_account_id: str
|
||||
message_converter: WecomBotMessageConverter = WecomBotMessageConverter()
|
||||
event_converter: WecomBotEventConverter = WecomBotEventConverter()
|
||||
config: dict
|
||||
bot_uuid: str = None
|
||||
_ws_mode: bool = False
|
||||
|
||||
def __init__(self, config: dict, logger: EventLogger):
|
||||
required_keys = ['Token', 'EncodingAESKey', 'Corpid', 'BotId']
|
||||
missing_keys = [key for key in required_keys if key not in config]
|
||||
if missing_keys:
|
||||
raise Exception(f'WecomBot 缺少配置项: {missing_keys}')
|
||||
enable_webhook = config.get('enable-webhook', False)
|
||||
|
||||
bot = WecomBotClient(
|
||||
Token=config['Token'],
|
||||
EnCodingAESKey=config['EncodingAESKey'],
|
||||
Corpid=config['Corpid'],
|
||||
logger=logger,
|
||||
unified_mode=True,
|
||||
)
|
||||
bot_account_id = config['BotId']
|
||||
if not enable_webhook:
|
||||
bot = WecomBotWsClient(
|
||||
bot_id=config['BotId'],
|
||||
secret=config['Secret'],
|
||||
logger=logger,
|
||||
encoding_aes_key=config.get('EncodingAESKey', ''),
|
||||
)
|
||||
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__(
|
||||
config=config,
|
||||
@@ -204,6 +220,7 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
bot=bot,
|
||||
bot_account_id=bot_account_id,
|
||||
)
|
||||
self._ws_mode = ws_mode
|
||||
|
||||
async def reply_message(
|
||||
self,
|
||||
@@ -212,7 +229,15 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
quote_origin: bool = False,
|
||||
):
|
||||
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(
|
||||
self,
|
||||
@@ -222,31 +247,22 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
quote_origin: 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)
|
||||
msg_id = message_source.source_platform_object.message_id
|
||||
|
||||
# 将片段推送到 WecomBotClient 中的队列,返回值用于判断是否走降级逻辑
|
||||
success = await self.bot.push_stream_chunk(msg_id, content, is_final=is_final)
|
||||
if not success and is_final:
|
||||
# 未命中流式队列时使用旧有 set_message 兜底
|
||||
await self.bot.set_message(msg_id, content)
|
||||
return {'stream': success}
|
||||
if self._ws_mode:
|
||||
success = await self.bot.push_stream_chunk(msg_id, content, is_final=is_final)
|
||||
if not success and is_final:
|
||||
event = message_source.source_platform_object
|
||||
req_id = event.get('req_id', '')
|
||||
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:
|
||||
"""智能机器人侧默认开启流式能力。
|
||||
@@ -259,7 +275,11 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
return True
|
||||
|
||||
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(
|
||||
self,
|
||||
@@ -288,29 +308,25 @@ class WecomBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
self.bot_uuid = bot_uuid
|
||||
|
||||
async def handle_unified_webhook(self, bot_uuid: str, path: str, request):
|
||||
"""处理统一 webhook 请求。
|
||||
|
||||
Args:
|
||||
bot_uuid: Bot 的 UUID
|
||||
path: 子路径(如果有的话)
|
||||
request: Quart Request 对象
|
||||
|
||||
Returns:
|
||||
响应数据
|
||||
"""
|
||||
if self._ws_mode:
|
||||
return None
|
||||
return await self.bot.handle_unified_webhook(request)
|
||||
|
||||
async def run_async(self):
|
||||
# 统一 webhook 模式下,不启动独立的 Quart 应用
|
||||
# 保持运行但不启动独立端口
|
||||
if self._ws_mode:
|
||||
await self.bot.connect()
|
||||
else:
|
||||
|
||||
async def keep_alive():
|
||||
while True:
|
||||
await asyncio.sleep(1)
|
||||
async def keep_alive():
|
||||
while True:
|
||||
await asyncio.sleep(1)
|
||||
|
||||
await keep_alive()
|
||||
await keep_alive()
|
||||
|
||||
async def kill(self) -> bool:
|
||||
if self._ws_mode:
|
||||
await self.bot.disconnect()
|
||||
return True
|
||||
return False
|
||||
|
||||
async def unregister_listener(
|
||||
|
||||
@@ -11,35 +11,64 @@ metadata:
|
||||
icon: wecombot.png
|
||||
spec:
|
||||
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
|
||||
label:
|
||||
en_US: Corpid
|
||||
zh_Hans: 企业ID
|
||||
description:
|
||||
en_US: Required for Webhook mode
|
||||
zh_Hans: 使用 Webhook 模式时必填
|
||||
type: string
|
||||
required: true
|
||||
required: false
|
||||
default: ""
|
||||
- name: Token
|
||||
label:
|
||||
en_US: Token
|
||||
zh_Hans: 令牌 (Token)
|
||||
description:
|
||||
en_US: Required for Webhook mode
|
||||
zh_Hans: 使用 Webhook 模式时必填
|
||||
type: string
|
||||
required: true
|
||||
required: false
|
||||
default: ""
|
||||
- name: EncodingAESKey
|
||||
label:
|
||||
en_US: EncodingAESKey
|
||||
zh_Hans: 消息加解密密钥 (EncodingAESKey)
|
||||
type: string
|
||||
required: true
|
||||
default: ""
|
||||
- name: BotId
|
||||
label:
|
||||
en_US: BotId
|
||||
zh_Hans: 机器人ID
|
||||
description:
|
||||
en_US: Required for Webhook mode. Optional for WebSocket mode (used for file decryption)
|
||||
zh_Hans: 使用 Webhook 模式时必填。WebSocket 模式下可选(用于文件解密)
|
||||
type: string
|
||||
required: false
|
||||
default: ""
|
||||
execution:
|
||||
python:
|
||||
path: ./wecombot.py
|
||||
attr: WecomBotAdapter
|
||||
attr: WecomBotAdapter
|
||||
|
||||
@@ -81,22 +81,33 @@ class WecomEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||
return event.source_platform_object
|
||||
|
||||
@staticmethod
|
||||
async def target2yiri(event: WecomCSEvent):
|
||||
async def target2yiri(event: WecomCSEvent, bot: WecomCSClient = None):
|
||||
"""
|
||||
将 WecomEvent 转换为平台的 FriendMessage 对象。
|
||||
|
||||
Args:
|
||||
event (WecomEvent): 企业微信客服事件。
|
||||
bot (WecomCSClient): 企业微信客服客户端,用于获取用户信息。
|
||||
|
||||
Returns:
|
||||
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':
|
||||
yiri_chain = await WecomMessageConverter.target2yiri(event.message, event.message_id)
|
||||
friend = platform_entities.Friend(
|
||||
id=f'u{event.user_id}',
|
||||
nickname=str(event.user_id),
|
||||
nickname=nickname,
|
||||
remark='',
|
||||
)
|
||||
|
||||
@@ -106,7 +117,7 @@ class WecomEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||
elif event.type == 'image':
|
||||
friend = platform_entities.Friend(
|
||||
id=f'u{event.user_id}',
|
||||
nickname=str(event.user_id),
|
||||
nickname=nickname,
|
||||
remark='',
|
||||
)
|
||||
|
||||
@@ -187,7 +198,7 @@ class WecomCSAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
async def on_message(event: WecomCSEvent):
|
||||
self.bot_account_id = event.receiver_id
|
||||
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:
|
||||
await self.logger.error(f'Error in wecomcs callback: {traceback.format_exc()}')
|
||||
|
||||
|
||||
@@ -337,7 +337,14 @@ class RuntimeConnectionHandler(handler.Handler):
|
||||
)
|
||||
|
||||
messages_obj = [provider_message.Message.model_validate(message) for message in messages]
|
||||
funcs_obj = [resource_tool.LLMTool.model_validate(func) for func in funcs]
|
||||
|
||||
# The func field is excluded during model_dump() in plugin side (marked as exclude=True),
|
||||
# but it's a required field for LLMTool validation. We need to provide a placeholder
|
||||
# function when reconstructing the LLMTool objects from serialized data.
|
||||
async def _placeholder_func(**kwargs):
|
||||
pass
|
||||
|
||||
funcs_obj = [resource_tool.LLMTool.model_validate({**func, 'func': _placeholder_func}) for func in funcs]
|
||||
|
||||
result = await llm_model.provider.invoke_llm(
|
||||
query=None,
|
||||
@@ -558,6 +565,16 @@ class RuntimeConnectionHandler(handler.Handler):
|
||||
except Exception as e:
|
||||
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)
|
||||
async def invoke_parser(data: dict[str, Any]) -> handler.ActionResponse:
|
||||
"""Plugin requests host to invoke a parser plugin."""
|
||||
@@ -582,6 +599,94 @@ class RuntimeConnectionHandler(handler.Handler):
|
||||
except Exception as e:
|
||||
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)
|
||||
async def ping(data: dict[str, Any]) -> handler.ActionResponse:
|
||||
"""Ping"""
|
||||
|
||||
@@ -441,6 +441,7 @@ class DifyServiceAPIRunner(runner.RequestRunner):
|
||||
is_final = False
|
||||
think_start = False
|
||||
think_end = False
|
||||
yielded_final = False
|
||||
|
||||
remove_think = self.pipeline_config['output'].get('misc', '').get('remove-think')
|
||||
|
||||
@@ -493,13 +494,19 @@ class DifyServiceAPIRunner(runner.RequestRunner):
|
||||
if 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)
|
||||
yield provider_message.MessageChunk(
|
||||
role='assistant',
|
||||
content=basic_mode_pending_chunk,
|
||||
is_final=is_final,
|
||||
)
|
||||
if is_final:
|
||||
yielded_final = True
|
||||
|
||||
if chunk is None:
|
||||
raise errors.DifyAPIError('Dify API 没有返回任何响应,请检查网络连接和API配置')
|
||||
|
||||
@@ -4,6 +4,7 @@ import json
|
||||
import copy
|
||||
import typing
|
||||
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.provider.message as provider_message
|
||||
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')
|
||||
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):
|
||||
self.active_calls: dict[str, dict] = {}
|
||||
self.completed_calls: list[provider_message.ToolCall] = []
|
||||
# Primary model
|
||||
if query.use_llm_model_uuid:
|
||||
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(
|
||||
self, query: pipeline_query.Query
|
||||
) -> typing.AsyncGenerator[provider_message.Message | provider_message.MessageChunk, None]:
|
||||
"""运行请求"""
|
||||
"""Run request"""
|
||||
pending_tool_calls = []
|
||||
|
||||
# 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')
|
||||
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:
|
||||
all_results.extend(result)
|
||||
@@ -113,51 +211,51 @@ class LocalAgentRunner(runner.RequestRunner):
|
||||
|
||||
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(
|
||||
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:
|
||||
# 非流式输出,直接请求
|
||||
|
||||
msg = await use_llm_model.provider.invoke_llm(
|
||||
# Non-streaming: invoke with fallback
|
||||
msg, use_llm_model = await self._invoke_with_fallback(
|
||||
query,
|
||||
use_llm_model,
|
||||
candidates,
|
||||
req_messages,
|
||||
query.use_funcs,
|
||||
extra_args=use_llm_model.model_entity.extra_args,
|
||||
remove_think=remove_think,
|
||||
remove_think,
|
||||
)
|
||||
yield msg
|
||||
final_msg = msg
|
||||
else:
|
||||
# 流式输出,需要处理工具调用
|
||||
# Streaming: invoke with fallback
|
||||
tool_calls_map: dict[str, provider_message.ToolCall] = {}
|
||||
msg_idx = 0
|
||||
accumulated_content = '' # 从开始累积的所有内容
|
||||
accumulated_content = ''
|
||||
last_role = 'assistant'
|
||||
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,
|
||||
use_llm_model,
|
||||
candidates,
|
||||
req_messages,
|
||||
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
|
||||
|
||||
# 记录角色
|
||||
if msg.role:
|
||||
last_role = msg.role
|
||||
|
||||
# 累积内容
|
||||
if msg.content:
|
||||
accumulated_content += msg.content
|
||||
|
||||
# 处理工具调用
|
||||
if msg.tool_calls:
|
||||
for tool_call in msg.tool_calls:
|
||||
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:
|
||||
# 流式处理中,工具调用参数可能分多个chunk返回,需要追加而不是覆盖
|
||||
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:
|
||||
msg_sequence += 1
|
||||
yield provider_message.MessageChunk(
|
||||
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,
|
||||
is_final=msg.is_final,
|
||||
msg_sequence=msg_sequence,
|
||||
)
|
||||
|
||||
# 创建最终消息用于后续处理
|
||||
final_msg = provider_message.MessageChunk(
|
||||
role=last_role,
|
||||
content=accumulated_content,
|
||||
@@ -198,7 +293,8 @@ class LocalAgentRunner(runner.RequestRunner):
|
||||
|
||||
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:
|
||||
for tool_call in pending_tool_calls:
|
||||
try:
|
||||
@@ -239,7 +335,6 @@ class LocalAgentRunner(runner.RequestRunner):
|
||||
|
||||
req_messages.append(msg)
|
||||
except Exception as e:
|
||||
# 工具调用出错,添加一个报错信息到 req_messages
|
||||
err_msg = provider_message.Message(role='tool', content=f'err: {e}', tool_call_id=tool_call.id)
|
||||
|
||||
yield err_msg
|
||||
@@ -247,39 +342,38 @@ class LocalAgentRunner(runner.RequestRunner):
|
||||
req_messages.append(err_msg)
|
||||
|
||||
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:
|
||||
tool_calls_map = {}
|
||||
msg_idx = 0
|
||||
accumulated_content = '' # 从开始累积的所有内容
|
||||
accumulated_content = ''
|
||||
last_role = 'assistant'
|
||||
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,
|
||||
use_llm_model,
|
||||
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,
|
||||
remove_think=remove_think,
|
||||
):
|
||||
)
|
||||
async for msg in tool_stream_src:
|
||||
msg_idx += 1
|
||||
|
||||
# 记录角色
|
||||
if msg.role:
|
||||
last_role = msg.role
|
||||
|
||||
# 第一次请求工具调用时的内容
|
||||
# Prepend first-round content on first chunk of tool-call round
|
||||
if msg_idx == 1:
|
||||
accumulated_content = first_content if first_content is not None else accumulated_content
|
||||
|
||||
# 累积内容
|
||||
if msg.content:
|
||||
accumulated_content += msg.content
|
||||
|
||||
# 处理工具调用
|
||||
if msg.tool_calls:
|
||||
for tool_call in msg.tool_calls:
|
||||
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:
|
||||
# 流式处理中,工具调用参数可能分多个chunk返回,需要追加而不是覆盖
|
||||
tool_calls_map[tool_call.id].function.arguments += tool_call.function.arguments
|
||||
|
||||
# 每8个chunk或最后一个chunk时,输出所有累积的内容
|
||||
if msg_idx % 8 == 0 or msg.is_final:
|
||||
msg_sequence += 1
|
||||
yield provider_message.MessageChunk(
|
||||
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,
|
||||
is_final=msg.is_final,
|
||||
msg_sequence=msg_sequence,
|
||||
@@ -312,12 +404,12 @@ class LocalAgentRunner(runner.RequestRunner):
|
||||
msg_sequence=msg_sequence,
|
||||
)
|
||||
else:
|
||||
# 处理完所有调用,再次请求
|
||||
# Non-streaming: use committed model directly (no fallback in tool loop)
|
||||
msg = await use_llm_model.provider.invoke_llm(
|
||||
query,
|
||||
use_llm_model,
|
||||
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,
|
||||
remove_think=remove_think,
|
||||
)
|
||||
|
||||
@@ -321,13 +321,19 @@ class RuntimeKnowledgeBase(KnowledgeBaseInterface):
|
||||
if not plugin_id:
|
||||
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 = {
|
||||
'query': query,
|
||||
'knowledge_base_id': kb.uuid,
|
||||
'collection_id': kb.collection_id or kb.uuid,
|
||||
'retrieval_settings': settings,
|
||||
'creation_settings': kb.creation_settings or {},
|
||||
'filters': settings.pop('filters', {}),
|
||||
'filters': filters,
|
||||
}
|
||||
|
||||
result = await self.ap.plugin_connector.call_rag_retrieve(
|
||||
|
||||
@@ -2,7 +2,7 @@ import langbot
|
||||
|
||||
semantic_version = f'v{langbot.__version__}'
|
||||
|
||||
required_database_version = 19
|
||||
required_database_version = 24
|
||||
"""Tag the version of the database schema, used to check if the database needs to be migrated"""
|
||||
|
||||
debug_mode = False
|
||||
|
||||
@@ -100,7 +100,7 @@ class VectorDBManager:
|
||||
) -> list[dict]:
|
||||
"""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:
|
||||
{ 'ids': [['id1']], 'distances': [[0.1]], 'metadatas': [[{}]] }
|
||||
"""
|
||||
@@ -130,7 +130,7 @@ class VectorDBManager:
|
||||
parsed_results.append(
|
||||
{
|
||||
'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 {},
|
||||
}
|
||||
)
|
||||
|
||||
@@ -2,11 +2,14 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
from typing import Any
|
||||
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
|
||||
import chromadb
|
||||
import chromadb.errors
|
||||
|
||||
# RRF smoothing constant (standard value from the literature)
|
||||
_RRF_K = 60
|
||||
|
||||
|
||||
class ChromaVectorDatabase(VectorDatabase):
|
||||
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._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:
|
||||
if collection not in self._collections:
|
||||
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)
|
||||
if documents is not None:
|
||||
kwargs['documents'] = documents
|
||||
await asyncio.to_thread(col.add, **kwargs)
|
||||
self.ap.logger.info(f"Added {len(ids)} embeddings to Chroma collection '{collection}'.")
|
||||
await asyncio.to_thread(col.upsert, **kwargs)
|
||||
self.ap.logger.info(f"Upserted {len(ids)} embeddings to Chroma collection '{collection}'.")
|
||||
|
||||
async def search(
|
||||
self,
|
||||
@@ -47,6 +54,23 @@ class ChromaVectorDatabase(VectorDatabase):
|
||||
filter: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
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_embeddings=query_embedding,
|
||||
n_results=k,
|
||||
@@ -55,9 +79,137 @@ class ChromaVectorDatabase(VectorDatabase):
|
||||
if filter:
|
||||
query_kwargs['where'] = filter
|
||||
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
|
||||
|
||||
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:
|
||||
col = await self.get_or_create_collection(collection)
|
||||
await asyncio.to_thread(col.delete, where={'file_id': file_id})
|
||||
|
||||
@@ -95,11 +95,12 @@
|
||||
"max": 0
|
||||
},
|
||||
"misc": {
|
||||
"hide-exception": true,
|
||||
"exception-handling": "show-hint",
|
||||
"failure-hint": "Request failed.",
|
||||
"at-sender": true,
|
||||
"quote-origin": true,
|
||||
"track-function-calls": false,
|
||||
"remove-think": false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,8 +59,11 @@ stages:
|
||||
label:
|
||||
en_US: Model
|
||||
zh_Hans: 模型
|
||||
type: llm-model-selector
|
||||
type: model-fallback-selector
|
||||
required: true
|
||||
default:
|
||||
primary: ''
|
||||
fallbacks: []
|
||||
- name: max-round
|
||||
label:
|
||||
en_US: Max Round
|
||||
|
||||
@@ -78,13 +78,39 @@ stages:
|
||||
en_US: Misc
|
||||
zh_Hans: 杂项
|
||||
config:
|
||||
- name: hide-exception
|
||||
- name: exception-handling
|
||||
label:
|
||||
en_US: Hide Exception
|
||||
zh_Hans: 不输出异常信息给用户
|
||||
type: boolean
|
||||
en_US: Exception Handling Strategy
|
||||
zh_Hans: 异常处理策略
|
||||
description:
|
||||
en_US: Controls how error messages are displayed to the user when an AI request fails
|
||||
zh_Hans: 控制 AI 请求失败时向用户展示错误信息的方式
|
||||
type: select
|
||||
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
|
||||
label:
|
||||
en_US: At Sender
|
||||
@@ -119,3 +145,4 @@ stages:
|
||||
type: boolean
|
||||
required: true
|
||||
default: false
|
||||
|
||||
|
||||
113
tests/unit_tests/pipeline/test_config_coercion.py
Normal file
113
tests/unit_tests/pipeline/test_config_coercion.py
Normal file
@@ -0,0 +1,113 @@
|
||||
"""Unit tests for config_coercion module"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from langbot.pkg.pipeline.config_coercion import _coerce_value, coerce_pipeline_config
|
||||
|
||||
|
||||
class TestCoerceValue:
|
||||
"""Tests for _coerce_value function"""
|
||||
|
||||
def test_none_passthrough(self):
|
||||
assert _coerce_value(None, 'integer') is None
|
||||
assert _coerce_value(None, 'boolean') is None
|
||||
|
||||
def test_string_to_integer(self):
|
||||
assert _coerce_value('120', 'integer') == 120
|
||||
assert _coerce_value('0', 'integer') == 0
|
||||
assert _coerce_value('-5', 'integer') == -5
|
||||
|
||||
def test_integer_passthrough(self):
|
||||
assert _coerce_value(42, 'integer') == 42
|
||||
|
||||
def test_string_to_float(self):
|
||||
assert _coerce_value('3.14', 'number') == 3.14
|
||||
assert _coerce_value('3.14', 'float') == 3.14
|
||||
|
||||
def test_int_to_float(self):
|
||||
assert _coerce_value(3, 'number') == 3.0
|
||||
assert isinstance(_coerce_value(3, 'number'), float)
|
||||
|
||||
def test_float_passthrough(self):
|
||||
assert _coerce_value(3.14, 'float') == 3.14
|
||||
|
||||
def test_string_to_bool(self):
|
||||
assert _coerce_value('true', 'boolean') is True
|
||||
assert _coerce_value('True', 'boolean') is True
|
||||
assert _coerce_value('false', 'boolean') is False
|
||||
assert _coerce_value('False', 'boolean') is False
|
||||
|
||||
def test_bool_passthrough(self):
|
||||
assert _coerce_value(True, 'boolean') is True
|
||||
assert _coerce_value(False, 'boolean') is False
|
||||
|
||||
def test_invalid_bool_string_raises(self):
|
||||
with pytest.raises(ValueError):
|
||||
_coerce_value('notabool', 'boolean')
|
||||
|
||||
def test_unknown_type_passthrough(self):
|
||||
assert _coerce_value('hello', 'string') == 'hello'
|
||||
assert _coerce_value('hello', 'unknown') == 'hello'
|
||||
|
||||
def test_invalid_integer_raises(self):
|
||||
with pytest.raises(ValueError):
|
||||
_coerce_value('abc', 'integer')
|
||||
|
||||
|
||||
class TestCoercePipelineConfig:
|
||||
"""Tests for coerce_pipeline_config function"""
|
||||
|
||||
def _make_meta(self, section_name: str, stage_name: str, fields: list[dict]) -> dict:
|
||||
return {
|
||||
'name': section_name,
|
||||
'stages': [{'name': stage_name, 'config': fields}],
|
||||
}
|
||||
|
||||
def test_coerce_integer_in_config(self):
|
||||
config = {'trigger': {'misc': {'timeout': '120'}}}
|
||||
meta = self._make_meta('trigger', 'misc', [{'name': 'timeout', 'type': 'integer'}])
|
||||
coerce_pipeline_config(config, meta)
|
||||
assert config['trigger']['misc']['timeout'] == 120
|
||||
|
||||
def test_coerce_boolean_in_config(self):
|
||||
config = {'output': {'misc': {'at-sender': 'true'}}}
|
||||
meta = self._make_meta('output', 'misc', [{'name': 'at-sender', 'type': 'boolean'}])
|
||||
coerce_pipeline_config(config, meta)
|
||||
assert config['output']['misc']['at-sender'] is True
|
||||
|
||||
def test_missing_section_skipped(self):
|
||||
config = {'ai': {}}
|
||||
meta = self._make_meta('trigger', 'misc', [{'name': 'x', 'type': 'integer'}])
|
||||
coerce_pipeline_config(config, meta) # should not raise
|
||||
|
||||
def test_missing_field_skipped(self):
|
||||
config = {'trigger': {'misc': {}}}
|
||||
meta = self._make_meta('trigger', 'misc', [{'name': 'nonexistent', 'type': 'integer'}])
|
||||
coerce_pipeline_config(config, meta) # should not raise
|
||||
|
||||
def test_invalid_value_logs_warning(self, caplog):
|
||||
config = {'trigger': {'misc': {'timeout': 'abc'}}}
|
||||
meta = self._make_meta('trigger', 'misc', [{'name': 'timeout', 'type': 'integer'}])
|
||||
import logging
|
||||
|
||||
with caplog.at_level(logging.WARNING):
|
||||
coerce_pipeline_config(config, meta)
|
||||
assert config['trigger']['misc']['timeout'] == 'abc' # unchanged
|
||||
assert 'Failed to coerce' in caplog.text
|
||||
|
||||
def test_empty_metadata(self):
|
||||
config = {'trigger': {'misc': {'timeout': '120'}}}
|
||||
coerce_pipeline_config(config) # no metadata args, should not raise
|
||||
|
||||
def test_multiple_metadata(self):
|
||||
config = {
|
||||
'trigger': {'misc': {'timeout': '120'}},
|
||||
'output': {'misc': {'at-sender': 'false'}},
|
||||
}
|
||||
meta_trigger = self._make_meta('trigger', 'misc', [{'name': 'timeout', 'type': 'integer'}])
|
||||
meta_output = self._make_meta('output', 'misc', [{'name': 'at-sender', 'type': 'boolean'}])
|
||||
coerce_pipeline_config(config, meta_trigger, meta_output)
|
||||
assert config['trigger']['misc']['timeout'] == 120
|
||||
assert config['output']['misc']['at-sender'] is False
|
||||
517
uv.lock
generated
517
uv.lock
generated
@@ -1,5 +1,5 @@
|
||||
version = 1
|
||||
revision = 2
|
||||
revision = 3
|
||||
requires-python = ">=3.11, <4.0"
|
||||
resolution-markers = [
|
||||
"python_full_version >= '3.14' and sys_platform == 'win32'",
|
||||
@@ -964,6 +964,30 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/58/6b3d24e6b9bc474a2dcdee65dfd1f008867015408a271562e4b690561a4d/cryptography-46.0.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:8456928655f856c6e1533ff59d5be76578a7157224dbd9ce6872f25055ab9ab7", size = 3407605, upload-time = "2026-02-10T19:18:29.233Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cuda-bindings"
|
||||
version = "12.9.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "cuda-pathfinder", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" },
|
||||
]
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/45/e7/b47792cc2d01c7e1d37c32402182524774dadd2d26339bd224e0e913832e/cuda_bindings-12.9.4-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c912a3d9e6b6651853eed8eed96d6800d69c08e94052c292fec3f282c5a817c9", size = 12210593, upload-time = "2025-10-21T14:51:36.574Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/c1/dabe88f52c3e3760d861401bb994df08f672ec893b8f7592dc91626adcf3/cuda_bindings-12.9.4-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fda147a344e8eaeca0c6ff113d2851ffca8f7dfc0a6c932374ee5c47caa649c8", size = 12151019, upload-time = "2025-10-21T14:51:43.167Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/56/e465c31dc9111be3441a9ba7df1941fe98f4aa6e71e8788a3fb4534ce24d/cuda_bindings-12.9.4-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:32bdc5a76906be4c61eb98f546a6786c5773a881f3b166486449b5d141e4a39f", size = 11906628, upload-time = "2025-10-21T14:51:49.905Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/84/1e6be415e37478070aeeee5884c2022713c1ecc735e6d82d744de0252eee/cuda_bindings-12.9.4-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:56e0043c457a99ac473ddc926fe0dc4046694d99caef633e92601ab52cbe17eb", size = 11925991, upload-time = "2025-10-21T14:51:56.535Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/af/6dfd8f2ed90b1d4719bc053ff8940e494640fe4212dc3dd72f383e4992da/cuda_bindings-12.9.4-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8b72ee72a9cc1b531db31eebaaee5c69a8ec3500e32c6933f2d3b15297b53686", size = 11922703, upload-time = "2025-10-21T14:52:03.585Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/19/90ac264acc00f6df8a49378eedec9fd2db3061bf9263bf9f39fd3d8377c3/cuda_bindings-12.9.4-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d80bffc357df9988dca279734bc9674c3934a654cab10cadeed27ce17d8635ee", size = 11924658, upload-time = "2025-10-21T14:52:10.411Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cuda-pathfinder"
|
||||
version = "1.4.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/07/02/59a5bc738a09def0b49aea0e460bdf97f65206d0d041246147cf6207e69c/cuda_pathfinder-1.4.1-py3-none-any.whl", hash = "sha256:40793006082de88e0950753655e55558a446bed9a7d9d0bcb48b2506d50ed82a", size = 43903, upload-time = "2026-03-06T21:05:24.372Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dashscope"
|
||||
version = "1.25.10"
|
||||
@@ -1088,7 +1112,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "flask"
|
||||
version = "3.1.2"
|
||||
version = "3.1.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "blinker" },
|
||||
@@ -1098,9 +1122,9 @@ dependencies = [
|
||||
{ name = "markupsafe" },
|
||||
{ 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 = [
|
||||
{ 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]]
|
||||
@@ -1729,6 +1753,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/14/2f/967ba146e6d58cf6a652da73885f52fc68001525b4197effc174321d70b4/jmespath-1.1.0-py3-none-any.whl", hash = "sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64", size = 20419, upload-time = "2026-01-22T16:35:24.919Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "joblib"
|
||||
version = "1.5.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/41/f2/d34e8b3a08a9cc79a50b2208a93dce981fe615b64d5a4d4abee421d898df/joblib-1.5.3.tar.gz", hash = "sha256:8561a3269e6801106863fd0d6d84bb737be9e7631e33aaed3fb9ce5953688da3", size = 331603, upload-time = "2025-12-15T08:41:46.427Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/91/984aca2ec129e2757d1e4e3c81c3fcda9d0f85b74670a094cc443d9ee949/joblib-1.5.3-py3-none-any.whl", hash = "sha256:5fc3c5039fc5ca8c0276333a188bbd59d6b7ab37fe6632daa76bc7f9ec18e713", size = 309071, upload-time = "2025-12-15T08:41:44.973Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jsonpatch"
|
||||
version = "1.33"
|
||||
@@ -1799,7 +1832,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "langbot"
|
||||
version = "4.8.7"
|
||||
version = "4.9.1"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "aiocqhttp" },
|
||||
@@ -1895,7 +1928,7 @@ requires-dist = [
|
||||
{ name = "botocore", specifier = ">=1.42.39" },
|
||||
{ name = "certifi", specifier = ">=2025.4.26" },
|
||||
{ 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 = "cryptography", specifier = ">=44.0.3" },
|
||||
{ name = "dashscope", specifier = ">=1.25.10" },
|
||||
@@ -1904,7 +1937,7 @@ requires-dist = [
|
||||
{ name = "ebooklib", specifier = ">=0.18" },
|
||||
{ name = "gewechat-client", specifier = ">=0.1.5" },
|
||||
{ name = "html2text", specifier = ">=2024.2.26" },
|
||||
{ name = "langbot-plugin", specifier = "==0.2.7" },
|
||||
{ name = "langbot-plugin", specifier = "==0.3.1" },
|
||||
{ name = "langchain", specifier = ">=0.2.0" },
|
||||
{ name = "langchain-text-splitters", specifier = ">=0.0.1" },
|
||||
{ name = "lark-oapi", specifier = ">=1.4.15" },
|
||||
@@ -1927,7 +1960,7 @@ requires-dist = [
|
||||
{ name = "pymilvus", specifier = ">=2.6.4" },
|
||||
{ name = "pynacl", specifier = ">=1.5.0" },
|
||||
{ name = "pypdf2", specifier = ">=3.0.1" },
|
||||
{ name = "pyseekdb", specifier = "==1.0.0b7" },
|
||||
{ name = "pyseekdb", specifier = "==1.1.0.post3" },
|
||||
{ name = "python-docx", specifier = ">=1.1.0" },
|
||||
{ name = "python-socks", specifier = ">=2.7.1" },
|
||||
{ name = "python-telegram-bot", specifier = ">=22.0" },
|
||||
@@ -1960,7 +1993,7 @@ dev = [
|
||||
|
||||
[[package]]
|
||||
name = "langbot-plugin"
|
||||
version = "0.2.7"
|
||||
version = "0.3.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "aiofiles" },
|
||||
@@ -1978,28 +2011,28 @@ dependencies = [
|
||||
{ name = "watchdog" },
|
||||
{ name = "websockets" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9e/a0/babd76596e5de38149da67b8da20e0519cc5f10080de9dc2b16919486f29/langbot_plugin-0.2.7.tar.gz", hash = "sha256:5c8ad1820283901a33356f79a56c84b4744712a463e1c7aecc6e9defe4db4446", size = 162458, upload-time = "2026-02-25T06:00:52.512Z" }
|
||||
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 = [
|
||||
{ url = "https://files.pythonhosted.org/packages/32/2a/6575cf5d5babb7a9400a8aca243e4b8341d83b673e5e9c0394c0393f1c3e/langbot_plugin-0.2.7-py3-none-any.whl", hash = "sha256:17344e61537a5bb97fc77cd83812b5db926f29005e92fefbcbaca5bb47bf55f0", size = 133476, upload-time = "2026-02-25T06:00:50.988Z" },
|
||||
{ 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]]
|
||||
name = "langchain"
|
||||
version = "1.2.7"
|
||||
version = "1.2.12"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "langchain-core" },
|
||||
{ name = "langgraph" },
|
||||
{ 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 = [
|
||||
{ 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]]
|
||||
name = "langchain-core"
|
||||
version = "1.2.7"
|
||||
version = "1.2.18"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "jsonpatch" },
|
||||
@@ -2011,9 +2044,9 @@ dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
{ 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 = [
|
||||
{ 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]]
|
||||
@@ -2030,7 +2063,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "langgraph"
|
||||
version = "1.0.7"
|
||||
version = "1.1.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "langchain-core" },
|
||||
@@ -2040,9 +2073,9 @@ dependencies = [
|
||||
{ name = "pydantic" },
|
||||
{ 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 = [
|
||||
{ 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]]
|
||||
@@ -2060,15 +2093,15 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "langgraph-prebuilt"
|
||||
version = "1.0.7"
|
||||
version = "1.0.8"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "langchain-core" },
|
||||
{ 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 = [
|
||||
{ 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]]
|
||||
@@ -2816,6 +2849,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/67/5c9c8f1ba4a599e35a77ca7e0a0210ab6cd732f719bc3b0fc95c69aaca10/nakuru_project_idk-0.0.2.1-py3-none-any.whl", hash = "sha256:bddd8af8a46ef381bd05b806d6c07bd8ba407c58b47ce6148d750bd77c4420bc", size = 24281, upload-time = "2023-05-07T15:00:25.094Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "networkx"
|
||||
version = "3.6.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/6a/51/63fe664f3908c97be9d2e4f1158eb633317598cfa6e1fc14af5383f17512/networkx-3.6.1.tar.gz", hash = "sha256:26b7c357accc0c8cde558ad486283728b65b6a95d85ee1cd66bafab4c8168509", size = 2517025, upload-time = "2025-12-08T17:02:39.908Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/9e/c9/b2622292ea83fbb4ec318f5b9ab867d0a28ab43c5717bb85b0a5f6b3b0a4/networkx-3.6.1-py3-none-any.whl", hash = "sha256:d47fbf302e7d9cbbb9e2555a0d267983d2aa476bac30e90dfbe5669bd57f3762", size = 2068504, upload-time = "2025-12-08T17:02:38.159Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nodeenv"
|
||||
version = "1.10.0"
|
||||
@@ -2904,6 +2946,140 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/de/e5/b7d20451657664b07986c2f6e3be564433f5dcaf3482d68eaecd79afaf03/numpy-2.4.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:be71bf1edb48ebbbf7f6337b5bfd2f895d1902f6335a5830b20141fc126ffba0", size = 12502577, upload-time = "2026-01-31T23:13:07.08Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nvidia-cublas-cu12"
|
||||
version = "12.8.4.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/61/e24b560ab2e2eaeb3c839129175fb330dfcfc29e5203196e5541a4c44682/nvidia_cublas_cu12-12.8.4.1-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:8ac4e771d5a348c551b2a426eda6193c19aa630236b418086020df5ba9667142", size = 594346921, upload-time = "2025-03-07T01:44:31.254Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nvidia-cuda-cupti-cu12"
|
||||
version = "12.8.90"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/02/2adcaa145158bf1a8295d83591d22e4103dbfd821bcaf6f3f53151ca4ffa/nvidia_cuda_cupti_cu12-12.8.90-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ea0cb07ebda26bb9b29ba82cda34849e73c166c18162d3913575b0c9db9a6182", size = 10248621, upload-time = "2025-03-07T01:40:21.213Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nvidia-cuda-nvrtc-cu12"
|
||||
version = "12.8.93"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/05/6b/32f747947df2da6994e999492ab306a903659555dddc0fbdeb9d71f75e52/nvidia_cuda_nvrtc_cu12-12.8.93-py3-none-manylinux2010_x86_64.manylinux_2_12_x86_64.whl", hash = "sha256:a7756528852ef889772a84c6cd89d41dfa74667e24cca16bb31f8f061e3e9994", size = 88040029, upload-time = "2025-03-07T01:42:13.562Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nvidia-cuda-runtime-cu12"
|
||||
version = "12.8.90"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/9b/a997b638fcd068ad6e4d53b8551a7d30fe8b404d6f1804abf1df69838932/nvidia_cuda_runtime_cu12-12.8.90-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:adade8dcbd0edf427b7204d480d6066d33902cab2a4707dcfc48a2d0fd44ab90", size = 954765, upload-time = "2025-03-07T01:40:01.615Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nvidia-cudnn-cu12"
|
||||
version = "9.10.2.21"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "nvidia-cublas-cu12", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" },
|
||||
]
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/51/e123d997aa098c61d029f76663dedbfb9bc8dcf8c60cbd6adbe42f76d049/nvidia_cudnn_cu12-9.10.2.21-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:949452be657fa16687d0930933f032835951ef0892b37d2d53824d1a84dc97a8", size = 706758467, upload-time = "2025-06-06T21:54:08.597Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nvidia-cufft-cu12"
|
||||
version = "11.3.3.83"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "nvidia-nvjitlink-cu12", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" },
|
||||
]
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/13/ee4e00f30e676b66ae65b4f08cb5bcbb8392c03f54f2d5413ea99a5d1c80/nvidia_cufft_cu12-11.3.3.83-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d2dd21ec0b88cf61b62e6b43564355e5222e4a3fb394cac0db101f2dd0d4f74", size = 193118695, upload-time = "2025-03-07T01:45:27.821Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nvidia-cufile-cu12"
|
||||
version = "1.13.1.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/fe/1bcba1dfbfb8d01be8d93f07bfc502c93fa23afa6fd5ab3fc7c1df71038a/nvidia_cufile_cu12-1.13.1.3-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1d069003be650e131b21c932ec3d8969c1715379251f8d23a1860554b1cb24fc", size = 1197834, upload-time = "2025-03-07T01:45:50.723Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nvidia-curand-cu12"
|
||||
version = "10.3.9.90"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/aa/6584b56dc84ebe9cf93226a5cde4d99080c8e90ab40f0c27bda7a0f29aa1/nvidia_curand_cu12-10.3.9.90-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:b32331d4f4df5d6eefa0554c565b626c7216f87a06a4f56fab27c3b68a830ec9", size = 63619976, upload-time = "2025-03-07T01:46:23.323Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nvidia-cusolver-cu12"
|
||||
version = "11.7.3.90"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "nvidia-cublas-cu12", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" },
|
||||
{ name = "nvidia-cusparse-cu12", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" },
|
||||
{ name = "nvidia-nvjitlink-cu12", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" },
|
||||
]
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/85/48/9a13d2975803e8cf2777d5ed57b87a0b6ca2cc795f9a4f59796a910bfb80/nvidia_cusolver_cu12-11.7.3.90-py3-none-manylinux_2_27_x86_64.whl", hash = "sha256:4376c11ad263152bd50ea295c05370360776f8c3427b30991df774f9fb26c450", size = 267506905, upload-time = "2025-03-07T01:47:16.273Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nvidia-cusparse-cu12"
|
||||
version = "12.5.8.93"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "nvidia-nvjitlink-cu12", marker = "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'" },
|
||||
]
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/f5/e1854cb2f2bcd4280c44736c93550cc300ff4b8c95ebe370d0aa7d2b473d/nvidia_cusparse_cu12-12.5.8.93-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1ec05d76bbbd8b61b06a80e1eaf8cf4959c3d4ce8e711b65ebd0443bb0ebb13b", size = 288216466, upload-time = "2025-03-07T01:48:13.779Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nvidia-cusparselt-cu12"
|
||||
version = "0.7.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/56/79/12978b96bd44274fe38b5dde5cfb660b1d114f70a65ef962bcbbed99b549/nvidia_cusparselt_cu12-0.7.1-py3-none-manylinux2014_x86_64.whl", hash = "sha256:f1bb701d6b930d5a7cea44c19ceb973311500847f81b634d802b7b539dc55623", size = 287193691, upload-time = "2025-02-26T00:15:44.104Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nvidia-nccl-cu12"
|
||||
version = "2.27.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/89/f7a07dc961b60645dbbf42e80f2bc85ade7feb9a491b11a1e973aa00071f/nvidia_nccl_cu12-2.27.5-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ad730cf15cb5d25fe849c6e6ca9eb5b76db16a80f13f425ac68d8e2e55624457", size = 322348229, upload-time = "2025-06-26T04:11:28.385Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nvidia-nvjitlink-cu12"
|
||||
version = "12.8.93"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/74/86a07f1d0f42998ca31312f998bd3b9a7eff7f52378f4f270c8679c77fb9/nvidia_nvjitlink_cu12-12.8.93-py3-none-manylinux2010_x86_64.manylinux_2_12_x86_64.whl", hash = "sha256:81ff63371a7ebd6e6451970684f916be2eab07321b73c9d244dc2b4da7f73b88", size = 39254836, upload-time = "2025-03-07T01:49:55.661Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nvidia-nvshmem-cu12"
|
||||
version = "3.4.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/09/6ea3ea725f82e1e76684f0708bbedd871fc96da89945adeba65c3835a64c/nvidia_nvshmem_cu12-3.4.5-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:042f2500f24c021db8a06c5eec2539027d57460e1c1a762055a6554f72c369bd", size = 139103095, upload-time = "2025-09-06T00:32:31.266Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nvidia-nvtx-cu12"
|
||||
version = "12.8.90"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/eb/86626c1bbc2edb86323022371c39aa48df6fd8b0a1647bc274577f72e90b/nvidia_nvtx_cu12-12.8.90-py3-none-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5b17e2001cc0d751a5bc2c6ec6d26ad95913324a4adb86788c944f8ce9ba441f", size = 89954, upload-time = "2025-03-07T01:42:44.131Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "oauthlib"
|
||||
version = "3.3.1"
|
||||
@@ -3924,12 +4100,16 @@ name = "pylibseekdb"
|
||||
version = "1.1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/b8/c226744a7a1da9295725920a36867ee5665f2617972c7881d5ed4cbd45c8/pylibseekdb-1.1.0-cp311-cp311-macosx_15_0_arm64.whl", hash = "sha256:0a0ad03d87f1db1a7087ba89e398ce1ee00496e977d38c493104d0d517590968", size = 148743770, upload-time = "2026-01-30T05:26:14.275Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/51/4d/57151735afc29039f4ed680256012a33dd719ba3fd84d7c33a9bd260fc8a/pylibseekdb-1.1.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:e272bee013aabab152c4795676b3b0ba1107a8058f29a07d2a803168faea090c", size = 147132528, upload-time = "2026-01-30T03:40:10.878Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/d7/5583fbf27e89952cda52bb9b1919229bd652d02aafac156758ac862c48e7/pylibseekdb-1.1.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:116a28356532705ed262e2a7951ac8221ae8c97ade866fdab2df521dcca62530", size = 170696822, upload-time = "2026-01-30T03:40:18.417Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/2b/150592287119f80cff9b025d59879a561a0cca80e71cecbf74a41af6220b/pylibseekdb-1.1.0-cp312-cp312-macosx_15_0_arm64.whl", hash = "sha256:d6ae33353e833cb56a7ce2cdb0305b872cdac9467eb79c277f82479c529b38ef", size = 148734111, upload-time = "2026-01-30T05:26:56.906Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/a3/b55087293115ecbe22313b40533fd67b0192c36e6bedb05aa7058a83a86a/pylibseekdb-1.1.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:9e2f8240b08a93e347d32534e7c394b7a151b67555a384eb88d73d4b0f8b9d14", size = 147137592, upload-time = "2026-01-30T03:40:26.087Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/04/31/c0979960d790621dec277f64b5d6c70932f8bb9adb59029d7b481cfe9c30/pylibseekdb-1.1.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:4d8615471bac39b1980951cbce0d742fa7bec676f28eb95f4db687fdd1e9c71b", size = 170681044, upload-time = "2026-01-30T03:40:34.276Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/7d/8acbf3eca93905c1b13b015a9e02b426fc69c10e7c162be96b35a2b1c7a4/pylibseekdb-1.1.0-cp313-cp313-macosx_15_0_arm64.whl", hash = "sha256:d5688a0fe6fc703e5a707cbe0e139d570f1d34daff1491304d6b43154f2e12d9", size = 148743750, upload-time = "2026-01-30T05:27:39.832Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/24/7f510ad13ad129a691fa965dc5bce874320b682674cbf12fc2e35310719b/pylibseekdb-1.1.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:1e53d171246239bd526d1a1f9b3abef1ad9b10597bc1c0a2acf7e65afbd7d844", size = 147136041, upload-time = "2026-01-30T03:40:41.782Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/eb/c5988e1ad72233a920f4e444d8d866c42363220b340d78a7525307922f35/pylibseekdb-1.1.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:66d01ee9c0ad4a2e88ea2420f9c4d1ee9bb011b70c553a654c8a4e230e920ad7", size = 170684140, upload-time = "2026-01-30T03:40:49.351Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/6f/b4a619c3a1b937fb080aa977b1d4011a1e587255707d54856188e5359a4c/pylibseekdb-1.1.0-cp314-cp314-macosx_15_0_arm64.whl", hash = "sha256:11d2fbc98dcb8ec97257b949184dc09d9ba693811e77457bba9c8f80d282c265", size = 148745880, upload-time = "2026-01-30T05:38:26.631Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/94/534359608571d08825ac21e709aa680b559989c905f99e273d82d5b17db2/pylibseekdb-1.1.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:ff05ac4bb13a4b5f9dd03771ded866beed72562ea497f68a4ae897c226afc446", size = 147132460, upload-time = "2026-01-30T03:40:56.684Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/5e/7588a06918ac145fb69e57ae372b72d6fc713b9263c29eb7268f8a4edbef/pylibseekdb-1.1.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:065158b79192cce7635995a7599e99b21a3ff729cd6f68e31a65ed62f830bd3a", size = 170677921, upload-time = "2026-01-30T03:41:03.783Z" },
|
||||
]
|
||||
@@ -4043,20 +4223,21 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "pyseekdb"
|
||||
version = "1.0.0b7"
|
||||
version = "1.1.0.post3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "httpx" },
|
||||
{ name = "httpx", marker = "python_full_version < '3.14'" },
|
||||
{ name = "numpy" },
|
||||
{ name = "onnxruntime" },
|
||||
{ name = "pylibseekdb", marker = "sys_platform == 'linux'" },
|
||||
{ name = "onnxruntime", marker = "python_full_version < '3.14'" },
|
||||
{ name = "pylibseekdb", marker = "(platform_machine == 'arm64' and sys_platform == 'darwin') or sys_platform == 'linux'" },
|
||||
{ name = "pymysql" },
|
||||
{ name = "sentence-transformers", marker = "python_full_version >= '3.14'" },
|
||||
{ name = "tenacity" },
|
||||
{ name = "tokenizers" },
|
||||
{ name = "tqdm" },
|
||||
{ name = "tokenizers", marker = "python_full_version < '3.14'" },
|
||||
{ name = "tqdm", marker = "python_full_version < '3.14'" },
|
||||
]
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/92/6a/a0d4728de90e028a60a3583e6e96579087f0cf793e705ea7898a1490541c/pyseekdb-1.0.0b7-py3-none-any.whl", hash = "sha256:e32920636c345bc73adf03040f9bcb1ecc420d652cedae1558999cce19a67d52", size = 60927, upload-time = "2025-12-29T13:19:04.669Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/58/6e/2373239ab80c35a17aa14e8219727f06567e91d3b7f1b8c36d28ce94d04b/pyseekdb-1.1.0.post3-py3-none-any.whl", hash = "sha256:0437c9a4de72be44eb24b070b2b8099086467c08af10a57191498a61257a4bfb", size = 110985, upload-time = "2026-02-12T14:19:05.402Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4636,6 +4817,168 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/fc/51/727abb13f44c1fcf6d145979e1535a35794db0f6e450a0cb46aa24732fe2/s3transfer-0.16.0-py3-none-any.whl", hash = "sha256:18e25d66fed509e3868dc1572b3f427ff947dd2c56f844a5bf09481ad3f3b2fe", size = 86830, upload-time = "2025-12-01T02:30:57.729Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "safetensors"
|
||||
version = "0.7.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/29/9c/6e74567782559a63bd040a236edca26fd71bc7ba88de2ef35d75df3bca5e/safetensors-0.7.0.tar.gz", hash = "sha256:07663963b67e8bd9f0b8ad15bb9163606cd27cc5a1b96235a50d8369803b96b0", size = 200878, upload-time = "2025-11-19T15:18:43.199Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/47/aef6c06649039accf914afef490268e1067ed82be62bcfa5b7e886ad15e8/safetensors-0.7.0-cp38-abi3-macosx_10_12_x86_64.whl", hash = "sha256:c82f4d474cf725255d9e6acf17252991c3c8aac038d6ef363a4bf8be2f6db517", size = 467781, upload-time = "2025-11-19T15:18:35.84Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/00/374c0c068e30cd31f1e1b46b4b5738168ec79e7689ca82ee93ddfea05109/safetensors-0.7.0-cp38-abi3-macosx_11_0_arm64.whl", hash = "sha256:94fd4858284736bb67a897a41608b5b0c2496c9bdb3bf2af1fa3409127f20d57", size = 447058, upload-time = "2025-11-19T15:18:34.416Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/06/578ffed52c2296f93d7fd2d844cabfa92be51a587c38c8afbb8ae449ca89/safetensors-0.7.0-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e07d91d0c92a31200f25351f4acb2bc6aff7f48094e13ebb1d0fb995b54b6542", size = 491748, upload-time = "2025-11-19T15:18:09.79Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/33/1debbbb70e4791dde185edb9413d1fe01619255abb64b300157d7f15dddd/safetensors-0.7.0-cp38-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8469155f4cb518bafb4acf4865e8bb9d6804110d2d9bdcaa78564b9fd841e104", size = 503881, upload-time = "2025-11-19T15:18:16.145Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/1c/40c2ca924d60792c3be509833df711b553c60effbd91da6f5284a83f7122/safetensors-0.7.0-cp38-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:54bef08bf00a2bff599982f6b08e8770e09cc012d7bba00783fc7ea38f1fb37d", size = 623463, upload-time = "2025-11-19T15:18:21.11Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9b/3a/13784a9364bd43b0d61eef4bea2845039bc2030458b16594a1bd787ae26e/safetensors-0.7.0-cp38-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:42cb091236206bb2016d245c377ed383aa7f78691748f3bb6ee1bfa51ae2ce6a", size = 532855, upload-time = "2025-11-19T15:18:25.719Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/60/429e9b1cb3fc651937727befe258ea24122d9663e4d5709a48c9cbfceecb/safetensors-0.7.0-cp38-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dac7252938f0696ddea46f5e855dd3138444e82236e3be475f54929f0c510d48", size = 507152, upload-time = "2025-11-19T15:18:33.023Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/a8/4b45e4e059270d17af60359713ffd83f97900d45a6afa73aaa0d737d48b6/safetensors-0.7.0-cp38-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1d060c70284127fa805085d8f10fbd0962792aed71879d00864acda69dbab981", size = 541856, upload-time = "2025-11-19T15:18:31.075Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/87/d26d8407c44175d8ae164a95b5a62707fcc445f3c0c56108e37d98070a3d/safetensors-0.7.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:cdab83a366799fa730f90a4ebb563e494f28e9e92c4819e556152ad55e43591b", size = 674060, upload-time = "2025-11-19T15:18:37.211Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/f5/57644a2ff08dc6325816ba7217e5095f17269dada2554b658442c66aed51/safetensors-0.7.0-cp38-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:672132907fcad9f2aedcb705b2d7b3b93354a2aec1b2f706c4db852abe338f85", size = 771715, upload-time = "2025-11-19T15:18:38.689Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/31/17883e13a814bd278ae6e266b13282a01049b0c81341da7fd0e3e71a80a3/safetensors-0.7.0-cp38-abi3-musllinux_1_2_i686.whl", hash = "sha256:5d72abdb8a4d56d4020713724ba81dac065fedb7f3667151c4a637f1d3fb26c0", size = 714377, upload-time = "2025-11-19T15:18:40.162Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/d8/0c8a7dc9b41dcac53c4cbf9df2b9c83e0e0097203de8b37a712b345c0be5/safetensors-0.7.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:b0f6d66c1c538d5a94a73aa9ddca8ccc4227e6c9ff555322ea40bdd142391dd4", size = 677368, upload-time = "2025-11-19T15:18:41.627Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/e5/cb4b713c8a93469e3c5be7c3f8d77d307e65fe89673e731f5c2bfd0a9237/safetensors-0.7.0-cp38-abi3-win32.whl", hash = "sha256:c74af94bf3ac15ac4d0f2a7c7b4663a15f8c2ab15ed0fc7531ca61d0835eccba", size = 326423, upload-time = "2025-11-19T15:18:45.74Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/e6/ec8471c8072382cb91233ba7267fd931219753bb43814cbc71757bfd4dab/safetensors-0.7.0-cp38-abi3-win_amd64.whl", hash = "sha256:d1239932053f56f3456f32eb9625590cc7582e905021f94636202a864d470755", size = 341380, upload-time = "2025-11-19T15:18:44.427Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "scikit-learn"
|
||||
version = "1.8.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "joblib", marker = "python_full_version >= '3.14'" },
|
||||
{ name = "numpy", marker = "python_full_version >= '3.14'" },
|
||||
{ name = "scipy", marker = "python_full_version >= '3.14'" },
|
||||
{ name = "threadpoolctl", marker = "python_full_version >= '3.14'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/0e/d4/40988bf3b8e34feec1d0e6a051446b1f66225f8529b9309becaeef62b6c4/scikit_learn-1.8.0.tar.gz", hash = "sha256:9bccbb3b40e3de10351f8f5068e105d0f4083b1a65fa07b6634fbc401a6287fd", size = 7335585, upload-time = "2025-12-10T07:08:53.618Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/92/53ea2181da8ac6bf27170191028aee7251f8f841f8d3edbfdcaf2008fde9/scikit_learn-1.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:146b4d36f800c013d267b29168813f7a03a43ecd2895d04861f1240b564421da", size = 8595835, upload-time = "2025-12-10T07:07:39.385Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/01/18/d154dc1638803adf987910cdd07097d9c526663a55666a97c124d09fb96a/scikit_learn-1.8.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:f984ca4b14914e6b4094c5d52a32ea16b49832c03bd17a110f004db3c223e8e1", size = 8080381, upload-time = "2025-12-10T07:07:41.93Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/44/226142fcb7b7101e64fdee5f49dbe6288d4c7af8abf593237b70fca080a4/scikit_learn-1.8.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5e30adb87f0cc81c7690a84f7932dd66be5bac57cfe16b91cb9151683a4a2d3b", size = 8799632, upload-time = "2025-12-10T07:07:43.899Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/4d/4a67f30778a45d542bbea5db2dbfa1e9e100bf9ba64aefe34215ba9f11f6/scikit_learn-1.8.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ada8121bcb4dac28d930febc791a69f7cb1673c8495e5eee274190b73a4559c1", size = 9103788, upload-time = "2025-12-10T07:07:45.982Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/3c/45c352094cfa60050bcbb967b1faf246b22e93cb459f2f907b600f2ceda5/scikit_learn-1.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:c57b1b610bd1f40ba43970e11ce62821c2e6569e4d74023db19c6b26f246cb3b", size = 8081706, upload-time = "2025-12-10T07:07:48.111Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/46/5416595bb395757f754feb20c3d776553a386b661658fb21b7c814e89efe/scikit_learn-1.8.0-cp311-cp311-win_arm64.whl", hash = "sha256:2838551e011a64e3053ad7618dda9310175f7515f1742fa2d756f7c874c05961", size = 7688451, upload-time = "2025-12-10T07:07:49.873Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/74/e6a7cc4b820e95cc38cf36cd74d5aa2b42e8ffc2d21fe5a9a9c45c1c7630/scikit_learn-1.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5fb63362b5a7ddab88e52b6dbb47dac3fd7dafeee740dc6c8d8a446ddedade8e", size = 8548242, upload-time = "2025-12-10T07:07:51.568Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/d8/9be608c6024d021041c7f0b3928d4749a706f4e2c3832bbede4fb4f58c95/scikit_learn-1.8.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:5025ce924beccb28298246e589c691fe1b8c1c96507e6d27d12c5fadd85bfd76", size = 8079075, upload-time = "2025-12-10T07:07:53.697Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/47/f187b4636ff80cc63f21cd40b7b2d177134acaa10f6bb73746130ee8c2e5/scikit_learn-1.8.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4496bb2cf7a43ce1a2d7524a79e40bc5da45cf598dbf9545b7e8316ccba47bb4", size = 8660492, upload-time = "2025-12-10T07:07:55.574Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/74/b7a304feb2b49df9fafa9382d4d09061a96ee9a9449a7cbea7988dda0828/scikit_learn-1.8.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a0bcfe4d0d14aec44921545fd2af2338c7471de9cb701f1da4c9d85906ab847a", size = 8931904, upload-time = "2025-12-10T07:07:57.666Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/c4/0ab22726a04ede56f689476b760f98f8f46607caecff993017ac1b64aa5d/scikit_learn-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:35c007dedb2ffe38fe3ee7d201ebac4a2deccd2408e8621d53067733e3c74809", size = 8019359, upload-time = "2025-12-10T07:07:59.838Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/90/344a67811cfd561d7335c1b96ca21455e7e472d281c3c279c4d3f2300236/scikit_learn-1.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:8c497fff237d7b4e07e9ef1a640887fa4fb765647f86fbe00f969ff6280ce2bb", size = 7641898, upload-time = "2025-12-10T07:08:01.36Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/03/aa/e22e0768512ce9255eba34775be2e85c2048da73da1193e841707f8f039c/scikit_learn-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0d6ae97234d5d7079dc0040990a6f7aeb97cb7fa7e8945f1999a429b23569e0a", size = 8513770, upload-time = "2025-12-10T07:08:03.251Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/58/37/31b83b2594105f61a381fc74ca19e8780ee923be2d496fcd8d2e1147bd99/scikit_learn-1.8.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:edec98c5e7c128328124a029bceb09eda2d526997780fef8d65e9a69eead963e", size = 8044458, upload-time = "2025-12-10T07:08:05.336Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/5a/3f1caed8765f33eabb723596666da4ebbf43d11e96550fb18bdec42b467b/scikit_learn-1.8.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:74b66d8689d52ed04c271e1329f0c61635bcaf5b926db9b12d58914cdc01fe57", size = 8610341, upload-time = "2025-12-10T07:08:07.732Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/38/cf/06896db3f71c75902a8e9943b444a56e727418f6b4b4a90c98c934f51ed4/scikit_learn-1.8.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8fdf95767f989b0cfedb85f7ed8ca215d4be728031f56ff5a519ee1e3276dc2e", size = 8900022, upload-time = "2025-12-10T07:08:09.862Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/f9/9b7563caf3ec8873e17a31401858efab6b39a882daf6c1bfa88879c0aa11/scikit_learn-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:2de443b9373b3b615aec1bb57f9baa6bb3a9bd093f1269ba95c17d870422b271", size = 7989409, upload-time = "2025-12-10T07:08:12.028Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/bd/1f4001503650e72c4f6009ac0c4413cb17d2d601cef6f71c0453da2732fc/scikit_learn-1.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:eddde82a035681427cbedded4e6eff5e57fa59216c2e3e90b10b19ab1d0a65c3", size = 7619760, upload-time = "2025-12-10T07:08:13.688Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/7d/a630359fc9dcc95496588c8d8e3245cc8fd81980251079bc09c70d41d951/scikit_learn-1.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:7cc267b6108f0a1499a734167282c00c4ebf61328566b55ef262d48e9849c735", size = 8826045, upload-time = "2025-12-10T07:08:15.215Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/56/a0c86f6930cfcd1c7054a2bc417e26960bb88d32444fe7f71d5c2cfae891/scikit_learn-1.8.0-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:fe1c011a640a9f0791146011dfd3c7d9669785f9fed2b2a5f9e207536cf5c2fd", size = 8420324, upload-time = "2025-12-10T07:08:17.561Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/46/1e/05962ea1cebc1cf3876667ecb14c283ef755bf409993c5946ade3b77e303/scikit_learn-1.8.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72358cce49465d140cc4e7792015bb1f0296a9742d5622c67e31399b75468b9e", size = 8680651, upload-time = "2025-12-10T07:08:19.952Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fe/56/a85473cd75f200c9759e3a5f0bcab2d116c92a8a02ee08ccd73b870f8bb4/scikit_learn-1.8.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:80832434a6cc114f5219211eec13dcbc16c2bac0e31ef64c6d346cde3cf054cb", size = 8925045, upload-time = "2025-12-10T07:08:22.11Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/b7/64d8cfa896c64435ae57f4917a548d7ac7a44762ff9802f75a79b77cb633/scikit_learn-1.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:ee787491dbfe082d9c3013f01f5991658b0f38aa8177e4cd4bf434c58f551702", size = 8507994, upload-time = "2025-12-10T07:08:23.943Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/37/e192ea709551799379958b4c4771ec507347027bb7c942662c7fbeba31cb/scikit_learn-1.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:bf97c10a3f5a7543f9b88cbf488d33d175e9146115a451ae34568597ba33dcde", size = 7869518, upload-time = "2025-12-10T07:08:25.71Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/24/05/1af2c186174cc92dcab2233f327336058c077d38f6fe2aceb08e6ab4d509/scikit_learn-1.8.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c22a2da7a198c28dd1a6e1136f19c830beab7fdca5b3e5c8bba8394f8a5c45b3", size = 8528667, upload-time = "2025-12-10T07:08:27.541Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/25/01c0af38fe969473fb292bba9dc2b8f9b451f3112ff242c647fee3d0dfe7/scikit_learn-1.8.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:6b595b07a03069a2b1740dc08c2299993850ea81cce4fe19b2421e0c970de6b7", size = 8066524, upload-time = "2025-12-10T07:08:29.822Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/ce/a0623350aa0b68647333940ee46fe45086c6060ec604874e38e9ab7d8e6c/scikit_learn-1.8.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:29ffc74089f3d5e87dfca4c2c8450f88bdc61b0fc6ed5d267f3988f19a1309f6", size = 8657133, upload-time = "2025-12-10T07:08:31.865Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/cb/861b41341d6f1245e6ca80b1c1a8c4dfce43255b03df034429089ca2a2c5/scikit_learn-1.8.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fb65db5d7531bccf3a4f6bec3462223bea71384e2cda41da0f10b7c292b9e7c4", size = 8923223, upload-time = "2025-12-10T07:08:34.166Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/18/a8def8f91b18cd1ba6e05dbe02540168cb24d47e8dcf69e8d00b7da42a08/scikit_learn-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:56079a99c20d230e873ea40753102102734c5953366972a71d5cb39a32bc40c6", size = 8096518, upload-time = "2025-12-10T07:08:36.339Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/77/482076a678458307f0deb44e29891d6022617b2a64c840c725495bee343f/scikit_learn-1.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:3bad7565bc9cf37ce19a7c0d107742b320c1285df7aab1a6e2d28780df167242", size = 7754546, upload-time = "2025-12-10T07:08:38.128Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/d1/ef294ca754826daa043b2a104e59960abfab4cf653891037d19dd5b6f3cf/scikit_learn-1.8.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:4511be56637e46c25721e83d1a9cea9614e7badc7040c4d573d75fbe257d6fd7", size = 8848305, upload-time = "2025-12-10T07:08:41.013Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/e2/b1f8b05138ee813b8e1a4149f2f0d289547e60851fd1bb268886915adbda/scikit_learn-1.8.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:a69525355a641bf8ef136a7fa447672fb54fe8d60cab5538d9eb7c6438543fb9", size = 8432257, upload-time = "2025-12-10T07:08:42.873Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/26/11/c32b2138a85dcb0c99f6afd13a70a951bfdff8a6ab42d8160522542fb647/scikit_learn-1.8.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c2656924ec73e5939c76ac4c8b026fc203b83d8900362eb2599d8aee80e4880f", size = 8678673, upload-time = "2025-12-10T07:08:45.362Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/57/51f2384575bdec454f4fe4e7a919d696c9ebce914590abf3e52d47607ab8/scikit_learn-1.8.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:15fc3b5d19cc2be65404786857f2e13c70c83dd4782676dd6814e3b89dc8f5b9", size = 8922467, upload-time = "2025-12-10T07:08:47.408Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/4d/748c9e2872637a57981a04adc038dacaa16ba8ca887b23e34953f0b3f742/scikit_learn-1.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:00d6f1d66fbcf4eba6e356e1420d33cc06c70a45bb1363cd6f6a8e4ebbbdece2", size = 8774395, upload-time = "2025-12-10T07:08:49.337Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/22/d7b2ebe4704a5e50790ba089d5c2ae308ab6bb852719e6c3bd4f04c3a363/scikit_learn-1.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:f28dd15c6bb0b66ba09728cf09fd8736c304be29409bd8445a080c1280619e8c", size = 8002647, upload-time = "2025-12-10T07:08:51.601Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "scipy"
|
||||
version = "1.17.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "numpy", marker = "python_full_version >= '3.14'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822, upload-time = "2026-02-23T00:26:24.851Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/df/75/b4ce781849931fef6fd529afa6b63711d5a733065722d0c3e2724af9e40a/scipy-1.17.1-cp311-cp311-macosx_10_14_x86_64.whl", hash = "sha256:1f95b894f13729334fb990162e911c9e5dc1ab390c58aa6cbecb389c5b5e28ec", size = 31613675, upload-time = "2026-02-23T00:16:00.13Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/58/bccc2861b305abdd1b8663d6130c0b3d7cc22e8d86663edbc8401bfd40d4/scipy-1.17.1-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:e18f12c6b0bc5a592ed23d3f7b891f68fd7f8241d69b7883769eb5d5dfb52696", size = 28162057, upload-time = "2026-02-23T00:16:09.456Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/ee/18146b7757ed4976276b9c9819108adbc73c5aad636e5353e20746b73069/scipy-1.17.1-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:a3472cfbca0a54177d0faa68f697d8ba4c80bbdc19908c3465556d9f7efce9ee", size = 20334032, upload-time = "2026-02-23T00:16:17.358Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/e6/cef1cf3557f0c54954198554a10016b6a03b2ec9e22a4e1df734936bd99c/scipy-1.17.1-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:766e0dc5a616d026a3a1cffa379af959671729083882f50307e18175797b3dfd", size = 22709533, upload-time = "2026-02-23T00:16:25.791Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/60/8804678875fc59362b0fb759ab3ecce1f09c10a735680318ac30da8cd76b/scipy-1.17.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:744b2bf3640d907b79f3fd7874efe432d1cf171ee721243e350f55234b4cec4c", size = 33062057, upload-time = "2026-02-23T00:16:36.931Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/09/7d/af933f0f6e0767995b4e2d705a0665e454d1c19402aa7e895de3951ebb04/scipy-1.17.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43af8d1f3bea642559019edfe64e9b11192a8978efbd1539d7bc2aaa23d92de4", size = 35349300, upload-time = "2026-02-23T00:16:49.108Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/3d/7ccbbdcbb54c8fdc20d3b6930137c782a163fa626f0aef920349873421ba/scipy-1.17.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:cd96a1898c0a47be4520327e01f874acfd61fb48a9420f8aa9f6483412ffa444", size = 35127333, upload-time = "2026-02-23T00:17:01.293Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e8/19/f926cb11c42b15ba08e3a71e376d816ac08614f769b4f47e06c3580c836a/scipy-1.17.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4eb6c25dd62ee8d5edf68a8e1c171dd71c292fdae95d8aeb3dd7d7de4c364082", size = 37741314, upload-time = "2026-02-23T00:17:12.576Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/da/0d1df507cf574b3f224ccc3d45244c9a1d732c81dcb26b1e8a766ae271a8/scipy-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:d30e57c72013c2a4fe441c2fcb8e77b14e152ad48b5464858e07e2ad9fbfceff", size = 36607512, upload-time = "2026-02-23T00:17:23.424Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/68/7f/bdd79ceaad24b671543ffe0ef61ed8e659440eb683b66f033454dcee90eb/scipy-1.17.1-cp311-cp311-win_arm64.whl", hash = "sha256:9ecb4efb1cd6e8c4afea0daa91a87fbddbce1b99d2895d151596716c0b2e859d", size = 24599248, upload-time = "2026-02-23T00:17:34.561Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/48/b992b488d6f299dbe3f11a20b24d3dda3d46f1a635ede1c46b5b17a7b163/scipy-1.17.1-cp312-cp312-macosx_10_14_x86_64.whl", hash = "sha256:35c3a56d2ef83efc372eaec584314bd0ef2e2f0d2adb21c55e6ad5b344c0dcb8", size = 31610954, upload-time = "2026-02-23T00:17:49.855Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/02/cf107b01494c19dc100f1d0b7ac3cc08666e96ba2d64db7626066cee895e/scipy-1.17.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:fcb310ddb270a06114bb64bbe53c94926b943f5b7f0842194d585c65eb4edd76", size = 28172662, upload-time = "2026-02-23T00:18:01.64Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cf/a9/599c28631bad314d219cf9ffd40e985b24d603fc8a2f4ccc5ae8419a535b/scipy-1.17.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:cc90d2e9c7e5c7f1a482c9875007c095c3194b1cfedca3c2f3291cdc2bc7c086", size = 20344366, upload-time = "2026-02-23T00:18:12.015Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/f5/906eda513271c8deb5af284e5ef0206d17a96239af79f9fa0aebfe0e36b4/scipy-1.17.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:c80be5ede8f3f8eded4eff73cc99a25c388ce98e555b17d31da05287015ffa5b", size = 22704017, upload-time = "2026-02-23T00:18:21.502Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/34/16f10e3042d2f1d6b66e0428308ab52224b6a23049cb2f5c1756f713815f/scipy-1.17.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e19ebea31758fac5893a2ac360fedd00116cbb7628e650842a6691ba7ca28a21", size = 32927842, upload-time = "2026-02-23T00:18:35.367Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/01/8e/1e35281b8ab6d5d72ebe9911edcdffa3f36b04ed9d51dec6dd140396e220/scipy-1.17.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:02ae3b274fde71c5e92ac4d54bc06c42d80e399fec704383dcd99b301df37458", size = 35235890, upload-time = "2026-02-23T00:18:49.188Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c5/5c/9d7f4c88bea6e0d5a4f1bc0506a53a00e9fcb198de372bfe4d3652cef482/scipy-1.17.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8a604bae87c6195d8b1045eddece0514d041604b14f2727bbc2b3020172045eb", size = 35003557, upload-time = "2026-02-23T00:18:54.74Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/65/94/7698add8f276dbab7a9de9fb6b0e02fc13ee61d51c7c3f85ac28b65e1239/scipy-1.17.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f590cd684941912d10becc07325a3eeb77886fe981415660d9265c4c418d0bea", size = 37625856, upload-time = "2026-02-23T00:19:00.307Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/84/dc08d77fbf3d87d3ee27f6a0c6dcce1de5829a64f2eae85a0ecc1f0daa73/scipy-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:41b71f4a3a4cab9d366cd9065b288efc4d4f3c0b37a91a8e0947fb5bd7f31d87", size = 36549682, upload-time = "2026-02-23T00:19:07.67Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/98/fe9ae9ffb3b54b62559f52dedaebe204b408db8109a8c66fdd04869e6424/scipy-1.17.1-cp312-cp312-win_arm64.whl", hash = "sha256:f4115102802df98b2b0db3cce5cb9b92572633a1197c77b7553e5203f284a5b3", size = 24547340, upload-time = "2026-02-23T00:19:12.024Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/27/07ee1b57b65e92645f219b37148a7e7928b82e2b5dbeccecb4dff7c64f0b/scipy-1.17.1-cp313-cp313-macosx_10_14_x86_64.whl", hash = "sha256:5e3c5c011904115f88a39308379c17f91546f77c1667cea98739fe0fccea804c", size = 31590199, upload-time = "2026-02-23T00:19:17.192Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ec/ae/db19f8ab842e9b724bf5dbb7db29302a91f1e55bc4d04b1025d6d605a2c5/scipy-1.17.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:6fac755ca3d2c3edcb22f479fceaa241704111414831ddd3bc6056e18516892f", size = 28154001, upload-time = "2026-02-23T00:19:22.241Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5b/58/3ce96251560107b381cbd6e8413c483bbb1228a6b919fa8652b0d4090e7f/scipy-1.17.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:7ff200bf9d24f2e4d5dc6ee8c3ac64d739d3a89e2326ba68aaf6c4a2b838fd7d", size = 20325719, upload-time = "2026-02-23T00:19:26.329Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/83/15087d945e0e4d48ce2377498abf5ad171ae013232ae31d06f336e64c999/scipy-1.17.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:4b400bdc6f79fa02a4d86640310dde87a21fba0c979efff5248908c6f15fad1b", size = 22683595, upload-time = "2026-02-23T00:19:30.304Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/e0/e58fbde4a1a594c8be8114eb4aac1a55bcd6587047efc18a61eb1f5c0d30/scipy-1.17.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2b64ca7d4aee0102a97f3ba22124052b4bd2152522355073580bf4845e2550b6", size = 32896429, upload-time = "2026-02-23T00:19:35.536Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/5f/f17563f28ff03c7b6799c50d01d5d856a1d55f2676f537ca8d28c7f627cd/scipy-1.17.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:581b2264fc0aa555f3f435a5944da7504ea3a065d7029ad60e7c3d1ae09c5464", size = 35203952, upload-time = "2026-02-23T00:19:42.259Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/a5/9afd17de24f657fdfe4df9a3f1ea049b39aef7c06000c13db1530d81ccca/scipy-1.17.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:beeda3d4ae615106d7094f7e7cef6218392e4465cc95d25f900bebabfded0950", size = 34979063, upload-time = "2026-02-23T00:19:47.547Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/13/88b1d2384b424bf7c924f2038c1c409f8d88bb2a8d49d097861dd64a57b2/scipy-1.17.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6609bc224e9568f65064cfa72edc0f24ee6655b47575954ec6339534b2798369", size = 37598449, upload-time = "2026-02-23T00:19:53.238Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/e5/d6d0e51fc888f692a35134336866341c08655d92614f492c6860dc45bb2c/scipy-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:37425bc9175607b0268f493d79a292c39f9d001a357bebb6b88fdfaff13f6448", size = 36510943, upload-time = "2026-02-23T00:20:50.89Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/fd/3be73c564e2a01e690e19cc618811540ba5354c67c8680dce3281123fb79/scipy-1.17.1-cp313-cp313-win_arm64.whl", hash = "sha256:5cf36e801231b6a2059bf354720274b7558746f3b1a4efb43fcf557ccd484a87", size = 24545621, upload-time = "2026-02-23T00:20:55.871Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/6b/17787db8b8114933a66f9dcc479a8272e4b4da75fe03b0c282f7b0ade8cd/scipy-1.17.1-cp313-cp313t-macosx_10_14_x86_64.whl", hash = "sha256:d59c30000a16d8edc7e64152e30220bfbd724c9bbb08368c054e24c651314f0a", size = 31936708, upload-time = "2026-02-23T00:19:58.694Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/38/2e/524405c2b6392765ab1e2b722a41d5da33dc5c7b7278184a8ad29b6cb206/scipy-1.17.1-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:010f4333c96c9bb1a4516269e33cb5917b08ef2166d5556ca2fd9f082a9e6ea0", size = 28570135, upload-time = "2026-02-23T00:20:03.934Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/c3/5bd7199f4ea8556c0c8e39f04ccb014ac37d1468e6cfa6a95c6b3562b76e/scipy-1.17.1-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:2ceb2d3e01c5f1d83c4189737a42d9cb2fc38a6eeed225e7515eef71ad301dce", size = 20741977, upload-time = "2026-02-23T00:20:07.935Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/b8/8ccd9b766ad14c78386599708eb745f6b44f08400a5fd0ade7cf89b6fc93/scipy-1.17.1-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:844e165636711ef41f80b4103ed234181646b98a53c8f05da12ca5ca289134f6", size = 23029601, upload-time = "2026-02-23T00:20:12.161Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/a0/3cb6f4d2fb3e17428ad2880333cac878909ad1a89f678527b5328b93c1d4/scipy-1.17.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:158dd96d2207e21c966063e1635b1063cd7787b627b6f07305315dd73d9c679e", size = 33019667, upload-time = "2026-02-23T00:20:17.208Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/c3/2d834a5ac7bf3a0c806ad1508efc02dda3c8c61472a56132d7894c312dea/scipy-1.17.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:74cbb80d93260fe2ffa334efa24cb8f2f0f622a9b9febf8b483c0b865bfb3475", size = 35264159, upload-time = "2026-02-23T00:20:23.087Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/77/d3ed4becfdbd217c52062fafe35a72388d1bd82c2d0ba5ca19d6fcc93e11/scipy-1.17.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:dbc12c9f3d185f5c737d801da555fb74b3dcfa1a50b66a1a93e09190f41fab50", size = 35102771, upload-time = "2026-02-23T00:20:28.636Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/12/d19da97efde68ca1ee5538bb261d5d2c062f0c055575128f11a2730e3ac1/scipy-1.17.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:94055a11dfebe37c656e70317e1996dc197e1a15bbcc351bcdd4610e128fe1ca", size = 37665910, upload-time = "2026-02-23T00:20:34.743Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/1c/1172a88d507a4baaf72c5a09bb6c018fe2ae0ab622e5830b703a46cc9e44/scipy-1.17.1-cp313-cp313t-win_amd64.whl", hash = "sha256:e30bdeaa5deed6bc27b4cc490823cd0347d7dae09119b8803ae576ea0ce52e4c", size = 36562980, upload-time = "2026-02-23T00:20:40.575Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/b0/eb757336e5a76dfa7911f63252e3b7d1de00935d7705cf772db5b45ec238/scipy-1.17.1-cp313-cp313t-win_arm64.whl", hash = "sha256:a720477885a9d2411f94a93d16f9d89bad0f28ca23c3f8daa521e2dcc3f44d49", size = 24856543, upload-time = "2026-02-23T00:20:45.313Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cf/83/333afb452af6f0fd70414dc04f898647ee1423979ce02efa75c3b0f2c28e/scipy-1.17.1-cp314-cp314-macosx_10_14_x86_64.whl", hash = "sha256:a48a72c77a310327f6a3a920092fa2b8fd03d7deaa60f093038f22d98e096717", size = 31584510, upload-time = "2026-02-23T00:21:01.015Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/a6/d05a85fd51daeb2e4ea71d102f15b34fedca8e931af02594193ae4fd25f7/scipy-1.17.1-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:45abad819184f07240d8a696117a7aacd39787af9e0b719d00285549ed19a1e9", size = 28170131, upload-time = "2026-02-23T00:21:05.888Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/db/7b/8624a203326675d7746a254083a187398090a179335b2e4a20e2ddc46e83/scipy-1.17.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:3fd1fcdab3ea951b610dc4cef356d416d5802991e7e32b5254828d342f7b7e0b", size = 20342032, upload-time = "2026-02-23T00:21:09.904Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/35/2c342897c00775d688d8ff3987aced3426858fd89d5a0e26e020b660b301/scipy-1.17.1-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:7bdf2da170b67fdf10bca777614b1c7d96ae3ca5794fd9587dce41eb2966e866", size = 22678766, upload-time = "2026-02-23T00:21:14.313Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/f2/7cdb8eb308a1a6ae1e19f945913c82c23c0c442a462a46480ce487fdc0ac/scipy-1.17.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:adb2642e060a6549c343603a3851ba76ef0b74cc8c079a9a58121c7ec9fe2350", size = 32957007, upload-time = "2026-02-23T00:21:19.663Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/2e/7eea398450457ecb54e18e9d10110993fa65561c4f3add5e8eccd2b9cd41/scipy-1.17.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eee2cfda04c00a857206a4330f0c5e3e56535494e30ca445eb19ec624ae75118", size = 35221333, upload-time = "2026-02-23T00:21:25.278Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d9/77/5b8509d03b77f093a0d52e606d3c4f79e8b06d1d38c441dacb1e26cacf46/scipy-1.17.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d2650c1fb97e184d12d8ba010493ee7b322864f7d3d00d3f9bb97d9c21de4068", size = 35042066, upload-time = "2026-02-23T00:21:31.358Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/df/18f80fb99df40b4070328d5ae5c596f2f00fffb50167e31439e932f29e7d/scipy-1.17.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:08b900519463543aa604a06bec02461558a6e1cef8fdbb8098f77a48a83c8118", size = 37612763, upload-time = "2026-02-23T00:21:37.247Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4b/39/f0e8ea762a764a9dc52aa7dabcfad51a354819de1f0d4652b6a1122424d6/scipy-1.17.1-cp314-cp314-win_amd64.whl", hash = "sha256:3877ac408e14da24a6196de0ddcace62092bfc12a83823e92e49e40747e52c19", size = 37290984, upload-time = "2026-02-23T00:22:35.023Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/56/fe201e3b0f93d1a8bcf75d3379affd228a63d7e2d80ab45467a74b494947/scipy-1.17.1-cp314-cp314-win_arm64.whl", hash = "sha256:f8885db0bc2bffa59d5c1b72fad7a6a92d3e80e7257f967dd81abb553a90d293", size = 25192877, upload-time = "2026-02-23T00:22:39.798Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/96/ad/f8c414e121f82e02d76f310f16db9899c4fcde36710329502a6b2a3c0392/scipy-1.17.1-cp314-cp314t-macosx_10_14_x86_64.whl", hash = "sha256:1cc682cea2ae55524432f3cdff9e9a3be743d52a7443d0cba9017c23c87ae2f6", size = 31949750, upload-time = "2026-02-23T00:21:42.289Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/b0/c741e8865d61b67c81e255f4f0a832846c064e426636cd7de84e74d209be/scipy-1.17.1-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:2040ad4d1795a0ae89bfc7e8429677f365d45aa9fd5e4587cf1ea737f927b4a1", size = 28585858, upload-time = "2026-02-23T00:21:47.706Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/1b/3985219c6177866628fa7c2595bfd23f193ceebbe472c98a08824b9466ff/scipy-1.17.1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:131f5aaea57602008f9822e2115029b55d4b5f7c070287699fe45c661d051e39", size = 20757723, upload-time = "2026-02-23T00:21:52.039Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/19/2a04aa25050d656d6f7b9e7b685cc83d6957fb101665bfd9369ca6534563/scipy-1.17.1-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:9cdc1a2fcfd5c52cfb3045feb399f7b3ce822abdde3a193a6b9a60b3cb5854ca", size = 23043098, upload-time = "2026-02-23T00:21:56.185Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/f1/3383beb9b5d0dbddd030335bf8a8b32d4317185efe495374f134d8be6cce/scipy-1.17.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e3dcd57ab780c741fde8dc68619de988b966db759a3c3152e8e9142c26295ad", size = 33030397, upload-time = "2026-02-23T00:22:01.404Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/68/8f21e8a65a5a03f25a79165ec9d2b28c00e66dc80546cf5eb803aeeff35b/scipy-1.17.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a9956e4d4f4a301ebf6cde39850333a6b6110799d470dbbb1e25326ac447f52a", size = 35281163, upload-time = "2026-02-23T00:22:07.024Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/8d/c8a5e19479554007a5632ed7529e665c315ae7492b4f946b0deb39870e39/scipy-1.17.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:a4328d245944d09fd639771de275701ccadf5f781ba0ff092ad141e017eccda4", size = 35116291, upload-time = "2026-02-23T00:22:12.585Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/52/52/e57eceff0e342a1f50e274264ed47497b59e6a4e3118808ee58ddda7b74a/scipy-1.17.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a77cbd07b940d326d39a1d1b37817e2ee4d79cb30e7338f3d0cddffae70fcaa2", size = 37682317, upload-time = "2026-02-23T00:22:18.513Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/2f/b29eafe4a3fbc3d6de9662b36e028d5f039e72d345e05c250e121a230dd4/scipy-1.17.1-cp314-cp314t-win_amd64.whl", hash = "sha256:eb092099205ef62cd1782b006658db09e2fed75bffcae7cc0d44052d8aa0f484", size = 37345327, upload-time = "2026-02-23T00:22:24.442Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/39/338d9219c4e87f3e708f18857ecd24d22a0c3094752393319553096b98af/scipy-1.17.1-cp314-cp314t-win_arm64.whl", hash = "sha256:200e1050faffacc162be6a486a984a0497866ec54149a01270adc8a59b7c7d21", size = 25489165, upload-time = "2026-02-23T00:22:29.563Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sentence-transformers"
|
||||
version = "5.2.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "huggingface-hub", marker = "python_full_version >= '3.14'" },
|
||||
{ name = "numpy", marker = "python_full_version >= '3.14'" },
|
||||
{ name = "scikit-learn", marker = "python_full_version >= '3.14'" },
|
||||
{ name = "scipy", marker = "python_full_version >= '3.14'" },
|
||||
{ name = "torch", marker = "python_full_version >= '3.14'" },
|
||||
{ name = "tqdm", marker = "python_full_version >= '3.14'" },
|
||||
{ name = "transformers", marker = "python_full_version >= '3.14'" },
|
||||
{ name = "typing-extensions", marker = "python_full_version >= '3.14'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5b/30/21664028fc0776eb1ca024879480bbbab36f02923a8ff9e4cae5a150fa35/sentence_transformers-5.2.3.tar.gz", hash = "sha256:3cd3044e1f3fe859b6a1b66336aac502eaae5d3dd7d5c8fc237f37fbf58137c7", size = 381623, upload-time = "2026-02-17T14:05:20.238Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/46/9f/dba4b3e18ebbe1eaa29d9f1764fbc7da0cd91937b83f2b7928d15c5d2d36/sentence_transformers-5.2.3-py3-none-any.whl", hash = "sha256:6437c62d4112b615ddebda362dfc16a4308d604c5b68125ed586e3e95d5b2e30", size = 494225, upload-time = "2026-02-17T14:05:18.596Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "setuptools"
|
||||
version = "80.10.2"
|
||||
@@ -4854,6 +5197,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/78/96ddb99933e11d91bc6e05edae23d2687e44213066bcbaca338898c73c47/textual-7.5.0-py3-none-any.whl", hash = "sha256:849dfee9d705eab3b2d07b33152b7bd74fb1f5056e002873cc448bce500c6374", size = 718164, upload-time = "2026-01-30T13:46:37.635Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "threadpoolctl"
|
||||
version = "3.6.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b7/4d/08c89e34946fce2aec4fbb45c9016efd5f4d7f24af8e5d93296e935631d8/threadpoolctl-3.6.0.tar.gz", hash = "sha256:8ab8b4aa3491d812b623328249fab5302a68d2d71745c8a4c719a2fcaba9f44e", size = 21274, upload-time = "2025-03-13T13:49:23.031Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/32/d5/f9a850d79b0851d1d4ef6456097579a9005b31fea68726a4ae5f2d82ddd9/threadpoolctl-3.6.0-py3-none-any.whl", hash = "sha256:43a0b8fd5a2928500110039e43a5eed8480b918967083ea48dc3ab9f13c4a7fb", size = 18638, upload-time = "2025-03-13T13:49:21.846Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tiktoken"
|
||||
version = "0.12.0"
|
||||
@@ -4988,6 +5340,72 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/23/d1/136eb2cb77520a31e1f64cbae9d33ec6df0d78bdf4160398e86eec8a8754/tomli-2.4.0-py3-none-any.whl", hash = "sha256:1f776e7d669ebceb01dee46484485f43a4048746235e683bcdffacdf1fb4785a", size = 14477, upload-time = "2026-01-11T11:22:37.446Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "torch"
|
||||
version = "2.10.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "cuda-bindings", marker = "python_full_version >= '3.14' and platform_machine == 'x86_64' and sys_platform == 'linux'" },
|
||||
{ name = "filelock", marker = "python_full_version >= '3.14'" },
|
||||
{ name = "fsspec", marker = "python_full_version >= '3.14'" },
|
||||
{ name = "jinja2", marker = "python_full_version >= '3.14'" },
|
||||
{ name = "networkx", marker = "python_full_version >= '3.14'" },
|
||||
{ name = "nvidia-cublas-cu12", marker = "python_full_version >= '3.14' and platform_machine == 'x86_64' and sys_platform == 'linux'" },
|
||||
{ name = "nvidia-cuda-cupti-cu12", marker = "python_full_version >= '3.14' and platform_machine == 'x86_64' and sys_platform == 'linux'" },
|
||||
{ name = "nvidia-cuda-nvrtc-cu12", marker = "python_full_version >= '3.14' and platform_machine == 'x86_64' and sys_platform == 'linux'" },
|
||||
{ name = "nvidia-cuda-runtime-cu12", marker = "python_full_version >= '3.14' and platform_machine == 'x86_64' and sys_platform == 'linux'" },
|
||||
{ name = "nvidia-cudnn-cu12", marker = "python_full_version >= '3.14' and platform_machine == 'x86_64' and sys_platform == 'linux'" },
|
||||
{ name = "nvidia-cufft-cu12", marker = "python_full_version >= '3.14' and platform_machine == 'x86_64' and sys_platform == 'linux'" },
|
||||
{ name = "nvidia-cufile-cu12", marker = "python_full_version >= '3.14' and platform_machine == 'x86_64' and sys_platform == 'linux'" },
|
||||
{ name = "nvidia-curand-cu12", marker = "python_full_version >= '3.14' and platform_machine == 'x86_64' and sys_platform == 'linux'" },
|
||||
{ name = "nvidia-cusolver-cu12", marker = "python_full_version >= '3.14' and platform_machine == 'x86_64' and sys_platform == 'linux'" },
|
||||
{ name = "nvidia-cusparse-cu12", marker = "python_full_version >= '3.14' and platform_machine == 'x86_64' and sys_platform == 'linux'" },
|
||||
{ name = "nvidia-cusparselt-cu12", marker = "python_full_version >= '3.14' and platform_machine == 'x86_64' and sys_platform == 'linux'" },
|
||||
{ name = "nvidia-nccl-cu12", marker = "python_full_version >= '3.14' and platform_machine == 'x86_64' and sys_platform == 'linux'" },
|
||||
{ name = "nvidia-nvjitlink-cu12", marker = "python_full_version >= '3.14' and platform_machine == 'x86_64' and sys_platform == 'linux'" },
|
||||
{ name = "nvidia-nvshmem-cu12", marker = "python_full_version >= '3.14' and platform_machine == 'x86_64' and sys_platform == 'linux'" },
|
||||
{ name = "nvidia-nvtx-cu12", marker = "python_full_version >= '3.14' and platform_machine == 'x86_64' and sys_platform == 'linux'" },
|
||||
{ name = "setuptools", marker = "python_full_version >= '3.14'" },
|
||||
{ name = "sympy", marker = "python_full_version >= '3.14'" },
|
||||
{ name = "triton", marker = "python_full_version >= '3.14' and platform_machine == 'x86_64' and sys_platform == 'linux'" },
|
||||
{ name = "typing-extensions", marker = "python_full_version >= '3.14'" },
|
||||
]
|
||||
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/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/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/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/61/d8/15b9d9d3a6b0c01b883787bd056acbe5cc321090d4b216d3ea89a8fcfdf3/torch-2.10.0-cp311-none-macosx_11_0_arm64.whl", hash = "sha256:b7bd80f3477b830dd166c707c5b0b82a898e7b16f59a7d9d42778dd058272e8b", size = 79423461, upload-time = "2026-01-21T16:24:50.266Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/af/758e242e9102e9988969b5e621d41f36b8f258bb4a099109b7a4b4b50ea4/torch-2.10.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:5fd4117d89ffd47e3dcc71e71a22efac24828ad781c7e46aaaf56bf7f2796acf", size = 145996088, upload-time = "2026-01-21T16:24:44.171Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/8e/3c74db5e53bff7ed9e34c8123e6a8bfef718b2450c35eefab85bb4a7e270/torch-2.10.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:787124e7db3b379d4f1ed54dd12ae7c741c16a4d29b49c0226a89bea50923ffb", size = 915711952, upload-time = "2026-01-21T16:23:53.503Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/01/624c4324ca01f66ae4c7cd1b74eb16fb52596dce66dbe51eff95ef9e7a4c/torch-2.10.0-cp312-cp312-win_amd64.whl", hash = "sha256:2c66c61f44c5f903046cc696d088e21062644cbe541c7f1c4eaae88b2ad23547", size = 113757972, upload-time = "2026-01-21T16:24:39.516Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/5c/dee910b87c4d5c0fcb41b50839ae04df87c1cfc663cf1b5fca7ea565eeaa/torch-2.10.0-cp312-none-macosx_11_0_arm64.whl", hash = "sha256:6d3707a61863d1c4d6ebba7be4ca320f42b869ee657e9b2c21c736bf17000294", size = 79498198, upload-time = "2026-01-21T16:24:34.704Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/6f/f2e91e34e3fcba2e3fc8d8f74e7d6c22e74e480bbd1db7bc8900fdf3e95c/torch-2.10.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:5c4d217b14741e40776dd7074d9006fd28b8a97ef5654db959d8635b2fe5f29b", size = 146004247, upload-time = "2026-01-21T16:24:29.335Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/fb/5160261aeb5e1ee12ee95fe599d0541f7c976c3701d607d8fc29e623229f/torch-2.10.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:6b71486353fce0f9714ca0c9ef1c850a2ae766b409808acd58e9678a3edb7738", size = 915716445, upload-time = "2026-01-21T16:22:45.353Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/16/502fb1b41e6d868e8deb5b0e3ae926bbb36dab8ceb0d1b769b266ad7b0c3/torch-2.10.0-cp313-cp313-win_amd64.whl", hash = "sha256:c2ee399c644dc92ef7bc0d4f7e74b5360c37cdbe7c5ba11318dda49ffac2bc57", size = 113757050, upload-time = "2026-01-21T16:24:19.204Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/0b/39929b148f4824bc3ad6f9f72a29d4ad865bcf7ebfc2fa67584773e083d2/torch-2.10.0-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:3202429f58309b9fa96a614885eace4b7995729f44beb54d3e4a47773649d382", size = 79851305, upload-time = "2026-01-21T16:24:09.209Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/14/21fbce63bc452381ba5f74a2c0a959fdf5ad5803ccc0c654e752e0dbe91a/torch-2.10.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:aae1b29cd68e50a9397f5ee897b9c24742e9e306f88a807a27d617f07adb3bd8", size = 146005472, upload-time = "2026-01-21T16:22:29.022Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/54/fd/b207d1c525cb570ef47f3e9f836b154685011fce11a2f444ba8a4084d042/torch-2.10.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:6021db85958db2f07ec94e1bc77212721ba4920c12a18dc552d2ae36a3eb163f", size = 915612644, upload-time = "2026-01-21T16:21:47.019Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/53/0197f868c75f1050b199fe58f9bf3bf3aecac9b4e85cc9c964383d745403/torch-2.10.0-cp313-cp313t-win_amd64.whl", hash = "sha256:ff43db38af76fda183156153983c9a096fc4c78d0cd1e07b14a2314c7f01c2c8", size = 113997015, upload-time = "2026-01-21T16:23:00.767Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0e/13/e76b4d9c160e89fff48bf16b449ea324bda84745d2ab30294c37c2434c0d/torch-2.10.0-cp313-none-macosx_11_0_arm64.whl", hash = "sha256:cdf2a523d699b70d613243211ecaac14fe9c5df8a0b0a9c02add60fb2a413e0f", size = 79498248, upload-time = "2026-01-21T16:23:09.315Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4f/93/716b5ac0155f1be70ed81bacc21269c3ece8dba0c249b9994094110bfc51/torch-2.10.0-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:bf0d9ff448b0218e0433aeb198805192346c4fd659c852370d5cc245f602a06a", size = 79464992, upload-time = "2026-01-21T16:23:05.162Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/69/2b/51e663ff190c9d16d4a8271203b71bc73a16aa7619b9f271a69b9d4a936b/torch-2.10.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:233aed0659a2503b831d8a67e9da66a62c996204c0bba4f4c442ccc0c68a3f60", size = 146018567, upload-time = "2026-01-21T16:22:23.393Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/cd/4b95ef7f293b927c283db0b136c42be91c8ec6845c44de0238c8c23bdc80/torch-2.10.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:682497e16bdfa6efeec8cde66531bc8d1fbbbb4d8788ec6173c089ed3cc2bfe5", size = 915721646, upload-time = "2026-01-21T16:21:16.983Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/56/97/078a007208f8056d88ae43198833469e61a0a355abc0b070edd2c085eb9a/torch-2.10.0-cp314-cp314-win_amd64.whl", hash = "sha256:6528f13d2a8593a1a412ea07a99812495bec07e9224c28b2a25c0a30c7da025c", size = 113752373, upload-time = "2026-01-21T16:22:13.471Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/94/71994e7d0d5238393df9732fdab607e37e2b56d26a746cb59fdb415f8966/torch-2.10.0-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:f5ab4ba32383061be0fb74bda772d470140a12c1c3b58a0cfbf3dae94d164c28", size = 79850324, upload-time = "2026-01-21T16:22:09.494Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/65/1a05346b418ea8ccd10360eef4b3e0ce688fba544e76edec26913a8d0ee0/torch-2.10.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:716b01a176c2a5659c98f6b01bf868244abdd896526f1c692712ab36dbaf9b63", size = 146006482, upload-time = "2026-01-21T16:22:18.42Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/b9/5f6f9d9e859fc3235f60578fa64f52c9c6e9b4327f0fe0defb6de5c0de31/torch-2.10.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:d8f5912ba938233f86361e891789595ff35ca4b4e2ac8fe3670895e5976731d6", size = 915613050, upload-time = "2026-01-21T16:20:49.035Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/66/4d/35352043ee0eaffdeff154fad67cd4a31dbed7ff8e3be1cc4549717d6d51/torch-2.10.0-cp314-cp314t-win_amd64.whl", hash = "sha256:71283a373f0ee2c89e0f0d5f446039bdabe8dbc3c9ccf35f0f784908b0acd185", size = 113995816, upload-time = "2026-01-21T16:22:05.312Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tqdm"
|
||||
version = "4.67.2"
|
||||
@@ -5000,6 +5418,39 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/e2/31eac96de2915cf20ccaed0225035db149dfb9165a9ed28d4b252ef3f7f7/tqdm-4.67.2-py3-none-any.whl", hash = "sha256:9a12abcbbff58b6036b2167d9d3853042b9d436fe7330f06ae047867f2f8e0a7", size = 78354, upload-time = "2026-01-30T23:12:04.368Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "transformers"
|
||||
version = "5.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "huggingface-hub", marker = "python_full_version >= '3.14'" },
|
||||
{ name = "numpy", marker = "python_full_version >= '3.14'" },
|
||||
{ name = "packaging", marker = "python_full_version >= '3.14'" },
|
||||
{ name = "pyyaml", marker = "python_full_version >= '3.14'" },
|
||||
{ name = "regex", marker = "python_full_version >= '3.14'" },
|
||||
{ name = "safetensors", marker = "python_full_version >= '3.14'" },
|
||||
{ name = "tokenizers", marker = "python_full_version >= '3.14'" },
|
||||
{ name = "tqdm", marker = "python_full_version >= '3.14'" },
|
||||
{ name = "typer", marker = "python_full_version >= '3.14'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/fc/1a/70e830d53ecc96ce69cfa8de38f163712d2b43ac52fbd743f39f56025c31/transformers-5.3.0.tar.gz", hash = "sha256:009555b364029da9e2946d41f1c5de9f15e6b1df46b189b7293f33a161b9c557", size = 8830831, upload-time = "2026-03-04T17:41:46.119Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/88/ae8320064e32679a5429a2c9ebbc05c2bf32cefb6e076f9b07f6d685a9b4/transformers-5.3.0-py3-none-any.whl", hash = "sha256:50ac8c89c3c7033444fb3f9f53138096b997ebb70d4b5e50a2e810bf12d3d29a", size = 10661827, upload-time = "2026-03-04T17:41:42.722Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "triton"
|
||||
version = "3.6.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/12/b05ba554d2c623bffa59922b94b0775673de251f468a9609bc9e45de95e9/triton-3.6.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e8e323d608e3a9bfcc2d9efcc90ceefb764a82b99dea12a86d643c72539ad5d3", size = 188214640, upload-time = "2026-01-20T16:00:35.869Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/a8/cdf8b3e4c98132f965f88c2313a4b493266832ad47fb52f23d14d4f86bb5/triton-3.6.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:74caf5e34b66d9f3a429af689c1c7128daba1d8208df60e81106b115c00d6fca", size = 188266850, upload-time = "2026-01-20T16:00:43.041Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/0b/37d991d8c130ce81a8728ae3c25b6e60935838e9be1b58791f5997b24a54/triton-3.6.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:10c7f76c6e72d2ef08df639e3d0d30729112f47a56b0c81672edc05ee5116ac9", size = 188289450, upload-time = "2026-01-20T16:00:49.136Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/f8/9c66bfc55361ec6d0e4040a0337fb5924ceb23de4648b8a81ae9d33b2b38/triton-3.6.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d002e07d7180fd65e622134fbd980c9a3d4211fb85224b56a0a0efbd422ab72f", size = 188400296, upload-time = "2026-01-20T16:00:56.042Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/3d/9e7eee57b37c80cec63322c0231bb6da3cfe535a91d7a4d64896fcb89357/triton-3.6.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a17a5d5985f0ac494ed8a8e54568f092f7057ef60e1b0fa09d3fd1512064e803", size = 188273063, upload-time = "2026-01-20T16:01:07.278Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/56/6113c23ff46c00aae423333eb58b3e60bdfe9179d542781955a5e1514cb3/triton-3.6.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:46bd1c1af4b6704e554cad2eeb3b0a6513a980d470ccfa63189737340c7746a7", size = 188397994, upload-time = "2026-01-20T16:01:14.236Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typer"
|
||||
version = "0.21.1"
|
||||
@@ -5421,14 +5872,14 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "werkzeug"
|
||||
version = "3.1.5"
|
||||
version = "3.1.6"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ 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 = [
|
||||
{ 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]]
|
||||
|
||||
@@ -102,5 +102,10 @@
|
||||
"typescript": "^5.8.3",
|
||||
"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
|
||||
excludeLinksFromLockfile: false
|
||||
|
||||
overrides:
|
||||
minimatch: 3.1.3
|
||||
|
||||
dependencies:
|
||||
'@dnd-kit/core':
|
||||
specifier: ^6.3.1
|
||||
@@ -345,7 +348,7 @@ packages:
|
||||
dependencies:
|
||||
'@eslint/object-schema': 2.1.7
|
||||
debug: 4.4.3
|
||||
minimatch: 3.1.2
|
||||
minimatch: 3.1.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
dev: true
|
||||
@@ -375,7 +378,7 @@ packages:
|
||||
ignore: 5.3.2
|
||||
import-fresh: 3.3.1
|
||||
js-yaml: 4.1.1
|
||||
minimatch: 3.1.2
|
||||
minimatch: 3.1.3
|
||||
strip-json-comments: 3.1.1
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
@@ -2260,7 +2263,7 @@ packages:
|
||||
'@typescript-eslint/types': 8.54.0
|
||||
'@typescript-eslint/visitor-keys': 8.54.0
|
||||
debug: 4.4.3
|
||||
minimatch: 9.0.5
|
||||
minimatch: 3.1.3
|
||||
semver: 7.7.3
|
||||
tinyglobby: 0.2.15
|
||||
ts-api-utils: 2.4.0(typescript@5.9.3)
|
||||
@@ -2678,12 +2681,6 @@ packages:
|
||||
concat-map: 0.0.1
|
||||
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:
|
||||
resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -3345,7 +3342,7 @@ packages:
|
||||
hasown: 2.0.2
|
||||
is-core-module: 2.16.1
|
||||
is-glob: 4.0.3
|
||||
minimatch: 3.1.2
|
||||
minimatch: 3.1.3
|
||||
object.fromentries: 2.0.8
|
||||
object.groupby: 1.0.3
|
||||
object.values: 1.2.1
|
||||
@@ -3376,7 +3373,7 @@ packages:
|
||||
hasown: 2.0.2
|
||||
jsx-ast-utils: 3.3.5
|
||||
language-tags: 1.0.9
|
||||
minimatch: 3.1.2
|
||||
minimatch: 3.1.3
|
||||
object.fromentries: 2.0.8
|
||||
safe-regex-test: 1.1.0
|
||||
string.prototype.includes: 2.0.1
|
||||
@@ -3428,7 +3425,7 @@ packages:
|
||||
estraverse: 5.3.0
|
||||
hasown: 2.0.2
|
||||
jsx-ast-utils: 3.3.5
|
||||
minimatch: 3.1.2
|
||||
minimatch: 3.1.3
|
||||
object.entries: 1.1.9
|
||||
object.fromentries: 2.0.8
|
||||
object.values: 1.2.1
|
||||
@@ -3498,7 +3495,7 @@ packages:
|
||||
is-glob: 4.0.3
|
||||
json-stable-stringify-without-jsonify: 1.0.1
|
||||
lodash.merge: 4.6.2
|
||||
minimatch: 3.1.2
|
||||
minimatch: 3.1.3
|
||||
natural-compare: 1.4.0
|
||||
optionator: 0.9.4
|
||||
transitivePeerDependencies:
|
||||
@@ -5113,19 +5110,12 @@ packages:
|
||||
engines: {node: '>=18'}
|
||||
dev: true
|
||||
|
||||
/minimatch@3.1.2:
|
||||
resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==}
|
||||
/minimatch@3.1.3:
|
||||
resolution: {integrity: sha512-M2GCs7Vk83NxkUyQV1bkABc4yxgz9kILhHImZiBPAZ9ybuvCb0/H7lEl5XvIg3g+9d4eNotkZA5IWwYl0tibaA==}
|
||||
dependencies:
|
||||
brace-expansion: 1.1.12
|
||||
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:
|
||||
resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==}
|
||||
dev: true
|
||||
|
||||
@@ -124,12 +124,6 @@ export default function BotForm({
|
||||
const currentAdapter = form.watch('adapter');
|
||||
const currentAdapterConfig = form.watch('adapter_config');
|
||||
|
||||
// Serialize adapter_config to a stable string so it can be used as a
|
||||
// useEffect dependency without triggering on every render. form.watch()
|
||||
// returns a new object reference each time, which would otherwise cause
|
||||
// the filtering effect below to loop indefinitely.
|
||||
const adapterConfigJson = JSON.stringify(currentAdapterConfig);
|
||||
|
||||
useEffect(() => {
|
||||
setBotFormValues();
|
||||
}, []);
|
||||
@@ -153,7 +147,7 @@ export default function BotForm({
|
||||
// For non-Lark adapters, show all fields
|
||||
setFilteredDynamicFormConfigList(dynamicFormConfigList);
|
||||
}
|
||||
}, [currentAdapter, adapterConfigJson, dynamicFormConfigList]);
|
||||
}, [currentAdapter, currentAdapterConfig, dynamicFormConfigList]);
|
||||
|
||||
// 复制到剪贴板的辅助函数 - 使用页面上的真实input元素
|
||||
const copyToClipboard = () => {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { Copy, Check } from 'lucide-react';
|
||||
import {
|
||||
MessageChainComponent,
|
||||
Plain,
|
||||
@@ -27,6 +28,7 @@ interface SessionInfo {
|
||||
is_active: boolean;
|
||||
platform?: string | null;
|
||||
user_id?: string | null;
|
||||
user_name?: string | null;
|
||||
}
|
||||
|
||||
interface SessionMessage {
|
||||
@@ -60,8 +62,29 @@ export default function BotSessionMonitor({ botId }: BotSessionMonitorProps) {
|
||||
const [messages, setMessages] = useState<SessionMessage[]>([]);
|
||||
const [loadingSessions, setLoadingSessions] = useState(false);
|
||||
const [loadingMessages, setLoadingMessages] = useState(false);
|
||||
const [copiedUserId, setCopiedUserId] = useState(false);
|
||||
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 () => {
|
||||
setLoadingSessions(true);
|
||||
try {
|
||||
@@ -338,24 +361,36 @@ export default function BotSessionMonitor({ botId }: BotSessionMonitorProps) {
|
||||
>
|
||||
<div className="flex items-center justify-between mb-0.5">
|
||||
<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 className="text-[11px] text-muted-foreground tabular-nums flex-shrink-0">
|
||||
{formatRelativeTime(session.last_activity)}
|
||||
</span>
|
||||
</div>
|
||||
<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 && (
|
||||
<span className="px-1 py-0.5 rounded bg-muted text-[10px]">
|
||||
{session.platform}
|
||||
</span>
|
||||
)}
|
||||
{session.user_id && (
|
||||
<span className="truncate text-[10px]">
|
||||
{abbreviateId(session.user_id)}
|
||||
</span>
|
||||
)}
|
||||
{session.is_active && (
|
||||
<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>
|
||||
)}
|
||||
<span>{session.pipeline_name}</span>
|
||||
<span className="truncate">{session.pipeline_name}</span>
|
||||
</div>
|
||||
</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="min-w-0">
|
||||
<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 className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
{parseSessionType(selectedSessionId) && (
|
||||
<span>{parseSessionType(selectedSessionId)}</span>
|
||||
)}
|
||||
{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?.platform && <span>·</span>}
|
||||
<span>·</span>
|
||||
<span>{selectedSession.pipeline_name}</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
FormMessage,
|
||||
} from '@/components/ui/form';
|
||||
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 { useTranslation } from 'react-i18next';
|
||||
|
||||
@@ -73,6 +73,12 @@ export default function DynamicFormComponent({
|
||||
case 'bot-selector':
|
||||
fieldSchema = z.string();
|
||||
break;
|
||||
case 'model-fallback-selector':
|
||||
fieldSchema = z.object({
|
||||
primary: z.string(),
|
||||
fallbacks: z.array(z.string()),
|
||||
});
|
||||
break;
|
||||
case 'prompt-editor':
|
||||
fieldSchema = z.array(
|
||||
z.object({
|
||||
@@ -160,39 +166,34 @@ export default function DynamicFormComponent({
|
||||
const onSubmitRef = useRef(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,
|
||||
// triggering a re-render loop.
|
||||
const lastEmittedRef = useRef<string>('');
|
||||
|
||||
const emitValues = useCallback(() => {
|
||||
// 监听表单值变化
|
||||
useEffect(() => {
|
||||
// Emit initial form values immediately so the parent always has a valid snapshot,
|
||||
// even if the user saves without modifying any field.
|
||||
// form.watch(callback) only fires on subsequent changes, not on mount.
|
||||
const formValues = form.getValues();
|
||||
const finalValues = itemConfigList.reduce(
|
||||
const initialFinalValues = itemConfigList.reduce(
|
||||
(acc, item) => {
|
||||
acc[item.name] = formValues[item.name] ?? item.default;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, object>,
|
||||
);
|
||||
const serialized = JSON.stringify(finalValues);
|
||||
if (serialized !== lastEmittedRef.current) {
|
||||
lastEmittedRef.current = serialized;
|
||||
onSubmitRef.current?.(finalValues);
|
||||
}
|
||||
}, [form, itemConfigList]);
|
||||
|
||||
// 监听表单值变化
|
||||
useEffect(() => {
|
||||
// Emit initial form values immediately so the parent always has a valid snapshot,
|
||||
// even if the user saves without modifying any field.
|
||||
// form.watch(callback) only fires on subsequent changes, not on mount.
|
||||
emitValues();
|
||||
onSubmitRef.current?.(initialFinalValues);
|
||||
|
||||
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);
|
||||
});
|
||||
return () => subscription.unsubscribe();
|
||||
}, [form, itemConfigList, emitValues]);
|
||||
}, [form, itemConfigList]);
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
@@ -231,6 +232,7 @@ export default function DynamicFormComponent({
|
||||
|
||||
// All fields are disabled when editing (creation_settings are immutable)
|
||||
const isFieldDisabled = !!isEditing;
|
||||
|
||||
return (
|
||||
<FormField
|
||||
key={config.id}
|
||||
|
||||
@@ -124,6 +124,28 @@ export default function DynamicFormItemComponent({
|
||||
}
|
||||
}, [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(() => {
|
||||
if (
|
||||
config.type === DynamicFormItemType.KNOWLEDGE_BASE_SELECTOR ||
|
||||
@@ -171,12 +193,7 @@ export default function DynamicFormItemComponent({
|
||||
return <Textarea {...field} className="min-h-[120px]" />;
|
||||
|
||||
case DynamicFormItemType.BOOLEAN:
|
||||
return (
|
||||
<Switch
|
||||
checked={field.value ?? false}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
);
|
||||
return <Switch checked={field.value} onCheckedChange={field.onChange} />;
|
||||
|
||||
case DynamicFormItemType.STRING_ARRAY:
|
||||
return (
|
||||
@@ -227,7 +244,7 @@ export default function DynamicFormItemComponent({
|
||||
|
||||
case DynamicFormItemType.SELECT:
|
||||
return (
|
||||
<Select value={field.value ?? ''} onValueChange={field.onChange}>
|
||||
<Select value={field.value} onValueChange={field.onChange}>
|
||||
<SelectTrigger className="bg-[#ffffff] dark:bg-[#2a2a2e]">
|
||||
<SelectValue placeholder={t('common.select')} />
|
||||
</SelectTrigger>
|
||||
@@ -318,6 +335,172 @@ export default function DynamicFormItemComponent({
|
||||
</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 modelValue = field.value as {
|
||||
primary: string;
|
||||
fallbacks: string[];
|
||||
};
|
||||
|
||||
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:
|
||||
// Group KBs by Knowledge Engine name
|
||||
const kbsByEngine = knowledgeBases.reduce(
|
||||
|
||||
@@ -422,12 +422,12 @@ export default function HomeSidebar({
|
||||
const language = localStorage.getItem('langbot_language');
|
||||
if (language === 'zh-Hans' || language === 'zh-Hant') {
|
||||
window.open(
|
||||
'https://docs.langbot.app/zh/insight/guide.html',
|
||||
'https://docs.langbot.app/zh/insight/guide',
|
||||
'_blank',
|
||||
);
|
||||
} else {
|
||||
window.open(
|
||||
'https://docs.langbot.app/en/insight/guide.html',
|
||||
'https://docs.langbot.app/en/insight/guide',
|
||||
'_blank',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -23,9 +23,9 @@ export const sidebarConfigList = [
|
||||
route: '/home/bots',
|
||||
description: t('bots.description'),
|
||||
helpLink: {
|
||||
en_US: 'https://docs.langbot.app/en/usage/platforms/readme.html',
|
||||
zh_Hans: 'https://docs.langbot.app/zh/usage/platforms/readme.html',
|
||||
ja_JP: 'https://docs.langbot.app/ja/usage/platforms/readme.html',
|
||||
en_US: 'https://docs.langbot.app/en/usage/platforms/readme',
|
||||
zh_Hans: 'https://docs.langbot.app/zh/usage/platforms/readme',
|
||||
ja_JP: 'https://docs.langbot.app/ja/usage/platforms/readme',
|
||||
},
|
||||
}),
|
||||
new SidebarChildVO({
|
||||
@@ -44,9 +44,9 @@ export const sidebarConfigList = [
|
||||
route: '/home/pipelines',
|
||||
description: t('pipelines.description'),
|
||||
helpLink: {
|
||||
en_US: 'https://docs.langbot.app/en/usage/pipelines/readme.html',
|
||||
zh_Hans: 'https://docs.langbot.app/zh/usage/pipelines/readme.html',
|
||||
ja_JP: 'https://docs.langbot.app/ja/usage/pipelines/readme.html',
|
||||
en_US: 'https://docs.langbot.app/en/usage/pipelines/readme',
|
||||
zh_Hans: 'https://docs.langbot.app/zh/usage/pipelines/readme',
|
||||
ja_JP: 'https://docs.langbot.app/ja/usage/pipelines/readme',
|
||||
},
|
||||
}),
|
||||
new SidebarChildVO({
|
||||
@@ -65,8 +65,8 @@ export const sidebarConfigList = [
|
||||
route: '/home/monitoring',
|
||||
description: t('monitoring.description'),
|
||||
helpLink: {
|
||||
en_US: 'https://docs.langbot.app/en/features/monitoring.html',
|
||||
zh_Hans: 'https://docs.langbot.app/zh/features/monitoring.html',
|
||||
en_US: '',
|
||||
zh_Hans: '',
|
||||
},
|
||||
}),
|
||||
new SidebarChildVO({
|
||||
@@ -84,9 +84,9 @@ export const sidebarConfigList = [
|
||||
route: '/home/knowledge',
|
||||
description: t('knowledge.description'),
|
||||
helpLink: {
|
||||
en_US: 'https://docs.langbot.app/en/usage/knowledge/readme.html',
|
||||
zh_Hans: 'https://docs.langbot.app/zh/usage/knowledge/readme.html',
|
||||
ja_JP: 'https://docs.langbot.app/ja/usage/knowledge/readme.html',
|
||||
en_US: 'https://docs.langbot.app/en/usage/knowledge/readme',
|
||||
zh_Hans: 'https://docs.langbot.app/zh/usage/knowledge/readme',
|
||||
ja_JP: 'https://docs.langbot.app/ja/usage/knowledge/readme',
|
||||
},
|
||||
}),
|
||||
new SidebarChildVO({
|
||||
@@ -105,9 +105,9 @@ export const sidebarConfigList = [
|
||||
route: '/home/plugins',
|
||||
description: t('plugins.description'),
|
||||
helpLink: {
|
||||
en_US: 'https://docs.langbot.app/en/usage/plugin/plugin-intro.html',
|
||||
zh_Hans: 'https://docs.langbot.app/zh/usage/plugin/plugin-intro.html',
|
||||
ja_JP: 'https://docs.langbot.app/ja/usage/plugin/plugin-intro.html',
|
||||
en_US: 'https://docs.langbot.app/en/usage/plugin/plugin-intro',
|
||||
zh_Hans: 'https://docs.langbot.app/zh/usage/plugin/plugin-intro',
|
||||
ja_JP: 'https://docs.langbot.app/ja/usage/plugin/plugin-intro',
|
||||
},
|
||||
}),
|
||||
];
|
||||
|
||||
@@ -463,14 +463,16 @@ export default function ModelsDialog({
|
||||
)
|
||||
: t('models.providerCount', { count: otherProviders.length })}
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleCreateProvider}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
{t('models.addProvider')}
|
||||
</Button>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleCreateProvider}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
{t('models.addProvider')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Provider List */}
|
||||
|
||||
@@ -36,11 +36,11 @@ export default function NewVersionDialog({
|
||||
const getUpdateDocsUrl = () => {
|
||||
const language = i18n.language;
|
||||
if (language === 'zh-Hans' || language === 'zh-Hant') {
|
||||
return 'https://docs.langbot.app/zh/deploy/update.html';
|
||||
return 'https://docs.langbot.app/zh/deploy/update';
|
||||
} else if (language === 'ja-JP') {
|
||||
return 'https://docs.langbot.app/ja/deploy/update.html';
|
||||
return 'https://docs.langbot.app/ja/deploy/update';
|
||||
} else {
|
||||
return 'https://docs.langbot.app/en/deploy/update.html';
|
||||
return 'https://docs.langbot.app/en/deploy/update';
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ import { Button } from '@/components/ui/button';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
import { KnowledgeBase } from '@/app/infra/entities/api';
|
||||
import { CustomApiError } from '@/app/infra/entities/common';
|
||||
import { toast } from 'sonner';
|
||||
import KBForm from '@/app/home/knowledge/components/kb-form/KBForm';
|
||||
import KBDoc from '@/app/home/knowledge/components/kb-docs/KBDoc';
|
||||
@@ -68,7 +69,9 @@ export default function KBDetailDialog({
|
||||
setKbInfo(resp.base);
|
||||
} catch (e) {
|
||||
console.error('Failed to load KB info:', e);
|
||||
toast.error(t('knowledge.loadKnowledgeBaseFailed'));
|
||||
toast.error(
|
||||
t('knowledge.loadKnowledgeBaseFailed') + (e as CustomApiError).msg,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,7 +139,9 @@ export default function KBDetailDialog({
|
||||
onKbDeleted();
|
||||
} catch (e) {
|
||||
console.error('Failed to delete KB:', e);
|
||||
toast.error(t('knowledge.deleteKnowledgeBaseFailed'));
|
||||
toast.error(
|
||||
t('knowledge.deleteKnowledgeBaseFailed') + (e as CustomApiError).msg,
|
||||
);
|
||||
} finally {
|
||||
setShowDeleteConfirm(false);
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
import { toast } from 'sonner';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ParserInfo } from '@/app/infra/entities/api';
|
||||
import { I18nObject } from '@/app/infra/entities/common';
|
||||
import { CustomApiError, I18nObject } from '@/app/infra/entities/common';
|
||||
import { extractI18nObject } from '@/i18n/I18nProvider';
|
||||
|
||||
interface FileUploadZoneProps {
|
||||
@@ -97,7 +97,9 @@ export default function FileUploadZone({
|
||||
onUploadSuccess();
|
||||
} catch (error) {
|
||||
console.error('File upload failed:', error);
|
||||
const errorMessage = t('knowledge.documentsTab.uploadError');
|
||||
const errorMessage =
|
||||
t('knowledge.documentsTab.uploadError') +
|
||||
(error as CustomApiError).msg;
|
||||
toast.error(errorMessage, { id: toastId });
|
||||
onUploadError(errorMessage);
|
||||
} finally {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
import { KnowledgeBaseFile } from '@/app/infra/entities/api';
|
||||
import { I18nObject } from '@/app/infra/entities/common';
|
||||
import { I18nObject, CustomApiError } from '@/app/infra/entities/common';
|
||||
import { columns, DocumentFile } from './documents/columns';
|
||||
import { DataTable } from './documents/data-table';
|
||||
import FileUploadZone from './FileUploadZone';
|
||||
@@ -87,7 +87,10 @@ export default function KBDoc({
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Delete failed:', error);
|
||||
toast.error(t('knowledge.documentsTab.fileDeleteFailed'));
|
||||
toast.error(
|
||||
t('knowledge.documentsTab.fileDeleteFailed') +
|
||||
(error as CustomApiError).msg,
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import Link from 'next/link';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
@@ -23,6 +24,7 @@ import {
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { KnowledgeBase, KnowledgeEngine } from '@/app/infra/entities/api';
|
||||
import { CustomApiError } from '@/app/infra/entities/common';
|
||||
import { toast } from 'sonner';
|
||||
import { extractI18nObject } from '@/i18n/I18nProvider';
|
||||
import DynamicFormComponent from '@/app/home/components/dynamic-form/DynamicFormComponent';
|
||||
@@ -217,7 +219,10 @@ export default function KBForm({
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('update knowledge base failed', err);
|
||||
toast.error(t('knowledge.updateKnowledgeBaseFailed'));
|
||||
toast.error(
|
||||
t('knowledge.updateKnowledgeBaseFailed') +
|
||||
(err as CustomApiError).msg,
|
||||
);
|
||||
});
|
||||
} else {
|
||||
// Create knowledge base
|
||||
@@ -228,17 +233,26 @@ export default function KBForm({
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('create knowledge base failed', err);
|
||||
toast.error(t('knowledge.createKnowledgeBaseFailed'));
|
||||
toast.error(
|
||||
t('knowledge.createKnowledgeBaseFailed') +
|
||||
(err as CustomApiError).msg,
|
||||
);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// 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
|
||||
const retrievalFormItems = parseCreationSchema(
|
||||
selectedEngine?.retrieval_schema,
|
||||
const retrievalFormItems = useMemo(
|
||||
() => parseCreationSchema(selectedEngine?.retrieval_schema),
|
||||
[selectedEngine?.retrieval_schema],
|
||||
);
|
||||
|
||||
// Show loading state
|
||||
@@ -257,9 +271,12 @@ export default function KBForm({
|
||||
<p className="text-muted-foreground">
|
||||
{t('knowledge.noEnginesAvailable')}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
<Link
|
||||
href="/home/plugins"
|
||||
className="text-sm text-primary hover:underline"
|
||||
>
|
||||
{t('knowledge.installEngineHint')}
|
||||
</p>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
import { useAsyncTask, AsyncTaskStatus } from '@/hooks/useAsyncTask';
|
||||
import { toast } from 'sonner';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
interface KBMigrationDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
internalKbCount: number;
|
||||
externalKbCount: number;
|
||||
onMigrationComplete: () => void;
|
||||
}
|
||||
|
||||
export default function KBMigrationDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
internalKbCount,
|
||||
externalKbCount,
|
||||
onMigrationComplete,
|
||||
}: KBMigrationDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [dismissing, setDismissing] = useState(false);
|
||||
|
||||
const asyncTask = useAsyncTask({
|
||||
onSuccess: () => {
|
||||
toast.success(t('knowledge.migration.success'));
|
||||
onOpenChange(false);
|
||||
onMigrationComplete();
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(`${t('knowledge.migration.error')}${error}`);
|
||||
},
|
||||
});
|
||||
|
||||
const handleMigration = async (installPlugin: boolean) => {
|
||||
try {
|
||||
const resp = await httpClient.executeRagMigration(installPlugin);
|
||||
asyncTask.startTask(resp.task_id);
|
||||
} catch {
|
||||
toast.error(t('knowledge.migration.error'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleDismiss = async () => {
|
||||
setDismissing(true);
|
||||
try {
|
||||
await httpClient.dismissRagMigration();
|
||||
onOpenChange(false);
|
||||
} catch {
|
||||
toast.error(t('knowledge.migration.dismissError'));
|
||||
} finally {
|
||||
setDismissing(false);
|
||||
}
|
||||
};
|
||||
|
||||
const isRunning = asyncTask.status === AsyncTaskStatus.RUNNING;
|
||||
const isError = asyncTask.status === AsyncTaskStatus.ERROR;
|
||||
const totalCount = internalKbCount + externalKbCount;
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(v) => {
|
||||
if (!isRunning) onOpenChange(v);
|
||||
}}
|
||||
>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('knowledge.migration.title')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('knowledge.migration.description')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="py-4 space-y-3">
|
||||
{!isRunning && !isError && (
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('knowledge.migration.detected', {
|
||||
total: totalCount,
|
||||
internal: internalKbCount,
|
||||
external: externalKbCount,
|
||||
})}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{isRunning && (
|
||||
<div className="flex items-center gap-3">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-primary" />
|
||||
<p className="text-sm">{t('knowledge.migration.running')}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isError && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm text-destructive">
|
||||
{t('knowledge.migration.error')}
|
||||
</p>
|
||||
{asyncTask.error && (
|
||||
<p className="text-xs text-muted-foreground bg-muted p-2 rounded">
|
||||
{asyncTask.error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="flex flex-col gap-2 sm:flex-col">
|
||||
{!isRunning && !isError && (
|
||||
<>
|
||||
<Button onClick={() => handleMigration(true)} className="w-full">
|
||||
{t('knowledge.migration.startWithInstall')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => handleMigration(false)}
|
||||
className="w-full"
|
||||
>
|
||||
{t('knowledge.migration.startDataOnly')}
|
||||
</Button>
|
||||
<p className="text-xs text-muted-foreground text-center">
|
||||
{t('knowledge.migration.dataOnlyHint')}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
{isError && (
|
||||
<Button onClick={() => handleMigration(true)} className="w-full">
|
||||
{t('knowledge.migration.retry')}
|
||||
</Button>
|
||||
)}
|
||||
{!isRunning && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={handleDismiss}
|
||||
disabled={dismissing}
|
||||
className="w-full text-destructive hover:text-destructive"
|
||||
>
|
||||
{t('knowledge.migration.dismiss')}
|
||||
</Button>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { RetrieveResult } from '@/app/infra/entities/api';
|
||||
import { CustomApiError } from '@/app/infra/entities/common';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
interface KBRetrieveGenericProps {
|
||||
@@ -41,7 +42,7 @@ export default function KBRetrieveGeneric({
|
||||
setResults(response.results);
|
||||
} catch (error) {
|
||||
console.error('Retrieve failed:', error);
|
||||
toast.error(t('knowledge.retrieveError'));
|
||||
toast.error(t('knowledge.retrieveError') + (error as CustomApiError).msg);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
|
||||
.knowledgeListContainer {
|
||||
width: 100%;
|
||||
margin-top: 2rem;
|
||||
padding-left: 0.8rem;
|
||||
padding-right: 0.8rem;
|
||||
display: grid;
|
||||
|
||||
@@ -7,6 +7,7 @@ import { useEffect, useState } from 'react';
|
||||
import { KnowledgeBaseVO } from '@/app/home/knowledge/components/kb-card/KBCardVO';
|
||||
import KBCard from '@/app/home/knowledge/components/kb-card/KBCard';
|
||||
import KBDetailDialog from '@/app/home/knowledge/KBDetailDialog';
|
||||
import KBMigrationDialog from '@/app/home/knowledge/components/kb-migration-dialog/KBMigrationDialog';
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
import { KnowledgeBase } from '@/app/infra/entities/api';
|
||||
|
||||
@@ -18,10 +19,29 @@ export default function KnowledgePage() {
|
||||
const [selectedKbId, setSelectedKbId] = useState<string>('');
|
||||
const [detailDialogOpen, setDetailDialogOpen] = useState(false);
|
||||
|
||||
// Migration dialog state
|
||||
const [migrationDialogOpen, setMigrationDialogOpen] = useState(false);
|
||||
const [migrationInternalCount, setMigrationInternalCount] = useState(0);
|
||||
const [migrationExternalCount, setMigrationExternalCount] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
getKnowledgeBaseList();
|
||||
checkMigrationStatus();
|
||||
}, []);
|
||||
|
||||
async function checkMigrationStatus() {
|
||||
try {
|
||||
const resp = await httpClient.getRagMigrationStatus();
|
||||
if (resp.needed) {
|
||||
setMigrationInternalCount(resp.internal_kb_count);
|
||||
setMigrationExternalCount(resp.external_kb_count);
|
||||
setMigrationDialogOpen(true);
|
||||
}
|
||||
} catch {
|
||||
// Silently ignore - migration check is non-critical
|
||||
}
|
||||
}
|
||||
|
||||
async function getKnowledgeBaseList() {
|
||||
const resp = await httpClient.getKnowledgeBases();
|
||||
|
||||
@@ -85,8 +105,20 @@ export default function KnowledgePage() {
|
||||
getKnowledgeBaseList();
|
||||
};
|
||||
|
||||
const handleMigrationComplete = () => {
|
||||
getKnowledgeBaseList();
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<KBMigrationDialog
|
||||
open={migrationDialogOpen}
|
||||
onOpenChange={setMigrationDialogOpen}
|
||||
internalKbCount={migrationInternalCount}
|
||||
externalKbCount={migrationExternalCount}
|
||||
onMigrationComplete={handleMigrationComplete}
|
||||
/>
|
||||
|
||||
<KBDetailDialog
|
||||
open={detailDialogOpen}
|
||||
onOpenChange={setDetailDialogOpen}
|
||||
|
||||
@@ -120,6 +120,8 @@ export default function PipelineFormComponent({
|
||||
|
||||
// Track unsaved changes by comparing current form values against a saved snapshot
|
||||
const savedSnapshotRef = useRef<string>('');
|
||||
// Track which dynamic form stages have completed their initial mount emission.
|
||||
const initializedStagesRef = useRef<Set<string>>(new Set());
|
||||
const watchedValues = form.watch();
|
||||
const hasUnsavedChanges = useMemo(() => {
|
||||
if (!isEditMode || !savedSnapshotRef.current) return false;
|
||||
@@ -160,6 +162,7 @@ export default function PipelineFormComponent({
|
||||
};
|
||||
form.reset(loadedValues);
|
||||
savedSnapshotRef.current = JSON.stringify(loadedValues);
|
||||
initializedStagesRef.current.clear();
|
||||
});
|
||||
}
|
||||
}, []);
|
||||
@@ -235,6 +238,33 @@ export default function PipelineFormComponent({
|
||||
});
|
||||
}
|
||||
|
||||
// Called from DynamicFormComponent/N8nAuthFormComponent onSubmit callbacks.
|
||||
// On the first emission for a stage (mount-time default filling), the
|
||||
// snapshot is synchronously re-captured so that hasUnsavedChanges stays false.
|
||||
function handleDynamicFormEmit(
|
||||
formName: keyof FormValues,
|
||||
stageName: string,
|
||||
values: object,
|
||||
) {
|
||||
const stageKey = `${String(formName)}.${stageName}`;
|
||||
const isFirstEmission = !initializedStagesRef.current.has(stageKey);
|
||||
|
||||
const currentValues =
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(form.getValues(formName) as Record<string, any>) || {};
|
||||
form.setValue(formName, {
|
||||
...currentValues,
|
||||
[stageName]: values,
|
||||
});
|
||||
|
||||
if (isFirstEmission) {
|
||||
initializedStagesRef.current.add(stageKey);
|
||||
// Synchronously re-capture snapshot so that the useMemo comparison
|
||||
// in the same render cycle still returns false.
|
||||
savedSnapshotRef.current = JSON.stringify(form.getValues());
|
||||
}
|
||||
}
|
||||
|
||||
function renderDynamicForms(
|
||||
stage: PipelineConfigStage,
|
||||
formName: keyof FormValues,
|
||||
@@ -264,13 +294,7 @@ export default function PipelineFormComponent({
|
||||
{}
|
||||
}
|
||||
onSubmit={(values) => {
|
||||
const currentValues =
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(form.getValues(formName) as Record<string, any>) || {};
|
||||
form.setValue(formName, {
|
||||
...currentValues,
|
||||
[stage.name]: values,
|
||||
});
|
||||
handleDynamicFormEmit(formName, stage.name, values);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -302,13 +326,7 @@ export default function PipelineFormComponent({
|
||||
{}
|
||||
}
|
||||
onSubmit={(values) => {
|
||||
const currentValues =
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(form.getValues(formName) as Record<string, any>) || {};
|
||||
form.setValue(formName, {
|
||||
...currentValues,
|
||||
[stage.name]: values,
|
||||
});
|
||||
handleDynamicFormEmit(formName, stage.name, values);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
@@ -333,13 +351,7 @@ export default function PipelineFormComponent({
|
||||
(form.watch(formName) as Record<string, any>)?.[stage.name] || {}
|
||||
}
|
||||
onSubmit={(values) => {
|
||||
const currentValues =
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(form.getValues(formName) as Record<string, any>) || {};
|
||||
form.setValue(formName, {
|
||||
...currentValues,
|
||||
[stage.name]: values,
|
||||
});
|
||||
handleDynamicFormEmit(formName, stage.name, values);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,13 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
useState,
|
||||
useEffect,
|
||||
useCallback,
|
||||
useRef,
|
||||
Suspense,
|
||||
useMemo,
|
||||
} from 'react';
|
||||
import { useState, useEffect, useCallback, useRef, Suspense } from 'react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
Select,
|
||||
@@ -70,7 +63,7 @@ function MarketPageContent({
|
||||
RecommendationList[]
|
||||
>([]);
|
||||
|
||||
const pageSize = 16; // 每页16个,4行x4列
|
||||
const pageSize = 12; // 每页12个
|
||||
const searchTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const scrollContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
@@ -330,38 +323,7 @@ function MarketPageContent({
|
||||
};
|
||||
}, []);
|
||||
|
||||
// 计算所有推荐插件的 ID 集合
|
||||
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 visiblePlugins = plugins;
|
||||
|
||||
// 加载更多
|
||||
const loadMore = useCallback(() => {
|
||||
@@ -562,8 +524,7 @@ function MarketPageContent({
|
||||
{/* Recommendation Lists */}
|
||||
{!searchQuery &&
|
||||
componentFilter === 'all' &&
|
||||
selectedTags.length === 0 &&
|
||||
currentPage === 1 && (
|
||||
selectedTags.length === 0 && (
|
||||
<div className="pt-4">
|
||||
<RecommendationLists
|
||||
lists={recommendationLists}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
import { useState, useRef, useEffect, useCallback } from 'react';
|
||||
import { ChevronLeft, ChevronRight, Star } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import PluginMarketCardComponent from './plugin-market-card/PluginMarketCardComponent';
|
||||
@@ -18,7 +18,7 @@ export interface RecommendationList {
|
||||
plugins: PluginV4[];
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 4; // plugins per page in a recommendation row
|
||||
// Match the main plugin grid: grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4
|
||||
|
||||
function pluginToVO(
|
||||
plugin: PluginV4,
|
||||
@@ -47,18 +47,53 @@ function RecommendationListRow({
|
||||
list,
|
||||
tagNames,
|
||||
onInstall,
|
||||
isLast,
|
||||
}: {
|
||||
list: RecommendationList;
|
||||
tagNames: Record<string, string>;
|
||||
onInstall: (author: string, pluginName: string) => void;
|
||||
isLast: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [page, setPage] = useState(0);
|
||||
const [perPage, setPerPage] = useState(4);
|
||||
const gridRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const plugins = list.plugins || [];
|
||||
const totalPages = Math.ceil(plugins.length / PAGE_SIZE);
|
||||
const start = page * PAGE_SIZE;
|
||||
const visiblePlugins = plugins.slice(start, start + PAGE_SIZE);
|
||||
|
||||
// Measure how many columns the CSS grid actually renders
|
||||
const measureCols = useCallback(() => {
|
||||
if (!gridRef.current) return;
|
||||
const style = window.getComputedStyle(gridRef.current);
|
||||
const cols = style.gridTemplateColumns.split(' ').length;
|
||||
setPerPage(cols);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
measureCols();
|
||||
const observer = new ResizeObserver(measureCols);
|
||||
if (gridRef.current) observer.observe(gridRef.current);
|
||||
return () => observer.disconnect();
|
||||
}, [measureCols]);
|
||||
|
||||
// Auto-advance every 5 seconds
|
||||
useEffect(() => {
|
||||
if (plugins.length <= perPage) return;
|
||||
const timer = setInterval(() => {
|
||||
setPage((p) => {
|
||||
const tp = Math.max(1, Math.ceil(plugins.length / perPage));
|
||||
return p >= tp - 1 ? 0 : p + 1;
|
||||
});
|
||||
}, 5000);
|
||||
return () => clearInterval(timer);
|
||||
}, [plugins.length, perPage]);
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(plugins.length / perPage));
|
||||
const safePage = Math.min(page, totalPages - 1);
|
||||
if (safePage !== page) setPage(safePage);
|
||||
|
||||
const start = safePage * perPage;
|
||||
const visiblePlugins = plugins.slice(start, start + perPage);
|
||||
|
||||
if (plugins.length === 0) return null;
|
||||
|
||||
@@ -77,19 +112,19 @@ function RecommendationListRow({
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setPage((p) => Math.max(0, p - 1))}
|
||||
disabled={page === 0}
|
||||
disabled={safePage === 0}
|
||||
className="h-7 w-7 p-0"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</Button>
|
||||
<span className="text-xs text-muted-foreground px-1">
|
||||
{page + 1} / {totalPages}
|
||||
{safePage + 1} / {totalPages}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
|
||||
disabled={page >= totalPages - 1}
|
||||
disabled={safePage >= totalPages - 1}
|
||||
className="h-7 w-7 p-0"
|
||||
>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
@@ -97,7 +132,10 @@ function RecommendationListRow({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4 gap-6">
|
||||
<div
|
||||
ref={gridRef}
|
||||
className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 2xl:grid-cols-4 gap-6"
|
||||
>
|
||||
{visiblePlugins.map((plugin) => (
|
||||
<PluginMarketCardComponent
|
||||
key={plugin.author + ' / ' + plugin.name}
|
||||
@@ -107,7 +145,9 @@ function RecommendationListRow({
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{totalPages > 1 && <div className="border-b border-border mt-6" />}
|
||||
{totalPages > 1 && !isLast && (
|
||||
<div className="border-b border-border mt-6" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -125,12 +165,13 @@ export function RecommendationLists({
|
||||
|
||||
return (
|
||||
<div className="mt-6">
|
||||
{lists.map((list) => (
|
||||
{lists.map((list, index) => (
|
||||
<RecommendationListRow
|
||||
key={list.uuid}
|
||||
list={list}
|
||||
tagNames={tagNames}
|
||||
onInstall={onInstall}
|
||||
isLast={index === lists.length - 1}
|
||||
/>
|
||||
))}
|
||||
<div className="border-b border-border mb-6" />
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { PluginMarketCardVO } from './PluginMarketCardVO';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
import {
|
||||
Wrench,
|
||||
AudioWaveform,
|
||||
@@ -9,8 +15,9 @@ import {
|
||||
ExternalLink,
|
||||
Book,
|
||||
FileText,
|
||||
Info,
|
||||
} from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
|
||||
export default function PluginMarketCardComponent({
|
||||
@@ -24,6 +31,43 @@ export default function PluginMarketCardComponent({
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
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) {
|
||||
e.stopPropagation();
|
||||
@@ -46,6 +90,13 @@ export default function PluginMarketCardComponent({
|
||||
Parser: <FileText className="w-4 h-4" />,
|
||||
};
|
||||
|
||||
// Plugins that only contain KnowledgeRetriever components are deprecated
|
||||
const isDeprecated = (() => {
|
||||
if (!cardVO.components) return false;
|
||||
const keys = Object.keys(cardVO.components);
|
||||
return keys.length > 0 && keys.every((k) => k === 'KnowledgeRetriever');
|
||||
})();
|
||||
|
||||
return (
|
||||
<div
|
||||
className="w-[100%] h-auto min-h-[8rem] sm:min-h-[9rem] bg-white rounded-[10px] shadow-[0px_0px_4px_0_rgba(0,0,0,0.2)] p-3 sm:p-[1rem] hover:shadow-[0px_3px_6px_0_rgba(0,0,0,0.12)] transition-all duration-200 hover:scale-[1.005] dark:bg-[#1f1f22] relative"
|
||||
@@ -66,8 +117,34 @@ export default function PluginMarketCardComponent({
|
||||
<div className="text-[0.65rem] sm:text-[0.7rem] text-[#666] dark:text-[#999] truncate w-full">
|
||||
{cardVO.pluginId}
|
||||
</div>
|
||||
<div className="text-base sm:text-[1.2rem] text-black dark:text-[#f0f0f0] truncate w-full">
|
||||
{cardVO.label}
|
||||
<div className="flex items-center gap-1.5 w-full min-w-0">
|
||||
<div className="text-base sm:text-[1.2rem] text-black dark:text-[#f0f0f0] truncate">
|
||||
{cardVO.label}
|
||||
</div>
|
||||
{isDeprecated && (
|
||||
<TooltipProvider delayDuration={200}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
asChild
|
||||
onClick={(e) => e.preventDefault()}
|
||||
>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[0.6rem] px-1.5 py-0 h-4 flex-shrink-0 border-red-400 text-red-500 dark:border-red-500 dark:text-red-400 gap-0.5 cursor-help"
|
||||
>
|
||||
{t('market.deprecated')}
|
||||
<Info className="w-2.5 h-2.5" />
|
||||
</Badge>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
side="top"
|
||||
className="max-w-[240px] text-xs"
|
||||
>
|
||||
{t('market.deprecatedTooltip')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -95,10 +172,13 @@ export default function PluginMarketCardComponent({
|
||||
</div>
|
||||
|
||||
{/* 下部分:下载量、标签和组件列表 */}
|
||||
<div className="w-full flex flex-row items-center justify-between gap-2 px-0 sm:px-[0.4rem] flex-shrink-0">
|
||||
<div className="flex flex-row items-center justify-start gap-2 flex-wrap">
|
||||
<div
|
||||
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
|
||||
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"
|
||||
@@ -116,14 +196,14 @@ export default function PluginMarketCardComponent({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
{cardVO.tags && cardVO.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{cardVO.tags.slice(0, 2).map((tag) => (
|
||||
{/* Tags - adaptive */}
|
||||
{cardVO.tags && cardVO.tags.length > 0 && visibleTags > 0 && (
|
||||
<div className="flex flex-row items-center gap-1.5 overflow-hidden flex-shrink min-w-0">
|
||||
{cardVO.tags.slice(0, visibleTags).map((tag) => (
|
||||
<Badge
|
||||
key={tag}
|
||||
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
|
||||
className="w-2.5 h-2.5 flex-shrink-0"
|
||||
@@ -138,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" />
|
||||
<line x1="7" y1="7" x2="7.01" y2="7" />
|
||||
</svg>
|
||||
<span className="truncate">{tagNames[tag] || tag}</span>
|
||||
<span className="truncate max-w-[5rem]">
|
||||
{tagNames[tag] || tag}
|
||||
</span>
|
||||
</Badge>
|
||||
))}
|
||||
{cardVO.tags.length > 2 && (
|
||||
{remainingTags > 0 && (
|
||||
<Badge
|
||||
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>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -262,6 +262,12 @@ export interface ApiRespSystemInfo {
|
||||
limitation: SystemLimitation;
|
||||
}
|
||||
|
||||
export interface RagMigrationStatusResp {
|
||||
needed: boolean;
|
||||
internal_kb_count: number;
|
||||
external_kb_count: number;
|
||||
}
|
||||
|
||||
export interface ApiRespPluginSystemStatus {
|
||||
is_enable: boolean;
|
||||
is_connected: boolean;
|
||||
|
||||
@@ -35,6 +35,7 @@ export enum DynamicFormItemType {
|
||||
SELECT = 'select',
|
||||
LLM_MODEL_SELECTOR = 'llm-model-selector',
|
||||
EMBEDDING_MODEL_SELECTOR = 'embedding-model-selector',
|
||||
MODEL_FALLBACK_SELECTOR = 'model-fallback-selector',
|
||||
PROMPT_EDITOR = 'prompt-editor',
|
||||
UNKNOWN = 'unknown',
|
||||
KNOWLEDGE_BASE_SELECTOR = 'knowledge-base-selector',
|
||||
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
ModelProvider,
|
||||
ApiRespKnowledgeEngines,
|
||||
ApiRespParsers,
|
||||
RagMigrationStatusResp,
|
||||
} from '@/app/infra/entities/api';
|
||||
import { Plugin } from '@/app/infra/entities/plugin';
|
||||
import { GetBotLogsRequest } from '@/app/infra/http/requestParam/bots/GetBotLogsRequest';
|
||||
@@ -355,6 +356,7 @@ export class BackendClient extends BaseHttpClient {
|
||||
is_active: boolean;
|
||||
platform: string | null;
|
||||
user_id: string | null;
|
||||
user_name: string | null;
|
||||
}>;
|
||||
total: number;
|
||||
}> {
|
||||
@@ -383,6 +385,7 @@ export class BackendClient extends BaseHttpClient {
|
||||
level: string;
|
||||
platform: string | null;
|
||||
user_id: string | null;
|
||||
user_name: string | null;
|
||||
runner_name: string | null;
|
||||
variables: string | null;
|
||||
role: string | null;
|
||||
@@ -710,6 +713,23 @@ export class BackendClient extends BaseHttpClient {
|
||||
return this.get('/api/v1/system/status/plugin-system');
|
||||
}
|
||||
|
||||
// ============ RAG Migration API ============
|
||||
public getRagMigrationStatus(): Promise<RagMigrationStatusResp> {
|
||||
return this.get('/api/v1/knowledge/migration/status');
|
||||
}
|
||||
|
||||
public executeRagMigration(
|
||||
installPlugin: boolean = true,
|
||||
): Promise<AsyncTaskCreatedResp> {
|
||||
return this.post('/api/v1/knowledge/migration/execute', {
|
||||
install_plugin: installPlugin,
|
||||
});
|
||||
}
|
||||
|
||||
public dismissRagMigration(): Promise<object> {
|
||||
return this.post('/api/v1/knowledge/migration/dismiss');
|
||||
}
|
||||
|
||||
public getPluginDebugInfo(): Promise<{
|
||||
debug_url: string;
|
||||
plugin_debug_key: string;
|
||||
|
||||
@@ -284,6 +284,27 @@ export default function Login() {
|
||||
</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>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -253,6 +253,27 @@ export default function Register() {
|
||||
</Button>
|
||||
</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>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -47,6 +47,12 @@ const enUS = {
|
||||
copyFailed: 'Copy Failed',
|
||||
test: 'Test',
|
||||
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...',
|
||||
fieldRequired: 'This field is required',
|
||||
or: 'or',
|
||||
@@ -230,6 +236,11 @@ const enUS = {
|
||||
modelsCount: '{{count}} model(s)',
|
||||
expandModels: 'Expand',
|
||||
collapseModels: 'Collapse',
|
||||
fallback: {
|
||||
primary: 'Primary Model',
|
||||
fallbackList: 'Fallback Models',
|
||||
addFallback: 'Add Fallback Model',
|
||||
},
|
||||
},
|
||||
bots: {
|
||||
title: 'Bots',
|
||||
@@ -483,6 +494,9 @@ const enUS = {
|
||||
allComponents: 'All Components',
|
||||
requestPlugin: 'Request Plugin',
|
||||
viewDetails: 'View Details',
|
||||
deprecated: 'Deprecated',
|
||||
deprecatedTooltip:
|
||||
'Please install the corresponding Knowledge Engine plugin.',
|
||||
tags: {
|
||||
filterByTags: 'Filter by Tags',
|
||||
selected: 'selected',
|
||||
@@ -707,7 +721,7 @@ const enUS = {
|
||||
cannotChangeEmbeddingModel:
|
||||
'Knowledge base created cannot be modified embedding model',
|
||||
updateKnowledgeBaseSuccess: 'Knowledge base updated successfully',
|
||||
updateKnowledgeBaseFailed: 'Knowledge base update failed',
|
||||
updateKnowledgeBaseFailed: 'Knowledge base update failed: ',
|
||||
documentsTab: {
|
||||
name: 'Name',
|
||||
status: 'Status',
|
||||
@@ -717,14 +731,14 @@ const enUS = {
|
||||
supportedFormats:
|
||||
'Supports PDF, Word, TXT, Markdown, HTML, ZIP and other document formats',
|
||||
uploadSuccess: 'File uploaded successfully!',
|
||||
uploadError: 'File upload failed, please try again',
|
||||
uploadError: 'File upload failed: ',
|
||||
uploadingFile: 'Uploading file...',
|
||||
fileSizeExceeded:
|
||||
'File size exceeds 10MB limit. Please split into smaller files.',
|
||||
actions: 'Actions',
|
||||
delete: 'Delete File',
|
||||
fileDeleteSuccess: 'File deleted successfully',
|
||||
fileDeleteFailed: 'File deletion failed',
|
||||
fileDeleteFailed: 'File deletion failed: ',
|
||||
processing: 'Processing',
|
||||
completed: 'Completed',
|
||||
failed: 'Failed',
|
||||
@@ -745,7 +759,7 @@ const enUS = {
|
||||
content: 'Content',
|
||||
fileName: 'File Name',
|
||||
noResults: 'No results',
|
||||
retrieveError: 'Retrieve failed',
|
||||
retrieveError: 'Retrieve failed: ',
|
||||
unknownEngine: 'Unknown Engine',
|
||||
knowledgeEngine: 'Knowledge Engine',
|
||||
knowledgeEngineRequired: 'Knowledge engine is required',
|
||||
@@ -757,10 +771,10 @@ const enUS = {
|
||||
engineSettingsReadonly: 'read-only in edit mode',
|
||||
retrievalSettings: 'Retrieval Settings',
|
||||
noEnginesAvailable: 'No knowledge base engines available',
|
||||
installEngineHint: 'Please install a knowledge base plugin first',
|
||||
createKnowledgeBaseFailed: 'Failed to create knowledge base',
|
||||
loadKnowledgeBaseFailed: 'Failed to load knowledge base',
|
||||
deleteKnowledgeBaseFailed: 'Failed to delete knowledge base',
|
||||
installEngineHint: 'Please install a "Knowledge Engine" plugin first',
|
||||
createKnowledgeBaseFailed: 'Failed to create knowledge base: ',
|
||||
loadKnowledgeBaseFailed: 'Failed to load knowledge base: ',
|
||||
deleteKnowledgeBaseFailed: 'Failed to delete knowledge base: ',
|
||||
getKnowledgeBaseListError: 'Failed to get knowledge base list: ',
|
||||
embeddingModel: 'Embedding Model',
|
||||
embeddingModelRequired: 'Embedding model is required for this engine',
|
||||
@@ -773,6 +787,23 @@ const enUS = {
|
||||
retrieverConfiguration: 'Retriever Configuration',
|
||||
retrieverInstallInfo: 'You can install Knowledge Retriever plugins from',
|
||||
retrieverMarketLink: 'here',
|
||||
migration: {
|
||||
title: 'Knowledge Base Migration',
|
||||
description:
|
||||
'The new version has refactored the knowledge base into a plugin-based architecture, unifying built-in and external knowledge bases as "Knowledge Engine" plugins. Migration of legacy knowledge base data is required. Your old data has been automatically backed up in the database.',
|
||||
detected:
|
||||
'Found {{total}} knowledge base(s) to migrate ({{internal}} internal, {{external}} external).',
|
||||
startWithInstall: 'Auto-install Plugin & Migrate',
|
||||
startDataOnly: 'Migrate Data Only',
|
||||
dataOnlyHint:
|
||||
'"Migrate Data Only" is for offline/intranet environments. Please install the corresponding plugin manually after migration.',
|
||||
dismiss: 'Discard Original Data',
|
||||
running: 'Migrating knowledge bases, please wait...',
|
||||
success: 'Knowledge base migration completed',
|
||||
error: 'Knowledge base migration failed: ',
|
||||
dismissError: 'Operation failed',
|
||||
retry: 'Retry',
|
||||
},
|
||||
},
|
||||
register: {
|
||||
title: 'Initialize LangBot 👋',
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
const jaJP = {
|
||||
const jaJP = {
|
||||
common: {
|
||||
login: 'ログイン',
|
||||
logout: 'ログアウト',
|
||||
@@ -48,6 +48,12 @@ const jaJP = {
|
||||
copyFailed: 'コピーに失敗しました',
|
||||
test: 'テスト',
|
||||
forgotPassword: 'パスワードを忘れた?',
|
||||
agreementNotice: '続行することで、以下に同意したものとみなされます:',
|
||||
privacyPolicy: 'プライバシーポリシー',
|
||||
and: 'および',
|
||||
dataCollectionPolicy: 'データ収集ポリシー',
|
||||
dataCollectionPolicyUrl:
|
||||
'https://docs.langbot.app/ja/insight/data-collection-policy',
|
||||
loading: '読み込み中...',
|
||||
fieldRequired: 'この項目は必須です',
|
||||
or: 'または',
|
||||
@@ -235,6 +241,11 @@ const jaJP = {
|
||||
modelsCount: '{{count}} 個のモデル',
|
||||
expandModels: '展開',
|
||||
collapseModels: '折りたたむ',
|
||||
fallback: {
|
||||
primary: 'プライマリモデル',
|
||||
fallbackList: 'フォールバックモデル',
|
||||
addFallback: 'フォールバックモデルを追加',
|
||||
},
|
||||
},
|
||||
bots: {
|
||||
title: 'ボット',
|
||||
@@ -491,6 +502,9 @@ const jaJP = {
|
||||
noTags: 'タグがありません',
|
||||
},
|
||||
viewDetails: '詳細を表示',
|
||||
deprecated: '非推奨',
|
||||
deprecatedTooltip:
|
||||
'対応する「ナレッジエンジン」プラグインをインストールしてください。',
|
||||
},
|
||||
mcp: {
|
||||
title: 'MCP',
|
||||
@@ -709,7 +723,7 @@ const jaJP = {
|
||||
cannotChangeEmbeddingModel:
|
||||
'知識ベース作成後は埋め込みモデルを変更できません',
|
||||
updateKnowledgeBaseSuccess: '知識ベースの更新に成功しました',
|
||||
updateKnowledgeBaseFailed: '知識ベースの更新に失敗しました',
|
||||
updateKnowledgeBaseFailed: '知識ベースの更新に失敗しました:',
|
||||
documentsTab: {
|
||||
name: '名前',
|
||||
status: 'ステータス',
|
||||
@@ -720,14 +734,14 @@ const jaJP = {
|
||||
supportedFormats:
|
||||
'PDF、Word、TXT、Markdownなどのドキュメントファイルをサポートしています',
|
||||
uploadSuccess: 'ファイルのアップロードに成功しました!',
|
||||
uploadError: 'ファイルのアップロードに失敗しました。再度お試しください',
|
||||
uploadError: 'ファイルのアップロードに失敗しました:',
|
||||
uploadingFile: 'ファイルをアップロード中...',
|
||||
fileSizeExceeded:
|
||||
'ファイルサイズが10MBの制限を超えています。より小さいファイルに分割してください。',
|
||||
actions: 'アクション',
|
||||
delete: 'ドキュメントを削除',
|
||||
fileDeleteSuccess: 'ドキュメントの削除に成功しました',
|
||||
fileDeleteFailed: 'ドキュメントの削除に失敗しました',
|
||||
fileDeleteFailed: 'ドキュメントの削除に失敗しました:',
|
||||
processing: '処理中',
|
||||
completed: '完了',
|
||||
failed: '失敗',
|
||||
@@ -748,10 +762,13 @@ const jaJP = {
|
||||
content: '内容',
|
||||
fileName: 'ファイル名',
|
||||
noResults: '検索結果がありません',
|
||||
retrieveError: '検索に失敗しました',
|
||||
retrieveError: '検索に失敗しました:',
|
||||
noEnginesAvailable: '利用可能なナレッジエンジンがありません',
|
||||
installEngineHint:
|
||||
'先に「ナレッジエンジン」プラグインをインストールしてください',
|
||||
unknownEngine: '不明なエンジン',
|
||||
loadKnowledgeBaseFailed: 'ナレッジベースの読み込みに失敗しました',
|
||||
deleteKnowledgeBaseFailed: 'ナレッジベースの削除に失敗しました',
|
||||
loadKnowledgeBaseFailed: 'ナレッジベースの読み込みに失敗しました:',
|
||||
deleteKnowledgeBaseFailed: 'ナレッジベースの削除に失敗しました:',
|
||||
getKnowledgeBaseListError: 'ナレッジベース一覧の取得に失敗しました:',
|
||||
addExternal: '外部ナレッジベースを追加',
|
||||
createExternalSuccess: '外部ナレッジベースが正常に作成されました',
|
||||
@@ -762,6 +779,23 @@ const jaJP = {
|
||||
retrieverConfiguration: '検索器設定',
|
||||
retrieverInstallInfo: 'ナレッジ検索器プラグインは',
|
||||
retrieverMarketLink: 'こちらからインストールできます',
|
||||
migration: {
|
||||
title: 'ナレッジベースの移行',
|
||||
description:
|
||||
'新バージョンではナレッジベースをプラグインベースのアーキテクチャに再構築し、内蔵ナレッジベースと外部ナレッジベースを「ナレッジエンジン」プラグインとして統合しました。旧ナレッジベースデータの移行が必要です。旧データはデータベースに自動的にバックアップされています。',
|
||||
detected:
|
||||
'移行が必要なナレッジベースが{{total}}件見つかりました(内部{{internal}}件、外部{{external}}件)。',
|
||||
startWithInstall: 'プラグインを自動インストールして移行',
|
||||
startDataOnly: 'データのみ移行',
|
||||
dataOnlyHint:
|
||||
'「データのみ移行」はオフライン環境向けです。移行完了後に対応するプラグインを手動でインストールしてください。',
|
||||
dismiss: '元データを破棄',
|
||||
running: 'ナレッジベースを移行中です。しばらくお待ちください...',
|
||||
success: 'ナレッジベースの移行が完了しました',
|
||||
error: 'ナレッジベースの移行に失敗しました:',
|
||||
dismissError: '操作に失敗しました',
|
||||
retry: 'リトライ',
|
||||
},
|
||||
},
|
||||
register: {
|
||||
title: 'LangBot を初期化 👋',
|
||||
|
||||
@@ -47,6 +47,12 @@ const zhHans = {
|
||||
copyFailed: '复制失败',
|
||||
test: '测试',
|
||||
forgotPassword: '忘记密码?',
|
||||
agreementNotice: '继续即表示您同意我们的',
|
||||
privacyPolicy: '隐私政策',
|
||||
and: '和',
|
||||
dataCollectionPolicy: '数据收集政策',
|
||||
dataCollectionPolicyUrl:
|
||||
'https://docs.langbot.app/zh/insight/data-collection-policy',
|
||||
loading: '加载中...',
|
||||
fieldRequired: '此字段为必填项',
|
||||
or: '或',
|
||||
@@ -221,6 +227,11 @@ const zhHans = {
|
||||
modelsCount: '{{count}} 个模型',
|
||||
expandModels: '展开',
|
||||
collapseModels: '收起',
|
||||
fallback: {
|
||||
primary: '主模型',
|
||||
fallbackList: '备用模型',
|
||||
addFallback: '添加备用模型',
|
||||
},
|
||||
},
|
||||
bots: {
|
||||
title: '机器人',
|
||||
@@ -468,6 +479,8 @@ const zhHans = {
|
||||
noTags: '暂无标签',
|
||||
},
|
||||
viewDetails: '查看详情',
|
||||
deprecated: '已弃用',
|
||||
deprecatedTooltip: '请安装对应「知识引擎」插件',
|
||||
},
|
||||
mcp: {
|
||||
title: 'MCP',
|
||||
@@ -679,7 +692,7 @@ const zhHans = {
|
||||
updateTime: '更新于',
|
||||
cannotChangeEmbeddingModel: '知识库创建后不可修改嵌入模型',
|
||||
updateKnowledgeBaseSuccess: '知识库更新成功',
|
||||
updateKnowledgeBaseFailed: '知识库更新失败',
|
||||
updateKnowledgeBaseFailed: '知识库更新失败:',
|
||||
documentsTab: {
|
||||
name: '名称',
|
||||
status: '状态',
|
||||
@@ -688,13 +701,13 @@ const zhHans = {
|
||||
uploading: '上传中...',
|
||||
supportedFormats: '支持 PDF、Word、TXT、Markdown、HTML、ZIP 等文档格式',
|
||||
uploadSuccess: '文件上传成功!',
|
||||
uploadError: '文件上传失败,请重试',
|
||||
uploadError: '文件上传失败:',
|
||||
uploadingFile: '上传文件中...',
|
||||
fileSizeExceeded: '文件大小超过 10MB 限制,请分割成较小的文件后上传',
|
||||
actions: '操作',
|
||||
delete: '删除文件',
|
||||
fileDeleteSuccess: '文件删除成功',
|
||||
fileDeleteFailed: '文件删除失败',
|
||||
fileDeleteFailed: '文件删除失败:',
|
||||
processing: '处理中',
|
||||
completed: '完成',
|
||||
failed: '失败',
|
||||
@@ -715,7 +728,7 @@ const zhHans = {
|
||||
content: '内容',
|
||||
fileName: '文件名',
|
||||
noResults: '暂无结果',
|
||||
retrieveError: '检索失败',
|
||||
retrieveError: '检索失败:',
|
||||
unknownEngine: '未知引擎',
|
||||
knowledgeEngine: '知识引擎',
|
||||
knowledgeEngineRequired: '知识引擎不能为空',
|
||||
@@ -726,10 +739,10 @@ const zhHans = {
|
||||
engineSettingsReadonly: '编辑模式下不可修改',
|
||||
retrievalSettings: '检索设置',
|
||||
noEnginesAvailable: '没有可用的知识库引擎',
|
||||
installEngineHint: '请先安装知识库插件',
|
||||
createKnowledgeBaseFailed: '知识库创建失败',
|
||||
loadKnowledgeBaseFailed: '知识库加载失败',
|
||||
deleteKnowledgeBaseFailed: '知识库删除失败',
|
||||
installEngineHint: '请先安装「知识引擎」插件',
|
||||
createKnowledgeBaseFailed: '知识库创建失败:',
|
||||
loadKnowledgeBaseFailed: '知识库加载失败:',
|
||||
deleteKnowledgeBaseFailed: '知识库删除失败:',
|
||||
getKnowledgeBaseListError: '获取知识库列表失败:',
|
||||
embeddingModel: '嵌入模型',
|
||||
embeddingModelRequired: '此引擎需要选择嵌入模型',
|
||||
@@ -742,6 +755,23 @@ const zhHans = {
|
||||
retrieverConfiguration: '检索器配置',
|
||||
retrieverInstallInfo: '您可以从',
|
||||
retrieverMarketLink: '此处安装知识检索器插件',
|
||||
migration: {
|
||||
title: '知识库迁移',
|
||||
description:
|
||||
'新版本已将知识库重构为插件化架构,并统一内置知识库和外部知识库为「知识引擎」插件,需要对旧知识库数据进行迁移。您的旧数据已自动备份在数据库中。',
|
||||
detected:
|
||||
'共检测到 {{total}} 个知识库需要迁移({{internal}} 个内置知识库,{{external}} 个外部知识库)。',
|
||||
startWithInstall: '自动安装插件并迁移',
|
||||
startDataOnly: '仅迁移数据',
|
||||
dataOnlyHint:
|
||||
'「仅迁移数据」适合内网环境使用,请在迁移完成后自行安装对应插件',
|
||||
dismiss: '丢弃原数据',
|
||||
running: '正在迁移知识库,请稍候...',
|
||||
success: '知识库迁移完成',
|
||||
error: '知识库迁移失败:',
|
||||
dismissError: '操作失败',
|
||||
retry: '重试',
|
||||
},
|
||||
},
|
||||
register: {
|
||||
title: '初始化 LangBot 👋',
|
||||
|
||||
@@ -47,6 +47,12 @@ const zhHant = {
|
||||
copyFailed: '複製失敗',
|
||||
test: '測試',
|
||||
forgotPassword: '忘記密碼?',
|
||||
agreementNotice: '繼續即表示您同意我們的',
|
||||
privacyPolicy: '隱私政策',
|
||||
and: '和',
|
||||
dataCollectionPolicy: '數據收集政策',
|
||||
dataCollectionPolicyUrl:
|
||||
'https://docs.langbot.app/zh/insight/data-collection-policy',
|
||||
loading: '載入中...',
|
||||
fieldRequired: '此欄位為必填',
|
||||
or: '或',
|
||||
@@ -220,6 +226,11 @@ const zhHant = {
|
||||
modelsCount: '{{count}} 個模型',
|
||||
expandModels: '展開',
|
||||
collapseModels: '收起',
|
||||
fallback: {
|
||||
primary: '主模型',
|
||||
fallbackList: '備用模型',
|
||||
addFallback: '新增備用模型',
|
||||
},
|
||||
},
|
||||
bots: {
|
||||
title: '機器人',
|
||||
@@ -461,6 +472,8 @@ const zhHant = {
|
||||
noTags: '暫無標籤',
|
||||
},
|
||||
viewDetails: '查看詳情',
|
||||
deprecated: '已棄用',
|
||||
deprecatedTooltip: '請安裝對應「知識引擎」插件',
|
||||
},
|
||||
mcp: {
|
||||
title: 'MCP',
|
||||
@@ -672,7 +685,7 @@ const zhHant = {
|
||||
updateTime: '更新於',
|
||||
cannotChangeEmbeddingModel: '知識庫建立後不可修改嵌入模型',
|
||||
updateKnowledgeBaseSuccess: '知識庫更新成功',
|
||||
updateKnowledgeBaseFailed: '知識庫更新失敗',
|
||||
updateKnowledgeBaseFailed: '知識庫更新失敗:',
|
||||
documentsTab: {
|
||||
name: '名稱',
|
||||
status: '狀態',
|
||||
@@ -681,13 +694,13 @@ const zhHant = {
|
||||
uploading: '上傳中...',
|
||||
supportedFormats: '支援 PDF、Word、TXT、Markdown 等文檔格式',
|
||||
uploadSuccess: '文檔上傳成功!',
|
||||
uploadError: '文檔上傳失敗,請重試',
|
||||
uploadError: '文檔上傳失敗:',
|
||||
uploadingFile: '上傳文檔中...',
|
||||
fileSizeExceeded: '檔案大小超過 10MB 限制,請分割成較小的檔案後上傳',
|
||||
actions: '操作',
|
||||
delete: '刪除文檔',
|
||||
fileDeleteSuccess: '文檔刪除成功',
|
||||
fileDeleteFailed: '文檔刪除失敗',
|
||||
fileDeleteFailed: '文檔刪除失敗:',
|
||||
processing: '處理中',
|
||||
completed: '完成',
|
||||
failed: '失敗',
|
||||
@@ -708,10 +721,12 @@ const zhHant = {
|
||||
content: '內容',
|
||||
fileName: '文檔名稱',
|
||||
noResults: '暫無結果',
|
||||
retrieveError: '檢索失敗',
|
||||
retrieveError: '檢索失敗:',
|
||||
noEnginesAvailable: '沒有可用的知識庫引擎',
|
||||
installEngineHint: '請先安裝「知識引擎」插件',
|
||||
unknownEngine: '未知引擎',
|
||||
loadKnowledgeBaseFailed: '知識庫載入失敗',
|
||||
deleteKnowledgeBaseFailed: '知識庫刪除失敗',
|
||||
loadKnowledgeBaseFailed: '知識庫載入失敗:',
|
||||
deleteKnowledgeBaseFailed: '知識庫刪除失敗:',
|
||||
getKnowledgeBaseListError: '取得知識庫列表失敗:',
|
||||
addExternal: '添加外部知識庫',
|
||||
createExternalSuccess: '外部知識庫創建成功',
|
||||
@@ -722,6 +737,23 @@ const zhHant = {
|
||||
retrieverConfiguration: '檢索器配置',
|
||||
retrieverInstallInfo: '您可以從',
|
||||
retrieverMarketLink: '此處安裝知識檢索器插件',
|
||||
migration: {
|
||||
title: '知識庫遷移',
|
||||
description:
|
||||
'新版本已將知識庫重構為插件化架構,並統一內建知識庫和外部知識庫為「知識引擎」插件,需要對舊知識庫資料進行遷移。您的舊資料已自動備份在資料庫中。',
|
||||
detected:
|
||||
'共檢測到 {{total}} 個知識庫需要遷移({{internal}} 個內建知識庫,{{external}} 個外部知識庫)。',
|
||||
startWithInstall: '自動安裝插件並遷移',
|
||||
startDataOnly: '僅遷移資料',
|
||||
dataOnlyHint:
|
||||
'「僅遷移資料」適合內網環境使用,請在遷移完成後自行安裝對應插件',
|
||||
dismiss: '丟棄原數據',
|
||||
running: '正在遷移知識庫,請稍候...',
|
||||
success: '知識庫遷移完成',
|
||||
error: '知識庫遷移失敗:',
|
||||
dismissError: '操作失敗',
|
||||
retry: '重試',
|
||||
},
|
||||
},
|
||||
register: {
|
||||
title: '初始化 LangBot 👋',
|
||||
|
||||
Reference in New Issue
Block a user