Compare commits

..

1 Commits

Author SHA1 Message Date
fdc310
92c3b81014 feat: Support for interactive card message processing 2026-05-05 17:44:28 +08:00
43 changed files with 3632 additions and 6083 deletions

View File

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

View File

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

View File

@@ -1,6 +1,5 @@
import quart import quart
import mimetypes import mimetypes
import asyncio
from ... import group from ... import group
from langbot.pkg.utils import importutil from langbot.pkg.utils import importutil
@@ -36,640 +35,3 @@ class AdaptersRouterGroup(group.RouterGroup):
return quart.Response( return quart.Response(
importutil.read_resource_file_bytes(icon_path), mimetype=mimetypes.guess_type(icon_path)[0] 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,16 +39,6 @@ def _normalize_plugin_asset_path(filepath: str) -> str | None:
return f'assets/{normalized}' 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') @group.group_class('plugins', '/api/v1/plugins')
class PluginsRouterGroup(group.RouterGroup): class PluginsRouterGroup(group.RouterGroup):
async def _check_extensions_limit(self) -> str | None: async def _check_extensions_limit(self) -> str | None:
@@ -199,7 +189,7 @@ class PluginsRouterGroup(group.RouterGroup):
# CSP for HTML pages served to sandboxed iframes (opaque origin). # CSP for HTML pages served to sandboxed iframes (opaque origin).
# 'self' doesn't work in sandboxed iframes — use actual server origin. # 'self' doesn't work in sandboxed iframes — use actual server origin.
if mime_type and mime_type.startswith('text/html'): if mime_type and mime_type.startswith('text/html'):
origin = _get_request_origin() origin = f'{quart.request.scheme}://{quart.request.host}'
resp.headers['Content-Security-Policy'] = ( resp.headers['Content-Security-Policy'] = (
f'default-src {origin}; ' f'default-src {origin}; '
f"script-src {origin} 'unsafe-inline'; " f"script-src {origin} 'unsafe-inline'; "

View File

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

View File

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

View File

@@ -31,126 +31,15 @@ class KnowledgeService:
if not knowledge_engine_plugin_id: if not knowledge_engine_plugin_id:
raise ValueError('knowledge_engine_plugin_id is required') 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( kb = await self.ap.rag_mgr.create_knowledge_base(
name=kb_data.get('name', 'Untitled'), name=kb_data.get('name', 'Untitled'),
knowledge_engine_plugin_id=knowledge_engine_plugin_id, knowledge_engine_plugin_id=knowledge_engine_plugin_id,
creation_settings=creation_settings, creation_settings=kb_data.get('creation_settings', {}),
retrieval_settings=retrieval_settings, retrieval_settings=kb_data.get('retrieval_settings', {}),
description=kb_data.get('description', ''), description=kb_data.get('description', ''),
) )
return kb.uuid 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: async def update_knowledge_base(self, kb_uuid: str, kb_data: dict) -> None:
"""更新知识库""" """更新知识库"""
# Filter to only mutable fields # Filter to only mutable fields

View File

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

View File

@@ -19,18 +19,6 @@ spec:
en: https://link.langbot.app/en/platforms/dingtalk en: https://link.langbot.app/en/platforms/dingtalk
ja: https://link.langbot.app/ja/platforms/dingtalk ja: https://link.langbot.app/ja/platforms/dingtalk
config: 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 - name: client_id
label: label:
en_US: Client ID en_US: Client ID
@@ -52,10 +40,6 @@ spec:
en_US: Robot Code en_US: Robot Code
zh_Hans: 机器人代码 zh_Hans: 机器人代码
zh_Hant: 機器人代碼 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 type: string
required: true required: true
default: "" default: ""

View File

@@ -434,6 +434,43 @@ class LarkMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
'duration': message_content.get('duration', 0), 'duration': message_content.get('duration', 0),
} }
] ]
elif message.message_type == 'interactive':
# Card messages have a different structure:
# {"title": "...", "elements": [[{tag, ...}, ...], ...]}
# Each top-level array in "elements" is a group of elements on the same row.
card_text_parts = []
title = message_content.get('title', '')
if title:
card_text_parts.append(title)
for group in message_content.get('elements', []):
if not isinstance(group, list):
group = [group]
for element in group:
if not isinstance(element, dict):
continue
tag = element.get('tag', '')
if tag == 'text':
t = element.get('text', '')
if t:
card_text_parts.append(t)
elif tag == 'a':
t = element.get('text', '')
href = element.get('href', '')
if t:
card_text_parts.append(f'[{t}]({href})' if href else t)
elif tag == 'at':
pass # skip @mentions in card content
elif tag == 'markdown':
t = element.get('content', '')
if t:
card_text_parts.append(t)
if not card_text_parts:
card_text_parts.append('[Card Message]')
message_content['content'] = [{'tag': 'text', 'text': '\n'.join(card_text_parts), 'style': []}]
for ele in message_content['content']: for ele in message_content['content']:
if ele['tag'] == 'text': if ele['tag'] == 'text':
@@ -872,10 +909,17 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
return P2CardActionTriggerResponse({'toast': {'type': 'error', 'content': '反馈处理失败'}}) return P2CardActionTriggerResponse({'toast': {'type': 'error', 'content': '反馈处理失败'}})
def sync_on_bot_p2p_chat_entered(event):
# No-op: this event fires when a user opens a P2P chat with the bot.
# LangBot does not need to process it; the handler is registered to
# suppress the "processor not found" error from the Lark SDK.
pass
event_handler = ( event_handler = (
lark_oapi.EventDispatcherHandler.builder('', '') lark_oapi.EventDispatcherHandler.builder('', '')
.register_p2_im_message_receive_v1(sync_on_message) .register_p2_im_message_receive_v1(sync_on_message)
.register_p2_card_action_trigger(sync_on_card_action) .register_p2_card_action_trigger(sync_on_card_action)
.register_p2_im_chat_access_event_bot_p2p_chat_entered_v1(sync_on_bot_p2p_chat_entered)
.build() .build()
) )
@@ -1025,90 +1069,7 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
return api_client return api_client
async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain): async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain):
text_elements, media_items = await self.message_converter.yiri2target(message, self.api_client) pass
# 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: async def is_stream_output_supported(self) -> bool:
is_stream = False is_stream = False
@@ -1717,6 +1678,9 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
await self.logger.error(f'Error in lark card action callback: {traceback.format_exc()}') await self.logger.error(f'Error in lark card action callback: {traceback.format_exc()}')
return {'toast': {'type': 'error', 'content': '反馈处理失败'}} return {'toast': {'type': 'error', 'content': '反馈处理失败'}}
elif 'im.chat.access_event.bot_p2p_chat_entered_v1' == type:
# No-op: this event fires when a user opens a P2P chat with the bot.
pass
elif 'im.chat.member.bot.added_v1' == type: elif 'im.chat.member.bot.added_v1' == type:
try: try:
bot_added_welcome_msg = self.config.get('bot_added_welcome', '') bot_added_welcome_msg = self.config.get('bot_added_welcome', '')

View File

@@ -23,20 +23,6 @@ spec:
en: https://link.langbot.app/en/platforms/lark en: https://link.langbot.app/en/platforms/lark
ja: https://link.langbot.app/ja/platforms/lark ja: https://link.langbot.app/ja/platforms/lark
config: 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 - name: app_id
label: label:
en_US: App ID en_US: App ID

View File

@@ -32,20 +32,6 @@ spec:
type: string type: string
required: true required: true
default: "https://ilinkai.weixin.qq.com" 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 - name: token
label: label:
en_US: Token en_US: Token

View File

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

View File

@@ -19,18 +19,6 @@ spec:
en: https://link.langbot.app/en/platforms/wecombot en: https://link.langbot.app/en/platforms/wecombot
ja: https://link.langbot.app/ja/platforms/wecombot ja: https://link.langbot.app/ja/platforms/wecombot
config: 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 - name: BotId
label: label:
en_US: BotId en_US: BotId

View File

@@ -11,7 +11,6 @@ import os
import sys import sys
import httpx import httpx
import sqlalchemy import sqlalchemy
import yaml
from async_lru import alru_cache from async_lru import alru_cache
from langbot_plugin.api.entities.builtin.pipeline.query import provider_session from langbot_plugin.api.entities.builtin.pipeline.query import provider_session
@@ -35,10 +34,6 @@ from ..core import taskmgr
from ..entity.persistence import plugin as persistence_plugin from ..entity.persistence import plugin as persistence_plugin
class PluginRuntimeNotConnectedError(RuntimeError):
"""Raised when plugin runtime operations are requested before connection."""
class PluginRuntimeConnector: class PluginRuntimeConnector:
"""Plugin runtime connector""" """Plugin runtime connector"""
@@ -196,114 +191,44 @@ class PluginRuntimeConnector:
async def ping_plugin_runtime(self): async def ping_plugin_runtime(self):
if not hasattr(self, 'handler'): if not hasattr(self, 'handler'):
raise PluginRuntimeNotConnectedError('Plugin runtime is not connected') raise Exception('Plugin runtime is not connected')
return await self.handler.ping() return await self.handler.ping()
def _inspect_plugin_package( def _extract_deps_metadata(
self, self,
file_bytes: bytes, file_bytes: bytes,
task_context: taskmgr.TaskContext | None, task_context: taskmgr.TaskContext | None,
) -> tuple[str | None, str | None]: ):
"""Extract plugin identity and dependency metadata from a plugin package.""" """Extract dependency count from requirements.txt inside plugin zip."""
plugin_author = None if task_context is None:
plugin_name = None return
try: try:
with zipfile.ZipFile(io.BytesIO(file_bytes)) as zf: with zipfile.ZipFile(io.BytesIO(file_bytes)) as zf:
try: for name in zf.namelist():
manifest = yaml.safe_load(zf.read('manifest.yaml').decode('utf-8', errors='ignore')) or {} if name.endswith('requirements.txt'):
metadata = manifest.get('metadata', {}) content = zf.read(name).decode('utf-8', errors='ignore')
plugin_author = metadata.get('author') deps = [
plugin_name = metadata.get('name') line.strip()
except Exception: for line in content.splitlines()
pass if line.strip() and not line.strip().startswith('#')
]
if task_context is not None: task_context.metadata['deps_total'] = len(deps)
for name in zf.namelist(): task_context.metadata['deps_list'] = deps
if name.endswith('requirements.txt'): break
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: except Exception:
pass 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( async def install_plugin(
self, self,
install_source: PluginInstallSource, install_source: PluginInstallSource,
install_info: dict[str, Any], install_info: dict[str, Any],
task_context: taskmgr.TaskContext | None = None, 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: if install_source == PluginInstallSource.LOCAL:
# transfer file before install # transfer file before install
file_bytes = install_info['plugin_file'] file_bytes = install_info['plugin_file']
plugin_author, plugin_name = self._inspect_plugin_package(file_bytes, task_context) self._extract_deps_metadata(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') file_key = await self.handler.send_file(file_bytes, 'lbpkg')
install_info['plugin_file_key'] = file_key install_info['plugin_file_key'] = file_key
del install_info['plugin_file'] del install_info['plugin_file']
@@ -340,9 +265,7 @@ class PluginRuntimeConnector:
task_context.metadata['download_speed'] = downloaded / elapsed if elapsed > 0 else 0 task_context.metadata['download_speed'] = downloaded / elapsed if elapsed > 0 else 0
file_bytes = b''.join(chunks) file_bytes = b''.join(chunks)
plugin_author, plugin_name = self._inspect_plugin_package(file_bytes, task_context) self._extract_deps_metadata(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') file_key = await self.handler.send_file(file_bytes, 'lbpkg')
install_info['plugin_file_key'] = file_key install_info['plugin_file_key'] = file_key
self.ap.logger.info(f'Transfered file {file_key} to plugin runtime') self.ap.logger.info(f'Transfered file {file_key} to plugin runtime')
@@ -366,8 +289,6 @@ class PluginRuntimeConnector:
if metadata is not None and task_context is not None: if metadata is not None and task_context is not None:
task_context.metadata.update(metadata) task_context.metadata.update(metadata)
await self._wait_for_installed_plugin_ready(plugin_author, plugin_name, task_context)
async def upgrade_plugin( async def upgrade_plugin(
self, self,
plugin_author: str, plugin_author: str,

View File

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

View File

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

View File

@@ -14,14 +14,7 @@ class TokenManager:
def __init__(self, name: str, tokens: list[str]): def __init__(self, name: str, tokens: list[str]):
self.name = name self.name = name
self.tokens = [] self.tokens = 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 self.using_token_index = 0
def get_token(self) -> str: def get_token(self) -> str:

View File

@@ -1,32 +0,0 @@
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
from langbot.pkg.plugin.connector import PluginRuntimeConnector, PluginRuntimeNotConnectedError
def make_connector() -> PluginRuntimeConnector:
app = SimpleNamespace(instance_config=SimpleNamespace(data={'plugin': {'enable': True}}))
return PluginRuntimeConnector(app, AsyncMock())
@pytest.mark.asyncio
async def test_ping_plugin_runtime_raises_specific_error_when_not_connected():
connector = make_connector()
with pytest.raises(PluginRuntimeNotConnectedError, match='Plugin runtime is not connected'):
await connector.ping_plugin_runtime()
@pytest.mark.asyncio
async def test_ping_plugin_runtime_delegates_to_connected_handler():
connector = make_connector()
connector.handler = SimpleNamespace(ping=AsyncMock(return_value='pong'))
result = await connector.ping_plugin_runtime()
assert result == 'pong'
connector.handler.ping.assert_awaited_once()

View File

@@ -11,14 +11,10 @@ import langbot_plugin.api.entities.builtin.platform.message as platform_message
import langbot_plugin.api.entities.builtin.provider.session as provider_session 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.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.entity.persistence import model as persistence_model
from langbot.pkg.pipeline.preproc.preproc import PreProcessor from langbot.pkg.pipeline.preproc.preproc import PreProcessor
from langbot.pkg.provider.modelmgr import requester from langbot.pkg.provider.modelmgr import requester
from langbot.pkg.provider.modelmgr.modelmgr import ModelManager 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 from langbot.pkg.provider.runners.localagent import LocalAgentRunner
@@ -62,93 +58,6 @@ def test_runtime_rerank_model_data_preserves_uuid_after_update_payload_uuid_remo
assert runtime_entity.name == 'rerank-model' 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 @pytest.mark.asyncio
async def test_updated_llm_model_is_immediately_usable_by_local_agent_pipeline(): async def test_updated_llm_model_is_immediately_usable_by_local_agent_pipeline():
from langbot.pkg.api.http.service.model import LLMModelsService 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, useRef } from 'react'; import { useEffect, useState, useCallback, Suspense } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom'; import { useNavigate, useSearchParams } from 'react-router-dom';
import { httpClient } from '@/app/infra/http/HttpClient'; import { httpClient } from '@/app/infra/http/HttpClient';
import { toast } from 'sonner'; import { toast } from 'sonner';
@@ -20,39 +20,10 @@ import { Button } from '@/components/ui/button';
import { LoadingSpinner } from '@/components/ui/loading-spinner'; import { LoadingSpinner } from '@/components/ui/loading-spinner';
import langbotIcon from '@/app/assets/langbot-logo.webp'; 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() { function SpaceOAuthCallbackContent() {
const navigate = useNavigate(); const navigate = useNavigate();
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const { t } = useTranslation(); const { t } = useTranslation();
const isMountedRef = useRef(true);
const [status, setStatus] = useState< const [status, setStatus] = useState<
'loading' | 'confirm' | 'success' | 'error' 'loading' | 'confirm' | 'success' | 'error'
@@ -66,11 +37,7 @@ function SpaceOAuthCallbackContent() {
const handleOAuthCallback = useCallback( const handleOAuthCallback = useCallback(
async (authCode: string) => { async (authCode: string) => {
try { try {
const response = await getOrCreateSpaceOAuthLoginPromise(authCode); const response = await httpClient.exchangeSpaceOAuthCode(authCode);
if (!isMountedRef.current) {
return;
}
localStorage.setItem('token', response.token); localStorage.setItem('token', response.token);
if (response.user) { if (response.user) {
localStorage.setItem('userEmail', response.user); localStorage.setItem('userEmail', response.user);
@@ -85,10 +52,6 @@ function SpaceOAuthCallbackContent() {
navigate(redirectTo); navigate(redirectTo);
}, 1000); }, 1000);
} catch (err) { } catch (err) {
if (!isMountedRef.current) {
return;
}
setStatus('error'); setStatus('error');
const errorObj = err as { msg?: string }; const errorObj = err as { msg?: string };
const errMsg = (errorObj?.msg || '').toLowerCase(); const errMsg = (errorObj?.msg || '').toLowerCase();
@@ -109,10 +72,6 @@ function SpaceOAuthCallbackContent() {
setIsProcessing(true); setIsProcessing(true);
try { try {
const response = await httpClient.bindSpaceAccount(authCode, state); const response = await httpClient.bindSpaceAccount(authCode, state);
if (!isMountedRef.current) {
return;
}
localStorage.setItem('token', response.token); localStorage.setItem('token', response.token);
if (response.user) { if (response.user) {
localStorage.setItem('userEmail', response.user); localStorage.setItem('userEmail', response.user);
@@ -123,10 +82,6 @@ function SpaceOAuthCallbackContent() {
navigate('/home'); navigate('/home');
}, 1000); }, 1000);
} catch (err) { } catch (err) {
if (!isMountedRef.current) {
return;
}
setStatus('error'); setStatus('error');
const errorObj = err as { msg?: string }; const errorObj = err as { msg?: string };
const errMsg = (errorObj?.msg || '').toLowerCase(); const errMsg = (errorObj?.msg || '').toLowerCase();
@@ -136,17 +91,13 @@ function SpaceOAuthCallbackContent() {
setErrorMessage(t('account.bindSpaceFailed')); setErrorMessage(t('account.bindSpaceFailed'));
} }
} finally { } finally {
if (isMountedRef.current) { setIsProcessing(false);
setIsProcessing(false);
}
} }
}, },
[navigate, t], [navigate, t],
); );
useEffect(() => { useEffect(() => {
isMountedRef.current = true;
const authCode = searchParams.get('code'); const authCode = searchParams.get('code');
const error = searchParams.get('error'); const error = searchParams.get('error');
const errorDescription = searchParams.get('error_description'); const errorDescription = searchParams.get('error_description');
@@ -184,9 +135,6 @@ function SpaceOAuthCallbackContent() {
// Normal login/register mode // Normal login/register mode
handleOAuthCallback(authCode); handleOAuthCallback(authCode);
} }
return () => {
isMountedRef.current = false;
};
}, [searchParams, handleOAuthCallback, t]); }, [searchParams, handleOAuthCallback, t]);
const handleConfirmBind = () => { const handleConfirmBind = () => {

View File

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

View File

@@ -11,16 +11,13 @@ import {
FormMessage, FormMessage,
} from '@/components/ui/form'; } from '@/components/ui/form';
import DynamicFormItemComponent from '@/app/home/components/dynamic-form/DynamicFormItemComponent'; import DynamicFormItemComponent from '@/app/home/components/dynamic-form/DynamicFormItemComponent';
import QrCodeLoginDialog, {
QrLoginPlatform,
} from '@/app/home/components/qrcode-login/QrCodeLoginDialog';
import { useEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { extractI18nObject } from '@/i18n/I18nProvider'; import { extractI18nObject } from '@/i18n/I18nProvider';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Copy, Check, Globe, QrCode } from 'lucide-react'; import { Copy, Check, Globe } from 'lucide-react';
import { copyToClipboard } from '@/app/utils/clipboard'; import { copyToClipboard } from '@/app/utils/clipboard';
import { systemInfo } from '@/app/infra/http'; import { systemInfo } from '@/app/infra/http';
@@ -198,7 +195,6 @@ export default function DynamicFormComponent({
isEditing, isEditing,
externalDependentValues, externalDependentValues,
systemContext, systemContext,
onValidate,
}: { }: {
itemConfigList: IDynamicFormItemSchema[]; itemConfigList: IDynamicFormItemSchema[];
onSubmit?: (val: object) => unknown; onSubmit?: (val: object) => unknown;
@@ -209,9 +205,6 @@ export default function DynamicFormComponent({
/** Extra variables accessible via the `__system.*` namespace in show_if conditions. /** Extra variables accessible via the `__system.*` namespace in show_if conditions.
* e.g. `{ is_wizard: true }` makes `show_if: { field: "__system.is_wizard", ... }` work. */ * e.g. `{ is_wizard: true }` makes `show_if: { field: "__system.is_wizard", ... }` work. */
systemContext?: Record<string, unknown>; 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 isInitialMount = useRef(true);
const previousInitialValues = useRef(initialValues); const previousInitialValues = useRef(initialValues);
@@ -258,10 +251,7 @@ export default function DynamicFormComponent({
const editableItems = useMemo( const editableItems = useMemo(
() => () =>
itemConfigList.filter( itemConfigList.filter(
(item) => (item) => item.type !== 'webhook-url' && item.type !== 'embed-code',
item.type !== 'webhook-url' &&
item.type !== 'embed-code' &&
item.type !== 'qr-code-login',
), ),
[itemConfigList], [itemConfigList],
); );
@@ -362,17 +352,6 @@ export default function DynamicFormComponent({
}, {} as FormValues), }, {} 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 变化时更新表单值 // 当 initialValues 变化时更新表单值
// 但要避免因为内部表单更新触发的 onSubmit 导致的 initialValues 变化而重新设置表单 // 但要避免因为内部表单更新触发的 onSubmit 导致的 initialValues 变化而重新设置表单
useEffect(() => { useEffect(() => {
@@ -455,28 +434,9 @@ export default function DynamicFormComponent({
return () => subscription.unsubscribe(); return () => subscription.unsubscribe();
}, [form, editableItems]); }, [form, editableItems]);
// State for QR code login dialog
const [qrDialogOpen, setQrDialogOpen] = useState(false);
const [qrDialogPlatform, setQrDialogPlatform] =
useState<QrLoginPlatform>('feishu');
return ( return (
<Form {...form}> <Form {...form}>
<div className="space-y-4"> <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) => { {itemConfigList.map((config) => {
if (config.show_if) { if (config.show_if) {
const dependValue = resolveShowIfValue( const dependValue = resolveShowIfValue(
@@ -563,66 +523,6 @@ 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 // Boolean fields use a special inline layout
if (config.type === 'boolean') { if (config.type === 'boolean') {
return ( return (

View File

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

View File

@@ -1,366 +0,0 @@
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

@@ -1,393 +0,0 @@
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,6 +57,7 @@ const getFormSchema = (t: (key: string) => string) =>
* Parse creation schema from Knowledge Engine to IDynamicFormItemSchema[] * Parse creation schema from Knowledge Engine to IDynamicFormItemSchema[]
*/ */
function parseCreationSchema( function parseCreationSchema(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
schemaItems: any | any[] | undefined, schemaItems: any | any[] | undefined,
): IDynamicFormItemSchema[] { ): IDynamicFormItemSchema[] {
if (!schemaItems) return []; if (!schemaItems) return [];
@@ -106,10 +107,6 @@ export default function KBForm({
const savedSnapshotRef = useRef<string>(''); const savedSnapshotRef = useRef<string>('');
const isInitializing = useRef(true); 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 formSchema = getFormSchema(t);
const form = useForm<z.infer<typeof formSchema>>({ const form = useForm<z.infer<typeof formSchema>>({
@@ -238,24 +235,7 @@ export default function KBForm({
} }
}; };
const onSubmit = async (data: z.infer<typeof formSchema>) => { const onSubmit = (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 = { const kbData: KnowledgeBase = {
name: data.name, name: data.name,
description: data.description ?? '', description: data.description ?? '',
@@ -510,9 +490,6 @@ export default function KBForm({
} }
isEditing={isEditing} isEditing={isEditing}
externalDependentValues={retrievalSettings} externalDependentValues={retrievalSettings}
onValidate={(validateFn) =>
(configValidateRef.current = validateFn)
}
/> />
</CardContent> </CardContent>
</Card> </Card>
@@ -535,9 +512,6 @@ export default function KBForm({
setRetrievalSettings(val as Record<string, unknown>) setRetrievalSettings(val as Record<string, unknown>)
} }
externalDependentValues={configSettings} externalDependentValues={configSettings}
onValidate={(validateFn) =>
(retrievalValidateRef.current = validateFn)
}
/> />
</CardContent> </CardContent>
</Card> </Card>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -44,8 +44,6 @@ const enUS = {
success: 'Success', success: 'Success',
save: 'Save', save: 'Save',
saving: 'Saving...', saving: 'Saving...',
recommend: 'Recommended',
start: 'Start',
confirm: 'Confirm', confirm: 'Confirm',
confirmDelete: 'Confirm Delete', confirmDelete: 'Confirm Delete',
deleteConfirmation: 'Are you sure you want to delete this?', deleteConfirmation: 'Are you sure you want to delete this?',
@@ -930,10 +928,6 @@ const enUS = {
engineSettingsDescription: engineSettingsDescription:
'Configuration for the selected knowledge engine', 'Configuration for the selected knowledge engine',
engineSettingsReadonly: 'read-only in edit mode', 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', retrievalSettings: 'Retrieval Settings',
retrievalSettingsDescription: retrievalSettingsDescription:
'Configure how documents are retrieved from this knowledge base', 'Configure how documents are retrieved from this knowledge base',
@@ -1338,51 +1332,6 @@ const enUS = {
backToWorkbench: 'Back to Workbench', 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: { pluginPages: {
selectFromSidebar: 'Select a plugin page from the sidebar', selectFromSidebar: 'Select a plugin page from the sidebar',
invalidPage: 'Invalid plugin page', invalidPage: 'Invalid plugin page',

View File

@@ -47,8 +47,6 @@ const esES = {
success: 'Éxito', success: 'Éxito',
save: 'Guardar', save: 'Guardar',
saving: 'Guardando...', saving: 'Guardando...',
recommend: 'Recomendado',
start: 'Iniciar',
confirm: 'Confirmar', confirm: 'Confirmar',
confirmDelete: 'Confirmar eliminación', confirmDelete: 'Confirmar eliminación',
deleteConfirmation: '¿Estás seguro de que deseas eliminar esto?', deleteConfirmation: '¿Estás seguro de que deseas eliminar esto?',
@@ -953,10 +951,6 @@ const esES = {
engineSettingsDescription: engineSettingsDescription:
'Configuración del motor de conocimiento seleccionado', 'Configuración del motor de conocimiento seleccionado',
engineSettingsReadonly: 'solo lectura en modo de edición', 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', retrievalSettings: 'Configuración de recuperación',
retrievalSettingsDescription: retrievalSettingsDescription:
'Configura cómo se recuperan los documentos de esta base de conocimiento', 'Configura cómo se recuperan los documentos de esta base de conocimiento',
@@ -1377,55 +1371,6 @@ const esES = {
backToWorkbench: 'Volver al panel de trabajo', 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: { pluginPages: {
selectFromSidebar: 'Selecciona una página de plugin en la barra lateral', selectFromSidebar: 'Selecciona una página de plugin en la barra lateral',
invalidPage: 'Página de plugin no válida', invalidPage: 'Página de plugin no válida',

View File

@@ -45,8 +45,6 @@
success: '成功', success: '成功',
save: '保存', save: '保存',
saving: '保存中...', saving: '保存中...',
recommend: 'おすすめ',
start: '開始',
confirm: '確認', confirm: '確認',
confirmDelete: '削除の確認', confirmDelete: '削除の確認',
deleteConfirmation: '本当に削除しますか?', deleteConfirmation: '本当に削除しますか?',
@@ -926,10 +924,6 @@
engineSettings: 'エンジン設定', engineSettings: 'エンジン設定',
engineSettingsDescription: '選択したナレッジエンジンの設定', engineSettingsDescription: '選択したナレッジエンジンの設定',
engineSettingsReadonly: '編集モードでは変更できません', engineSettingsReadonly: '編集モードでは変更できません',
engineSettingsInvalid:
'エンジン設定の検証に失敗しました、必須項目を確認してください',
retrievalSettingsInvalid:
'検索設定の検証に失敗しました、必須項目を確認してください',
retrievalSettings: '検索設定', retrievalSettings: '検索設定',
retrievalSettingsDescription: 'このナレッジベースからの文書検索方法を設定', retrievalSettingsDescription: 'このナレッジベースからの文書検索方法を設定',
dangerZone: '危険ゾーン', dangerZone: '危険ゾーン',
@@ -1345,46 +1339,6 @@
backToWorkbench: 'ワークベンチに戻る', 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: { pluginPages: {
selectFromSidebar: 'サイドバーからプラグインページを選択してください', selectFromSidebar: 'サイドバーからプラグインページを選択してください',
invalidPage: '無効なプラグインページ', invalidPage: '無効なプラグインページ',

View File

@@ -45,8 +45,6 @@ const ruRU = {
success: 'Успешно', success: 'Успешно',
save: 'Сохранить', save: 'Сохранить',
saving: 'Сохранение...', saving: 'Сохранение...',
recommend: 'Рекомендуется',
start: 'Начать',
confirm: 'Подтвердить', confirm: 'Подтвердить',
confirmDelete: 'Подтвердить удаление', confirmDelete: 'Подтвердить удаление',
deleteConfirmation: 'Вы уверены, что хотите удалить это?', deleteConfirmation: 'Вы уверены, что хотите удалить это?',
@@ -938,10 +936,6 @@ const ruRU = {
engineSettings: 'Настройки движка', engineSettings: 'Настройки движка',
engineSettingsDescription: 'Конфигурация выбранного движка знаний', engineSettingsDescription: 'Конфигурация выбранного движка знаний',
engineSettingsReadonly: 'только чтение в режиме редактирования', engineSettingsReadonly: 'только чтение в режиме редактирования',
engineSettingsInvalid:
'Настройки движка недействительны, проверьте обязательные поля',
retrievalSettingsInvalid:
'Настройки извлечения недействительны, проверьте обязательные поля',
retrievalSettings: 'Настройки извлечения', retrievalSettings: 'Настройки извлечения',
retrievalSettingsDescription: retrievalSettingsDescription:
'Настройте способ извлечения документов из базы знаний', 'Настройте способ извлечения документов из базы знаний',
@@ -1348,53 +1342,6 @@ const ruRU = {
backToWorkbench: 'Вернуться к рабочей панели', 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: { pluginPages: {
selectFromSidebar: 'Выберите страницу плагина на боковой панели', selectFromSidebar: 'Выберите страницу плагина на боковой панели',
invalidPage: 'Недопустимая страница плагина', invalidPage: 'Недопустимая страница плагина',

View File

@@ -44,8 +44,6 @@ const thTH = {
success: 'สำเร็จ', success: 'สำเร็จ',
save: 'บันทึก', save: 'บันทึก',
saving: 'กำลังบันทึก...', saving: 'กำลังบันทึก...',
recommend: 'แนะนำ',
start: 'เริ่ม',
confirm: 'ยืนยัน', confirm: 'ยืนยัน',
confirmDelete: 'ยืนยันการลบ', confirmDelete: 'ยืนยันการลบ',
deleteConfirmation: 'คุณแน่ใจหรือไม่ว่าต้องการลบสิ่งนี้?', deleteConfirmation: 'คุณแน่ใจหรือไม่ว่าต้องการลบสิ่งนี้?',
@@ -917,10 +915,6 @@ const thTH = {
engineSettings: 'การตั้งค่าเครื่องมือ', engineSettings: 'การตั้งค่าเครื่องมือ',
engineSettingsDescription: 'การกำหนดค่าสำหรับเครื่องมือความรู้ที่เลือก', engineSettingsDescription: 'การกำหนดค่าสำหรับเครื่องมือความรู้ที่เลือก',
engineSettingsReadonly: 'อ่านอย่างเดียวในโหมดแก้ไข', engineSettingsReadonly: 'อ่านอย่างเดียวในโหมดแก้ไข',
engineSettingsInvalid:
'การตั้งค่าเครื่องมือไม่ถูกต้อง โปรดตรวจสอบฟิลด์ที่จำเป็น',
retrievalSettingsInvalid:
'การตั้งค่าการดึงข้อมูลไม่ถูกต้อง โปรดตรวจสอบฟิลด์ที่จำเป็น',
retrievalSettings: 'การตั้งค่าการดึงข้อมูล', retrievalSettings: 'การตั้งค่าการดึงข้อมูล',
retrievalSettingsDescription: 'กำหนดค่าวิธีดึงเอกสารจากฐานความรู้นี้', retrievalSettingsDescription: 'กำหนดค่าวิธีดึงเอกสารจากฐานความรู้นี้',
dangerZone: 'โซนอันตราย', dangerZone: 'โซนอันตราย',
@@ -1317,50 +1311,6 @@ const thTH = {
backToWorkbench: 'กลับไปหน้าทำงาน', 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: { pluginPages: {
selectFromSidebar: 'เลือกหน้าปลั๊กอินจากแถบด้านข้าง', selectFromSidebar: 'เลือกหน้าปลั๊กอินจากแถบด้านข้าง',
invalidPage: 'หน้าปลั๊กอินไม่ถูกต้อง', invalidPage: 'หน้าปลั๊กอินไม่ถูกต้อง',

View File

@@ -45,8 +45,6 @@ const viVN = {
success: 'Thành công', success: 'Thành công',
save: 'Lưu', save: 'Lưu',
saving: 'Đang lưu...', saving: 'Đang lưu...',
recommend: 'Đề xuất',
start: 'Bắt đầu',
confirm: 'Xác nhận', confirm: 'Xác nhận',
confirmDelete: 'Xác nhận xóa', 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?', deleteConfirmation: 'Bạn có chắc chắn muốn xóa mục này không?',
@@ -930,10 +928,6 @@ const viVN = {
engineSettings: 'Cài đặt công cụ', engineSettings: 'Cài đặt công cụ',
engineSettingsDescription: 'Cấu hình cho công cụ tri thức đã chọn', engineSettingsDescription: 'Cấu hình cho công cụ tri thức đã chọn',
engineSettingsReadonly: 'chỉ đọc trong chế độ chỉnh sửa', 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', retrievalSettings: 'Cài đặt truy xuất',
retrievalSettingsDescription: retrievalSettingsDescription:
'Cấu hình cách truy xuất tài liệu từ cơ sở tri thức này', 'Cấu hình cách truy xuất tài liệu từ cơ sở tri thức này',
@@ -1339,52 +1333,6 @@ const viVN = {
backToWorkbench: 'Quay lại bàn làm việc', 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: { pluginPages: {
selectFromSidebar: 'Chọn một trang plugin từ thanh bên', selectFromSidebar: 'Chọn một trang plugin từ thanh bên',
invalidPage: 'Trang plugin không hợp lệ', invalidPage: 'Trang plugin không hợp lệ',

View File

@@ -43,8 +43,6 @@ const zhHans = {
success: '成功', success: '成功',
save: '保存', save: '保存',
saving: '保存中...', saving: '保存中...',
recommend: '推荐',
start: '开始',
confirm: '确认', confirm: '确认',
confirmDelete: '确认删除', confirmDelete: '确认删除',
deleteConfirmation: '你确定要删除这个吗?', deleteConfirmation: '你确定要删除这个吗?',
@@ -888,8 +886,6 @@ const zhHans = {
engineSettings: '引擎设置', engineSettings: '引擎设置',
engineSettingsDescription: '所选知识引擎的配置', engineSettingsDescription: '所选知识引擎的配置',
engineSettingsReadonly: '编辑模式下不可修改', engineSettingsReadonly: '编辑模式下不可修改',
engineSettingsInvalid: '引擎设置中存在无效项,请检查必填字段',
retrievalSettingsInvalid: '检索设置中存在无效项,请检查必填字段',
retrievalSettings: '检索设置', retrievalSettings: '检索设置',
retrievalSettingsDescription: '配置从此知识库检索文档的方式', retrievalSettingsDescription: '配置从此知识库检索文档的方式',
dangerZone: '危险区域', dangerZone: '危险区域',
@@ -1278,47 +1274,6 @@ const zhHans = {
backToWorkbench: '返回工作台', 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: { pluginPages: {
selectFromSidebar: '从侧边栏选择一个插件页面', selectFromSidebar: '从侧边栏选择一个插件页面',
invalidPage: '无效的插件页面', invalidPage: '无效的插件页面',

View File

@@ -43,8 +43,6 @@ const zhHant = {
success: '成功', success: '成功',
save: '儲存', save: '儲存',
saving: '儲存中...', saving: '儲存中...',
recommend: '推薦',
start: '開始',
confirm: '確認', confirm: '確認',
confirmDelete: '確認刪除', confirmDelete: '確認刪除',
deleteConfirmation: '您確定要刪除這個嗎?', deleteConfirmation: '您確定要刪除這個嗎?',
@@ -882,8 +880,6 @@ const zhHant = {
engineSettings: '引擎設定', engineSettings: '引擎設定',
engineSettingsDescription: '所選知識引擎的設定', engineSettingsDescription: '所選知識引擎的設定',
engineSettingsReadonly: '編輯模式下不可修改', engineSettingsReadonly: '編輯模式下不可修改',
engineSettingsInvalid: '引擎設定中存在無效項,請檢查必填欄位',
retrievalSettingsInvalid: '檢索設定中存在無效項,請檢查必填欄位',
retrievalSettings: '檢索設定', retrievalSettings: '檢索設定',
retrievalSettingsDescription: '設定從此知識庫檢索文件的方式', retrievalSettingsDescription: '設定從此知識庫檢索文件的方式',
dangerZone: '危險區域', dangerZone: '危險區域',
@@ -1278,47 +1274,6 @@ const zhHant = {
backToWorkbench: '返回工作台', 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: { pluginPages: {
selectFromSidebar: '從側邊欄選擇一個插件頁面', selectFromSidebar: '從側邊欄選擇一個插件頁面',
invalidPage: '無效的插件頁面', invalidPage: '無效的插件頁面',