Compare commits

...

15 Commits

Author SHA1 Message Date
huanghuoguoguo
aa4b5d6732 fix(plugin): validate plugin id format 2026-05-16 10:36:58 +08:00
RockChinQ
b251fc4b89 fix(plugin): resolve plugin page asset origin 2026-05-14 15:39:17 +08:00
Junyan Qin
075c85e2bc chore: bump version 4.9.7 2026-05-12 23:48:52 +08:00
Junyan Qin
62b63ca2ca chore: bump langbot plugin to 0.3.11 2026-05-12 23:47:35 +08:00
fdc310
3680a80248 feat(lark): implement message sending functionality in LarkAdapter 2026-05-12 18:28:34 +08:00
fdc310
6713b57d01 feat: enhance API key normalization and improve Space OAuth callback handling 2026-05-11 15:03:30 +08:00
fdc310
ea13ef87f2 feat(provider): add API key normalization and update OpenAI requester initialization 2026-05-11 14:21:42 +08:00
fdc310
59bd581e88 feat(i18n): add 'recommend' and 'start' keys for Spanish, Russian, Thai, and Vietnamese locales 2026-05-11 10:31:32 +08:00
fdc310
cba83a62e8 feat(i18n): add Feishu, WeChat, DingTalk, and WeCombot support in multiple languages 2026-05-11 10:08:16 +08:00
Dongchuan Fu
f412127fb0 feat: add one-click app creation for Feishu/dingding/wexin/wecombot with QR code support (#2165)
* feat: add one-click app creation for Feishu with QR code support

* feat: implement WeChat QR code login functionality and update related configurations

* feat: add qrcode dependency for QR code generation support

* feat: enhance QR code login UI and add internationalization support for new labels

* feat: new ui back

* feat: add DingTalk one-click app creation and QR code login support

* feat: add WeComBot one-click creation support and enhance QR code login functionality

* feat: Update the robot creation function and bind the most recently updated pipeline
2026-05-10 22:31:31 +08:00
huanghuoguoguo
5273bbb23f feat(i18n): add missing i18n keys for knowledge validation messages
Add engineSettingsInvalid and retrievalSettingsInvalid keys to all
locale files (zh-Hant, ja-JP, vi-VN, es-ES, ru-RU, th-TH) for the
new dynamic form validation feature.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-10 18:29:22 +08:00
huanghuoguoguo
0ceab3f6a5 feat(knowledge): validate required fields based on plugin schema
Add business-agnostic validation for knowledge base creation:
- Backend: dynamically validate required fields from plugin's creation_schema
  and retrieval_schema, with support for show_if conditional fields
- Frontend: expose validation function from DynamicFormComponent and
  validate before KBForm submission
- Add i18n translations for validation error messages

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-10 18:25:28 +08:00
RockChinQ
aedc097188 fix(plugin): update runtime PyPI index defaults 2026-05-09 15:26:53 +08:00
RockChinQ
18b27dd9ef fix(plugin): use runtime dependency failure fix 2026-05-09 14:56:56 +08:00
RockChinQ
3f50a56623 fix(plugin): surface dependency install failures 2026-05-09 14:42:05 +08:00
43 changed files with 6074 additions and 3586 deletions

View File

@@ -1,6 +1,6 @@
[project]
name = "langbot"
version = "4.9.6"
version = "4.9.7"
description = "Production-grade platform for building agentic IM bots"
readme = "README.md"
license-files = ["LICENSE"]
@@ -22,7 +22,7 @@ dependencies = [
"discord-py>=2.5.2",
"pynacl>=1.5.0", # Required for Discord voice support
"gewechat-client>=0.1.5",
"lark-oapi>=1.4.15",
"lark-oapi>=1.5.5",
"mcp>=1.25.0",
"nakuru-project-idk>=0.0.2.1",
"ollama>=0.4.8",
@@ -35,6 +35,7 @@ dependencies = [
"python-telegram-bot>=22.0",
"pyyaml>=6.0.2",
"qq-botpy-rc>=1.2.1.6",
"qrcode>=7.4",
"quart>=0.20.0",
"quart-cors>=0.8.0",
"requests>=2.32.3",
@@ -69,7 +70,7 @@ dependencies = [
"chromadb>=1.0.0,<2.0.0",
"qdrant-client (>=1.15.1,<2.0.0)",
"pyseekdb==1.1.0.post3",
"langbot-plugin==0.3.10",
"langbot-plugin==0.3.11",
"asyncpg>=0.30.0",
"line-bot-sdk>=3.19.0",
"matrix-nio>=0.25.2",

View File

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

View File

@@ -1,5 +1,6 @@
import quart
import mimetypes
import asyncio
from ... import group
from langbot.pkg.utils import importutil
@@ -35,3 +36,640 @@ class AdaptersRouterGroup(group.RouterGroup):
return quart.Response(
importutil.read_resource_file_bytes(icon_path), mimetype=mimetypes.guess_type(icon_path)[0]
)
# In-memory session store for active registrations
_create_app_sessions: dict = {}
_SESSION_TTL = 900 # 15 minutes
def _cleanup_expired_sessions():
"""Remove sessions that have exceeded their TTL."""
import time
now = time.time()
expired = [sid for sid, s in _create_app_sessions.items() if now - s.get('created_at', 0) > _SESSION_TTL]
for sid in expired:
session = _create_app_sessions.pop(sid, None)
if session and session.get('task') and not session['task'].done():
session['task'].cancel()
@self.route('/lark/create-app', methods=['POST'])
async def _() -> str:
"""Start Feishu one-click app registration. Returns session_id + QR code URL."""
import uuid
import time
import lark_oapi as lark
from lark_oapi.scene.registration.errors import AppAccessDeniedError, AppExpiredError
_cleanup_expired_sessions()
session_id = str(uuid.uuid4())
loop = asyncio.get_running_loop()
session = {
'status': 'pending',
'qr_url': None,
'expire_at': None,
'app_id': None,
'app_secret': None,
'error': None,
'created_at': time.time(),
}
_create_app_sessions[session_id] = session
def on_qr_code(info):
# May be called from a background thread by the SDK;
# use call_soon_threadsafe to safely update session state.
def _update():
session['qr_url'] = info['url']
session['expire_at'] = time.time() + 600 # 10 minutes
session['status'] = 'waiting'
loop.call_soon_threadsafe(_update)
async def run_registration():
try:
result = await lark.aregister_app(
on_qr_code=on_qr_code,
source='langbot',
)
session['status'] = 'success'
session['app_id'] = result['client_id']
session['app_secret'] = result['client_secret']
except AppAccessDeniedError:
session['status'] = 'error'
session['error'] = 'User denied authorization'
except AppExpiredError:
session['status'] = 'error'
session['error'] = 'QR code expired'
except Exception as e:
session['status'] = 'error'
session['error'] = str(e)
task = asyncio.create_task(run_registration())
session['task'] = task
# Wait for QR code to be ready (max 10 seconds)
for _ in range(20):
if session['qr_url']:
break
await asyncio.sleep(0.5)
if not session['qr_url']:
task.cancel()
session['status'] = 'error'
session['error'] = 'Timeout waiting for QR code'
return self.http_status(504, -1, 'Timeout waiting for QR code')
return self.success(
data={
'session_id': session_id,
'qr_url': session['qr_url'],
'expire_at': session['expire_at'],
}
)
@self.route('/lark/create-app/status/<session_id>', methods=['GET'])
async def _(session_id: str) -> str:
"""Poll registration status."""
session = _create_app_sessions.get(session_id)
if not session:
return self.http_status(404, -1, 'Session not found')
data = {'status': session['status']}
if session['status'] == 'success':
data['app_id'] = session['app_id']
data['app_secret'] = session['app_secret']
_create_app_sessions.pop(session_id, None)
elif session['status'] == 'error':
data['error'] = session['error']
_create_app_sessions.pop(session_id, None)
return self.success(data=data)
@self.route('/lark/create-app/<session_id>', methods=['DELETE'])
async def _(session_id: str) -> str:
"""Cancel and clean up a registration session."""
session = _create_app_sessions.pop(session_id, None)
if session and session.get('task') and not session['task'].done():
session['task'].cancel()
return self.success(data={})
# -----------------------------------------------------------------------
# WeChat QR Code Login
# -----------------------------------------------------------------------
_weixin_login_sessions: dict = {}
_WEIXIN_SESSION_TTL = 600 # 10 minutes (3 retries × 3 min QR validity)
def _cleanup_expired_weixin_sessions():
import time
now = time.time()
expired = [
sid for sid, s in _weixin_login_sessions.items() if now - s.get('created_at', 0) > _WEIXIN_SESSION_TTL
]
for sid in expired:
session = _weixin_login_sessions.pop(sid, None)
if session and session.get('task') and not session['task'].done():
session['task'].cancel()
@self.route('/weixin/login', methods=['POST'])
async def _() -> str:
"""Start WeChat QR code login. Returns session_id + QR code data URL."""
import uuid
import time
import io
import base64
from langbot.libs.openclaw_weixin_api.client import OpenClawWeixinClient, DEFAULT_BASE_URL
_cleanup_expired_weixin_sessions()
session_id = str(uuid.uuid4())
loop = asyncio.get_running_loop()
session = {
'status': 'pending',
'qr_data_url': None,
'expire_at': None,
'token': None,
'base_url': None,
'account_id': None,
'error': None,
'created_at': time.time(),
}
_weixin_login_sessions[session_id] = session
client = OpenClawWeixinClient(
base_url=DEFAULT_BASE_URL,
token='',
)
async def run_login():
try:
import qrcode as qr_lib
for _attempt in range(3):
qr_resp = await client.fetch_qrcode()
if not qr_resp.qrcode or not qr_resp.qrcode_img_content:
raise Exception('Failed to get QR code from server')
# Generate QR code image locally
qr = qr_lib.QRCode(error_correction=qr_lib.constants.ERROR_CORRECT_L)
qr.add_data(qr_resp.qrcode_img_content)
qr.make(fit=True)
img = qr.make_image(fill_color='black', back_color='white')
buf = io.BytesIO()
img.save(buf, format='PNG')
b64 = base64.b64encode(buf.getvalue()).decode('utf-8')
data_url = f'data:image/png;base64,{b64}'
def _update_qr():
session['qr_data_url'] = data_url
session['expire_at'] = time.time() + 480 # 8 minutes
session['status'] = 'waiting'
loop.call_soon_threadsafe(_update_qr)
# Poll for scan status
deadline = loop.time() + 180
while loop.time() < deadline:
try:
status_resp = await client.poll_qrcode_status(qr_resp.qrcode)
except Exception:
await asyncio.sleep(2)
continue
if status_resp.status == 'confirmed' and status_resp.bot_token:
session['status'] = 'success'
session['token'] = status_resp.bot_token
session['base_url'] = status_resp.baseurl or client.base_url
session['account_id'] = status_resp.ilink_bot_id or ''
return
if status_resp.status == 'expired':
break # retry with new QR code
await asyncio.sleep(1)
else:
pass # timeout, retry
# All retries exhausted
session['status'] = 'error'
session['error'] = 'QR code login failed: max retries exceeded'
except Exception as e:
session['status'] = 'error'
session['error'] = str(e)
finally:
await client.close()
task = asyncio.create_task(run_login())
session['task'] = task
# Wait for QR code to be ready (max 10 seconds)
for _ in range(20):
if session['qr_data_url']:
break
await asyncio.sleep(0.5)
if not session['qr_data_url']:
task.cancel()
session['status'] = 'error'
session['error'] = 'Timeout waiting for QR code'
return self.http_status(504, -1, 'Timeout waiting for QR code')
return self.success(
data={
'session_id': session_id,
'qr_data_url': session['qr_data_url'],
'expire_at': session['expire_at'],
}
)
@self.route('/weixin/login/status/<session_id>', methods=['GET'])
async def _(session_id: str) -> str:
"""Poll WeChat login status."""
session = _weixin_login_sessions.get(session_id)
if not session:
return self.http_status(404, -1, 'Session not found')
data = {'status': session['status']}
if session['status'] == 'success':
data['token'] = session['token']
data['base_url'] = session['base_url']
data['account_id'] = session['account_id']
_weixin_login_sessions.pop(session_id, None)
elif session['status'] == 'error':
data['error'] = session['error']
_weixin_login_sessions.pop(session_id, None)
return self.success(data=data)
@self.route('/weixin/login/<session_id>', methods=['DELETE'])
async def _(session_id: str) -> str:
"""Cancel and clean up a WeChat login session."""
session = _weixin_login_sessions.pop(session_id, None)
if session and session.get('task') and not session['task'].done():
session['task'].cancel()
return self.success(data={})
# -----------------------------------------------------------------------
# DingTalk Device Flow QR Code Login
# -----------------------------------------------------------------------
_dingtalk_sessions: dict = {}
_DINGTALK_SESSION_TTL = 600 # 10 minutes (QR code validity window)
def _cleanup_expired_dingtalk_sessions():
import time
now = time.time()
expired = [
sid for sid, s in _dingtalk_sessions.items() if now - s.get('created_at', 0) > _DINGTALK_SESSION_TTL
]
for sid in expired:
session = _dingtalk_sessions.pop(sid, None)
if session and session.get('task') and not session['task'].done():
session['task'].cancel()
@self.route('/dingtalk/create-app', methods=['POST'])
async def _() -> str:
"""Start DingTalk one-click app creation via Device Flow. Returns session_id + QR code URL."""
import uuid
import time
import aiohttp
DINGTALK_BASE_URL = 'https://oapi.dingtalk.com'
_cleanup_expired_dingtalk_sessions()
session_id = str(uuid.uuid4())
session = {
'status': 'pending',
'qr_url': None,
'expire_at': None,
'client_id': None,
'client_secret': None,
'error': None,
'created_at': time.time(),
'device_code': None,
'interval': 5,
}
_dingtalk_sessions[session_id] = session
async def run_device_flow():
try:
timeout = aiohttp.ClientTimeout(total=10)
async with aiohttp.ClientSession(timeout=timeout) as http:
# Step 1: Init — get nonce
async with http.post(
f'{DINGTALK_BASE_URL}/app/registration/init',
json={'source': 'langbot'},
) as resp:
try:
data = await resp.json()
except (aiohttp.ContentTypeError, ValueError):
session['status'] = 'error'
session['error'] = 'Invalid response from DingTalk service'
return
if data.get('errcode', -1) != 0:
session['status'] = 'error'
session['error'] = data.get('errmsg', 'Failed to init')
return
nonce = data['nonce']
# Step 2: Begin — get device_code + QR URL
async with http.post(
f'{DINGTALK_BASE_URL}/app/registration/begin',
json={'nonce': nonce},
) as resp:
try:
data = await resp.json()
except (aiohttp.ContentTypeError, ValueError):
session['status'] = 'error'
session['error'] = 'Invalid response from DingTalk service'
return
if data.get('errcode', -1) != 0:
session['status'] = 'error'
session['error'] = data.get('errmsg', 'Failed to begin authorization')
return
device_code = data['device_code']
verification_uri_complete = data.get('verification_uri_complete', '')
expires_in = data.get('expires_in', 7200)
interval = data.get('interval', 5)
session['device_code'] = device_code
session['interval'] = interval
session['qr_url'] = verification_uri_complete
session['expire_at'] = time.time() + 600 # QR code valid for ~10 min
session['status'] = 'waiting'
# Step 3: Poll for authorization result
deadline = time.time() + expires_in
while time.time() < deadline:
await asyncio.sleep(interval)
async with http.post(
f'{DINGTALK_BASE_URL}/app/registration/poll',
json={'device_code': device_code},
) as poll_resp:
try:
poll_data = await poll_resp.json()
except (aiohttp.ContentTypeError, ValueError):
continue
if poll_data.get('errcode', -1) != 0:
session['status'] = 'error'
session['error'] = poll_data.get('errmsg', 'Poll failed')
return
status = poll_data.get('status', '')
if status == 'SUCCESS':
session['status'] = 'success'
session['client_id'] = poll_data.get('client_id', '')
session['client_secret'] = poll_data.get('client_secret', '')
return
elif status == 'FAIL':
session['status'] = 'error'
session['error'] = poll_data.get('fail_reason', 'Authorization failed')
return
elif status == 'EXPIRED':
session['status'] = 'error'
session['error'] = 'QR code expired'
return
# status == 'WAITING': continue polling
# Timeout
session['status'] = 'error'
session['error'] = 'QR code expired'
except asyncio.CancelledError:
return
except Exception as e:
session['status'] = 'error'
session['error'] = str(e)
task = asyncio.create_task(run_device_flow())
session['task'] = task
# Wait for QR code to be ready (max 10 seconds)
for _ in range(20):
if session['qr_url'] or session['error']:
break
await asyncio.sleep(0.5)
if session['error']:
task.cancel()
return self.http_status(502, -1, session['error'])
if not session['qr_url']:
task.cancel()
session['status'] = 'error'
session['error'] = 'Timeout waiting for QR code'
return self.http_status(504, -1, 'Timeout waiting for QR code')
return self.success(
data={
'session_id': session_id,
'qr_url': session['qr_url'],
'expire_at': session['expire_at'],
}
)
@self.route('/dingtalk/create-app/status/<session_id>', methods=['GET'])
async def _(session_id: str) -> str:
"""Poll DingTalk Device Flow status."""
_cleanup_expired_dingtalk_sessions()
session = _dingtalk_sessions.get(session_id)
if not session:
return self.http_status(404, -1, 'Session not found')
data = {'status': session['status']}
if session['status'] == 'success':
data['client_id'] = session['client_id']
data['client_secret'] = session['client_secret']
_dingtalk_sessions.pop(session_id, None)
elif session['status'] == 'error':
data['error'] = session['error']
_dingtalk_sessions.pop(session_id, None)
return self.success(data=data)
@self.route('/dingtalk/create-app/<session_id>', methods=['DELETE'])
async def _(session_id: str) -> str:
"""Cancel and clean up a DingTalk Device Flow session."""
session = _dingtalk_sessions.pop(session_id, None)
if session and session.get('task') and not session['task'].done():
session['task'].cancel()
return self.success(data={})
# -----------------------------------------------------------------------
# WeComBot QR Code One-Click Create
# -----------------------------------------------------------------------
_wecombot_sessions: dict = {}
_WECOMBOT_SESSION_TTL = 300 # 5 minutes (WeCom QR validity window)
def _cleanup_expired_wecombot_sessions():
import time
now = time.time()
expired = [
sid for sid, s in _wecombot_sessions.items() if now - s.get('created_at', 0) > _WECOMBOT_SESSION_TTL
]
for sid in expired:
session = _wecombot_sessions.pop(sid, None)
if session and session.get('task') and not session['task'].done():
session['task'].cancel()
@self.route('/wecombot/create-bot', methods=['POST'])
async def _() -> str:
"""Start WeComBot one-click creation via QR code. Returns session_id + QR code URL."""
import uuid
import time
import aiohttp
WECOM_QC_GENERATE_URL = 'https://work.weixin.qq.com/ai/qc/generate'
WECOM_QC_QUERY_URL = 'https://work.weixin.qq.com/ai/qc/query_result'
_cleanup_expired_wecombot_sessions()
session_id = str(uuid.uuid4())
session = {
'status': 'pending',
'qr_url': None,
'expire_at': None,
'botid': None,
'secret': None,
'error': None,
'created_at': time.time(),
'scode': None,
'task': None,
}
_wecombot_sessions[session_id] = session
async def run_qr_flow():
try:
timeout = aiohttp.ClientTimeout(total=10)
async with aiohttp.ClientSession(timeout=timeout) as http:
# Step 1: Generate QR code
async with http.get(
f'{WECOM_QC_GENERATE_URL}?source=langbot&plat=0',
) as resp:
try:
data = await resp.json()
except (aiohttp.ContentTypeError, ValueError):
session['status'] = 'error'
session['error'] = 'Invalid response from WeCom service'
return
if not data.get('data', {}).get('scode') or not data.get('data', {}).get('auth_url'):
session['status'] = 'error'
session['error'] = data.get('errmsg', 'Failed to generate QR code')
return
scode = data['data']['scode']
auth_url = data['data']['auth_url']
session['scode'] = scode
session['qr_url'] = auth_url
session['expire_at'] = time.time() + _WECOMBOT_SESSION_TTL
session['status'] = 'waiting'
# Step 2: Poll for scan result
deadline = time.time() + _WECOMBOT_SESSION_TTL
while time.time() < deadline:
await asyncio.sleep(3)
async with http.get(
f'{WECOM_QC_QUERY_URL}?scode={scode}',
) as poll_resp:
try:
poll_data = await poll_resp.json()
except (aiohttp.ContentTypeError, ValueError):
continue
status = poll_data.get('data', {}).get('status', '')
if status == 'success':
bot_info = poll_data.get('data', {}).get('bot_info', {})
if bot_info.get('botid') and bot_info.get('secret'):
session['status'] = 'success'
session['botid'] = bot_info['botid']
session['secret'] = bot_info['secret']
return
else:
session['status'] = 'error'
session['error'] = 'Scan succeeded but bot info is incomplete'
return
# Timeout
session['status'] = 'error'
session['error'] = 'QR code expired'
except asyncio.CancelledError:
return
except Exception as e:
session['status'] = 'error'
session['error'] = str(e)
task = asyncio.create_task(run_qr_flow())
session['task'] = task
# Wait for QR code to be ready (max 10 seconds)
for _ in range(20):
if session['qr_url'] or session['error']:
break
await asyncio.sleep(0.5)
if session['error']:
task.cancel()
return self.http_status(502, -1, session['error'])
if not session['qr_url']:
task.cancel()
session['status'] = 'error'
session['error'] = 'Timeout waiting for QR code'
return self.http_status(504, -1, 'Timeout waiting for QR code')
return self.success(
data={
'session_id': session_id,
'qr_url': session['qr_url'],
'expire_at': session['expire_at'],
}
)
@self.route('/wecombot/create-bot/status/<session_id>', methods=['GET'])
async def _(session_id: str) -> str:
"""Poll WeComBot creation status."""
_cleanup_expired_wecombot_sessions()
session = _wecombot_sessions.get(session_id)
if not session:
return self.http_status(404, -1, 'Session not found')
data = {'status': session['status']}
if session['status'] == 'success':
data['botid'] = session['botid']
data['secret'] = session['secret']
_wecombot_sessions.pop(session_id, None)
elif session['status'] == 'error':
data['error'] = session['error']
_wecombot_sessions.pop(session_id, None)
return self.success(data=data)
@self.route('/wecombot/create-bot/<session_id>', methods=['DELETE'])
async def _(session_id: str) -> str:
"""Cancel and clean up a WeComBot creation session."""
session = _wecombot_sessions.pop(session_id, None)
if session and session.get('task') and not session['task'].done():
session['task'].cancel()
return self.success(data={})

View File

@@ -39,6 +39,16 @@ def _normalize_plugin_asset_path(filepath: str) -> str | None:
return f'assets/{normalized}'
def _get_request_origin() -> str:
"""Return the public request origin, respecting reverse-proxy headers."""
forwarded_proto = quart.request.headers.get('X-Forwarded-Proto', '').split(',')[0].strip()
forwarded_host = quart.request.headers.get('X-Forwarded-Host', '').split(',')[0].strip()
scheme = forwarded_proto or quart.request.scheme
host = forwarded_host or quart.request.host
return f'{scheme}://{host}'
@group.group_class('plugins', '/api/v1/plugins')
class PluginsRouterGroup(group.RouterGroup):
async def _check_extensions_limit(self) -> str | None:
@@ -189,7 +199,7 @@ class PluginsRouterGroup(group.RouterGroup):
# CSP for HTML pages served to sandboxed iframes (opaque origin).
# 'self' doesn't work in sandboxed iframes — use actual server origin.
if mime_type and mime_type.startswith('text/html'):
origin = f'{quart.request.scheme}://{quart.request.host}'
origin = _get_request_origin()
resp.headers['Content-Security-Policy'] = (
f'default-src {origin}; '
f"script-src {origin} 'unsafe-inline'; "

View File

@@ -146,6 +146,7 @@ class UserRouterGroup(group.RouterGroup):
return self.fail(3, str(e))
except ValueError as e:
traceback.print_exc()
self.ap.logger.warning(f'Space OAuth callback failed: {e}')
return self.fail(1, str(e))
except Exception as e:
traceback.print_exc()

View File

@@ -99,11 +99,11 @@ class BotService:
# TODO: 检查配置信息格式
bot_data['uuid'] = str(uuid.uuid4())
# checkout the default pipeline
# bind the most recently updated pipeline if any exist
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(persistence_pipeline.LegacyPipeline).where(
persistence_pipeline.LegacyPipeline.is_default == True
)
sqlalchemy.select(persistence_pipeline.LegacyPipeline)
.order_by(persistence_pipeline.LegacyPipeline.updated_at.desc())
.limit(1)
)
pipeline = result.first()
if pipeline is not None:

View File

@@ -31,15 +31,126 @@ class KnowledgeService:
if not knowledge_engine_plugin_id:
raise ValueError('knowledge_engine_plugin_id is required')
creation_settings = kb_data.get('creation_settings', {})
retrieval_settings = kb_data.get('retrieval_settings', {})
# Validate required fields based on plugin's creation_schema and retrieval_schema
await self._validate_schema_required_fields(
knowledge_engine_plugin_id,
creation_settings,
retrieval_settings,
)
kb = await self.ap.rag_mgr.create_knowledge_base(
name=kb_data.get('name', 'Untitled'),
knowledge_engine_plugin_id=knowledge_engine_plugin_id,
creation_settings=kb_data.get('creation_settings', {}),
retrieval_settings=kb_data.get('retrieval_settings', {}),
creation_settings=creation_settings,
retrieval_settings=retrieval_settings,
description=kb_data.get('description', ''),
)
return kb.uuid
async def _validate_schema_required_fields(
self,
plugin_id: str,
creation_settings: dict,
retrieval_settings: dict,
) -> None:
"""Validate required fields based on plugin's creation_schema and retrieval_schema.
This is a business-agnostic validation that checks all fields marked as
required in the plugin's schema, regardless of field type.
Args:
plugin_id: Knowledge Engine plugin ID.
creation_settings: User-provided creation settings.
retrieval_settings: User-provided retrieval settings.
Raises:
ValueError: If any required field is missing or empty.
"""
# Validate creation_schema
try:
creation_schema = await self.ap.plugin_connector.get_rag_creation_schema(plugin_id)
self._check_required_fields(creation_schema, creation_settings, 'creation_settings')
except ValueError:
raise
except Exception as e:
self.ap.logger.warning(f'Failed to get creation_schema for validation: {e}')
# Validate retrieval_schema
try:
retrieval_schema = await self.ap.plugin_connector.get_rag_retrieval_schema(plugin_id)
self._check_required_fields(retrieval_schema, retrieval_settings, 'retrieval_settings')
except ValueError:
raise
except Exception as e:
self.ap.logger.warning(f'Failed to get retrieval_schema for validation: {e}')
def _check_required_fields(
self,
schema: dict | list,
settings: dict,
context: str,
) -> None:
"""Check required fields in schema against provided settings.
Args:
schema: Plugin-defined schema (can be list or dict with 'schema' key).
settings: User-provided settings values.
context: Context name for error messages (e.g., 'creation_settings').
Raises:
ValueError: If a required field is missing or empty.
"""
if not schema:
return
# schema can be a list directly, or a dict with 'schema' key
items = schema if isinstance(schema, list) else schema.get('schema', [])
if not items:
return
for item in items:
field_name = item.get('name')
if not field_name:
continue
is_required = item.get('required', False)
if not is_required:
continue
# Check show_if condition - if field is conditionally shown, only validate when condition is met
show_if = item.get('show_if')
if show_if:
depend_field = show_if.get('field')
operator = show_if.get('operator')
expected_value = show_if.get('value')
if depend_field and operator:
depend_value = settings.get(depend_field)
# If show_if condition is not met, skip validation for this field
if operator == 'eq' and depend_value != expected_value:
continue
if operator == 'neq' and depend_value == expected_value:
continue
if operator == 'in' and isinstance(expected_value, list) and depend_value not in expected_value:
continue
value = settings.get(field_name)
# Validate required field has a non-empty value
if value is None or (isinstance(value, str) and value.strip() == ''):
# Get field label for friendly error message
label = item.get('label', {})
field_label = (
label.get('en_US', field_name)
or label.get('zh_Hans', field_name)
or label.get('zh_Hant', field_name)
or field_name
)
raise ValueError(f'{field_label} is required ({context}.{field_name})')
async def update_knowledge_base(self, kb_uuid: str, kb_data: dict) -> None:
"""更新知识库"""
# Filter to only mutable fields

View File

@@ -17,6 +17,24 @@ class ModelProviderService:
def __init__(self, ap: app.Application) -> None:
self.ap = ap
@staticmethod
def _normalize_api_keys(api_keys: str | list[str] | tuple[str, ...] | None) -> list[str]:
if api_keys is None:
return []
raw_keys = [api_keys] if isinstance(api_keys, str) else list(api_keys)
normalized_keys = []
seen_keys = set()
for raw_key in raw_keys:
normalized_key = raw_key.strip() if isinstance(raw_key, str) else ''
if not normalized_key or normalized_key in seen_keys:
continue
normalized_keys.append(normalized_key)
seen_keys.add(normalized_key)
return normalized_keys
async def get_providers(self) -> list[dict]:
"""Get all providers"""
result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_model.ModelProvider))
@@ -59,6 +77,7 @@ class ModelProviderService:
async def create_provider(self, provider_data: dict) -> str:
"""Create a new provider"""
provider_data['uuid'] = str(uuid.uuid4())
provider_data['api_keys'] = self._normalize_api_keys(provider_data.get('api_keys'))
await self.ap.persistence_mgr.execute_async(
sqlalchemy.insert(persistence_model.ModelProvider).values(**provider_data)
)
@@ -72,6 +91,8 @@ class ModelProviderService:
"""Update an existing provider"""
if 'uuid' in provider_data:
del provider_data['uuid']
if 'api_keys' in provider_data:
provider_data['api_keys'] = self._normalize_api_keys(provider_data.get('api_keys'))
await self.ap.persistence_mgr.execute_async(
sqlalchemy.update(persistence_model.ModelProvider)
.where(persistence_model.ModelProvider.uuid == provider_uuid)
@@ -141,6 +162,8 @@ class ModelProviderService:
async def find_or_create_provider(self, requester: str, base_url: str, api_keys: list) -> str:
"""Find existing provider or create new one"""
api_keys = self._normalize_api_keys(api_keys)
# Try to find existing provider with same config
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(persistence_model.ModelProvider).where(
@@ -168,7 +191,7 @@ class ModelProviderService:
'name': provider_name,
'requester': requester,
'base_url': base_url,
'api_keys': api_keys or [],
'api_keys': api_keys,
}
)
@@ -177,7 +200,7 @@ class ModelProviderService:
await self.ap.persistence_mgr.execute_async(
sqlalchemy.update(persistence_model.ModelProvider)
.where(persistence_model.ModelProvider.uuid == '00000000-0000-0000-0000-000000000000')
.values(api_keys=[api_key])
.values(api_keys=self._normalize_api_keys(api_key))
)
await self.ap.model_mgr.reload_provider('00000000-0000-0000-0000-000000000000')

View File

@@ -19,6 +19,18 @@ spec:
en: https://link.langbot.app/en/platforms/dingtalk
ja: https://link.langbot.app/ja/platforms/dingtalk
config:
- name: one-click-create
label:
en_US: One-Click Create App
zh_Hans: 一键创建应用
zh_Hant: 一鍵建立應用
description:
en_US: "Scan QR code with DingTalk to automatically create an app and fill in credentials. Note: Robot Code cannot be obtained automatically, you need to copy it from the DingTalk Developer Backend manually."
zh_Hans: "使用钉钉扫码自动创建应用并填写凭据。注意:机器人代码无法自动获取,需前往钉钉开发者后台手动复制。"
zh_Hant: "使用釘釘掃碼自動建立應用並填寫憑證。注意:機器人代碼無法自動取得,需前往釘釘開發者後台手動複製。"
type: qr-code-login
login_platform: dingtalk
required: false
- name: client_id
label:
en_US: Client ID
@@ -40,6 +52,10 @@ spec:
en_US: Robot Code
zh_Hans: 机器人代码
zh_Hant: 機器人代碼
description:
en_US: "Required for image recognition, file upload and other features. Get it from DingTalk Developer Backend > Robot Configuration."
zh_Hans: "识图、上传文件等功能必填。请前往钉钉开发者后台 > 机器人配置中获取。"
zh_Hant: "識圖、上傳檔案等功能必填。請前往釘釘開發者後台 > 機器人設定中取得。"
type: string
required: true
default: ""

View File

@@ -1025,7 +1025,90 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
return api_client
async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain):
pass
text_elements, media_items = await self.message_converter.yiri2target(message, self.api_client)
# Map standard target_type to Feishu receive_id_type
if target_type == 'person':
receive_id_type = 'open_id'
elif target_type == 'group':
receive_id_type = 'chat_id'
else:
receive_id_type = target_type
# Send text message if there are text elements
if text_elements:
needs_post = any(ele['tag'] == 'at' for paragraph in text_elements for ele in paragraph)
if needs_post:
msg_type = 'post'
final_content = json.dumps(
{
'zh_Hans': {
'title': '',
'content': text_elements,
},
}
)
else:
msg_type = 'text'
parts = []
for paragraph in text_elements:
para_text = ''.join(ele.get('text', '') for ele in paragraph)
if para_text:
parts.append(para_text)
final_content = json.dumps({'text': '\n\n'.join(parts)})
request: CreateMessageRequest = (
CreateMessageRequest.builder()
.receive_id_type(receive_id_type)
.request_body(
CreateMessageRequestBody.builder()
.receive_id(target_id)
.content(final_content)
.msg_type(msg_type)
.uuid(str(uuid.uuid4()))
.build()
)
.build()
)
app_access_token = self.get_app_access_token()
req_opt: RequestOption = (
RequestOption.builder().app_ticket(self.app_ticket).app_access_token(app_access_token).build()
)
response: CreateMessageResponse = self.api_client.im.v1.message.create(request, req_opt)
if not response.success():
raise Exception(
f'client.im.v1.message.create failed, code: {response.code}, msg: {response.msg}, log_id: {response.get_log_id()}, resp: \n{json.dumps(json.loads(response.raw.content), indent=4, ensure_ascii=False)}'
)
# Send media messages separately (image, audio, file, etc.)
for media in media_items:
request: CreateMessageRequest = (
CreateMessageRequest.builder()
.receive_id_type(receive_id_type)
.request_body(
CreateMessageRequestBody.builder()
.receive_id(target_id)
.content(json.dumps(media['content']))
.msg_type(media['msg_type'])
.uuid(str(uuid.uuid4()))
.build()
)
.build()
)
app_access_token = self.get_app_access_token()
req_opt: RequestOption = (
RequestOption.builder().app_ticket(self.app_ticket).app_access_token(app_access_token).build()
)
response: CreateMessageResponse = self.api_client.im.v1.message.create(request, req_opt)
if not response.success():
raise Exception(
f'client.im.v1.message.create ({media["msg_type"]}) failed, code: {response.code}, msg: {response.msg}, log_id: {response.get_log_id()}, resp: \n{json.dumps(json.loads(response.raw.content), indent=4, ensure_ascii=False)}'
)
async def is_stream_output_supported(self) -> bool:
is_stream = False

View File

@@ -23,6 +23,20 @@ spec:
en: https://link.langbot.app/en/platforms/lark
ja: https://link.langbot.app/ja/platforms/lark
config:
- name: one-click-create
label:
en_US: One-Click Create App
zh_Hans: 一键创建应用
zh_Hant: 一鍵建立應用
ja_JP: ワンクリックでアプリ作成
description:
en_US: Scan QR code to automatically create a Feishu app and fill in credentials
zh_Hans: 扫码自动创建飞书应用并填写凭据
zh_Hant: 掃碼自動建立飛書應用並填寫憑證
ja_JP: QRコードをスキャンしてFeishuアプリを自動作成し、認証情報を入力
type: qr-code-login
login_platform: feishu
required: false
- name: app_id
label:
en_US: App ID

View File

@@ -32,6 +32,20 @@ spec:
type: string
required: true
default: "https://ilinkai.weixin.qq.com"
- name: qr-login
label:
en_US: Scan QR Login
zh_Hans: 扫码登录
zh_Hant: 掃碼登入
ja_JP: QRコードでログイン
description:
en_US: Scan QR code with WeChat to authorize and automatically fill in the token
zh_Hans: 使用微信扫码授权,自动填写令牌
zh_Hant: 使用微信掃碼授權,自動填寫令牌
ja_JP: WeChatでQRコードをスキャンし、トークンを自動入力
type: qr-code-login
login_platform: weixin
required: false
- name: token
label:
en_US: Token

View File

@@ -27,10 +27,7 @@ class WebPageBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
listeners: dict = pydantic.Field(default_factory=dict, exclude=True)
_ws_adapter: typing.Any = None
class Config:
arbitrary_types_allowed = True
# Allow private attributes
underscore_attrs_are_private = True
model_config = pydantic.ConfigDict(arbitrary_types_allowed=True)
def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger, **kwargs):
super().__init__(config=config, logger=logger, **kwargs)

View File

@@ -19,6 +19,18 @@ spec:
en: https://link.langbot.app/en/platforms/wecombot
ja: https://link.langbot.app/ja/platforms/wecombot
config:
- name: one-click-create
label:
en_US: One-Click Create Bot
zh_Hans: 一键创建机器人
zh_Hant: 一鍵建立機器人
description:
en_US: "Scan QR code with WeCom to automatically create a bot and fill in BotId and Secret. Note: Robot Name needs to be filled in manually."
zh_Hans: "使用企业微信扫码自动创建机器人并填写 BotId 和 Secret。注意机器人名称需手动填写。"
zh_Hant: "使用企業微信掃碼自動建立機器人並填寫 BotId 和 Secret。注意機器人名稱需手動填寫。"
type: qr-code-login
login_platform: wecombot
required: false
- name: BotId
label:
en_US: BotId

View File

@@ -11,6 +11,7 @@ import os
import sys
import httpx
import sqlalchemy
import yaml
from async_lru import alru_cache
from langbot_plugin.api.entities.builtin.pipeline.query import provider_session
@@ -195,40 +196,110 @@ class PluginRuntimeConnector:
return await self.handler.ping()
def _extract_deps_metadata(
def _inspect_plugin_package(
self,
file_bytes: bytes,
task_context: taskmgr.TaskContext | None,
):
"""Extract dependency count from requirements.txt inside plugin zip."""
if task_context is None:
return
) -> tuple[str | None, str | None]:
"""Extract plugin identity and dependency metadata from a plugin package."""
plugin_author = None
plugin_name = None
try:
with zipfile.ZipFile(io.BytesIO(file_bytes)) as zf:
for name in zf.namelist():
if name.endswith('requirements.txt'):
content = zf.read(name).decode('utf-8', errors='ignore')
deps = [
line.strip()
for line in content.splitlines()
if line.strip() and not line.strip().startswith('#')
]
task_context.metadata['deps_total'] = len(deps)
task_context.metadata['deps_list'] = deps
break
try:
manifest = yaml.safe_load(zf.read('manifest.yaml').decode('utf-8', errors='ignore')) or {}
metadata = manifest.get('metadata', {})
plugin_author = metadata.get('author')
plugin_name = metadata.get('name')
except Exception:
pass
if task_context is not None:
for name in zf.namelist():
if name.endswith('requirements.txt'):
content = zf.read(name).decode('utf-8', errors='ignore')
deps = [
line.strip()
for line in content.splitlines()
if line.strip() and not line.strip().startswith('#')
]
task_context.metadata['deps_total'] = len(deps)
task_context.metadata['deps_list'] = deps
break
except Exception:
pass
return plugin_author, plugin_name
def _build_plugin_startup_failure_message(
self,
plugin_author: str,
plugin_name: str,
task_context: taskmgr.TaskContext | None,
) -> str:
dep_hint = ''
if task_context is not None:
current_dep = task_context.metadata.get('current_dep')
if current_dep:
dep_hint = f' Last dependency: {current_dep}.'
return (
f'Plugin {plugin_author}/{plugin_name} failed to start after installation. '
f'Dependency installation or plugin initialization may have failed.{dep_hint} '
f'Please check the plugin requirements and runtime logs.'
)
async def _wait_for_installed_plugin_ready(
self,
plugin_author: str | None,
plugin_name: str | None,
task_context: taskmgr.TaskContext | None,
timeout: float = 30,
):
"""Wait until the installed plugin is registered by the runtime.
The plugin runtime launches plugins asynchronously. If dependency installation
fails, the plugin process exits before registration; without this check the
install task can incorrectly finish successfully.
"""
if not plugin_author or not plugin_name:
return
deadline = time.time() + timeout
last_error: Exception | None = None
while time.time() < deadline:
try:
plugin = await self.get_plugin_info(plugin_author, plugin_name)
if plugin is not None:
status = plugin.get('status')
if status == 'initialized':
return
except Exception as e:
last_error = e
await asyncio.sleep(0.5)
message = self._build_plugin_startup_failure_message(plugin_author, plugin_name, task_context)
if last_error is not None:
message = f'{message} Last runtime error: {last_error}'
raise RuntimeError(message)
async def install_plugin(
self,
install_source: PluginInstallSource,
install_info: dict[str, Any],
task_context: taskmgr.TaskContext | None = None,
):
plugin_author = install_info.get('plugin_author')
plugin_name = install_info.get('plugin_name')
if install_source == PluginInstallSource.LOCAL:
# transfer file before install
file_bytes = install_info['plugin_file']
self._extract_deps_metadata(file_bytes, task_context)
plugin_author, plugin_name = self._inspect_plugin_package(file_bytes, task_context)
if task_context is not None and plugin_author and plugin_name:
task_context.metadata['plugin_name'] = f'{plugin_author}/{plugin_name}'
file_key = await self.handler.send_file(file_bytes, 'lbpkg')
install_info['plugin_file_key'] = file_key
del install_info['plugin_file']
@@ -265,7 +336,9 @@ class PluginRuntimeConnector:
task_context.metadata['download_speed'] = downloaded / elapsed if elapsed > 0 else 0
file_bytes = b''.join(chunks)
self._extract_deps_metadata(file_bytes, task_context)
plugin_author, plugin_name = self._inspect_plugin_package(file_bytes, task_context)
if task_context is not None and plugin_author and plugin_name:
task_context.metadata['plugin_name'] = f'{plugin_author}/{plugin_name}'
file_key = await self.handler.send_file(file_bytes, 'lbpkg')
install_info['plugin_file_key'] = file_key
self.ap.logger.info(f'Transfered file {file_key} to plugin runtime')
@@ -289,6 +362,8 @@ class PluginRuntimeConnector:
if metadata is not None and task_context is not None:
task_context.metadata.update(metadata)
await self._wait_for_installed_plugin_ready(plugin_author, plugin_name, task_context)
async def upgrade_plugin(
self,
plugin_author: str,
@@ -558,11 +633,12 @@ class PluginRuntimeConnector:
Raises:
ValueError: If plugin_id is not in the expected 'author/name' format.
"""
if '/' not in plugin_id:
segments = plugin_id.split('/')
if len(segments) != 2 or not all(segments):
raise ValueError(
f"Invalid plugin_id format: '{plugin_id}'. Expected 'author/name' format (e.g. 'langbot/rag-engine')."
)
return plugin_id.split('/', 1)
return segments[0], segments[1]
async def call_rag_ingest(self, plugin_id: str, context_data: dict[str, Any]) -> dict[str, Any]:
"""Call plugin to ingest document.

View File

@@ -340,6 +340,7 @@ class ProviderAPIRequester(metaclass=abc.ABCMeta):
"""Provider API请求器"""
name: str = None
init_api_key: str = 'langbot-init-placeholder'
ap: app.Application

View File

@@ -25,7 +25,7 @@ class OpenAIChatCompletions(requester.ProviderAPIRequester):
async def initialize(self):
self.client = openai.AsyncClient(
api_key='',
api_key=self.init_api_key,
base_url=self.requester_cfg['base_url'].replace(' ', ''),
timeout=self.requester_cfg['timeout'],
http_client=httpx.AsyncClient(trust_env=True, timeout=self.requester_cfg['timeout']),

View File

@@ -25,7 +25,7 @@ class ModelScopeChatCompletions(requester.ProviderAPIRequester):
async def initialize(self):
self.client = openai.AsyncClient(
api_key='',
api_key=self.init_api_key,
base_url=self.requester_cfg['base_url'],
timeout=self.requester_cfg['timeout'],
http_client=httpx.AsyncClient(trust_env=True, timeout=self.requester_cfg['timeout']),

View File

@@ -14,7 +14,14 @@ class TokenManager:
def __init__(self, name: str, tokens: list[str]):
self.name = name
self.tokens = tokens
self.tokens = []
seen_tokens = set()
for token in tokens:
normalized_token = token.strip() if isinstance(token, str) else ''
if not normalized_token or normalized_token in seen_tokens:
continue
self.tokens.append(normalized_token)
seen_tokens.add(normalized_token)
self.using_token_index = 0
def get_token(self) -> str:

View File

@@ -0,0 +1,25 @@
"""Test plugin ID parsing validation."""
import pytest
from src.langbot.pkg.plugin.connector import PluginRuntimeConnector
def test_parse_plugin_id_accepts_author_name():
assert PluginRuntimeConnector._parse_plugin_id('langbot/rag-engine') == ('langbot', 'rag-engine')
@pytest.mark.parametrize(
'plugin_id',
[
'',
'author',
'author/',
'/name',
'author/name/extra',
'/',
],
)
def test_parse_plugin_id_rejects_malformed_ids(plugin_id):
with pytest.raises(ValueError, match='Expected'):
PluginRuntimeConnector._parse_plugin_id(plugin_id)

View File

@@ -11,10 +11,14 @@ import langbot_plugin.api.entities.builtin.platform.message as platform_message
import langbot_plugin.api.entities.builtin.provider.session as provider_session
from langbot.pkg.api.http.service.model import _runtime_model_data
from langbot.pkg.api.http.service.provider import ModelProviderService
from langbot.pkg.entity.persistence import model as persistence_model
from langbot.pkg.pipeline.preproc.preproc import PreProcessor
from langbot.pkg.provider.modelmgr import requester
from langbot.pkg.provider.modelmgr.modelmgr import ModelManager
from langbot.pkg.provider.modelmgr.requesters.chatcmpl import OpenAIChatCompletions
from langbot.pkg.provider.modelmgr.requesters.modelscopechatcmpl import ModelScopeChatCompletions
from langbot.pkg.provider.modelmgr.token import TokenManager
from langbot.pkg.provider.runners.localagent import LocalAgentRunner
@@ -58,6 +62,93 @@ def test_runtime_rerank_model_data_preserves_uuid_after_update_payload_uuid_remo
assert runtime_entity.name == 'rerank-model'
def test_normalize_space_provider_api_keys_filters_blank_values():
assert ModelProviderService._normalize_api_keys('space-key') == ['space-key']
assert ModelProviderService._normalize_api_keys(' trimmed-key ') == ['trimmed-key']
assert ModelProviderService._normalize_api_keys('') == []
assert ModelProviderService._normalize_api_keys(' ') == []
assert ModelProviderService._normalize_api_keys(None) == []
assert ModelProviderService._normalize_api_keys([' first-key ', '', 'first-key', 'second-key']) == [
'first-key',
'second-key',
]
def test_token_manager_filters_blank_and_duplicate_tokens():
token_mgr = TokenManager('provider-uuid', [' first-key ', '', 'first-key', 'second-key', ' '])
assert token_mgr.tokens == ['first-key', 'second-key']
assert token_mgr.get_token() == 'first-key'
@pytest.mark.asyncio
async def test_openai_requester_initialize_uses_placeholder_api_key(monkeypatch):
captured_kwargs = {}
def fake_client(**kwargs):
captured_kwargs.update(kwargs)
return SimpleNamespace(**kwargs)
monkeypatch.setattr('langbot.pkg.provider.modelmgr.requesters.chatcmpl.openai.AsyncClient', fake_client)
monkeypatch.setattr('langbot.pkg.provider.modelmgr.requesters.chatcmpl.httpx.AsyncClient', fake_client)
requester_inst = OpenAIChatCompletions(ap=SimpleNamespace(), config={})
await requester_inst.initialize()
assert captured_kwargs['api_key'] == OpenAIChatCompletions.init_api_key
@pytest.mark.asyncio
async def test_modelscope_requester_initialize_uses_placeholder_api_key(monkeypatch):
captured_kwargs = {}
def fake_client(**kwargs):
captured_kwargs.update(kwargs)
return SimpleNamespace(**kwargs)
monkeypatch.setattr('langbot.pkg.provider.modelmgr.requesters.modelscopechatcmpl.openai.AsyncClient', fake_client)
monkeypatch.setattr('langbot.pkg.provider.modelmgr.requesters.modelscopechatcmpl.httpx.AsyncClient', fake_client)
requester_inst = ModelScopeChatCompletions(ap=SimpleNamespace(), config={})
await requester_inst.initialize()
assert captured_kwargs['api_key'] == ModelScopeChatCompletions.init_api_key
@pytest.mark.asyncio
async def test_openai_embedding_call_overrides_placeholder_api_key():
captured_request = {}
async def fake_create(**kwargs):
captured_request['api_key'] = fake_client.api_key
captured_request['kwargs'] = kwargs
return SimpleNamespace(
data=[SimpleNamespace(embedding=[0.1, 0.2])],
usage=SimpleNamespace(prompt_tokens=3, total_tokens=3),
)
fake_client = SimpleNamespace(
api_key=OpenAIChatCompletions.init_api_key,
embeddings=SimpleNamespace(create=fake_create),
)
requester_inst = OpenAIChatCompletions(ap=SimpleNamespace(), config={})
requester_inst.client = fake_client
embeddings, usage_info = await requester_inst.invoke_embedding(
model=requester.RuntimeEmbeddingModel(
model_entity=SimpleNamespace(name='text-embedding-3-small', extra_args={}),
provider=SimpleNamespace(token_mgr=TokenManager('provider-uuid', [' runtime-key ', '', 'runtime-key'])),
),
input_text=['hello'],
)
assert captured_request['api_key'] == 'runtime-key'
assert captured_request['kwargs']['model'] == 'text-embedding-3-small'
assert embeddings == [[0.1, 0.2]]
assert usage_info == {'prompt_tokens': 3, 'total_tokens': 3}
@pytest.mark.asyncio
async def test_updated_llm_model_is_immediately_usable_by_local_agent_pipeline():
from langbot.pkg.api.http.service.model import LLMModelsService

7067
uv.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,4 +1,4 @@
import { useEffect, useState, useCallback, Suspense } from 'react';
import { useEffect, useState, useCallback, Suspense, useRef } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { httpClient } from '@/app/infra/http/HttpClient';
import { toast } from 'sonner';
@@ -20,10 +20,39 @@ import { Button } from '@/components/ui/button';
import { LoadingSpinner } from '@/components/ui/loading-spinner';
import langbotIcon from '@/app/assets/langbot-logo.webp';
type SpaceOAuthLoginResult = {
token: string;
user: string;
};
const pendingSpaceOAuthLogins = new Map<
string,
Promise<SpaceOAuthLoginResult>
>();
function getOrCreateSpaceOAuthLoginPromise(
authCode: string,
): Promise<SpaceOAuthLoginResult> {
const pendingRequest = pendingSpaceOAuthLogins.get(authCode);
if (pendingRequest) {
return pendingRequest;
}
const requestPromise = httpClient
.exchangeSpaceOAuthCode(authCode)
.finally(() => {
pendingSpaceOAuthLogins.delete(authCode);
});
pendingSpaceOAuthLogins.set(authCode, requestPromise);
return requestPromise;
}
function SpaceOAuthCallbackContent() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const { t } = useTranslation();
const isMountedRef = useRef(true);
const [status, setStatus] = useState<
'loading' | 'confirm' | 'success' | 'error'
@@ -37,7 +66,11 @@ function SpaceOAuthCallbackContent() {
const handleOAuthCallback = useCallback(
async (authCode: string) => {
try {
const response = await httpClient.exchangeSpaceOAuthCode(authCode);
const response = await getOrCreateSpaceOAuthLoginPromise(authCode);
if (!isMountedRef.current) {
return;
}
localStorage.setItem('token', response.token);
if (response.user) {
localStorage.setItem('userEmail', response.user);
@@ -52,6 +85,10 @@ function SpaceOAuthCallbackContent() {
navigate(redirectTo);
}, 1000);
} catch (err) {
if (!isMountedRef.current) {
return;
}
setStatus('error');
const errorObj = err as { msg?: string };
const errMsg = (errorObj?.msg || '').toLowerCase();
@@ -72,6 +109,10 @@ function SpaceOAuthCallbackContent() {
setIsProcessing(true);
try {
const response = await httpClient.bindSpaceAccount(authCode, state);
if (!isMountedRef.current) {
return;
}
localStorage.setItem('token', response.token);
if (response.user) {
localStorage.setItem('userEmail', response.user);
@@ -82,6 +123,10 @@ function SpaceOAuthCallbackContent() {
navigate('/home');
}, 1000);
} catch (err) {
if (!isMountedRef.current) {
return;
}
setStatus('error');
const errorObj = err as { msg?: string };
const errMsg = (errorObj?.msg || '').toLowerCase();
@@ -91,13 +136,17 @@ function SpaceOAuthCallbackContent() {
setErrorMessage(t('account.bindSpaceFailed'));
}
} finally {
setIsProcessing(false);
if (isMountedRef.current) {
setIsProcessing(false);
}
}
},
[navigate, t],
);
useEffect(() => {
isMountedRef.current = true;
const authCode = searchParams.get('code');
const error = searchParams.get('error');
const errorDescription = searchParams.get('error_description');
@@ -135,6 +184,9 @@ function SpaceOAuthCallbackContent() {
// Normal login/register mode
handleOAuthCallback(authCode);
}
return () => {
isMountedRef.current = false;
};
}, [searchParams, handleOAuthCallback, t]);
const handleConfirmBind = () => {

View File

@@ -267,6 +267,7 @@ export default function BotForm({
type: parseDynamicFormItemType(item.type),
options: item.options,
show_if: item.show_if,
login_platform: item.login_platform,
}),
),
);

View File

@@ -11,13 +11,16 @@ import {
FormMessage,
} from '@/components/ui/form';
import DynamicFormItemComponent from '@/app/home/components/dynamic-form/DynamicFormItemComponent';
import QrCodeLoginDialog, {
QrLoginPlatform,
} from '@/app/home/components/qrcode-login/QrCodeLoginDialog';
import { useEffect, useMemo, useRef, useState } from 'react';
import { extractI18nObject } from '@/i18n/I18nProvider';
import { useTranslation } from 'react-i18next';
import { cn } from '@/lib/utils';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Copy, Check, Globe } from 'lucide-react';
import { Copy, Check, Globe, QrCode } from 'lucide-react';
import { copyToClipboard } from '@/app/utils/clipboard';
import { systemInfo } from '@/app/infra/http';
@@ -195,6 +198,7 @@ export default function DynamicFormComponent({
isEditing,
externalDependentValues,
systemContext,
onValidate,
}: {
itemConfigList: IDynamicFormItemSchema[];
onSubmit?: (val: object) => unknown;
@@ -205,6 +209,9 @@ export default function DynamicFormComponent({
/** Extra variables accessible via the `__system.*` namespace in show_if conditions.
* e.g. `{ is_wizard: true }` makes `show_if: { field: "__system.is_wizard", ... }` work. */
systemContext?: Record<string, unknown>;
/** Callback to expose validation function to parent component.
* Parent can call this function to trigger validation and get validity state. */
onValidate?: (validateFn: () => Promise<boolean>) => void;
}) {
const isInitialMount = useRef(true);
const previousInitialValues = useRef(initialValues);
@@ -251,7 +258,10 @@ export default function DynamicFormComponent({
const editableItems = useMemo(
() =>
itemConfigList.filter(
(item) => item.type !== 'webhook-url' && item.type !== 'embed-code',
(item) =>
item.type !== 'webhook-url' &&
item.type !== 'embed-code' &&
item.type !== 'qr-code-login',
),
[itemConfigList],
);
@@ -352,6 +362,17 @@ export default function DynamicFormComponent({
}, {} as FormValues),
});
// Expose validation function to parent component
const validate = async (): Promise<boolean> => {
// Trigger validation for all fields
const result = await form.trigger();
return result;
};
useEffect(() => {
onValidate?.(validate);
}, [onValidate]);
// 当 initialValues 变化时更新表单值
// 但要避免因为内部表单更新触发的 onSubmit 导致的 initialValues 变化而重新设置表单
useEffect(() => {
@@ -434,9 +455,28 @@ export default function DynamicFormComponent({
return () => subscription.unsubscribe();
}, [form, editableItems]);
// State for QR code login dialog
const [qrDialogOpen, setQrDialogOpen] = useState(false);
const [qrDialogPlatform, setQrDialogPlatform] =
useState<QrLoginPlatform>('feishu');
return (
<Form {...form}>
<div className="space-y-4">
{/* QR code login dialog */}
<QrCodeLoginDialog
open={qrDialogOpen}
onOpenChange={setQrDialogOpen}
platform={qrDialogPlatform}
onSuccess={(credentials) => {
for (const [key, value] of Object.entries(credentials)) {
if (value) {
form.setValue(key as keyof FormValues, value as never);
}
}
}}
/>
{itemConfigList.map((config) => {
if (config.show_if) {
const dependValue = resolveShowIfValue(
@@ -523,6 +563,66 @@ export default function DynamicFormComponent({
);
}
// QR code login button (e.g. Feishu one-click create, WeChat scan login)
if (config.type === 'qr-code-login') {
return (
<FormItem key={config.id}>
<div
className="relative flex items-center gap-4 p-4 rounded-xl border-2 border-dashed cursor-pointer transition-all hover:border-solid hover:shadow-md group"
style={{
borderColor:
'color-mix(in srgb, var(--primary) 25%, transparent)',
background:
'color-mix(in srgb, var(--primary) 3%, transparent)',
}}
onClick={() => {
if (!isEditing) {
setQrDialogPlatform(
(config.login_platform as QrLoginPlatform) || 'feishu',
);
setQrDialogOpen(true);
}
}}
>
<div className="flex items-center justify-center h-12 w-12 rounded-lg bg-primary/10 shrink-0">
<QrCode className="h-6 w-6 text-primary" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-foreground">
{extractI18nObject(config.label)}
</span>
<span className="px-1.5 py-0.5 text-[10px] font-bold rounded bg-primary text-primary-foreground">
{t('common.recommend')}
</span>
</div>
{config.description && (
<p className="text-xs text-muted-foreground mt-1 leading-relaxed">
{extractI18nObject(config.description)}
</p>
)}
</div>
<Button
type="button"
size="sm"
disabled={!!isEditing}
className="shrink-0"
onClick={(e) => {
e.stopPropagation();
setQrDialogPlatform(
(config.login_platform as QrLoginPlatform) || 'feishu',
);
setQrDialogOpen(true);
}}
>
<QrCode className="h-3.5 w-3.5 mr-1" />
{t('common.start')}
</Button>
</div>
</FormItem>
);
}
// Boolean fields use a special inline layout
if (config.type === 'boolean') {
return (

View File

@@ -16,6 +16,7 @@ export class DynamicFormItemConfig implements IDynamicFormItemSchema {
description?: I18nObject;
options?: IDynamicFormItemOption[];
show_if?: IShowIfCondition;
login_platform?: string;
constructor(params: IDynamicFormItemSchema) {
this.id = params.id;
@@ -27,6 +28,7 @@ export class DynamicFormItemConfig implements IDynamicFormItemSchema {
this.description = params.description;
this.options = params.options;
this.show_if = params.show_if;
this.login_platform = params.login_platform;
}
}

View File

@@ -0,0 +1,366 @@
import { useEffect, useRef, useState, useCallback } from 'react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { useTranslation } from 'react-i18next';
import { Loader2, RefreshCw, CheckCircle2, XCircle } from 'lucide-react';
import QRCode from 'qrcode';
export type QrLoginPlatform = 'feishu' | 'weixin' | 'dingtalk' | 'wecombot';
interface PlatformConfig {
titleKey: string;
connectingKey: string;
scanQRCodeKey: string;
waitingKey: string;
successKey: string;
failedKey: string;
retryKey: string;
apiBase: string;
extractSuccess: (data: Record<string, string>) => Record<string, string>;
successNoteKey?: string;
}
const PLATFORM_CONFIGS: Record<QrLoginPlatform, PlatformConfig> = {
feishu: {
titleKey: 'feishu.createApp',
connectingKey: 'feishu.connecting',
scanQRCodeKey: 'feishu.scanQRCode',
waitingKey: 'feishu.waitingForScan',
successKey: 'feishu.createSuccess',
failedKey: 'feishu.createFailed',
retryKey: 'feishu.retry',
apiBase: '/api/v1/platform/adapters/lark/create-app',
extractSuccess: (data) => ({
app_id: data.app_id,
app_secret: data.app_secret,
...(data.app_name ? { app_name: data.app_name } : {}),
}),
},
weixin: {
titleKey: 'weixin.scanLogin',
connectingKey: 'feishu.connecting',
scanQRCodeKey: 'weixin.scanQRCode',
waitingKey: 'feishu.waitingForScan',
successKey: 'weixin.loginSuccess',
failedKey: 'weixin.loginFailed',
retryKey: 'feishu.retry',
apiBase: '/api/v1/platform/adapters/weixin/login',
extractSuccess: (data) => ({
token: data.token,
base_url: data.base_url,
...(data.account_id ? { account_id: data.account_id } : {}),
}),
},
dingtalk: {
titleKey: 'dingtalk.createApp',
connectingKey: 'dingtalk.connecting',
scanQRCodeKey: 'dingtalk.scanQRCode',
waitingKey: 'dingtalk.waitingForScan',
successKey: 'dingtalk.createSuccess',
failedKey: 'dingtalk.createFailed',
retryKey: 'dingtalk.retry',
apiBase: '/api/v1/platform/adapters/dingtalk/create-app',
extractSuccess: (data) => ({
client_id: data.client_id,
client_secret: data.client_secret,
}),
successNoteKey: 'dingtalk.robotCodeNote',
},
wecombot: {
titleKey: 'wecombot.createBot',
connectingKey: 'wecombot.connecting',
scanQRCodeKey: 'wecombot.scanQRCode',
waitingKey: 'wecombot.waitingForScan',
successKey: 'wecombot.createSuccess',
failedKey: 'wecombot.createFailed',
retryKey: 'wecombot.retry',
apiBase: '/api/v1/platform/adapters/wecombot/create-bot',
extractSuccess: (data) => ({
BotId: data.botid,
Secret: data.secret,
}),
successNoteKey: 'wecombot.robotNameNote',
},
};
interface QrCodeLoginDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
platform: QrLoginPlatform;
onSuccess: (credentials: Record<string, string>) => void;
}
type DialogState = 'connecting' | 'waiting' | 'success' | 'error';
const POLL_INTERVAL_MS = 3000;
export default function QrCodeLoginDialog({
open,
onOpenChange,
platform,
onSuccess,
}: QrCodeLoginDialogProps) {
const { t } = useTranslation();
const platformConfig = PLATFORM_CONFIGS[platform];
const [state, setState] = useState<DialogState>('connecting');
const [qrDataUrl, setQrDataUrl] = useState('');
const [expireIn, setExpireIn] = useState(0);
const [errorMessage, setErrorMessage] = useState('');
const pollTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const countdownRef = useRef<ReturnType<typeof setInterval> | null>(null);
const abortRef = useRef<AbortController | null>(null);
const sessionIdRef = useRef<string | null>(null);
const cleanedRef = useRef(false);
const onSuccessRef = useRef(onSuccess);
onSuccessRef.current = onSuccess;
const onOpenChangeRef = useRef(onOpenChange);
onOpenChangeRef.current = onOpenChange;
const tRef = useRef(t);
tRef.current = t;
const platformConfigRef = useRef(platformConfig);
platformConfigRef.current = platformConfig;
const cleanup = useCallback(() => {
if (cleanedRef.current) return;
cleanedRef.current = true;
if (pollTimerRef.current) {
clearInterval(pollTimerRef.current);
pollTimerRef.current = null;
}
if (countdownRef.current) {
clearInterval(countdownRef.current);
countdownRef.current = null;
}
if (abortRef.current) {
abortRef.current.abort();
abortRef.current = null;
}
// Cancel backend session
if (sessionIdRef.current) {
const token = localStorage.getItem('token');
const baseUrl =
import.meta.env.VITE_API_BASE_URL || window.location.origin;
fetch(
`${baseUrl}${platformConfigRef.current.apiBase}/${sessionIdRef.current}`,
{
method: 'DELETE',
headers: { Authorization: `Bearer ${token}` },
keepalive: true,
},
).catch(() => {});
sessionIdRef.current = null;
}
}, []);
const startLogin = useCallback(async () => {
cleanup();
cleanedRef.current = false;
setState('connecting');
setQrDataUrl('');
setExpireIn(0);
setErrorMessage('');
const token = localStorage.getItem('token');
const baseUrl = import.meta.env.VITE_API_BASE_URL || window.location.origin;
const cfg = platformConfigRef.current;
try {
const controller = new AbortController();
abortRef.current = controller;
const res = await fetch(`${baseUrl}${cfg.apiBase}`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
signal: controller.signal,
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const json = await res.json();
if (json.code !== 0) throw new Error(json.msg || 'Request failed');
const { session_id, qr_data_url, qr_url, expire_at } = json.data;
sessionIdRef.current = session_id;
// qr_data_url is a pre-rendered data URL (WeChat);
// qr_url is a plain URL string (Feishu) that needs local QR generation.
if (qr_data_url) {
setQrDataUrl(qr_data_url);
} else if (qr_url) {
const dataUrl = await QRCode.toDataURL(qr_url, {
width: 224,
margin: 2,
});
setQrDataUrl(dataUrl);
}
setState('waiting');
// Calculate remaining seconds
const remaining = Math.max(0, Math.floor(expire_at - Date.now() / 1000));
setExpireIn(remaining);
// Start countdown
countdownRef.current = setInterval(() => {
setExpireIn((prev) => {
if (prev <= 1) {
if (countdownRef.current) {
clearInterval(countdownRef.current);
countdownRef.current = null;
}
return 0;
}
return prev - 1;
});
}, 1000);
// Start polling
pollTimerRef.current = setInterval(async () => {
try {
const pollRes = await fetch(
`${baseUrl}${cfg.apiBase}/status/${session_id}`,
{ headers: { Authorization: `Bearer ${token}` } },
);
if (!pollRes.ok) return;
const pollJson = await pollRes.json();
if (pollJson.code !== 0) return;
const { status, error, ...rest } = pollJson.data;
if (status === 'success') {
sessionIdRef.current = null; // backend already cleaned up
cleanup();
setState('success');
setTimeout(() => {
onSuccessRef.current(cfg.extractSuccess(rest));
onOpenChangeRef.current(false);
}, 1500);
} else if (status === 'error') {
sessionIdRef.current = null;
cleanup();
setState('error');
setErrorMessage(error || tRef.current(cfg.failedKey));
}
} catch {
// ignore poll errors, will retry next interval
}
}, POLL_INTERVAL_MS);
} catch (err: unknown) {
if (err instanceof Error && err.name === 'AbortError') return;
setState('error');
setErrorMessage(
err instanceof Error ? err.message : tRef.current(cfg.failedKey),
);
}
}, [cleanup]);
useEffect(() => {
if (open) {
startLogin();
}
return () => {
cleanup();
};
}, [open, startLogin, cleanup]);
const handleOpenChange = (newOpen: boolean) => {
if (!newOpen) {
cleanup();
}
onOpenChange(newOpen);
};
const formatTime = (seconds: number) => {
const m = Math.floor(seconds / 60);
const s = seconds % 60;
if (m > 0) {
return `${m}m${s.toString().padStart(2, '0')}s`;
}
return `${s}s`;
};
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{t(platformConfig.titleKey)}</DialogTitle>
</DialogHeader>
<div className="flex flex-col items-center justify-center py-4 space-y-4">
{/* Connecting */}
{state === 'connecting' && (
<div className="flex flex-col items-center space-y-3 py-8">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
<p className="text-sm text-muted-foreground">
{t(platformConfig.connectingKey)}
</p>
</div>
)}
{/* QR code area */}
{state === 'waiting' && qrDataUrl && (
<div className="flex flex-col items-center space-y-3">
<p className="text-sm text-muted-foreground text-center">
{t(platformConfig.scanQRCodeKey)}
</p>
<div className="border rounded-lg p-2 bg-white">
<img src={qrDataUrl} alt="QR Code" className="w-56 h-56" />
</div>
{expireIn > 0 && (
<p className="text-xs text-muted-foreground">
{t(platformConfig.waitingKey)} ({formatTime(expireIn)})
</p>
)}
</div>
)}
{/* Success */}
{state === 'success' && (
<div className="flex flex-col items-center space-y-3 py-8">
<CheckCircle2 className="h-12 w-12 text-green-500" />
<p className="text-sm text-green-600 font-medium">
{t(platformConfig.successKey)}
</p>
{platformConfig.successNoteKey && (
<p className="text-xs text-muted-foreground text-center max-w-xs">
{t(platformConfig.successNoteKey)}
</p>
)}
</div>
)}
{/* Error */}
{state === 'error' && (
<div className="flex flex-col items-center space-y-3 py-8">
<XCircle className="h-12 w-12 text-red-500" />
<p className="text-sm text-red-600 text-center">
{errorMessage || t(platformConfig.failedKey)}
</p>
</div>
)}
</div>
{state === 'error' && (
<DialogFooter>
<Button variant="outline" onClick={() => handleOpenChange(false)}>
{t('common.cancel')}
</Button>
<Button onClick={() => startLogin()}>
<RefreshCw className="h-4 w-4 mr-1.5" />
{t(platformConfig.retryKey)}
</Button>
</DialogFooter>
)}
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,393 @@
import { useEffect, useRef, useState, useCallback } from 'react';
import {
Dialog,
DialogContent,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { useTranslation } from 'react-i18next';
import {
Loader2,
RefreshCw,
CheckCircle2,
XCircle,
ScanLine,
} from 'lucide-react';
import QRCode from 'qrcode';
import { httpClient } from '@/app/infra/http/HttpClient';
export type QrLoginPlatform = 'feishu' | 'weixin';
interface PlatformConfig {
titleKey: string;
connectingKey: string;
scanQRCodeKey: string;
waitingKey: string;
successKey: string;
failedKey: string;
retryKey: string;
apiBase: string;
brandColor: string;
adapterName: string;
extractSuccess: (data: Record<string, string>) => Record<string, string>;
}
const PLATFORM_CONFIGS: Record<QrLoginPlatform, PlatformConfig> = {
feishu: {
titleKey: 'feishu.createApp',
connectingKey: 'feishu.connecting',
scanQRCodeKey: 'feishu.scanQRCode',
waitingKey: 'feishu.waitingForScan',
successKey: 'feishu.createSuccess',
failedKey: 'feishu.createFailed',
retryKey: 'feishu.retry',
apiBase: '/api/v1/platform/adapters/lark/create-app',
brandColor: '#3370ff',
adapterName: 'lark',
extractSuccess: (data) => ({
app_id: data.app_id,
app_secret: data.app_secret,
...(data.app_name ? { app_name: data.app_name } : {}),
}),
},
weixin: {
titleKey: 'weixin.scanLogin',
connectingKey: 'feishu.connecting',
scanQRCodeKey: 'weixin.scanQRCode',
waitingKey: 'feishu.waitingForScan',
successKey: 'weixin.loginSuccess',
failedKey: 'weixin.loginFailed',
retryKey: 'feishu.retry',
apiBase: '/api/v1/platform/adapters/weixin/login',
brandColor: '#07c160',
adapterName: 'openclaw-weixin',
extractSuccess: (data) => ({
token: data.token,
base_url: data.base_url,
...(data.account_id ? { account_id: data.account_id } : {}),
}),
},
};
interface QrCodeLoginDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
platform: QrLoginPlatform;
onSuccess: (credentials: Record<string, string>) => void;
}
type DialogState = 'connecting' | 'waiting' | 'success' | 'error';
const POLL_INTERVAL_MS = 3000;
export default function QrCodeLoginDialog({
open,
onOpenChange,
platform,
onSuccess,
}: QrCodeLoginDialogProps) {
const { t } = useTranslation();
const platformConfig = PLATFORM_CONFIGS[platform];
const [state, setState] = useState<DialogState>('connecting');
const [qrDataUrl, setQrDataUrl] = useState('');
const [expireIn, setExpireIn] = useState(0);
const [errorMessage, setErrorMessage] = useState('');
const pollTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const countdownRef = useRef<ReturnType<typeof setInterval> | null>(null);
const abortRef = useRef<AbortController | null>(null);
const sessionIdRef = useRef<string | null>(null);
const onSuccessRef = useRef(onSuccess);
onSuccessRef.current = onSuccess;
const onOpenChangeRef = useRef(onOpenChange);
onOpenChangeRef.current = onOpenChange;
const tRef = useRef(t);
tRef.current = t;
const platformConfigRef = useRef(platformConfig);
platformConfigRef.current = platformConfig;
const cleanup = useCallback(() => {
if (pollTimerRef.current) {
clearInterval(pollTimerRef.current);
pollTimerRef.current = null;
}
if (countdownRef.current) {
clearInterval(countdownRef.current);
countdownRef.current = null;
}
if (abortRef.current) {
abortRef.current.abort();
abortRef.current = null;
}
if (sessionIdRef.current) {
const token = localStorage.getItem('token');
const baseUrl =
import.meta.env.VITE_API_BASE_URL || window.location.origin;
fetch(
`${baseUrl}${platformConfigRef.current.apiBase}/${sessionIdRef.current}`,
{
method: 'DELETE',
headers: { Authorization: `Bearer ${token}` },
},
).catch(() => {});
sessionIdRef.current = null;
}
}, []);
const startLogin = useCallback(async () => {
cleanup();
setState('connecting');
setQrDataUrl('');
setExpireIn(0);
setErrorMessage('');
const token = localStorage.getItem('token');
const baseUrl = import.meta.env.VITE_API_BASE_URL || window.location.origin;
const cfg = platformConfigRef.current;
try {
const controller = new AbortController();
abortRef.current = controller;
const res = await fetch(`${baseUrl}${cfg.apiBase}`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
signal: controller.signal,
});
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const json = await res.json();
if (json.code !== 0) throw new Error(json.msg || 'Request failed');
const { session_id, qr_data_url, qr_url, expire_at } = json.data;
sessionIdRef.current = session_id;
if (qr_data_url) {
setQrDataUrl(qr_data_url);
} else if (qr_url) {
const dataUrl = await QRCode.toDataURL(qr_url, {
width: 280,
margin: 2,
color: {
dark: '#000000',
light: '#ffffff',
},
});
setQrDataUrl(dataUrl);
}
setState('waiting');
const remaining = Math.max(0, Math.floor(expire_at - Date.now() / 1000));
setExpireIn(remaining);
countdownRef.current = setInterval(() => {
setExpireIn((prev) => {
if (prev <= 1) {
if (countdownRef.current) {
clearInterval(countdownRef.current);
countdownRef.current = null;
}
return 0;
}
return prev - 1;
});
}, 1000);
pollTimerRef.current = setInterval(async () => {
try {
const pollRes = await fetch(
`${baseUrl}${cfg.apiBase}/status/${session_id}`,
{ headers: { Authorization: `Bearer ${token}` } },
);
if (!pollRes.ok) return;
const pollJson = await pollRes.json();
if (pollJson.code !== 0) return;
const { status, error, ...rest } = pollJson.data;
if (status === 'success') {
sessionIdRef.current = null;
cleanup();
setState('success');
setTimeout(() => {
onSuccessRef.current(cfg.extractSuccess(rest));
onOpenChangeRef.current(false);
}, 1500);
} else if (status === 'error') {
sessionIdRef.current = null;
cleanup();
setState('error');
setErrorMessage(error || tRef.current(cfg.failedKey));
}
} catch {
// ignore poll errors
}
}, POLL_INTERVAL_MS);
} catch (err: unknown) {
if (err instanceof Error && err.name === 'AbortError') return;
setState('error');
setErrorMessage(
err instanceof Error ? err.message : tRef.current(cfg.failedKey),
);
}
}, [cleanup]);
useEffect(() => {
if (open) {
startLogin();
}
return () => {
cleanup();
};
}, [open, startLogin, cleanup]);
const handleOpenChange = (newOpen: boolean) => {
if (!newOpen) {
cleanup();
}
onOpenChange(newOpen);
};
const formatTime = (seconds: number) => {
const m = Math.floor(seconds / 60);
const s = seconds % 60;
if (m > 0) {
return `${m}:${s.toString().padStart(2, '0')}`;
}
return `0:${s.toString().padStart(2, '0')}`;
};
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="sm:max-w-md p-0 overflow-hidden">
{/* Brand header */}
<div className="flex items-center gap-3 px-6 pt-6 pb-2">
<img
src={httpClient.getAdapterIconURL(platformConfig.adapterName)}
alt={platform}
className="h-10 w-10 rounded-lg"
/>
<div>
<DialogTitle className="text-lg">
{t(platformConfig.titleKey)}
</DialogTitle>
</div>
</div>
<div className="flex flex-col items-center justify-center px-6 pb-6 space-y-4">
{/* Connecting */}
{state === 'connecting' && (
<div className="flex flex-col items-center space-y-4 py-12">
<div className="relative">
<div
className="absolute inset-0 rounded-full animate-ping opacity-20"
style={{ backgroundColor: platformConfig.brandColor }}
/>
<Loader2
className="h-10 w-10 animate-spin relative"
style={{ color: platformConfig.brandColor }}
/>
</div>
<p className="text-sm text-muted-foreground font-medium">
{t(platformConfig.connectingKey)}
</p>
</div>
)}
{/* QR code area */}
{state === 'waiting' && qrDataUrl && (
<div className="flex flex-col items-center space-y-4 py-2">
{/* Instruction */}
<div
className="flex items-center gap-2 px-4 py-2 rounded-full text-sm font-medium"
style={{
backgroundColor: `${platformConfig.brandColor}10`,
color: platformConfig.brandColor,
}}
>
<ScanLine className="h-4 w-4" />
{t(platformConfig.scanQRCodeKey)}
</div>
{/* QR Code with border animation */}
<div className="relative">
<div
className="absolute -inset-1 rounded-2xl opacity-30 animate-pulse"
style={{ backgroundColor: platformConfig.brandColor }}
/>
<div className="relative bg-white rounded-xl p-3 shadow-lg">
<img src={qrDataUrl} alt="QR Code" className="w-64 h-64" />
</div>
</div>
{/* Countdown */}
{expireIn > 0 && (
<div className="flex items-center gap-2 text-sm">
<div
className="h-2 w-2 rounded-full animate-pulse"
style={{ backgroundColor: platformConfig.brandColor }}
/>
<span className="text-muted-foreground">
{t(platformConfig.waitingKey)}
</span>
<span
className="font-mono font-semibold tabular-nums"
style={{
color:
expireIn < 60 ? '#ef4444' : platformConfig.brandColor,
}}
>
{formatTime(expireIn)}
</span>
</div>
)}
</div>
)}
{/* Success */}
{state === 'success' && (
<div className="flex flex-col items-center space-y-3 py-12">
<div className="relative">
<div className="absolute inset-0 rounded-full bg-green-100 animate-ping opacity-30" />
<CheckCircle2 className="h-16 w-16 text-green-500 relative" />
</div>
<p className="text-base text-green-600 font-semibold">
{t(platformConfig.successKey)}
</p>
</div>
)}
{/* Error */}
{state === 'error' && (
<div className="flex flex-col items-center space-y-3 py-12">
<XCircle className="h-16 w-16 text-red-400" />
<p className="text-sm text-red-500 text-center max-w-xs">
{errorMessage || t(platformConfig.failedKey)}
</p>
</div>
)}
</div>
{/* Error footer with retry */}
{state === 'error' && (
<DialogFooter className="px-6 pb-6 pt-0">
<Button variant="outline" onClick={() => handleOpenChange(false)}>
{t('common.cancel')}
</Button>
<Button
onClick={() => startLogin()}
style={{ backgroundColor: platformConfig.brandColor }}
>
<RefreshCw className="h-4 w-4 mr-1.5" />
{t(platformConfig.retryKey)}
</Button>
</DialogFooter>
)}
</DialogContent>
</Dialog>
);
}

View File

@@ -57,7 +57,6 @@ const getFormSchema = (t: (key: string) => string) =>
* Parse creation schema from Knowledge Engine to IDynamicFormItemSchema[]
*/
function parseCreationSchema(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
schemaItems: any | any[] | undefined,
): IDynamicFormItemSchema[] {
if (!schemaItems) return [];
@@ -107,6 +106,10 @@ export default function KBForm({
const savedSnapshotRef = useRef<string>('');
const isInitializing = useRef(true);
// Refs to store validation functions from dynamic forms
const configValidateRef = useRef<(() => Promise<boolean>) | null>(null);
const retrievalValidateRef = useRef<(() => Promise<boolean>) | null>(null);
const formSchema = getFormSchema(t);
const form = useForm<z.infer<typeof formSchema>>({
@@ -235,7 +238,24 @@ export default function KBForm({
}
};
const onSubmit = (data: z.infer<typeof formSchema>) => {
const onSubmit = async (data: z.infer<typeof formSchema>) => {
// Validate dynamic forms before submission
if (configValidateRef.current) {
const configValid = await configValidateRef.current();
if (!configValid) {
toast.error(t('knowledge.engineSettingsInvalid'));
return;
}
}
if (retrievalValidateRef.current) {
const retrievalValid = await retrievalValidateRef.current();
if (!retrievalValid) {
toast.error(t('knowledge.retrievalSettingsInvalid'));
return;
}
}
const kbData: KnowledgeBase = {
name: data.name,
description: data.description ?? '',
@@ -490,6 +510,9 @@ export default function KBForm({
}
isEditing={isEditing}
externalDependentValues={retrievalSettings}
onValidate={(validateFn) =>
(configValidateRef.current = validateFn)
}
/>
</CardContent>
</Card>
@@ -512,6 +535,9 @@ export default function KBForm({
setRetrievalSettings(val as Record<string, unknown>)
}
externalDependentValues={configSettings}
onValidate={(validateFn) =>
(retrievalValidateRef.current = validateFn)
}
/>
</CardContent>
</Card>

View File

@@ -76,10 +76,12 @@ function MarketplaceContent() {
// Register task completion callback for toast and plugin list refresh
useEffect(() => {
const onComplete = (_taskId: number, success: boolean) => {
const onComplete = (_taskId: number, success: boolean, error?: string) => {
if (success) {
toast.success(t('plugins.installSuccess'));
refreshPlugins();
} else {
toast.error(error || t('plugins.installFailed'));
}
};
registerOnTaskComplete(onComplete);

View File

@@ -45,7 +45,11 @@ export interface PluginInstallTask {
currentAction: string; // raw backend action string
}
type OnTaskCompleteCallback = (taskId: number, success: boolean) => void;
type OnTaskCompleteCallback = (
taskId: number,
success: boolean,
error?: string,
) => void;
interface PluginInstallTaskContextValue {
tasks: PluginInstallTask[];
@@ -224,13 +228,16 @@ export function PluginInstallTaskProvider({
onTaskCompleteCallbacks.current.delete(cb);
}, []);
const notifyTaskComplete = useCallback((taskId: number, success: boolean) => {
if (notifiedTaskIds.current.has(taskId)) return;
notifiedTaskIds.current.add(taskId);
onTaskCompleteCallbacks.current.forEach((cb) => {
cb(taskId, success);
});
}, []);
const notifyTaskComplete = useCallback(
(taskId: number, success: boolean, error?: string) => {
if (notifiedTaskIds.current.has(taskId)) return;
notifiedTaskIds.current.add(taskId);
onTaskCompleteCallbacks.current.forEach((cb) => {
cb(taskId, success, error);
});
},
[],
);
const pollTask = useCallback(
(taskKey: string, taskId: number) => {
@@ -289,7 +296,7 @@ export function PluginInstallTaskProvider({
}
if (exception) {
notifyTaskComplete(taskId, false);
notifyTaskComplete(taskId, false, exception);
return {
...t,
stage: InstallStage.ERROR,

View File

@@ -167,11 +167,13 @@ function PluginListView() {
// Register task completion callback for toast and plugin list refresh
useEffect(() => {
const onComplete = (_taskId: number, success: boolean) => {
const onComplete = (_taskId: number, success: boolean, error?: string) => {
if (success) {
toast.success(t('plugins.installSuccess'));
pluginInstalledRef.current?.refreshPluginList();
refreshPlugins();
} else {
toast.error(error || t('plugins.installFailed'));
}
};
registerOnTaskComplete(onComplete);

View File

@@ -21,6 +21,7 @@ export interface IDynamicFormItemSchema {
/** when type is PLUGIN_SELECTOR, the scopes is the scopes of components(plugin contains), the default is all */
scopes?: string[];
accept?: string; // For file type: accepted MIME types
login_platform?: string; // For qr-code-login type: platform identifier (e.g. 'feishu', 'weixin')
}
export enum DynamicFormItemType {
@@ -46,6 +47,7 @@ export enum DynamicFormItemType {
TOOLS_SELECTOR = 'tools-selector',
WEBHOOK_URL = 'webhook-url',
EMBED_CODE = 'embed-code',
QR_CODE_LOGIN = 'qr-code-login',
}
export interface IFileConfig {

View File

@@ -590,6 +590,9 @@ export class BackendClient extends BaseHttpClient {
name: string,
filepath: string,
): string {
if (this.instance.defaults.baseURL === '/') {
return `${window.location.origin}/api/v1/plugins/${author}/${name}/assets/${filepath}`;
}
return (
this.instance.defaults.baseURL +
`/api/v1/plugins/${author}/${name}/assets/${filepath}`

View File

@@ -228,6 +228,7 @@ export default function WizardPage() {
type: parseDynamicFormItemType(item.type),
options: item.options,
show_if: item.show_if,
login_platform: item.login_platform,
}),
);
}, [adapters, selectedAdapter]);
@@ -247,6 +248,7 @@ export default function WizardPage() {
type: parseDynamicFormItemType(item.type),
options: item.options,
show_if: item.show_if,
login_platform: item.login_platform,
}),
);
}, [selectedRunnerConfigStage]);

View File

@@ -44,6 +44,8 @@ const enUS = {
success: 'Success',
save: 'Save',
saving: 'Saving...',
recommend: 'Recommended',
start: 'Start',
confirm: 'Confirm',
confirmDelete: 'Confirm Delete',
deleteConfirmation: 'Are you sure you want to delete this?',
@@ -928,6 +930,10 @@ const enUS = {
engineSettingsDescription:
'Configuration for the selected knowledge engine',
engineSettingsReadonly: 'read-only in edit mode',
engineSettingsInvalid:
'Engine settings validation failed, please check required fields',
retrievalSettingsInvalid:
'Retrieval settings validation failed, please check required fields',
retrievalSettings: 'Retrieval Settings',
retrievalSettingsDescription:
'Configure how documents are retrieved from this knowledge base',
@@ -1332,6 +1338,51 @@ const enUS = {
backToWorkbench: 'Back to Workbench',
},
},
feishu: {
createApp: 'One-Click Create Feishu App',
scanQRCode:
'Scan the QR code below with Feishu to authorize and automatically create the app',
waitingForScan: 'Waiting for scan',
createSuccess: 'App created successfully! Credentials have been filled in',
createFailed: 'Creation failed',
connecting: 'Connecting to Feishu service...',
expired: 'QR code expired, please try again',
denied: 'Authorization denied by user',
connectionLost: 'Connection lost, please try again',
reconnecting: 'Reconnecting...',
retry: 'Retry',
},
weixin: {
scanLogin: 'Scan QR Login',
scanQRCode:
'Scan the QR code below with WeChat to authorize and automatically fill in the token',
loginSuccess: 'Login successful! Token has been filled in',
loginFailed: 'Login failed',
},
dingtalk: {
createApp: 'One-Click Create DingTalk App',
scanQRCode:
'Scan the QR code below with DingTalk to authorize and automatically create the app',
waitingForScan: 'Waiting for scan',
createSuccess: 'App created successfully! Credentials have been filled in',
createFailed: 'Creation failed',
connecting: 'Connecting to DingTalk service...',
retry: 'Retry',
robotCodeNote:
'Robot Code cannot be obtained automatically. Please go to DingTalk Developer Backend > Robot Configuration to copy it manually. It is required for features like image recognition and file upload.',
},
wecombot: {
createBot: 'One-Click Create WeCom Bot',
scanQRCode:
'Scan the QR code below with WeCom to authorize and automatically create the bot',
waitingForScan: 'Waiting for scan',
createSuccess: 'Bot created successfully! Credentials have been filled in',
createFailed: 'Creation failed',
connecting: 'Connecting to WeCom service...',
retry: 'Retry',
robotNameNote:
'Robot Name cannot be obtained automatically. Please fill it in manually.',
},
pluginPages: {
selectFromSidebar: 'Select a plugin page from the sidebar',
invalidPage: 'Invalid plugin page',

View File

@@ -47,6 +47,8 @@ const esES = {
success: 'Éxito',
save: 'Guardar',
saving: 'Guardando...',
recommend: 'Recomendado',
start: 'Iniciar',
confirm: 'Confirmar',
confirmDelete: 'Confirmar eliminación',
deleteConfirmation: '¿Estás seguro de que deseas eliminar esto?',
@@ -951,6 +953,10 @@ const esES = {
engineSettingsDescription:
'Configuración del motor de conocimiento seleccionado',
engineSettingsReadonly: 'solo lectura en modo de edición',
engineSettingsInvalid:
'La configuración del motor no es válida, verifique los campos obligatorios',
retrievalSettingsInvalid:
'La configuración de recuperación no es válida, verifique los campos obligatorios',
retrievalSettings: 'Configuración de recuperación',
retrievalSettingsDescription:
'Configura cómo se recuperan los documentos de esta base de conocimiento',
@@ -1371,6 +1377,55 @@ const esES = {
backToWorkbench: 'Volver al panel de trabajo',
},
},
feishu: {
createApp: 'Crear aplicación de Feishu con un clic',
scanQRCode:
'Escanea el código QR de abajo con Feishu para autorizar y crear la aplicación automáticamente',
waitingForScan: 'Esperando escaneo',
createSuccess:
'¡Aplicación creada correctamente! Las credenciales se han rellenado automáticamente',
createFailed: 'Error al crear la aplicación',
connecting: 'Conectando con el servicio de Feishu...',
expired: 'El código QR ha caducado. Inténtalo de nuevo',
denied: 'El usuario rechazó la autorización',
connectionLost: 'Se perdió la conexión. Inténtalo de nuevo',
reconnecting: 'Reconectando...',
retry: 'Reintentar',
},
weixin: {
scanLogin: 'Iniciar sesión en WeChat con QR',
scanQRCode:
'Escanea el código QR de abajo con WeChat para autorizar e introducir el token automáticamente',
loginSuccess:
'¡Inicio de sesión correcto! El token se ha rellenado automáticamente',
loginFailed: 'Error al iniciar sesión',
},
dingtalk: {
createApp: 'Crear aplicación de DingTalk con un clic',
scanQRCode:
'Escanea el código QR de abajo con DingTalk para autorizar y crear la aplicación automáticamente',
waitingForScan: 'Esperando escaneo',
createSuccess:
'¡Aplicación creada correctamente! Las credenciales se han rellenado automáticamente',
createFailed: 'Error al crear la aplicación',
connecting: 'Conectando con el servicio de DingTalk...',
retry: 'Reintentar',
robotCodeNote:
'El código del robot no puede obtenerse automáticamente. Ve al panel de desarrolladores de DingTalk > Configuración del robot para copiarlo manualmente. Es necesario para funciones como reconocimiento de imágenes y carga de archivos.',
},
wecombot: {
createBot: 'Crear bot de WeCom con un clic',
scanQRCode:
'Escanea el código QR de abajo con WeCom para autorizar y crear el bot automáticamente',
waitingForScan: 'Esperando escaneo',
createSuccess:
'¡Bot creado correctamente! Las credenciales se han rellenado automáticamente',
createFailed: 'Error al crear el bot',
connecting: 'Conectando con el servicio de WeCom...',
retry: 'Reintentar',
robotNameNote:
'El nombre del robot no puede obtenerse automáticamente. Introdúcelo manualmente.',
},
pluginPages: {
selectFromSidebar: 'Selecciona una página de plugin en la barra lateral',
invalidPage: 'Página de plugin no válida',

View File

@@ -45,6 +45,8 @@
success: '成功',
save: '保存',
saving: '保存中...',
recommend: 'おすすめ',
start: '開始',
confirm: '確認',
confirmDelete: '削除の確認',
deleteConfirmation: '本当に削除しますか?',
@@ -924,6 +926,10 @@
engineSettings: 'エンジン設定',
engineSettingsDescription: '選択したナレッジエンジンの設定',
engineSettingsReadonly: '編集モードでは変更できません',
engineSettingsInvalid:
'エンジン設定の検証に失敗しました、必須項目を確認してください',
retrievalSettingsInvalid:
'検索設定の検証に失敗しました、必須項目を確認してください',
retrievalSettings: '検索設定',
retrievalSettingsDescription: 'このナレッジベースからの文書検索方法を設定',
dangerZone: '危険ゾーン',
@@ -1339,6 +1345,46 @@
backToWorkbench: 'ワークベンチに戻る',
},
},
feishu: {
createApp: 'ワンクリックでFeishuアプリ作成',
scanQRCode: '以下のQRコードをFeishuでスキャンし、アプリを自動作成',
waitingForScan: 'スキャン待ち',
createSuccess: 'アプリ作成成功!認証情報が自動入力されました',
createFailed: '作成失敗',
connecting: 'Feishuサービスに接続中...',
expired: 'QRコードの有効期限が切れました。もう一度お試しください',
denied: 'ユーザーが承認を拒否しました',
connectionLost: '接続が切断されました。もう一度お試しください',
reconnecting: '再接続中...',
retry: '再試行',
},
weixin: {
scanLogin: 'QRコードでWeChatログイン',
scanQRCode: '以下のQRコードをWeChatでスキャンし、トークンを自動入力',
loginSuccess: 'ログイン成功!トークンが自動入力されました',
loginFailed: 'ログイン失敗',
},
dingtalk: {
createApp: 'ワンクリックでDingTalkアプリ作成',
scanQRCode: '以下のQRコードをDingTalkでスキャンし、アプリを自動作成',
waitingForScan: 'スキャン待ち',
createSuccess: 'アプリ作成成功!認証情報が自動入力されました',
createFailed: '作成失敗',
connecting: 'DingTalkサービスに接続中...',
retry: '再試行',
robotCodeNote:
'ロボットコードは自動取得できません。DingTalk開発者バックエンド > ロボット設定から手動でコピーしてください。画像認識やファイルアップロードなどの機能に必要です。',
},
wecombot: {
createBot: 'ワンクリックでWeComボット作成',
scanQRCode: '以下のQRコードをWeComでスキャンし、ボットを自動作成',
waitingForScan: 'スキャン待ち',
createSuccess: 'ボット作成成功!認証情報が自動入力されました',
createFailed: '作成失敗',
connecting: 'WeComサービスに接続中...',
retry: '再試行',
robotNameNote: 'ロボット名は自動取得できません。手動で入力してください。',
},
pluginPages: {
selectFromSidebar: 'サイドバーからプラグインページを選択してください',
invalidPage: '無効なプラグインページ',

View File

@@ -45,6 +45,8 @@ const ruRU = {
success: 'Успешно',
save: 'Сохранить',
saving: 'Сохранение...',
recommend: 'Рекомендуется',
start: 'Начать',
confirm: 'Подтвердить',
confirmDelete: 'Подтвердить удаление',
deleteConfirmation: 'Вы уверены, что хотите удалить это?',
@@ -936,6 +938,10 @@ const ruRU = {
engineSettings: 'Настройки движка',
engineSettingsDescription: 'Конфигурация выбранного движка знаний',
engineSettingsReadonly: 'только чтение в режиме редактирования',
engineSettingsInvalid:
'Настройки движка недействительны, проверьте обязательные поля',
retrievalSettingsInvalid:
'Настройки извлечения недействительны, проверьте обязательные поля',
retrievalSettings: 'Настройки извлечения',
retrievalSettingsDescription:
'Настройте способ извлечения документов из базы знаний',
@@ -1342,6 +1348,53 @@ const ruRU = {
backToWorkbench: 'Вернуться к рабочей панели',
},
},
feishu: {
createApp: 'Создать приложение Feishu в один клик',
scanQRCode:
'Отсканируйте QR-код ниже в Feishu, чтобы авторизоваться и автоматически создать приложение',
waitingForScan: 'Ожидание сканирования',
createSuccess:
'Приложение успешно создано! Учётные данные заполнены автоматически',
createFailed: 'Не удалось создать приложение',
connecting: 'Подключение к сервису Feishu...',
expired: 'Срок действия QR-кода истёк. Повторите попытку',
denied: 'Пользователь отклонил авторизацию',
connectionLost: 'Соединение потеряно. Повторите попытку',
reconnecting: 'Переподключение...',
retry: 'Повторить',
},
weixin: {
scanLogin: 'Войти в WeChat по QR-коду',
scanQRCode:
'Отсканируйте QR-код ниже в WeChat, чтобы авторизоваться и автоматически заполнить токен',
loginSuccess: 'Вход выполнен успешно! Токен заполнен автоматически',
loginFailed: 'Не удалось выполнить вход',
},
dingtalk: {
createApp: 'Создать приложение DingTalk в один клик',
scanQRCode:
'Отсканируйте QR-код ниже в DingTalk, чтобы авторизоваться и автоматически создать приложение',
waitingForScan: 'Ожидание сканирования',
createSuccess:
'Приложение успешно создано! Учётные данные заполнены автоматически',
createFailed: 'Не удалось создать приложение',
connecting: 'Подключение к сервису DingTalk...',
retry: 'Повторить',
robotCodeNote:
'Код робота нельзя получить автоматически. Перейдите в консоль разработчика DingTalk > Настройки робота и скопируйте его вручную. Он нужен для таких функций, как распознавание изображений и загрузка файлов.',
},
wecombot: {
createBot: 'Создать бота WeCom в один клик',
scanQRCode:
'Отсканируйте QR-код ниже в WeCom, чтобы авторизоваться и автоматически создать бота',
waitingForScan: 'Ожидание сканирования',
createSuccess: 'Бот успешно создан! Учётные данные заполнены автоматически',
createFailed: 'Не удалось создать бота',
connecting: 'Подключение к сервису WeCom...',
retry: 'Повторить',
robotNameNote:
'Имя бота нельзя получить автоматически. Пожалуйста, введите его вручную.',
},
pluginPages: {
selectFromSidebar: 'Выберите страницу плагина на боковой панели',
invalidPage: 'Недопустимая страница плагина',

View File

@@ -44,6 +44,8 @@ const thTH = {
success: 'สำเร็จ',
save: 'บันทึก',
saving: 'กำลังบันทึก...',
recommend: 'แนะนำ',
start: 'เริ่ม',
confirm: 'ยืนยัน',
confirmDelete: 'ยืนยันการลบ',
deleteConfirmation: 'คุณแน่ใจหรือไม่ว่าต้องการลบสิ่งนี้?',
@@ -915,6 +917,10 @@ const thTH = {
engineSettings: 'การตั้งค่าเครื่องมือ',
engineSettingsDescription: 'การกำหนดค่าสำหรับเครื่องมือความรู้ที่เลือก',
engineSettingsReadonly: 'อ่านอย่างเดียวในโหมดแก้ไข',
engineSettingsInvalid:
'การตั้งค่าเครื่องมือไม่ถูกต้อง โปรดตรวจสอบฟิลด์ที่จำเป็น',
retrievalSettingsInvalid:
'การตั้งค่าการดึงข้อมูลไม่ถูกต้อง โปรดตรวจสอบฟิลด์ที่จำเป็น',
retrievalSettings: 'การตั้งค่าการดึงข้อมูล',
retrievalSettingsDescription: 'กำหนดค่าวิธีดึงเอกสารจากฐานความรู้นี้',
dangerZone: 'โซนอันตราย',
@@ -1311,6 +1317,50 @@ const thTH = {
backToWorkbench: 'กลับไปหน้าทำงาน',
},
},
feishu: {
createApp: 'สร้างแอป Feishu ด้วยคลิกเดียว',
scanQRCode:
'สแกนคิวอาร์โค้ดด้านล่างด้วย Feishu เพื่ออนุญาตและสร้างแอปโดยอัตโนมัติ',
waitingForScan: 'กำลังรอสแกน',
createSuccess: 'สร้างแอปสำเร็จแล้ว และกรอกข้อมูลรับรองให้อัตโนมัติ',
createFailed: 'สร้างแอปไม่สำเร็จ',
connecting: 'กำลังเชื่อมต่อบริการ Feishu...',
expired: 'คิวอาร์โค้ดหมดอายุแล้ว กรุณาลองใหม่',
denied: 'ผู้ใช้ปฏิเสธการอนุญาต',
connectionLost: 'การเชื่อมต่อขาดหาย กรุณาลองใหม่',
reconnecting: 'กำลังเชื่อมต่อใหม่...',
retry: 'ลองใหม่',
},
weixin: {
scanLogin: 'เข้าสู่ระบบ WeChat ด้วยคิวอาร์โค้ด',
scanQRCode:
'สแกนคิวอาร์โค้ดด้านล่างด้วย WeChat เพื่ออนุญาตและกรอกโทเคนอัตโนมัติ',
loginSuccess: 'เข้าสู่ระบบสำเร็จ และกรอกโทเคนอัตโนมัติแล้ว',
loginFailed: 'เข้าสู่ระบบไม่สำเร็จ',
},
dingtalk: {
createApp: 'สร้างแอป DingTalk ด้วยคลิกเดียว',
scanQRCode:
'สแกนคิวอาร์โค้ดด้านล่างด้วย DingTalk เพื่ออนุญาตและสร้างแอปโดยอัตโนมัติ',
waitingForScan: 'กำลังรอสแกน',
createSuccess: 'สร้างแอปสำเร็จแล้ว และกรอกข้อมูลรับรองให้อัตโนมัติ',
createFailed: 'สร้างแอปไม่สำเร็จ',
connecting: 'กำลังเชื่อมต่อบริการ DingTalk...',
retry: 'ลองใหม่',
robotCodeNote:
'ไม่สามารถดึงรหัส Robot ได้โดยอัตโนมัติ กรุณาไปที่หลังบ้านนักพัฒนา DingTalk > การตั้งค่า Robot เพื่อคัดลอกด้วยตนเอง ฟิลด์นี้จำเป็นสำหรับฟังก์ชันอย่างการรู้จำภาพและการอัปโหลดไฟล์',
},
wecombot: {
createBot: 'สร้างบอต WeCom ด้วยคลิกเดียว',
scanQRCode:
'สแกนคิวอาร์โค้ดด้านล่างด้วย WeCom เพื่ออนุญาตและสร้างบอตโดยอัตโนมัติ',
waitingForScan: 'กำลังรอสแกน',
createSuccess: 'สร้างบอตสำเร็จแล้ว และกรอกข้อมูลรับรองให้อัตโนมัติ',
createFailed: 'สร้างบอตไม่สำเร็จ',
connecting: 'กำลังเชื่อมต่อบริการ WeCom...',
retry: 'ลองใหม่',
robotNameNote: 'ไม่สามารถดึงชื่อบอตได้โดยอัตโนมัติ กรุณากรอกด้วยตนเอง',
},
pluginPages: {
selectFromSidebar: 'เลือกหน้าปลั๊กอินจากแถบด้านข้าง',
invalidPage: 'หน้าปลั๊กอินไม่ถูกต้อง',

View File

@@ -45,6 +45,8 @@ const viVN = {
success: 'Thành công',
save: 'Lưu',
saving: 'Đang lưu...',
recommend: 'Đề xuất',
start: 'Bắt đầu',
confirm: 'Xác nhận',
confirmDelete: 'Xác nhận xóa',
deleteConfirmation: 'Bạn có chắc chắn muốn xóa mục này không?',
@@ -928,6 +930,10 @@ const viVN = {
engineSettings: 'Cài đặt công cụ',
engineSettingsDescription: 'Cấu hình cho công cụ tri thức đã chọn',
engineSettingsReadonly: 'chỉ đọc trong chế độ chỉnh sửa',
engineSettingsInvalid:
'Cài đặt công cụ không hợp lệ, vui lòng kiểm tra các trường bắt buộc',
retrievalSettingsInvalid:
'Cài đặt truy xuất không hợp lệ, vui lòng kiểm tra các trường bắt buộc',
retrievalSettings: 'Cài đặt truy xuất',
retrievalSettingsDescription:
'Cấu hình cách truy xuất tài liệu từ cơ sở tri thức này',
@@ -1333,6 +1339,52 @@ const viVN = {
backToWorkbench: 'Quay lại bàn làm việc',
},
},
feishu: {
createApp: 'Tạo ứng dụng Feishu chỉ với một lần nhấp',
scanQRCode:
'Quét mã QR bên dưới bằng Feishu để ủy quyền và tự động tạo ứng dụng',
waitingForScan: 'Đang chờ quét',
createSuccess:
'Tạo ứng dụng thành công! Thông tin xác thực đã được điền tự động',
createFailed: 'Tạo ứng dụng thất bại',
connecting: 'Đang kết nối tới dịch vụ Feishu...',
expired: 'Mã QR đã hết hạn, vui lòng thử lại',
denied: 'Người dùng đã từ chối ủy quyền',
connectionLost: 'Kết nối đã bị mất, vui lòng thử lại',
reconnecting: 'Đang kết nối lại...',
retry: 'Thử lại',
},
weixin: {
scanLogin: 'Đăng nhập WeChat bằng mã QR',
scanQRCode:
'Quét mã QR bên dưới bằng WeChat để ủy quyền và tự động điền token',
loginSuccess: 'Đăng nhập thành công! Token đã được điền tự động',
loginFailed: 'Đăng nhập thất bại',
},
dingtalk: {
createApp: 'Tạo ứng dụng DingTalk chỉ với một lần nhấp',
scanQRCode:
'Quét mã QR bên dưới bằng DingTalk để ủy quyền và tự động tạo ứng dụng',
waitingForScan: 'Đang chờ quét',
createSuccess:
'Tạo ứng dụng thành công! Thông tin xác thực đã được điền tự động',
createFailed: 'Tạo ứng dụng thất bại',
connecting: 'Đang kết nối tới dịch vụ DingTalk...',
retry: 'Thử lại',
robotCodeNote:
'Không thể tự động lấy Robot Code. Vui lòng vào trang quản trị nhà phát triển DingTalk > Cấu hình robot để sao chép thủ công. Trường này là bắt buộc cho các tính năng như nhận diện hình ảnh và tải tệp lên.',
},
wecombot: {
createBot: 'Tạo bot WeCom chỉ với một lần nhấp',
scanQRCode: 'Quét mã QR bên dưới bằng WeCom để ủy quyền và tự động tạo bot',
waitingForScan: 'Đang chờ quét',
createSuccess:
'Tạo bot thành công! Thông tin xác thực đã được điền tự động',
createFailed: 'Tạo bot thất bại',
connecting: 'Đang kết nối tới dịch vụ WeCom...',
retry: 'Thử lại',
robotNameNote: 'Không thể tự động lấy tên bot. Vui lòng điền thủ công.',
},
pluginPages: {
selectFromSidebar: 'Chọn một trang plugin từ thanh bên',
invalidPage: 'Trang plugin không hợp lệ',

View File

@@ -43,6 +43,8 @@ const zhHans = {
success: '成功',
save: '保存',
saving: '保存中...',
recommend: '推荐',
start: '开始',
confirm: '确认',
confirmDelete: '确认删除',
deleteConfirmation: '你确定要删除这个吗?',
@@ -886,6 +888,8 @@ const zhHans = {
engineSettings: '引擎设置',
engineSettingsDescription: '所选知识引擎的配置',
engineSettingsReadonly: '编辑模式下不可修改',
engineSettingsInvalid: '引擎设置中存在无效项,请检查必填字段',
retrievalSettingsInvalid: '检索设置中存在无效项,请检查必填字段',
retrievalSettings: '检索设置',
retrievalSettingsDescription: '配置从此知识库检索文档的方式',
dangerZone: '危险区域',
@@ -1274,6 +1278,47 @@ const zhHans = {
backToWorkbench: '返回工作台',
},
},
feishu: {
createApp: '一键创建飞书应用',
scanQRCode: '请使用飞书扫描以下二维码,授权后将自动创建应用并填写凭据',
waitingForScan: '等待扫码中',
createSuccess: '应用创建成功!凭据已自动填入',
createFailed: '创建失败',
connecting: '正在连接飞书服务...',
expired: '二维码已过期,请重试',
denied: '用户已拒绝授权',
connectionLost: '连接已断开,请重试',
reconnecting: '正在重新连接...',
retry: '重试',
},
weixin: {
scanLogin: '扫码登录微信',
scanQRCode: '请使用微信扫描以下二维码,授权后将自动登录并填写令牌',
loginSuccess: '登录成功!令牌已自动填入',
loginFailed: '登录失败',
},
dingtalk: {
createApp: '一键创建钉钉应用',
scanQRCode: '请使用钉钉扫描以下二维码,授权后将自动创建应用并填写凭据',
waitingForScan: '等待扫码中',
createSuccess: '应用创建成功!凭据已自动填入',
createFailed: '创建失败',
connecting: '正在连接钉钉服务...',
retry: '重试',
robotCodeNote:
'机器人代码无法自动获取,请前往钉钉开发者后台 > 机器人配置中手动复制。识图、上传文件等功能需要填写此字段。',
},
wecombot: {
createBot: '一键创建企业微信机器人',
scanQRCode:
'请使用企业微信扫描以下二维码,授权后将自动创建机器人并填写凭据',
waitingForScan: '等待扫码中',
createSuccess: '机器人创建成功!凭据已自动填入',
createFailed: '创建失败',
connecting: '正在连接企业微信服务...',
retry: '重试',
robotNameNote: '机器人名称无法自动获取,请手动填写。',
},
pluginPages: {
selectFromSidebar: '从侧边栏选择一个插件页面',
invalidPage: '无效的插件页面',

View File

@@ -43,6 +43,8 @@ const zhHant = {
success: '成功',
save: '儲存',
saving: '儲存中...',
recommend: '推薦',
start: '開始',
confirm: '確認',
confirmDelete: '確認刪除',
deleteConfirmation: '您確定要刪除這個嗎?',
@@ -880,6 +882,8 @@ const zhHant = {
engineSettings: '引擎設定',
engineSettingsDescription: '所選知識引擎的設定',
engineSettingsReadonly: '編輯模式下不可修改',
engineSettingsInvalid: '引擎設定中存在無效項,請檢查必填欄位',
retrievalSettingsInvalid: '檢索設定中存在無效項,請檢查必填欄位',
retrievalSettings: '檢索設定',
retrievalSettingsDescription: '設定從此知識庫檢索文件的方式',
dangerZone: '危險區域',
@@ -1274,6 +1278,47 @@ const zhHant = {
backToWorkbench: '返回工作台',
},
},
feishu: {
createApp: '一鍵建立飛書應用',
scanQRCode: '請使用飛書掃描以下 QR Code授權後將自動建立應用並填寫憑證',
waitingForScan: '等待掃描中',
createSuccess: '應用建立成功!憑證已自動填入',
createFailed: '建立失敗',
connecting: '正在連線飛書服務...',
expired: 'QR Code 已過期,請重試',
denied: '使用者已拒絕授權',
connectionLost: '連線已斷開,請重試',
reconnecting: '正在重新連線...',
retry: '重試',
},
weixin: {
scanLogin: '掃碼登入微信',
scanQRCode: '請使用微信掃描以下 QR Code授權後將自動登入並填寫令牌',
loginSuccess: '登入成功!令牌已自動填入',
loginFailed: '登入失敗',
},
dingtalk: {
createApp: '一鍵建立釘釘應用',
scanQRCode: '請使用釘釘掃描以下 QR Code授權後將自動建立應用並填寫憑證',
waitingForScan: '等待掃碼中',
createSuccess: '應用建立成功!憑證已自動填入',
createFailed: '建立失敗',
connecting: '正在連線釘釘服務...',
retry: '重試',
robotCodeNote:
'機器人代碼無法自動取得,請前往釘釘開發者後台 > 機器人設定中手動複製。識圖、上傳檔案等功能需要填寫此欄位。',
},
wecombot: {
createBot: '一鍵建立企業微信機器人',
scanQRCode:
'請使用企業微信掃描以下 QR Code授權後將自動建立機器人並填寫憑證',
waitingForScan: '等待掃碼中',
createSuccess: '機器人建立成功!憑證已自動填入',
createFailed: '建立失敗',
connecting: '正在連線企業微信服務...',
retry: '重試',
robotNameNote: '機器人名稱無法自動取得,請手動填寫。',
},
pluginPages: {
selectFromSidebar: '從側邊欄選擇一個插件頁面',
invalidPage: '無效的插件頁面',