Compare commits

...

12 Commits

Author SHA1 Message Date
RockChinQ
4054ba2a76 docs(issue-template): add deployment version selector 2026-06-01 23:31:29 -04:00
Dongchuan Fu
c7cb42bd79 feat(lark): add domain configuration options for Lark adapter (#2220) 2026-05-27 15:34:35 +08:00
Dongchuan Fu
894709d577 feat(qrcode-login): enhance WeChat login flow with expiration handlin… (#2212)
* feat(qrcode-login): enhance WeChat login flow with expiration handling and improved session management

* feat(qrcode-login): replace RefreshCw icon with RotateCw for loading state

* feat(qrcode-login): adjust session expiration handling and improve error status management
2026-05-21 14:28:02 +08:00
Rock Chin
6823069103 style(web): format AddModelPopover state initialization 2026-05-20 21:49:16 +08:00
Junyan Qin
699545a196 fix(web): fix models dialog provider type select and split add/scan popovers
1. Fix provider type select showing blank when editing: await
   loadRequesters() before loadProvider() to ensure options are
   populated before setting the selected value.

2. Split 'Add Model' into two separate entries: a '+ Add Model'
   button for manual add and a Radar icon button for scan. Each
   opens its own popover with only one layer of tabs (model type
   for manual, no tabs for scan since types are auto-detected).

3. Fix popover position: side='bottom' instead of 'left'.

4. Fix popover scroll: model type tabs stay fixed at top, content
   area scrolls independently when it overflows.

5. Scan mode now fetches all model types at once (no modelType
   filter), and routes each scanned model to the correct API
   based on its own type field.
2026-05-20 18:21:40 +08:00
Sebastion
f0061817ea fix: remove /debug/exec endpoint that allows authenticated RCE via exec() (#2178)
The /api/v1/system/debug/exec endpoint passes user-supplied HTTP body
directly to Python exec(), enabling arbitrary code execution for any
authenticated user when debug_mode is enabled. This is a critical
security risk (CWE-94): a single misconfiguration or compromised JWT
grants full server-side code execution.

Remove the endpoint entirely. The /debug/plugin/action endpoint (which
does not use exec()) is left intact as it serves a different, scoped
purpose.

Co-authored-by: Junyan Chin <rockchinq@gmail.com>
2026-05-19 00:53:39 +08:00
sheetung
688202e7d1 Merge pull request #2211 from sheetung/feat/aiocqhttp-json-msg
feat(aiocqhttp): handle json type messages in message converter
2026-05-18 15:35:49 +08:00
sheetung
d46b762d03 ci: trigger re-run 2026-05-18 07:32:49 +00:00
sheetung
0963fd5443 feat(aiocqhttp): unify json card message parsing with standard field extraction
Unify JSON card message parsing across mini-program, music, and article/video
types. Extract app, preview, title, and url fields using the standard QQ JSON
card structure (meta.detail_1 / music / news) instead of app-name hardcoding.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 07:22:14 +00:00
RockChinQ
6471770737 docs: add practical guide links to localized readmes 2026-05-18 13:18:27 +08:00
RockChinQ
314b7d15bb docs: link practical guides in readme 2026-05-18 13:16:07 +08:00
sheetung
c758908745 feat(aiocqhttp): handle json type messages in message converter
Add support for parsing OneBot JSON message segments (QQ mini-program,
Bilibili share cards, etc.) in the target2yiri converter. Parses the
card metadata and converts it to plain text to avoid silently dropping
these message types.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 04:58:48 +00:00
23 changed files with 699 additions and 385 deletions

View File

@@ -10,6 +10,15 @@ 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: 异常情况

View File

@@ -10,6 +10,15 @@ 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

View File

@@ -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)
📍 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

View File

@@ -47,6 +47,8 @@ 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/)。
---
## 快速开始

View File

@@ -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)
📍 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

View File

@@ -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)
📍 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

View File

@@ -46,6 +46,8 @@ 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/)。
---
## クイックスタート

View File

@@ -46,6 +46,8 @@ 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/).
---
## 빠른 시작

View File

@@ -46,6 +46,8 @@ 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/).
---
## Быстрый старт

View File

@@ -48,6 +48,8 @@ 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/)。
---
## 快速開始

View File

@@ -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)
📍 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

View File

@@ -179,8 +179,6 @@ 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
@@ -208,60 +206,32 @@ class AdaptersRouterGroup(group.RouterGroup):
async def run_login():
try:
import qrcode as qr_lib
for _attempt in range(3):
qr_resp = await client.fetch_qrcode()
if not qr_resp.qrcode or not qr_resp.qrcode_img_content:
raise Exception('Failed to get QR code from server')
# Generate QR code image locally
qr = qr_lib.QRCode(error_correction=qr_lib.constants.ERROR_CORRECT_L)
qr.add_data(qr_resp.qrcode_img_content)
qr.make(fit=True)
img = qr.make_image(fill_color='black', back_color='white')
buf = io.BytesIO()
img.save(buf, format='PNG')
b64 = base64.b64encode(buf.getvalue()).decode('utf-8')
data_url = f'data:image/png;base64,{b64}'
def _update_qr():
session['qr_data_url'] = data_url
session['expire_at'] = time.time() + 480 # 8 minutes
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
session['status'] = 'waiting'
loop.call_soon_threadsafe(_update_qr)
# Poll for scan status
deadline = loop.time() + 180
while loop.time() < deadline:
try:
status_resp = await client.poll_qrcode_status(qr_resp.qrcode)
except Exception:
await asyncio.sleep(2)
continue
if status_resp.status == 'confirmed' and status_resp.bot_token:
session['status'] = 'success'
session['token'] = status_resp.bot_token
session['base_url'] = status_resp.baseurl or client.base_url
session['account_id'] = status_resp.ilink_bot_id or ''
return
if status_resp.status == 'expired':
break # retry with new QR code
await asyncio.sleep(1)
else:
pass # timeout, retry
# All retries exhausted
session['status'] = 'error'
session['error'] = 'QR code login failed: max retries exceeded'
loop.call_soon_threadsafe(_update)
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:
session['status'] = 'error'
session['error'] = str(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
finally:
await client.close()
@@ -295,7 +265,11 @@ class AdaptersRouterGroup(group.RouterGroup):
if not session:
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':
data['token'] = session['token']
@@ -305,6 +279,9 @@ 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)

View File

@@ -140,17 +140,6 @@ 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'],

View File

@@ -3,6 +3,7 @@ import typing
import asyncio
import traceback
import datetime
import json
import aiocqhttp
import pydantic
@@ -293,6 +294,29 @@ 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)

View File

@@ -881,7 +881,8 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
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)
cipher = AESCipher(config.get('encrypt-key', ''))
self.request_app_ticket(api_client, config)
@@ -1014,13 +1015,28 @@ 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']
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'):
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

View File

@@ -23,6 +23,57 @@ 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
@@ -140,10 +191,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

View 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!")

View File

@@ -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,19 +319,26 @@ export default function ModelsDialog({
setIsSubmitting(true);
try {
for (const item of models) {
if (modelType === 'llm') {
const effectiveType = item.model.type || modelType;
if (effectiveType === 'llm') {
await httpClient.createProviderLLMModel({
name: item.model.name,
provider_uuid: providerUuid,
abilities: item.abilities,
extra_args: {},
} as never);
} else {
} else if (effectiveType === 'embedding') {
await httpClient.createProviderEmbeddingModel({
name: item.model.name,
provider_uuid: providerUuid,
extra_args: {},
} as never);
} else {
await httpClient.createProviderRerankModel({
name: item.model.name,
provider_uuid: providerUuid,
extra_args: {},
} as never);
}
}
setAddModelPopoverOpen(null);

View File

@@ -73,10 +73,13 @@ export default function ProviderForm({
>([]);
useEffect(() => {
loadRequesters();
if (providerId) {
loadProvider(providerId);
async function init() {
await loadRequesters();
if (providerId) {
await loadProvider(providerId);
}
}
init();
}, [providerId]);
async function loadRequesters() {

View File

@@ -8,7 +8,6 @@ import {
Wrench,
Check,
RefreshCw,
Search,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
@@ -33,6 +32,8 @@ import ExtraArgsEditor from './ExtraArgsEditor';
interface AddModelPopoverProps {
isOpen: boolean;
initialMode?: 'manual' | 'scan';
trigger?: React.ReactNode;
onOpen: () => void;
onClose: () => void;
onAddModel: (
@@ -41,7 +42,7 @@ interface AddModelPopoverProps {
abilities: string[],
extraArgs: ExtraArg[],
) => Promise<void>;
onScanModels: (modelType: ModelType) => Promise<ScanModelsResult>;
onScanModels: (modelType?: ModelType) => Promise<ScanModelsResult>;
onAddScannedModels: (
modelType: ModelType,
models: SelectedScannedModel[],
@@ -60,6 +61,8 @@ interface AddModelPopoverProps {
export default function AddModelPopover({
isOpen,
initialMode = 'manual',
trigger,
onOpen,
onClose,
onAddModel,
@@ -92,7 +95,7 @@ export default function AddModelPopover({
const wasOpen = prevIsOpenRef.current;
if (isOpen && !wasOpen) {
setTab('llm');
setMode('manual');
setMode(initialMode);
setName('');
setAbilities([]);
setExtraArgs([]);
@@ -101,8 +104,12 @@ 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(() => {
@@ -122,9 +129,8 @@ export default function AddModelPopover({
const handleScan = async () => {
setScanLoading(true);
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 = (
result.debug?.response as { data?: Record<string, unknown>[] }
)?.data;
@@ -143,9 +149,9 @@ export default function AddModelPopover({
| undefined;
const tools = features?.tools as Record<string, unknown> | undefined;
if (tools?.function_calling === true) {
const abilities = new Set(model.abilities || []);
abilities.add('func_call');
model.abilities = [...abilities];
const nextAbilities = new Set(model.abilities || []);
nextAbilities.add('func_call');
model.abilities = [...nextAbilities];
}
}
}
@@ -247,305 +253,321 @@ export default function AddModelPopover({
onOpenChange={(open) => (open ? onOpen() : onClose())}
>
<PopoverTrigger asChild>
<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>
{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>
)}
</PopoverTrigger>
<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"
style={{
maxHeight: 'min(70vh, var(--radix-popover-content-available-height))',
}}
className="w-[min(24rem,calc(100vw-2rem))] max-h-[calc(100vh-8rem)] flex flex-col overflow-hidden"
align="end"
side="left"
side="bottom"
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)}>
<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>
<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={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>
<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>
)}
<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' && (
<TabsContent value="manual" className="mt-3">
<div className="space-y-3">
<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>
<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}
/>
<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>
);
})
)}
</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}
onClick={handleAddScanned}
disabled={
isSubmitting ||
scanLoading ||
Object.keys(selectedScannedModels).length === 0
}
>
{isSubmitting ? t('common.saving') : t('common.add')}
{isSubmitting
? t('common.saving')
: t('models.addSelectedModels')}
</Button>
<Button
className="flex-1"
size="sm"
variant="outline"
onClick={handleTest}
disabled={isSubmitting || isTesting}
size="sm"
onClick={handleScan}
disabled={scanLoading || isSubmitting}
>
{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')
)}
<RefreshCw
className={`h-3.5 w-3.5 ${scanLoading ? 'animate-spin' : ''}`}
/>
</Button>
</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>
</TabsContent>
</Tabs>
</div>
</Tabs>
</PopoverContent>
</Popover>

View File

@@ -6,6 +6,7 @@ import {
Trash2,
Settings,
LogIn,
Radar,
} from 'lucide-react';
import { httpClient, systemInfo } from '@/app/infra/http/HttpClient';
import { ModelProvider } from '@/app/infra/entities/api';
@@ -60,7 +61,7 @@ interface ProviderCardProps {
abilities: string[],
extraArgs: ExtraArg[],
) => Promise<void>;
onScanModels: (modelType: ModelType) => Promise<ScanModelsResult>;
onScanModels: (modelType?: ModelType) => Promise<ScanModelsResult>;
onAddScannedModels: (
modelType: ModelType,
models: SelectedScannedModel[],
@@ -130,6 +131,7 @@ export default function ProviderCard({
const { t } = useTranslation();
const [deleteProviderConfirmOpen, setDeleteProviderConfirmOpen] =
useState(false);
const [addModelMode, setAddModelMode] = useState<'manual' | 'scan'>('manual');
const canDelete =
!isLangBotModels &&
@@ -310,19 +312,75 @@ export default function ProviderCard({
<div />
)}
{!isLangBotModels && (
<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 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>
)}
</div>
</CardHeader>

View File

@@ -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[],

View File

@@ -4,11 +4,16 @@ import {
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { useTranslation } from 'react-i18next';
import { Loader2, RefreshCw, CheckCircle2, XCircle } from 'lucide-react';
import {
Loader2,
RefreshCw,
RotateCw,
CheckCircle2,
XCircle,
} from 'lucide-react';
import QRCode from 'qrcode';
export type QrLoginPlatform = 'feishu' | 'weixin' | 'dingtalk' | 'wecombot';
@@ -96,7 +101,7 @@ interface QrCodeLoginDialogProps {
onSuccess: (credentials: Record<string, string>) => void;
}
type DialogState = 'connecting' | 'waiting' | 'success' | 'error';
type DialogState = 'connecting' | 'waiting' | 'expired' | 'success' | 'error';
const POLL_INTERVAL_MS = 3000;
@@ -115,8 +120,10 @@ 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);
@@ -140,11 +147,14 @@ 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 =
@@ -171,6 +181,7 @@ 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 {
@@ -191,8 +202,6 @@ 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) {
@@ -204,11 +213,9 @@ 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) {
@@ -222,7 +229,35 @@ export default function QrCodeLoginDialog({
});
}, 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 () => {
try {
const pollRes = await fetch(
@@ -237,7 +272,7 @@ export default function QrCodeLoginDialog({
const { status, error, ...rest } = pollJson.data;
if (status === 'success') {
sessionIdRef.current = null; // backend already cleaned up
sessionIdRef.current = null;
cleanup();
setState('success');
setTimeout(() => {
@@ -249,9 +284,14 @@ 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, will retry next interval
// ignore poll errors
}
}, POLL_INTERVAL_MS);
} catch (err: unknown) {
@@ -323,6 +363,31 @@ 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">
@@ -350,7 +415,7 @@ export default function QrCodeLoginDialog({
</div>
{state === 'error' && (
<DialogFooter>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => handleOpenChange(false)}>
{t('common.cancel')}
</Button>
@@ -358,7 +423,7 @@ export default function QrCodeLoginDialog({
<RefreshCw className="h-4 w-4 mr-1.5" />
{t(platformConfig.retryKey)}
</Button>
</DialogFooter>
</div>
)}
</DialogContent>
</Dialog>