mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-02 20:14:36 +00:00
Compare commits
12 Commits
feat/agent
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4054ba2a76 | ||
|
|
c7cb42bd79 | ||
|
|
894709d577 | ||
|
|
6823069103 | ||
|
|
699545a196 | ||
|
|
f0061817ea | ||
|
|
688202e7d1 | ||
|
|
d46b762d03 | ||
|
|
0963fd5443 | ||
|
|
6471770737 | ||
|
|
314b7d15bb | ||
|
|
c758908745 |
9
.github/ISSUE_TEMPLATE/bug-report.yml
vendored
9
.github/ISSUE_TEMPLATE/bug-report.yml
vendored
@@ -10,6 +10,15 @@ body:
|
|||||||
placeholder: 例如:v3.3.0、CentOS x64 Python 3.10.3、Docker
|
placeholder: 例如:v3.3.0、CentOS x64 Python 3.10.3、Docker
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
- type: dropdown
|
||||||
|
attributes:
|
||||||
|
label: 部署版本
|
||||||
|
description: 请选择您使用的 LangBot 部署版本。
|
||||||
|
options:
|
||||||
|
- 社区版
|
||||||
|
- 云服务
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
- type: textarea
|
- type: textarea
|
||||||
attributes:
|
attributes:
|
||||||
label: 异常情况
|
label: 异常情况
|
||||||
|
|||||||
9
.github/ISSUE_TEMPLATE/bug-report_en.yml
vendored
9
.github/ISSUE_TEMPLATE/bug-report_en.yml
vendored
@@ -10,6 +10,15 @@ body:
|
|||||||
placeholder: "For example: v3.3.0, CentOS x64 Python 3.10.3, Docker"
|
placeholder: "For example: v3.3.0, CentOS x64 Python 3.10.3, Docker"
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
- type: dropdown
|
||||||
|
attributes:
|
||||||
|
label: Deployment version
|
||||||
|
description: Please select the LangBot deployment version you are using.
|
||||||
|
options:
|
||||||
|
- Community Edition
|
||||||
|
- Cloud Service
|
||||||
|
validations:
|
||||||
|
required: true
|
||||||
- type: textarea
|
- type: textarea
|
||||||
attributes:
|
attributes:
|
||||||
label: Exception
|
label: Exception
|
||||||
|
|||||||
@@ -47,6 +47,8 @@ LangBot is an **open-source, production-grade platform** for building AI-powered
|
|||||||
|
|
||||||
[→ Learn more about all features](https://link.langbot.app/en/docs/features)
|
[→ Learn more about all features](https://link.langbot.app/en/docs/features)
|
||||||
|
|
||||||
|
📍 Practical guides: [deploy a multi-platform AI bot in 5 minutes](https://blog.langbot.app/en/blog/deploy-ai-bot-in-5-minutes/), [connect DeepSeek to WeChat, Discord, and Telegram](https://blog.langbot.app/en/blog/connect-deepseek-to-wechat/), [run a Dify Agent in Discord, Telegram, and Slack](https://blog.langbot.app/en/blog/dify-agent-discord-telegram-slack/), and [build an n8n-powered chatbot](https://blog.langbot.app/en/blog/n8n-multi-platform-ai-chatbot/).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|||||||
@@ -47,6 +47,8 @@ LangBot 是一个**开源的生产级平台**,用于构建 AI 驱动的即时
|
|||||||
|
|
||||||
[→ 了解更多功能特性](https://link.langbot.app/zh/docs/features)
|
[→ 了解更多功能特性](https://link.langbot.app/zh/docs/features)
|
||||||
|
|
||||||
|
📍 实践指南:[5 分钟部署多平台 AI 机器人](https://blog.langbot.app/zh/blog/deploy-ai-bot-in-5-minutes/)、[将 DeepSeek 接入微信、企业微信与 Discord](https://blog.langbot.app/zh/blog/connect-deepseek-to-wechat/)、[让 Dify Agent 跑在 Discord、Telegram 和 Slack 上](https://blog.langbot.app/zh/blog/dify-agent-discord-telegram-slack/),以及[用 n8n 构建多平台 AI 聊天机器人](https://blog.langbot.app/zh/blog/n8n-multi-platform-ai-chatbot/)。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 快速开始
|
## 快速开始
|
||||||
|
|||||||
@@ -46,6 +46,8 @@ LangBot es una **plataforma de código abierto y grado de producción** para con
|
|||||||
|
|
||||||
[→ Conocer más sobre todas las funcionalidades](https://link.langbot.app/en/docs/features)
|
[→ Conocer más sobre todas las funcionalidades](https://link.langbot.app/en/docs/features)
|
||||||
|
|
||||||
|
📍 Guías prácticas: [desplegar un bot de IA multiplataforma en 5 minutos](https://blog.langbot.app/en/blog/deploy-ai-bot-in-5-minutes/), [conectar DeepSeek a WeChat, Discord y Telegram](https://blog.langbot.app/en/blog/connect-deepseek-to-wechat/), [ejecutar un Dify Agent en Discord, Telegram y Slack](https://blog.langbot.app/en/blog/dify-agent-discord-telegram-slack/) y [crear un chatbot con n8n](https://blog.langbot.app/en/blog/n8n-multi-platform-ai-chatbot/).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Inicio Rápido
|
## Inicio Rápido
|
||||||
|
|||||||
@@ -46,6 +46,8 @@ LangBot est une **plateforme open-source de niveau production** pour créer des
|
|||||||
|
|
||||||
[→ En savoir plus sur toutes les fonctionnalités](https://link.langbot.app/en/docs/features)
|
[→ En savoir plus sur toutes les fonctionnalités](https://link.langbot.app/en/docs/features)
|
||||||
|
|
||||||
|
📍 Guides pratiques : [déployer un bot IA multiplateforme en 5 minutes](https://blog.langbot.app/en/blog/deploy-ai-bot-in-5-minutes/), [connecter DeepSeek à WeChat, Discord et Telegram](https://blog.langbot.app/en/blog/connect-deepseek-to-wechat/), [exécuter un Dify Agent dans Discord, Telegram et Slack](https://blog.langbot.app/en/blog/dify-agent-discord-telegram-slack/) et [créer un chatbot avec n8n](https://blog.langbot.app/en/blog/n8n-multi-platform-ai-chatbot/).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Démarrage Rapide
|
## Démarrage Rapide
|
||||||
|
|||||||
@@ -46,6 +46,8 @@ LangBot は、AI搭載のインスタントメッセージングボットを構
|
|||||||
|
|
||||||
[→ すべての機能について詳しく見る](https://link.langbot.app/ja/docs/features)
|
[→ すべての機能について詳しく見る](https://link.langbot.app/ja/docs/features)
|
||||||
|
|
||||||
|
📍 実践ガイド: [5分でマルチプラットフォームAIボットをデプロイ](https://blog.langbot.app/en/blog/deploy-ai-bot-in-5-minutes/)、[DeepSeekをWeChat・Discord・Telegramに接続](https://blog.langbot.app/en/blog/connect-deepseek-to-wechat/)、[Dify AgentをDiscord・Telegram・Slackで動かす](https://blog.langbot.app/en/blog/dify-agent-discord-telegram-slack/)、[n8n連携チャットボットを構築](https://blog.langbot.app/en/blog/n8n-multi-platform-ai-chatbot/)。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## クイックスタート
|
## クイックスタート
|
||||||
|
|||||||
@@ -46,6 +46,8 @@ LangBot은 AI 기반 인스턴트 메시징 봇을 구축하기 위한 **오픈
|
|||||||
|
|
||||||
[→ 모든 기능 자세히 보기](https://link.langbot.app/en/docs/features)
|
[→ 모든 기능 자세히 보기](https://link.langbot.app/en/docs/features)
|
||||||
|
|
||||||
|
📍 실전 가이드: [5분 만에 멀티 플랫폼 AI 봇 배포하기](https://blog.langbot.app/en/blog/deploy-ai-bot-in-5-minutes/), [DeepSeek를 WeChat, Discord, Telegram에 연결하기](https://blog.langbot.app/en/blog/connect-deepseek-to-wechat/), [Dify Agent를 Discord, Telegram, Slack에서 실행하기](https://blog.langbot.app/en/blog/dify-agent-discord-telegram-slack/), [n8n 기반 챗봇 만들기](https://blog.langbot.app/en/blog/n8n-multi-platform-ai-chatbot/).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 빠른 시작
|
## 빠른 시작
|
||||||
|
|||||||
@@ -46,6 +46,8 @@ LangBot — это **платформа с открытым исходным к
|
|||||||
|
|
||||||
[→ Подробнее обо всех возможностях](https://link.langbot.app/en/docs/features)
|
[→ Подробнее обо всех возможностях](https://link.langbot.app/en/docs/features)
|
||||||
|
|
||||||
|
📍 Практические руководства: [развернуть мультиплатформенного ИИ-бота за 5 минут](https://blog.langbot.app/en/blog/deploy-ai-bot-in-5-minutes/), [подключить DeepSeek к WeChat, Discord и Telegram](https://blog.langbot.app/en/blog/connect-deepseek-to-wechat/), [запустить Dify Agent в Discord, Telegram и Slack](https://blog.langbot.app/en/blog/dify-agent-discord-telegram-slack/) и [создать чат-бота на n8n](https://blog.langbot.app/en/blog/n8n-multi-platform-ai-chatbot/).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Быстрый старт
|
## Быстрый старт
|
||||||
|
|||||||
@@ -48,6 +48,8 @@ LangBot 是一個**開源的生產級平台**,用於建構 AI 驅動的即時
|
|||||||
|
|
||||||
[→ 了解更多功能特性](https://link.langbot.app/zh/docs/features)
|
[→ 了解更多功能特性](https://link.langbot.app/zh/docs/features)
|
||||||
|
|
||||||
|
📍 實踐指南:[5 分鐘部署多平台 AI 機器人](https://blog.langbot.app/zh/blog/deploy-ai-bot-in-5-minutes/)、[將 DeepSeek 接入微信、企業微信與 Discord](https://blog.langbot.app/zh/blog/connect-deepseek-to-wechat/)、[讓 Dify Agent 跑在 Discord、Telegram 和 Slack 上](https://blog.langbot.app/zh/blog/dify-agent-discord-telegram-slack/),以及[用 n8n 建構多平台 AI 聊天機器人](https://blog.langbot.app/zh/blog/n8n-multi-platform-ai-chatbot/)。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 快速開始
|
## 快速開始
|
||||||
|
|||||||
@@ -46,6 +46,8 @@ LangBot là một **nền tảng mã nguồn mở, cấp sản xuất** để x
|
|||||||
|
|
||||||
[→ Tìm hiểu thêm về tất cả tính năng](https://link.langbot.app/en/docs/features)
|
[→ Tìm hiểu thêm về tất cả tính năng](https://link.langbot.app/en/docs/features)
|
||||||
|
|
||||||
|
📍 Hướng dẫn thực hành: [triển khai bot AI đa nền tảng trong 5 phút](https://blog.langbot.app/en/blog/deploy-ai-bot-in-5-minutes/), [kết nối DeepSeek với WeChat, Discord và Telegram](https://blog.langbot.app/en/blog/connect-deepseek-to-wechat/), [chạy Dify Agent trên Discord, Telegram và Slack](https://blog.langbot.app/en/blog/dify-agent-discord-telegram-slack/) và [xây dựng chatbot với n8n](https://blog.langbot.app/en/blog/n8n-multi-platform-ai-chatbot/).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Bắt đầu nhanh
|
## Bắt đầu nhanh
|
||||||
|
|||||||
@@ -179,8 +179,6 @@ class AdaptersRouterGroup(group.RouterGroup):
|
|||||||
"""Start WeChat QR code login. Returns session_id + QR code data URL."""
|
"""Start WeChat QR code login. Returns session_id + QR code data URL."""
|
||||||
import uuid
|
import uuid
|
||||||
import time
|
import time
|
||||||
import io
|
|
||||||
import base64
|
|
||||||
|
|
||||||
from langbot.libs.openclaw_weixin_api.client import OpenClawWeixinClient, DEFAULT_BASE_URL
|
from langbot.libs.openclaw_weixin_api.client import OpenClawWeixinClient, DEFAULT_BASE_URL
|
||||||
|
|
||||||
@@ -208,60 +206,32 @@ class AdaptersRouterGroup(group.RouterGroup):
|
|||||||
|
|
||||||
async def run_login():
|
async def run_login():
|
||||||
try:
|
try:
|
||||||
import qrcode as qr_lib
|
|
||||||
|
|
||||||
for _attempt in range(3):
|
def on_qrcode(qr_data_url: str, _qr_url: str):
|
||||||
qr_resp = await client.fetch_qrcode()
|
def _update():
|
||||||
if not qr_resp.qrcode or not qr_resp.qrcode_img_content:
|
session['qr_data_url'] = qr_data_url
|
||||||
raise Exception('Failed to get QR code from server')
|
session['expire_at'] = time.time() + 180
|
||||||
|
|
||||||
# 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'
|
session['status'] = 'waiting'
|
||||||
|
|
||||||
loop.call_soon_threadsafe(_update_qr)
|
loop.call_soon_threadsafe(_update)
|
||||||
|
|
||||||
# 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'
|
|
||||||
|
|
||||||
|
result = await client.login(
|
||||||
|
max_retries=1,
|
||||||
|
poll_timeout_ms=180_000,
|
||||||
|
on_qrcode=on_qrcode,
|
||||||
|
)
|
||||||
|
session['status'] = 'success'
|
||||||
|
session['token'] = result.token
|
||||||
|
session['base_url'] = result.base_url
|
||||||
|
session['account_id'] = result.account_id
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
session['status'] = 'error'
|
error_message = str(e)
|
||||||
session['error'] = str(e)
|
if 'expired' in error_message.lower() or 'max retries exceeded' in error_message.lower():
|
||||||
|
session['status'] = 'expired'
|
||||||
|
session['error'] = 'QR code expired'
|
||||||
|
else:
|
||||||
|
session['status'] = 'error'
|
||||||
|
session['error'] = error_message
|
||||||
finally:
|
finally:
|
||||||
await client.close()
|
await client.close()
|
||||||
|
|
||||||
@@ -295,7 +265,11 @@ class AdaptersRouterGroup(group.RouterGroup):
|
|||||||
if not session:
|
if not session:
|
||||||
return self.http_status(404, -1, 'Session not found')
|
return self.http_status(404, -1, 'Session not found')
|
||||||
|
|
||||||
data = {'status': session['status']}
|
data = {
|
||||||
|
'status': session['status'],
|
||||||
|
'qr_data_url': session['qr_data_url'],
|
||||||
|
'expire_at': session['expire_at'],
|
||||||
|
}
|
||||||
|
|
||||||
if session['status'] == 'success':
|
if session['status'] == 'success':
|
||||||
data['token'] = session['token']
|
data['token'] = session['token']
|
||||||
@@ -305,6 +279,9 @@ class AdaptersRouterGroup(group.RouterGroup):
|
|||||||
elif session['status'] == 'error':
|
elif session['status'] == 'error':
|
||||||
data['error'] = session['error']
|
data['error'] = session['error']
|
||||||
_weixin_login_sessions.pop(session_id, None)
|
_weixin_login_sessions.pop(session_id, None)
|
||||||
|
elif session['status'] == 'expired':
|
||||||
|
data['error'] = session['error']
|
||||||
|
_weixin_login_sessions.pop(session_id, None)
|
||||||
|
|
||||||
return self.success(data=data)
|
return self.success(data=data)
|
||||||
|
|
||||||
|
|||||||
@@ -140,17 +140,6 @@ class SystemRouterGroup(group.RouterGroup):
|
|||||||
async def _() -> str:
|
async def _() -> str:
|
||||||
return self.success(data=await self.ap.maintenance_service.get_storage_analysis())
|
return self.success(data=await self.ap.maintenance_service.get_storage_analysis())
|
||||||
|
|
||||||
@self.route('/debug/exec', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
|
|
||||||
async def _() -> str:
|
|
||||||
if not constants.debug_mode:
|
|
||||||
return self.http_status(403, 403, 'Forbidden')
|
|
||||||
|
|
||||||
py_code = await quart.request.data
|
|
||||||
|
|
||||||
ap = self.ap
|
|
||||||
|
|
||||||
return self.success(data=exec(py_code, {'ap': ap}))
|
|
||||||
|
|
||||||
@self.route(
|
@self.route(
|
||||||
'/debug/plugin/action',
|
'/debug/plugin/action',
|
||||||
methods=['POST'],
|
methods=['POST'],
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import typing
|
|||||||
import asyncio
|
import asyncio
|
||||||
import traceback
|
import traceback
|
||||||
import datetime
|
import datetime
|
||||||
|
import json
|
||||||
|
|
||||||
import aiocqhttp
|
import aiocqhttp
|
||||||
import pydantic
|
import pydantic
|
||||||
@@ -293,6 +294,29 @@ class AiocqhttpMessageConverter(abstract_platform_adapter.AbstractMessageConvert
|
|||||||
elif msg.type == 'dice':
|
elif msg.type == 'dice':
|
||||||
face_id = msg.data['result']
|
face_id = msg.data['result']
|
||||||
yiri_msg_list.append(platform_message.Face(face_type='dice', face_id=int(face_id), face_name='骰子'))
|
yiri_msg_list.append(platform_message.Face(face_type='dice', face_id=int(face_id), face_name='骰子'))
|
||||||
|
elif msg.type == 'json':
|
||||||
|
try:
|
||||||
|
raw = msg.data.get('data', {})
|
||||||
|
if isinstance(raw, str):
|
||||||
|
raw = json.loads(raw)
|
||||||
|
if isinstance(raw, dict):
|
||||||
|
_meta = raw.get('meta', {}) or {}
|
||||||
|
if isinstance(_meta, dict):
|
||||||
|
_detail = _meta.get('detail_1') or _meta.get('music') or _meta.get('news') or {}
|
||||||
|
else:
|
||||||
|
_detail = {}
|
||||||
|
if isinstance(_detail, dict):
|
||||||
|
preview = _detail.get('preview', '')
|
||||||
|
title = _detail.get('desc', '') or _detail.get('title', '')
|
||||||
|
url = _detail.get('qqdocurl', '') or _detail.get('jumpUrl', '')
|
||||||
|
else:
|
||||||
|
preview = title = url = ''
|
||||||
|
text = ' '.join([f'[{raw.get("app", "")}]', preview, title, url]).strip()
|
||||||
|
yiri_msg_list.append(platform_message.Plain(text=text or '[收到一张JSON卡片]'))
|
||||||
|
else:
|
||||||
|
yiri_msg_list.append(platform_message.Plain(text=str(raw)))
|
||||||
|
except Exception:
|
||||||
|
yiri_msg_list.append(platform_message.Plain(text='[收到一张JSON卡片]'))
|
||||||
|
|
||||||
chain = platform_message.MessageChain(yiri_msg_list)
|
chain = platform_message.MessageChain(yiri_msg_list)
|
||||||
|
|
||||||
|
|||||||
@@ -881,7 +881,8 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
|
|
||||||
bot_account_id = config['bot_name']
|
bot_account_id = config['bot_name']
|
||||||
|
|
||||||
bot = lark_oapi.ws.Client(config['app_id'], config['app_secret'], event_handler=event_handler)
|
domain = self._resolve_domain(config)
|
||||||
|
bot = lark_oapi.ws.Client(config['app_id'], config['app_secret'], event_handler=event_handler, domain=domain)
|
||||||
api_client = self.build_api_client(config)
|
api_client = self.build_api_client(config)
|
||||||
cipher = AESCipher(config.get('encrypt-key', ''))
|
cipher = AESCipher(config.get('encrypt-key', ''))
|
||||||
self.request_app_ticket(api_client, config)
|
self.request_app_ticket(api_client, config)
|
||||||
@@ -1014,13 +1015,28 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _resolve_domain(config) -> str:
|
||||||
|
domain = config.get('domain', lark_oapi.FEISHU_DOMAIN)
|
||||||
|
if domain == 'custom':
|
||||||
|
domain = config.get('custom_domain', '')
|
||||||
|
if not domain:
|
||||||
|
raise ValueError('Custom domain is required when domain is set to "custom"')
|
||||||
|
return domain.rstrip('/')
|
||||||
|
|
||||||
def build_api_client(self, config):
|
def build_api_client(self, config):
|
||||||
app_id = config['app_id']
|
app_id = config['app_id']
|
||||||
app_secret = config['app_secret']
|
app_secret = config['app_secret']
|
||||||
api_client = lark_oapi.Client.builder().app_id(app_id).app_secret(app_secret).build()
|
domain = self._resolve_domain(config)
|
||||||
|
api_client = lark_oapi.Client.builder().app_id(app_id).app_secret(app_secret).domain(domain).build()
|
||||||
if 'isv' == config.get('app_type', 'self'):
|
if 'isv' == config.get('app_type', 'self'):
|
||||||
api_client = (
|
api_client = (
|
||||||
lark_oapi.Client.builder().app_id(app_id).app_secret(app_secret).app_type(lark_oapi.AppType.ISV).build()
|
lark_oapi.Client.builder()
|
||||||
|
.app_id(app_id)
|
||||||
|
.app_secret(app_secret)
|
||||||
|
.app_type(lark_oapi.AppType.ISV)
|
||||||
|
.domain(domain)
|
||||||
|
.build()
|
||||||
)
|
)
|
||||||
return api_client
|
return api_client
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,57 @@ 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: domain
|
||||||
|
label:
|
||||||
|
en_US: Platform Domain
|
||||||
|
zh_Hans: 平台域名
|
||||||
|
zh_Hant: 平台域名
|
||||||
|
ja_JP: プラットフォームドメイン
|
||||||
|
description:
|
||||||
|
en_US: Select the open platform domain. Use Feishu for Chinese mainland, Lark for international
|
||||||
|
zh_Hans: 选择开放平台域名,国内使用飞书,海外使用 Lark
|
||||||
|
zh_Hant: 選擇開放平台域名,國內使用飛書,海外使用 Lark
|
||||||
|
ja_JP: オープンプラットフォームのドメインを選択。中国国内は飛書、海外は Lark を使用
|
||||||
|
type: select
|
||||||
|
options:
|
||||||
|
- name: https://open.feishu.cn
|
||||||
|
label:
|
||||||
|
en_US: Feishu (open.feishu.cn)
|
||||||
|
zh_Hans: 飞书 (open.feishu.cn)
|
||||||
|
zh_Hant: 飛書 (open.feishu.cn)
|
||||||
|
ja_JP: 飛書 (open.feishu.cn)
|
||||||
|
- name: https://open.larksuite.com
|
||||||
|
label:
|
||||||
|
en_US: Lark (open.larksuite.com)
|
||||||
|
zh_Hans: Lark (open.larksuite.com)
|
||||||
|
zh_Hant: Lark (open.larksuite.com)
|
||||||
|
ja_JP: Lark (open.larksuite.com)
|
||||||
|
- name: custom
|
||||||
|
label:
|
||||||
|
en_US: Custom
|
||||||
|
zh_Hans: 自定义
|
||||||
|
zh_Hant: 自定義
|
||||||
|
ja_JP: カスタム
|
||||||
|
required: false
|
||||||
|
default: https://open.feishu.cn
|
||||||
|
- name: custom_domain
|
||||||
|
label:
|
||||||
|
en_US: Custom Domain
|
||||||
|
zh_Hans: 自定义域名
|
||||||
|
zh_Hant: 自定義域名
|
||||||
|
ja_JP: カスタムドメイン
|
||||||
|
description:
|
||||||
|
en_US: "Enter the full domain URL, e.g. https://open.example.com"
|
||||||
|
zh_Hans: "输入完整的域名 URL,例如 https://open.example.com"
|
||||||
|
zh_Hant: "輸入完整的域名 URL,例如 https://open.example.com"
|
||||||
|
ja_JP: "完全なドメイン URL を入力(例: https://open.example.com)"
|
||||||
|
type: string
|
||||||
|
required: false
|
||||||
|
default: ""
|
||||||
|
show_if:
|
||||||
|
field: domain
|
||||||
|
operator: eq
|
||||||
|
value: custom
|
||||||
- name: one-click-create
|
- name: one-click-create
|
||||||
label:
|
label:
|
||||||
en_US: One-Click Create App
|
en_US: One-Click Create App
|
||||||
@@ -140,10 +191,10 @@ spec:
|
|||||||
zh_Hant: 應用類型
|
zh_Hant: 應用類型
|
||||||
ja_JP: アプリタイプ
|
ja_JP: アプリタイプ
|
||||||
description:
|
description:
|
||||||
en_US: Default to self-built application, refer to https://open.feishu.cn/document/platform-overveiw/overview
|
en_US: "Default to self-built application, refer to https://open.feishu.cn/document/platform-overveiw/overview"
|
||||||
zh_Hans: 默认为企业自建应用,参考 https://open.feishu.cn/document/platform-overveiw/overview
|
zh_Hans: "默认为企业自建应用,参考 https://open.feishu.cn/document/platform-overveiw/overview"
|
||||||
zh_Hant: 預設為企業自建應用,參考 https://open.feishu.cn/document/platform-overveiw/overview
|
zh_Hant: "預設為企業自建應用,參考 https://open.feishu.cn/document/platform-overveiw/overview"
|
||||||
ja_JP: デフォルトはカスタムアプリです。詳細は https://open.feishu.cn/document/platform-overveiw/overview を参照してください
|
ja_JP: "デフォルトはカスタムアプリです。詳細は https://open.feishu.cn/document/platform-overveiw/overview を参照してください"
|
||||||
type: select
|
type: select
|
||||||
options:
|
options:
|
||||||
- name: self
|
- name: self
|
||||||
|
|||||||
66
tests/test_cwe94_debug_exec.py
Normal file
66
tests/test_cwe94_debug_exec.py
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
"""
|
||||||
|
PoC test for CWE-94: Authenticated RCE via exec() on user-supplied Python code.
|
||||||
|
|
||||||
|
The /api/v1/system/debug/exec endpoint passes raw HTTP body to exec(),
|
||||||
|
allowing arbitrary code execution when debug_mode is True.
|
||||||
|
|
||||||
|
This test verifies that:
|
||||||
|
1. The exec() endpoint is removed from the codebase entirely.
|
||||||
|
2. No route matches /api/v1/system/debug/exec.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import ast
|
||||||
|
import pathlib
|
||||||
|
|
||||||
|
# Resolve project root (one level up from tests/)
|
||||||
|
_PROJECT_ROOT = pathlib.Path(__file__).resolve().parent.parent
|
||||||
|
|
||||||
|
VULN_FILE = (
|
||||||
|
_PROJECT_ROOT
|
||||||
|
/ "src"
|
||||||
|
/ "langbot"
|
||||||
|
/ "pkg"
|
||||||
|
/ "api"
|
||||||
|
/ "http"
|
||||||
|
/ "controller"
|
||||||
|
/ "groups"
|
||||||
|
/ "system.py"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_exec_call_in_system_controller():
|
||||||
|
"""Verify there is no exec() call in system.py that takes user input."""
|
||||||
|
with open(VULN_FILE, "r") as f:
|
||||||
|
source = f.read()
|
||||||
|
|
||||||
|
tree = ast.parse(source)
|
||||||
|
|
||||||
|
exec_calls = []
|
||||||
|
for node in ast.walk(tree):
|
||||||
|
if isinstance(node, ast.Call):
|
||||||
|
func = node.func
|
||||||
|
# Match bare exec() call
|
||||||
|
if isinstance(func, ast.Name) and func.id == "exec":
|
||||||
|
exec_calls.append(node.lineno)
|
||||||
|
|
||||||
|
assert len(exec_calls) == 0, (
|
||||||
|
f"Found exec() call(s) at line(s) {exec_calls} in system.py. "
|
||||||
|
"User-supplied code must never be passed to exec()."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_no_debug_exec_route():
|
||||||
|
"""Verify the /debug/exec route is not registered."""
|
||||||
|
with open(VULN_FILE, "r") as f:
|
||||||
|
source = f.read()
|
||||||
|
|
||||||
|
assert "debug/exec" not in source, (
|
||||||
|
"The /debug/exec route still exists in system.py. "
|
||||||
|
"This endpoint allows arbitrary code execution and must be removed."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
test_no_exec_call_in_system_controller()
|
||||||
|
test_no_debug_exec_route()
|
||||||
|
print("All tests passed!")
|
||||||
@@ -295,7 +295,7 @@ export default function ModelsDialog({
|
|||||||
|
|
||||||
async function handleScanModels(
|
async function handleScanModels(
|
||||||
providerUuid: string,
|
providerUuid: string,
|
||||||
modelType: ModelType,
|
modelType?: ModelType,
|
||||||
): Promise<ScanModelsResult> {
|
): Promise<ScanModelsResult> {
|
||||||
try {
|
try {
|
||||||
const resp = await httpClient.scanProviderModels(providerUuid, modelType);
|
const resp = await httpClient.scanProviderModels(providerUuid, modelType);
|
||||||
@@ -319,19 +319,26 @@ export default function ModelsDialog({
|
|||||||
setIsSubmitting(true);
|
setIsSubmitting(true);
|
||||||
try {
|
try {
|
||||||
for (const item of models) {
|
for (const item of models) {
|
||||||
if (modelType === 'llm') {
|
const effectiveType = item.model.type || modelType;
|
||||||
|
if (effectiveType === 'llm') {
|
||||||
await httpClient.createProviderLLMModel({
|
await httpClient.createProviderLLMModel({
|
||||||
name: item.model.name,
|
name: item.model.name,
|
||||||
provider_uuid: providerUuid,
|
provider_uuid: providerUuid,
|
||||||
abilities: item.abilities,
|
abilities: item.abilities,
|
||||||
extra_args: {},
|
extra_args: {},
|
||||||
} as never);
|
} as never);
|
||||||
} else {
|
} else if (effectiveType === 'embedding') {
|
||||||
await httpClient.createProviderEmbeddingModel({
|
await httpClient.createProviderEmbeddingModel({
|
||||||
name: item.model.name,
|
name: item.model.name,
|
||||||
provider_uuid: providerUuid,
|
provider_uuid: providerUuid,
|
||||||
extra_args: {},
|
extra_args: {},
|
||||||
} as never);
|
} as never);
|
||||||
|
} else {
|
||||||
|
await httpClient.createProviderRerankModel({
|
||||||
|
name: item.model.name,
|
||||||
|
provider_uuid: providerUuid,
|
||||||
|
extra_args: {},
|
||||||
|
} as never);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
setAddModelPopoverOpen(null);
|
setAddModelPopoverOpen(null);
|
||||||
|
|||||||
@@ -73,10 +73,13 @@ export default function ProviderForm({
|
|||||||
>([]);
|
>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadRequesters();
|
async function init() {
|
||||||
if (providerId) {
|
await loadRequesters();
|
||||||
loadProvider(providerId);
|
if (providerId) {
|
||||||
|
await loadProvider(providerId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
init();
|
||||||
}, [providerId]);
|
}, [providerId]);
|
||||||
|
|
||||||
async function loadRequesters() {
|
async function loadRequesters() {
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import {
|
|||||||
Wrench,
|
Wrench,
|
||||||
Check,
|
Check,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Search,
|
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
@@ -33,6 +32,8 @@ import ExtraArgsEditor from './ExtraArgsEditor';
|
|||||||
|
|
||||||
interface AddModelPopoverProps {
|
interface AddModelPopoverProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
|
initialMode?: 'manual' | 'scan';
|
||||||
|
trigger?: React.ReactNode;
|
||||||
onOpen: () => void;
|
onOpen: () => void;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onAddModel: (
|
onAddModel: (
|
||||||
@@ -41,7 +42,7 @@ interface AddModelPopoverProps {
|
|||||||
abilities: string[],
|
abilities: string[],
|
||||||
extraArgs: ExtraArg[],
|
extraArgs: ExtraArg[],
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
onScanModels: (modelType: ModelType) => Promise<ScanModelsResult>;
|
onScanModels: (modelType?: ModelType) => Promise<ScanModelsResult>;
|
||||||
onAddScannedModels: (
|
onAddScannedModels: (
|
||||||
modelType: ModelType,
|
modelType: ModelType,
|
||||||
models: SelectedScannedModel[],
|
models: SelectedScannedModel[],
|
||||||
@@ -60,6 +61,8 @@ interface AddModelPopoverProps {
|
|||||||
|
|
||||||
export default function AddModelPopover({
|
export default function AddModelPopover({
|
||||||
isOpen,
|
isOpen,
|
||||||
|
initialMode = 'manual',
|
||||||
|
trigger,
|
||||||
onOpen,
|
onOpen,
|
||||||
onClose,
|
onClose,
|
||||||
onAddModel,
|
onAddModel,
|
||||||
@@ -92,7 +95,7 @@ export default function AddModelPopover({
|
|||||||
const wasOpen = prevIsOpenRef.current;
|
const wasOpen = prevIsOpenRef.current;
|
||||||
if (isOpen && !wasOpen) {
|
if (isOpen && !wasOpen) {
|
||||||
setTab('llm');
|
setTab('llm');
|
||||||
setMode('manual');
|
setMode(initialMode);
|
||||||
setName('');
|
setName('');
|
||||||
setAbilities([]);
|
setAbilities([]);
|
||||||
setExtraArgs([]);
|
setExtraArgs([]);
|
||||||
@@ -101,8 +104,12 @@ export default function AddModelPopover({
|
|||||||
setSelectedScannedModels({});
|
setSelectedScannedModels({});
|
||||||
setScanQuery('');
|
setScanQuery('');
|
||||||
onResetTestResult();
|
onResetTestResult();
|
||||||
|
if (initialMode === 'scan') {
|
||||||
|
handleScan();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
prevIsOpenRef.current = isOpen;
|
prevIsOpenRef.current = isOpen;
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [isOpen, onResetTestResult]);
|
}, [isOpen, onResetTestResult]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -122,9 +129,8 @@ export default function AddModelPopover({
|
|||||||
const handleScan = async () => {
|
const handleScan = async () => {
|
||||||
setScanLoading(true);
|
setScanLoading(true);
|
||||||
try {
|
try {
|
||||||
const result = await onScanModels(tab);
|
const result = await onScanModels(trigger ? undefined : tab);
|
||||||
|
|
||||||
// Enrich abilities from debug.response.data (e.g. features.tools.function_calling)
|
|
||||||
const debugData = (
|
const debugData = (
|
||||||
result.debug?.response as { data?: Record<string, unknown>[] }
|
result.debug?.response as { data?: Record<string, unknown>[] }
|
||||||
)?.data;
|
)?.data;
|
||||||
@@ -143,9 +149,9 @@ export default function AddModelPopover({
|
|||||||
| undefined;
|
| undefined;
|
||||||
const tools = features?.tools as Record<string, unknown> | undefined;
|
const tools = features?.tools as Record<string, unknown> | undefined;
|
||||||
if (tools?.function_calling === true) {
|
if (tools?.function_calling === true) {
|
||||||
const abilities = new Set(model.abilities || []);
|
const nextAbilities = new Set(model.abilities || []);
|
||||||
abilities.add('func_call');
|
nextAbilities.add('func_call');
|
||||||
model.abilities = [...abilities];
|
model.abilities = [...nextAbilities];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -247,305 +253,321 @@ export default function AddModelPopover({
|
|||||||
onOpenChange={(open) => (open ? onOpen() : onClose())}
|
onOpenChange={(open) => (open ? onOpen() : onClose())}
|
||||||
>
|
>
|
||||||
<PopoverTrigger asChild>
|
<PopoverTrigger asChild>
|
||||||
<Button
|
{trigger || (
|
||||||
variant="ghost"
|
<Button
|
||||||
size="sm"
|
variant="ghost"
|
||||||
className="h-6 text-xs"
|
size="sm"
|
||||||
onClick={(e) => e.stopPropagation()}
|
className="h-6 text-xs"
|
||||||
>
|
onClick={(e) => e.stopPropagation()}
|
||||||
<Plus className="h-3 w-3 mr-1" />
|
>
|
||||||
{t('models.addModel')}
|
<Plus className="h-3 w-3 mr-1" />
|
||||||
</Button>
|
{t('models.addModel')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
</PopoverTrigger>
|
</PopoverTrigger>
|
||||||
<PopoverContent
|
<PopoverContent
|
||||||
className="w-[min(24rem,calc(100vw-2rem))] max-h-[70vh] overflow-y-auto overscroll-none focus:outline-none focus-visible:outline-none focus-visible:ring-0"
|
className="w-[min(24rem,calc(100vw-2rem))] max-h-[calc(100vh-8rem)] flex flex-col overflow-hidden"
|
||||||
style={{
|
|
||||||
maxHeight: 'min(70vh, var(--radix-popover-content-available-height))',
|
|
||||||
}}
|
|
||||||
align="end"
|
align="end"
|
||||||
side="left"
|
side="bottom"
|
||||||
sideOffset={8}
|
sideOffset={8}
|
||||||
collisionPadding={16}
|
collisionPadding={16}
|
||||||
onWheel={(e) => e.stopPropagation()}
|
|
||||||
onTouchMove={(e) => e.stopPropagation()}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
onClick={(e) => e.stopPropagation()}
|
||||||
>
|
>
|
||||||
<Tabs value={tab} onValueChange={(v) => setTab(v as ModelType)}>
|
<Tabs
|
||||||
<TabsList className="grid w-full grid-cols-3">
|
value={tab}
|
||||||
<TabsTrigger value="llm">
|
onValueChange={(v) => setTab(v as ModelType)}
|
||||||
<MessageSquareText className="h-4 w-4 mr-1" />
|
className="flex flex-col min-h-0 flex-1"
|
||||||
{t('models.chat')}
|
>
|
||||||
</TabsTrigger>
|
<div className="flex-shrink-0">
|
||||||
<TabsTrigger value="embedding">
|
{!(trigger && initialMode === 'scan') && (
|
||||||
<Cpu className="h-4 w-4 mr-1" />
|
<TabsList className="grid w-full grid-cols-3">
|
||||||
{t('models.embedding')}
|
<TabsTrigger value="llm">
|
||||||
</TabsTrigger>
|
<MessageSquareText className="h-4 w-4 mr-1" />
|
||||||
<TabsTrigger value="rerank">
|
{t('models.chat')}
|
||||||
<ArrowUpDown className="h-4 w-4 mr-1" />
|
</TabsTrigger>
|
||||||
{t('models.rerank')}
|
<TabsTrigger value="embedding">
|
||||||
</TabsTrigger>
|
<Cpu className="h-4 w-4 mr-1" />
|
||||||
</TabsList>
|
{t('models.embedding')}
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="rerank">
|
||||||
|
<ArrowUpDown className="h-4 w-4 mr-1" />
|
||||||
|
{t('models.rerank')}
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<Tabs
|
<div className="overflow-y-auto flex-1 min-h-0">
|
||||||
value={mode}
|
<Tabs
|
||||||
onValueChange={(v) => setMode(v as 'manual' | 'scan')}
|
value={mode}
|
||||||
>
|
onValueChange={(v) => setMode(v as 'manual' | 'scan')}
|
||||||
<TabsList className="grid w-full grid-cols-2 mt-3">
|
>
|
||||||
<TabsTrigger value="manual">{t('models.manualAdd')}</TabsTrigger>
|
{!trigger && (
|
||||||
<TabsTrigger value="scan">{t('models.scanAdd')}</TabsTrigger>
|
<TabsList className="grid w-full grid-cols-2 mt-3">
|
||||||
</TabsList>
|
<TabsTrigger value="manual">
|
||||||
|
{t('models.manualAdd')}
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="scan">{t('models.scanAdd')}</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
)}
|
||||||
|
|
||||||
<TabsContent value="manual" className="mt-3">
|
<TabsContent value="manual" className="mt-3">
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>{t('models.modelName')}</Label>
|
|
||||||
<Input
|
|
||||||
placeholder={t('models.modelName')}
|
|
||||||
value={name}
|
|
||||||
onChange={(e) => setName(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{tab === 'llm' && (
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>{t('models.abilities')}</Label>
|
<Label>{t('models.modelName')}</Label>
|
||||||
<div className="flex gap-4">
|
<Input
|
||||||
<div className="flex items-center gap-2">
|
placeholder={t('models.modelName')}
|
||||||
<Checkbox
|
value={name}
|
||||||
id="add-vision"
|
onChange={(e) => setName(e.target.value)}
|
||||||
checked={abilities.includes('vision')}
|
/>
|
||||||
onCheckedChange={(checked) =>
|
</div>
|
||||||
toggleAbility('vision', checked as boolean)
|
|
||||||
}
|
{tab === 'llm' && (
|
||||||
/>
|
<div className="space-y-2">
|
||||||
<Label htmlFor="add-vision" className="text-sm">
|
<Label>{t('models.abilities')}</Label>
|
||||||
<Eye className="h-3 w-3 inline mr-1" />
|
<div className="flex gap-4">
|
||||||
{t('models.visionAbility')}
|
<div className="flex items-center gap-2">
|
||||||
</Label>
|
<Checkbox
|
||||||
</div>
|
id="add-vision"
|
||||||
<div className="flex items-center gap-2">
|
checked={abilities.includes('vision')}
|
||||||
<Checkbox
|
onCheckedChange={(checked) =>
|
||||||
id="add-func-call"
|
toggleAbility('vision', checked as boolean)
|
||||||
checked={abilities.includes('func_call')}
|
}
|
||||||
onCheckedChange={(checked) =>
|
/>
|
||||||
toggleAbility('func_call', checked as boolean)
|
<Label htmlFor="add-vision" className="text-sm">
|
||||||
}
|
<Eye className="h-3 w-3 inline mr-1" />
|
||||||
/>
|
{t('models.visionAbility')}
|
||||||
<Label htmlFor="add-func-call" className="text-sm">
|
</Label>
|
||||||
<Wrench className="h-3 w-3 inline mr-1" />
|
</div>
|
||||||
{t('models.functionCallAbility')}
|
<div className="flex items-center gap-2">
|
||||||
</Label>
|
<Checkbox
|
||||||
|
id="add-func-call"
|
||||||
|
checked={abilities.includes('func_call')}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
toggleAbility('func_call', checked as boolean)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Label htmlFor="add-func-call" className="text-sm">
|
||||||
|
<Wrench className="h-3 w-3 inline mr-1" />
|
||||||
|
{t('models.functionCallAbility')}
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ExtraArgsEditor
|
||||||
|
args={extraArgs}
|
||||||
|
onChange={setExtraArgs}
|
||||||
|
modelType={tab}
|
||||||
|
/>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
className="flex-1"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleAdd}
|
||||||
|
disabled={isSubmitting || isTesting}
|
||||||
|
>
|
||||||
|
{isSubmitting ? t('common.saving') : t('common.add')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
className="flex-1"
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleTest}
|
||||||
|
disabled={isSubmitting || isTesting}
|
||||||
|
>
|
||||||
|
{isTesting ? (
|
||||||
|
t('common.loading')
|
||||||
|
) : testResult?.success ? (
|
||||||
|
<>
|
||||||
|
<Check className="h-4 w-4 mr-1 text-green-500" />
|
||||||
|
{(testResult.duration / 1000).toFixed(1)}s
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
t('common.test')
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="scan" className="space-y-2 mt-0 pt-0">
|
||||||
|
{scanLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-4">
|
||||||
|
<RefreshCw className="h-4 w-4 mr-2 animate-spin text-muted-foreground" />
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{t('models.scanModels')}...
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Input
|
||||||
|
placeholder={t('models.searchScannedModels')}
|
||||||
|
value={scanQuery}
|
||||||
|
onChange={(e) => setScanQuery(e.target.value)}
|
||||||
|
disabled={scannedModels.length === 0}
|
||||||
|
/>
|
||||||
|
{selectableModels.length > 0 && (
|
||||||
|
<div className="flex items-center gap-2 pt-1">
|
||||||
|
<Checkbox
|
||||||
|
id="scan-select-all"
|
||||||
|
checked={allSelected}
|
||||||
|
onCheckedChange={toggleSelectAll}
|
||||||
|
/>
|
||||||
|
<Label
|
||||||
|
htmlFor="scan-select-all"
|
||||||
|
className="text-sm font-medium"
|
||||||
|
>
|
||||||
|
{t('models.selectAll')}
|
||||||
|
<span className="text-muted-foreground ml-1">
|
||||||
|
({Object.keys(selectedScannedModels).length}/
|
||||||
|
{selectableModels.length})
|
||||||
|
</span>
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="h-64 overflow-y-auto overscroll-contain rounded-md border"
|
||||||
|
onWheel={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<div className="p-3 space-y-2">
|
||||||
|
{filteredScannedModels.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{scannedModels.length === 0
|
||||||
|
? t('models.noScannedModels')
|
||||||
|
: t('models.noScannedModelsMatch')}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
filteredScannedModels.map((model) => {
|
||||||
|
const isSelected = Boolean(
|
||||||
|
selectedScannedModels[model.id],
|
||||||
|
);
|
||||||
|
const selectedAbilities =
|
||||||
|
selectedScannedModels[model.id]?.abilities || [];
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={model.id}
|
||||||
|
className="rounded-md border p-3 space-y-2"
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<Checkbox
|
||||||
|
checked={isSelected || model.already_added}
|
||||||
|
disabled={model.already_added}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
toggleScannedModel(
|
||||||
|
model,
|
||||||
|
checked as boolean,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="text-sm font-medium break-all">
|
||||||
|
{model.name}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{model.already_added
|
||||||
|
? t('models.alreadyAdded')
|
||||||
|
: model.type === 'llm'
|
||||||
|
? t('models.chat')
|
||||||
|
: model.type === 'embedding'
|
||||||
|
? t('models.embedding')
|
||||||
|
: t('models.rerank')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{model.type === 'llm' &&
|
||||||
|
isSelected &&
|
||||||
|
!model.already_added && (
|
||||||
|
<div className="flex gap-4 pl-7">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Checkbox
|
||||||
|
id={`scan-vision-${model.id}`}
|
||||||
|
checked={selectedAbilities.includes(
|
||||||
|
'vision',
|
||||||
|
)}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
toggleScannedModelAbility(
|
||||||
|
model.id,
|
||||||
|
'vision',
|
||||||
|
checked as boolean,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Label
|
||||||
|
htmlFor={`scan-vision-${model.id}`}
|
||||||
|
className="text-sm"
|
||||||
|
>
|
||||||
|
<Eye className="h-3 w-3 inline mr-1" />
|
||||||
|
{t('models.visionAbility')}
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Checkbox
|
||||||
|
id={`scan-func-${model.id}`}
|
||||||
|
checked={selectedAbilities.includes(
|
||||||
|
'func_call',
|
||||||
|
)}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
toggleScannedModelAbility(
|
||||||
|
model.id,
|
||||||
|
'func_call',
|
||||||
|
checked as boolean,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Label
|
||||||
|
htmlFor={`scan-func-${model.id}`}
|
||||||
|
className="text-sm"
|
||||||
|
>
|
||||||
|
<Wrench className="h-3 w-3 inline mr-1" />
|
||||||
|
{t('models.functionCallAbility')}
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<ExtraArgsEditor
|
|
||||||
args={extraArgs}
|
|
||||||
onChange={setExtraArgs}
|
|
||||||
modelType={tab}
|
|
||||||
/>
|
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button
|
<Button
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={handleAdd}
|
onClick={handleAddScanned}
|
||||||
disabled={isSubmitting || isTesting}
|
disabled={
|
||||||
|
isSubmitting ||
|
||||||
|
scanLoading ||
|
||||||
|
Object.keys(selectedScannedModels).length === 0
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{isSubmitting ? t('common.saving') : t('common.add')}
|
{isSubmitting
|
||||||
|
? t('common.saving')
|
||||||
|
: t('models.addSelectedModels')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
className="flex-1"
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={handleTest}
|
size="sm"
|
||||||
disabled={isSubmitting || isTesting}
|
onClick={handleScan}
|
||||||
|
disabled={scanLoading || isSubmitting}
|
||||||
>
|
>
|
||||||
{isTesting ? (
|
<RefreshCw
|
||||||
t('common.loading')
|
className={`h-3.5 w-3.5 ${scanLoading ? 'animate-spin' : ''}`}
|
||||||
) : testResult?.success ? (
|
/>
|
||||||
<>
|
|
||||||
<Check className="h-4 w-4 mr-1 text-green-500" />
|
|
||||||
{(testResult.duration / 1000).toFixed(1)}s
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
t('common.test')
|
|
||||||
)}
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</TabsContent>
|
||||||
</TabsContent>
|
</Tabs>
|
||||||
|
</div>
|
||||||
<TabsContent value="scan" className="space-y-3 mt-3">
|
|
||||||
<div className="text-xs text-muted-foreground">
|
|
||||||
{t('models.scanModelsHint')}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-2">
|
|
||||||
<Button
|
|
||||||
className="flex-1"
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
onClick={handleScan}
|
|
||||||
disabled={scanLoading || isSubmitting}
|
|
||||||
>
|
|
||||||
{scanLoading ? (
|
|
||||||
<RefreshCw className="h-4 w-4 mr-1 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Search className="h-4 w-4 mr-1" />
|
|
||||||
)}
|
|
||||||
{t('models.scanModels')}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
className="flex-1"
|
|
||||||
size="sm"
|
|
||||||
onClick={handleAddScanned}
|
|
||||||
disabled={
|
|
||||||
isSubmitting ||
|
|
||||||
scanLoading ||
|
|
||||||
Object.keys(selectedScannedModels).length === 0
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{isSubmitting
|
|
||||||
? t('common.saving')
|
|
||||||
: t('models.addSelectedModels')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Label>{t('models.scannedModels')}</Label>
|
|
||||||
<Input
|
|
||||||
placeholder={t('models.searchScannedModels')}
|
|
||||||
value={scanQuery}
|
|
||||||
onChange={(e) => setScanQuery(e.target.value)}
|
|
||||||
disabled={scannedModels.length === 0}
|
|
||||||
/>
|
|
||||||
{selectableModels.length > 0 && (
|
|
||||||
<div className="flex items-center gap-2 pt-1">
|
|
||||||
<Checkbox
|
|
||||||
id="scan-select-all"
|
|
||||||
checked={allSelected}
|
|
||||||
onCheckedChange={toggleSelectAll}
|
|
||||||
/>
|
|
||||||
<Label
|
|
||||||
htmlFor="scan-select-all"
|
|
||||||
className="text-sm font-medium"
|
|
||||||
>
|
|
||||||
{t('models.selectAll')}
|
|
||||||
<span className="text-muted-foreground ml-1">
|
|
||||||
({Object.keys(selectedScannedModels).length}/
|
|
||||||
{selectableModels.length})
|
|
||||||
</span>
|
|
||||||
</Label>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
className="h-64 overflow-y-auto overscroll-none rounded-md border"
|
|
||||||
onWheel={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
<div className="p-3 space-y-2">
|
|
||||||
{filteredScannedModels.length === 0 ? (
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
{scannedModels.length === 0
|
|
||||||
? t('models.noScannedModels')
|
|
||||||
: t('models.noScannedModelsMatch')}
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
filteredScannedModels.map((model) => {
|
|
||||||
const isSelected = Boolean(
|
|
||||||
selectedScannedModels[model.id],
|
|
||||||
);
|
|
||||||
const selectedAbilities =
|
|
||||||
selectedScannedModels[model.id]?.abilities || [];
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={model.id}
|
|
||||||
className="rounded-md border p-3 space-y-2"
|
|
||||||
>
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<Checkbox
|
|
||||||
checked={isSelected || model.already_added}
|
|
||||||
disabled={model.already_added}
|
|
||||||
onCheckedChange={(checked) =>
|
|
||||||
toggleScannedModel(model, checked as boolean)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<div className="text-sm font-medium break-all">
|
|
||||||
{model.name}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-muted-foreground">
|
|
||||||
{model.already_added
|
|
||||||
? t('models.alreadyAdded')
|
|
||||||
: model.type === 'llm'
|
|
||||||
? t('models.chat')
|
|
||||||
: model.type === 'embedding'
|
|
||||||
? t('models.embedding')
|
|
||||||
: t('models.rerank')}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{tab === 'llm' &&
|
|
||||||
isSelected &&
|
|
||||||
!model.already_added && (
|
|
||||||
<div className="flex gap-4 pl-7">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Checkbox
|
|
||||||
id={`scan-vision-${model.id}`}
|
|
||||||
checked={selectedAbilities.includes(
|
|
||||||
'vision',
|
|
||||||
)}
|
|
||||||
onCheckedChange={(checked) =>
|
|
||||||
toggleScannedModelAbility(
|
|
||||||
model.id,
|
|
||||||
'vision',
|
|
||||||
checked as boolean,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Label
|
|
||||||
htmlFor={`scan-vision-${model.id}`}
|
|
||||||
className="text-sm"
|
|
||||||
>
|
|
||||||
<Eye className="h-3 w-3 inline mr-1" />
|
|
||||||
{t('models.visionAbility')}
|
|
||||||
</Label>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Checkbox
|
|
||||||
id={`scan-func-${model.id}`}
|
|
||||||
checked={selectedAbilities.includes(
|
|
||||||
'func_call',
|
|
||||||
)}
|
|
||||||
onCheckedChange={(checked) =>
|
|
||||||
toggleScannedModelAbility(
|
|
||||||
model.id,
|
|
||||||
'func_call',
|
|
||||||
checked as boolean,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<Label
|
|
||||||
htmlFor={`scan-func-${model.id}`}
|
|
||||||
className="text-sm"
|
|
||||||
>
|
|
||||||
<Wrench className="h-3 w-3 inline mr-1" />
|
|
||||||
{t('models.functionCallAbility')}
|
|
||||||
</Label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</TabsContent>
|
|
||||||
</Tabs>
|
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
</Popover>
|
</Popover>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
Trash2,
|
Trash2,
|
||||||
Settings,
|
Settings,
|
||||||
LogIn,
|
LogIn,
|
||||||
|
Radar,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { httpClient, systemInfo } from '@/app/infra/http/HttpClient';
|
import { httpClient, systemInfo } from '@/app/infra/http/HttpClient';
|
||||||
import { ModelProvider } from '@/app/infra/entities/api';
|
import { ModelProvider } from '@/app/infra/entities/api';
|
||||||
@@ -60,7 +61,7 @@ interface ProviderCardProps {
|
|||||||
abilities: string[],
|
abilities: string[],
|
||||||
extraArgs: ExtraArg[],
|
extraArgs: ExtraArg[],
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
onScanModels: (modelType: ModelType) => Promise<ScanModelsResult>;
|
onScanModels: (modelType?: ModelType) => Promise<ScanModelsResult>;
|
||||||
onAddScannedModels: (
|
onAddScannedModels: (
|
||||||
modelType: ModelType,
|
modelType: ModelType,
|
||||||
models: SelectedScannedModel[],
|
models: SelectedScannedModel[],
|
||||||
@@ -130,6 +131,7 @@ export default function ProviderCard({
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [deleteProviderConfirmOpen, setDeleteProviderConfirmOpen] =
|
const [deleteProviderConfirmOpen, setDeleteProviderConfirmOpen] =
|
||||||
useState(false);
|
useState(false);
|
||||||
|
const [addModelMode, setAddModelMode] = useState<'manual' | 'scan'>('manual');
|
||||||
|
|
||||||
const canDelete =
|
const canDelete =
|
||||||
!isLangBotModels &&
|
!isLangBotModels &&
|
||||||
@@ -310,19 +312,75 @@ export default function ProviderCard({
|
|||||||
<div />
|
<div />
|
||||||
)}
|
)}
|
||||||
{!isLangBotModels && (
|
{!isLangBotModels && (
|
||||||
<AddModelPopover
|
<div className="flex items-center gap-1">
|
||||||
isOpen={addModelPopoverOpen === provider.uuid}
|
<AddModelPopover
|
||||||
onOpen={onOpenAddModel}
|
isOpen={
|
||||||
onClose={onCloseAddModel}
|
addModelPopoverOpen === provider.uuid &&
|
||||||
onAddModel={onAddModel}
|
addModelMode === 'manual'
|
||||||
onScanModels={onScanModels}
|
}
|
||||||
onAddScannedModels={onAddScannedModels}
|
initialMode="manual"
|
||||||
onTestModel={onTestModel}
|
trigger={
|
||||||
isSubmitting={isSubmitting}
|
<Button
|
||||||
isTesting={isTesting}
|
variant="ghost"
|
||||||
testResult={testResult}
|
size="sm"
|
||||||
onResetTestResult={onResetTestResult}
|
className="h-6 text-xs"
|
||||||
/>
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setAddModelMode('manual');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Plus className="h-3 w-3 mr-1" />
|
||||||
|
{t('models.addModel')}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
onOpen={() => {
|
||||||
|
setAddModelMode('manual');
|
||||||
|
onOpenAddModel();
|
||||||
|
}}
|
||||||
|
onClose={onCloseAddModel}
|
||||||
|
onAddModel={onAddModel}
|
||||||
|
onScanModels={onScanModels}
|
||||||
|
onAddScannedModels={onAddScannedModels}
|
||||||
|
onTestModel={onTestModel}
|
||||||
|
isSubmitting={isSubmitting}
|
||||||
|
isTesting={isTesting}
|
||||||
|
testResult={testResult}
|
||||||
|
onResetTestResult={onResetTestResult}
|
||||||
|
/>
|
||||||
|
<AddModelPopover
|
||||||
|
isOpen={
|
||||||
|
addModelPopoverOpen === provider.uuid &&
|
||||||
|
addModelMode === 'scan'
|
||||||
|
}
|
||||||
|
initialMode="scan"
|
||||||
|
trigger={
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-6 w-6"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setAddModelMode('scan');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Radar className="h-3 w-3" />
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
onOpen={() => {
|
||||||
|
setAddModelMode('scan');
|
||||||
|
onOpenAddModel();
|
||||||
|
}}
|
||||||
|
onClose={onCloseAddModel}
|
||||||
|
onAddModel={onAddModel}
|
||||||
|
onScanModels={onScanModels}
|
||||||
|
onAddScannedModels={onAddScannedModels}
|
||||||
|
onTestModel={onTestModel}
|
||||||
|
isSubmitting={isSubmitting}
|
||||||
|
isTesting={isTesting}
|
||||||
|
testResult={testResult}
|
||||||
|
onResetTestResult={onResetTestResult}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|||||||
@@ -90,7 +90,7 @@ export interface ProviderCardProps {
|
|||||||
abilities: string[],
|
abilities: string[],
|
||||||
extraArgs: ExtraArg[],
|
extraArgs: ExtraArg[],
|
||||||
) => Promise<void>;
|
) => Promise<void>;
|
||||||
onScanModels: (modelType: ModelType) => Promise<ScanModelsResult>;
|
onScanModels: (modelType?: ModelType) => Promise<ScanModelsResult>;
|
||||||
onAddScannedModels: (
|
onAddScannedModels: (
|
||||||
modelType: ModelType,
|
modelType: ModelType,
|
||||||
models: SelectedScannedModel[],
|
models: SelectedScannedModel[],
|
||||||
|
|||||||
@@ -4,11 +4,16 @@ import {
|
|||||||
DialogContent,
|
DialogContent,
|
||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle,
|
DialogTitle,
|
||||||
DialogFooter,
|
|
||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { Loader2, RefreshCw, CheckCircle2, XCircle } from 'lucide-react';
|
import {
|
||||||
|
Loader2,
|
||||||
|
RefreshCw,
|
||||||
|
RotateCw,
|
||||||
|
CheckCircle2,
|
||||||
|
XCircle,
|
||||||
|
} from 'lucide-react';
|
||||||
import QRCode from 'qrcode';
|
import QRCode from 'qrcode';
|
||||||
|
|
||||||
export type QrLoginPlatform = 'feishu' | 'weixin' | 'dingtalk' | 'wecombot';
|
export type QrLoginPlatform = 'feishu' | 'weixin' | 'dingtalk' | 'wecombot';
|
||||||
@@ -96,7 +101,7 @@ interface QrCodeLoginDialogProps {
|
|||||||
onSuccess: (credentials: Record<string, string>) => void;
|
onSuccess: (credentials: Record<string, string>) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
type DialogState = 'connecting' | 'waiting' | 'success' | 'error';
|
type DialogState = 'connecting' | 'waiting' | 'expired' | 'success' | 'error';
|
||||||
|
|
||||||
const POLL_INTERVAL_MS = 3000;
|
const POLL_INTERVAL_MS = 3000;
|
||||||
|
|
||||||
@@ -115,8 +120,10 @@ export default function QrCodeLoginDialog({
|
|||||||
const [errorMessage, setErrorMessage] = useState('');
|
const [errorMessage, setErrorMessage] = useState('');
|
||||||
const pollTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
const pollTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
const countdownRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
const countdownRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
const checkExpiredRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
const abortRef = useRef<AbortController | null>(null);
|
const abortRef = useRef<AbortController | null>(null);
|
||||||
const sessionIdRef = useRef<string | null>(null);
|
const sessionIdRef = useRef<string | null>(null);
|
||||||
|
const baseUrlRef = useRef('');
|
||||||
const cleanedRef = useRef(false);
|
const cleanedRef = useRef(false);
|
||||||
|
|
||||||
const onSuccessRef = useRef(onSuccess);
|
const onSuccessRef = useRef(onSuccess);
|
||||||
@@ -140,11 +147,14 @@ export default function QrCodeLoginDialog({
|
|||||||
clearInterval(countdownRef.current);
|
clearInterval(countdownRef.current);
|
||||||
countdownRef.current = null;
|
countdownRef.current = null;
|
||||||
}
|
}
|
||||||
|
if (checkExpiredRef.current) {
|
||||||
|
clearInterval(checkExpiredRef.current);
|
||||||
|
checkExpiredRef.current = null;
|
||||||
|
}
|
||||||
if (abortRef.current) {
|
if (abortRef.current) {
|
||||||
abortRef.current.abort();
|
abortRef.current.abort();
|
||||||
abortRef.current = null;
|
abortRef.current = null;
|
||||||
}
|
}
|
||||||
// Cancel backend session
|
|
||||||
if (sessionIdRef.current) {
|
if (sessionIdRef.current) {
|
||||||
const token = localStorage.getItem('token');
|
const token = localStorage.getItem('token');
|
||||||
const baseUrl =
|
const baseUrl =
|
||||||
@@ -171,6 +181,7 @@ export default function QrCodeLoginDialog({
|
|||||||
|
|
||||||
const token = localStorage.getItem('token');
|
const token = localStorage.getItem('token');
|
||||||
const baseUrl = import.meta.env.VITE_API_BASE_URL || window.location.origin;
|
const baseUrl = import.meta.env.VITE_API_BASE_URL || window.location.origin;
|
||||||
|
baseUrlRef.current = baseUrl;
|
||||||
const cfg = platformConfigRef.current;
|
const cfg = platformConfigRef.current;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -191,8 +202,6 @@ export default function QrCodeLoginDialog({
|
|||||||
const { session_id, qr_data_url, qr_url, expire_at } = json.data;
|
const { session_id, qr_data_url, qr_url, expire_at } = json.data;
|
||||||
sessionIdRef.current = session_id;
|
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) {
|
if (qr_data_url) {
|
||||||
setQrDataUrl(qr_data_url);
|
setQrDataUrl(qr_data_url);
|
||||||
} else if (qr_url) {
|
} else if (qr_url) {
|
||||||
@@ -204,11 +213,9 @@ export default function QrCodeLoginDialog({
|
|||||||
}
|
}
|
||||||
setState('waiting');
|
setState('waiting');
|
||||||
|
|
||||||
// Calculate remaining seconds
|
|
||||||
const remaining = Math.max(0, Math.floor(expire_at - Date.now() / 1000));
|
const remaining = Math.max(0, Math.floor(expire_at - Date.now() / 1000));
|
||||||
setExpireIn(remaining);
|
setExpireIn(remaining);
|
||||||
|
|
||||||
// Start countdown
|
|
||||||
countdownRef.current = setInterval(() => {
|
countdownRef.current = setInterval(() => {
|
||||||
setExpireIn((prev) => {
|
setExpireIn((prev) => {
|
||||||
if (prev <= 1) {
|
if (prev <= 1) {
|
||||||
@@ -222,7 +229,35 @@ export default function QrCodeLoginDialog({
|
|||||||
});
|
});
|
||||||
}, 1000);
|
}, 1000);
|
||||||
|
|
||||||
// Start polling
|
// When countdown hits 0, stop polling and show expired state
|
||||||
|
checkExpiredRef.current = setInterval(() => {
|
||||||
|
setExpireIn((current) => {
|
||||||
|
if (current <= 0) {
|
||||||
|
if (checkExpiredRef.current) {
|
||||||
|
clearInterval(checkExpiredRef.current);
|
||||||
|
checkExpiredRef.current = null;
|
||||||
|
}
|
||||||
|
if (pollTimerRef.current) {
|
||||||
|
clearInterval(pollTimerRef.current);
|
||||||
|
pollTimerRef.current = null;
|
||||||
|
}
|
||||||
|
if (sessionIdRef.current) {
|
||||||
|
fetch(
|
||||||
|
`${baseUrlRef.current}${cfg.apiBase}/${sessionIdRef.current}`,
|
||||||
|
{
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
keepalive: true,
|
||||||
|
},
|
||||||
|
).catch(() => {});
|
||||||
|
sessionIdRef.current = null;
|
||||||
|
}
|
||||||
|
setState('expired');
|
||||||
|
}
|
||||||
|
return current;
|
||||||
|
});
|
||||||
|
}, 500);
|
||||||
|
|
||||||
pollTimerRef.current = setInterval(async () => {
|
pollTimerRef.current = setInterval(async () => {
|
||||||
try {
|
try {
|
||||||
const pollRes = await fetch(
|
const pollRes = await fetch(
|
||||||
@@ -237,7 +272,7 @@ export default function QrCodeLoginDialog({
|
|||||||
const { status, error, ...rest } = pollJson.data;
|
const { status, error, ...rest } = pollJson.data;
|
||||||
|
|
||||||
if (status === 'success') {
|
if (status === 'success') {
|
||||||
sessionIdRef.current = null; // backend already cleaned up
|
sessionIdRef.current = null;
|
||||||
cleanup();
|
cleanup();
|
||||||
setState('success');
|
setState('success');
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -249,9 +284,14 @@ export default function QrCodeLoginDialog({
|
|||||||
cleanup();
|
cleanup();
|
||||||
setState('error');
|
setState('error');
|
||||||
setErrorMessage(error || tRef.current(cfg.failedKey));
|
setErrorMessage(error || tRef.current(cfg.failedKey));
|
||||||
|
} else if (status === 'expired') {
|
||||||
|
sessionIdRef.current = null;
|
||||||
|
cleanup();
|
||||||
|
setExpireIn(0);
|
||||||
|
setState('expired');
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// ignore poll errors, will retry next interval
|
// ignore poll errors
|
||||||
}
|
}
|
||||||
}, POLL_INTERVAL_MS);
|
}, POLL_INTERVAL_MS);
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
@@ -323,6 +363,31 @@ export default function QrCodeLoginDialog({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* QR code expired — click overlay to refresh */}
|
||||||
|
{state === 'expired' && qrDataUrl && (
|
||||||
|
<div className="flex flex-col items-center space-y-3">
|
||||||
|
<p className="text-sm text-muted-foreground text-center">
|
||||||
|
{t(platformConfig.scanQRCodeKey)}
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="relative border rounded-lg p-2 bg-white cursor-pointer group"
|
||||||
|
onClick={() => startLogin()}
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
src={qrDataUrl}
|
||||||
|
alt="QR Code"
|
||||||
|
className="w-56 h-56 opacity-40"
|
||||||
|
/>
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center bg-white/60 rounded-lg group-hover:bg-white/70 transition-colors">
|
||||||
|
<div className="flex items-center justify-center w-16 h-16 rounded-full bg-black/5 group-hover:bg-black/10 transition-colors">
|
||||||
|
<RotateCw className="h-8 w-8 text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Success */}
|
{/* Success */}
|
||||||
{state === 'success' && (
|
{state === 'success' && (
|
||||||
<div className="flex flex-col items-center space-y-3 py-8">
|
<div className="flex flex-col items-center space-y-3 py-8">
|
||||||
@@ -350,7 +415,7 @@ export default function QrCodeLoginDialog({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{state === 'error' && (
|
{state === 'error' && (
|
||||||
<DialogFooter>
|
<div className="flex justify-end gap-2">
|
||||||
<Button variant="outline" onClick={() => handleOpenChange(false)}>
|
<Button variant="outline" onClick={() => handleOpenChange(false)}>
|
||||||
{t('common.cancel')}
|
{t('common.cancel')}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -358,7 +423,7 @@ export default function QrCodeLoginDialog({
|
|||||||
<RefreshCw className="h-4 w-4 mr-1.5" />
|
<RefreshCw className="h-4 w-4 mr-1.5" />
|
||||||
{t(platformConfig.retryKey)}
|
{t(platformConfig.retryKey)}
|
||||||
</Button>
|
</Button>
|
||||||
</DialogFooter>
|
</div>
|
||||||
)}
|
)}
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
|
|||||||
Reference in New Issue
Block a user