mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-02 20:14:36 +00:00
Compare commits
62 Commits
master
...
validation
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
485f421920 | ||
|
|
329d813577 | ||
|
|
9ce42ddcb6 | ||
|
|
608ac82762 | ||
|
|
f516fa3a4f | ||
|
|
779cf9899f | ||
|
|
63a3f323e7 | ||
|
|
4a60bdb6b6 | ||
|
|
3ceb0c6829 | ||
|
|
31f4bc1ad6 | ||
|
|
d4602bca34 | ||
|
|
5c932c66e6 | ||
|
|
6a9f7e2c16 | ||
|
|
16901bc574 | ||
|
|
3a1ea8e945 | ||
|
|
cab5f99b97 | ||
|
|
560799cc33 | ||
|
|
8275cfd140 | ||
|
|
14330741cc | ||
|
|
7d0d37cac6 | ||
|
|
d43cbf0243 | ||
|
|
74f8a500b2 | ||
|
|
937110e193 | ||
|
|
ca74fc1ba4 | ||
|
|
29a0041887 | ||
|
|
2484ddc44d | ||
|
|
d89356af65 | ||
|
|
5a90b0e06b | ||
|
|
c2af8ff9c0 | ||
|
|
93589ee381 | ||
|
|
87c5aed9e7 | ||
|
|
aa4d46fd87 | ||
|
|
aa4b5d6732 | ||
|
|
748cc68667 | ||
|
|
bb55cd7ba9 | ||
|
|
3ba727f0e4 | ||
|
|
3eaadea3e0 | ||
|
|
1a3c73bc05 | ||
|
|
adb4b29c94 | ||
|
|
af58c34c26 | ||
|
|
12c9d02145 | ||
|
|
871c4525ca | ||
|
|
3872e3e1ac | ||
|
|
ea6ed9b7fd | ||
|
|
70ec75f9a2 | ||
|
|
9e1ff7f85c | ||
|
|
91e99e2f46 | ||
|
|
59871c3118 | ||
|
|
3780a68dfa | ||
|
|
9908dc7800 | ||
|
|
84afe8551d | ||
|
|
53747fc1f0 | ||
|
|
1f855c3e7f | ||
|
|
66a0a7c9c8 | ||
|
|
25bf3ea0b3 | ||
|
|
d2c7a51e46 | ||
|
|
d38e3d9181 | ||
|
|
77be87ed40 | ||
|
|
27227aa31f | ||
|
|
1af2cb5bc2 | ||
|
|
37641f05f2 | ||
|
|
4bb0b49907 |
9
.github/ISSUE_TEMPLATE/bug-report.yml
vendored
9
.github/ISSUE_TEMPLATE/bug-report.yml
vendored
@@ -10,15 +10,6 @@ body:
|
||||
placeholder: 例如:v3.3.0、CentOS x64 Python 3.10.3、Docker
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
attributes:
|
||||
label: 部署版本
|
||||
description: 请选择您使用的 LangBot 部署版本。
|
||||
options:
|
||||
- 社区版
|
||||
- 云服务
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: 异常情况
|
||||
|
||||
9
.github/ISSUE_TEMPLATE/bug-report_en.yml
vendored
9
.github/ISSUE_TEMPLATE/bug-report_en.yml
vendored
@@ -10,15 +10,6 @@ body:
|
||||
placeholder: "For example: v3.3.0, CentOS x64 Python 3.10.3, Docker"
|
||||
validations:
|
||||
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
|
||||
attributes:
|
||||
label: Exception
|
||||
|
||||
@@ -47,8 +47,6 @@ LangBot is an **open-source, production-grade platform** for building AI-powered
|
||||
|
||||
[→ 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
|
||||
|
||||
@@ -47,8 +47,6 @@ LangBot 是一个**开源的生产级平台**,用于构建 AI 驱动的即时
|
||||
|
||||
[→ 了解更多功能特性](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,8 +46,6 @@ 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)
|
||||
|
||||
📍 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
|
||||
|
||||
@@ -46,8 +46,6 @@ 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)
|
||||
|
||||
📍 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
|
||||
|
||||
@@ -46,8 +46,6 @@ LangBot は、AI搭載のインスタントメッセージングボットを構
|
||||
|
||||
[→ すべての機能について詳しく見る](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,8 +46,6 @@ LangBot은 AI 기반 인스턴트 메시징 봇을 구축하기 위한 **오픈
|
||||
|
||||
[→ 모든 기능 자세히 보기](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,8 +46,6 @@ LangBot — это **платформа с открытым исходным к
|
||||
|
||||
[→ Подробнее обо всех возможностях](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,8 +48,6 @@ LangBot 是一個**開源的生產級平台**,用於建構 AI 驅動的即時
|
||||
|
||||
[→ 了解更多功能特性](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,8 +46,6 @@ 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)
|
||||
|
||||
📍 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
|
||||
|
||||
@@ -179,6 +179,8 @@ class AdaptersRouterGroup(group.RouterGroup):
|
||||
"""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
|
||||
|
||||
@@ -206,32 +208,60 @@ class AdaptersRouterGroup(group.RouterGroup):
|
||||
|
||||
async def run_login():
|
||||
try:
|
||||
import qrcode as qr_lib
|
||||
|
||||
def on_qrcode(qr_data_url: str, _qr_url: str):
|
||||
def _update():
|
||||
session['qr_data_url'] = qr_data_url
|
||||
session['expire_at'] = time.time() + 180
|
||||
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)
|
||||
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'
|
||||
|
||||
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:
|
||||
error_message = 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
|
||||
session['status'] = 'error'
|
||||
session['error'] = str(e)
|
||||
finally:
|
||||
await client.close()
|
||||
|
||||
@@ -265,11 +295,7 @@ class AdaptersRouterGroup(group.RouterGroup):
|
||||
if not session:
|
||||
return self.http_status(404, -1, 'Session not found')
|
||||
|
||||
data = {
|
||||
'status': session['status'],
|
||||
'qr_data_url': session['qr_data_url'],
|
||||
'expire_at': session['expire_at'],
|
||||
}
|
||||
data = {'status': session['status']}
|
||||
|
||||
if session['status'] == 'success':
|
||||
data['token'] = session['token']
|
||||
@@ -279,9 +305,6 @@ class AdaptersRouterGroup(group.RouterGroup):
|
||||
elif session['status'] == 'error':
|
||||
data['error'] = session['error']
|
||||
_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)
|
||||
|
||||
|
||||
@@ -7,10 +7,8 @@ import httpx
|
||||
import uuid
|
||||
import os
|
||||
import posixpath
|
||||
import sqlalchemy
|
||||
|
||||
from .....core import taskmgr
|
||||
from .....entity.persistence import plugin as persistence_plugin
|
||||
from .. import group
|
||||
from langbot_plugin.runtime.plugin.mgr import PluginInstallSource
|
||||
|
||||
@@ -150,15 +148,7 @@ class PluginsRouterGroup(group.RouterGroup):
|
||||
return self.http_status(404, -1, 'plugin not found')
|
||||
|
||||
if quart.request.method == 'GET':
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.select(persistence_plugin.PluginSetting.config)
|
||||
.where(persistence_plugin.PluginSetting.plugin_author == author)
|
||||
.where(persistence_plugin.PluginSetting.plugin_name == plugin_name)
|
||||
)
|
||||
persisted_config = result.scalar_one_or_none()
|
||||
|
||||
config = persisted_config if persisted_config is not None else plugin['plugin_config']
|
||||
return self.success(data={'config': config})
|
||||
return self.success(data={'config': plugin['plugin_config']})
|
||||
elif quart.request.method == 'PUT':
|
||||
data = await quart.request.json
|
||||
|
||||
|
||||
@@ -140,6 +140,17 @@ class SystemRouterGroup(group.RouterGroup):
|
||||
async def _() -> str:
|
||||
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(
|
||||
'/debug/plugin/action',
|
||||
methods=['POST'],
|
||||
|
||||
@@ -3,7 +3,6 @@ import typing
|
||||
import asyncio
|
||||
import traceback
|
||||
import datetime
|
||||
import json
|
||||
|
||||
import aiocqhttp
|
||||
import pydantic
|
||||
@@ -294,29 +293,6 @@ class AiocqhttpMessageConverter(abstract_platform_adapter.AbstractMessageConvert
|
||||
elif msg.type == 'dice':
|
||||
face_id = msg.data['result']
|
||||
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)
|
||||
|
||||
|
||||
@@ -881,8 +881,7 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
|
||||
bot_account_id = config['bot_name']
|
||||
|
||||
domain = self._resolve_domain(config)
|
||||
bot = lark_oapi.ws.Client(config['app_id'], config['app_secret'], event_handler=event_handler, domain=domain)
|
||||
bot = lark_oapi.ws.Client(config['app_id'], config['app_secret'], event_handler=event_handler)
|
||||
api_client = self.build_api_client(config)
|
||||
cipher = AESCipher(config.get('encrypt-key', ''))
|
||||
self.request_app_ticket(api_client, config)
|
||||
@@ -1015,28 +1014,13 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
|
||||
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):
|
||||
app_id = config['app_id']
|
||||
app_secret = config['app_secret']
|
||||
domain = self._resolve_domain(config)
|
||||
api_client = lark_oapi.Client.builder().app_id(app_id).app_secret(app_secret).domain(domain).build()
|
||||
api_client = lark_oapi.Client.builder().app_id(app_id).app_secret(app_secret).build()
|
||||
if 'isv' == config.get('app_type', 'self'):
|
||||
api_client = (
|
||||
lark_oapi.Client.builder()
|
||||
.app_id(app_id)
|
||||
.app_secret(app_secret)
|
||||
.app_type(lark_oapi.AppType.ISV)
|
||||
.domain(domain)
|
||||
.build()
|
||||
lark_oapi.Client.builder().app_id(app_id).app_secret(app_secret).app_type(lark_oapi.AppType.ISV).build()
|
||||
)
|
||||
return api_client
|
||||
|
||||
|
||||
@@ -23,57 +23,6 @@ spec:
|
||||
en: https://link.langbot.app/en/platforms/lark
|
||||
ja: https://link.langbot.app/ja/platforms/lark
|
||||
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
|
||||
label:
|
||||
en_US: One-Click Create App
|
||||
@@ -191,10 +140,10 @@ spec:
|
||||
zh_Hant: 應用類型
|
||||
ja_JP: アプリタイプ
|
||||
description:
|
||||
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_Hant: "預設為企業自建應用,參考 https://open.feishu.cn/document/platform-overveiw/overview"
|
||||
ja_JP: "デフォルトはカスタムアプリです。詳細は 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_Hant: 預設為企業自建應用,參考 https://open.feishu.cn/document/platform-overveiw/overview
|
||||
ja_JP: デフォルトはカスタムアプリです。詳細は https://open.feishu.cn/document/platform-overveiw/overview を参照してください
|
||||
type: select
|
||||
options:
|
||||
- name: self
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def dedupe_preregistered_groups() -> None:
|
||||
"""Keep API integration route registration isolated across test modules."""
|
||||
from langbot.pkg.api.http.controller import group
|
||||
|
||||
seen: set[tuple[str, str]] = set()
|
||||
unique_groups = []
|
||||
for group_cls in group.preregistered_groups:
|
||||
key = (group_cls.name, group_cls.path)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
unique_groups.append(group_cls)
|
||||
|
||||
group.preregistered_groups[:] = unique_groups
|
||||
|
||||
|
||||
@pytest.fixture(scope='module')
|
||||
def http_controller_cls(mock_circular_import_chain):
|
||||
"""Import HTTPController under each module's circular-import isolation."""
|
||||
from langbot.pkg.api.http.controller.main import HTTPController
|
||||
|
||||
dedupe_preregistered_groups()
|
||||
return HTTPController
|
||||
@@ -102,9 +102,11 @@ def fake_bot_app():
|
||||
|
||||
|
||||
@pytest.fixture(scope='module')
|
||||
async def quart_test_client(fake_bot_app, http_controller_cls):
|
||||
async def quart_test_client(fake_bot_app):
|
||||
"""Create Quart test client (module scope to avoid route re-registration)."""
|
||||
controller = http_controller_cls(fake_bot_app)
|
||||
from langbot.pkg.api.http.controller.main import HTTPController
|
||||
|
||||
controller = HTTPController(fake_bot_app)
|
||||
await controller.initialize()
|
||||
|
||||
client = controller.quart_app.test_client()
|
||||
|
||||
@@ -101,9 +101,11 @@ def fake_embed_app():
|
||||
|
||||
|
||||
@pytest.fixture(scope='module')
|
||||
async def quart_test_client(fake_embed_app, http_controller_cls):
|
||||
async def quart_test_client(fake_embed_app):
|
||||
"""Create Quart test client (module scope)."""
|
||||
controller = http_controller_cls(fake_embed_app)
|
||||
from langbot.pkg.api.http.controller.main import HTTPController
|
||||
|
||||
controller = HTTPController(fake_embed_app)
|
||||
await controller.initialize()
|
||||
|
||||
client = controller.quart_app.test_client()
|
||||
@@ -297,4 +299,4 @@ class TestEmbedFeedbackEndpoint:
|
||||
json={'message_id': 'msg-123', 'feedback_type': 99}
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert response.status_code == 400
|
||||
@@ -107,9 +107,11 @@ def fake_knowledge_app():
|
||||
|
||||
|
||||
@pytest.fixture(scope='module')
|
||||
async def quart_test_client(fake_knowledge_app, http_controller_cls):
|
||||
async def quart_test_client(fake_knowledge_app):
|
||||
"""Create Quart test client (module scope to avoid route re-registration)."""
|
||||
controller = http_controller_cls(fake_knowledge_app)
|
||||
from langbot.pkg.api.http.controller.main import HTTPController
|
||||
|
||||
controller = HTTPController(fake_knowledge_app)
|
||||
await controller.initialize()
|
||||
|
||||
client = controller.quart_app.test_client()
|
||||
|
||||
@@ -113,9 +113,11 @@ def fake_monitoring_app():
|
||||
|
||||
|
||||
@pytest.fixture(scope='module')
|
||||
async def quart_test_client(fake_monitoring_app, http_controller_cls):
|
||||
async def quart_test_client(fake_monitoring_app):
|
||||
"""Create Quart test client (module scope)."""
|
||||
controller = http_controller_cls(fake_monitoring_app)
|
||||
from langbot.pkg.api.http.controller.main import HTTPController
|
||||
|
||||
controller = HTTPController(fake_monitoring_app)
|
||||
await controller.initialize()
|
||||
|
||||
client = controller.quart_app.test_client()
|
||||
@@ -327,4 +329,4 @@ class TestMonitoringExportEndpoint:
|
||||
headers={'Authorization': 'Bearer test_token'}
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.status_code == 200
|
||||
@@ -119,9 +119,11 @@ def fake_pipeline_app():
|
||||
|
||||
|
||||
@pytest.fixture(scope='module')
|
||||
async def quart_test_client(fake_pipeline_app, http_controller_cls):
|
||||
async def quart_test_client(fake_pipeline_app):
|
||||
"""Create Quart test client (module scope to avoid route re-registration)."""
|
||||
controller = http_controller_cls(fake_pipeline_app)
|
||||
from langbot.pkg.api.http.controller.main import HTTPController
|
||||
|
||||
controller = HTTPController(fake_pipeline_app)
|
||||
await controller.initialize()
|
||||
|
||||
client = controller.quart_app.test_client()
|
||||
|
||||
@@ -116,9 +116,11 @@ def fake_provider_app():
|
||||
|
||||
|
||||
@pytest.fixture(scope='module')
|
||||
async def quart_test_client(fake_provider_app, http_controller_cls):
|
||||
async def quart_test_client(fake_provider_app):
|
||||
"""Create Quart test client (module scope to avoid route re-registration)."""
|
||||
controller = http_controller_cls(fake_provider_app)
|
||||
from langbot.pkg.api.http.controller.main import HTTPController
|
||||
|
||||
controller = HTTPController(fake_provider_app)
|
||||
await controller.initialize()
|
||||
|
||||
client = controller.quart_app.test_client()
|
||||
|
||||
@@ -119,13 +119,15 @@ def fake_api_app():
|
||||
# ============== QUART TEST CLIENT FIXTURE ==============
|
||||
|
||||
@pytest.fixture
|
||||
async def quart_test_client(fake_api_app, http_controller_cls):
|
||||
async def quart_test_client(fake_api_app):
|
||||
"""
|
||||
Create Quart test client with real HTTPController and route registration.
|
||||
|
||||
Requires mock_circular_import_chain fixture to run first (usefixtures).
|
||||
"""
|
||||
controller = http_controller_cls(fake_api_app)
|
||||
from langbot.pkg.api.http.controller.main import HTTPController
|
||||
|
||||
controller = HTTPController(fake_api_app)
|
||||
await controller.initialize()
|
||||
|
||||
client = controller.quart_app.test_client()
|
||||
@@ -342,4 +344,4 @@ class TestRealImports:
|
||||
break
|
||||
|
||||
assert user_group is not None
|
||||
assert user_group.path == '/api/v1/user'
|
||||
assert user_group.path == '/api/v1/user'
|
||||
@@ -1,66 +0,0 @@
|
||||
"""
|
||||
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!")
|
||||
@@ -479,47 +479,6 @@ class TestMessageAggregatorMerge:
|
||||
assert "hello" in merged_str
|
||||
assert "world" in merged_str
|
||||
|
||||
def test_merge_messages_preserves_routed_by_rule_if_any_input_matches(self):
|
||||
"""Merged PendingMessage should keep routed_by_rule when any input was rule-routed."""
|
||||
aggregator = get_aggregator_module()
|
||||
|
||||
app = make_aggregator_app()
|
||||
agg = aggregator.MessageAggregator(app)
|
||||
|
||||
chain1 = text_chain("first")
|
||||
chain2 = text_chain("second")
|
||||
event = friend_message_event(chain1)
|
||||
adapter = mock_adapter()
|
||||
|
||||
pending1 = aggregator.PendingMessage(
|
||||
bot_uuid='test-bot',
|
||||
launcher_type=provider_session.LauncherTypes.PERSON,
|
||||
launcher_id=12345,
|
||||
sender_id=12345,
|
||||
message_event=event,
|
||||
message_chain=chain1,
|
||||
adapter=adapter,
|
||||
pipeline_uuid='test-pipeline-uuid',
|
||||
routed_by_rule=False,
|
||||
)
|
||||
|
||||
pending2 = aggregator.PendingMessage(
|
||||
bot_uuid='test-bot',
|
||||
launcher_type=provider_session.LauncherTypes.PERSON,
|
||||
launcher_id=12345,
|
||||
sender_id=12345,
|
||||
message_event=event,
|
||||
message_chain=chain2,
|
||||
adapter=adapter,
|
||||
pipeline_uuid='test-pipeline-uuid',
|
||||
routed_by_rule=True,
|
||||
)
|
||||
|
||||
merged = agg._merge_messages([pending1, pending2])
|
||||
|
||||
assert merged.routed_by_rule is True
|
||||
assert str(merged.message_chain) == 'first\nsecond'
|
||||
|
||||
|
||||
class TestMessageAggregatorFlush:
|
||||
"""Tests for buffer flush behavior."""
|
||||
@@ -635,3 +594,44 @@ class TestMessageAggregatorFlushAll:
|
||||
# Both buffers should be flushed
|
||||
assert len(agg.buffers) == 0
|
||||
assert app.query_pool.add_query.call_count == 2
|
||||
|
||||
|
||||
class TestMessageAggregatorMergeRoutedFlag:
|
||||
"""Tests for preserving routed message state during merge."""
|
||||
|
||||
def test_merge_messages_preserves_routed_by_rule_if_any_input_matches(self):
|
||||
"""Merged PendingMessage keeps routed_by_rule when any input was rule-routed."""
|
||||
aggregator = get_aggregator_module()
|
||||
agg = aggregator.MessageAggregator(ap=None)
|
||||
chain1 = text_chain("first")
|
||||
chain2 = text_chain("second")
|
||||
event = friend_message_event(chain1)
|
||||
adapter = mock_adapter()
|
||||
|
||||
pending1 = aggregator.PendingMessage(
|
||||
bot_uuid='test-bot',
|
||||
launcher_type=provider_session.LauncherTypes.PERSON,
|
||||
launcher_id=12345,
|
||||
sender_id=12345,
|
||||
message_event=event,
|
||||
message_chain=chain1,
|
||||
adapter=adapter,
|
||||
pipeline_uuid='test-pipeline',
|
||||
routed_by_rule=False,
|
||||
)
|
||||
pending2 = aggregator.PendingMessage(
|
||||
bot_uuid='test-bot',
|
||||
launcher_type=provider_session.LauncherTypes.PERSON,
|
||||
launcher_id=12345,
|
||||
sender_id=12345,
|
||||
message_event=event,
|
||||
message_chain=chain2,
|
||||
adapter=adapter,
|
||||
pipeline_uuid='test-pipeline',
|
||||
routed_by_rule=True,
|
||||
)
|
||||
|
||||
merged = agg._merge_messages([pending1, pending2])
|
||||
|
||||
assert merged.routed_by_rule is True
|
||||
assert str(merged.message_chain) == 'first\nsecond'
|
||||
|
||||
@@ -119,10 +119,10 @@ class TestContentFilterStageInit:
|
||||
|
||||
await stage.initialize(pipeline_config)
|
||||
|
||||
assert {filter_impl.name for filter_impl in stage.filter_chain} == {
|
||||
assert [filter_impl.name for filter_impl in stage.filter_chain] == [
|
||||
'ban-word-filter',
|
||||
'content-ignore',
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
class TestPreContentFilter:
|
||||
|
||||
@@ -11,7 +11,7 @@ Tests cover:
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
from unittest.mock import Mock
|
||||
from importlib import import_module
|
||||
|
||||
from tests.factories import (
|
||||
@@ -166,6 +166,29 @@ class TestLongTextProcessStageProcess:
|
||||
assert isinstance(components[0], platform_message.Plain)
|
||||
assert components[0].text == 'short response'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_response_message_chain_continues_without_processing(self):
|
||||
"""Empty response chains should be a no-op for long text processing."""
|
||||
longtext = get_longtext_module()
|
||||
entities = get_entities_module()
|
||||
|
||||
app = FakeApp()
|
||||
stage = longtext.LongTextProcessStage(app)
|
||||
|
||||
pipeline_config = make_longtext_config(strategy='forward', threshold=1)
|
||||
|
||||
await stage.initialize(pipeline_config)
|
||||
|
||||
query = text_query("hello")
|
||||
query.pipeline_config = pipeline_config
|
||||
query.resp_message_chain = []
|
||||
|
||||
result = await stage.process(query, 'LongTextProcessStage')
|
||||
|
||||
assert result.result_type == entities.ResultType.CONTINUE
|
||||
assert result.new_query is query
|
||||
assert query.resp_message_chain == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_plain_component_skips(self):
|
||||
"""resp_message_chain with non-Plain components should skip processing."""
|
||||
@@ -200,48 +223,6 @@ class TestLongTextProcessStageProcess:
|
||||
assert components[0].text == 'short'
|
||||
assert components[1].url == 'https://example.com/img.png'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_resp_message_chain(self):
|
||||
"""Empty resp_message_chain should be handled gracefully."""
|
||||
longtext = get_longtext_module()
|
||||
entities = get_entities_module()
|
||||
|
||||
app = FakeApp()
|
||||
stage = longtext.LongTextProcessStage(app)
|
||||
|
||||
pipeline_config = make_longtext_config(strategy='forward')
|
||||
|
||||
await stage.initialize(pipeline_config)
|
||||
|
||||
query = text_query("hello")
|
||||
query.pipeline_config = pipeline_config
|
||||
query.resp_message_chain = []
|
||||
|
||||
result = await stage.process(query, 'LongTextProcessStage')
|
||||
|
||||
assert result.result_type == entities.ResultType.CONTINUE
|
||||
assert result.new_query is query
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_response_message_chain_does_not_call_strategy(self):
|
||||
"""Empty response chains should be a no-op for long text processing."""
|
||||
longtext = get_longtext_module()
|
||||
entities = get_entities_module()
|
||||
|
||||
app = FakeApp()
|
||||
stage = longtext.LongTextProcessStage(app)
|
||||
stage.strategy_impl = AsyncMock()
|
||||
|
||||
query = text_query("hello")
|
||||
query.pipeline_config = make_longtext_config(strategy='forward', threshold=1)
|
||||
query.resp_message_chain = []
|
||||
|
||||
result = await stage.process(query, 'LongTextProcessStage')
|
||||
|
||||
assert result.result_type == entities.ResultType.CONTINUE
|
||||
assert result.new_query is query
|
||||
stage.strategy_impl.process.assert_not_called()
|
||||
|
||||
class TestForwardStrategy:
|
||||
"""Tests for ForwardComponentStrategy."""
|
||||
|
||||
|
||||
@@ -223,8 +223,10 @@ def test_token_manager_next_token_empty():
|
||||
"""Test TokenManager.next_token with empty tokens doesn't error."""
|
||||
mgr = token.TokenManager(name='test', tokens=[])
|
||||
|
||||
assert mgr.next_token() is None
|
||||
mgr.next_token()
|
||||
|
||||
assert mgr.get_token() == ''
|
||||
assert mgr.using_token_index == 0
|
||||
|
||||
|
||||
# ============================================================================
|
||||
|
||||
@@ -434,23 +434,6 @@ class TestRAGRuntimeServiceGetFileStream:
|
||||
|
||||
assert result == b''
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_file_stream_normalizes_safe_path(self):
|
||||
"""Safe relative paths are normalized before loading."""
|
||||
mock_app = self._create_mock_app()
|
||||
|
||||
mocks = self._make_rag_import_mocks()
|
||||
|
||||
with isolated_sys_modules(mocks):
|
||||
from langbot.pkg.rag.service.runtime import RAGRuntimeService
|
||||
|
||||
service = RAGRuntimeService(mock_app)
|
||||
|
||||
result = await service.get_file_stream('knowledge/./files/doc.pdf')
|
||||
|
||||
assert result == b'file content'
|
||||
mock_app.storage_mgr.storage_provider.load.assert_called_once_with('knowledge/files/doc.pdf')
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_file_stream_path_traversal_blocked(self):
|
||||
"""Path traversal attacks are blocked."""
|
||||
@@ -471,37 +454,6 @@ class TestRAGRuntimeServiceGetFileStream:
|
||||
with pytest.raises(ValueError, match='Invalid storage path'):
|
||||
await service.get_file_stream('knowledge/../../../etc/passwd')
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
'storage_path',
|
||||
[
|
||||
'',
|
||||
'../secret.txt',
|
||||
'/absolute/path.txt',
|
||||
'..\\secret.txt',
|
||||
'nested\\..\\secret.txt',
|
||||
'%2e%2e/secret.txt',
|
||||
'nested/%2e%2e/secret.txt',
|
||||
'C:\\secret.txt',
|
||||
'safe/\x00file.txt',
|
||||
],
|
||||
)
|
||||
async def test_get_file_stream_rejects_unsafe_paths(self, storage_path: str):
|
||||
"""Unsafe runtime file paths are rejected before storage load."""
|
||||
mock_app = self._create_mock_app()
|
||||
|
||||
mocks = self._make_rag_import_mocks()
|
||||
|
||||
with isolated_sys_modules(mocks):
|
||||
from langbot.pkg.rag.service.runtime import RAGRuntimeService
|
||||
|
||||
service = RAGRuntimeService(mock_app)
|
||||
|
||||
with pytest.raises(ValueError, match='Invalid storage path'):
|
||||
await service.get_file_stream(storage_path)
|
||||
|
||||
mock_app.storage_mgr.storage_provider.load.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_file_stream_normalizes_path(self):
|
||||
"""Valid paths with .. in filename (not traversal) should work."""
|
||||
@@ -520,3 +472,50 @@ class TestRAGRuntimeServiceGetFileStream:
|
||||
# Let's test a simple valid path
|
||||
await service.get_file_stream('knowledge/files/test.pdf')
|
||||
mock_app.storage_mgr.storage_provider.load.assert_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_file_stream_normalizes_safe_relative_path(self):
|
||||
"""Safe relative paths are normalized before loading."""
|
||||
mock_app = self._create_mock_app()
|
||||
|
||||
mocks = self._make_rag_import_mocks()
|
||||
|
||||
with isolated_sys_modules(mocks):
|
||||
from langbot.pkg.rag.service.runtime import RAGRuntimeService
|
||||
|
||||
service = RAGRuntimeService(mock_app)
|
||||
|
||||
await service.get_file_stream('knowledge/./files/doc.pdf')
|
||||
|
||||
mock_app.storage_mgr.storage_provider.load.assert_called_once_with('knowledge/files/doc.pdf')
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"storage_path",
|
||||
[
|
||||
"",
|
||||
"../secret.txt",
|
||||
"/absolute/path.txt",
|
||||
"..\\secret.txt",
|
||||
"nested\\..\\secret.txt",
|
||||
"%2e%2e/secret.txt",
|
||||
"nested/%2e%2e/secret.txt",
|
||||
"C:\\secret.txt",
|
||||
"safe/\x00file.txt",
|
||||
],
|
||||
)
|
||||
async def test_get_file_stream_rejects_unsafe_paths(self, storage_path):
|
||||
"""Traversal, absolute, encoded, and Windows-style paths are rejected."""
|
||||
mock_app = self._create_mock_app()
|
||||
|
||||
mocks = self._make_rag_import_mocks()
|
||||
|
||||
with isolated_sys_modules(mocks):
|
||||
from langbot.pkg.rag.service.runtime import RAGRuntimeService
|
||||
|
||||
service = RAGRuntimeService(mock_app)
|
||||
|
||||
with pytest.raises(ValueError, match='Invalid storage path'):
|
||||
await service.get_file_stream(storage_path)
|
||||
|
||||
mock_app.storage_mgr.storage_provider.load.assert_not_called()
|
||||
|
||||
@@ -191,18 +191,19 @@ class TestGetFuncSchema:
|
||||
assert result['parameters']['properties']['param_name']['description'] == 'This is the param description.'
|
||||
|
||||
def test_missing_parameter_doc_uses_empty_description(self):
|
||||
"""Undocumented parameters should not break schema generation."""
|
||||
"""Test that undocumented parameters do not crash schema generation."""
|
||||
funcschema = get_funcschema_module()
|
||||
|
||||
def sample_function(documented: str, undocumented: int):
|
||||
"""Sample function.
|
||||
def partially_documented_func(documented: str, undocumented: int):
|
||||
"""Function with one undocumented param.
|
||||
|
||||
Args:
|
||||
documented(str): documented parameter description
|
||||
documented: Documented parameter.
|
||||
"""
|
||||
pass
|
||||
|
||||
result = funcschema.get_func_schema(sample_function)
|
||||
result = funcschema.get_func_schema(partially_documented_func)
|
||||
|
||||
assert result['parameters']['properties']['documented']['description'] == 'documented parameter description'
|
||||
assert result['parameters']['properties']['undocumented']['description'] == ''
|
||||
props = result['parameters']['properties']
|
||||
assert props['documented']['description'] == 'Documented parameter.'
|
||||
assert props['undocumented']['description'] == ''
|
||||
|
||||
@@ -56,29 +56,21 @@ class TestGetQQImageDownloadableUrl:
|
||||
# Fragment is not included in query string parsing
|
||||
assert "http://example.com/image.jpg" in result_url
|
||||
|
||||
def test_https_url(self):
|
||||
"""Parse HTTPS URL and preserve its scheme."""
|
||||
def test_https_url_preserves_scheme(self):
|
||||
"""Parse HTTPS URL without downgrading the scheme."""
|
||||
url = "https://example.com/image.jpg"
|
||||
result_url, query = get_qq_image_downloadable_url(url)
|
||||
|
||||
assert result_url == "https://example.com/image.jpg"
|
||||
assert query == {}
|
||||
|
||||
def test_preserves_qq_https_scheme_and_query(self):
|
||||
"""QQ image URLs keep HTTPS and query parameters."""
|
||||
result_url, query = get_qq_image_downloadable_url(
|
||||
'https://gchat.qpic.cn/gchatpic_new/abc/0?term=2&is_origin=1'
|
||||
)
|
||||
def test_missing_scheme_defaults_to_http(self):
|
||||
"""Parse scheme-less URL with the existing HTTP default."""
|
||||
url = "example.com/image.jpg?param=value"
|
||||
result_url, query = get_qq_image_downloadable_url(url)
|
||||
|
||||
assert result_url == 'https://gchat.qpic.cn/gchatpic_new/abc/0'
|
||||
assert query == {'term': ['2'], 'is_origin': ['1']}
|
||||
|
||||
def test_defaults_missing_scheme_to_http(self):
|
||||
"""Scheme-less image URLs default to HTTP."""
|
||||
result_url, query = get_qq_image_downloadable_url('gchat.qpic.cn/gchatpic_new/abc/0?term=2')
|
||||
|
||||
assert result_url == 'http://gchat.qpic.cn/gchatpic_new/abc/0'
|
||||
assert query == {'term': ['2']}
|
||||
assert result_url == "http://example.com/image.jpg"
|
||||
assert query == {"param": ["value"]}
|
||||
|
||||
|
||||
class TestExtractB64AndFormat:
|
||||
|
||||
@@ -75,61 +75,6 @@ class TestPkgMgr:
|
||||
]
|
||||
mock_pipmain.assert_called_once_with(expected_args)
|
||||
|
||||
def test_install_requirements_defaults_extra_params_to_none(self):
|
||||
"""install_requirements should not use a mutable default for extra_params."""
|
||||
signature = inspect.signature(pkgmgr.install_requirements)
|
||||
|
||||
assert signature.parameters['extra_params'].default is None
|
||||
|
||||
def test_install_requirements_omitted_extra_params_uses_independent_base_commands(self, monkeypatch):
|
||||
"""Omitted extra_params should not share mutable state across calls."""
|
||||
calls = []
|
||||
monkeypatch.setattr(pkgmgr, 'pipmain', calls.append)
|
||||
|
||||
pkgmgr.install_requirements('requirements.txt')
|
||||
pkgmgr.install_requirements('requirements-dev.txt')
|
||||
|
||||
assert calls == [
|
||||
[
|
||||
'install',
|
||||
'-r',
|
||||
'requirements.txt',
|
||||
'-i',
|
||||
'https://pypi.tuna.tsinghua.edu.cn/simple',
|
||||
'--trusted-host',
|
||||
'pypi.tuna.tsinghua.edu.cn',
|
||||
],
|
||||
[
|
||||
'install',
|
||||
'-r',
|
||||
'requirements-dev.txt',
|
||||
'-i',
|
||||
'https://pypi.tuna.tsinghua.edu.cn/simple',
|
||||
'--trusted-host',
|
||||
'pypi.tuna.tsinghua.edu.cn',
|
||||
],
|
||||
]
|
||||
|
||||
def test_install_requirements_preserves_explicit_extra_params(self, monkeypatch):
|
||||
"""Explicit extra_params should be appended to the generated pip command."""
|
||||
calls = []
|
||||
monkeypatch.setattr(pkgmgr, 'pipmain', calls.append)
|
||||
|
||||
pkgmgr.install_requirements('requirements.txt', extra_params=['--no-deps'])
|
||||
|
||||
assert calls == [
|
||||
[
|
||||
'install',
|
||||
'-r',
|
||||
'requirements.txt',
|
||||
'-i',
|
||||
'https://pypi.tuna.tsinghua.edu.cn/simple',
|
||||
'--trusted-host',
|
||||
'pypi.tuna.tsinghua.edu.cn',
|
||||
'--no-deps',
|
||||
]
|
||||
]
|
||||
|
||||
def test_install_requirements_with_extra_params(self):
|
||||
"""install_requirements handles extra params."""
|
||||
with patch('langbot.pkg.utils.pkgmgr.pipmain') as mock_pipmain:
|
||||
@@ -155,3 +100,34 @@ class TestPkgMgr:
|
||||
call_args = mock_pipmain.call_args[0][0]
|
||||
assert '--no-cache-dir' in call_args
|
||||
assert '--verbose' in call_args
|
||||
|
||||
def test_install_requirements_defaults_extra_params_to_none(self):
|
||||
"""install_requirements does not use a mutable list default."""
|
||||
signature = inspect.signature(pkgmgr.install_requirements)
|
||||
|
||||
assert signature.parameters['extra_params'].default is None
|
||||
|
||||
def test_install_requirements_omitted_extra_params_are_isolated(self):
|
||||
"""Repeated calls without extra_params use independent base commands."""
|
||||
with patch('langbot.pkg.utils.pkgmgr.pipmain') as mock_pipmain:
|
||||
pkgmgr.install_requirements('requirements.txt')
|
||||
pkgmgr.install_requirements('requirements-dev.txt')
|
||||
|
||||
assert mock_pipmain.call_args_list[0].args[0] == [
|
||||
'install',
|
||||
'-r',
|
||||
'requirements.txt',
|
||||
'-i',
|
||||
'https://pypi.tuna.tsinghua.edu.cn/simple',
|
||||
'--trusted-host',
|
||||
'pypi.tuna.tsinghua.edu.cn',
|
||||
]
|
||||
assert mock_pipmain.call_args_list[1].args[0] == [
|
||||
'install',
|
||||
'-r',
|
||||
'requirements-dev.txt',
|
||||
'-i',
|
||||
'https://pypi.tuna.tsinghua.edu.cn/simple',
|
||||
'--trusted-host',
|
||||
'pypi.tuna.tsinghua.edu.cn',
|
||||
]
|
||||
|
||||
@@ -87,22 +87,6 @@ class TestGetRunnerCategory:
|
||||
assert get_runner_category("test", "https://example.com") == RunnerCategory.CLOUD
|
||||
assert get_runner_category("test", "https://myserver.example.org") == RunnerCategory.CLOUD
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
'runner_url',
|
||||
[
|
||||
'api.dify.ai/v1',
|
||||
'localhost:7860',
|
||||
'https:///v1',
|
||||
'https://',
|
||||
'https://exa mple.com',
|
||||
'http://[::1',
|
||||
'http://localhost:bad',
|
||||
],
|
||||
)
|
||||
def test_invalid_urls_return_unknown(self, runner_url):
|
||||
"""Invalid or incomplete URLs should return UNKNOWN."""
|
||||
assert get_runner_category("test", runner_url) == RunnerCategory.UNKNOWN
|
||||
|
||||
def test_urlparse_exception_returns_unknown(self):
|
||||
"""Exception during URL parsing should return UNKNOWN."""
|
||||
# Test by mocking urlparse to raise an exception
|
||||
@@ -115,36 +99,49 @@ class TestGetRunnerCategory:
|
||||
result = runner.get_runner_category("test", "http://example.com")
|
||||
assert result == RunnerCategory.UNKNOWN
|
||||
|
||||
def test_url_without_scheme_returns_unknown(self):
|
||||
"""URL without scheme should return UNKNOWN."""
|
||||
assert get_runner_category("test", "example.com") == RunnerCategory.UNKNOWN
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
'runner_url',
|
||||
"runner_url",
|
||||
[
|
||||
'http://localhost:7860',
|
||||
'http://127.0.0.1:7860',
|
||||
'http://10.0.0.1:7860',
|
||||
'http://172.16.0.1:7860',
|
||||
'http://172.31.255.255:7860',
|
||||
'http://192.168.1.20:7860',
|
||||
'http://[::1]:7860',
|
||||
"api.dify.ai/v1",
|
||||
"localhost:7860",
|
||||
"https:///v1",
|
||||
"https://",
|
||||
"https://exa mple.com",
|
||||
"http://[::1",
|
||||
"http://localhost:bad",
|
||||
],
|
||||
)
|
||||
def test_detects_local_hosts_with_ipaddress(self, runner_url):
|
||||
"""Local hostnames and private IPs should be categorized as LOCAL."""
|
||||
assert get_runner_category('langflow-api', runner_url) == RunnerCategory.LOCAL
|
||||
def test_invalid_urls_return_unknown(self, runner_url):
|
||||
"""Invalid or scheme-less URLs should not default to CLOUD."""
|
||||
assert get_runner_category("test", runner_url) == RunnerCategory.UNKNOWN
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
'runner_url',
|
||||
"runner_url",
|
||||
[
|
||||
'http://10.evil.com',
|
||||
'http://192.168.example.com',
|
||||
"http://localhost:7860",
|
||||
"http://127.0.0.1:7860",
|
||||
"http://10.0.0.1:7860",
|
||||
"http://172.16.0.1:7860",
|
||||
"http://172.31.255.255:7860",
|
||||
"http://192.168.1.20:7860",
|
||||
"http://[::1]:7860",
|
||||
],
|
||||
)
|
||||
def test_local_hosts_are_detected_with_ipaddress(self, runner_url):
|
||||
"""Loopback/private IP addresses and localhost should be LOCAL."""
|
||||
assert get_runner_category("test", runner_url) == RunnerCategory.LOCAL
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"runner_url",
|
||||
[
|
||||
"http://10.evil.com",
|
||||
"http://192.168.example.com",
|
||||
],
|
||||
)
|
||||
def test_private_ip_prefix_domains_are_not_local(self, runner_url):
|
||||
"""Domain names that only look like private IP prefixes should not be LOCAL."""
|
||||
assert get_runner_category('langflow-api', runner_url) == RunnerCategory.CLOUD
|
||||
assert get_runner_category("test", runner_url) == RunnerCategory.CLOUD
|
||||
|
||||
|
||||
class TestIsCloudRunner:
|
||||
"""Test is_cloud_runner helper function."""
|
||||
|
||||
@@ -338,9 +338,7 @@ function NavItems({
|
||||
tooltip={config.name}
|
||||
>
|
||||
{config.icon}
|
||||
<span className="cursor-pointer select-none">
|
||||
{config.name}
|
||||
</span>
|
||||
<span>{config.name}</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
);
|
||||
@@ -730,9 +728,7 @@ function NavItems({
|
||||
}}
|
||||
>
|
||||
{config.icon}
|
||||
<span className="cursor-pointer select-none">
|
||||
{config.name}
|
||||
</span>
|
||||
<span>{config.name}</span>
|
||||
<div className="ml-auto flex items-center gap-0.5 -mr-1">
|
||||
{canCreate &&
|
||||
(isPlugin ? (
|
||||
@@ -1112,7 +1108,7 @@ function PluginPagesNav() {
|
||||
className="select-none"
|
||||
>
|
||||
{pluginIcon}
|
||||
<span className="cursor-pointer">{page.name}</span>
|
||||
<span>{page.name}</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
);
|
||||
@@ -1132,7 +1128,7 @@ function PluginPagesNav() {
|
||||
className="select-none"
|
||||
>
|
||||
{pluginIcon}
|
||||
<span className="cursor-pointer">{label}</span>
|
||||
<span>{label}</span>
|
||||
<ChevronRight className="ml-auto size-4 transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90" />
|
||||
</SidebarMenuButton>
|
||||
</CollapsibleTrigger>
|
||||
@@ -1148,9 +1144,7 @@ function PluginPagesNav() {
|
||||
onClick={() => navigate(route)}
|
||||
className="select-none"
|
||||
>
|
||||
<span className="cursor-pointer">
|
||||
{page.name}
|
||||
</span>
|
||||
<span>{page.name}</span>
|
||||
</SidebarMenuSubButton>
|
||||
</SidebarMenuSubItem>
|
||||
);
|
||||
|
||||
@@ -295,7 +295,7 @@ export default function ModelsDialog({
|
||||
|
||||
async function handleScanModels(
|
||||
providerUuid: string,
|
||||
modelType?: ModelType,
|
||||
modelType: ModelType,
|
||||
): Promise<ScanModelsResult> {
|
||||
try {
|
||||
const resp = await httpClient.scanProviderModels(providerUuid, modelType);
|
||||
@@ -319,22 +319,15 @@ export default function ModelsDialog({
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
for (const item of models) {
|
||||
const effectiveType = item.model.type || modelType;
|
||||
if (effectiveType === 'llm') {
|
||||
if (modelType === 'llm') {
|
||||
await httpClient.createProviderLLMModel({
|
||||
name: item.model.name,
|
||||
provider_uuid: providerUuid,
|
||||
abilities: item.abilities,
|
||||
extra_args: {},
|
||||
} as never);
|
||||
} else if (effectiveType === 'embedding') {
|
||||
await httpClient.createProviderEmbeddingModel({
|
||||
name: item.model.name,
|
||||
provider_uuid: providerUuid,
|
||||
extra_args: {},
|
||||
} as never);
|
||||
} else {
|
||||
await httpClient.createProviderRerankModel({
|
||||
await httpClient.createProviderEmbeddingModel({
|
||||
name: item.model.name,
|
||||
provider_uuid: providerUuid,
|
||||
extra_args: {},
|
||||
|
||||
@@ -73,13 +73,10 @@ export default function ProviderForm({
|
||||
>([]);
|
||||
|
||||
useEffect(() => {
|
||||
async function init() {
|
||||
await loadRequesters();
|
||||
if (providerId) {
|
||||
await loadProvider(providerId);
|
||||
}
|
||||
loadRequesters();
|
||||
if (providerId) {
|
||||
loadProvider(providerId);
|
||||
}
|
||||
init();
|
||||
}, [providerId]);
|
||||
|
||||
async function loadRequesters() {
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
Wrench,
|
||||
Check,
|
||||
RefreshCw,
|
||||
Search,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
@@ -32,8 +33,6 @@ import ExtraArgsEditor from './ExtraArgsEditor';
|
||||
|
||||
interface AddModelPopoverProps {
|
||||
isOpen: boolean;
|
||||
initialMode?: 'manual' | 'scan';
|
||||
trigger?: React.ReactNode;
|
||||
onOpen: () => void;
|
||||
onClose: () => void;
|
||||
onAddModel: (
|
||||
@@ -42,7 +41,7 @@ interface AddModelPopoverProps {
|
||||
abilities: string[],
|
||||
extraArgs: ExtraArg[],
|
||||
) => Promise<void>;
|
||||
onScanModels: (modelType?: ModelType) => Promise<ScanModelsResult>;
|
||||
onScanModels: (modelType: ModelType) => Promise<ScanModelsResult>;
|
||||
onAddScannedModels: (
|
||||
modelType: ModelType,
|
||||
models: SelectedScannedModel[],
|
||||
@@ -61,8 +60,6 @@ interface AddModelPopoverProps {
|
||||
|
||||
export default function AddModelPopover({
|
||||
isOpen,
|
||||
initialMode = 'manual',
|
||||
trigger,
|
||||
onOpen,
|
||||
onClose,
|
||||
onAddModel,
|
||||
@@ -95,7 +92,7 @@ export default function AddModelPopover({
|
||||
const wasOpen = prevIsOpenRef.current;
|
||||
if (isOpen && !wasOpen) {
|
||||
setTab('llm');
|
||||
setMode(initialMode);
|
||||
setMode('manual');
|
||||
setName('');
|
||||
setAbilities([]);
|
||||
setExtraArgs([]);
|
||||
@@ -104,12 +101,8 @@ export default function AddModelPopover({
|
||||
setSelectedScannedModels({});
|
||||
setScanQuery('');
|
||||
onResetTestResult();
|
||||
if (initialMode === 'scan') {
|
||||
handleScan();
|
||||
}
|
||||
}
|
||||
prevIsOpenRef.current = isOpen;
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [isOpen, onResetTestResult]);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -129,8 +122,9 @@ export default function AddModelPopover({
|
||||
const handleScan = async () => {
|
||||
setScanLoading(true);
|
||||
try {
|
||||
const result = await onScanModels(trigger ? undefined : tab);
|
||||
const result = await onScanModels(tab);
|
||||
|
||||
// Enrich abilities from debug.response.data (e.g. features.tools.function_calling)
|
||||
const debugData = (
|
||||
result.debug?.response as { data?: Record<string, unknown>[] }
|
||||
)?.data;
|
||||
@@ -149,9 +143,9 @@ export default function AddModelPopover({
|
||||
| undefined;
|
||||
const tools = features?.tools as Record<string, unknown> | undefined;
|
||||
if (tools?.function_calling === true) {
|
||||
const nextAbilities = new Set(model.abilities || []);
|
||||
nextAbilities.add('func_call');
|
||||
model.abilities = [...nextAbilities];
|
||||
const abilities = new Set(model.abilities || []);
|
||||
abilities.add('func_call');
|
||||
model.abilities = [...abilities];
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -253,321 +247,305 @@ export default function AddModelPopover({
|
||||
onOpenChange={(open) => (open ? onOpen() : onClose())}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
{trigger || (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 text-xs"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Plus className="h-3 w-3 mr-1" />
|
||||
{t('models.addModel')}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 text-xs"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Plus className="h-3 w-3 mr-1" />
|
||||
{t('models.addModel')}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-[min(24rem,calc(100vw-2rem))] max-h-[calc(100vh-8rem)] flex flex-col overflow-hidden"
|
||||
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"
|
||||
style={{
|
||||
maxHeight: 'min(70vh, var(--radix-popover-content-available-height))',
|
||||
}}
|
||||
align="end"
|
||||
side="bottom"
|
||||
side="left"
|
||||
sideOffset={8}
|
||||
collisionPadding={16}
|
||||
onWheel={(e) => e.stopPropagation()}
|
||||
onTouchMove={(e) => e.stopPropagation()}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Tabs
|
||||
value={tab}
|
||||
onValueChange={(v) => setTab(v as ModelType)}
|
||||
className="flex flex-col min-h-0 flex-1"
|
||||
>
|
||||
<div className="flex-shrink-0">
|
||||
{!(trigger && initialMode === 'scan') && (
|
||||
<TabsList className="grid w-full grid-cols-3">
|
||||
<TabsTrigger value="llm">
|
||||
<MessageSquareText className="h-4 w-4 mr-1" />
|
||||
{t('models.chat')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="embedding">
|
||||
<Cpu className="h-4 w-4 mr-1" />
|
||||
{t('models.embedding')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="rerank">
|
||||
<ArrowUpDown className="h-4 w-4 mr-1" />
|
||||
{t('models.rerank')}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
)}
|
||||
</div>
|
||||
<Tabs value={tab} onValueChange={(v) => setTab(v as ModelType)}>
|
||||
<TabsList className="grid w-full grid-cols-3">
|
||||
<TabsTrigger value="llm">
|
||||
<MessageSquareText className="h-4 w-4 mr-1" />
|
||||
{t('models.chat')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="embedding">
|
||||
<Cpu className="h-4 w-4 mr-1" />
|
||||
{t('models.embedding')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="rerank">
|
||||
<ArrowUpDown className="h-4 w-4 mr-1" />
|
||||
{t('models.rerank')}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<div className="overflow-y-auto flex-1 min-h-0">
|
||||
<Tabs
|
||||
value={mode}
|
||||
onValueChange={(v) => setMode(v as 'manual' | 'scan')}
|
||||
>
|
||||
{!trigger && (
|
||||
<TabsList className="grid w-full grid-cols-2 mt-3">
|
||||
<TabsTrigger value="manual">
|
||||
{t('models.manualAdd')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="scan">{t('models.scanAdd')}</TabsTrigger>
|
||||
</TabsList>
|
||||
)}
|
||||
<Tabs
|
||||
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>
|
||||
<TabsTrigger value="scan">{t('models.scanAdd')}</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="manual" className="mt-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">
|
||||
<Label>{t('models.abilities')}</Label>
|
||||
<div className="flex gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="add-vision"
|
||||
checked={abilities.includes('vision')}
|
||||
onCheckedChange={(checked) =>
|
||||
toggleAbility('vision', checked as boolean)
|
||||
}
|
||||
/>
|
||||
<Label htmlFor="add-vision" 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="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>
|
||||
)}
|
||||
|
||||
<ExtraArgsEditor
|
||||
args={extraArgs}
|
||||
onChange={setExtraArgs}
|
||||
modelType={tab}
|
||||
<TabsContent value="manual" className="mt-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 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>
|
||||
</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>
|
||||
);
|
||||
})
|
||||
)}
|
||||
{tab === 'llm' && (
|
||||
<div className="space-y-2">
|
||||
<Label>{t('models.abilities')}</Label>
|
||||
<div className="flex gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
id="add-vision"
|
||||
checked={abilities.includes('vision')}
|
||||
onCheckedChange={(checked) =>
|
||||
toggleAbility('vision', checked as boolean)
|
||||
}
|
||||
/>
|
||||
<Label htmlFor="add-vision" 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="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>
|
||||
)}
|
||||
|
||||
<ExtraArgsEditor
|
||||
args={extraArgs}
|
||||
onChange={setExtraArgs}
|
||||
modelType={tab}
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
className="flex-1"
|
||||
size="sm"
|
||||
onClick={handleAddScanned}
|
||||
disabled={
|
||||
isSubmitting ||
|
||||
scanLoading ||
|
||||
Object.keys(selectedScannedModels).length === 0
|
||||
}
|
||||
onClick={handleAdd}
|
||||
disabled={isSubmitting || isTesting}
|
||||
>
|
||||
{isSubmitting
|
||||
? t('common.saving')
|
||||
: t('models.addSelectedModels')}
|
||||
{isSubmitting ? t('common.saving') : t('common.add')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
size="sm"
|
||||
onClick={handleScan}
|
||||
disabled={scanLoading || isSubmitting}
|
||||
variant="outline"
|
||||
onClick={handleTest}
|
||||
disabled={isSubmitting || isTesting}
|
||||
>
|
||||
<RefreshCw
|
||||
className={`h-3.5 w-3.5 ${scanLoading ? 'animate-spin' : ''}`}
|
||||
/>
|
||||
{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>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<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>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
Trash2,
|
||||
Settings,
|
||||
LogIn,
|
||||
Radar,
|
||||
} from 'lucide-react';
|
||||
import { httpClient, systemInfo } from '@/app/infra/http/HttpClient';
|
||||
import { ModelProvider } from '@/app/infra/entities/api';
|
||||
@@ -61,7 +60,7 @@ interface ProviderCardProps {
|
||||
abilities: string[],
|
||||
extraArgs: ExtraArg[],
|
||||
) => Promise<void>;
|
||||
onScanModels: (modelType?: ModelType) => Promise<ScanModelsResult>;
|
||||
onScanModels: (modelType: ModelType) => Promise<ScanModelsResult>;
|
||||
onAddScannedModels: (
|
||||
modelType: ModelType,
|
||||
models: SelectedScannedModel[],
|
||||
@@ -131,7 +130,6 @@ export default function ProviderCard({
|
||||
const { t } = useTranslation();
|
||||
const [deleteProviderConfirmOpen, setDeleteProviderConfirmOpen] =
|
||||
useState(false);
|
||||
const [addModelMode, setAddModelMode] = useState<'manual' | 'scan'>('manual');
|
||||
|
||||
const canDelete =
|
||||
!isLangBotModels &&
|
||||
@@ -312,75 +310,19 @@ export default function ProviderCard({
|
||||
<div />
|
||||
)}
|
||||
{!isLangBotModels && (
|
||||
<div className="flex items-center gap-1">
|
||||
<AddModelPopover
|
||||
isOpen={
|
||||
addModelPopoverOpen === provider.uuid &&
|
||||
addModelMode === 'manual'
|
||||
}
|
||||
initialMode="manual"
|
||||
trigger={
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
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>
|
||||
<AddModelPopover
|
||||
isOpen={addModelPopoverOpen === provider.uuid}
|
||||
onOpen={onOpenAddModel}
|
||||
onClose={onCloseAddModel}
|
||||
onAddModel={onAddModel}
|
||||
onScanModels={onScanModels}
|
||||
onAddScannedModels={onAddScannedModels}
|
||||
onTestModel={onTestModel}
|
||||
isSubmitting={isSubmitting}
|
||||
isTesting={isTesting}
|
||||
testResult={testResult}
|
||||
onResetTestResult={onResetTestResult}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
|
||||
@@ -90,7 +90,7 @@ export interface ProviderCardProps {
|
||||
abilities: string[],
|
||||
extraArgs: ExtraArg[],
|
||||
) => Promise<void>;
|
||||
onScanModels: (modelType?: ModelType) => Promise<ScanModelsResult>;
|
||||
onScanModels: (modelType: ModelType) => Promise<ScanModelsResult>;
|
||||
onAddScannedModels: (
|
||||
modelType: ModelType,
|
||||
models: SelectedScannedModel[],
|
||||
|
||||
@@ -4,16 +4,11 @@ import {
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Loader2,
|
||||
RefreshCw,
|
||||
RotateCw,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
} from 'lucide-react';
|
||||
import { Loader2, RefreshCw, CheckCircle2, XCircle } from 'lucide-react';
|
||||
import QRCode from 'qrcode';
|
||||
|
||||
export type QrLoginPlatform = 'feishu' | 'weixin' | 'dingtalk' | 'wecombot';
|
||||
@@ -101,7 +96,7 @@ interface QrCodeLoginDialogProps {
|
||||
onSuccess: (credentials: Record<string, string>) => void;
|
||||
}
|
||||
|
||||
type DialogState = 'connecting' | 'waiting' | 'expired' | 'success' | 'error';
|
||||
type DialogState = 'connecting' | 'waiting' | 'success' | 'error';
|
||||
|
||||
const POLL_INTERVAL_MS = 3000;
|
||||
|
||||
@@ -120,10 +115,8 @@ export default function QrCodeLoginDialog({
|
||||
const [errorMessage, setErrorMessage] = useState('');
|
||||
const pollTimerRef = 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 sessionIdRef = useRef<string | null>(null);
|
||||
const baseUrlRef = useRef('');
|
||||
const cleanedRef = useRef(false);
|
||||
|
||||
const onSuccessRef = useRef(onSuccess);
|
||||
@@ -147,14 +140,11 @@ export default function QrCodeLoginDialog({
|
||||
clearInterval(countdownRef.current);
|
||||
countdownRef.current = null;
|
||||
}
|
||||
if (checkExpiredRef.current) {
|
||||
clearInterval(checkExpiredRef.current);
|
||||
checkExpiredRef.current = null;
|
||||
}
|
||||
if (abortRef.current) {
|
||||
abortRef.current.abort();
|
||||
abortRef.current = null;
|
||||
}
|
||||
// Cancel backend session
|
||||
if (sessionIdRef.current) {
|
||||
const token = localStorage.getItem('token');
|
||||
const baseUrl =
|
||||
@@ -181,7 +171,6 @@ export default function QrCodeLoginDialog({
|
||||
|
||||
const token = localStorage.getItem('token');
|
||||
const baseUrl = import.meta.env.VITE_API_BASE_URL || window.location.origin;
|
||||
baseUrlRef.current = baseUrl;
|
||||
const cfg = platformConfigRef.current;
|
||||
|
||||
try {
|
||||
@@ -202,6 +191,8 @@ export default function QrCodeLoginDialog({
|
||||
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) {
|
||||
@@ -213,9 +204,11 @@ export default function QrCodeLoginDialog({
|
||||
}
|
||||
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) {
|
||||
@@ -229,35 +222,7 @@ export default function QrCodeLoginDialog({
|
||||
});
|
||||
}, 1000);
|
||||
|
||||
// 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);
|
||||
|
||||
// Start polling
|
||||
pollTimerRef.current = setInterval(async () => {
|
||||
try {
|
||||
const pollRes = await fetch(
|
||||
@@ -272,7 +237,7 @@ export default function QrCodeLoginDialog({
|
||||
const { status, error, ...rest } = pollJson.data;
|
||||
|
||||
if (status === 'success') {
|
||||
sessionIdRef.current = null;
|
||||
sessionIdRef.current = null; // backend already cleaned up
|
||||
cleanup();
|
||||
setState('success');
|
||||
setTimeout(() => {
|
||||
@@ -284,14 +249,9 @@ export default function QrCodeLoginDialog({
|
||||
cleanup();
|
||||
setState('error');
|
||||
setErrorMessage(error || tRef.current(cfg.failedKey));
|
||||
} else if (status === 'expired') {
|
||||
sessionIdRef.current = null;
|
||||
cleanup();
|
||||
setExpireIn(0);
|
||||
setState('expired');
|
||||
}
|
||||
} catch {
|
||||
// ignore poll errors
|
||||
// ignore poll errors, will retry next interval
|
||||
}
|
||||
}, POLL_INTERVAL_MS);
|
||||
} catch (err: unknown) {
|
||||
@@ -363,31 +323,6 @@ export default function QrCodeLoginDialog({
|
||||
</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 */}
|
||||
{state === 'success' && (
|
||||
<div className="flex flex-col items-center space-y-3 py-8">
|
||||
@@ -415,7 +350,7 @@ export default function QrCodeLoginDialog({
|
||||
</div>
|
||||
|
||||
{state === 'error' && (
|
||||
<div className="flex justify-end gap-2">
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => handleOpenChange(false)}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
@@ -423,7 +358,7 @@ export default function QrCodeLoginDialog({
|
||||
<RefreshCw className="h-4 w-4 mr-1.5" />
|
||||
{t(platformConfig.retryKey)}
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
Reference in New Issue
Block a user