mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-07-30 08:06:07 +00:00
Compare commits
37 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1bdf371b5d | |||
| cdfde83e39 | |||
| 4a753d98f5 | |||
| 354aadfce0 | |||
| 4b6931d294 | |||
| 5eb2b90ae6 | |||
| d8c14fc969 | |||
| 6cae954a67 | |||
| 183fb87649 | |||
| 56b3c15213 | |||
| 0cde4c71cd | |||
| 98a633c712 | |||
| 776d48380e | |||
| 5e6b3cc550 | |||
| ea4cd80939 | |||
| ff7fd2f5e0 | |||
| 3b3aa93cf8 | |||
| f658f0ede5 | |||
| 47d75082e8 | |||
| 8849fb172e | |||
| 6818ae2783 | |||
| e7f1059194 | |||
| 537677044e | |||
| 321891441c | |||
| 7f36103aa4 | |||
| b1750882ed | |||
| b55f073e62 | |||
| e6cfee541f | |||
| f10ea82418 | |||
| a32c4d152f | |||
| 83b0d26e99 | |||
| e08b5db625 | |||
| d0f65b17ec | |||
| 2b533c4a00 | |||
| f663d87a60 | |||
| 60e5b873ee | |||
| b96f209b98 |
@@ -37,10 +37,6 @@ jobs:
|
||||
working-directory: web
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Run frontend unit tests
|
||||
working-directory: web
|
||||
run: pnpm test:unit
|
||||
|
||||
- name: Install Playwright browsers
|
||||
working-directory: web
|
||||
run: pnpm exec playwright install --with-deps chromium
|
||||
|
||||
@@ -92,17 +92,6 @@ docker compose --profile all up -d
|
||||
|
||||
---
|
||||
|
||||
## Live Demo
|
||||
|
||||
**Try it now:** https://demo.langbot.dev/
|
||||
|
||||
- Email: `demo@langbot.app`
|
||||
- Password: `langbot123456`
|
||||
|
||||
_Note: Public demo environment. Do not enter sensitive information._
|
||||
|
||||
---
|
||||
|
||||
## Supported Platforms
|
||||
|
||||
| Platform | Status | Notes |
|
||||
@@ -178,6 +167,17 @@ LangBot is **agent-friendly by design** — your coding agents (Claude Code, Cod
|
||||
|
||||
---
|
||||
|
||||
## Live Demo
|
||||
|
||||
**Try it now:** https://demo.langbot.dev/
|
||||
|
||||
- Email: `demo@langbot.app`
|
||||
- Password: `langbot123456`
|
||||
|
||||
_Note: Public demo environment. Do not enter sensitive information._
|
||||
|
||||
---
|
||||
|
||||
## Community
|
||||
|
||||
[](https://discord.gg/wdNEHETs87)
|
||||
@@ -186,6 +186,12 @@ LangBot is **agent-friendly by design** — your coding agents (Claude Code, Cod
|
||||
|
||||
---
|
||||
|
||||
## Star History
|
||||
|
||||
[](https://star-history.com/#langbot-app/LangBot&Date)
|
||||
|
||||
---
|
||||
|
||||
## Contributors
|
||||
|
||||
Thanks to all [contributors](https://github.com/langbot-app/LangBot/graphs/contributors) who have helped make LangBot better:
|
||||
|
||||
+16
-10
@@ -92,16 +92,6 @@ docker compose --profile all up -d
|
||||
|
||||
---
|
||||
|
||||
## 在线演示
|
||||
|
||||
**立即体验:** https://demo.langbot.dev/
|
||||
- 邮箱:`demo@langbot.app`
|
||||
- 密码:`langbot123456`
|
||||
|
||||
*注意:公开演示环境,请不要在其中填入任何敏感信息。*
|
||||
|
||||
---
|
||||
|
||||
## 支持的平台
|
||||
|
||||
| 平台 | 状态 | 备注 |
|
||||
@@ -180,6 +170,16 @@ docker compose --profile all up -d
|
||||
|
||||
---
|
||||
|
||||
## 在线演示
|
||||
|
||||
**立即体验:** https://demo.langbot.dev/
|
||||
- 邮箱:`demo@langbot.app`
|
||||
- 密码:`langbot123456`
|
||||
|
||||
*注意:公开演示环境,请不要在其中填入任何敏感信息。*
|
||||
|
||||
---
|
||||
|
||||
## 为 AI Agent 而生 🤖
|
||||
|
||||
LangBot **从设计上就对 Agent 友好** —— 你的编码 Agent(Claude Code、Codex、Copilot、Cursor 等)可以一等公民般地操作、扩展和部署 LangBot:
|
||||
@@ -203,6 +203,12 @@ LangBot **从设计上就对 Agent 友好** —— 你的编码 Agent(Claude C
|
||||
|
||||
---
|
||||
|
||||
## Star 趋势
|
||||
|
||||
[](https://star-history.com/#langbot-app/LangBot&Date)
|
||||
|
||||
---
|
||||
|
||||
## 贡献者
|
||||
|
||||
感谢所有[贡献者](https://github.com/langbot-app/LangBot/graphs/contributors)对 LangBot 的帮助:
|
||||
|
||||
+14
-10
@@ -91,16 +91,6 @@ docker compose --profile all up -d
|
||||
|
||||
---
|
||||
|
||||
## Demo en Vivo
|
||||
|
||||
**Pruébelo ahora:** https://demo.langbot.dev/
|
||||
- Correo electrónico: `demo@langbot.app`
|
||||
- Contraseña: `langbot123456`
|
||||
|
||||
*Nota: Entorno de demostración público. No ingrese información confidencial.*
|
||||
|
||||
---
|
||||
|
||||
## Plataformas Soportadas
|
||||
|
||||
| Plataforma | Estado | Notas |
|
||||
@@ -163,6 +153,14 @@ docker compose --profile all up -d
|
||||
|
||||
---
|
||||
|
||||
## Demo en Vivo
|
||||
|
||||
**Pruébelo ahora:** https://demo.langbot.dev/
|
||||
- Correo electrónico: `demo@langbot.app`
|
||||
- Contraseña: `langbot123456`
|
||||
|
||||
*Nota: Entorno de demostración público. No ingrese información confidencial.*
|
||||
|
||||
## Diseñado para Agentes de IA 🤖
|
||||
|
||||
LangBot es **agent-friendly por diseño** —— tus agentes de codificación (Claude Code, Codex, Copilot, Cursor, …) pueden operar, extender y desplegar LangBot con soporte de primera clase:
|
||||
@@ -184,6 +182,12 @@ LangBot es **agent-friendly por diseño** —— tus agentes de codificación (C
|
||||
|
||||
---
|
||||
|
||||
## Historial de Stars
|
||||
|
||||
[](https://star-history.com/#langbot-app/LangBot&Date)
|
||||
|
||||
---
|
||||
|
||||
## Colaboradores
|
||||
|
||||
Gracias a todos los [colaboradores](https://github.com/langbot-app/LangBot/graphs/contributors) que han ayudado a mejorar LangBot:
|
||||
|
||||
+14
-10
@@ -91,16 +91,6 @@ docker compose --profile all up -d
|
||||
|
||||
---
|
||||
|
||||
## Démo en Ligne
|
||||
|
||||
**Essayez maintenant :** https://demo.langbot.dev/
|
||||
- Email : `demo@langbot.app`
|
||||
- Mot de passe : `langbot123456`
|
||||
|
||||
*Note : Environnement de démonstration public. Ne saisissez pas d'informations sensibles.*
|
||||
|
||||
---
|
||||
|
||||
## Plateformes Supportées
|
||||
|
||||
| Plateforme | Statut | Notes |
|
||||
@@ -163,6 +153,14 @@ docker compose --profile all up -d
|
||||
|
||||
---
|
||||
|
||||
## Démo en Ligne
|
||||
|
||||
**Essayez maintenant :** https://demo.langbot.dev/
|
||||
- Email : `demo@langbot.app`
|
||||
- Mot de passe : `langbot123456`
|
||||
|
||||
*Note : Environnement de démonstration public. Ne saisissez pas d'informations sensibles.*
|
||||
|
||||
## Conçu pour les agents IA 🤖
|
||||
|
||||
LangBot est **agent-friendly par conception** —— vos agents de codage (Claude Code, Codex, Copilot, Cursor, …) peuvent exploiter, étendre et déployer LangBot avec un support de premier ordre :
|
||||
@@ -184,6 +182,12 @@ LangBot est **agent-friendly par conception** —— vos agents de codage (Claud
|
||||
|
||||
---
|
||||
|
||||
## Historique des Stars
|
||||
|
||||
[](https://star-history.com/#langbot-app/LangBot&Date)
|
||||
|
||||
---
|
||||
|
||||
## Contributeurs
|
||||
|
||||
Merci à tous les [contributeurs](https://github.com/langbot-app/LangBot/graphs/contributors) qui ont aidé à améliorer LangBot :
|
||||
|
||||
+14
-10
@@ -91,16 +91,6 @@ docker compose --profile all up -d
|
||||
|
||||
---
|
||||
|
||||
## ライブデモ
|
||||
|
||||
**今すぐ試す:** https://demo.langbot.dev/
|
||||
- メール: `demo@langbot.app`
|
||||
- パスワード: `langbot123456`
|
||||
|
||||
*注意: 公開デモ環境です。機密情報を入力しないでください。*
|
||||
|
||||
---
|
||||
|
||||
## 対応プラットフォーム
|
||||
|
||||
| プラットフォーム | ステータス | 備考 |
|
||||
@@ -163,6 +153,14 @@ docker compose --profile all up -d
|
||||
|
||||
---
|
||||
|
||||
## ライブデモ
|
||||
|
||||
**今すぐ試す:** https://demo.langbot.dev/
|
||||
- メール: `demo@langbot.app`
|
||||
- パスワード: `langbot123456`
|
||||
|
||||
*注意: 公開デモ環境です。機密情報を入力しないでください。*
|
||||
|
||||
## AI エージェントのために 🤖
|
||||
|
||||
LangBot は **設計段階からエージェントフレンドリー** です。お使いのコーディングエージェント(Claude Code、Codex、Copilot、Cursor など)が、ファーストクラスのサポートで LangBot を操作・拡張・デプロイできます:
|
||||
@@ -184,6 +182,12 @@ LangBot は **設計段階からエージェントフレンドリー** です。
|
||||
|
||||
---
|
||||
|
||||
## Star 推移
|
||||
|
||||
[](https://star-history.com/#langbot-app/LangBot&Date)
|
||||
|
||||
---
|
||||
|
||||
## コントリビューター
|
||||
|
||||
LangBot をより良くするために貢献してくださったすべての[コントリビューター](https://github.com/langbot-app/LangBot/graphs/contributors)に感謝します:
|
||||
|
||||
+14
-10
@@ -91,16 +91,6 @@ docker compose --profile all up -d
|
||||
|
||||
---
|
||||
|
||||
## 라이브 데모
|
||||
|
||||
**지금 체험:** https://demo.langbot.dev/
|
||||
- 이메일: `demo@langbot.app`
|
||||
- 비밀번호: `langbot123456`
|
||||
|
||||
*참고: 공개 데모 환경입니다. 민감한 정보를 입력하지 마세요.*
|
||||
|
||||
---
|
||||
|
||||
## 지원 플랫폼
|
||||
|
||||
| 플랫폼 | 상태 | 비고 |
|
||||
@@ -163,6 +153,14 @@ docker compose --profile all up -d
|
||||
|
||||
---
|
||||
|
||||
## 라이브 데모
|
||||
|
||||
**지금 체험:** https://demo.langbot.dev/
|
||||
- 이메일: `demo@langbot.app`
|
||||
- 비밀번호: `langbot123456`
|
||||
|
||||
*참고: 공개 데모 환경입니다. 민감한 정보를 입력하지 마세요.*
|
||||
|
||||
## AI 에이전트를 위한 설계 🤖
|
||||
|
||||
LangBot은 **설계 단계부터 에이전트 친화적**입니다 —— 코딩 에이전트(Claude Code, Codex, Copilot, Cursor 등)가 일급 지원으로 LangBot을 운영·확장·배포할 수 있습니다:
|
||||
@@ -184,6 +182,12 @@ LangBot은 **설계 단계부터 에이전트 친화적**입니다 —— 코딩
|
||||
|
||||
---
|
||||
|
||||
## Star 추이
|
||||
|
||||
[](https://star-history.com/#langbot-app/LangBot&Date)
|
||||
|
||||
---
|
||||
|
||||
## 기여자
|
||||
|
||||
LangBot을 더 나은 프로젝트로 만들어 주신 모든 [기여자](https://github.com/langbot-app/LangBot/graphs/contributors)분들께 감사드립니다:
|
||||
|
||||
+14
-10
@@ -91,16 +91,6 @@ docker compose --profile all up -d
|
||||
|
||||
---
|
||||
|
||||
## Демо
|
||||
|
||||
**Попробуйте прямо сейчас:** https://demo.langbot.dev/
|
||||
- Email: `demo@langbot.app`
|
||||
- Пароль: `langbot123456`
|
||||
|
||||
*Примечание: Публичная демо-среда. Не вводите конфиденциальную информацию.*
|
||||
|
||||
---
|
||||
|
||||
## Поддерживаемые платформы
|
||||
|
||||
| Платформа | Статус | Примечания |
|
||||
@@ -163,6 +153,14 @@ docker compose --profile all up -d
|
||||
|
||||
---
|
||||
|
||||
## Демо
|
||||
|
||||
**Попробуйте прямо сейчас:** https://demo.langbot.dev/
|
||||
- Email: `demo@langbot.app`
|
||||
- Пароль: `langbot123456`
|
||||
|
||||
*Примечание: Публичная демо-среда. Не вводите конфиденциальную информацию.*
|
||||
|
||||
## Создано для ИИ-агентов 🤖
|
||||
|
||||
LangBot **дружелюбен к агентам по своей архитектуре** —— ваши кодинг-агенты (Claude Code, Codex, Copilot, Cursor и др.) могут управлять, расширять и развёртывать LangBot с первоклассной поддержкой:
|
||||
@@ -184,6 +182,12 @@ LangBot **дружелюбен к агентам по своей архитек
|
||||
|
||||
---
|
||||
|
||||
## История Stars
|
||||
|
||||
[](https://star-history.com/#langbot-app/LangBot&Date)
|
||||
|
||||
---
|
||||
|
||||
## Участники
|
||||
|
||||
Спасибо всем [участникам](https://github.com/langbot-app/LangBot/graphs/contributors), которые помогли сделать LangBot лучше:
|
||||
|
||||
+14
-10
@@ -93,16 +93,6 @@ docker compose --profile all up -d
|
||||
|
||||
---
|
||||
|
||||
## 線上演示
|
||||
|
||||
**立即體驗:** https://demo.langbot.dev/
|
||||
- 信箱:`demo@langbot.app`
|
||||
- 密碼:`langbot123456`
|
||||
|
||||
*注意:公開演示環境,請不要在其中填入任何敏感資訊。*
|
||||
|
||||
---
|
||||
|
||||
## 支援的平台
|
||||
|
||||
| 平台 | 狀態 | 備註 |
|
||||
@@ -179,6 +169,14 @@ docker compose --profile all up -d
|
||||
|
||||
---
|
||||
|
||||
## 線上演示
|
||||
|
||||
**立即體驗:** https://demo.langbot.dev/
|
||||
- 信箱:`demo@langbot.app`
|
||||
- 密碼:`langbot123456`
|
||||
|
||||
*注意:公開演示環境,請不要在其中填入任何敏感資訊。*
|
||||
|
||||
## 為 AI Agent 而生 🤖
|
||||
|
||||
LangBot **從設計上就對 Agent 友善** —— 你的編碼 Agent(Claude Code、Codex、Copilot、Cursor 等)可以一等公民般地操作、擴充和部署 LangBot:
|
||||
@@ -202,6 +200,12 @@ LangBot **從設計上就對 Agent 友善** —— 你的編碼 Agent(Claude C
|
||||
|
||||
---
|
||||
|
||||
## Star 趨勢
|
||||
|
||||
[](https://star-history.com/#langbot-app/LangBot&Date)
|
||||
|
||||
---
|
||||
|
||||
## 貢獻者
|
||||
|
||||
感謝所有[貢獻者](https://github.com/langbot-app/LangBot/graphs/contributors)對 LangBot 的幫助:
|
||||
|
||||
+14
-10
@@ -91,16 +91,6 @@ docker compose --profile all up -d
|
||||
|
||||
---
|
||||
|
||||
## Demo trực tuyến
|
||||
|
||||
**Thử ngay:** https://demo.langbot.dev/
|
||||
- Email: `demo@langbot.app`
|
||||
- Mật khẩu: `langbot123456`
|
||||
|
||||
*Lưu ý: Môi trường demo công khai. Không nhập thông tin nhạy cảm.*
|
||||
|
||||
---
|
||||
|
||||
## Nền tảng được hỗ trợ
|
||||
|
||||
| Nền tảng | Trạng thái | Ghi chú |
|
||||
@@ -163,6 +153,14 @@ docker compose --profile all up -d
|
||||
|
||||
---
|
||||
|
||||
## Demo trực tuyến
|
||||
|
||||
**Thử ngay:** https://demo.langbot.dev/
|
||||
- Email: `demo@langbot.app`
|
||||
- Mật khẩu: `langbot123456`
|
||||
|
||||
*Lưu ý: Môi trường demo công khai. Không nhập thông tin nhạy cảm.*
|
||||
|
||||
## Được xây dựng cho AI Agent 🤖
|
||||
|
||||
LangBot **thân thiện với agent ngay từ thiết kế** —— các coding agent của bạn (Claude Code, Codex, Copilot, Cursor, …) có thể vận hành, mở rộng và triển khai LangBot với sự hỗ trợ hạng nhất:
|
||||
@@ -184,6 +182,12 @@ LangBot **thân thiện với agent ngay từ thiết kế** —— các coding
|
||||
|
||||
---
|
||||
|
||||
## Lịch sử Star
|
||||
|
||||
[](https://star-history.com/#langbot-app/LangBot&Date)
|
||||
|
||||
---
|
||||
|
||||
## Người đóng góp
|
||||
|
||||
Cảm ơn tất cả [người đóng góp](https://github.com/langbot-app/LangBot/graphs/contributors) đã giúp LangBot trở nên tốt hơn:
|
||||
|
||||
+4
-12
@@ -139,8 +139,7 @@ spec:
|
||||
cpu: "1000m"
|
||||
# Liveness probe to restart container if it becomes unresponsive
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /healthz
|
||||
tcpSocket:
|
||||
port: 5400
|
||||
initialDelaySeconds: 30
|
||||
periodSeconds: 10
|
||||
@@ -148,8 +147,7 @@ spec:
|
||||
failureThreshold: 3
|
||||
# Readiness probe to know when container is ready to accept traffic
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /healthz
|
||||
tcpSocket:
|
||||
port: 5400
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 5
|
||||
@@ -267,16 +265,14 @@ spec:
|
||||
memory: "1Gi"
|
||||
cpu: "1000m"
|
||||
livenessProbe:
|
||||
httpGet:
|
||||
path: /healthz
|
||||
tcpSocket:
|
||||
port: 5410
|
||||
initialDelaySeconds: 20
|
||||
periodSeconds: 10
|
||||
timeoutSeconds: 5
|
||||
failureThreshold: 3
|
||||
readinessProbe:
|
||||
httpGet:
|
||||
path: /healthz
|
||||
tcpSocket:
|
||||
port: 5410
|
||||
initialDelaySeconds: 10
|
||||
periodSeconds: 5
|
||||
@@ -323,10 +319,6 @@ metadata:
|
||||
app: langbot
|
||||
spec:
|
||||
replicas: 1
|
||||
# Plugin Runtime has a single active LangBot control owner. Recreate avoids
|
||||
# two LangBot pods fighting over that connection during a rolling update.
|
||||
strategy:
|
||||
type: Recreate
|
||||
selector:
|
||||
matchLabels:
|
||||
app: langbot
|
||||
|
||||
@@ -31,10 +31,8 @@ Both are compatible with LangBot.
|
||||
|
||||
## Installation
|
||||
|
||||
Valkey Search support is included automatically on Linux and macOS. The official `valkey-glide`
|
||||
client does not currently publish a Windows package, so LangBot skips this optional dependency on
|
||||
Windows; LangBot remains usable there, but the Valkey Search backend is unavailable. To install the
|
||||
client manually on a supported platform:
|
||||
Valkey Search support is included when you install LangBot — the `valkey-glide` dependency is
|
||||
declared in `pyproject.toml`. To install manually:
|
||||
|
||||
```bash
|
||||
pip install 'valkey-glide>=2.4.1,<3.0.0'
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 33 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 20 KiB |
+3
-3
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "langbot"
|
||||
version = "4.10.6"
|
||||
version = "4.10.5"
|
||||
description = "Production-grade platform for building agentic IM bots"
|
||||
readme = "README.md"
|
||||
license-files = ["LICENSE"]
|
||||
@@ -70,7 +70,7 @@ dependencies = [
|
||||
"chromadb>=1.0.0,<2.0.0",
|
||||
"qdrant-client (>=1.15.1,<2.0.0)",
|
||||
"pyseekdb==1.1.0.post3",
|
||||
"langbot-plugin==0.4.17",
|
||||
"langbot-plugin==0.4.13",
|
||||
"asyncpg>=0.30.0",
|
||||
"line-bot-sdk>=3.19.0",
|
||||
"matrix-nio>=0.25.2",
|
||||
@@ -80,7 +80,7 @@ dependencies = [
|
||||
"pgvector>=0.4.1",
|
||||
"botocore>=1.42.39",
|
||||
"litellm>=1.0.0",
|
||||
"valkey-glide>=2.4.1,<3.0.0; sys_platform != 'win32'", # No Windows wheels are published
|
||||
"valkey-glide>=2.4.1,<3.0.0",
|
||||
]
|
||||
keywords = [
|
||||
"bot",
|
||||
|
||||
@@ -885,10 +885,7 @@ class DingTalkClient:
|
||||
|
||||
while not self._stopped:
|
||||
try:
|
||||
# open_connection performs blocking network I/O in the DingTalk SDK.
|
||||
# Run it off the event loop so connection stalls do not block the
|
||||
# LangBot HTTP server and other async tasks.
|
||||
connection = await asyncio.to_thread(self.client.open_connection)
|
||||
connection = self.client.open_connection()
|
||||
|
||||
if not connection:
|
||||
if self.logger:
|
||||
|
||||
@@ -21,7 +21,7 @@ import quart
|
||||
|
||||
from ... import group
|
||||
from ......utils import paths
|
||||
from ......platform.sources.websocket_manager import is_valid_session_id, ws_connection_manager
|
||||
from ......platform.sources.websocket_manager import ws_connection_manager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -203,15 +203,11 @@ class EmbedRouterGroup(group.RouterGroup):
|
||||
if session_type not in ['person', 'group']:
|
||||
return self.http_status(400, -1, 'session_type must be person or group')
|
||||
|
||||
session_id = quart.request.args.get('session_id', '')
|
||||
if not is_valid_session_id(session_id):
|
||||
return self.http_status(400, -1, 'Valid session_id is required')
|
||||
|
||||
websocket_adapter = self.ap.platform_mgr.websocket_proxy_bot.adapter
|
||||
if not websocket_adapter:
|
||||
return self.http_status(404, -1, 'WebSocket adapter not found')
|
||||
|
||||
messages = websocket_adapter.get_websocket_messages(pipeline_uuid, session_type, session_id)
|
||||
messages = websocket_adapter.get_websocket_messages(pipeline_uuid, session_type)
|
||||
return self.success(data={'messages': messages})
|
||||
|
||||
except Exception as e:
|
||||
@@ -231,15 +227,11 @@ class EmbedRouterGroup(group.RouterGroup):
|
||||
if session_type not in ['person', 'group']:
|
||||
return self.http_status(400, -1, 'session_type must be person or group')
|
||||
|
||||
session_id = quart.request.args.get('session_id', '')
|
||||
if not is_valid_session_id(session_id):
|
||||
return self.http_status(400, -1, 'Valid session_id is required')
|
||||
|
||||
websocket_adapter = self.ap.platform_mgr.websocket_proxy_bot.adapter
|
||||
if not websocket_adapter:
|
||||
return self.http_status(404, -1, 'WebSocket adapter not found')
|
||||
|
||||
websocket_adapter.reset_session(pipeline_uuid, session_type, session_id)
|
||||
websocket_adapter.reset_session(pipeline_uuid, session_type)
|
||||
return self.success(data={'message': 'Session reset successfully'})
|
||||
|
||||
except Exception as e:
|
||||
@@ -302,11 +294,6 @@ class EmbedRouterGroup(group.RouterGroup):
|
||||
)
|
||||
return
|
||||
|
||||
session_id = quart.websocket.args.get('session_id', '')
|
||||
if not is_valid_session_id(session_id):
|
||||
await quart.websocket.send(json.dumps({'type': 'error', 'message': 'Valid session_id is required'}))
|
||||
return
|
||||
|
||||
websocket_adapter = self.ap.platform_mgr.websocket_proxy_bot.adapter
|
||||
if not websocket_adapter:
|
||||
await quart.websocket.send(json.dumps({'type': 'error', 'message': 'WebSocket adapter not found'}))
|
||||
@@ -317,7 +304,6 @@ class EmbedRouterGroup(group.RouterGroup):
|
||||
websocket=quart.websocket._get_current_object(),
|
||||
pipeline_uuid=pipeline_uuid,
|
||||
session_type=session_type,
|
||||
session_id=session_id,
|
||||
metadata={'user_agent': quart.websocket.headers.get('User-Agent', '')},
|
||||
)
|
||||
|
||||
|
||||
@@ -232,13 +232,8 @@ class ModelProviderService:
|
||||
|
||||
llm_models = await self.ap.llm_model_service.get_llm_models_by_provider(provider_uuid)
|
||||
embedding_models = await self.ap.embedding_models_service.get_embedding_models_by_provider(provider_uuid)
|
||||
rerank_service = getattr(self.ap, 'rerank_models_service', None)
|
||||
rerank_models = (
|
||||
await rerank_service.get_rerank_models_by_provider(provider_uuid) if rerank_service is not None else []
|
||||
)
|
||||
existing_llm_names = {model['name'] for model in llm_models}
|
||||
existing_embedding_names = {model['name'] for model in embedding_models}
|
||||
existing_rerank_names = {model['name'] for model in rerank_models}
|
||||
|
||||
filtered_models = []
|
||||
for model in scanned_models:
|
||||
@@ -265,8 +260,6 @@ class ModelProviderService:
|
||||
'already_added': (
|
||||
model_name in existing_embedding_names
|
||||
if scanned_type == 'embedding'
|
||||
else model_name in existing_rerank_names
|
||||
if scanned_type == 'rerank'
|
||||
else model_name in existing_llm_names
|
||||
),
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
@@ -29,7 +28,6 @@ _DOCKER_BOX_HOST = 'langbot_box'
|
||||
_DEFAULT_PORT = 5410
|
||||
|
||||
_HEARTBEAT_INTERVAL_SEC = 20
|
||||
_HEARTBEAT_FAILURE_THRESHOLD = 3
|
||||
|
||||
# Top-level keys under ``box`` that are LangBot-internal and should not be
|
||||
# forwarded to the Box runtime.
|
||||
@@ -115,8 +113,6 @@ class BoxRuntimeConnector(ManagedRuntimeConnector):
|
||||
self._handler_task: asyncio.Task | None = None
|
||||
self._ctrl_task: asyncio.Task | None = None
|
||||
self._heartbeat_task: asyncio.Task | None = None
|
||||
self._ctrl = None
|
||||
self._generation = 0
|
||||
|
||||
# Parse the relay URL once for reuse.
|
||||
parsed = urlparse(self.ws_relay_base_url)
|
||||
@@ -149,67 +145,29 @@ class BoxRuntimeConnector(ManagedRuntimeConnector):
|
||||
return self.uses_websocket()
|
||||
|
||||
async def initialize(self) -> None:
|
||||
async with self._lifecycle_lock:
|
||||
if self._closing:
|
||||
raise BoxRuntimeUnavailableError('box runtime connector is shutting down')
|
||||
self._generation += 1
|
||||
await self._stop_transport()
|
||||
try:
|
||||
if self._uses_websocket():
|
||||
if platform.get_platform() == 'win32' and not self.configured_runtime_endpoint:
|
||||
await self._start_subprocess_then_ws()
|
||||
else:
|
||||
await self._connect_remote_ws()
|
||||
else:
|
||||
await self._start_local_stdio()
|
||||
except BaseException:
|
||||
await self._stop_transport()
|
||||
await self._close_managed_subprocess()
|
||||
raise
|
||||
if self._uses_websocket():
|
||||
if platform.get_platform() == 'win32' and not self.configured_runtime_endpoint:
|
||||
await self._start_subprocess_then_ws()
|
||||
else:
|
||||
await self._connect_remote_ws()
|
||||
else:
|
||||
await self._start_local_stdio()
|
||||
|
||||
if self._heartbeat_task is None or self._heartbeat_task.done():
|
||||
self._heartbeat_task = asyncio.create_task(self._heartbeat_loop())
|
||||
|
||||
async def reconnect(self) -> None:
|
||||
async with self._lifecycle_lock:
|
||||
if self._closing:
|
||||
raise BoxRuntimeUnavailableError('box runtime connector is shutting down')
|
||||
self._generation += 1
|
||||
await self._stop_transport()
|
||||
try:
|
||||
if self._uses_websocket():
|
||||
if platform.get_platform() == 'win32' and not self.configured_runtime_endpoint:
|
||||
await self._start_subprocess_then_ws()
|
||||
else:
|
||||
await self._connect_remote_ws()
|
||||
else:
|
||||
await self._start_local_stdio()
|
||||
except BaseException:
|
||||
await self._stop_transport()
|
||||
await self._close_managed_subprocess()
|
||||
raise
|
||||
|
||||
if self._heartbeat_task is None or self._heartbeat_task.done():
|
||||
self._heartbeat_task = asyncio.create_task(self._heartbeat_loop())
|
||||
# Start heartbeat after successful connection
|
||||
if self._heartbeat_task is None:
|
||||
self._heartbeat_task = asyncio.create_task(self._heartbeat_loop())
|
||||
|
||||
# -- heartbeat -----------------------------------------------------------
|
||||
|
||||
async def _heartbeat_loop(self) -> None:
|
||||
"""Periodically ping the Box runtime to detect silent disconnections."""
|
||||
failures = 0
|
||||
while not self._closing:
|
||||
while True:
|
||||
await asyncio.sleep(_HEARTBEAT_INTERVAL_SEC)
|
||||
try:
|
||||
await self.ping()
|
||||
failures = 0
|
||||
self.ap.logger.debug('Heartbeat to Box runtime success.')
|
||||
except Exception as e:
|
||||
failures += 1
|
||||
self.ap.logger.warning(f'Box runtime heartbeat failed ({failures}/{_HEARTBEAT_FAILURE_THRESHOLD}): {e}')
|
||||
if failures >= _HEARTBEAT_FAILURE_THRESHOLD:
|
||||
failures = 0
|
||||
if self.runtime_disconnect_callback is not None:
|
||||
await self.runtime_disconnect_callback(self)
|
||||
self.ap.logger.debug(f'Failed to heartbeat to Box runtime: {e}')
|
||||
|
||||
async def ping(self) -> None:
|
||||
if self._handler is None:
|
||||
@@ -238,11 +196,9 @@ class BoxRuntimeConnector(ManagedRuntimeConnector):
|
||||
# mirroring `rt -s`.
|
||||
args=['-m', 'langbot_plugin.cli.__init__', 'box', '-s', '--ws-control-port', str(self._relay_port)],
|
||||
env=env,
|
||||
capture_stderr=False,
|
||||
)
|
||||
self._ctrl = ctrl
|
||||
self._ctrl_task = asyncio.create_task(
|
||||
ctrl.run(self._make_connection_callback('stdio', connected, connect_error, self._generation))
|
||||
ctrl.run(self._make_connection_callback('stdio', connected, connect_error))
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -326,9 +282,8 @@ class BoxRuntimeConnector(ManagedRuntimeConnector):
|
||||
await self.runtime_disconnect_callback(self)
|
||||
|
||||
ctrl = WebSocketClientController(ws_url=ws_url, make_connection_failed_callback=on_connect_failed)
|
||||
self._ctrl = ctrl
|
||||
self._ctrl_task = asyncio.create_task(
|
||||
ctrl.run(self._make_connection_callback(transport_name, connected, connect_error, self._generation))
|
||||
ctrl.run(self._make_connection_callback(transport_name, connected, connect_error))
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -344,29 +299,9 @@ class BoxRuntimeConnector(ManagedRuntimeConnector):
|
||||
transport_name: str,
|
||||
connected: asyncio.Event,
|
||||
connect_error: list[Exception],
|
||||
generation: int,
|
||||
):
|
||||
async def new_connection_callback(connection: Connection) -> None:
|
||||
if generation != self._generation or self._closing:
|
||||
await connection.close()
|
||||
return
|
||||
handler = Handler(connection)
|
||||
connection_ready = False
|
||||
disconnect_notified = False
|
||||
|
||||
async def notify_disconnect() -> None:
|
||||
nonlocal disconnect_notified
|
||||
if (
|
||||
connection_ready
|
||||
and not disconnect_notified
|
||||
and generation == self._generation
|
||||
and not self._closing
|
||||
and self.runtime_disconnect_callback is not None
|
||||
):
|
||||
disconnect_notified = True
|
||||
self.ap.logger.error('Disconnected from Box runtime, trying to reconnect...')
|
||||
await self.runtime_disconnect_callback(self)
|
||||
|
||||
self._handler = handler
|
||||
self.client.set_handler(handler)
|
||||
self._handler_task = asyncio.create_task(handler.run())
|
||||
@@ -376,74 +311,33 @@ class BoxRuntimeConnector(ManagedRuntimeConnector):
|
||||
await handler.call_action(LangBotToBoxAction.INIT, self._filtered_box_config)
|
||||
self.ap.logger.debug('Sent box configuration to Box runtime via INIT.')
|
||||
self.ap.logger.info(f'Connected to Box runtime via {transport_name}.')
|
||||
connection_ready = True
|
||||
connected.set()
|
||||
await self._handler_task
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
if not connected.is_set():
|
||||
connect_error.append(exc)
|
||||
connected.set()
|
||||
return
|
||||
finally:
|
||||
if getattr(self, '_handler', None) is handler:
|
||||
self._handler = None
|
||||
self.client.set_handler(None)
|
||||
await notify_disconnect()
|
||||
|
||||
# If we reach here, handler.run() returned normally (connection
|
||||
# closed) or raised after the initial handshake succeeded.
|
||||
# Either way, treat it as a disconnect.
|
||||
if connected.is_set():
|
||||
if self._uses_websocket():
|
||||
self.ap.logger.error('Disconnected from Box runtime, trying to reconnect...')
|
||||
if self.runtime_disconnect_callback is not None:
|
||||
await self.runtime_disconnect_callback(self)
|
||||
else:
|
||||
self.ap.logger.error(
|
||||
'Disconnected from Box runtime via stdio. '
|
||||
'Cannot automatically reconnect — please restart LangBot.'
|
||||
)
|
||||
|
||||
return new_connection_callback
|
||||
|
||||
# -- lifecycle -----------------------------------------------------------
|
||||
|
||||
async def _stop_transport(self) -> None:
|
||||
if self._handler is not None:
|
||||
with contextlib.suppress(Exception):
|
||||
await self._handler.close()
|
||||
self.client.set_handler(None)
|
||||
tasks = [
|
||||
task
|
||||
for task in (self._handler_task, self._ctrl_task)
|
||||
if task is not None and task is not asyncio.current_task()
|
||||
]
|
||||
for task in tasks:
|
||||
task.cancel()
|
||||
if tasks:
|
||||
await asyncio.gather(*tasks, return_exceptions=True)
|
||||
close_ctrl = getattr(self._ctrl, 'close', None)
|
||||
if close_ctrl is not None:
|
||||
with contextlib.suppress(Exception):
|
||||
await close_ctrl()
|
||||
self._handler = None
|
||||
self._handler_task = None
|
||||
self._ctrl_task = None
|
||||
self._ctrl = None
|
||||
|
||||
async def aclose(self) -> None:
|
||||
self._closing = True
|
||||
self._generation += 1
|
||||
if self._heartbeat_task is not None:
|
||||
self._heartbeat_task.cancel()
|
||||
await asyncio.gather(self._heartbeat_task, return_exceptions=True)
|
||||
self._heartbeat_task = None
|
||||
await self._stop_transport()
|
||||
|
||||
process = getattr(self, '_subprocess', None)
|
||||
if process is not None and process.returncode is None:
|
||||
with contextlib.suppress(ProcessLookupError):
|
||||
process.terminate()
|
||||
try:
|
||||
await asyncio.wait_for(process.wait(), timeout=3)
|
||||
except asyncio.TimeoutError:
|
||||
with contextlib.suppress(ProcessLookupError):
|
||||
process.kill()
|
||||
await process.wait()
|
||||
self._subprocess = None
|
||||
await self._close_managed_subprocess()
|
||||
|
||||
def dispose(self) -> None:
|
||||
"""Best-effort synchronous compatibility wrapper; prefer ``aclose``."""
|
||||
self._closing = True
|
||||
if self._heartbeat_task is not None:
|
||||
self._heartbeat_task.cancel()
|
||||
self._heartbeat_task = None
|
||||
|
||||
@@ -2,7 +2,6 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import collections
|
||||
import contextlib
|
||||
import datetime as _dt
|
||||
import enum
|
||||
import json
|
||||
@@ -66,8 +65,6 @@ class BoxService:
|
||||
self.workspace_quota_mb = self._load_workspace_quota_mb()
|
||||
self._recent_errors: collections.deque[dict] = collections.deque(maxlen=_MAX_RECENT_ERRORS)
|
||||
self._shutdown_task = None
|
||||
self._reconnect_task: asyncio.Task | None = None
|
||||
self._closing = False
|
||||
self._available = False
|
||||
self._connector_error: str = ''
|
||||
self._reconnecting = False
|
||||
@@ -113,8 +110,6 @@ class BoxService:
|
||||
self.ap.logger.warning(f'LangBot Box runtime unavailable, sandbox features disabled: {exc}')
|
||||
self._available = False
|
||||
self._connector_error = str(exc)
|
||||
if self._runtime_connector is not None:
|
||||
await self._on_runtime_disconnect(self._runtime_connector)
|
||||
|
||||
async def _on_runtime_disconnect(self, connector: BoxRuntimeConnector) -> None:
|
||||
"""Called by the connector when the Box runtime connection drops.
|
||||
@@ -123,34 +118,29 @@ class BoxService:
|
||||
Skipped entirely when Box is disabled by config — that path should
|
||||
never have connected in the first place.
|
||||
"""
|
||||
if not self._enabled or self._closing:
|
||||
if not self._enabled:
|
||||
return
|
||||
if self._reconnect_task is not None and not self._reconnect_task.done():
|
||||
if self._reconnecting:
|
||||
return # Another reconnect loop is already running
|
||||
self._reconnecting = True
|
||||
self._available = False
|
||||
self._connector_error = 'Disconnected from Box runtime'
|
||||
self.ap.logger.warning('Box runtime disconnected, sandbox features temporarily disabled.')
|
||||
self._reconnect_task = asyncio.create_task(self._reconnect_loop(connector))
|
||||
asyncio.create_task(self._reconnect_loop(connector))
|
||||
|
||||
async def _reconnect_loop(self, connector: BoxRuntimeConnector) -> None:
|
||||
"""Retry reconnection with exponential backoff (3s → 60s max)."""
|
||||
delay = 3
|
||||
max_delay = 60
|
||||
try:
|
||||
while not self._closing:
|
||||
while True:
|
||||
self.ap.logger.info(f'Attempting to reconnect to Box runtime in {delay}s...')
|
||||
await asyncio.sleep(delay)
|
||||
try:
|
||||
await connector.reconnect()
|
||||
self._ensure_default_workspace()
|
||||
await self._purge_attachment_dirs()
|
||||
connector.dispose()
|
||||
await connector.initialize()
|
||||
self._available = True
|
||||
self._connector_error = ''
|
||||
skill_mgr = getattr(self.ap, 'skill_mgr', None)
|
||||
reload_skills = getattr(skill_mgr, 'reload_skills', None)
|
||||
if callable(reload_skills):
|
||||
await reload_skills()
|
||||
self.ap.logger.info('Box runtime reconnected, sandbox features restored.')
|
||||
return
|
||||
except Exception as exc:
|
||||
@@ -159,7 +149,6 @@ class BoxService:
|
||||
delay = min(delay * 2, max_delay)
|
||||
finally:
|
||||
self._reconnecting = False
|
||||
self._reconnect_task = None
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
@@ -849,28 +838,14 @@ class BoxService:
|
||||
return attachments
|
||||
|
||||
async def shutdown(self):
|
||||
if self._closing:
|
||||
return
|
||||
self._closing = True
|
||||
self._available = False
|
||||
reconnect_task = self._reconnect_task
|
||||
self._reconnect_task = None
|
||||
if reconnect_task is not None and reconnect_task is not asyncio.current_task():
|
||||
reconnect_task.cancel()
|
||||
await asyncio.gather(reconnect_task, return_exceptions=True)
|
||||
# The runtime may already be offline. A failed best-effort SHUTDOWN RPC
|
||||
# must not prevent us from cancelling transports and reaping children.
|
||||
with contextlib.suppress(Exception):
|
||||
await self.client.shutdown()
|
||||
if self._runtime_connector is not None:
|
||||
await self._runtime_connector.aclose()
|
||||
await self.client.shutdown()
|
||||
|
||||
def dispose(self):
|
||||
if self._runtime_connector is not None:
|
||||
self._runtime_connector.dispose()
|
||||
loop = getattr(self.ap, 'event_loop', None)
|
||||
if loop is not None and not loop.is_closed() and (self._shutdown_task is None or self._shutdown_task.done()):
|
||||
self._shutdown_task = loop.create_task(self.shutdown())
|
||||
elif self._runtime_connector is not None:
|
||||
self._runtime_connector.dispose()
|
||||
|
||||
async def get_sessions(self) -> list[dict]:
|
||||
if not self._available:
|
||||
|
||||
@@ -4,7 +4,6 @@ import logging
|
||||
import asyncio
|
||||
import traceback
|
||||
import os
|
||||
import contextlib
|
||||
|
||||
from ..platform import botmgr as im_mgr
|
||||
from ..platform.webhook_pusher import WebhookPusher
|
||||
@@ -167,8 +166,7 @@ class Application:
|
||||
maintenance_service: maintenance_service.MaintenanceService = None
|
||||
|
||||
def __init__(self):
|
||||
self._shutdown_lock = asyncio.Lock()
|
||||
self._shutdown_complete = False
|
||||
pass
|
||||
|
||||
async def initialize(self):
|
||||
pass
|
||||
@@ -320,39 +318,7 @@ class Application:
|
||||
return default
|
||||
return parsed
|
||||
|
||||
async def shutdown(self):
|
||||
"""Stop application work and deterministically release runtime resources."""
|
||||
async with self._shutdown_lock:
|
||||
if self._shutdown_complete:
|
||||
return
|
||||
|
||||
if self.task_mgr is not None:
|
||||
self.task_mgr.cancel_by_scope(core_entities.LifecycleControlScope.APPLICATION)
|
||||
if self.platform_mgr is not None:
|
||||
with contextlib.suppress(Exception):
|
||||
await self.platform_mgr.shutdown()
|
||||
if self.tool_mgr is not None:
|
||||
with contextlib.suppress(Exception):
|
||||
await self.tool_mgr.shutdown()
|
||||
if self.box_service is not None:
|
||||
with contextlib.suppress(Exception):
|
||||
await self.box_service.shutdown()
|
||||
if self.plugin_connector is not None:
|
||||
with contextlib.suppress(Exception):
|
||||
await self.plugin_connector.aclose()
|
||||
|
||||
if self.task_mgr is not None:
|
||||
tasks = [wrapper.task for wrapper in self.task_mgr.tasks if not wrapper.task.done()]
|
||||
if tasks:
|
||||
await asyncio.gather(*tasks, return_exceptions=True)
|
||||
self._shutdown_complete = True
|
||||
|
||||
def dispose(self):
|
||||
"""Compatibility wrapper for callers that cannot await shutdown."""
|
||||
loop = self.event_loop
|
||||
if loop is not None and not loop.is_closed():
|
||||
loop.create_task(self.shutdown())
|
||||
return
|
||||
if self.plugin_connector is not None:
|
||||
self.plugin_connector.dispose()
|
||||
if self.box_service is not None:
|
||||
|
||||
@@ -46,38 +46,21 @@ async def make_app(loop: asyncio.AbstractEventLoop) -> app.Application:
|
||||
|
||||
async def main(loop: asyncio.AbstractEventLoop):
|
||||
app_inst: app.Application | None = None
|
||||
runtime_loop = asyncio.get_running_loop()
|
||||
shutdown_requested = asyncio.Event()
|
||||
run_task: asyncio.Task | None = None
|
||||
try:
|
||||
# Hang system signal processing
|
||||
import signal
|
||||
|
||||
def signal_handler(sig, frame):
|
||||
if app_inst is not None:
|
||||
app_inst.dispose()
|
||||
print('[Signal] Program exit.')
|
||||
runtime_loop.call_soon_threadsafe(shutdown_requested.set)
|
||||
os._exit(0)
|
||||
|
||||
signal.signal(signal.SIGINT, signal_handler)
|
||||
if hasattr(signal, 'SIGTERM'):
|
||||
signal.signal(signal.SIGTERM, signal_handler)
|
||||
|
||||
app_inst = await make_app(loop)
|
||||
if app_inst is None:
|
||||
return
|
||||
run_task = asyncio.create_task(app_inst.run())
|
||||
shutdown_task = asyncio.create_task(shutdown_requested.wait())
|
||||
done, pending = await asyncio.wait((run_task, shutdown_task), return_when=asyncio.FIRST_COMPLETED)
|
||||
if shutdown_task in done:
|
||||
await app_inst.shutdown()
|
||||
if not run_task.done():
|
||||
run_task.cancel()
|
||||
for task in pending:
|
||||
task.cancel()
|
||||
results = await asyncio.gather(run_task, shutdown_task, return_exceptions=True)
|
||||
run_result = results[0]
|
||||
if isinstance(run_result, BaseException) and not isinstance(run_result, asyncio.CancelledError):
|
||||
raise run_result
|
||||
await app_inst.run()
|
||||
except Exception:
|
||||
traceback.print_exc()
|
||||
finally:
|
||||
if app_inst is not None:
|
||||
await app_inst.shutdown()
|
||||
app_inst.dispose()
|
||||
traceback.print_exc()
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
from .. import stage, app
|
||||
from ...utils import version, proxy
|
||||
from ...pipeline import pool, controller, pipelinemgr
|
||||
@@ -185,16 +187,11 @@ class BuildAppStage(stage.BootingStage):
|
||||
ap.maintenance_service = maintenance_service_inst
|
||||
|
||||
async def runtime_disconnect_callback(connector: plugin_connector.PluginRuntimeConnector) -> None:
|
||||
connector.schedule_reconnect()
|
||||
await asyncio.sleep(3)
|
||||
await plugin_connector_inst.initialize()
|
||||
|
||||
plugin_connector_inst = plugin_connector.PluginRuntimeConnector(ap, runtime_disconnect_callback)
|
||||
try:
|
||||
await plugin_connector_inst.initialize()
|
||||
except Exception as exc:
|
||||
# Keep the API/UI available while an external or managed runtime is
|
||||
# starting, then recover in the background with bounded backoff.
|
||||
ap.logger.warning(f'Plugin runtime unavailable during startup; reconnecting in background: {exc}')
|
||||
plugin_connector_inst.schedule_reconnect()
|
||||
await plugin_connector_inst.initialize()
|
||||
ap.plugin_connector = plugin_connector_inst
|
||||
|
||||
ctrl = controller.Controller(ap)
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
from __future__ import annotations
|
||||
import typing
|
||||
|
||||
import sqlalchemy
|
||||
|
||||
from .. import handler
|
||||
from ... import entities
|
||||
from ... import plugin_diagnostics
|
||||
from ....entity.persistence.bot import BotAdmin
|
||||
import langbot_plugin.api.entities.builtin.provider.message as provider_message
|
||||
import langbot_plugin.api.entities.builtin.provider.session as provider_session
|
||||
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
|
||||
@@ -26,14 +24,7 @@ class CommandHandler(handler.MessageHandler):
|
||||
|
||||
privilege = 1
|
||||
|
||||
admins = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.select(BotAdmin).where(
|
||||
BotAdmin.bot_uuid == (query.bot_uuid or ''),
|
||||
BotAdmin.launcher_type == query.launcher_type.value,
|
||||
BotAdmin.launcher_id == str(query.launcher_id),
|
||||
)
|
||||
)
|
||||
if admins.first() is not None:
|
||||
if f'{query.launcher_type.value}_{query.launcher_id}' in self.ap.instance_config.data['admins']:
|
||||
privilege = 2
|
||||
|
||||
spt = command_text.split(' ')
|
||||
|
||||
@@ -240,11 +240,7 @@ class AiocqhttpMessageConverter(abstract_platform_adapter.AbstractMessageConvert
|
||||
async def process_message_data(msg_data, reply_list):
|
||||
if msg_data['type'] == 'image':
|
||||
image_base64, image_format = await image.qq_image_url_to_base64(msg_data['data']['url'])
|
||||
reply_list.append(
|
||||
platform_message.Image(
|
||||
url=msg_data['data']['url'], base64=f'data:image/{image_format};base64,{image_base64}'
|
||||
)
|
||||
)
|
||||
reply_list.append(platform_message.Image(base64=f'data:image/{image_format};base64,{image_base64}'))
|
||||
|
||||
elif msg_data['type'] == 'text':
|
||||
reply_list.append(platform_message.Plain(text=msg_data['data']['text']))
|
||||
@@ -289,9 +285,7 @@ class AiocqhttpMessageConverter(abstract_platform_adapter.AbstractMessageConvert
|
||||
image_msg = platform_message.Face(face_id=face_id, face_name=face_name)
|
||||
else:
|
||||
image_base64, image_format = await image.qq_image_url_to_base64(msg.data['url'])
|
||||
image_msg = platform_message.Image(
|
||||
url=msg.data['url'], base64=f'data:image/{image_format};base64,{image_base64}'
|
||||
)
|
||||
image_msg = platform_message.Image(base64=f'data:image/{image_format};base64,{image_base64}')
|
||||
yiri_msg_list.append(image_msg)
|
||||
elif msg.type == 'forward':
|
||||
# 暂时不太合理
|
||||
@@ -497,11 +491,7 @@ class AiocqhttpAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
|
||||
message_converter: AiocqhttpMessageConverter = AiocqhttpMessageConverter()
|
||||
event_converter: AiocqhttpEventConverter = pydantic.Field(default_factory=AiocqhttpEventConverter)
|
||||
|
||||
on_websocket_connection_event_cache: list[aiocqhttp.Event] = []
|
||||
_listener_wrappers: dict[
|
||||
tuple[typing.Type[platform_events.Event], typing.Callable],
|
||||
tuple[str, typing.Callable],
|
||||
] = {}
|
||||
on_websocket_connection_event_cache: typing.List[typing.Callable[[aiocqhttp.Event], None]] = []
|
||||
|
||||
def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger):
|
||||
super().__init__(
|
||||
@@ -516,7 +506,6 @@ class AiocqhttpAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
|
||||
self.config['shutdown_trigger'] = shutdown_trigger_placeholder
|
||||
|
||||
self.on_websocket_connection_event_cache = []
|
||||
self._listener_wrappers = {}
|
||||
|
||||
if 'access-token' in config:
|
||||
self.bot = aiocqhttp.CQHttp(access_token=config['access-token'])
|
||||
@@ -524,16 +513,6 @@ class AiocqhttpAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
|
||||
else:
|
||||
self.bot = aiocqhttp.CQHttp()
|
||||
|
||||
self.bot.on_websocket_connection(self._on_websocket_connection)
|
||||
|
||||
async def _on_websocket_connection(self, event: aiocqhttp.Event):
|
||||
for cached_event in self.on_websocket_connection_event_cache:
|
||||
if cached_event.self_id == event.self_id and cached_event.time == event.time:
|
||||
return
|
||||
|
||||
self.on_websocket_connection_event_cache.append(event)
|
||||
await self.logger.info(f'WebSocket connection established, bot id: {event.self_id}')
|
||||
|
||||
async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain):
|
||||
# Check if message contains a Forward component
|
||||
forward_msg = message.get_first(platform_message.Forward)
|
||||
@@ -669,14 +648,22 @@ class AiocqhttpAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
|
||||
|
||||
if event_type == platform_events.GroupMessage:
|
||||
self.bot.on_message('group')(on_message)
|
||||
self._listener_wrappers[(event_type, callback)] = ('message.group', on_message)
|
||||
# self.bot.on_notice()(on_message)
|
||||
elif event_type == platform_events.FriendMessage:
|
||||
self.bot.on_message('private')(on_message)
|
||||
self._listener_wrappers[(event_type, callback)] = ('message.private', on_message)
|
||||
# self.bot.on_notice()(on_message)
|
||||
# print(event_type)
|
||||
|
||||
async def on_websocket_connection(event: aiocqhttp.Event):
|
||||
for event in self.on_websocket_connection_event_cache:
|
||||
if event.self_id == event.self_id and event.time == event.time:
|
||||
return
|
||||
|
||||
self.on_websocket_connection_event_cache.append(event)
|
||||
await self.logger.info(f'WebSocket connection established, bot id: {event.self_id}')
|
||||
|
||||
self.bot.on_websocket_connection(on_websocket_connection)
|
||||
|
||||
def unregister_listener(
|
||||
self,
|
||||
event_type: typing.Type[platform_events.Event],
|
||||
@@ -684,12 +671,7 @@ class AiocqhttpAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
|
||||
[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None
|
||||
],
|
||||
):
|
||||
listener = self._listener_wrappers.pop((event_type, callback), None)
|
||||
if listener is None:
|
||||
return
|
||||
|
||||
event_name, wrapper = listener
|
||||
self.bot._bus.unsubscribe(event_name, wrapper)
|
||||
return super().unregister_listener(event_type, callback)
|
||||
|
||||
async def run_async(self):
|
||||
await self.bot._server_app.run_task(**self.config)
|
||||
|
||||
@@ -783,9 +783,7 @@ class DiscordMessageConverter(abstract_platform_adapter.AbstractMessageConverter
|
||||
image_data = await response.read()
|
||||
image_base64 = base64.b64encode(image_data).decode('utf-8')
|
||||
image_format = response.headers['Content-Type']
|
||||
element_list.append(
|
||||
platform_message.Image(url=attachment.url, base64=f'data:{image_format};base64,{image_base64}')
|
||||
)
|
||||
element_list.append(platform_message.Image(base64=f'data:{image_format};base64,{image_base64}'))
|
||||
|
||||
return platform_message.MessageChain(element_list)
|
||||
|
||||
|
||||
@@ -242,33 +242,6 @@ def _lark_extract_action_form_inputs(action: typing.Any, action_value_obj: dict)
|
||||
return form_inputs
|
||||
|
||||
|
||||
class NonBlockingLarkWSClient(lark_oapi.ws.Client):
|
||||
"""Keep the SDK's synchronous connection lookup off LangBot's event loop.
|
||||
|
||||
lark-oapi performs ``requests.post`` inside its async ``_connect`` method.
|
||||
A stalled TLS handshake therefore freezes Quart and every other adapter in
|
||||
the process. Pre-fetch the URL in a worker thread, then let the SDK finish
|
||||
the WebSocket setup on the main loop with the already-resolved URL.
|
||||
"""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self._langbot_connect_lock = asyncio.Lock()
|
||||
|
||||
async def _connect(self) -> None:
|
||||
async with self._langbot_connect_lock:
|
||||
if self._conn is not None:
|
||||
return
|
||||
|
||||
conn_url = await asyncio.to_thread(self._get_conn_url)
|
||||
original_get_conn_url = self._get_conn_url
|
||||
self._get_conn_url = lambda: conn_url
|
||||
try:
|
||||
await super()._connect()
|
||||
finally:
|
||||
self._get_conn_url = original_get_conn_url
|
||||
|
||||
|
||||
class AESCipher(object):
|
||||
def __init__(self, key):
|
||||
self.bs = AES.block_size
|
||||
@@ -1267,9 +1240,7 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
bot_account_id = config['bot_name']
|
||||
|
||||
domain = self._resolve_domain(config)
|
||||
bot = NonBlockingLarkWSClient(
|
||||
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, domain=domain)
|
||||
api_client = self.build_api_client(config)
|
||||
cipher = AESCipher(config.get('encrypt-key', ''))
|
||||
self.request_app_ticket(api_client, config)
|
||||
@@ -1436,27 +1407,6 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
)
|
||||
return api_client
|
||||
|
||||
@staticmethod
|
||||
def _has_markdown_table(text_elements: list) -> bool:
|
||||
"""Check if text elements contain markdown table syntax (|...|).
|
||||
|
||||
A markdown table requires:
|
||||
- At least one line with pipe characters (|)
|
||||
- A separator line with pipes and dashes (|---|)
|
||||
"""
|
||||
# Regex to detect markdown table: lines with pipes and separator lines with dashes
|
||||
table_pattern = re.compile(r'^\s*\|.+\|\s*$', re.MULTILINE)
|
||||
separator_pattern = re.compile(r'^\s*\|\s*-+(\s*\|\s*-+)*\s*\|\s*$', re.MULTILINE)
|
||||
|
||||
for paragraph in text_elements:
|
||||
for ele in paragraph:
|
||||
if ele.get('tag') == 'md':
|
||||
text = ele.get('text', '')
|
||||
# Quick check: if has pipes and separator line, it's likely a table
|
||||
if '|' in text and table_pattern.search(text) and separator_pattern.search(text):
|
||||
return True
|
||||
return False
|
||||
|
||||
async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain):
|
||||
text_elements, media_items = await self.message_converter.yiri2target(message, self.api_client)
|
||||
|
||||
@@ -1470,10 +1420,7 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
|
||||
# Send text message if there are text elements
|
||||
if text_elements:
|
||||
# Use 'post' format if: has @mentions OR has markdown tables
|
||||
has_at = any(ele['tag'] == 'at' for paragraph in text_elements for ele in paragraph)
|
||||
has_table = self._has_markdown_table(text_elements)
|
||||
needs_post = has_at or has_table
|
||||
needs_post = any(ele['tag'] == 'at' for paragraph in text_elements for ele in paragraph)
|
||||
|
||||
if needs_post:
|
||||
msg_type = 'post'
|
||||
@@ -1967,10 +1914,9 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
|
||||
# Send text message if there are text elements
|
||||
if text_elements:
|
||||
# Use 'post' format if: has @mentions OR has markdown tables
|
||||
has_at = any(ele['tag'] == 'at' for paragraph in text_elements for ele in paragraph)
|
||||
has_table = self._has_markdown_table(text_elements)
|
||||
needs_post = has_at or has_table
|
||||
# Determine msg_type based on content: use 'post' if at mentions
|
||||
# are present (requires post paragraph structure), otherwise 'text'
|
||||
needs_post = any(ele['tag'] == 'at' for paragraph in text_elements for ele in paragraph)
|
||||
|
||||
if needs_post:
|
||||
msg_type = 'post'
|
||||
|
||||
@@ -102,7 +102,7 @@ class QQOfficialMessageConverter(abstract_platform_adapter.AbstractMessageConver
|
||||
yiri_msg_list.append(platform_message.Source(id=message_id, time=datetime.datetime.now()))
|
||||
if pic_url is not None:
|
||||
base64_url = await image.get_qq_official_image_base64(pic_url=pic_url, content_type=content_type)
|
||||
yiri_msg_list.append(platform_message.Image(url=pic_url, base64=base64_url))
|
||||
yiri_msg_list.append(platform_message.Image(base64=base64_url))
|
||||
|
||||
yiri_msg_list.append(platform_message.Plain(text=message))
|
||||
chain = platform_message.MessageChain(yiri_msg_list)
|
||||
|
||||
@@ -47,7 +47,7 @@ class SlackMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
|
||||
yiri_msg_list.append(platform_message.Source(id=message_id, time=datetime.datetime.now()))
|
||||
if pic_url is not None:
|
||||
base64_url = await image.get_slack_image_to_base64(pic_url=pic_url, bot_token=bot.bot_token)
|
||||
yiri_msg_list.append(platform_message.Image(url=pic_url, base64=base64_url))
|
||||
yiri_msg_list.append(platform_message.Image(base64=base64_url))
|
||||
|
||||
yiri_msg_list.append(platform_message.Plain(text=message))
|
||||
chain = platform_message.MessageChain(yiri_msg_list)
|
||||
|
||||
@@ -157,8 +157,7 @@ class TelegramMessageConverter(abstract_platform_adapter.AbstractMessageConverte
|
||||
|
||||
message_components.append(
|
||||
platform_message.Image(
|
||||
url=file.file_path,
|
||||
base64=f'data:{file_format};base64,{base64.b64encode(file_bytes).decode("utf-8")}',
|
||||
base64=f'data:{file_format};base64,{base64.b64encode(file_bytes).decode("utf-8")}'
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@ import langbot_plugin.api.entities.builtin.platform.events as platform_events
|
||||
import langbot_plugin.api.entities.builtin.platform.entities as platform_entities
|
||||
import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_platform_logger
|
||||
from ...core import app
|
||||
from .websocket_manager import WebSocketConnection, is_valid_session_id, ws_connection_manager
|
||||
from .websocket_manager import ws_connection_manager, WebSocketConnection
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -91,59 +91,6 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
|
||||
self.outbound_message_queue = asyncio.Queue()
|
||||
self.stream_enabled = True
|
||||
|
||||
@staticmethod
|
||||
def _conversation_key(pipeline_uuid: str, session_id: str | None = None) -> str:
|
||||
"""Return the history key for a pipeline/client conversation."""
|
||||
return f'{pipeline_uuid}:{session_id}' if session_id else pipeline_uuid
|
||||
|
||||
@staticmethod
|
||||
def _parse_embed_target(target_id: str) -> tuple[str, str] | None:
|
||||
"""Extract pipeline and session identifiers from a stable embed launcher."""
|
||||
target_value = str(target_id)
|
||||
for prefix in ('websocket_', 'websocketgroup_'):
|
||||
if target_value.startswith(prefix):
|
||||
target = target_value[len(prefix) :]
|
||||
break
|
||||
else:
|
||||
return None
|
||||
if ':' not in target:
|
||||
return None
|
||||
pipeline_uuid, session_id = target.rsplit(':', 1)
|
||||
if not pipeline_uuid or not is_valid_session_id(session_id):
|
||||
return None
|
||||
return pipeline_uuid, session_id
|
||||
|
||||
@classmethod
|
||||
async def _get_connection_from_target(cls, target_id: str):
|
||||
"""Resolve a person or group WebSocket launcher to its connection."""
|
||||
target_value = str(target_id)
|
||||
for prefix in ('websocket_', 'websocketgroup_'):
|
||||
if target_value.startswith(prefix):
|
||||
target = target_value[len(prefix) :]
|
||||
break
|
||||
else:
|
||||
return None
|
||||
connection = await ws_connection_manager.get_connection(target)
|
||||
if connection is not None:
|
||||
return connection
|
||||
embed_target = cls._parse_embed_target(target_id)
|
||||
if embed_target is not None:
|
||||
pipeline_uuid, session_id = embed_target
|
||||
return await ws_connection_manager.get_connection_by_session_id(session_id, pipeline_uuid)
|
||||
return await ws_connection_manager.get_connection_by_session_id(target)
|
||||
|
||||
async def _get_message_context(self, message_source) -> tuple[str, str | None]:
|
||||
"""Resolve the originating pipeline and browser session for a reply."""
|
||||
sender = getattr(message_source, 'sender', None)
|
||||
sender_id = getattr(sender, 'id', '')
|
||||
connection = await self._get_connection_from_target(sender_id)
|
||||
if connection is not None:
|
||||
return connection.pipeline_uuid, connection.session_id
|
||||
embed_target = self._parse_embed_target(sender_id)
|
||||
if embed_target is not None:
|
||||
return embed_target
|
||||
return typing.cast(str, self.ap.platform_mgr.websocket_proxy_bot.bot_entity.use_pipeline_uuid), None
|
||||
|
||||
async def send_message(
|
||||
self,
|
||||
target_type: str,
|
||||
@@ -156,26 +103,15 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
|
||||
target_id 可能是 launcher_id(如 websocket_xxx)或 pipeline_uuid。
|
||||
我们需要尝试两种方式来确保消息能够送达。
|
||||
"""
|
||||
connection = await self._get_connection_from_target(target_id)
|
||||
if connection is not None:
|
||||
pipeline_uuid = connection.pipeline_uuid
|
||||
session_id = connection.session_id
|
||||
else:
|
||||
embed_target = self._parse_embed_target(target_id)
|
||||
if embed_target is not None:
|
||||
pipeline_uuid, session_id = embed_target
|
||||
else:
|
||||
pipeline_uuid = typing.cast(
|
||||
str,
|
||||
self.ap.platform_mgr.websocket_proxy_bot.bot_entity.use_pipeline_uuid,
|
||||
)
|
||||
session_id = None
|
||||
# 获取当前的 pipeline_uuid
|
||||
pipeline_uuid = self.ap.platform_mgr.websocket_proxy_bot.bot_entity.use_pipeline_uuid
|
||||
session_type = 'group' if target_type == 'group' else 'person'
|
||||
conversation_key = self._conversation_key(pipeline_uuid, session_id)
|
||||
|
||||
# 选择会话
|
||||
session = self.websocket_group_session if session_type == 'group' else self.websocket_person_session
|
||||
|
||||
msg_id = len(session.get_message_list(conversation_key)) + 1
|
||||
# 生成唯一消息ID
|
||||
msg_id = len(session.get_message_list(pipeline_uuid)) + 1
|
||||
|
||||
message_data = WebSocketMessage(
|
||||
id=msg_id,
|
||||
@@ -186,8 +122,10 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
|
||||
is_final=True,
|
||||
)
|
||||
|
||||
session.get_message_list(conversation_key).append(message_data)
|
||||
# 保存到历史记录
|
||||
session.get_message_list(pipeline_uuid).append(message_data)
|
||||
|
||||
# 直接广播到当前pipeline的连接
|
||||
await ws_connection_manager.broadcast_to_pipeline(
|
||||
pipeline_uuid,
|
||||
{
|
||||
@@ -196,7 +134,6 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
|
||||
'data': message_data.model_dump(),
|
||||
},
|
||||
session_type=session_type,
|
||||
session_id=session_id,
|
||||
)
|
||||
|
||||
return message_data.model_dump()
|
||||
@@ -215,11 +152,12 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
|
||||
else self.websocket_person_session
|
||||
)
|
||||
|
||||
pipeline_uuid, session_id = await self._get_message_context(message_source)
|
||||
# 从message_source获取pipeline_uuid和connection_id
|
||||
pipeline_uuid = self.ap.platform_mgr.websocket_proxy_bot.bot_entity.use_pipeline_uuid
|
||||
session_type = 'group' if isinstance(message_source, platform_events.GroupMessage) else 'person'
|
||||
conversation_key = self._conversation_key(pipeline_uuid, session_id)
|
||||
|
||||
msg_id = len(session.get_message_list(conversation_key)) + 1
|
||||
# 生成新的消息ID
|
||||
msg_id = len(session.get_message_list(pipeline_uuid)) + 1
|
||||
|
||||
message_data = WebSocketMessage(
|
||||
id=msg_id,
|
||||
@@ -230,8 +168,10 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
|
||||
is_final=True,
|
||||
)
|
||||
|
||||
session.get_message_list(conversation_key).append(message_data)
|
||||
# 保存到历史记录
|
||||
session.get_message_list(pipeline_uuid).append(message_data)
|
||||
|
||||
# 直接广播到所有该pipeline的连接,包含session_type信息
|
||||
await ws_connection_manager.broadcast_to_pipeline(
|
||||
pipeline_uuid,
|
||||
{
|
||||
@@ -240,7 +180,6 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
|
||||
'data': message_data.model_dump(),
|
||||
},
|
||||
session_type=session_type,
|
||||
session_id=session_id,
|
||||
)
|
||||
|
||||
return message_data.model_dump()
|
||||
@@ -261,11 +200,10 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
|
||||
else self.websocket_person_session
|
||||
)
|
||||
|
||||
pipeline_uuid, session_id = await self._get_message_context(message_source)
|
||||
pipeline_uuid = self.ap.platform_mgr.websocket_proxy_bot.bot_entity.use_pipeline_uuid
|
||||
session_type = 'group' if isinstance(message_source, platform_events.GroupMessage) else 'person'
|
||||
conversation_key = self._conversation_key(pipeline_uuid, session_id)
|
||||
message_list = session.get_message_list(conversation_key)
|
||||
stream_message_indexes = session.get_stream_message_indexes(conversation_key)
|
||||
message_list = session.get_message_list(pipeline_uuid)
|
||||
stream_message_indexes = session.get_stream_message_indexes(pipeline_uuid)
|
||||
|
||||
# Streaming messages in LangBot have a stable resp_message_id during the same assistant reply.
|
||||
# Use it as the primary key to avoid overwriting an old card from a previous reply.
|
||||
@@ -309,6 +247,7 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
|
||||
if message_is_final and resp_message_id:
|
||||
stream_message_indexes.pop(resp_message_id, None)
|
||||
|
||||
# 直接广播到所有该pipeline的连接,包含session_type信息
|
||||
await ws_connection_manager.broadcast_to_pipeline(
|
||||
pipeline_uuid,
|
||||
{
|
||||
@@ -317,7 +256,6 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
|
||||
'data': message_data.model_dump(),
|
||||
},
|
||||
session_type=session_type,
|
||||
session_id=session_id,
|
||||
)
|
||||
|
||||
return message_data.model_dump()
|
||||
@@ -443,19 +381,23 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
|
||||
"""
|
||||
pipeline_uuid = connection.pipeline_uuid
|
||||
session_type = connection.session_type
|
||||
conversation_key = self._conversation_key(pipeline_uuid, connection.session_id)
|
||||
|
||||
# 获取stream参数,默认为True
|
||||
self.stream_enabled = message_data.get('stream', True)
|
||||
|
||||
# 选择会话
|
||||
use_session = self.websocket_group_session if session_type == 'group' else self.websocket_person_session
|
||||
|
||||
# 解析消息链
|
||||
message_chain_obj = message_data.get('message', [])
|
||||
|
||||
# 处理图片组件:将path转换为base64
|
||||
await self._process_image_components(message_chain_obj)
|
||||
|
||||
message_chain = platform_message.MessageChain.model_validate(message_chain_obj)
|
||||
|
||||
message_id = len(use_session.get_message_list(conversation_key)) + 1
|
||||
# 生成消息ID
|
||||
message_id = len(use_session.get_message_list(pipeline_uuid)) + 1
|
||||
|
||||
# 保存用户消息
|
||||
user_message = WebSocketMessage(
|
||||
@@ -467,8 +409,9 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
|
||||
connection_id=connection.connection_id,
|
||||
is_final=True, # 用户消息始终是完整的,非流式
|
||||
)
|
||||
use_session.get_message_list(conversation_key).append(user_message)
|
||||
use_session.get_message_list(pipeline_uuid).append(user_message)
|
||||
|
||||
# 广播用户消息到所有连接(包括发送者),包含session_type信息
|
||||
await ws_connection_manager.broadcast_to_pipeline(
|
||||
pipeline_uuid,
|
||||
{
|
||||
@@ -477,27 +420,25 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
|
||||
'data': user_message.model_dump(),
|
||||
},
|
||||
session_type=session_type,
|
||||
session_id=connection.session_id,
|
||||
)
|
||||
|
||||
# 添加消息源
|
||||
message_chain.insert(0, platform_message.Source(id=message_id, time=datetime.now().timestamp()))
|
||||
|
||||
# 创建事件
|
||||
launcher_id = f'{pipeline_uuid}:{connection.session_id}' if connection.session_id else connection.connection_id
|
||||
if session_type == 'person':
|
||||
sender = platform_entities.Friend(id=f'websocket_{launcher_id}', nickname='User', remark='User')
|
||||
sender = platform_entities.Friend(
|
||||
id=f'websocket_{connection.connection_id}', nickname='User', remark='User'
|
||||
)
|
||||
event = platform_events.FriendMessage(
|
||||
sender=sender, message_chain=message_chain, time=datetime.now().timestamp()
|
||||
)
|
||||
else:
|
||||
group = platform_entities.Group(
|
||||
id=f'websocketgroup_{launcher_id}' if connection.session_id else 'websocketgroup',
|
||||
name='Group',
|
||||
permission=platform_entities.Permission.Member,
|
||||
id='websocketgroup', name='Group', permission=platform_entities.Permission.Member
|
||||
)
|
||||
sender = platform_entities.GroupMember(
|
||||
id=f'websocket_{launcher_id}',
|
||||
id=f'websocket_{connection.connection_id}',
|
||||
member_name='User',
|
||||
group=group,
|
||||
permission=platform_entities.Permission.Member,
|
||||
@@ -527,47 +468,22 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
|
||||
if event.__class__ in listeners:
|
||||
asyncio.create_task(listeners[event.__class__](event, callback_adapter))
|
||||
|
||||
def get_websocket_messages(
|
||||
self,
|
||||
pipeline_uuid: str,
|
||||
session_type: str,
|
||||
session_id: str | None = None,
|
||||
) -> list[dict]:
|
||||
"""Return history for one pipeline/client conversation."""
|
||||
conversation_key = self._conversation_key(pipeline_uuid, session_id)
|
||||
session = self.websocket_person_session if session_type == 'person' else self.websocket_group_session
|
||||
return [message.model_dump() for message in session.message_lists.get(conversation_key, [])]
|
||||
def get_websocket_messages(self, pipeline_uuid: str, session_type: str) -> list[dict]:
|
||||
"""获取消息历史"""
|
||||
if session_type == 'person':
|
||||
return [message.model_dump() for message in self.websocket_person_session.get_message_list(pipeline_uuid)]
|
||||
else:
|
||||
return [message.model_dump() for message in self.websocket_group_session.get_message_list(pipeline_uuid)]
|
||||
|
||||
def reset_session(
|
||||
self,
|
||||
pipeline_uuid: str,
|
||||
session_type: str,
|
||||
session_id: str | None = None,
|
||||
):
|
||||
"""Reset one pipeline/client conversation."""
|
||||
conversation_key = self._conversation_key(pipeline_uuid, session_id)
|
||||
session = self.websocket_person_session if session_type == 'person' else self.websocket_group_session
|
||||
if conversation_key in session.message_lists:
|
||||
session.message_lists[conversation_key] = []
|
||||
if conversation_key in session.stream_message_indexes:
|
||||
session.stream_message_indexes[conversation_key] = {}
|
||||
|
||||
if session_id:
|
||||
launcher_id = (
|
||||
f'websocketgroup_{pipeline_uuid}:{session_id}'
|
||||
if session_type == 'group'
|
||||
else f'websocket_{pipeline_uuid}:{session_id}'
|
||||
)
|
||||
self.ap.sess_mgr.session_list = [
|
||||
candidate_session
|
||||
for candidate_session in self.ap.sess_mgr.session_list
|
||||
if not (
|
||||
str(
|
||||
candidate_session.launcher_type.value
|
||||
if hasattr(candidate_session.launcher_type, 'value')
|
||||
else candidate_session.launcher_type
|
||||
)
|
||||
== session_type
|
||||
and str(candidate_session.launcher_id) == launcher_id
|
||||
)
|
||||
]
|
||||
def reset_session(self, pipeline_uuid: str, session_type: str):
|
||||
"""重置会话"""
|
||||
if session_type == 'person':
|
||||
if pipeline_uuid in self.websocket_person_session.message_lists:
|
||||
self.websocket_person_session.message_lists[pipeline_uuid] = []
|
||||
if pipeline_uuid in self.websocket_person_session.stream_message_indexes:
|
||||
self.websocket_person_session.stream_message_indexes[pipeline_uuid] = {}
|
||||
else:
|
||||
if pipeline_uuid in self.websocket_group_session.message_lists:
|
||||
self.websocket_group_session.message_lists[pipeline_uuid] = []
|
||||
if pipeline_uuid in self.websocket_group_session.stream_message_indexes:
|
||||
self.websocket_group_session.stream_message_indexes[pipeline_uuid] = {}
|
||||
|
||||
@@ -9,16 +9,6 @@ from datetime import datetime
|
||||
import pydantic
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
_SESSION_FILTER_UNSET = object()
|
||||
|
||||
|
||||
def is_valid_session_id(value: str) -> bool:
|
||||
"""Accept only canonical random UUIDs for client conversation identifiers."""
|
||||
try:
|
||||
parsed = uuid.UUID(value)
|
||||
except (ValueError, TypeError, AttributeError):
|
||||
return False
|
||||
return parsed.version == 4 and str(parsed) == value
|
||||
|
||||
|
||||
class WebSocketConnection(pydantic.BaseModel):
|
||||
@@ -35,9 +25,6 @@ class WebSocketConnection(pydantic.BaseModel):
|
||||
session_type: str # 'person' or 'group'
|
||||
"""会话类型"""
|
||||
|
||||
session_id: str | None = None
|
||||
"""Optional client conversation identifier used by embed widgets."""
|
||||
|
||||
websocket: typing.Any = pydantic.Field(exclude=True)
|
||||
"""WebSocket连接对象 (quart.websocket)"""
|
||||
|
||||
@@ -78,15 +65,13 @@ class WebSocketConnectionManager:
|
||||
websocket: typing.Any,
|
||||
pipeline_uuid: str,
|
||||
session_type: str,
|
||||
metadata: dict | None = None,
|
||||
session_id: str | None = None,
|
||||
metadata: dict = None,
|
||||
) -> WebSocketConnection:
|
||||
"""Register a WebSocket connection and its optional embed session."""
|
||||
"""添加新的WebSocket连接"""
|
||||
async with self._lock:
|
||||
connection = WebSocketConnection(
|
||||
pipeline_uuid=pipeline_uuid,
|
||||
session_type=session_type,
|
||||
session_id=session_id,
|
||||
websocket=websocket,
|
||||
metadata=metadata or {},
|
||||
)
|
||||
@@ -135,25 +120,10 @@ class WebSocketConnectionManager:
|
||||
|
||||
logger.debug(f'WebSocket connection disconnected: {connection_id}')
|
||||
|
||||
async def get_connection(self, connection_id: str) -> WebSocketConnection | None:
|
||||
"""Get a connection by its transport identifier."""
|
||||
async def get_connection(self, connection_id: str) -> typing.Optional[WebSocketConnection]:
|
||||
"""获取指定连接"""
|
||||
return self.connections.get(connection_id)
|
||||
|
||||
async def get_connection_by_session_id(
|
||||
self,
|
||||
session_id: str,
|
||||
pipeline_uuid: str | None = None,
|
||||
) -> WebSocketConnection | None:
|
||||
"""Get an active embed connection by its stable browser session identifier."""
|
||||
for connection in self.connections.values():
|
||||
if (
|
||||
connection.session_id == session_id
|
||||
and connection.is_active
|
||||
and (pipeline_uuid is None or connection.pipeline_uuid == pipeline_uuid)
|
||||
):
|
||||
return connection
|
||||
return None
|
||||
|
||||
async def get_connections_by_pipeline(self, pipeline_uuid: str) -> list[WebSocketConnection]:
|
||||
"""获取指定流水线的所有连接"""
|
||||
connection_ids = self.pipeline_connections.get(pipeline_uuid, set())
|
||||
@@ -164,30 +134,20 @@ class WebSocketConnectionManager:
|
||||
connection_ids = self.session_connections.get(session_type, set())
|
||||
return [self.connections[cid] for cid in connection_ids if cid in self.connections]
|
||||
|
||||
async def broadcast_to_pipeline(
|
||||
self,
|
||||
pipeline_uuid: str,
|
||||
message: dict,
|
||||
session_type: str | None = None,
|
||||
session_id: typing.Any = _SESSION_FILTER_UNSET,
|
||||
):
|
||||
"""Broadcast a message to matching connections for one pipeline.
|
||||
async def broadcast_to_pipeline(self, pipeline_uuid: str, message: dict, session_type: str = None):
|
||||
"""向指定流水线的所有连接广播消息
|
||||
|
||||
Args:
|
||||
pipeline_uuid: Pipeline identifier.
|
||||
message: Serialized message to enqueue.
|
||||
session_type: Optional session-type filter.
|
||||
session_id: Embed conversation filter. Omit it to broadcast across
|
||||
conversations; pass ``None`` to target non-embed connections.
|
||||
pipeline_uuid: 流水线UUID
|
||||
message: 要广播的消息
|
||||
session_type: 可选的会话类型过滤器,如果提供则只向匹配的session_type连接广播
|
||||
"""
|
||||
connections = await self.get_connections_by_pipeline(pipeline_uuid)
|
||||
|
||||
# 如果指定了session_type,只向匹配的连接广播
|
||||
if session_type is not None:
|
||||
connections = [conn for conn in connections if conn.session_type == session_type]
|
||||
|
||||
if session_id is not _SESSION_FILTER_UNSET:
|
||||
connections = [conn for conn in connections if conn.session_id == session_id]
|
||||
|
||||
tasks = []
|
||||
for conn in connections:
|
||||
tasks.append(self.send_to_connection(conn.connection_id, message))
|
||||
|
||||
@@ -133,9 +133,7 @@ class WecomMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
|
||||
yiri_msg_list = []
|
||||
yiri_msg_list.append(platform_message.Source(id=message_id, time=datetime.datetime.now()))
|
||||
image_base64, image_format = await image.get_wecom_image_base64(pic_url=picurl)
|
||||
yiri_msg_list.append(
|
||||
platform_message.Image(url=picurl, base64=f'data:image/{image_format};base64,{image_base64}')
|
||||
)
|
||||
yiri_msg_list.append(platform_message.Image(base64=f'data:image/{image_format};base64,{image_base64}'))
|
||||
chain = platform_message.MessageChain(yiri_msg_list)
|
||||
|
||||
return chain
|
||||
|
||||
+153
-281
@@ -2,7 +2,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import io
|
||||
import time
|
||||
import zipfile
|
||||
@@ -37,12 +36,6 @@ from ..core import taskmgr
|
||||
from ..entity.persistence import plugin as persistence_plugin
|
||||
|
||||
|
||||
_CONNECT_TIMEOUT_SEC = 30.0
|
||||
_HEARTBEAT_INTERVAL_SEC = 20.0
|
||||
_HEARTBEAT_FAILURE_THRESHOLD = 3
|
||||
_RECONNECT_MAX_DELAY_SEC = 60.0
|
||||
|
||||
|
||||
class PluginRuntimeNotConnectedError(RuntimeError):
|
||||
"""Raised when plugin runtime operations are requested before connection."""
|
||||
|
||||
@@ -77,235 +70,128 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
|
||||
super().__init__(ap)
|
||||
self.runtime_disconnect_callback = runtime_disconnect_callback
|
||||
self.is_enable_plugin = self.ap.instance_config.data.get('plugin', {}).get('enable', True)
|
||||
self._transport_task: asyncio.Task | None = None
|
||||
self._reconnect_task: asyncio.Task | None = None
|
||||
self._generation = 0
|
||||
self._connected = asyncio.Event()
|
||||
|
||||
def _runtime_handler(self) -> handler.RuntimeConnectionHandler:
|
||||
runtime_handler = getattr(self, 'handler', None)
|
||||
if runtime_handler is None:
|
||||
raise PluginRuntimeNotConnectedError('Plugin runtime is not connected')
|
||||
return runtime_handler
|
||||
|
||||
def _runtime_available(self) -> bool:
|
||||
runtime_handler = getattr(self, 'handler', None)
|
||||
if runtime_handler is None:
|
||||
return False
|
||||
# Unit-level and explicitly injected handlers don't own a transport.
|
||||
# A managed transport must also have completed its handshake.
|
||||
return self._transport_task is None or self._connected.is_set()
|
||||
|
||||
async def heartbeat_loop(self):
|
||||
failures = 0
|
||||
while not self._closing:
|
||||
await asyncio.sleep(_HEARTBEAT_INTERVAL_SEC)
|
||||
while True:
|
||||
await asyncio.sleep(20)
|
||||
try:
|
||||
await self.ping_plugin_runtime()
|
||||
failures = 0
|
||||
self.ap.logger.debug('Heartbeat to plugin runtime success.')
|
||||
except Exception as e:
|
||||
failures += 1
|
||||
self.ap.logger.warning(
|
||||
f'Plugin runtime heartbeat failed ({failures}/{_HEARTBEAT_FAILURE_THRESHOLD}): {e}'
|
||||
)
|
||||
if failures >= _HEARTBEAT_FAILURE_THRESHOLD:
|
||||
self._connected.clear()
|
||||
self.schedule_reconnect()
|
||||
failures = 0
|
||||
self.ap.logger.debug(f'Failed to heartbeat to plugin runtime: {e}')
|
||||
|
||||
async def initialize(self):
|
||||
if not self.is_enable_plugin:
|
||||
self.ap.logger.info('Plugin system is disabled.')
|
||||
return
|
||||
|
||||
async with self._lifecycle_lock:
|
||||
if self._closing:
|
||||
raise PluginRuntimeNotConnectedError('Plugin runtime connector is shutting down')
|
||||
if self._connected.is_set() and hasattr(self, 'handler'):
|
||||
return
|
||||
|
||||
self._generation += 1
|
||||
generation = self._generation
|
||||
await self._stop_transport()
|
||||
self._connected = asyncio.Event()
|
||||
connect_errors: list[Exception] = []
|
||||
|
||||
async def new_connection_callback(
|
||||
connection: base_connection.Connection,
|
||||
):
|
||||
if generation != self._generation or self._closing:
|
||||
await connection.close()
|
||||
return
|
||||
connection_ready = False
|
||||
disconnect_notified = False
|
||||
|
||||
async def notify_disconnect() -> None:
|
||||
nonlocal disconnect_notified
|
||||
if (
|
||||
connection_ready
|
||||
and not disconnect_notified
|
||||
and generation == self._generation
|
||||
and not self._closing
|
||||
):
|
||||
disconnect_notified = True
|
||||
self._connected.clear()
|
||||
await self.runtime_disconnect_callback(self)
|
||||
|
||||
async def disconnect_callback(
|
||||
rchandler: handler.RuntimeConnectionHandler,
|
||||
) -> bool:
|
||||
await notify_disconnect()
|
||||
async def new_connection_callback(connection: base_connection.Connection):
|
||||
async def disconnect_callback(
|
||||
rchandler: handler.RuntimeConnectionHandler,
|
||||
) -> bool:
|
||||
if platform.get_platform() == 'docker' or platform.use_websocket_to_connect_plugin_runtime():
|
||||
self.ap.logger.error('Disconnected from plugin runtime, trying to reconnect...')
|
||||
await self.runtime_disconnect_callback(self)
|
||||
return False
|
||||
else:
|
||||
self.ap.logger.error(
|
||||
'Disconnected from plugin runtime, cannot automatically reconnect while LangBot connects to plugin runtime via stdio, please restart LangBot.'
|
||||
)
|
||||
return False
|
||||
|
||||
runtime_handler = handler.RuntimeConnectionHandler(connection, disconnect_callback, self.ap)
|
||||
self.handler = runtime_handler
|
||||
self.handler_task = asyncio.create_task(runtime_handler.run())
|
||||
self.handler = handler.RuntimeConnectionHandler(connection, disconnect_callback, self.ap)
|
||||
|
||||
self.handler_task = asyncio.create_task(self.handler.run())
|
||||
_ = await self.handler.ping()
|
||||
# Push the configured marketplace (Space) URL to the runtime so it
|
||||
# downloads plugins from the same Space LangBot is bound to, rather
|
||||
# than relying on the runtime's own env/default.
|
||||
space_url = self.ap.instance_config.data.get('space', {}).get('url', '').rstrip('/')
|
||||
if space_url:
|
||||
try:
|
||||
await runtime_handler.ping()
|
||||
space_url = self.ap.instance_config.data.get('space', {}).get('url', '').rstrip('/')
|
||||
if space_url:
|
||||
await runtime_handler.set_runtime_config(cloud_service_url=space_url)
|
||||
if generation == self._generation and not self._closing:
|
||||
connection_ready = True
|
||||
self._connected.set()
|
||||
self.ap.logger.info('Connected to plugin runtime.')
|
||||
await self.handler_task
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
if not self._connected.is_set():
|
||||
connect_errors.append(exc)
|
||||
self._connected.set()
|
||||
finally:
|
||||
if generation == self._generation and not self._closing:
|
||||
self._connected.clear()
|
||||
if getattr(self, 'handler', None) is runtime_handler:
|
||||
del self.handler
|
||||
await notify_disconnect()
|
||||
await self.handler.set_runtime_config(cloud_service_url=space_url)
|
||||
self.ap.logger.info(f'Pushed marketplace URL to plugin runtime: {space_url}')
|
||||
except Exception as e:
|
||||
self.ap.logger.warning(f'Failed to push runtime config: {e}')
|
||||
self.ap.logger.info('Connected to plugin runtime.')
|
||||
await self.handler_task
|
||||
|
||||
task_coro: typing.Coroutine
|
||||
if platform.get_platform() == 'docker' or platform.use_websocket_to_connect_plugin_runtime():
|
||||
ws_url = self.ap.instance_config.data.get('plugin', {}).get(
|
||||
'runtime_ws_url',
|
||||
'ws://langbot_plugin_runtime:5400/control/ws',
|
||||
)
|
||||
task: asyncio.Task | None = None
|
||||
|
||||
async def connection_failed(ctrl, exc=None):
|
||||
error = exc or RuntimeError('WebSocket connection failed')
|
||||
connect_errors.append(error)
|
||||
self._connected.set()
|
||||
|
||||
self.ctrl = ws_client_controller.WebSocketClientController(
|
||||
ws_url=ws_url,
|
||||
make_connection_failed_callback=connection_failed,
|
||||
)
|
||||
task_coro = self.ctrl.run(new_connection_callback)
|
||||
elif platform.get_platform() == 'win32':
|
||||
await self._start_runtime_subprocess('-m', 'langbot_plugin.cli.__init__', 'rt')
|
||||
ws_url = 'ws://localhost:5400/control/ws'
|
||||
|
||||
async def connection_failed(ctrl, exc=None):
|
||||
error = exc or RuntimeError('WebSocket connection failed')
|
||||
connect_errors.append(error)
|
||||
self._connected.set()
|
||||
|
||||
self.ctrl = ws_client_controller.WebSocketClientController(
|
||||
ws_url=ws_url,
|
||||
make_connection_failed_callback=connection_failed,
|
||||
)
|
||||
task_coro = self.ctrl.run(new_connection_callback)
|
||||
else:
|
||||
self.ctrl = stdio_client_controller.StdioClientController(
|
||||
command=sys.executable,
|
||||
args=['-m', 'langbot_plugin.cli.__init__', 'rt', '-s'],
|
||||
env=os.environ.copy(),
|
||||
capture_stderr=False,
|
||||
)
|
||||
task_coro = self.ctrl.run(new_connection_callback)
|
||||
|
||||
self._transport_task = asyncio.create_task(task_coro)
|
||||
try:
|
||||
await asyncio.wait_for(self._connected.wait(), timeout=_CONNECT_TIMEOUT_SEC)
|
||||
except asyncio.TimeoutError as exc:
|
||||
await self._stop_transport()
|
||||
raise PluginRuntimeNotConnectedError('Plugin runtime did not become ready within 30 seconds') from exc
|
||||
if connect_errors:
|
||||
await self._stop_transport()
|
||||
raise PluginRuntimeNotConnectedError(f'Plugin runtime connection failed: {connect_errors[-1]}')
|
||||
|
||||
if self.heartbeat_task is None or self.heartbeat_task.done():
|
||||
self.heartbeat_task = asyncio.create_task(self.heartbeat_loop())
|
||||
|
||||
def schedule_reconnect(self) -> None:
|
||||
if self._closing or not self.is_enable_plugin:
|
||||
return
|
||||
if self._reconnect_task is not None and not self._reconnect_task.done():
|
||||
return
|
||||
self._reconnect_task = asyncio.create_task(self._reconnect_loop())
|
||||
|
||||
async def _reconnect_loop(self) -> None:
|
||||
delay = 1.0
|
||||
try:
|
||||
while not self._closing:
|
||||
try:
|
||||
await self.initialize()
|
||||
return
|
||||
except Exception as exc:
|
||||
self.ap.logger.warning(f'Plugin runtime reconnection failed: {exc}; retrying in {delay:.0f}s')
|
||||
await asyncio.sleep(delay)
|
||||
delay = min(delay * 2, _RECONNECT_MAX_DELAY_SEC)
|
||||
finally:
|
||||
self._reconnect_task = None
|
||||
|
||||
async def _stop_transport(self) -> None:
|
||||
self._connected.clear()
|
||||
runtime_handler = getattr(self, 'handler', None)
|
||||
if runtime_handler is not None:
|
||||
with contextlib.suppress(Exception):
|
||||
await runtime_handler.close()
|
||||
if getattr(self, 'handler', None) is runtime_handler:
|
||||
del self.handler
|
||||
tasks = [
|
||||
task
|
||||
for task in (
|
||||
getattr(self, 'handler_task', None),
|
||||
self._transport_task,
|
||||
if platform.get_platform() == 'docker' or platform.use_websocket_to_connect_plugin_runtime(): # use websocket
|
||||
self.ap.logger.info('use websocket to connect to plugin runtime')
|
||||
ws_url = self.ap.instance_config.data.get('plugin', {}).get(
|
||||
'runtime_ws_url', 'ws://langbot_plugin_runtime:5400/control/ws'
|
||||
)
|
||||
if task is not None and task is not asyncio.current_task()
|
||||
]
|
||||
for task in tasks:
|
||||
task.cancel()
|
||||
if tasks:
|
||||
await asyncio.gather(*tasks, return_exceptions=True)
|
||||
self._transport_task = None
|
||||
if hasattr(self, 'handler_task'):
|
||||
del self.handler_task
|
||||
close_ctrl = getattr(getattr(self, 'ctrl', None), 'close', None)
|
||||
if close_ctrl is not None:
|
||||
with contextlib.suppress(Exception):
|
||||
await close_ctrl()
|
||||
|
||||
async def aclose(self) -> None:
|
||||
self._closing = True
|
||||
self._generation += 1
|
||||
reconnect_task = self._reconnect_task
|
||||
self._reconnect_task = None
|
||||
if reconnect_task is not None and reconnect_task is not asyncio.current_task():
|
||||
reconnect_task.cancel()
|
||||
await asyncio.gather(reconnect_task, return_exceptions=True)
|
||||
if self.heartbeat_task is not None:
|
||||
self.heartbeat_task.cancel()
|
||||
await asyncio.gather(self.heartbeat_task, return_exceptions=True)
|
||||
self.heartbeat_task = None
|
||||
await self._stop_transport()
|
||||
await self._close_managed_subprocess()
|
||||
async def make_connection_failed_callback(
|
||||
ctrl: ws_client_controller.WebSocketClientController,
|
||||
exc: Exception = None,
|
||||
) -> None:
|
||||
if exc is not None:
|
||||
self.ap.logger.error(f'Failed to connect to plugin runtime({ws_url}): {exc}')
|
||||
else:
|
||||
self.ap.logger.error(f'Failed to connect to plugin runtime({ws_url}), trying to reconnect...')
|
||||
await self.runtime_disconnect_callback(self)
|
||||
|
||||
self.ctrl = ws_client_controller.WebSocketClientController(
|
||||
ws_url=ws_url,
|
||||
make_connection_failed_callback=make_connection_failed_callback,
|
||||
)
|
||||
task = self.ctrl.run(new_connection_callback)
|
||||
elif platform.get_platform() == 'win32':
|
||||
# Due to Windows's lack of supports for both stdio and subprocess:
|
||||
# See also: https://docs.python.org/zh-cn/3.13/library/asyncio-platforms.html
|
||||
# We have to launch runtime via cmd but communicate via ws.
|
||||
self.ap.logger.info('(windows) use cmd to launch plugin runtime and communicate via ws')
|
||||
|
||||
await self._start_runtime_subprocess('-m', 'langbot_plugin.cli.__init__', 'rt')
|
||||
|
||||
ws_url = 'ws://localhost:5400/control/ws'
|
||||
|
||||
async def make_connection_failed_callback(
|
||||
ctrl: ws_client_controller.WebSocketClientController,
|
||||
exc: Exception = None,
|
||||
) -> None:
|
||||
if exc is not None:
|
||||
self.ap.logger.error(f'(windows) Failed to connect to plugin runtime({ws_url}): {exc}')
|
||||
else:
|
||||
self.ap.logger.error(
|
||||
f'(windows) Failed to connect to plugin runtime({ws_url}), trying to reconnect...'
|
||||
)
|
||||
await self.runtime_disconnect_callback(self)
|
||||
|
||||
self.ctrl = ws_client_controller.WebSocketClientController(
|
||||
ws_url=ws_url,
|
||||
make_connection_failed_callback=make_connection_failed_callback,
|
||||
)
|
||||
task = self.ctrl.run(new_connection_callback)
|
||||
|
||||
else: # stdio
|
||||
self.ap.logger.info('use stdio to connect to plugin runtime')
|
||||
# cmd: lbp rt -s
|
||||
python_path = sys.executable
|
||||
env = os.environ.copy()
|
||||
self.ctrl = stdio_client_controller.StdioClientController(
|
||||
command=python_path,
|
||||
args=['-m', 'langbot_plugin.cli.__init__', 'rt', '-s'],
|
||||
env=env,
|
||||
)
|
||||
task = self.ctrl.run(new_connection_callback)
|
||||
|
||||
if self.heartbeat_task is None:
|
||||
self.heartbeat_task = asyncio.create_task(self.heartbeat_loop())
|
||||
|
||||
asyncio.create_task(task)
|
||||
|
||||
async def initialize_plugins(self):
|
||||
pass
|
||||
|
||||
async def ping_plugin_runtime(self):
|
||||
return await self._runtime_handler().ping()
|
||||
if not hasattr(self, 'handler'):
|
||||
raise PluginRuntimeNotConnectedError('Plugin runtime is not connected')
|
||||
|
||||
return await self.handler.ping()
|
||||
|
||||
def _inspect_plugin_package(
|
||||
self,
|
||||
@@ -587,7 +473,7 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
|
||||
|
||||
file_bytes = download_resp.content
|
||||
self._inspect_plugin_package(file_bytes, task_context)
|
||||
file_key = await self._runtime_handler().send_file(file_bytes, 'lbpkg')
|
||||
file_key = await self.handler.send_file(file_bytes, 'lbpkg')
|
||||
install_info['plugin_file_key'] = file_key
|
||||
self.ap.logger.info(f'Transfered file {file_key} to plugin runtime')
|
||||
# Continue to install via runtime
|
||||
@@ -610,7 +496,7 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
|
||||
plugin_author, plugin_name = self._inspect_plugin_package(file_bytes, task_context)
|
||||
if task_context is not None and plugin_author and plugin_name:
|
||||
task_context.metadata['plugin_name'] = f'{plugin_author}/{plugin_name}'
|
||||
file_key = await self._runtime_handler().send_file(file_bytes, 'lbpkg')
|
||||
file_key = await self.handler.send_file(file_bytes, 'lbpkg')
|
||||
install_info['plugin_file_key'] = file_key
|
||||
del install_info['plugin_file']
|
||||
self.ap.logger.info(f'Transfered file {file_key} to plugin runtime')
|
||||
@@ -649,14 +535,14 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
|
||||
plugin_author, plugin_name = self._inspect_plugin_package(file_bytes, task_context)
|
||||
if task_context is not None and plugin_author and plugin_name:
|
||||
task_context.metadata['plugin_name'] = f'{plugin_author}/{plugin_name}'
|
||||
file_key = await self._runtime_handler().send_file(file_bytes, 'lbpkg')
|
||||
file_key = await self.handler.send_file(file_bytes, 'lbpkg')
|
||||
install_info['plugin_file_key'] = file_key
|
||||
self.ap.logger.info(f'Transfered file {file_key} to plugin runtime')
|
||||
except Exception as e:
|
||||
self.ap.logger.error(f'Failed to download file from GitHub: {e}')
|
||||
raise Exception(f'Failed to download file from GitHub: {e}')
|
||||
|
||||
async for ret in self._runtime_handler().install_plugin(install_source.value, install_info):
|
||||
async for ret in self.handler.install_plugin(install_source.value, install_info):
|
||||
current_action = ret.get('current_action', None)
|
||||
if current_action is not None:
|
||||
if task_context is not None:
|
||||
@@ -680,7 +566,7 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
|
||||
plugin_name: str,
|
||||
task_context: taskmgr.TaskContext | None = None,
|
||||
) -> dict[str, Any]:
|
||||
async for ret in self._runtime_handler().upgrade_plugin(plugin_author, plugin_name):
|
||||
async for ret in self.handler.upgrade_plugin(plugin_author, plugin_name):
|
||||
current_action = ret.get('current_action', None)
|
||||
if current_action is not None:
|
||||
if task_context is not None:
|
||||
@@ -698,7 +584,7 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
|
||||
delete_data: bool = False,
|
||||
task_context: taskmgr.TaskContext | None = None,
|
||||
) -> dict[str, Any]:
|
||||
async for ret in self._runtime_handler().delete_plugin(plugin_author, plugin_name):
|
||||
async for ret in self.handler.delete_plugin(plugin_author, plugin_name):
|
||||
current_action = ret.get('current_action', None)
|
||||
if current_action is not None:
|
||||
if task_context is not None:
|
||||
@@ -713,7 +599,7 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
|
||||
if delete_data:
|
||||
if task_context is not None:
|
||||
task_context.trace('Cleaning up plugin configuration and storage...')
|
||||
await self._runtime_handler().cleanup_plugin_data(plugin_author, plugin_name)
|
||||
await self.handler.cleanup_plugin_data(plugin_author, plugin_name)
|
||||
|
||||
async def list_plugins(self, component_kinds: list[str] | None = None) -> list[dict[str, Any]]:
|
||||
"""List plugins, optionally filtered by component kinds.
|
||||
@@ -724,10 +610,10 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
|
||||
component of the specified kinds will be returned.
|
||||
E.g., ['Command', 'EventListener', 'Tool'] for pipeline-related plugins.
|
||||
"""
|
||||
if not self.is_enable_plugin or not self._runtime_available():
|
||||
if not self.is_enable_plugin:
|
||||
return []
|
||||
|
||||
plugins = await self._runtime_handler().list_plugins()
|
||||
plugins = await self.handler.list_plugins()
|
||||
|
||||
# Filter plugins by component kinds if specified
|
||||
if component_kinds is not None:
|
||||
@@ -799,18 +685,18 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
|
||||
return plugins
|
||||
|
||||
async def get_plugin_info(self, author: str, plugin_name: str) -> dict[str, Any]:
|
||||
return await self._runtime_handler().get_plugin_info(author, plugin_name)
|
||||
return await self.handler.get_plugin_info(author, plugin_name)
|
||||
|
||||
async def set_plugin_config(self, plugin_author: str, plugin_name: str, config: dict[str, Any]) -> dict[str, Any]:
|
||||
return await self._runtime_handler().set_plugin_config(plugin_author, plugin_name, config)
|
||||
return await self.handler.set_plugin_config(plugin_author, plugin_name, config)
|
||||
|
||||
@alru_cache(ttl=5 * 60) # 5 minutes
|
||||
async def get_plugin_icon(self, plugin_author: str, plugin_name: str) -> dict[str, Any]:
|
||||
return await self._runtime_handler().get_plugin_icon(plugin_author, plugin_name)
|
||||
return await self.handler.get_plugin_icon(plugin_author, plugin_name)
|
||||
|
||||
@alru_cache(ttl=5 * 60) # 5 minutes
|
||||
async def get_plugin_readme(self, plugin_author: str, plugin_name: str, language: str = 'en') -> str:
|
||||
return await self._runtime_handler().get_plugin_readme(plugin_author, plugin_name, language)
|
||||
return await self.handler.get_plugin_readme(plugin_author, plugin_name, language)
|
||||
|
||||
async def get_plugin_logs(
|
||||
self,
|
||||
@@ -820,11 +706,11 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
|
||||
level: str | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
# Not cached: logs are live and change constantly.
|
||||
return await self._runtime_handler().get_plugin_logs(plugin_author, plugin_name, limit, level)
|
||||
return await self.handler.get_plugin_logs(plugin_author, plugin_name, limit, level)
|
||||
|
||||
@alru_cache(ttl=5 * 60)
|
||||
async def get_plugin_assets(self, plugin_author: str, plugin_name: str, filepath: str) -> dict[str, Any]:
|
||||
return await self._runtime_handler().get_plugin_assets(plugin_author, plugin_name, filepath)
|
||||
return await self.handler.get_plugin_assets(plugin_author, plugin_name, filepath)
|
||||
|
||||
async def handle_page_api(
|
||||
self,
|
||||
@@ -835,15 +721,13 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
|
||||
method: str,
|
||||
body: Any = None,
|
||||
) -> dict[str, Any]:
|
||||
return await self._runtime_handler().handle_page_api(
|
||||
plugin_author, plugin_name, page_id, endpoint, method, body
|
||||
)
|
||||
return await self.handler.handle_page_api(plugin_author, plugin_name, page_id, endpoint, method, body)
|
||||
|
||||
async def get_debug_info(self) -> dict[str, Any]:
|
||||
"""Get debug information including debug key and WS URL"""
|
||||
if not self.is_enable_plugin or not self._runtime_available():
|
||||
if not self.is_enable_plugin:
|
||||
return {}
|
||||
return await self._runtime_handler().get_debug_info()
|
||||
return await self.handler.get_debug_info()
|
||||
|
||||
async def emit_event(
|
||||
self,
|
||||
@@ -852,13 +736,13 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
|
||||
) -> context.EventContext:
|
||||
event_ctx = context.EventContext.from_event(event)
|
||||
|
||||
if not self.is_enable_plugin or not self._runtime_available():
|
||||
if not self.is_enable_plugin:
|
||||
event_ctx._emitted_plugins = []
|
||||
event_ctx._response_sources = []
|
||||
return event_ctx
|
||||
|
||||
# Pass include_plugins to runtime for filtering
|
||||
event_ctx_result = await self._runtime_handler().emit_event(
|
||||
event_ctx_result = await self.handler.emit_event(
|
||||
event_ctx.model_dump(serialize_as_any=False), include_plugins=bound_plugins
|
||||
)
|
||||
|
||||
@@ -871,19 +755,19 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
|
||||
|
||||
async def notify_plugin_diagnostic(self, diagnostic: dict[str, Any]) -> None:
|
||||
"""Best-effort diagnostic forwarding to the plugin runtime."""
|
||||
if not self.is_enable_plugin or not self._runtime_available():
|
||||
if not self.is_enable_plugin:
|
||||
return
|
||||
try:
|
||||
await self._runtime_handler().notify_plugin_diagnostic(diagnostic)
|
||||
await self.handler.notify_plugin_diagnostic(diagnostic)
|
||||
except Exception as e:
|
||||
self.ap.logger.debug(f'Plugin diagnostic forwarding skipped: {e}')
|
||||
|
||||
async def list_tools(self, bound_plugins: list[str] | None = None) -> list[ComponentManifest]:
|
||||
if not self.is_enable_plugin or not self._runtime_available():
|
||||
if not self.is_enable_plugin:
|
||||
return []
|
||||
|
||||
# Pass include_plugins to runtime for filtering
|
||||
list_tools_data = await self._runtime_handler().list_tools(include_plugins=bound_plugins)
|
||||
list_tools_data = await self.handler.list_tools(include_plugins=bound_plugins)
|
||||
|
||||
tools = [ComponentManifest.model_validate(tool) for tool in list_tools_data]
|
||||
|
||||
@@ -901,19 +785,16 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
|
||||
return {'error': 'Tool not found: plugin system is disabled'}
|
||||
|
||||
# Pass include_plugins to runtime for validation
|
||||
if not self._runtime_available():
|
||||
return {'error': 'Plugin runtime is temporarily unavailable'}
|
||||
|
||||
return await self._runtime_handler().call_tool(
|
||||
return await self.handler.call_tool(
|
||||
tool_name, parameters, session.model_dump(serialize_as_any=True), query_id, include_plugins=bound_plugins
|
||||
)
|
||||
|
||||
async def list_commands(self, bound_plugins: list[str] | None = None) -> list[ComponentManifest]:
|
||||
if not self.is_enable_plugin or not self._runtime_available():
|
||||
if not self.is_enable_plugin:
|
||||
return []
|
||||
|
||||
# Pass include_plugins to runtime for filtering
|
||||
list_commands_data = await self._runtime_handler().list_commands(include_plugins=bound_plugins)
|
||||
list_commands_data = await self.handler.list_commands(include_plugins=bound_plugins)
|
||||
|
||||
commands = [ComponentManifest.model_validate(command) for command in list_commands_data]
|
||||
|
||||
@@ -922,15 +803,12 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
|
||||
async def execute_command(
|
||||
self, command_ctx: command_context.ExecuteContext, bound_plugins: list[str] | None = None
|
||||
) -> typing.AsyncGenerator[command_context.CommandReturn, None]:
|
||||
if not self.is_enable_plugin or not self._runtime_available():
|
||||
if not self.is_enable_plugin:
|
||||
yield command_context.CommandReturn(error=command_errors.CommandNotFoundError(command_ctx.command))
|
||||
return
|
||||
|
||||
# Pass include_plugins to runtime for validation
|
||||
gen = self._runtime_handler().execute_command(
|
||||
command_ctx.model_dump(serialize_as_any=True),
|
||||
include_plugins=bound_plugins,
|
||||
)
|
||||
gen = self.handler.execute_command(command_ctx.model_dump(serialize_as_any=True), include_plugins=bound_plugins)
|
||||
|
||||
async for ret in gen:
|
||||
cmd_ret = command_context.CommandReturn.model_validate(ret)
|
||||
@@ -945,33 +823,27 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
|
||||
retrieval_context: dict[str, Any],
|
||||
) -> dict[str, Any]:
|
||||
"""Retrieve knowledge using a KnowledgeEngine instance."""
|
||||
if not self.is_enable_plugin or not self._runtime_available():
|
||||
if not self.is_enable_plugin:
|
||||
return {'results': []}
|
||||
|
||||
return await self._runtime_handler().retrieve_knowledge(
|
||||
plugin_author, plugin_name, retriever_name, retrieval_context
|
||||
)
|
||||
return await self.handler.retrieve_knowledge(plugin_author, plugin_name, retriever_name, retrieval_context)
|
||||
|
||||
def dispose(self):
|
||||
"""Best-effort synchronous compatibility wrapper; prefer ``aclose``."""
|
||||
self._closing = True
|
||||
# On non-Windows stdio mode, terminate via the controller's process handle.
|
||||
# On Windows, the managed subprocess is cleaned up by the base class.
|
||||
if (
|
||||
self.is_enable_plugin
|
||||
and hasattr(self, 'ctrl')
|
||||
and isinstance(self.ctrl, stdio_client_controller.StdioClientController)
|
||||
):
|
||||
self.ap.logger.info('Terminating plugin runtime process...')
|
||||
self.ctrl.process.terminate()
|
||||
|
||||
self._dispose_subprocess()
|
||||
|
||||
if self.heartbeat_task is not None:
|
||||
self.heartbeat_task.cancel()
|
||||
self.heartbeat_task = None
|
||||
if self._reconnect_task is not None:
|
||||
self._reconnect_task.cancel()
|
||||
self._reconnect_task = None
|
||||
for task in (
|
||||
getattr(self, 'handler_task', None),
|
||||
self._transport_task,
|
||||
):
|
||||
if task is not None:
|
||||
task.cancel()
|
||||
ctrl = getattr(self, 'ctrl', None)
|
||||
process = getattr(ctrl, 'process', None)
|
||||
if process is not None and process.returncode is None:
|
||||
process.terminate()
|
||||
self._dispose_subprocess()
|
||||
|
||||
@staticmethod
|
||||
def _parse_plugin_id(plugin_id: str) -> tuple[str, str]:
|
||||
@@ -1001,29 +873,29 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
|
||||
context_data: IngestionContext data.
|
||||
"""
|
||||
plugin_author, plugin_name = self._parse_plugin_id(plugin_id)
|
||||
return await self._runtime_handler().rag_ingest_document(plugin_author, plugin_name, context_data)
|
||||
return await self.handler.rag_ingest_document(plugin_author, plugin_name, context_data)
|
||||
|
||||
async def call_rag_delete_document(self, plugin_id: str, document_id: str, kb_id: str) -> bool:
|
||||
plugin_author, plugin_name = self._parse_plugin_id(plugin_id)
|
||||
return await self._runtime_handler().rag_delete_document(plugin_author, plugin_name, document_id, kb_id)
|
||||
return await self.handler.rag_delete_document(plugin_author, plugin_name, document_id, kb_id)
|
||||
|
||||
async def get_rag_creation_schema(self, plugin_id: str) -> dict[str, Any]:
|
||||
plugin_author, plugin_name = self._parse_plugin_id(plugin_id)
|
||||
return await self._runtime_handler().get_rag_creation_schema(plugin_author, plugin_name)
|
||||
return await self.handler.get_rag_creation_schema(plugin_author, plugin_name)
|
||||
|
||||
async def get_rag_retrieval_schema(self, plugin_id: str) -> dict[str, Any]:
|
||||
plugin_author, plugin_name = self._parse_plugin_id(plugin_id)
|
||||
return await self._runtime_handler().get_rag_retrieval_schema(plugin_author, plugin_name)
|
||||
return await self.handler.get_rag_retrieval_schema(plugin_author, plugin_name)
|
||||
|
||||
async def rag_on_kb_create(self, plugin_id: str, kb_id: str, config: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Notify plugin about KB creation."""
|
||||
plugin_author, plugin_name = self._parse_plugin_id(plugin_id)
|
||||
return await self._runtime_handler().rag_on_kb_create(plugin_author, plugin_name, kb_id, config)
|
||||
return await self.handler.rag_on_kb_create(plugin_author, plugin_name, kb_id, config)
|
||||
|
||||
async def rag_on_kb_delete(self, plugin_id: str, kb_id: str) -> dict[str, Any]:
|
||||
"""Notify plugin about KB deletion."""
|
||||
plugin_author, plugin_name = self._parse_plugin_id(plugin_id)
|
||||
return await self._runtime_handler().rag_on_kb_delete(plugin_author, plugin_name, kb_id)
|
||||
return await self.handler.rag_on_kb_delete(plugin_author, plugin_name, kb_id)
|
||||
|
||||
async def call_rag_retrieve(self, plugin_id: str, retrieval_context: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Call plugin to retrieve knowledge.
|
||||
@@ -1033,25 +905,25 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
|
||||
retrieval_context: RetrievalContext data.
|
||||
"""
|
||||
plugin_author, plugin_name = self._parse_plugin_id(plugin_id)
|
||||
return await self._runtime_handler().retrieve_knowledge(plugin_author, plugin_name, '', retrieval_context)
|
||||
return await self.handler.retrieve_knowledge(plugin_author, plugin_name, '', retrieval_context)
|
||||
|
||||
async def list_knowledge_engines(self) -> list[dict[str, Any]]:
|
||||
"""List all available Knowledge Engines from plugins.
|
||||
|
||||
Returns a list of Knowledge Engines with their capabilities and configuration schemas.
|
||||
"""
|
||||
if not self.is_enable_plugin or not self._runtime_available():
|
||||
if not self.is_enable_plugin:
|
||||
return []
|
||||
|
||||
return await self._runtime_handler().list_knowledge_engines()
|
||||
return await self.handler.list_knowledge_engines()
|
||||
|
||||
async def list_parsers(self) -> list[dict[str, Any]]:
|
||||
"""List all available parsers from plugins."""
|
||||
if not self.is_enable_plugin or not self._runtime_available():
|
||||
if not self.is_enable_plugin:
|
||||
return []
|
||||
return await self._runtime_handler().list_parsers()
|
||||
return await self.handler.list_parsers()
|
||||
|
||||
async def call_parser(self, plugin_id: str, context_data: dict[str, Any], file_bytes: bytes) -> dict[str, Any]:
|
||||
"""Call plugin to parse a document."""
|
||||
plugin_author, plugin_name = self._parse_plugin_id(plugin_id)
|
||||
return await self._runtime_handler().parse_document(plugin_author, plugin_name, context_data, file_bytes)
|
||||
return await self.handler.parse_document(plugin_author, plugin_name, context_data, file_bytes)
|
||||
|
||||
@@ -193,7 +193,6 @@ class ModelManager:
|
||||
existing_embedding_models = {
|
||||
m['uuid']: m for m in await self.ap.embedding_models_service.get_embedding_models()
|
||||
}
|
||||
existing_rerank_models = {m['uuid']: m for m in await self.ap.rerank_models_service.get_rerank_models()}
|
||||
|
||||
created = 0
|
||||
updated = 0
|
||||
@@ -259,33 +258,6 @@ class ModelManager:
|
||||
await self.ap.embedding_models_service.update_embedding_model(space_model.uuid, dict(desired))
|
||||
updated += 1
|
||||
|
||||
elif space_model.category == 'rerank':
|
||||
existing = existing_rerank_models.get(space_model.uuid)
|
||||
if existing is None:
|
||||
await self.ap.rerank_models_service.create_rerank_model(
|
||||
{
|
||||
'uuid': space_model.uuid,
|
||||
'name': space_model.model_id,
|
||||
'provider_uuid': space_model_provider.uuid,
|
||||
'extra_args': {},
|
||||
'prefered_ranking': space_model.featured_order,
|
||||
},
|
||||
preserve_uuid=True,
|
||||
)
|
||||
created += 1
|
||||
elif existing.get('provider_uuid') == space_model_provider.uuid:
|
||||
desired = {
|
||||
'name': space_model.model_id,
|
||||
'provider_uuid': space_model_provider.uuid,
|
||||
'prefered_ranking': space_model.featured_order,
|
||||
}
|
||||
if (
|
||||
existing.get('name') != desired['name']
|
||||
or existing.get('prefered_ranking') != desired['prefered_ranking']
|
||||
):
|
||||
await self.ap.rerank_models_service.update_rerank_model(space_model.uuid, dict(desired))
|
||||
updated += 1
|
||||
|
||||
if created or updated:
|
||||
self.ap.logger.info(f'Synced models from LangBot Space: {created} added, {updated} updated.')
|
||||
|
||||
|
||||
@@ -13,151 +13,6 @@ import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
|
||||
import langbot_plugin.api.entities.builtin.provider.message as provider_message
|
||||
|
||||
|
||||
class _ThinkStripState:
|
||||
"""Stateful filter that drops think blocks across chunks."""
|
||||
|
||||
_THINK_OPEN = '<think>'
|
||||
_THINK_CLOSE = '</think>'
|
||||
_LEGACY_OPEN = 'CRETIRE_REASONING_BEGINk'
|
||||
_LEGACY_CLOSE = 'CRETIRE_REASONING_ENDk'
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._pairs: tuple[tuple[str, str], ...] = (
|
||||
(self._THINK_OPEN, self._THINK_CLOSE),
|
||||
(self._LEGACY_OPEN, self._LEGACY_CLOSE),
|
||||
)
|
||||
self._open_tags = tuple(open_tag for open_tag, _close_tag in self._pairs)
|
||||
self._buf = ''
|
||||
self._close_tag: str | None = None
|
||||
self._pending_initial = True
|
||||
|
||||
def feed(self, chunk: str) -> str:
|
||||
"""Feed a streaming delta and return user-visible content."""
|
||||
if not chunk:
|
||||
return chunk
|
||||
|
||||
text = self._buf + chunk
|
||||
if self._close_tag is not None:
|
||||
return self._consume_think_body(text)
|
||||
|
||||
return self._process_visible_text(text)
|
||||
|
||||
def flush(self) -> str:
|
||||
"""Release buffered visible content when the stream ends."""
|
||||
if self._close_tag is not None:
|
||||
self._buf = ''
|
||||
self._close_tag = None
|
||||
return ''
|
||||
|
||||
pending, self._buf = self._buf, ''
|
||||
self._close_tag = None
|
||||
return pending
|
||||
|
||||
def _consume_think_body(self, text: str) -> str:
|
||||
close_tag = self._close_tag
|
||||
if close_tag is None:
|
||||
return text
|
||||
|
||||
close_idx = text.find(close_tag)
|
||||
if close_idx != -1:
|
||||
self._close_tag = None
|
||||
self._buf = ''
|
||||
self._pending_initial = False
|
||||
return self._process_visible_text(text[close_idx + len(close_tag) :])
|
||||
|
||||
self._buf = self._close_prefix(text, close_tag)
|
||||
return ''
|
||||
|
||||
def _process_visible_text(self, text: str) -> str:
|
||||
out: list[str] = []
|
||||
index = 0
|
||||
|
||||
while index < len(text):
|
||||
if self._pending_initial:
|
||||
open_idx, open_tag, close_tag = self._find_next_open(text, index)
|
||||
orphan_close_idx, orphan_close_tag = self._find_next_close(text, index)
|
||||
|
||||
if orphan_close_idx != -1 and (open_idx == -1 or orphan_close_idx < open_idx):
|
||||
self._pending_initial = False
|
||||
index = orphan_close_idx + len(orphan_close_tag)
|
||||
continue
|
||||
|
||||
if open_idx == -1:
|
||||
self._buf = text[index:]
|
||||
return ''.join(out)
|
||||
|
||||
if open_idx > index:
|
||||
self._pending_initial = False
|
||||
out.append(text[index:open_idx])
|
||||
index = open_idx
|
||||
continue
|
||||
|
||||
open_idx, open_tag, close_tag = self._find_next_open(text, index)
|
||||
if open_idx == -1:
|
||||
emit_end = self._visible_emit_end(text, index)
|
||||
out.append(text[index:emit_end])
|
||||
if emit_end > index:
|
||||
self._pending_initial = False
|
||||
self._buf = text[emit_end:]
|
||||
return ''.join(out)
|
||||
|
||||
out.append(text[index:open_idx])
|
||||
if open_idx > index:
|
||||
self._pending_initial = False
|
||||
body_start = open_idx + len(open_tag)
|
||||
close_idx = text.find(close_tag, body_start)
|
||||
if close_idx == -1:
|
||||
self._close_tag = close_tag
|
||||
self._buf = self._close_prefix(text[body_start:], close_tag)
|
||||
return ''.join(out)
|
||||
|
||||
self._pending_initial = False
|
||||
index = close_idx + len(close_tag)
|
||||
|
||||
self._buf = ''
|
||||
return ''.join(out)
|
||||
|
||||
def _find_next_open(self, text: str, start: int) -> tuple[int, str, str]:
|
||||
best_idx = -1
|
||||
best_open = ''
|
||||
best_close = ''
|
||||
for open_tag, close_tag in self._pairs:
|
||||
idx = text.find(open_tag, start)
|
||||
if idx != -1 and (best_idx == -1 or idx < best_idx):
|
||||
best_idx = idx
|
||||
best_open = open_tag
|
||||
best_close = close_tag
|
||||
return best_idx, best_open, best_close
|
||||
|
||||
def _find_next_close(self, text: str, start: int) -> tuple[int, str]:
|
||||
best_idx = -1
|
||||
best_close = ''
|
||||
for _open_tag, close_tag in self._pairs:
|
||||
idx = text.find(close_tag, start)
|
||||
if idx != -1 and (best_idx == -1 or idx < best_idx):
|
||||
best_idx = idx
|
||||
best_close = close_tag
|
||||
return best_idx, best_close
|
||||
|
||||
def _visible_emit_end(self, text: str, start: int) -> int:
|
||||
visible = text[start:]
|
||||
limit = min(len(visible), max(len(open_tag) for open_tag in self._open_tags) - 1)
|
||||
for keep in range(limit, 0, -1):
|
||||
suffix = visible[-keep:]
|
||||
if any(open_tag.startswith(suffix) for open_tag in self._open_tags):
|
||||
return len(text) - keep
|
||||
return len(text)
|
||||
|
||||
@staticmethod
|
||||
def _close_prefix(text: str, close_tag: str) -> str:
|
||||
limit = min(len(text), len(close_tag) - 1)
|
||||
for keep in range(limit, 0, -1):
|
||||
suffix = text[-keep:]
|
||||
if close_tag.startswith(suffix):
|
||||
return suffix
|
||||
return ''
|
||||
|
||||
|
||||
class LiteLLMRequester(requester.ProviderAPIRequester):
|
||||
"""LiteLLM unified API requester supporting chat, embedding, and rerank."""
|
||||
|
||||
@@ -382,25 +237,6 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
|
||||
|
||||
return req_messages
|
||||
|
||||
_THINK_PATTERNS: tuple[str, ...] = (
|
||||
r'^\s*(?:(?!<think>).)*?</think>\s*',
|
||||
r'^\s*(?:(?!CRETIRE_REASONING_BEGINk).)*?CRETIRE_REASONING_ENDk\s*',
|
||||
r'<think>.*?</think>',
|
||||
r'CRETIRE_REASONING_BEGINk.*?CRETIRE_REASONING_ENDk',
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _strip_think(cls, content: str) -> str:
|
||||
"""Strip chain-of-thought blocks from ``content``."""
|
||||
if not content:
|
||||
return content
|
||||
|
||||
import re
|
||||
|
||||
for pattern in cls._THINK_PATTERNS:
|
||||
content = re.sub(pattern, '', content, flags=re.DOTALL)
|
||||
return content.strip()
|
||||
|
||||
def _process_thinking_content(self, content: str, reasoning_content: str | None, remove_think: bool) -> str:
|
||||
"""Process thinking/reasoning content.
|
||||
|
||||
@@ -412,12 +248,20 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
|
||||
Returns:
|
||||
Processed content string
|
||||
"""
|
||||
if remove_think and content:
|
||||
content = self._strip_think(content)
|
||||
# Extract and handle thinking tags
|
||||
if content and 'CRETIRE_REASONING_BEGINk' in content and 'CRETIRE_REASONING_ENDk' in content:
|
||||
import re
|
||||
|
||||
if reasoning_content and not remove_think:
|
||||
content = f'<think>\n{reasoning_content}\n</think>\n{content or ""}'.strip()
|
||||
think_pattern = r'CRETIRE_REASONING_BEGINk(.*?)CRETIRE_REASONING_ENDk'
|
||||
|
||||
if remove_think:
|
||||
# Remove thinking tags and their content from output
|
||||
content = re.sub(think_pattern, '', content, flags=re.DOTALL).strip()
|
||||
# else: preserve thinking content as-is
|
||||
|
||||
# Handle separate reasoning_content field
|
||||
# Currently we don't include reasoning_content in user-facing output regardless of remove_think
|
||||
# because it's typically internal model reasoning, not user-visible thinking
|
||||
return content or ''
|
||||
|
||||
@staticmethod
|
||||
@@ -726,7 +570,6 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
|
||||
chunk_idx = 0
|
||||
role = 'assistant'
|
||||
tool_call_state: dict[int, dict[str, typing.Any]] = {}
|
||||
think_state = _ThinkStripState() if remove_think else None
|
||||
|
||||
try:
|
||||
response = await acompletion(**args)
|
||||
@@ -770,12 +613,6 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
|
||||
# Use reasoning_content as the displayed content
|
||||
delta_content = reasoning_content
|
||||
|
||||
if think_state is not None and delta_content:
|
||||
delta_content = think_state.feed(delta_content)
|
||||
if not delta_content:
|
||||
chunk_idx += 1
|
||||
continue
|
||||
|
||||
tool_calls = self._normalize_stream_tool_calls(delta.get('tool_calls'), tool_call_state)
|
||||
|
||||
if chunk_idx == 0 and not delta_content and not tool_calls:
|
||||
@@ -797,15 +634,6 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
|
||||
yield provider_message.MessageChunk(**chunk_data)
|
||||
chunk_idx += 1
|
||||
|
||||
if think_state is not None:
|
||||
pending_content = think_state.flush()
|
||||
if pending_content:
|
||||
yield provider_message.MessageChunk(
|
||||
role=role,
|
||||
content=pending_content,
|
||||
is_final=True,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
self._handle_litellm_error(e)
|
||||
|
||||
@@ -943,21 +771,16 @@ class LiteLLMRequester(requester.ProviderAPIRequester):
|
||||
if api_key:
|
||||
headers['Authorization'] = f'Bearer {api_key}'
|
||||
|
||||
request_args = dict(extra_args)
|
||||
rerank_url = request_args.pop('rerank_url', None)
|
||||
rerank_path = request_args.pop('rerank_path', 'rerank')
|
||||
|
||||
payload: dict[str, typing.Any] = {
|
||||
'model': model_name,
|
||||
'query': query,
|
||||
'documents': documents,
|
||||
'top_n': top_n,
|
||||
}
|
||||
if request_args:
|
||||
payload.update(request_args)
|
||||
if extra_args:
|
||||
payload.update(extra_args)
|
||||
|
||||
if not rerank_url:
|
||||
rerank_url = f'{base_url}/{str(rerank_path).strip("/")}'
|
||||
rerank_url = f'{base_url}/rerank'
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=timeout) as client:
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="TokenLab">
|
||||
<rect width="64" height="64" rx="14" fill="#111827"/>
|
||||
<path fill="#38bdf8" d="M17 14h30v8H36v28h-8V22H17z"/>
|
||||
<path fill="#a7f3d0" d="M40 30h8v20H28v-8h12z"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 265 B |
@@ -1,29 +0,0 @@
|
||||
apiVersion: v1
|
||||
kind: LLMAPIRequester
|
||||
metadata:
|
||||
name: tokenlab-chat-completions
|
||||
label:
|
||||
en_US: TokenLab
|
||||
zh_Hans: TokenLab
|
||||
icon: tokenlab.svg
|
||||
spec:
|
||||
litellm_provider: openai
|
||||
config:
|
||||
- name: base_url
|
||||
label:
|
||||
en_US: Base URL
|
||||
zh_Hans: 基础 URL
|
||||
type: string
|
||||
required: true
|
||||
default: https://api.tokenlab.sh/v1
|
||||
- name: timeout
|
||||
label:
|
||||
en_US: Timeout
|
||||
zh_Hans: 超时时间
|
||||
type: integer
|
||||
required: true
|
||||
default: 120
|
||||
alias: "tokenlab TokenLab gpt claude gemini deepseek qwen kimi minimax glm grok openai-compatible"
|
||||
support_type:
|
||||
- llm
|
||||
provider_category: maas
|
||||
@@ -49,23 +49,12 @@ def _model_has_ability(model: modelmgr_requester.RuntimeLLMModel, ability: str)
|
||||
class _StreamAccumulator:
|
||||
"""Accumulate streamed content and fragmented OpenAI-style tool calls."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
msg_sequence: int = 0,
|
||||
initial_content: str | None = None,
|
||||
remove_think: bool = False,
|
||||
):
|
||||
def __init__(self, msg_sequence: int = 0, initial_content: str | None = None):
|
||||
self.tool_calls_map: dict[str, provider_message.ToolCall] = {}
|
||||
self.msg_idx = 0
|
||||
self.accumulated_content = initial_content or ''
|
||||
self.last_role = 'assistant'
|
||||
self.msg_sequence = msg_sequence
|
||||
self.remove_think = remove_think
|
||||
self._think_state = None
|
||||
if remove_think:
|
||||
from ..modelmgr.requesters.litellmchat import _ThinkStripState
|
||||
|
||||
self._think_state = _ThinkStripState()
|
||||
|
||||
def add(self, msg: provider_message.MessageChunk) -> provider_message.MessageChunk | None:
|
||||
self.msg_idx += 1
|
||||
@@ -74,10 +63,7 @@ class _StreamAccumulator:
|
||||
self.last_role = msg.role
|
||||
|
||||
if msg.content:
|
||||
content = msg.content
|
||||
if self._think_state is not None:
|
||||
content = self._think_state.feed(content)
|
||||
self.accumulated_content += content
|
||||
self.accumulated_content += msg.content
|
||||
|
||||
if msg.tool_calls:
|
||||
for tool_call in msg.tool_calls:
|
||||
@@ -93,14 +79,11 @@ class _StreamAccumulator:
|
||||
if tool_call.function and tool_call.function.arguments:
|
||||
self.tool_calls_map[tool_call.id].function.arguments += tool_call.function.arguments
|
||||
|
||||
if msg.is_final:
|
||||
self._flush_think_state()
|
||||
|
||||
if self.msg_idx % 8 == 0 or msg.is_final:
|
||||
self.msg_sequence += 1
|
||||
return provider_message.MessageChunk(
|
||||
role=self.last_role,
|
||||
content=self._maybe_strip_think(self.accumulated_content),
|
||||
content=self.accumulated_content,
|
||||
tool_calls=list(self.tool_calls_map.values()) if (self.tool_calls_map and msg.is_final) else None,
|
||||
is_final=msg.is_final,
|
||||
msg_sequence=self.msg_sequence,
|
||||
@@ -109,29 +92,13 @@ class _StreamAccumulator:
|
||||
return None
|
||||
|
||||
def final_message(self) -> provider_message.MessageChunk:
|
||||
self._flush_think_state()
|
||||
return provider_message.MessageChunk(
|
||||
role=self.last_role,
|
||||
content=self._maybe_strip_think(self.accumulated_content),
|
||||
content=self.accumulated_content,
|
||||
tool_calls=list(self.tool_calls_map.values()) if self.tool_calls_map else None,
|
||||
msg_sequence=self.msg_sequence,
|
||||
)
|
||||
|
||||
def _maybe_strip_think(self, content: str) -> str:
|
||||
if not self.remove_think or not content:
|
||||
return content
|
||||
|
||||
from ..modelmgr.requesters.litellmchat import LiteLLMRequester
|
||||
|
||||
return LiteLLMRequester._strip_think(content)
|
||||
|
||||
def _flush_think_state(self) -> None:
|
||||
if self._think_state is None:
|
||||
return
|
||||
pending = self._think_state.flush()
|
||||
if pending:
|
||||
self.accumulated_content += pending
|
||||
|
||||
|
||||
@runner.runner_class('local-agent')
|
||||
class LocalAgentRunner(runner.RequestRunner):
|
||||
@@ -481,7 +448,7 @@ class LocalAgentRunner(runner.RequestRunner):
|
||||
except AttributeError:
|
||||
is_stream = False
|
||||
|
||||
remove_think = ((query.pipeline_config.get('output') or {}).get('misc') or {}).get('remove-think', False)
|
||||
remove_think = query.pipeline_config['output'].get('misc', '').get('remove-think')
|
||||
|
||||
# Build ordered candidate list (primary + fallbacks)
|
||||
candidates = await self._get_model_candidates(query)
|
||||
@@ -505,7 +472,7 @@ class LocalAgentRunner(runner.RequestRunner):
|
||||
final_msg = msg
|
||||
else:
|
||||
# Streaming: invoke with fallback
|
||||
stream_accumulator = _StreamAccumulator(msg_sequence=1, remove_think=remove_think)
|
||||
stream_accumulator = _StreamAccumulator(msg_sequence=1)
|
||||
|
||||
stream_src, use_llm_model = await self._invoke_stream_with_fallback(
|
||||
query,
|
||||
@@ -523,6 +490,7 @@ class LocalAgentRunner(runner.RequestRunner):
|
||||
final_msg = stream_accumulator.final_message()
|
||||
|
||||
pending_tool_calls = final_msg.tool_calls
|
||||
first_content = final_msg.content
|
||||
if isinstance(final_msg, provider_message.MessageChunk):
|
||||
first_end_sequence = final_msg.msg_sequence
|
||||
|
||||
@@ -605,14 +573,9 @@ class LocalAgentRunner(runner.RequestRunner):
|
||||
)
|
||||
|
||||
if is_stream:
|
||||
# Do NOT re-seed the accumulator with first_content:
|
||||
# the previous round's text was already pushed to the
|
||||
# platform adapter. Re-seeding would cause every
|
||||
# subsequent round to repeat the entire opening line,
|
||||
# which the platform then forwards as a duplicate message.
|
||||
stream_accumulator = _StreamAccumulator(
|
||||
msg_sequence=first_end_sequence,
|
||||
remove_think=remove_think,
|
||||
initial_content=first_content,
|
||||
)
|
||||
|
||||
tool_stream_src = use_llm_model.provider.invoke_llm_stream(
|
||||
|
||||
@@ -3,17 +3,14 @@ from __future__ import annotations
|
||||
import base64
|
||||
import enum
|
||||
import json
|
||||
import math
|
||||
import re
|
||||
import time
|
||||
import typing
|
||||
from contextlib import AsyncExitStack, asynccontextmanager
|
||||
from datetime import timedelta
|
||||
import traceback
|
||||
from langbot_plugin.api.entities.events import pipeline_query
|
||||
import sqlalchemy
|
||||
import asyncio
|
||||
import hashlib
|
||||
import httpx
|
||||
|
||||
import uuid as uuid_module
|
||||
@@ -29,13 +26,7 @@ from ....core import app
|
||||
import langbot_plugin.api.entities.builtin.resource.tool as resource_tool
|
||||
import langbot_plugin.api.entities.builtin.provider.message as provider_message
|
||||
from ....entity.persistence import mcp as persistence_mcp
|
||||
from .mcp_stdio import (
|
||||
BoxStdioSessionRuntime,
|
||||
MCPServerBoxConfig as MCPServerBoxConfig, # noqa: F401 - public re-export
|
||||
MCPSessionErrorPhase,
|
||||
_ColdStartRetry,
|
||||
_get_default_memory_mb,
|
||||
) # noqa: F401
|
||||
from .mcp_stdio import BoxStdioSessionRuntime, MCPServerBoxConfig, MCPSessionErrorPhase, _ColdStartRetry # noqa: F401
|
||||
|
||||
# Synthesized LLM tools for MCP resources (not from server tools/list).
|
||||
# Dispatched in MCPLoader.invoke_tool; placeholder func on LLMTool is never used.
|
||||
@@ -53,7 +44,6 @@ MCP_RESOURCE_CONTEXT_MAX_BYTES = 96 * 1024
|
||||
MCP_RESOURCE_TRACE_QUERY_KEY = '_mcp_resource_reads'
|
||||
MCP_RESOURCE_LINKS_QUERY_KEY = '_mcp_resource_links'
|
||||
MCP_RESOURCE_CONTEXT_QUERY_KEY = '_mcp_resource_context'
|
||||
MCP_TOOL_CALL_TIMEOUT_DEFAULT_SECONDS = 300.0
|
||||
|
||||
TEXT_LIKE_MIME_TYPES = {
|
||||
'application/json',
|
||||
@@ -206,20 +196,6 @@ class _TransportReconnect(Exception):
|
||||
"""
|
||||
|
||||
|
||||
class _CallerReconnect(Exception):
|
||||
"""Internal signal: a tool/resource call hit a server-expired session
|
||||
(e.g. a UDP/HTTP MCP server's own session timeout) and asked the owning
|
||||
lifecycle loop to rebuild the connection.
|
||||
|
||||
Like _TransportReconnect, this does NOT consume the fatal retry budget —
|
||||
the server-side timeout is expected, recurring behavior, not a failure.
|
||||
"""
|
||||
|
||||
|
||||
class MCPToolCallTimeoutError(TimeoutError):
|
||||
"""An MCP tool call exceeded its configured per-server deadline."""
|
||||
|
||||
|
||||
class RuntimeMCPSession:
|
||||
"""运行时 MCP 会话"""
|
||||
|
||||
@@ -269,9 +245,6 @@ class RuntimeMCPSession:
|
||||
self.ap = ap
|
||||
self.enable = enable
|
||||
self.session = None
|
||||
self.tool_call_timeout_sec = self._parse_tool_call_timeout(
|
||||
server_config.get('tool_call_timeout_sec', MCP_TOOL_CALL_TIMEOUT_DEFAULT_SECONDS)
|
||||
)
|
||||
|
||||
# Transient test sessions (created from the config page "test" button,
|
||||
# which carry no persisted server UUID) must NOT share the live
|
||||
@@ -292,12 +265,6 @@ class RuntimeMCPSession:
|
||||
self._lifecycle_task = None
|
||||
self._shutdown_event = asyncio.Event()
|
||||
self._ready_event = asyncio.Event()
|
||||
# Signaled by callers (invoke_mcp_tool / read_resource_envelope) when
|
||||
# they see a server-expired session; the lifecycle loop reconnects and
|
||||
# sets _reconnected_event so all callers waiting on this cycle resume
|
||||
# together, instead of each racing to rebuild the session itself.
|
||||
self._reconnect_event = asyncio.Event()
|
||||
self._reconnected_event: asyncio.Event | None = None
|
||||
# Set transiently when a WS transport drop should NOT stop the managed
|
||||
# process (it will be re-attached on the next initialize()).
|
||||
self._preserve_managed_process = False
|
||||
@@ -312,39 +279,21 @@ class RuntimeMCPSession:
|
||||
self._box_stdio_runtime = BoxStdioSessionRuntime(self)
|
||||
self.box_config = self._box_stdio_runtime.config
|
||||
|
||||
def _parse_tool_call_timeout(self, value: typing.Any) -> float:
|
||||
"""Return a safe tool-call timeout; zero explicitly disables it."""
|
||||
try:
|
||||
timeout = -1 if isinstance(value, bool) else float(value)
|
||||
if timeout > 0:
|
||||
# Validate the exact conversion used for each call here, so a
|
||||
# finite-but-enormous manual config cannot fail at invocation.
|
||||
timedelta(seconds=timeout)
|
||||
except (TypeError, ValueError, OverflowError):
|
||||
timeout = -1
|
||||
|
||||
if not math.isfinite(timeout) or timeout < 0:
|
||||
self.ap.logger.warning(
|
||||
f'Invalid MCP tool call timeout {value!r} for {self.server_name}; '
|
||||
f'using {MCP_TOOL_CALL_TIMEOUT_DEFAULT_SECONDS:g} seconds'
|
||||
)
|
||||
return MCP_TOOL_CALL_TIMEOUT_DEFAULT_SECONDS
|
||||
return timeout
|
||||
|
||||
async def _init_stdio_python_server(self):
|
||||
if self._uses_box_stdio():
|
||||
await self._box_stdio_runtime.initialize()
|
||||
return
|
||||
|
||||
# Box is configured but explicitly disabled. Refuse stdio MCP rather
|
||||
# than silently falling through to host-stdio — the operator asked for
|
||||
# the sandbox and the failure mode should be visible. An enabled Box
|
||||
# that is reconnecting is handled above and waits for availability.
|
||||
# Box is configured (ap.box_service exists) but currently unavailable
|
||||
# (disabled by config or connection failed). Refuse stdio MCP rather
|
||||
# than silently falling through to host-stdio — the operator asked
|
||||
# for the sandbox and the failure mode should be visible.
|
||||
#
|
||||
# Set ``error_phase = BOX_UNAVAILABLE`` BEFORE raising so the retry
|
||||
# wrapper can distinguish a deliberately disabled Box from an enabled
|
||||
# runtime that is still reconnecting. Keep the message itself short —
|
||||
# the frontend ignores it for this phase.
|
||||
# wrapper can short-circuit (retrying is pointless when Box is
|
||||
# deliberately off) and the frontend can render a localized,
|
||||
# actionable message instead of this raw RuntimeError. Keep the
|
||||
# message itself short — the frontend ignores it for this phase.
|
||||
box_service = getattr(self.ap, 'box_service', None)
|
||||
if box_service is not None and not getattr(box_service, 'available', False):
|
||||
self.error_phase = MCPSessionErrorPhase.BOX_UNAVAILABLE
|
||||
@@ -480,19 +429,12 @@ class RuntimeMCPSession:
|
||||
if self._uses_box_stdio():
|
||||
monitor_task = asyncio.create_task(self._box_stdio_runtime.monitor_process_health())
|
||||
shutdown_task = asyncio.create_task(self._shutdown_event.wait())
|
||||
reconnect_task = asyncio.create_task(self._reconnect_event.wait())
|
||||
done, pending = await asyncio.wait(
|
||||
[shutdown_task, monitor_task, reconnect_task],
|
||||
[shutdown_task, monitor_task],
|
||||
return_when=asyncio.FIRST_COMPLETED,
|
||||
)
|
||||
for task in pending:
|
||||
task.cancel()
|
||||
if reconnect_task in done and not self._shutdown_event.is_set():
|
||||
self._reconnect_event.clear()
|
||||
self.ap.logger.info(
|
||||
f'MCP session {self.server_name}: caller requested reconnect (server session expired)'
|
||||
)
|
||||
raise _CallerReconnect('Caller requested reconnect after session expiry')
|
||||
for task in done:
|
||||
if task is monitor_task and not self._shutdown_event.is_set():
|
||||
# The monitor completed. This is EITHER the managed
|
||||
@@ -520,20 +462,7 @@ class RuntimeMCPSession:
|
||||
self.error_phase = MCPSessionErrorPhase.RUNTIME
|
||||
raise Exception('Box managed process exited unexpectedly')
|
||||
else:
|
||||
shutdown_task = asyncio.create_task(self._shutdown_event.wait())
|
||||
reconnect_task = asyncio.create_task(self._reconnect_event.wait())
|
||||
done, pending = await asyncio.wait(
|
||||
[shutdown_task, reconnect_task],
|
||||
return_when=asyncio.FIRST_COMPLETED,
|
||||
)
|
||||
for task in pending:
|
||||
task.cancel()
|
||||
if reconnect_task in done and not self._shutdown_event.is_set():
|
||||
self._reconnect_event.clear()
|
||||
self.ap.logger.info(
|
||||
f'MCP session {self.server_name}: caller requested reconnect (server session expired)'
|
||||
)
|
||||
raise _CallerReconnect('Caller requested reconnect after session expiry')
|
||||
await self._shutdown_event.wait()
|
||||
|
||||
except _ColdStartRetry:
|
||||
# Cold-start in progress: set the preserve flag BEFORE the finally
|
||||
@@ -592,43 +521,6 @@ class RuntimeMCPSession:
|
||||
self.error_phase = None
|
||||
await asyncio.sleep(1)
|
||||
continue
|
||||
except _CallerReconnect:
|
||||
# A tool/resource call hit a server-expired session and asked us
|
||||
# to rebuild the connection. Same treatment as _TransportReconnect:
|
||||
# reconnect immediately WITHOUT consuming the fatal retry budget.
|
||||
# Wake any callers waiting in _trigger_reconnect() regardless of
|
||||
# outcome so they don't block for the full timeout.
|
||||
reconnected_event = self._reconnected_event
|
||||
if self._shutdown_event.is_set():
|
||||
if reconnected_event is not None:
|
||||
reconnected_event.set()
|
||||
return
|
||||
self.status = MCPSessionStatus.CONNECTING
|
||||
self.error_message = None
|
||||
self.error_phase = None
|
||||
try:
|
||||
if self.server_config['mode'] == 'stdio':
|
||||
await self._init_stdio_python_server()
|
||||
elif self.server_config['mode'] == 'remote':
|
||||
await self._init_remote_server()
|
||||
elif self.server_config['mode'] == 'sse':
|
||||
await self._init_sse_server()
|
||||
elif self.server_config['mode'] == 'http':
|
||||
await self._init_streamable_http_server()
|
||||
await self.refresh()
|
||||
self.status = MCPSessionStatus.CONNECTED
|
||||
self.ap.logger.info(f'MCP session {self.server_name} reconnected successfully after session expiry')
|
||||
except Exception as reconnect_err:
|
||||
self.status = MCPSessionStatus.ERROR
|
||||
self.error_message = str(reconnect_err)
|
||||
self.ap.logger.error(
|
||||
f'MCP session {self.server_name}: reconnect after session expiry failed: '
|
||||
f'{self._describe_exception(reconnect_err)}'
|
||||
)
|
||||
finally:
|
||||
if reconnected_event is not None:
|
||||
reconnected_event.set()
|
||||
continue
|
||||
except _ColdStartRetry as e:
|
||||
# The managed process is alive but still cold-starting (e.g.
|
||||
# `npx -y <pkg>` is still installing) and cannot yet answer the
|
||||
@@ -648,29 +540,17 @@ class RuntimeMCPSession:
|
||||
await asyncio.sleep(2)
|
||||
continue
|
||||
except Exception as e:
|
||||
self.retry_count = attempt + 1
|
||||
if self._shutdown_event.is_set():
|
||||
return # Shutdown requested, don't retry
|
||||
# BOX_UNAVAILABLE is a deliberate refusal, not a transient
|
||||
# failure — retrying produces log spam and a misleading
|
||||
# "Failed after N attempts" message. Surface it immediately.
|
||||
if self.error_phase == MCPSessionErrorPhase.BOX_UNAVAILABLE:
|
||||
box_service = getattr(self.ap, 'box_service', None)
|
||||
if box_service is not None and getattr(box_service, 'enabled', True):
|
||||
# Box is configured and may recover independently of
|
||||
# this MCP session. Keep retrying without consuming the
|
||||
# fatal budget; _wait_for_box_runtime() rate-limits the
|
||||
# loop to one warning per startup timeout.
|
||||
self.status = MCPSessionStatus.CONNECTING
|
||||
self.error_message = None
|
||||
self.error_phase = None
|
||||
await asyncio.sleep(1)
|
||||
continue
|
||||
# Explicitly disabled Box is a deliberate refusal, not a
|
||||
# transient failure. Surface it immediately without log
|
||||
# spam or a misleading "Failed after N attempts" message.
|
||||
self.retry_count = attempt + 1
|
||||
self.status = MCPSessionStatus.ERROR
|
||||
self.error_message = str(e)
|
||||
self._ready_event.set()
|
||||
return
|
||||
self.retry_count = attempt + 1
|
||||
if attempt >= self._MAX_RETRIES:
|
||||
self.status = MCPSessionStatus.ERROR
|
||||
self.error_message = f'Failed after {self._MAX_RETRIES + 1} attempts: {self._describe_exception(e)}'
|
||||
@@ -743,47 +623,6 @@ class RuntimeMCPSession:
|
||||
return True
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _is_session_terminated(exc: BaseException) -> bool:
|
||||
"""Whether exc indicates the server-side session expired.
|
||||
|
||||
Long-lived MCP servers (notably UDP/HTTP transports) commonly enforce
|
||||
their own session timeout (e.g. ~30 minutes); once it fires, any call
|
||||
on the cached session raises this rather than a transport-level error.
|
||||
"""
|
||||
for leaf in RuntimeMCPSession._iter_exception_leaves(exc):
|
||||
msg = str(leaf).lower()
|
||||
if 'session terminated' in msg or 'session expired' in msg:
|
||||
return True
|
||||
return False
|
||||
|
||||
_RECONNECT_WAIT_TIMEOUT = 30.0
|
||||
|
||||
async def _trigger_reconnect(self) -> bool:
|
||||
"""Ask the owning lifecycle loop to rebuild the session and wait for it.
|
||||
|
||||
Concurrent callers that hit the timeout at the same time all signal
|
||||
the same _reconnect_event and await the same _reconnected_event, so
|
||||
the lifecycle loop reconnects once and every caller resumes together
|
||||
— instead of each caller racing to rebuild the session itself.
|
||||
|
||||
Returns True if reconnection succeeded within the timeout.
|
||||
"""
|
||||
if self._shutdown_event.is_set():
|
||||
return False
|
||||
|
||||
if self._reconnected_event is None or self._reconnected_event.is_set():
|
||||
self._reconnected_event = asyncio.Event()
|
||||
reconnected_event = self._reconnected_event
|
||||
self._reconnect_event.set()
|
||||
|
||||
try:
|
||||
await asyncio.wait_for(reconnected_event.wait(), timeout=self._RECONNECT_WAIT_TIMEOUT)
|
||||
return self.status == MCPSessionStatus.CONNECTED
|
||||
except asyncio.TimeoutError:
|
||||
self.ap.logger.warning(f'MCP session {self.server_name} reconnect timed out')
|
||||
return False
|
||||
|
||||
_MONITOR_POLL_INTERVAL = 5
|
||||
_MONITOR_MAX_CONSECUTIVE_ERRORS = 3
|
||||
|
||||
@@ -992,58 +831,21 @@ class RuntimeMCPSession:
|
||||
arguments: dict,
|
||||
query: pipeline_query.Query | None = None,
|
||||
) -> list[provider_message.ContentElement]:
|
||||
for attempt in range(2):
|
||||
if not self.session:
|
||||
raise Exception('MCP session is not connected')
|
||||
if not self.session:
|
||||
raise Exception('MCP session is not connected')
|
||||
|
||||
try:
|
||||
read_timeout = timedelta(seconds=self.tool_call_timeout_sec) if self.tool_call_timeout_sec > 0 else None
|
||||
result = await self.session.call_tool(
|
||||
tool_name,
|
||||
arguments,
|
||||
read_timeout_seconds=read_timeout,
|
||||
)
|
||||
except Exception as e:
|
||||
if self._is_tool_call_timeout(e):
|
||||
self.ap.logger.warning(
|
||||
f'MCP tool {tool_name} on {self.server_name} timed out after '
|
||||
f'{self.tool_call_timeout_sec:g} seconds'
|
||||
)
|
||||
raise MCPToolCallTimeoutError(
|
||||
f"MCP tool '{tool_name}' on server '{self.server_name}' timed out after "
|
||||
f'{self.tool_call_timeout_sec:g} seconds'
|
||||
) from e
|
||||
if attempt == 0 and self._is_session_terminated(e):
|
||||
self.ap.logger.warning(
|
||||
f'MCP tool {tool_name} on {self.server_name} got session terminated, triggering reconnect...'
|
||||
)
|
||||
if await self._trigger_reconnect():
|
||||
continue
|
||||
raise
|
||||
|
||||
if result.isError:
|
||||
error_texts = []
|
||||
for content in result.content:
|
||||
if getattr(content, 'type', '') == 'text':
|
||||
error_texts.append(content.text)
|
||||
raise Exception('\n'.join(error_texts) if error_texts else 'Unknown error from MCP tool')
|
||||
|
||||
result_contents: list[provider_message.ContentElement] = []
|
||||
result = await self.session.call_tool(tool_name, arguments)
|
||||
if result.isError:
|
||||
error_texts = []
|
||||
for content in result.content:
|
||||
result_contents.extend(self._content_to_provider_elements(content, query=query, source_tool=tool_name))
|
||||
return result_contents
|
||||
if getattr(content, 'type', '') == 'text':
|
||||
error_texts.append(content.text)
|
||||
raise Exception('\n'.join(error_texts) if error_texts else 'Unknown error from MCP tool')
|
||||
|
||||
raise Exception('MCP session is not connected')
|
||||
|
||||
@staticmethod
|
||||
def _is_tool_call_timeout(exc: BaseException) -> bool:
|
||||
"""Recognize the MCP SDK's per-request timeout without retrying it."""
|
||||
return any(
|
||||
isinstance(leaf, McpError)
|
||||
and leaf.error.code == httpx.codes.REQUEST_TIMEOUT
|
||||
and leaf.error.message.startswith('Timed out while waiting for response')
|
||||
for leaf in RuntimeMCPSession._iter_exception_leaves(exc)
|
||||
)
|
||||
result_contents: list[provider_message.ContentElement] = []
|
||||
for content in result.content:
|
||||
result_contents.extend(self._content_to_provider_elements(content, query=query, source_tool=tool_name))
|
||||
return result_contents
|
||||
|
||||
def get_tools(self) -> list[resource_tool.LLMTool]:
|
||||
return self.functions
|
||||
@@ -1108,21 +910,7 @@ class RuntimeMCPSession:
|
||||
self._record_resource_read_trace(query, envelope)
|
||||
return envelope
|
||||
|
||||
result = None
|
||||
for attempt in range(2):
|
||||
if not self.session:
|
||||
raise Exception('MCP session is not connected')
|
||||
try:
|
||||
result = await self.session.read_resource(AnyUrl(uri))
|
||||
break
|
||||
except Exception as e:
|
||||
if attempt == 0 and self._is_session_terminated(e):
|
||||
self.ap.logger.warning(
|
||||
f'MCP resource read on {self.server_name} got session terminated, triggering reconnect...'
|
||||
)
|
||||
if await self._trigger_reconnect():
|
||||
continue
|
||||
raise
|
||||
result = await self.session.read_resource(AnyUrl(uri))
|
||||
contents: list[dict] = []
|
||||
total_bytes = 0
|
||||
truncated_any = False
|
||||
@@ -1277,34 +1065,15 @@ class RuntimeMCPSession:
|
||||
return self._box_stdio_runtime.uses_box_stdio()
|
||||
|
||||
def _build_box_session_id(self) -> str:
|
||||
# Compatible MCP servers share a session and remain isolated by
|
||||
# process_id. A server with a different immutable resource profile gets
|
||||
# another session; Docker/E2B cannot change memory/image/etc. after a
|
||||
# session has been created.
|
||||
config = self._box_stdio_runtime.config
|
||||
default_memory = _get_default_memory_mb(self.ap)
|
||||
profile = {
|
||||
'image': config.image,
|
||||
'network': config.network,
|
||||
'host_path_mode': config.host_path_mode,
|
||||
'cpus': config.cpus,
|
||||
'memory_mb': config.memory_mb or default_memory,
|
||||
'pids_limit': config.pids_limit,
|
||||
'read_only_rootfs': (config.read_only_rootfs if config.read_only_rootfs is not None else False),
|
||||
}
|
||||
default_profile = {
|
||||
'image': None,
|
||||
'network': 'on',
|
||||
'host_path_mode': 'ro',
|
||||
'cpus': None,
|
||||
'memory_mb': default_memory,
|
||||
'pids_limit': None,
|
||||
'read_only_rootfs': False,
|
||||
}
|
||||
if profile == default_profile:
|
||||
return 'mcp-shared'
|
||||
digest = hashlib.sha256(json.dumps(profile, sort_keys=True).encode('utf-8')).hexdigest()[:12]
|
||||
return f'mcp-shared-{digest}'
|
||||
# Both live servers and transient config-page tests share ONE Box
|
||||
# session ('mcp-shared'). A test therefore reuses the already-running
|
||||
# container (and, for an existing server, its live managed process)
|
||||
# instead of paying a full per-test session cold-start + dependency
|
||||
# bootstrap. Isolation between a test and the live servers is provided
|
||||
# at the *process* level: each server/test has its own process_id and a
|
||||
# test only ever stops its own process_id (see cleanup_session), so it
|
||||
# never disturbs another server's process or the shared session itself.
|
||||
return 'mcp-shared'
|
||||
|
||||
def _rewrite_path(self, path: str, host_path: str | None) -> str:
|
||||
return self._box_stdio_runtime.rewrite_path(path, host_path)
|
||||
@@ -1887,23 +1656,11 @@ class MCPLoader(loader.ToolLoader):
|
||||
async def shutdown(self):
|
||||
"""关闭所有工具"""
|
||||
self.ap.logger.info('Shutting down all MCP sessions...')
|
||||
|
||||
hosted_tasks = [task for task in self._hosted_mcp_tasks if not task.done()]
|
||||
for task in hosted_tasks:
|
||||
task.cancel()
|
||||
if hosted_tasks:
|
||||
await asyncio.gather(*hosted_tasks, return_exceptions=True)
|
||||
self._hosted_mcp_tasks.clear()
|
||||
|
||||
async def shutdown_session(server_name: str, session: RuntimeMCPSession) -> None:
|
||||
for server_name, session in list(self.sessions.items()):
|
||||
try:
|
||||
await session.shutdown()
|
||||
self.ap.logger.debug(f'Shutdown MCP session: {server_name}')
|
||||
except Exception as e:
|
||||
self.ap.logger.error(f'Error shutting down MCP session {server_name}: {e}\n{traceback.format_exc()}')
|
||||
|
||||
await asyncio.gather(
|
||||
*(shutdown_session(server_name, session) for server_name, session in list(self.sessions.items()))
|
||||
)
|
||||
self.sessions.clear()
|
||||
self.ap.logger.info('All MCP sessions shutdown complete')
|
||||
|
||||
@@ -185,10 +185,11 @@ class BoxStdioSessionRuntime:
|
||||
box_service = getattr(self.ap, 'box_service', None)
|
||||
if box_service is None:
|
||||
return False
|
||||
# An enabled Box service remains the required transport while it is
|
||||
# reconnecting. initialize() waits for availability instead of
|
||||
# permanently failing the MCP server or falling through to host stdio.
|
||||
return bool(getattr(box_service, 'enabled', True))
|
||||
# When Box is configured but currently unavailable (disabled or
|
||||
# connection failed), do NOT silently fall through to host-stdio —
|
||||
# that would bypass the sandbox the operator asked for. The caller
|
||||
# is expected to refuse the stdio MCP server with a clear error.
|
||||
return bool(getattr(box_service, 'available', False))
|
||||
|
||||
async def initialize(self) -> None:
|
||||
await self._wait_for_box_runtime()
|
||||
@@ -487,7 +488,7 @@ class BoxStdioSessionRuntime:
|
||||
)
|
||||
warned = True
|
||||
if asyncio.get_running_loop().time() >= deadline:
|
||||
self.owner.error_phase = MCPSessionErrorPhase.BOX_UNAVAILABLE
|
||||
self.owner.error_phase = MCPSessionErrorPhase.SESSION_CREATE
|
||||
raise Exception(f'Box runtime is not available after {int(timeout_sec)} seconds')
|
||||
await asyncio.sleep(1)
|
||||
|
||||
|
||||
@@ -57,7 +57,7 @@ class NativeToolLoader(loader.ToolLoader):
|
||||
return await is_box_backend_available(self.ap)
|
||||
|
||||
async def get_tools(self, bound_plugins: list[str] | None = None) -> list[resource_tool.LLMTool]:
|
||||
if not await self._is_sandbox_available():
|
||||
if not self._is_sandbox_available():
|
||||
return []
|
||||
if self._tools is None:
|
||||
self._tools = [
|
||||
@@ -71,7 +71,7 @@ class NativeToolLoader(loader.ToolLoader):
|
||||
return list(self._tools)
|
||||
|
||||
async def has_tool(self, name: str) -> bool:
|
||||
return name in _ALL_TOOL_NAMES and await self._is_sandbox_available()
|
||||
return name in _ALL_TOOL_NAMES and self._is_sandbox_available()
|
||||
|
||||
async def invoke_tool(self, name: str, parameters: dict, query: pipeline_query.Query):
|
||||
if name == EXEC_TOOL_NAME:
|
||||
@@ -652,9 +652,12 @@ else:
|
||||
if callable(refresh_skill):
|
||||
refresh_skill(selected_skill.get('name', ''))
|
||||
|
||||
async def _is_sandbox_available(self) -> bool:
|
||||
"""Refresh backend availability so Box reconnects restore tool exposure."""
|
||||
self._backend_available = await self._check_backend_available()
|
||||
def _is_sandbox_available(self) -> bool:
|
||||
"""Check if sandbox backend is available.
|
||||
|
||||
This checks the cached backend availability from initialization,
|
||||
not just whether the box_service process is running.
|
||||
"""
|
||||
return bool(self._backend_available)
|
||||
|
||||
def _build_exec_tool(self) -> resource_tool.LLMTool:
|
||||
|
||||
@@ -49,27 +49,19 @@ class SkillToolLoader(loader.ToolLoader):
|
||||
return await is_box_backend_available(self.ap)
|
||||
|
||||
async def get_tools(self, bound_plugins: list[str] | None = None) -> list[resource_tool.LLMTool]:
|
||||
if not await self._is_available():
|
||||
if not self._is_available():
|
||||
return []
|
||||
if not self._tools:
|
||||
self._tools = [
|
||||
self._build_activate_skill_tool(),
|
||||
self._build_register_skill_tool(),
|
||||
]
|
||||
return list(self._tools)
|
||||
|
||||
async def has_tool(self, name: str) -> bool:
|
||||
return await self._is_available() and name in SKILL_TOOL_NAMES
|
||||
return self._is_available() and name in SKILL_TOOL_NAMES
|
||||
|
||||
async def _is_available(self) -> bool:
|
||||
def _is_available(self) -> bool:
|
||||
"""Check if skill tools should be available.
|
||||
|
||||
Skill tools require both a skill manager and a sandbox backend.
|
||||
"""
|
||||
if not self._has_skill_manager():
|
||||
return False
|
||||
self._sandbox_available = await self._check_sandbox_available()
|
||||
return self._sandbox_available
|
||||
return self._has_skill_manager() and self._sandbox_available
|
||||
|
||||
async def invoke_tool(self, name: str, parameters: dict, query) -> typing.Any:
|
||||
if name == ACTIVATE_SKILL_TOOL_NAME:
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import os
|
||||
import sys
|
||||
from typing import TYPE_CHECKING, Awaitable, Callable
|
||||
@@ -28,8 +27,6 @@ class ManagedRuntimeConnector:
|
||||
self.ap = ap
|
||||
self.runtime_subprocess = None
|
||||
self.runtime_subprocess_task = None
|
||||
self._lifecycle_lock = asyncio.Lock()
|
||||
self._closing = False
|
||||
|
||||
async def _start_runtime_subprocess(self, *args: str) -> None:
|
||||
"""Launch a local runtime as a subprocess of the current Python interpreter.
|
||||
@@ -89,23 +86,3 @@ class ManagedRuntimeConnector:
|
||||
if self.runtime_subprocess_task is not None:
|
||||
self.runtime_subprocess_task.cancel()
|
||||
self.runtime_subprocess_task = None
|
||||
|
||||
async def _close_managed_subprocess(self) -> None:
|
||||
"""Terminate, escalate, and reap the optional owned subprocess."""
|
||||
process = self.runtime_subprocess
|
||||
wait_task = self.runtime_subprocess_task
|
||||
if process is not None and process.returncode is None:
|
||||
with contextlib.suppress(ProcessLookupError):
|
||||
process.terminate()
|
||||
try:
|
||||
await asyncio.wait_for(process.wait(), timeout=3)
|
||||
except asyncio.TimeoutError:
|
||||
with contextlib.suppress(ProcessLookupError):
|
||||
process.kill()
|
||||
await process.wait()
|
||||
if wait_task is not None and wait_task is not asyncio.current_task():
|
||||
if not wait_task.done():
|
||||
wait_task.cancel()
|
||||
await asyncio.gather(wait_task, return_exceptions=True)
|
||||
self.runtime_subprocess = None
|
||||
self.runtime_subprocess_task = None
|
||||
|
||||
@@ -119,8 +119,7 @@ class ValkeySearchVectorDatabase(VectorDatabase):
|
||||
def __init__(self, ap: app.Application):
|
||||
if not VALKEY_SEARCH_AVAILABLE:
|
||||
raise ImportError(
|
||||
'valkey-glide is not installed or is unavailable on this platform. '
|
||||
"On Linux or macOS, install it with: pip install 'valkey-glide>=2.4.1,<3.0.0'"
|
||||
"valkey-glide is not installed. Install it with: pip install 'valkey-glide>=2.4.1,<3.0.0'"
|
||||
)
|
||||
|
||||
self.ap = ap
|
||||
|
||||
@@ -143,11 +143,6 @@ box:
|
||||
backend: 'local' # 'local' (Docker/nsjail), 'docker', 'nsjail', or 'e2b'. Can be written via BOX__BACKEND.
|
||||
runtime:
|
||||
endpoint: '' # External Box Runtime base URL, e.g. 'ws://127.0.0.1:5410'. Leave empty for local auto-managed runtime.
|
||||
limits:
|
||||
max_sessions: 64 # Includes persistent sessions. New sessions fail explicitly when this cap is reached.
|
||||
max_managed_processes: 64 # Maximum concurrently running stdio MCP / managed processes.
|
||||
max_completed_processes: 256 # Global cap for retained exited-process diagnostics.
|
||||
completed_process_retention_sec: 300 # Keep exited-process diagnostics before releasing memory.
|
||||
local:
|
||||
profile: 'default'
|
||||
image: '' # Custom local sandbox image. Leave empty to use the profile default.
|
||||
|
||||
@@ -303,60 +303,11 @@
|
||||
'<svg viewBox="0 0 24 24"><path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8l-6-6zM6 20V4h7v5h5v11H6z"/><path d="M8 17l2.5-3.5L13 17l2-2.5L18 17H8z"/></svg>';
|
||||
|
||||
// ========== State ==========
|
||||
function createSessionId() {
|
||||
if (window.crypto && typeof window.crypto.randomUUID === "function") {
|
||||
return window.crypto.randomUUID();
|
||||
}
|
||||
|
||||
var bytes = new Uint8Array(16);
|
||||
if (!window.crypto || typeof window.crypto.getRandomValues !== "function") {
|
||||
throw new Error("Secure random number generation is unavailable");
|
||||
}
|
||||
window.crypto.getRandomValues(bytes);
|
||||
bytes[6] = (bytes[6] & 15) | 64;
|
||||
bytes[8] = (bytes[8] & 63) | 128;
|
||||
var hex = Array.prototype.map
|
||||
.call(bytes, function (value) {
|
||||
return value.toString(16).padStart(2, "0");
|
||||
})
|
||||
.join("");
|
||||
return (
|
||||
hex.slice(0, 8) +
|
||||
"-" +
|
||||
hex.slice(8, 12) +
|
||||
"-" +
|
||||
hex.slice(12, 16) +
|
||||
"-" +
|
||||
hex.slice(16, 20) +
|
||||
"-" +
|
||||
hex.slice(20)
|
||||
);
|
||||
}
|
||||
|
||||
function getOrCreateSessionId() {
|
||||
var storageKey = "langbot_embed_session_" + CONFIG.botUuid;
|
||||
try {
|
||||
var stored = window.sessionStorage.getItem(storageKey);
|
||||
if (
|
||||
/^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/.test(
|
||||
stored || "",
|
||||
)
|
||||
)
|
||||
return stored;
|
||||
var created = createSessionId();
|
||||
window.sessionStorage.setItem(storageKey, created);
|
||||
return created;
|
||||
} catch (e) {
|
||||
return createSessionId();
|
||||
}
|
||||
}
|
||||
|
||||
var state = {
|
||||
isOpen: false,
|
||||
isConnected: false,
|
||||
ws: null,
|
||||
connectionId: null,
|
||||
sessionId: getOrCreateSessionId(),
|
||||
reconnectAttempts: 0,
|
||||
heartbeatTimer: null,
|
||||
messages: [],
|
||||
@@ -364,9 +315,6 @@
|
||||
isStreaming: false,
|
||||
streamingMsgId: null,
|
||||
historyLoaded: false,
|
||||
hasConnected: false,
|
||||
messageVersion: 0,
|
||||
historyReloadTimer: null,
|
||||
pendingImage: null,
|
||||
feedbackState: {},
|
||||
};
|
||||
@@ -525,9 +473,7 @@
|
||||
"/api/v1/embed/" +
|
||||
CONFIG.botUuid +
|
||||
"/ws/connect?session_type=" +
|
||||
CONFIG.sessionType +
|
||||
"&session_id=" +
|
||||
encodeURIComponent(state.sessionId);
|
||||
CONFIG.sessionType;
|
||||
|
||||
try {
|
||||
state.ws = new WebSocket(url);
|
||||
@@ -574,8 +520,6 @@
|
||||
case "connected":
|
||||
state.isConnected = true;
|
||||
state.connectionId = data.connection_id;
|
||||
if (state.hasConnected) loadHistory(true);
|
||||
state.hasConnected = true;
|
||||
updateStatusDot();
|
||||
updateSendBtn();
|
||||
break;
|
||||
@@ -643,7 +587,6 @@
|
||||
|
||||
if (existingIdx >= 0) {
|
||||
state.messages[existingIdx] = msg;
|
||||
state.messageVersion++;
|
||||
updateMessageEl(existingIdx, msg);
|
||||
} else {
|
||||
addMessage(msg);
|
||||
@@ -713,27 +656,16 @@
|
||||
}
|
||||
|
||||
// ========== Message History ==========
|
||||
function scheduleHistoryReload() {
|
||||
if (state.historyReloadTimer) clearTimeout(state.historyReloadTimer);
|
||||
state.historyReloadTimer = setTimeout(function () {
|
||||
state.historyReloadTimer = null;
|
||||
loadHistory(true);
|
||||
}, 100);
|
||||
}
|
||||
|
||||
function loadHistory(force) {
|
||||
if (state.historyLoaded && !force) return;
|
||||
function loadHistory() {
|
||||
if (state.historyLoaded) return;
|
||||
state.historyLoaded = true;
|
||||
var messageVersion = state.messageVersion;
|
||||
|
||||
var url =
|
||||
CONFIG.baseUrl +
|
||||
"/api/v1/embed/" +
|
||||
CONFIG.botUuid +
|
||||
"/messages/" +
|
||||
CONFIG.sessionType +
|
||||
"?session_id=" +
|
||||
encodeURIComponent(state.sessionId);
|
||||
CONFIG.sessionType;
|
||||
var headers = {};
|
||||
if (state.sessionToken)
|
||||
headers["Authorization"] = "Bearer " + state.sessionToken;
|
||||
@@ -743,16 +675,6 @@
|
||||
})
|
||||
.then(function (json) {
|
||||
if (json.code === 0 && json.data && json.data.messages) {
|
||||
if (force && messageVersion !== state.messageVersion) {
|
||||
scheduleHistoryReload();
|
||||
return;
|
||||
}
|
||||
if (force) {
|
||||
state.messages = [];
|
||||
state.isStreaming = false;
|
||||
state.streamingMsgId = null;
|
||||
renderMessages();
|
||||
}
|
||||
var msgs = json.data.messages;
|
||||
for (var i = 0; i < msgs.length; i++) {
|
||||
addMessage(msgs[i], true);
|
||||
@@ -771,16 +693,13 @@
|
||||
"/api/v1/embed/" +
|
||||
CONFIG.botUuid +
|
||||
"/reset/" +
|
||||
CONFIG.sessionType +
|
||||
"?session_id=" +
|
||||
encodeURIComponent(state.sessionId);
|
||||
CONFIG.sessionType;
|
||||
var headers = {};
|
||||
if (state.sessionToken)
|
||||
headers["Authorization"] = "Bearer " + state.sessionToken;
|
||||
fetch(url, { method: "POST", headers: headers })
|
||||
.then(function () {
|
||||
state.messages = [];
|
||||
state.messageVersion++;
|
||||
state.isStreaming = false;
|
||||
state.streamingMsgId = null;
|
||||
state.historyLoaded = true;
|
||||
@@ -794,7 +713,6 @@
|
||||
// ========== UI Rendering ==========
|
||||
function addMessage(msg, silent) {
|
||||
state.messages.push(msg);
|
||||
if (!silent) state.messageVersion++;
|
||||
var el = createMessageEl(msg);
|
||||
if (els.welcome) {
|
||||
els.welcome.style.display = "none";
|
||||
|
||||
@@ -15,7 +15,6 @@ from tests.factories import FakeApp
|
||||
|
||||
|
||||
pytestmark = pytest.mark.integration
|
||||
SESSION_ID = '31c0f2e9-b115-4ee6-8f15-3e624d6456b1'
|
||||
|
||||
|
||||
@pytest.fixture(scope='module')
|
||||
@@ -192,10 +191,10 @@ class TestEmbedMessagesEndpoint:
|
||||
"""Tests for messages endpoint."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_messages_person_success(self, quart_test_client, fake_embed_app):
|
||||
async def test_get_messages_person_success(self, quart_test_client):
|
||||
"""GET messages/person returns messages."""
|
||||
response = await quart_test_client.get(
|
||||
f'/api/v1/embed/a1b2c3d4-5678-90ab-cdef-123456789abc/messages/person?session_id={SESSION_ID}',
|
||||
'/api/v1/embed/a1b2c3d4-5678-90ab-cdef-123456789abc/messages/person',
|
||||
headers={'Authorization': 'Bearer 1234567890.dummy'},
|
||||
)
|
||||
|
||||
@@ -203,30 +202,17 @@ class TestEmbedMessagesEndpoint:
|
||||
data = await response.get_json()
|
||||
assert data['code'] == 0
|
||||
assert 'messages' in data['data']
|
||||
fake_embed_app.platform_mgr.websocket_proxy_bot.adapter.get_websocket_messages.assert_called_with(
|
||||
'test-pipeline-uuid', 'person', SESSION_ID
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_messages_group_success(self, quart_test_client):
|
||||
"""GET messages/group returns messages."""
|
||||
response = await quart_test_client.get(
|
||||
f'/api/v1/embed/a1b2c3d4-5678-90ab-cdef-123456789abc/messages/group?session_id={SESSION_ID}',
|
||||
'/api/v1/embed/a1b2c3d4-5678-90ab-cdef-123456789abc/messages/group',
|
||||
headers={'Authorization': 'Bearer 1234567890.dummy'},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_messages_requires_session_id(self, quart_test_client):
|
||||
"""GET messages without a client session identifier returns 400."""
|
||||
response = await quart_test_client.get(
|
||||
'/api/v1/embed/a1b2c3d4-5678-90ab-cdef-123456789abc/messages/person',
|
||||
headers={'Authorization': 'Bearer 1234567890.dummy'},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_messages_invalid_session_type(self, quart_test_client):
|
||||
"""GET messages with invalid session_type returns 400."""
|
||||
@@ -243,29 +229,16 @@ class TestEmbedResetEndpoint:
|
||||
"""Tests for session reset endpoint."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reset_session_person_success(self, quart_test_client, fake_embed_app):
|
||||
async def test_reset_session_person_success(self, quart_test_client):
|
||||
"""POST reset/person resets session."""
|
||||
response = await quart_test_client.post(
|
||||
f'/api/v1/embed/a1b2c3d4-5678-90ab-cdef-123456789abc/reset/person?session_id={SESSION_ID}',
|
||||
'/api/v1/embed/a1b2c3d4-5678-90ab-cdef-123456789abc/reset/person',
|
||||
headers={'Authorization': 'Bearer 1234567890.dummy'},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
data = await response.get_json()
|
||||
assert data['code'] == 0
|
||||
fake_embed_app.platform_mgr.websocket_proxy_bot.adapter.reset_session.assert_called_with(
|
||||
'test-pipeline-uuid', 'person', SESSION_ID
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reset_session_requires_session_id(self, quart_test_client):
|
||||
"""POST reset without a client session identifier returns 400."""
|
||||
response = await quart_test_client.post(
|
||||
'/api/v1/embed/a1b2c3d4-5678-90ab-cdef-123456789abc/reset/person',
|
||||
headers={'Authorization': 'Bearer 1234567890.dummy'},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reset_session_invalid_uuid(self, quart_test_client):
|
||||
|
||||
@@ -77,12 +77,11 @@ async def backend(valkey_config):
|
||||
ValkeySearchVectorDatabase,
|
||||
VALKEY_SEARCH_AVAILABLE,
|
||||
)
|
||||
from glide import ft
|
||||
|
||||
if not VALKEY_SEARCH_AVAILABLE:
|
||||
pytest.skip('valkey-glide not installed')
|
||||
|
||||
from glide import ft
|
||||
|
||||
ap = _make_ap(valkey_config)
|
||||
db = ValkeySearchVectorDatabase(ap)
|
||||
client = await db._ensure_client()
|
||||
|
||||
@@ -777,44 +777,6 @@ class TestModelProviderServiceScanProviderModels:
|
||||
assert len(result['models']) == 1
|
||||
assert result['models'][0]['type'] == 'llm'
|
||||
|
||||
async def test_scan_provider_marks_existing_rerank_model(self):
|
||||
"""Rerank scan results use the rerank service when computing already_added."""
|
||||
ap = SimpleNamespace()
|
||||
ap.persistence_mgr = SimpleNamespace()
|
||||
ap.model_mgr = SimpleNamespace()
|
||||
ap.llm_model_service = SimpleNamespace()
|
||||
ap.embedding_models_service = SimpleNamespace()
|
||||
ap.rerank_models_service = SimpleNamespace()
|
||||
|
||||
provider = _create_mock_provider(provider_uuid='rerank-scan-uuid')
|
||||
ap.persistence_mgr.execute_async = AsyncMock(return_value=_create_mock_result([], first_item=provider))
|
||||
ap.persistence_mgr.serialize_model = Mock(
|
||||
return_value={
|
||||
'uuid': 'rerank-scan-uuid',
|
||||
'name': 'New API',
|
||||
'requester': 'new-api-chat-completions',
|
||||
'base_url': 'https://new-api.example.com/v1',
|
||||
'api_keys': ['key'],
|
||||
}
|
||||
)
|
||||
|
||||
runtime_provider = Mock()
|
||||
runtime_provider.token_mgr.get_token.return_value = 'token'
|
||||
runtime_provider.requester.scan_models = AsyncMock(
|
||||
return_value={'models': [{'id': 'Qwen3-Reranker-8B', 'type': 'rerank'}]}
|
||||
)
|
||||
ap.model_mgr.load_provider = AsyncMock(return_value=runtime_provider)
|
||||
ap.llm_model_service.get_llm_models_by_provider = AsyncMock(return_value=[])
|
||||
ap.embedding_models_service.get_embedding_models_by_provider = AsyncMock(return_value=[])
|
||||
ap.rerank_models_service.get_rerank_models_by_provider = AsyncMock(
|
||||
return_value=[{'name': 'Qwen3-Reranker-8B'}]
|
||||
)
|
||||
|
||||
result = await ModelProviderService(ap).scan_provider_models('rerank-scan-uuid', model_type='rerank')
|
||||
|
||||
assert result['models'][0]['type'] == 'rerank'
|
||||
assert result['models'][0]['already_added'] is True
|
||||
|
||||
async def test_scan_provider_not_implemented_raises_error(self):
|
||||
"""Raises ValueError when scan not implemented."""
|
||||
# Setup
|
||||
|
||||
@@ -693,7 +693,7 @@ class TestSpaceServiceGetModels:
|
||||
'uuid': 'uuid-2',
|
||||
'model_id': 'model-2',
|
||||
'provider': 'provider-2',
|
||||
'category': 'rerank',
|
||||
'category': 'chat',
|
||||
'status': 'active',
|
||||
},
|
||||
]
|
||||
@@ -714,7 +714,6 @@ class TestSpaceServiceGetModels:
|
||||
|
||||
# Verify
|
||||
assert len(result) == 2
|
||||
assert result[1].category == 'rerank'
|
||||
|
||||
async def test_get_models_api_error(self):
|
||||
"""Raises ValueError on API error."""
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
|
||||
from langbot_plugin.box.client import ActionRPCBoxClient
|
||||
from langbot.pkg.box import connector as connector_module
|
||||
from langbot.pkg.box.connector import BoxRuntimeConnector
|
||||
|
||||
|
||||
@@ -106,136 +104,3 @@ def test_box_runtime_connector_dispose_terminates_subprocess(monkeypatch: pytest
|
||||
ctrl_task.cancel.assert_called_once()
|
||||
assert connector._handler_task is None
|
||||
assert connector._ctrl_task is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_box_runtime_connector_cleans_partial_transport_on_connect_failure(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
):
|
||||
monkeypatch.setattr('langbot.pkg.utils.platform.get_platform', lambda: 'linux')
|
||||
monkeypatch.setattr('langbot.pkg.utils.platform.standalone_box', False)
|
||||
connector = BoxRuntimeConnector(make_app(Mock()))
|
||||
connector._start_local_stdio = AsyncMock(side_effect=RuntimeError('bind failed'))
|
||||
connector._stop_transport = AsyncMock()
|
||||
connector._close_managed_subprocess = AsyncMock()
|
||||
|
||||
with pytest.raises(RuntimeError, match='bind failed'):
|
||||
await connector.initialize()
|
||||
|
||||
assert connector._stop_transport.await_count == 2
|
||||
connector._close_managed_subprocess.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_box_runtime_connector_starts_heartbeat_after_reconnect(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
):
|
||||
monkeypatch.setattr('langbot.pkg.utils.platform.get_platform', lambda: 'linux')
|
||||
monkeypatch.setattr('langbot.pkg.utils.platform.standalone_box', False)
|
||||
connector = BoxRuntimeConnector(make_app(Mock()))
|
||||
connector._start_local_stdio = AsyncMock(side_effect=[RuntimeError('bind failed'), None])
|
||||
connector._stop_transport = AsyncMock()
|
||||
connector._close_managed_subprocess = AsyncMock()
|
||||
|
||||
with pytest.raises(RuntimeError, match='bind failed'):
|
||||
await connector.initialize()
|
||||
|
||||
assert connector._heartbeat_task is None
|
||||
|
||||
await connector.reconnect()
|
||||
|
||||
assert connector._heartbeat_task is not None
|
||||
assert not connector._heartbeat_task.done()
|
||||
await connector.aclose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_box_stdio_connection_does_not_capture_unconsumed_stderr(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
):
|
||||
monkeypatch.setattr('langbot.pkg.utils.platform.get_platform', lambda: 'linux')
|
||||
monkeypatch.setattr('langbot.pkg.utils.platform.standalone_box', False)
|
||||
created = {}
|
||||
|
||||
class FakeHandler:
|
||||
def __init__(self, connection):
|
||||
self.release = asyncio.Event()
|
||||
|
||||
async def call_action(self, action, data):
|
||||
return None
|
||||
|
||||
async def run(self):
|
||||
await self.release.wait()
|
||||
|
||||
async def close(self):
|
||||
self.release.set()
|
||||
|
||||
class FakeController:
|
||||
def __init__(self, **kwargs):
|
||||
created.update(kwargs)
|
||||
self.process = SimpleNamespace(returncode=0)
|
||||
|
||||
async def run(self, callback):
|
||||
await callback(object())
|
||||
|
||||
async def close(self):
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(connector_module, 'Handler', FakeHandler)
|
||||
monkeypatch.setattr(
|
||||
'langbot_plugin.runtime.io.controllers.stdio.client.StdioClientController',
|
||||
FakeController,
|
||||
)
|
||||
connector = BoxRuntimeConnector(make_app(Mock()))
|
||||
|
||||
await connector.initialize()
|
||||
|
||||
assert created['capture_stderr'] is False
|
||||
assert connector._handler is not None
|
||||
await connector.aclose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_box_disconnect_notifies_once_and_clears_handler(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
):
|
||||
monkeypatch.setattr('langbot.pkg.utils.platform.get_platform', lambda: 'linux')
|
||||
monkeypatch.setattr('langbot.pkg.utils.platform.standalone_box', False)
|
||||
disconnect = AsyncMock()
|
||||
|
||||
class FakeHandler:
|
||||
def __init__(self, connection):
|
||||
pass
|
||||
|
||||
async def call_action(self, action, data):
|
||||
return None
|
||||
|
||||
async def run(self):
|
||||
return None
|
||||
|
||||
async def close(self):
|
||||
return None
|
||||
|
||||
class FakeController:
|
||||
def __init__(self, **kwargs):
|
||||
self.process = SimpleNamespace(returncode=0)
|
||||
|
||||
async def run(self, callback):
|
||||
await callback(object())
|
||||
|
||||
async def close(self):
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(connector_module, 'Handler', FakeHandler)
|
||||
monkeypatch.setattr(
|
||||
'langbot_plugin.runtime.io.controllers.stdio.client.StdioClientController',
|
||||
FakeController,
|
||||
)
|
||||
connector = BoxRuntimeConnector(make_app(Mock()), runtime_disconnect_callback=disconnect)
|
||||
|
||||
await connector.initialize()
|
||||
await asyncio.sleep(0)
|
||||
|
||||
disconnect.assert_awaited_once_with(connector)
|
||||
assert connector._handler is None
|
||||
await connector.aclose()
|
||||
|
||||
@@ -325,50 +325,10 @@ async def test_box_service_dispose_schedules_shutdown_on_event_loop(monkeypatch:
|
||||
service.dispose()
|
||||
await asyncio.sleep(0)
|
||||
|
||||
connector.dispose.assert_not_called()
|
||||
connector.dispose.assert_called_once()
|
||||
service.shutdown.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_box_service_shutdown_reaps_connector_when_runtime_rpc_is_offline(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
):
|
||||
connector = Mock()
|
||||
connector.client = Mock()
|
||||
connector.client.shutdown = AsyncMock(side_effect=RuntimeError('offline'))
|
||||
connector.aclose = AsyncMock()
|
||||
|
||||
monkeypatch.setattr('langbot.pkg.box.service.BoxRuntimeConnector', Mock(return_value=connector))
|
||||
|
||||
service = BoxService(make_app(Mock()))
|
||||
await service.shutdown()
|
||||
|
||||
connector.client.shutdown.assert_awaited_once()
|
||||
connector.aclose.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_box_service_reconnect_restores_workspace_and_runs_cleanup(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
):
|
||||
app = make_app(Mock())
|
||||
app.skill_mgr = SimpleNamespace(reload_skills=AsyncMock())
|
||||
service = BoxService(app, client=Mock(spec=BoxRuntimeClient))
|
||||
connector = Mock()
|
||||
connector.reconnect = AsyncMock()
|
||||
service._ensure_default_workspace = Mock()
|
||||
service._purge_attachment_dirs = AsyncMock()
|
||||
monkeypatch.setattr('langbot.pkg.box.service.asyncio.sleep', AsyncMock())
|
||||
|
||||
await service._reconnect_loop(connector)
|
||||
|
||||
connector.reconnect.assert_awaited_once()
|
||||
service._ensure_default_workspace.assert_called_once()
|
||||
service._purge_attachment_dirs.assert_awaited_once()
|
||||
app.skill_mgr.reload_skills.assert_awaited_once()
|
||||
assert service.available is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_box_runtime_reuses_request_session():
|
||||
logger = Mock()
|
||||
|
||||
@@ -2,7 +2,6 @@ from __future__ import annotations
|
||||
|
||||
import signal
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -19,60 +18,47 @@ async def test_main_signal_handler_handles_sigint_before_app_created(monkeypatch
|
||||
async def fake_make_app(loop):
|
||||
captured_handler[signal.SIGINT](signal.SIGINT, None)
|
||||
|
||||
def fake_exit(code):
|
||||
raise SystemExit(code)
|
||||
|
||||
monkeypatch.setattr(signal, 'signal', fake_signal)
|
||||
monkeypatch.setattr(boot, 'make_app', fake_make_app)
|
||||
monkeypatch.setattr(boot.os, '_exit', fake_exit)
|
||||
|
||||
await boot.main(SimpleNamespace())
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
await boot.main(SimpleNamespace())
|
||||
|
||||
assert exc_info.value.code == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_main_signal_handler_disposes_created_app(monkeypatch):
|
||||
captured_handler = {}
|
||||
app_inst = SimpleNamespace(shutdown_called=False)
|
||||
app_inst = SimpleNamespace(disposed=False)
|
||||
|
||||
def fake_signal(sig, handler):
|
||||
captured_handler[sig] = handler
|
||||
|
||||
async def shutdown():
|
||||
app_inst.shutdown_called = True
|
||||
def dispose():
|
||||
app_inst.disposed = True
|
||||
|
||||
async def run():
|
||||
captured_handler[signal.SIGINT](signal.SIGINT, None)
|
||||
|
||||
async def fake_make_app(loop):
|
||||
app_inst.shutdown = shutdown
|
||||
app_inst.dispose = dispose
|
||||
app_inst.run = run
|
||||
return app_inst
|
||||
|
||||
def fake_exit(code):
|
||||
raise SystemExit(code)
|
||||
|
||||
monkeypatch.setattr(signal, 'signal', fake_signal)
|
||||
monkeypatch.setattr(boot, 'make_app', fake_make_app)
|
||||
monkeypatch.setattr(boot.os, '_exit', fake_exit)
|
||||
|
||||
await boot.main(SimpleNamespace())
|
||||
with pytest.raises(SystemExit) as exc_info:
|
||||
await boot.main(SimpleNamespace())
|
||||
|
||||
assert app_inst.shutdown_called is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_main_reports_app_run_failure_and_still_shuts_down(monkeypatch):
|
||||
app_inst = SimpleNamespace(shutdown_called=False)
|
||||
|
||||
async def shutdown():
|
||||
app_inst.shutdown_called = True
|
||||
|
||||
async def run():
|
||||
raise RuntimeError('run failed')
|
||||
|
||||
async def fake_make_app(loop):
|
||||
app_inst.shutdown = shutdown
|
||||
app_inst.run = run
|
||||
return app_inst
|
||||
|
||||
print_exc = Mock()
|
||||
monkeypatch.setattr(signal, 'signal', lambda *_args: None)
|
||||
monkeypatch.setattr(boot, 'make_app', fake_make_app)
|
||||
monkeypatch.setattr(boot.traceback, 'print_exc', print_exc)
|
||||
|
||||
await boot.main(SimpleNamespace())
|
||||
|
||||
print_exc.assert_called_once()
|
||||
assert app_inst.shutdown_called is True
|
||||
assert exc_info.value.code == 0
|
||||
assert app_inst.disposed is True
|
||||
|
||||
@@ -158,21 +158,17 @@ class TestCommandHandlerReal:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_admin_privilege_check(self, fake_app, mock_event_ctx, mock_execute_factory):
|
||||
"""A per-bot admin from the database is marked as admin in command events."""
|
||||
"""Admin users get privilege level 2."""
|
||||
from langbot_plugin.api.entities.builtin.provider.session import LauncherTypes
|
||||
|
||||
command = get_command_handler()
|
||||
|
||||
admin_result = Mock()
|
||||
admin_result.first.return_value = Mock()
|
||||
fake_app.persistence_mgr.execute_async = AsyncMock(return_value=admin_result)
|
||||
fake_app.instance_config.data = {}
|
||||
fake_app.instance_config.data = {'admins': ['person_12345']}
|
||||
fake_app.plugin_connector.emit_event = AsyncMock(return_value=mock_event_ctx)
|
||||
fake_app.cmd_mgr.execute = mock_execute_factory()
|
||||
|
||||
handler = command.CommandHandler(fake_app)
|
||||
query = command_query('status')
|
||||
query.bot_uuid = 'bot-1'
|
||||
query.launcher_type = LauncherTypes.PERSON
|
||||
query.launcher_id = 12345
|
||||
|
||||
@@ -180,28 +176,23 @@ class TestCommandHandlerReal:
|
||||
async for result in handler.handle(query):
|
||||
results.append(result)
|
||||
|
||||
fake_app.persistence_mgr.execute_async.assert_awaited_once()
|
||||
call_args = fake_app.plugin_connector.emit_event.call_args
|
||||
event = call_args[0][0]
|
||||
assert event.is_admin is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_non_admin_privilege_check(self, fake_app, mock_event_ctx, mock_execute_factory):
|
||||
"""A launcher absent from the per-bot admin table is not an admin."""
|
||||
"""Non-admin users get privilege level 1."""
|
||||
from langbot_plugin.api.entities.builtin.provider.session import LauncherTypes
|
||||
|
||||
command = get_command_handler()
|
||||
|
||||
admin_result = Mock()
|
||||
admin_result.first.return_value = None
|
||||
fake_app.persistence_mgr.execute_async = AsyncMock(return_value=admin_result)
|
||||
fake_app.instance_config.data = {}
|
||||
fake_app.instance_config.data = {'admins': ['person_12345']}
|
||||
fake_app.plugin_connector.emit_event = AsyncMock(return_value=mock_event_ctx)
|
||||
fake_app.cmd_mgr.execute = mock_execute_factory()
|
||||
|
||||
handler = command.CommandHandler(fake_app)
|
||||
query = command_query('status')
|
||||
query.bot_uuid = 'bot-1'
|
||||
query.launcher_type = LauncherTypes.PERSON
|
||||
query.launcher_id = 67890
|
||||
|
||||
@@ -209,7 +200,6 @@ class TestCommandHandlerReal:
|
||||
async for result in handler.handle(query):
|
||||
results.append(result)
|
||||
|
||||
fake_app.persistence_mgr.execute_async.assert_awaited_once()
|
||||
call_args = fake_app.plugin_connector.emit_event.call_args
|
||||
event = call_args[0][0]
|
||||
assert event.is_admin is False
|
||||
|
||||
@@ -2,7 +2,6 @@ import pytest
|
||||
import aiocqhttp
|
||||
|
||||
import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
||||
import langbot_plugin.api.entities.builtin.platform.events as platform_events
|
||||
from langbot.pkg.platform.sources.aiocqhttp import (
|
||||
AiocqhttpAdapter,
|
||||
AiocqhttpEventConverter,
|
||||
@@ -16,72 +15,6 @@ async def _convert_single(component: platform_message.MessageComponent):
|
||||
return message[0]
|
||||
|
||||
|
||||
class _TestLogger:
|
||||
def __init__(self):
|
||||
self.messages = []
|
||||
|
||||
async def info(self, message):
|
||||
self.messages.append(message)
|
||||
|
||||
|
||||
def _make_adapter():
|
||||
logger = _TestLogger()
|
||||
adapter = AiocqhttpAdapter.model_construct(
|
||||
config={},
|
||||
logger=logger,
|
||||
bot=aiocqhttp.CQHttp(),
|
||||
on_websocket_connection_event_cache=[],
|
||||
_listener_wrappers={},
|
||||
)
|
||||
adapter.bot.on_websocket_connection(adapter._on_websocket_connection)
|
||||
return adapter, logger
|
||||
|
||||
|
||||
def test_connection_listener_is_registered_once_for_multiple_message_listeners():
|
||||
adapter, _ = _make_adapter()
|
||||
|
||||
async def callback(event, source_adapter):
|
||||
return None
|
||||
|
||||
adapter.register_listener(platform_events.FriendMessage, callback)
|
||||
adapter.register_listener(platform_events.GroupMessage, callback)
|
||||
adapter.register_listener(platform_events.FeedbackEvent, callback)
|
||||
|
||||
assert len(adapter.bot._bus._subscribers['meta_event.lifecycle.connect']) == 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_connection_listener_only_suppresses_exact_duplicates():
|
||||
adapter, logger = _make_adapter()
|
||||
first = aiocqhttp.Event({'self_id': 1001, 'time': 10})
|
||||
duplicate = aiocqhttp.Event({'self_id': 1001, 'time': 10})
|
||||
second = aiocqhttp.Event({'self_id': 2002, 'time': 20})
|
||||
|
||||
await adapter._on_websocket_connection(first)
|
||||
await adapter._on_websocket_connection(duplicate)
|
||||
await adapter._on_websocket_connection(second)
|
||||
|
||||
assert adapter.on_websocket_connection_event_cache == [first, second]
|
||||
assert logger.messages == [
|
||||
'WebSocket connection established, bot id: 1001',
|
||||
'WebSocket connection established, bot id: 2002',
|
||||
]
|
||||
|
||||
|
||||
def test_unregister_listener_removes_registered_wrapper():
|
||||
adapter, _ = _make_adapter()
|
||||
|
||||
async def callback(event, source_adapter):
|
||||
return None
|
||||
|
||||
adapter.register_listener(platform_events.GroupMessage, callback)
|
||||
assert len(adapter.bot._bus._subscribers['message.group']) == 1
|
||||
|
||||
adapter.unregister_listener(platform_events.GroupMessage, callback)
|
||||
|
||||
assert not adapter.bot._bus._subscribers['message.group']
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
('payload', 'expected'),
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
import asyncio
|
||||
import threading
|
||||
|
||||
import pytest
|
||||
|
||||
from langbot.pkg.platform.sources.lark import NonBlockingLarkWSClient
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_lark_connection_url_lookup_does_not_block_main_event_loop(monkeypatch):
|
||||
client = NonBlockingLarkWSClient('app-id', 'app-secret')
|
||||
lookup_started = threading.Event()
|
||||
release_lookup = threading.Event()
|
||||
base_connect_urls: list[str] = []
|
||||
|
||||
def blocking_get_conn_url() -> str:
|
||||
lookup_started.set()
|
||||
if not release_lookup.wait(timeout=2):
|
||||
raise TimeoutError('test did not release the connection lookup')
|
||||
return 'wss://example.invalid/connect?device_id=device&service_id=service'
|
||||
|
||||
async def fake_base_connect(self):
|
||||
base_connect_urls.append(self._get_conn_url())
|
||||
|
||||
monkeypatch.setattr(client, '_get_conn_url', blocking_get_conn_url)
|
||||
monkeypatch.setattr('lark_oapi.ws.Client._connect', fake_base_connect)
|
||||
|
||||
connect_task = asyncio.create_task(client._connect())
|
||||
await asyncio.wait_for(asyncio.to_thread(lookup_started.wait, 1), timeout=1)
|
||||
|
||||
# If the SDK's synchronous requests.post still runs on the event-loop
|
||||
# thread, this sleep cannot complete until release_lookup is set.
|
||||
await asyncio.wait_for(asyncio.sleep(0.01), timeout=0.1)
|
||||
assert not connect_task.done()
|
||||
|
||||
release_lookup.set()
|
||||
await asyncio.wait_for(connect_task, timeout=1)
|
||||
|
||||
assert base_connect_urls == ['wss://example.invalid/connect?device_id=device&service_id=service']
|
||||
@@ -1,267 +0,0 @@
|
||||
"""Regression tests for isolated embed-widget conversations."""
|
||||
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
import pytest
|
||||
|
||||
import langbot_plugin.api.entities.builtin.platform.events as platform_events
|
||||
from langbot.pkg.platform.sources import websocket_adapter as websocket_adapter_module
|
||||
from langbot.pkg.platform.sources.websocket_adapter import WebSocketAdapter, WebSocketMessage, WebSocketSession
|
||||
from langbot.pkg.platform.sources.websocket_manager import WebSocketConnectionManager, is_valid_session_id
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_broadcast_only_reaches_connections_in_same_browser_session():
|
||||
manager = WebSocketConnectionManager()
|
||||
first = await manager.add_connection(
|
||||
websocket=Mock(),
|
||||
pipeline_uuid='pipeline-1',
|
||||
session_type='person',
|
||||
session_id='session-a',
|
||||
)
|
||||
second = await manager.add_connection(
|
||||
websocket=Mock(),
|
||||
pipeline_uuid='pipeline-1',
|
||||
session_type='person',
|
||||
session_id='session-b',
|
||||
)
|
||||
dashboard = await manager.add_connection(
|
||||
websocket=Mock(),
|
||||
pipeline_uuid='pipeline-1',
|
||||
session_type='person',
|
||||
)
|
||||
|
||||
await manager.broadcast_to_pipeline(
|
||||
'pipeline-1',
|
||||
{'type': 'response'},
|
||||
session_type='person',
|
||||
session_id='session-a',
|
||||
)
|
||||
|
||||
assert await first.send_queue.get() == {'type': 'response'}
|
||||
assert second.send_queue.empty()
|
||||
assert dashboard.send_queue.empty()
|
||||
|
||||
await manager.broadcast_to_pipeline(
|
||||
'pipeline-1',
|
||||
{'type': 'dashboard-response'},
|
||||
session_type='person',
|
||||
session_id=None,
|
||||
)
|
||||
|
||||
assert await dashboard.send_queue.get() == {'type': 'dashboard-response'}
|
||||
assert first.send_queue.empty()
|
||||
assert second.send_queue.empty()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_embed_event_uses_stable_session_launcher(monkeypatch):
|
||||
manager = WebSocketConnectionManager()
|
||||
session_id = '31c0f2e9-b115-4ee6-8f15-3e624d6456b1'
|
||||
connection = await manager.add_connection(
|
||||
websocket=Mock(),
|
||||
pipeline_uuid='pipeline-1',
|
||||
session_type='person',
|
||||
session_id=session_id,
|
||||
)
|
||||
monkeypatch.setattr(websocket_adapter_module, 'ws_connection_manager', manager)
|
||||
|
||||
adapter = WebSocketAdapter.model_construct(ap=Mock(), logger=AsyncMock())
|
||||
adapter.websocket_person_session = WebSocketSession(id='person')
|
||||
adapter.websocket_group_session = WebSocketSession(id='group')
|
||||
received = []
|
||||
|
||||
async def listener(event, _callback_adapter):
|
||||
received.append(event)
|
||||
|
||||
adapter.listeners = {platform_events.FriendMessage: listener}
|
||||
await adapter.handle_websocket_message(
|
||||
connection,
|
||||
{'message': [{'type': 'Plain', 'text': 'hello'}], 'stream': False},
|
||||
)
|
||||
await asyncio.sleep(0)
|
||||
|
||||
assert received[0].sender.id == f'websocket_pipeline-1:{session_id}'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_embed_group_event_uses_stable_session_launcher(monkeypatch):
|
||||
manager = WebSocketConnectionManager()
|
||||
session_id = '31c0f2e9-b115-4ee6-8f15-3e624d6456b1'
|
||||
connection = await manager.add_connection(
|
||||
websocket=Mock(),
|
||||
pipeline_uuid='pipeline-1',
|
||||
session_type='group',
|
||||
session_id=session_id,
|
||||
)
|
||||
monkeypatch.setattr(websocket_adapter_module, 'ws_connection_manager', manager)
|
||||
|
||||
adapter = WebSocketAdapter.model_construct(ap=Mock(), logger=AsyncMock())
|
||||
adapter.websocket_person_session = WebSocketSession(id='person')
|
||||
adapter.websocket_group_session = WebSocketSession(id='group')
|
||||
received = []
|
||||
|
||||
async def listener(event, _callback_adapter):
|
||||
received.append(event)
|
||||
|
||||
adapter.listeners = {platform_events.GroupMessage: listener}
|
||||
await adapter.handle_websocket_message(
|
||||
connection,
|
||||
{'message': [{'type': 'Plain', 'text': 'hello'}], 'stream': False},
|
||||
)
|
||||
await asyncio.sleep(0)
|
||||
|
||||
assert received[0].sender.id == f'websocket_pipeline-1:{session_id}'
|
||||
assert received[0].sender.group.id == f'websocketgroup_pipeline-1:{session_id}'
|
||||
|
||||
dashboard = await manager.add_connection(
|
||||
websocket=Mock(),
|
||||
pipeline_uuid='pipeline-1',
|
||||
session_type='group',
|
||||
)
|
||||
await adapter.handle_websocket_message(
|
||||
dashboard,
|
||||
{'stream': False, 'message': [{'type': 'Plain', 'text': 'dashboard'}]},
|
||||
None,
|
||||
)
|
||||
await asyncio.sleep(0)
|
||||
|
||||
assert received[1].sender.id == f'websocket_{dashboard.connection_id}'
|
||||
assert received[1].sender.group.id == 'websocketgroup'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stable_session_launcher_resolves_to_active_connection(monkeypatch):
|
||||
manager = WebSocketConnectionManager()
|
||||
session_id = '31c0f2e9-b115-4ee6-8f15-3e624d6456b1'
|
||||
await manager.add_connection(
|
||||
websocket=Mock(),
|
||||
pipeline_uuid='pipeline-2',
|
||||
session_type='person',
|
||||
session_id=session_id,
|
||||
)
|
||||
connection = await manager.add_connection(
|
||||
websocket=Mock(),
|
||||
pipeline_uuid='pipeline-1',
|
||||
session_type='person',
|
||||
session_id=session_id,
|
||||
)
|
||||
monkeypatch.setattr(websocket_adapter_module, 'ws_connection_manager', manager)
|
||||
|
||||
adapter = WebSocketAdapter.model_construct(ap=Mock(), logger=AsyncMock())
|
||||
message_source = Mock()
|
||||
message_source.sender.id = f'websocket_pipeline-1:{session_id}'
|
||||
|
||||
assert await adapter._get_message_context(message_source) == ('pipeline-1', session_id)
|
||||
assert await adapter._get_connection_from_target(f'websocketgroup_pipeline-1:{session_id}') is connection
|
||||
assert await manager.get_connection_by_session_id(session_id, 'pipeline-1') is connection
|
||||
|
||||
await manager.remove_connection(connection.connection_id)
|
||||
|
||||
assert await adapter._get_message_context(message_source) == ('pipeline-1', session_id)
|
||||
assert await manager.get_connection_by_session_id(session_id, 'pipeline-1') is None
|
||||
|
||||
|
||||
def test_session_ids_must_be_canonical_random_uuids():
|
||||
assert is_valid_session_id('31c0f2e9-b115-4ee6-8f15-3e624d6456b1')
|
||||
assert not is_valid_session_id('session-a')
|
||||
assert not is_valid_session_id('00000000-0000-0000-0000-000000000000')
|
||||
|
||||
|
||||
def test_history_read_does_not_allocate_unknown_session():
|
||||
adapter = WebSocketAdapter.model_construct(ap=Mock(), logger=AsyncMock())
|
||||
adapter.websocket_person_session = WebSocketSession(id='person')
|
||||
adapter.websocket_group_session = WebSocketSession(id='group')
|
||||
|
||||
assert adapter.get_websocket_messages('pipeline-1', 'person', 'missing-session') == []
|
||||
assert adapter.websocket_person_session.message_lists == {}
|
||||
|
||||
|
||||
def test_history_and_reset_are_scoped_to_browser_session():
|
||||
matching_provider_session = Mock(
|
||||
launcher_type=Mock(value='person'),
|
||||
launcher_id='websocket_pipeline-1:session-a',
|
||||
)
|
||||
matching_group_provider_session = Mock(
|
||||
launcher_type=Mock(value='group'),
|
||||
launcher_id='websocketgroup_pipeline-1:session-a',
|
||||
)
|
||||
other_session = Mock(
|
||||
launcher_type=Mock(value='person'),
|
||||
launcher_id='websocket_pipeline-1:session-b',
|
||||
)
|
||||
ap = Mock()
|
||||
ap.sess_mgr.session_list = [
|
||||
matching_provider_session,
|
||||
matching_group_provider_session,
|
||||
other_session,
|
||||
]
|
||||
adapter = WebSocketAdapter.model_construct(
|
||||
ap=ap,
|
||||
logger=AsyncMock(),
|
||||
)
|
||||
adapter.websocket_person_session = Mock()
|
||||
adapter.websocket_group_session = Mock()
|
||||
|
||||
session_a = [
|
||||
WebSocketMessage(
|
||||
id=1,
|
||||
role='user',
|
||||
content='private-a',
|
||||
message_chain=[],
|
||||
timestamp='2026-07-13T00:00:00',
|
||||
)
|
||||
]
|
||||
session_b = [
|
||||
WebSocketMessage(
|
||||
id=1,
|
||||
role='user',
|
||||
content='private-b',
|
||||
message_chain=[],
|
||||
timestamp='2026-07-13T00:00:00',
|
||||
)
|
||||
]
|
||||
histories = {
|
||||
'pipeline-1:session-a': session_a,
|
||||
'pipeline-1:session-b': session_b,
|
||||
}
|
||||
stream_indexes = {
|
||||
'pipeline-1:session-a': {'response-a': 0},
|
||||
'pipeline-1:session-b': {'response-b': 0},
|
||||
}
|
||||
adapter.websocket_person_session.get_message_list.side_effect = histories.__getitem__
|
||||
adapter.websocket_person_session.message_lists = histories
|
||||
adapter.websocket_person_session.stream_message_indexes = stream_indexes
|
||||
adapter.websocket_group_session.message_lists = {}
|
||||
adapter.websocket_group_session.stream_message_indexes = {}
|
||||
|
||||
assert adapter.get_websocket_messages('pipeline-1', 'person', 'session-a')[0]['content'] == 'private-a'
|
||||
assert adapter.get_websocket_messages('pipeline-1', 'person', 'session-b')[0]['content'] == 'private-b'
|
||||
|
||||
adapter.reset_session('pipeline-1', 'person', 'session-a')
|
||||
|
||||
assert histories['pipeline-1:session-a'] == []
|
||||
assert stream_indexes['pipeline-1:session-a'] == {}
|
||||
assert histories['pipeline-1:session-b'] == session_b
|
||||
assert stream_indexes['pipeline-1:session-b'] == {'response-b': 0}
|
||||
assert ap.sess_mgr.session_list == [matching_group_provider_session, other_session]
|
||||
|
||||
adapter.reset_session('pipeline-1', 'group', 'session-a')
|
||||
|
||||
assert ap.sess_mgr.session_list == [other_session]
|
||||
|
||||
|
||||
def test_widget_sends_stable_session_id_to_all_conversation_endpoints():
|
||||
widget_path = Path(__file__).parents[3] / 'src/langbot/templates/embed/widget.js'
|
||||
widget = widget_path.read_text(encoding='utf-8')
|
||||
|
||||
assert 'langbot_embed_session_' in widget
|
||||
assert 'window.sessionStorage' in widget
|
||||
assert 'window.localStorage' not in widget
|
||||
assert 'session_id=' in widget
|
||||
assert widget.count('encodeURIComponent(state.sessionId)') >= 3
|
||||
assert 'loadHistory(true)' in widget
|
||||
assert 'messageVersion !== state.messageVersion' in widget
|
||||
assert 'scheduleHistoryReload();' in widget
|
||||
@@ -62,25 +62,6 @@ class TestListPlugins:
|
||||
|
||||
assert result == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_empty_while_runtime_is_disconnected(self):
|
||||
connector = create_mock_connector()
|
||||
|
||||
result = await connector.list_plugins()
|
||||
|
||||
assert result == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_empty_after_managed_transport_disconnects(self):
|
||||
connector = create_mock_connector()
|
||||
connector.handler = AsyncMock()
|
||||
connector._transport_task = Mock()
|
||||
|
||||
result = await connector.list_plugins()
|
||||
|
||||
assert result == []
|
||||
connector.handler.list_plugins.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_calls_handler_list_plugins(self):
|
||||
"""Test that handler.list_plugins is called."""
|
||||
@@ -313,12 +294,6 @@ class TestListKnowledgeEngines:
|
||||
connector.handler.list_knowledge_engines.assert_called_once()
|
||||
assert result == [{'plugin_id': 'author/engine', 'name': 'Engine'}]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_empty_while_runtime_is_disconnected(self):
|
||||
connector = create_mock_connector()
|
||||
|
||||
assert await connector.list_knowledge_engines() == []
|
||||
|
||||
|
||||
class TestListParsers:
|
||||
"""Tests for list_parsers method."""
|
||||
@@ -356,12 +331,6 @@ class TestListParsers:
|
||||
connector.handler.list_parsers.assert_called_once()
|
||||
assert result == [{'plugin_id': 'author/parser', 'supported_mime_types': ['text/plain']}]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_empty_while_runtime_is_disconnected(self):
|
||||
connector = create_mock_connector()
|
||||
|
||||
assert await connector.list_parsers() == []
|
||||
|
||||
|
||||
class TestCallParser:
|
||||
"""Tests for call_parser method."""
|
||||
|
||||
@@ -1,20 +1,15 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
|
||||
from langbot.pkg.plugin import connector as connector_module
|
||||
from langbot.pkg.plugin.connector import PluginRuntimeConnector, PluginRuntimeNotConnectedError
|
||||
|
||||
|
||||
def make_connector() -> PluginRuntimeConnector:
|
||||
app = SimpleNamespace(
|
||||
logger=Mock(),
|
||||
instance_config=SimpleNamespace(data={'plugin': {'enable': True}, 'space': {'url': ''}}),
|
||||
)
|
||||
app = SimpleNamespace(instance_config=SimpleNamespace(data={'plugin': {'enable': True}}))
|
||||
return PluginRuntimeConnector(app, AsyncMock())
|
||||
|
||||
|
||||
@@ -35,133 +30,3 @@ async def test_ping_plugin_runtime_delegates_to_connected_handler():
|
||||
|
||||
assert result == 'pong'
|
||||
connector.handler.ping.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stop_transport_tolerates_handler_callback_removing_attribute():
|
||||
connector = make_connector()
|
||||
|
||||
class Handler:
|
||||
async def close(self):
|
||||
del connector.handler
|
||||
|
||||
connector.handler = Handler()
|
||||
|
||||
await connector._stop_transport()
|
||||
|
||||
assert not hasattr(connector, 'handler')
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stdio_runtime_connection_does_not_capture_unconsumed_stderr(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
):
|
||||
connector = make_connector()
|
||||
created = {}
|
||||
|
||||
class FakeRuntimeHandler:
|
||||
def __init__(self, connection, disconnect_callback, ap):
|
||||
self.release = asyncio.Event()
|
||||
|
||||
async def ping(self):
|
||||
return None
|
||||
|
||||
async def set_runtime_config(self, **kwargs):
|
||||
return None
|
||||
|
||||
async def run(self):
|
||||
await self.release.wait()
|
||||
|
||||
async def close(self):
|
||||
self.release.set()
|
||||
|
||||
class FakeController:
|
||||
def __init__(self, **kwargs):
|
||||
created.update(kwargs)
|
||||
|
||||
async def run(self, callback):
|
||||
await callback(object())
|
||||
|
||||
async def close(self):
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(connector_module.platform, 'get_platform', lambda: 'linux')
|
||||
monkeypatch.setattr(
|
||||
connector_module.platform,
|
||||
'use_websocket_to_connect_plugin_runtime',
|
||||
lambda: False,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
connector_module.handler,
|
||||
'RuntimeConnectionHandler',
|
||||
FakeRuntimeHandler,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
connector_module.stdio_client_controller,
|
||||
'StdioClientController',
|
||||
FakeController,
|
||||
)
|
||||
|
||||
await connector.initialize()
|
||||
|
||||
assert created['capture_stderr'] is False
|
||||
assert connector._connected.is_set()
|
||||
await connector.aclose()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_runtime_disconnect_notifies_once_and_clears_handler(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
):
|
||||
disconnect = AsyncMock()
|
||||
connector = PluginRuntimeConnector(make_connector().ap, disconnect)
|
||||
|
||||
class FakeRuntimeHandler:
|
||||
def __init__(self, connection, disconnect_callback, ap):
|
||||
self.disconnect_callback = disconnect_callback
|
||||
|
||||
async def ping(self):
|
||||
return None
|
||||
|
||||
async def set_runtime_config(self, **kwargs):
|
||||
return None
|
||||
|
||||
async def run(self):
|
||||
await self.disconnect_callback(self)
|
||||
|
||||
async def close(self):
|
||||
return None
|
||||
|
||||
class FakeController:
|
||||
def __init__(self, **kwargs):
|
||||
pass
|
||||
|
||||
async def run(self, callback):
|
||||
await callback(object())
|
||||
|
||||
async def close(self):
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(connector_module.platform, 'get_platform', lambda: 'linux')
|
||||
monkeypatch.setattr(
|
||||
connector_module.platform,
|
||||
'use_websocket_to_connect_plugin_runtime',
|
||||
lambda: False,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
connector_module.handler,
|
||||
'RuntimeConnectionHandler',
|
||||
FakeRuntimeHandler,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
connector_module.stdio_client_controller,
|
||||
'StdioClientController',
|
||||
FakeController,
|
||||
)
|
||||
|
||||
await connector.initialize()
|
||||
await asyncio.sleep(0)
|
||||
|
||||
disconnect.assert_awaited_once_with(connector)
|
||||
assert not hasattr(connector, 'handler')
|
||||
await connector.aclose()
|
||||
|
||||
@@ -279,122 +279,6 @@ class TestInvokeLLMStreamUsage:
|
||||
|
||||
assert query.variables['_stream_usage']['total_tokens'] == 12
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stream_removes_leading_think_across_chunks(self):
|
||||
"""A leading think block split across chunks must be removed."""
|
||||
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
|
||||
import langbot_plugin.api.entities.builtin.provider.message as provider_message
|
||||
|
||||
mock_ap = Mock()
|
||||
mock_ap.tool_mgr = Mock()
|
||||
mock_ap.tool_mgr.generate_tools_for_openai = AsyncMock(return_value=None)
|
||||
requester = litellmchat.LiteLLMRequester(ap=mock_ap, config={})
|
||||
model = MockRuntimeModel('minimax-m3', 'test-api-key')
|
||||
|
||||
chunks = [
|
||||
self._make_chunk(content='<thi'),
|
||||
self._make_chunk(content='nk>hidden'),
|
||||
self._make_chunk(content=' reasoning</thi'),
|
||||
self._make_chunk(content='nk>Visible answer', finish_reason='stop'),
|
||||
]
|
||||
|
||||
async def _aiter(*args, **kwargs):
|
||||
for c in chunks:
|
||||
yield c
|
||||
|
||||
query = Mock(spec=pipeline_query.Query)
|
||||
query.variables = {}
|
||||
messages = [provider_message.Message(role='user', content='Hi')]
|
||||
|
||||
with patch.object(litellmchat, 'acompletion', new=AsyncMock(side_effect=lambda **kw: _aiter())):
|
||||
collected = [
|
||||
chunk
|
||||
async for chunk in requester.invoke_llm_stream(
|
||||
query=query,
|
||||
model=model,
|
||||
messages=messages,
|
||||
remove_think=True,
|
||||
)
|
||||
]
|
||||
|
||||
assert ''.join(chunk.content or '' for chunk in collected) == 'Visible answer'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stream_removes_initial_orphan_think_close(self):
|
||||
"""Initial reasoning content without an open tag is removed until </think>."""
|
||||
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
|
||||
import langbot_plugin.api.entities.builtin.provider.message as provider_message
|
||||
|
||||
mock_ap = Mock()
|
||||
mock_ap.tool_mgr = Mock()
|
||||
mock_ap.tool_mgr.generate_tools_for_openai = AsyncMock(return_value=None)
|
||||
requester = litellmchat.LiteLLMRequester(ap=mock_ap, config={})
|
||||
model = MockRuntimeModel('minimax-m3', 'test-api-key')
|
||||
|
||||
chunks = [
|
||||
self._make_chunk(content='hidden reasoning'),
|
||||
self._make_chunk(content=' still hidden</thi'),
|
||||
self._make_chunk(content='nk>Visible answer', finish_reason='stop'),
|
||||
]
|
||||
|
||||
async def _aiter(*args, **kwargs):
|
||||
for c in chunks:
|
||||
yield c
|
||||
|
||||
query = Mock(spec=pipeline_query.Query)
|
||||
query.variables = {}
|
||||
messages = [provider_message.Message(role='user', content='Hi')]
|
||||
|
||||
with patch.object(litellmchat, 'acompletion', new=AsyncMock(side_effect=lambda **kw: _aiter())):
|
||||
collected = [
|
||||
chunk
|
||||
async for chunk in requester.invoke_llm_stream(
|
||||
query=query,
|
||||
model=model,
|
||||
messages=messages,
|
||||
remove_think=True,
|
||||
)
|
||||
]
|
||||
|
||||
assert ''.join(chunk.content or '' for chunk in collected) == 'Visible answer'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stream_removes_non_leading_think_content(self):
|
||||
"""A think block in the answer body is removed with its content."""
|
||||
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
|
||||
import langbot_plugin.api.entities.builtin.provider.message as provider_message
|
||||
|
||||
mock_ap = Mock()
|
||||
mock_ap.tool_mgr = Mock()
|
||||
mock_ap.tool_mgr.generate_tools_for_openai = AsyncMock(return_value=None)
|
||||
requester = litellmchat.LiteLLMRequester(ap=mock_ap, config={})
|
||||
model = MockRuntimeModel('gpt-4o', 'test-api-key')
|
||||
|
||||
chunks = [
|
||||
self._make_chunk(content='Use <think>x</think> as an XML-like example.', finish_reason='stop'),
|
||||
]
|
||||
|
||||
async def _aiter(*args, **kwargs):
|
||||
for c in chunks:
|
||||
yield c
|
||||
|
||||
query = Mock(spec=pipeline_query.Query)
|
||||
query.variables = {}
|
||||
messages = [provider_message.Message(role='user', content='Hi')]
|
||||
|
||||
with patch.object(litellmchat, 'acompletion', new=AsyncMock(side_effect=lambda **kw: _aiter())):
|
||||
collected = [
|
||||
chunk
|
||||
async for chunk in requester.invoke_llm_stream(
|
||||
query=query,
|
||||
model=model,
|
||||
messages=messages,
|
||||
remove_think=True,
|
||||
)
|
||||
]
|
||||
|
||||
assert ''.join(chunk.content or '' for chunk in collected) == 'Use as an XML-like example.'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stream_tool_call_delta_missing_id_and_name(self):
|
||||
"""LiteLLM may stream tool-call argument deltas with id/name set to None."""
|
||||
@@ -598,38 +482,6 @@ class TestProcessThinkingContent:
|
||||
result = requester._process_thinking_content(content, None, remove_think=True)
|
||||
assert result == 'The answer is 42.'
|
||||
|
||||
def test_remove_leading_think_tag(self):
|
||||
"""Test removing a leading <think> block when remove_think=True"""
|
||||
requester = litellmchat.LiteLLMRequester(ap=Mock(), config={})
|
||||
|
||||
content = '<think>Let me think...</think> The answer is 42.'
|
||||
result = requester._process_thinking_content(content, None, remove_think=True)
|
||||
assert result == 'The answer is 42.'
|
||||
|
||||
def test_remove_non_leading_think_tag(self):
|
||||
"""Test removing <think> and its content in the answer body"""
|
||||
requester = litellmchat.LiteLLMRequester(ap=Mock(), config={})
|
||||
|
||||
content = 'Use <think>example</think> in the document.'
|
||||
result = requester._process_thinking_content(content, None, remove_think=True)
|
||||
assert result == 'Use in the document.'
|
||||
|
||||
def test_remove_initial_orphan_think_close(self):
|
||||
"""Test removing leading reasoning content when only </think> is visible"""
|
||||
requester = litellmchat.LiteLLMRequester(ap=Mock(), config={})
|
||||
|
||||
content = 'hidden reasoning</think> Visible answer.'
|
||||
result = requester._process_thinking_content(content, None, remove_think=True)
|
||||
assert result == 'Visible answer.'
|
||||
|
||||
def test_remove_multiple_think_tags(self):
|
||||
"""Test removing multiple <think> blocks"""
|
||||
requester = litellmchat.LiteLLMRequester(ap=Mock(), config={})
|
||||
|
||||
content = '<think>hidden</think> Keep <think>example</think>.'
|
||||
result = requester._process_thinking_content(content, None, remove_think=True)
|
||||
assert result == 'Keep .'
|
||||
|
||||
def test_preserve_thinking_markers(self):
|
||||
"""Test preserving thinking markers when remove_think=False"""
|
||||
requester = litellmchat.LiteLLMRequester(ap=Mock(), config={})
|
||||
@@ -639,20 +491,6 @@ class TestProcessThinkingContent:
|
||||
assert 'CRETIRE_REASONING_BEGINk' in result
|
||||
assert 'The answer is 42.' in result
|
||||
|
||||
def test_preserve_reasoning_content_when_remove_think_false(self):
|
||||
"""Test showing separate reasoning_content when remove_think=False"""
|
||||
requester = litellmchat.LiteLLMRequester(ap=Mock(), config={})
|
||||
|
||||
result = requester._process_thinking_content('The answer is 42.', 'Let me think...', remove_think=False)
|
||||
assert result == '<think>\nLet me think...\n</think>\nThe answer is 42.'
|
||||
|
||||
def test_hide_reasoning_content_when_remove_think_true(self):
|
||||
"""Test hiding separate reasoning_content when remove_think=True"""
|
||||
requester = litellmchat.LiteLLMRequester(ap=Mock(), config={})
|
||||
|
||||
result = requester._process_thinking_content('The answer is 42.', 'Let me think...', remove_think=True)
|
||||
assert result == 'The answer is 42.'
|
||||
|
||||
def test_empty_content(self):
|
||||
"""Test empty content"""
|
||||
requester = litellmchat.LiteLLMRequester(ap=Mock(), config={})
|
||||
@@ -1147,46 +985,6 @@ class TestInvokeRerank:
|
||||
assert results[0]['relevance_score'] == 1.0
|
||||
assert results[1]['relevance_score'] == 0.0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
('model_extra_args', 'expected_url'),
|
||||
[
|
||||
({'rerank_path': 'reranks'}, 'https://gateway.example.com/v1/reranks'),
|
||||
({'rerank_url': 'https://rerank.example.com/api/rerank'}, 'https://rerank.example.com/api/rerank'),
|
||||
],
|
||||
)
|
||||
async def test_invoke_rerank_openai_compatible_endpoint_override(self, model_extra_args, expected_url):
|
||||
"""Endpoint configuration controls routing and is not sent in the Cohere body."""
|
||||
requester = litellmchat.LiteLLMRequester(
|
||||
ap=Mock(),
|
||||
config={
|
||||
'base_url': 'https://gateway.example.com/v1/',
|
||||
'custom_llm_provider': 'openai',
|
||||
},
|
||||
)
|
||||
model = MockRuntimeRerankModel('Qwen3-Reranker-8B', 'test-api-key')
|
||||
model.model_entity.extra_args = model_extra_args
|
||||
|
||||
mock_resp = Mock()
|
||||
mock_resp.raise_for_status = Mock()
|
||||
mock_resp.json = Mock(return_value={'results': [{'index': 0, 'relevance_score': 0.8}]})
|
||||
mock_client = AsyncMock()
|
||||
mock_client.post = AsyncMock(return_value=mock_resp)
|
||||
mock_client.__aenter__ = AsyncMock(return_value=mock_client)
|
||||
mock_client.__aexit__ = AsyncMock(return_value=False)
|
||||
|
||||
with patch('httpx.AsyncClient', return_value=mock_client):
|
||||
await requester.invoke_rerank(model=model, query='query', documents=['document'])
|
||||
|
||||
assert mock_client.post.call_args.args[0] == expected_url
|
||||
payload = mock_client.post.call_args.kwargs['json']
|
||||
assert payload == {
|
||||
'model': 'Qwen3-Reranker-8B',
|
||||
'query': 'query',
|
||||
'documents': ['document'],
|
||||
'top_n': 1,
|
||||
}
|
||||
|
||||
|
||||
class TestConvertMessages:
|
||||
"""Test _convert_messages method"""
|
||||
|
||||
@@ -1,217 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
import pytest
|
||||
|
||||
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
|
||||
import langbot_plugin.api.entities.builtin.provider.message as provider_message
|
||||
import langbot_plugin.api.entities.builtin.provider.session as provider_session
|
||||
|
||||
from langbot.pkg.provider.runners.localagent import LocalAgentRunner
|
||||
|
||||
|
||||
class RecordingProvider:
|
||||
"""Non-streaming provider that returns a tool-call on round 1 and plain text on round 2."""
|
||||
|
||||
def __init__(self):
|
||||
self.requests: list[dict] = []
|
||||
|
||||
async def invoke_llm(self, query, model, messages, funcs, extra_args=None, remove_think=None):
|
||||
self.requests.append(
|
||||
{
|
||||
'messages': list(messages),
|
||||
'funcs': list(funcs),
|
||||
'remove_think': remove_think,
|
||||
}
|
||||
)
|
||||
|
||||
if len(self.requests) == 1:
|
||||
return provider_message.Message(
|
||||
role='assistant',
|
||||
content='Let me check that.',
|
||||
tool_calls=[
|
||||
provider_message.ToolCall(
|
||||
id='call-1',
|
||||
type='function',
|
||||
function=provider_message.FunctionCall(
|
||||
name='exec',
|
||||
arguments=json.dumps({'command': "python -c 'print(42)'"}),
|
||||
),
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
return provider_message.Message(
|
||||
role='assistant',
|
||||
content='The result is 42.',
|
||||
)
|
||||
|
||||
|
||||
class RecordingStreamProvider:
|
||||
"""Streaming provider that returns a tool-call on round 1 and plain text on round 2."""
|
||||
|
||||
def __init__(self):
|
||||
self.stream_requests: list[dict] = []
|
||||
|
||||
def invoke_llm_stream(self, query, model, messages, funcs, extra_args=None, remove_think=None):
|
||||
self.stream_requests.append(
|
||||
{
|
||||
'messages': list(messages),
|
||||
'funcs': list(funcs),
|
||||
'remove_think': remove_think,
|
||||
}
|
||||
)
|
||||
|
||||
async def _stream():
|
||||
if len(self.stream_requests) == 1:
|
||||
yield provider_message.MessageChunk(
|
||||
role='assistant',
|
||||
content='Let me check that.',
|
||||
tool_calls=[
|
||||
provider_message.ToolCall(
|
||||
id='call-1',
|
||||
type='function',
|
||||
function=provider_message.FunctionCall(
|
||||
name='exec',
|
||||
arguments=json.dumps({'command': "python -c 'print(42)'"}),
|
||||
),
|
||||
)
|
||||
],
|
||||
is_final=True,
|
||||
)
|
||||
return
|
||||
|
||||
yield provider_message.MessageChunk(
|
||||
role='assistant',
|
||||
content='The result is 42.',
|
||||
is_final=True,
|
||||
)
|
||||
|
||||
return _stream()
|
||||
|
||||
|
||||
def make_query() -> pipeline_query.Query:
|
||||
adapter = AsyncMock()
|
||||
adapter.is_stream_output_supported = AsyncMock(return_value=False)
|
||||
|
||||
return pipeline_query.Query.model_construct(
|
||||
query_id='no-dup-query',
|
||||
launcher_type=provider_session.LauncherTypes.PERSON,
|
||||
launcher_id=12345,
|
||||
sender_id=12345,
|
||||
message_chain=[],
|
||||
message_event=None,
|
||||
adapter=adapter,
|
||||
pipeline_uuid='pipeline-uuid',
|
||||
bot_uuid='bot-uuid',
|
||||
pipeline_config={
|
||||
'ai': {
|
||||
'runner': {'runner': 'local-agent'},
|
||||
'local-agent': {'model': {'primary': 'test-model-uuid', 'fallbacks': []}, 'prompt': 'test-prompt'},
|
||||
},
|
||||
'output': {'misc': {'remove-think': False}},
|
||||
},
|
||||
prompt=SimpleNamespace(messages=[]),
|
||||
messages=[],
|
||||
user_message=provider_message.Message(
|
||||
role='user',
|
||||
content='What is the answer?',
|
||||
),
|
||||
use_funcs=[SimpleNamespace(name='exec')],
|
||||
use_llm_model_uuid='test-model-uuid',
|
||||
variables={},
|
||||
)
|
||||
|
||||
|
||||
def _make_app(provider) -> SimpleNamespace:
|
||||
model = SimpleNamespace(
|
||||
provider=provider,
|
||||
model_entity=SimpleNamespace(
|
||||
uuid='test-model-uuid',
|
||||
name='test-model',
|
||||
abilities=['func_call'],
|
||||
extra_args={},
|
||||
),
|
||||
)
|
||||
return SimpleNamespace(
|
||||
logger=Mock(),
|
||||
model_mgr=SimpleNamespace(get_model_by_uuid=AsyncMock(return_value=model)),
|
||||
tool_mgr=SimpleNamespace(
|
||||
execute_func_call=AsyncMock(
|
||||
return_value={
|
||||
'session_id': 'no-dup-query',
|
||||
'backend': 'podman',
|
||||
'status': 'completed',
|
||||
'ok': True,
|
||||
'exit_code': 0,
|
||||
'stdout': '42',
|
||||
'stderr': '',
|
||||
'duration_ms': 10,
|
||||
}
|
||||
)
|
||||
),
|
||||
rag_mgr=SimpleNamespace(),
|
||||
box_service=SimpleNamespace(
|
||||
get_system_guidance=Mock(return_value='sandbox guidance'),
|
||||
),
|
||||
skill_mgr=SimpleNamespace(
|
||||
get_skills_for_pipeline=AsyncMock(return_value=[]),
|
||||
detect_skill_activation=AsyncMock(return_value=None),
|
||||
build_activation_prompt=Mock(return_value=None),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_localagent_non_streaming_no_duplicate():
|
||||
"""Non-streaming: round-2 content must not contain round-1 text."""
|
||||
provider = RecordingProvider()
|
||||
app = _make_app(provider)
|
||||
|
||||
runner = LocalAgentRunner(app, pipeline_config={})
|
||||
query = make_query()
|
||||
|
||||
results = [message async for message in runner.run(query)]
|
||||
|
||||
# Expect: assistant (tool call) -> tool -> assistant (final answer)
|
||||
assert [message.role for message in results] == ['assistant', 'tool', 'assistant']
|
||||
|
||||
final_message = results[-1]
|
||||
assert final_message.content == 'The result is 42.'
|
||||
assert 'Let me check that.' not in final_message.content
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_localagent_streaming_no_duplicate():
|
||||
"""Streaming: round-2 content must not be re-seeded with round-1 text.
|
||||
|
||||
Regression test for the bug where _StreamAccumulator was initialized with
|
||||
initial_content=first_content, causing every subsequent round to repeat
|
||||
the entire opening line.
|
||||
"""
|
||||
provider = RecordingStreamProvider()
|
||||
app = _make_app(provider)
|
||||
|
||||
adapter = AsyncMock()
|
||||
adapter.is_stream_output_supported = AsyncMock(return_value=True)
|
||||
|
||||
query = make_query()
|
||||
query.adapter = adapter
|
||||
|
||||
runner = LocalAgentRunner(app, pipeline_config={})
|
||||
|
||||
results = [message async for message in runner.run(query)]
|
||||
|
||||
# All yielded messages should be MessageChunk in streaming mode
|
||||
assert all(isinstance(message, provider_message.MessageChunk) for message in results)
|
||||
|
||||
# The last assistant chunk is the final answer for round 2
|
||||
assistant_chunks = [m for m in results if m.role == 'assistant']
|
||||
assert len(assistant_chunks) >= 2
|
||||
|
||||
final_chunk = assistant_chunks[-1]
|
||||
assert final_chunk.content == 'The result is 42.'
|
||||
assert 'Let me check that.' not in final_chunk.content
|
||||
@@ -163,51 +163,6 @@ def test_stream_accumulator_merges_fragmented_tool_call_arguments():
|
||||
assert final_msg.tool_calls[0].function.arguments == '{"command":"pwd"}'
|
||||
|
||||
|
||||
def test_stream_accumulator_strips_leading_think_from_tool_round_content():
|
||||
accumulator = _StreamAccumulator(
|
||||
msg_sequence=3,
|
||||
initial_content='I will search for LangBot.',
|
||||
remove_think=True,
|
||||
)
|
||||
|
||||
assert accumulator.add(provider_message.MessageChunk(role='assistant', content='<thi')) is None
|
||||
assert accumulator.add(provider_message.MessageChunk(role='assistant', content='nk>hidden')) is None
|
||||
emitted = accumulator.add(
|
||||
provider_message.MessageChunk(
|
||||
role='assistant',
|
||||
content=' reasoning</think>Here is the answer.',
|
||||
is_final=True,
|
||||
)
|
||||
)
|
||||
|
||||
assert emitted is not None
|
||||
assert emitted.content == 'I will search for LangBot.Here is the answer.'
|
||||
assert '<think>' not in emitted.content
|
||||
assert 'hidden reasoning' not in emitted.content
|
||||
|
||||
|
||||
def test_stream_accumulator_strips_initial_orphan_think_close_from_tool_round_content():
|
||||
accumulator = _StreamAccumulator(
|
||||
msg_sequence=3,
|
||||
initial_content='I will search for LangBot.',
|
||||
remove_think=True,
|
||||
)
|
||||
|
||||
assert accumulator.add(provider_message.MessageChunk(role='assistant', content='hidden reasoning')) is None
|
||||
emitted = accumulator.add(
|
||||
provider_message.MessageChunk(
|
||||
role='assistant',
|
||||
content=' still hidden</think>Here is the answer.',
|
||||
is_final=True,
|
||||
)
|
||||
)
|
||||
|
||||
assert emitted is not None
|
||||
assert emitted.content == 'I will search for LangBot.Here is the answer.'
|
||||
assert '</think>' not in emitted.content
|
||||
assert 'hidden reasoning' not in emitted.content
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_localagent_uses_exec_for_exact_calculation():
|
||||
provider = RecordingProvider()
|
||||
|
||||
@@ -417,7 +417,7 @@ class TestBuildBoxSessionPayload:
|
||||
payload = s._build_box_session_payload('session-123')
|
||||
assert payload['image'] == 'node:20'
|
||||
assert payload['cpus'] == 2.0
|
||||
assert payload['memory_mb'] == 1024
|
||||
assert payload["memory_mb"] == 1024
|
||||
assert payload['pids_limit'] == 256
|
||||
|
||||
def test_none_fields_excluded(self, mcp_module):
|
||||
@@ -680,55 +680,14 @@ class TestGetRuntimeInfoDict:
|
||||
# ... but are isolated by distinct process_ids within that session.
|
||||
assert transient._box_stdio_runtime.process_id != live._box_stdio_runtime.process_id
|
||||
|
||||
def test_different_resource_profiles_use_different_box_sessions(self, mcp_module):
|
||||
ap = _make_ap()
|
||||
ap.box_service.available = True
|
||||
default = _make_session(
|
||||
mcp_module,
|
||||
{
|
||||
'name': 'default',
|
||||
'uuid': 'default-uuid',
|
||||
'mode': 'stdio',
|
||||
'command': 'uvx',
|
||||
'args': ['mcp-server-time'],
|
||||
},
|
||||
ap=ap,
|
||||
)
|
||||
constrained = _make_session(
|
||||
mcp_module,
|
||||
{
|
||||
'name': 'constrained',
|
||||
'uuid': 'constrained-uuid',
|
||||
'mode': 'stdio',
|
||||
'command': 'uvx',
|
||||
'args': ['mcp-server-time'],
|
||||
'box': {'memory_mb': 2048},
|
||||
},
|
||||
ap=ap,
|
||||
)
|
||||
|
||||
assert default._build_box_session_id() == 'mcp-shared'
|
||||
assert constrained._build_box_session_id().startswith('mcp-shared-')
|
||||
assert constrained._build_box_session_id() != default._build_box_session_id()
|
||||
|
||||
writable = _make_session(
|
||||
mcp_module,
|
||||
{
|
||||
'name': 'writable',
|
||||
'uuid': 'writable-uuid',
|
||||
'mode': 'stdio',
|
||||
'command': 'uvx',
|
||||
'args': ['mcp-server-time'],
|
||||
'box': {'host_path_mode': 'rw'},
|
||||
},
|
||||
ap=ap,
|
||||
)
|
||||
assert writable._build_box_session_id() != default._build_box_session_id()
|
||||
|
||||
def test_stdio_session_waits_for_enabled_box_when_temporarily_unavailable(self, mcp_module):
|
||||
def test_stdio_session_refuses_when_box_unavailable(self, mcp_module):
|
||||
"""Policy: when Box is configured but unavailable (disabled in config
|
||||
OR connection failed), stdio MCP servers are NOT treated as box-stdio.
|
||||
``_init_stdio_python_server`` will raise a clear refusal at start
|
||||
time; until then, the runtime info simply omits box_session_id so the
|
||||
UI can render the disabled state cleanly."""
|
||||
ap = _make_ap()
|
||||
ap.box_service.available = False
|
||||
ap.box_service.enabled = True
|
||||
s = _make_session(
|
||||
mcp_module,
|
||||
{
|
||||
@@ -741,125 +700,9 @@ class TestGetRuntimeInfoDict:
|
||||
ap=ap,
|
||||
)
|
||||
info = s.get_runtime_info_dict()
|
||||
assert info['box_session_id'] == 'mcp-shared'
|
||||
assert info['box_enabled'] is True
|
||||
|
||||
def test_stdio_session_refuses_when_box_is_disabled(self, mcp_module):
|
||||
ap = _make_ap()
|
||||
ap.box_service.available = False
|
||||
ap.box_service.enabled = False
|
||||
s = _make_session(
|
||||
mcp_module,
|
||||
{
|
||||
'name': 'test',
|
||||
'uuid': 'test-uuid',
|
||||
'mode': 'stdio',
|
||||
'command': 'python',
|
||||
'args': [],
|
||||
},
|
||||
ap=ap,
|
||||
)
|
||||
|
||||
info = s.get_runtime_info_dict()
|
||||
|
||||
assert 'box_session_id' not in info
|
||||
assert 'box_enabled' not in info
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stdio_session_waits_until_box_reconnects(self, mcp_module, monkeypatch):
|
||||
mcp_stdio_module = sys.modules['langbot.pkg.provider.tools.loaders.mcp_stdio']
|
||||
ap = _make_ap()
|
||||
ap.box_service.available = False
|
||||
ap.box_service.enabled = True
|
||||
session = _make_session(
|
||||
mcp_module,
|
||||
{
|
||||
'name': 'test',
|
||||
'uuid': 'test-uuid',
|
||||
'mode': 'stdio',
|
||||
'command': 'python',
|
||||
'args': [],
|
||||
'box': {'startup_timeout_sec': 1},
|
||||
},
|
||||
ap=ap,
|
||||
)
|
||||
|
||||
async def reconnect_box(_delay):
|
||||
ap.box_service.available = True
|
||||
|
||||
monkeypatch.setattr(mcp_stdio_module.asyncio, 'sleep', reconnect_box)
|
||||
|
||||
await session._box_stdio_runtime._wait_for_box_runtime()
|
||||
|
||||
assert ap.box_service.available is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_enabled_box_timeout_does_not_exhaust_mcp_retry_budget(self, mcp_module, monkeypatch):
|
||||
ap = _make_ap()
|
||||
ap.box_service.available = False
|
||||
ap.box_service.enabled = True
|
||||
session = _make_session(
|
||||
mcp_module,
|
||||
{
|
||||
'name': 'test',
|
||||
'uuid': 'test-uuid',
|
||||
'mode': 'stdio',
|
||||
'command': 'python',
|
||||
'args': [],
|
||||
},
|
||||
ap=ap,
|
||||
)
|
||||
attempts = 0
|
||||
|
||||
async def lifecycle():
|
||||
nonlocal attempts
|
||||
attempts += 1
|
||||
if attempts == 1:
|
||||
session.error_phase = mcp_module.MCPSessionErrorPhase.BOX_UNAVAILABLE
|
||||
raise RuntimeError('Box runtime is not available after 1 seconds')
|
||||
|
||||
session._lifecycle_loop = lifecycle
|
||||
sleep = AsyncMock()
|
||||
monkeypatch.setattr(mcp_module.asyncio, 'sleep', sleep)
|
||||
|
||||
await session._lifecycle_loop_with_retry()
|
||||
|
||||
assert attempts == 2
|
||||
assert session.retry_count == 0
|
||||
sleep.assert_awaited_once_with(1)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_disabled_box_still_stops_mcp_retry_loop(self, mcp_module):
|
||||
ap = _make_ap()
|
||||
ap.box_service.available = False
|
||||
ap.box_service.enabled = False
|
||||
session = _make_session(
|
||||
mcp_module,
|
||||
{
|
||||
'name': 'test',
|
||||
'uuid': 'test-uuid',
|
||||
'mode': 'stdio',
|
||||
'command': 'python',
|
||||
'args': [],
|
||||
},
|
||||
ap=ap,
|
||||
)
|
||||
attempts = 0
|
||||
|
||||
async def lifecycle():
|
||||
nonlocal attempts
|
||||
attempts += 1
|
||||
session.error_phase = mcp_module.MCPSessionErrorPhase.BOX_UNAVAILABLE
|
||||
raise RuntimeError('box_disabled_in_config')
|
||||
|
||||
session._lifecycle_loop = lifecycle
|
||||
|
||||
await session._lifecycle_loop_with_retry()
|
||||
|
||||
assert attempts == 1
|
||||
assert session.status == mcp_module.MCPSessionStatus.ERROR
|
||||
assert session.retry_count == 1
|
||||
|
||||
def test_stdio_session_without_box_service_uses_local_stdio(self, mcp_module):
|
||||
ap = _make_ap()
|
||||
del ap.box_service
|
||||
|
||||
@@ -12,7 +12,7 @@ import pytest
|
||||
from aiohttp import web
|
||||
from mcp import types as mcp_types
|
||||
|
||||
from langbot.pkg.provider.tools.loaders.mcp import MCPToolCallTimeoutError, RuntimeMCPSession
|
||||
from langbot.pkg.provider.tools.loaders.mcp import RuntimeMCPSession
|
||||
|
||||
|
||||
class _TransportProbe:
|
||||
@@ -65,25 +65,6 @@ class _TransportProbe:
|
||||
},
|
||||
}
|
||||
)
|
||||
if method == 'tools/call':
|
||||
tool_name = message.get('params', {}).get('name')
|
||||
if tool_name == 'hang':
|
||||
return web.Response(status=202)
|
||||
return web.json_response(
|
||||
{
|
||||
'jsonrpc': '2.0',
|
||||
'id': message['id'],
|
||||
'result': {
|
||||
'content': [
|
||||
{
|
||||
'type': 'text',
|
||||
'text': 'healthy',
|
||||
}
|
||||
],
|
||||
'isError': False,
|
||||
},
|
||||
}
|
||||
)
|
||||
return web.Response(status=202)
|
||||
return web.Response(status=self.streamable_status)
|
||||
|
||||
@@ -145,22 +126,11 @@ async def _transport_server(streamable_status: int | None):
|
||||
await runner.cleanup()
|
||||
|
||||
|
||||
def _session(
|
||||
url: str,
|
||||
*,
|
||||
timeout: float = 2,
|
||||
tool_call_timeout_sec: float = 300,
|
||||
) -> RuntimeMCPSession:
|
||||
def _session(url: str, *, timeout: float = 2) -> RuntimeMCPSession:
|
||||
app = cast(Any, SimpleNamespace(logger=Mock()))
|
||||
return RuntimeMCPSession(
|
||||
'remote-transport-test',
|
||||
{
|
||||
'uuid': 'srv-1',
|
||||
'mode': 'remote',
|
||||
'url': url,
|
||||
'timeout': timeout,
|
||||
'tool_call_timeout_sec': tool_call_timeout_sec,
|
||||
},
|
||||
{'uuid': 'srv-1', 'mode': 'remote', 'url': url, 'timeout': timeout},
|
||||
True,
|
||||
app,
|
||||
)
|
||||
@@ -194,24 +164,6 @@ async def test_remote_transport_real_streamable_http_success_keeps_session_usabl
|
||||
await _close_session(session)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_remote_transport_tool_timeout_does_not_poison_session():
|
||||
async with _transport_server(200) as (probe, url):
|
||||
session = _session(url, tool_call_timeout_sec=0.05)
|
||||
try:
|
||||
await session._init_remote_server()
|
||||
|
||||
with pytest.raises(MCPToolCallTimeoutError, match='timed out after 0.05 seconds'):
|
||||
await session.invoke_mcp_tool('hang', {})
|
||||
|
||||
result = await session.invoke_mcp_tool('health_check', {})
|
||||
|
||||
assert result[0].text == 'healthy'
|
||||
assert probe.streamable_messages.count('tools/call') == 2
|
||||
finally:
|
||||
await _close_session(session)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize('status_code', [400, 404, 405])
|
||||
async def test_remote_transport_real_streamable_http_error_falls_back_to_legacy_sse(status_code: int):
|
||||
|
||||
@@ -1,25 +1,20 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import base64
|
||||
from datetime import timedelta
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from mcp import types as mcp_types
|
||||
from mcp.shared.exceptions import McpError
|
||||
|
||||
from langbot.pkg.provider.tools.loaders.mcp import (
|
||||
MCP_RESOURCE_CONTEXT_QUERY_KEY,
|
||||
MCP_RESOURCE_TRACE_QUERY_KEY,
|
||||
MCP_TOOL_CALL_TIMEOUT_DEFAULT_SECONDS,
|
||||
MCP_TOOL_LIST_RESOURCES,
|
||||
MCP_TOOL_READ_RESOURCE,
|
||||
MCPLoader,
|
||||
MCPSessionStatus,
|
||||
MCPToolCallTimeoutError,
|
||||
RuntimeMCPSession,
|
||||
)
|
||||
from langbot.pkg.telemetry import features as telemetry_features
|
||||
@@ -66,111 +61,6 @@ def _http_status_error(status_code: int) -> httpx.HTTPStatusError:
|
||||
return httpx.HTTPStatusError(f'HTTP {status_code}', request=request, response=response)
|
||||
|
||||
|
||||
def _tool_result(text: str = 'ok') -> mcp_types.CallToolResult:
|
||||
return mcp_types.CallToolResult(
|
||||
content=[mcp_types.TextContent(type='text', text=text)],
|
||||
isError=False,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invoke_mcp_tool_uses_configurable_request_timeout():
|
||||
session = RuntimeMCPSession(
|
||||
'slow-tools',
|
||||
{
|
||||
'uuid': 'srv-1',
|
||||
'mode': 'remote',
|
||||
'tool_call_timeout_sec': 900,
|
||||
},
|
||||
True,
|
||||
_app(),
|
||||
)
|
||||
session.session = SimpleNamespace(call_tool=AsyncMock(return_value=_tool_result()))
|
||||
|
||||
result = await session.invoke_mcp_tool('render_video', {'quality': 'high'})
|
||||
|
||||
assert result[0].text == 'ok'
|
||||
session.session.call_tool.assert_awaited_once_with(
|
||||
'render_video',
|
||||
{'quality': 'high'},
|
||||
read_timeout_seconds=timedelta(seconds=900),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invoke_mcp_tool_zero_timeout_disables_request_deadline():
|
||||
session = RuntimeMCPSession(
|
||||
'unbounded-tools',
|
||||
{
|
||||
'uuid': 'srv-1',
|
||||
'mode': 'remote',
|
||||
'tool_call_timeout_sec': 0,
|
||||
},
|
||||
True,
|
||||
_app(),
|
||||
)
|
||||
session.session = SimpleNamespace(call_tool=AsyncMock(return_value=_tool_result()))
|
||||
|
||||
await session.invoke_mcp_tool('long_job', {})
|
||||
|
||||
session.session.call_tool.assert_awaited_once_with(
|
||||
'long_job',
|
||||
{},
|
||||
read_timeout_seconds=None,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invoke_mcp_tool_timeout_is_not_retried_and_session_remains_usable():
|
||||
session = RuntimeMCPSession(
|
||||
'recoverable-tools',
|
||||
{
|
||||
'uuid': 'srv-1',
|
||||
'mode': 'remote',
|
||||
'tool_call_timeout_sec': 5,
|
||||
},
|
||||
True,
|
||||
_app(),
|
||||
)
|
||||
timeout = McpError(
|
||||
mcp_types.ErrorData(
|
||||
code=httpx.codes.REQUEST_TIMEOUT,
|
||||
message='Timed out while waiting for response to ClientRequest. Waited 5 seconds.',
|
||||
)
|
||||
)
|
||||
call_tool = AsyncMock(side_effect=[timeout, _tool_result('recovered')])
|
||||
session.session = SimpleNamespace(call_tool=call_tool)
|
||||
|
||||
with pytest.raises(
|
||||
MCPToolCallTimeoutError,
|
||||
match="MCP tool 'long_job' on server 'recoverable-tools' timed out after 5 seconds",
|
||||
):
|
||||
await session.invoke_mcp_tool('long_job', {})
|
||||
|
||||
assert call_tool.await_count == 1
|
||||
second_result = await session.invoke_mcp_tool('health_check', {})
|
||||
assert second_result[0].text == 'recovered'
|
||||
assert call_tool.await_count == 2
|
||||
|
||||
|
||||
@pytest.mark.parametrize('invalid_timeout', [-1, float('inf'), 1e300, True, 'not-a-number'])
|
||||
def test_invalid_tool_call_timeout_falls_back_to_default(invalid_timeout):
|
||||
ap = _app()
|
||||
session = RuntimeMCPSession(
|
||||
'invalid-timeout',
|
||||
{
|
||||
'uuid': 'srv-1',
|
||||
'mode': 'remote',
|
||||
'tool_call_timeout_sec': invalid_timeout,
|
||||
},
|
||||
True,
|
||||
ap,
|
||||
)
|
||||
|
||||
assert session.tool_call_timeout_sec == MCP_TOOL_CALL_TIMEOUT_DEFAULT_SECONDS
|
||||
ap.logger.warning.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_remote_transport_falls_back_to_sse_for_compatible_http_status_in_exception_group():
|
||||
session = RuntimeMCPSession(
|
||||
@@ -431,42 +321,3 @@ async def test_build_resource_context_for_query_uses_only_bound_attached_text_re
|
||||
assert query.variables[MCP_RESOURCE_CONTEXT_QUERY_KEY]['resource_count'] == 1
|
||||
docs.session.read_resource.assert_awaited_once()
|
||||
other.session.read_resource.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_mcp_loader_shutdown_cancels_startup_tasks_and_closes_sessions_concurrently():
|
||||
loader = MCPLoader(_app())
|
||||
hosted_cancelled = asyncio.Event()
|
||||
|
||||
async def pending_host():
|
||||
try:
|
||||
await asyncio.Event().wait()
|
||||
finally:
|
||||
hosted_cancelled.set()
|
||||
|
||||
hosted_task = asyncio.create_task(pending_host())
|
||||
await asyncio.sleep(0)
|
||||
loader._hosted_mcp_tasks = [hosted_task]
|
||||
|
||||
started: set[str] = set()
|
||||
all_started = asyncio.Event()
|
||||
|
||||
class Session:
|
||||
def __init__(self, name: str):
|
||||
self.name = name
|
||||
|
||||
async def shutdown(self):
|
||||
started.add(self.name)
|
||||
if len(started) == 2:
|
||||
all_started.set()
|
||||
await all_started.wait()
|
||||
|
||||
loader.sessions = {'one': Session('one'), 'two': Session('two')}
|
||||
|
||||
await asyncio.wait_for(loader.shutdown(), timeout=1)
|
||||
|
||||
assert hosted_cancelled.is_set()
|
||||
assert hosted_task.cancelled()
|
||||
assert started == {'one', 'two'}
|
||||
assert loader._hosted_mcp_tasks == []
|
||||
assert loader.sessions == {}
|
||||
|
||||
@@ -8,8 +8,7 @@ and error handling without calling real LLM APIs.
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
from unittest.mock import Mock
|
||||
|
||||
from langbot.pkg.provider.modelmgr.modelmgr import ModelManager
|
||||
from langbot.pkg.provider.modelmgr import requester
|
||||
@@ -63,48 +62,6 @@ async def test_model_manager_skips_space_sync_when_disabled(mock_app_for_modelmg
|
||||
app.space_service.get_models.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sync_new_models_from_space_creates_rerank_models(mock_app_for_modelmgr):
|
||||
"""Space rerank entries are discovered and persisted under the shared provider."""
|
||||
app = mock_app_for_modelmgr
|
||||
provider = persistence_model.ModelProvider(
|
||||
uuid='space-provider',
|
||||
name='LangBot Space',
|
||||
requester='space-chat-completions',
|
||||
base_url='https://api.langbot.cloud/v1',
|
||||
api_keys=['space-key'],
|
||||
)
|
||||
app.persistence_mgr.execute_async = AsyncMock(return_value=_make_mock_result([provider], first_item=provider))
|
||||
app.space_service.get_models = AsyncMock(
|
||||
return_value=[
|
||||
SimpleNamespace(
|
||||
uuid='rerank-model-uuid',
|
||||
model_id='Qwen3-Reranker-8B',
|
||||
category='rerank',
|
||||
featured_order=10,
|
||||
)
|
||||
]
|
||||
)
|
||||
app.llm_model_service.get_llm_models = AsyncMock(return_value=[])
|
||||
app.embedding_models_service.get_embedding_models = AsyncMock(return_value=[])
|
||||
app.rerank_models_service = AsyncMock()
|
||||
app.rerank_models_service.get_rerank_models = AsyncMock(return_value=[])
|
||||
|
||||
model_mgr = ModelManager(app)
|
||||
await model_mgr.sync_new_models_from_space()
|
||||
|
||||
app.rerank_models_service.create_rerank_model.assert_awaited_once_with(
|
||||
{
|
||||
'uuid': 'rerank-model-uuid',
|
||||
'name': 'Qwen3-Reranker-8B',
|
||||
'provider_uuid': 'space-provider',
|
||||
'extra_args': {},
|
||||
'prefered_ranking': 10,
|
||||
},
|
||||
preserve_uuid=True,
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Model Loading Tests
|
||||
# ============================================================================
|
||||
|
||||
@@ -458,25 +458,6 @@ class TestSkillToolLoader:
|
||||
assert await loader.has_tool('activate') is True
|
||||
assert await loader.has_tool('register_skill') is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_tools_reappear_after_box_backend_recovers(self):
|
||||
from langbot.pkg.provider.tools.loaders.skill_authoring import SkillToolLoader
|
||||
|
||||
ap = _make_ap()
|
||||
ap.skill_mgr = SimpleNamespace(skills={'demo': _make_skill_data(name='demo')})
|
||||
ap.box_service = SimpleNamespace(
|
||||
available=False,
|
||||
get_status=AsyncMock(return_value={'backend': {'available': True}}),
|
||||
)
|
||||
|
||||
loader = SkillToolLoader(ap)
|
||||
await loader.initialize()
|
||||
assert await loader.get_tools() == []
|
||||
|
||||
ap.box_service.available = True
|
||||
|
||||
assert sorted(tool.name for tool in await loader.get_tools()) == ['activate', 'register_skill']
|
||||
|
||||
|
||||
class TestNativeToolLoaderSkillPaths:
|
||||
@pytest.mark.asyncio
|
||||
|
||||
@@ -151,21 +151,6 @@ async def test_native_tool_loader_exposes_all_tools_when_box_available():
|
||||
assert await loader.has_tool(tool_name) is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_native_tool_loader_refreshes_after_box_recovers():
|
||||
box_service = SimpleNamespace(
|
||||
available=False,
|
||||
get_status=AsyncMock(return_value={'backend': {'available': True}}),
|
||||
)
|
||||
loader = NativeToolLoader(SimpleNamespace(box_service=box_service, logger=Mock()))
|
||||
await loader.initialize()
|
||||
assert await loader.get_tools() == []
|
||||
|
||||
box_service.available = True
|
||||
|
||||
assert [tool.name for tool in await loader.get_tools()] == ['exec', 'read', 'write', 'edit', 'glob', 'grep']
|
||||
|
||||
|
||||
# ── read/write/edit file tool tests ─────────────────────────────
|
||||
|
||||
|
||||
|
||||
@@ -357,13 +357,10 @@ class TestCredentialsBuild:
|
||||
cred_calls.append(kwargs)
|
||||
return ('CRED', kwargs)
|
||||
|
||||
# These names are absent when the optional valkey-glide dependency is
|
||||
# unavailable (for example, on Windows), so allow the test doubles to
|
||||
# create them on the module.
|
||||
monkeypatch.setattr(mod, 'GlideClient', _FakeClient, raising=False)
|
||||
monkeypatch.setattr(mod, 'ServerCredentials', _fake_credentials, raising=False)
|
||||
monkeypatch.setattr(mod, 'GlideClientConfiguration', lambda **kw: kw, raising=False)
|
||||
monkeypatch.setattr(mod, 'NodeAddress', lambda *a, **k: ('node', a, k), raising=False)
|
||||
monkeypatch.setattr(mod, 'GlideClient', _FakeClient)
|
||||
monkeypatch.setattr(mod, 'ServerCredentials', _fake_credentials)
|
||||
monkeypatch.setattr(mod, 'GlideClientConfiguration', lambda **kw: kw)
|
||||
monkeypatch.setattr(mod, 'NodeAddress', lambda *a, **k: ('node', a, k))
|
||||
return backend, created, cred_calls, warnings
|
||||
|
||||
async def test_username_without_password_fails_closed(self, monkeypatch):
|
||||
|
||||
@@ -2008,7 +2008,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "langbot"
|
||||
version = "4.10.6"
|
||||
version = "4.10.5"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "aiocqhttp" },
|
||||
@@ -2084,7 +2084,7 @@ dependencies = [
|
||||
{ name = "tiktoken" },
|
||||
{ name = "urllib3" },
|
||||
{ name = "uv" },
|
||||
{ name = "valkey-glide", marker = "sys_platform != 'win32'" },
|
||||
{ name = "valkey-glide" },
|
||||
{ name = "websockets" },
|
||||
]
|
||||
|
||||
@@ -2124,7 +2124,7 @@ requires-dist = [
|
||||
{ name = "ebooklib", specifier = ">=0.18" },
|
||||
{ name = "gewechat-client", specifier = ">=0.1.5" },
|
||||
{ name = "html2text", specifier = ">=2024.2.26" },
|
||||
{ name = "langbot-plugin", specifier = "==0.4.17" },
|
||||
{ name = "langbot-plugin", specifier = "==0.4.13" },
|
||||
{ name = "langchain", specifier = ">=1.3.9" },
|
||||
{ name = "langchain-core", specifier = ">=1.3.3" },
|
||||
{ name = "langchain-text-splitters", specifier = ">=1.1.2" },
|
||||
@@ -2173,7 +2173,7 @@ requires-dist = [
|
||||
{ name = "tiktoken", specifier = ">=0.9.0" },
|
||||
{ name = "urllib3", specifier = ">=2.7.0" },
|
||||
{ name = "uv", specifier = ">=0.11.15" },
|
||||
{ name = "valkey-glide", marker = "sys_platform != 'win32'", specifier = ">=2.4.1,<3.0.0" },
|
||||
{ name = "valkey-glide", specifier = ">=2.4.1,<3.0.0" },
|
||||
{ name = "websockets", specifier = ">=15.0.1" },
|
||||
]
|
||||
|
||||
@@ -2189,7 +2189,7 @@ dev = [
|
||||
|
||||
[[package]]
|
||||
name = "langbot-plugin"
|
||||
version = "0.4.17"
|
||||
version = "0.4.13"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "aiofiles" },
|
||||
@@ -2210,9 +2210,9 @@ dependencies = [
|
||||
{ name = "watchdog" },
|
||||
{ name = "websockets" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/3a/96/336d7ac97ff5a9c413e7aaf0c329a7047ec91dec5fa4f9e0b1d0abb57d0e/langbot_plugin-0.4.17.tar.gz", hash = "sha256:b1539444f16568c0b3244f68c498ac9498c56e2c20f12e220e6ee735b8d6b5c2", size = 347080, upload-time = "2026-07-23T09:23:55.941Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/40/a6/1eaf77c3b81e9de3390c504c5f627dc41f43bff6df9aff0e1e31d796b6f0/langbot_plugin-0.4.13.tar.gz", hash = "sha256:f936340e67679c21f1e7e7f1447339f31a0a2c965db060ecfbd9d0c51bb0d6fe", size = 334887, upload-time = "2026-07-04T05:38:59.942Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/98/ac372a238e59894ac54189cb220a3fb75da48f319881eeb49d583712549a/langbot_plugin-0.4.17-py3-none-any.whl", hash = "sha256:6fd6c9d7e0583f04659f023a233c4201cb4c450d1ef696e79fa1e338933ee156", size = 229881, upload-time = "2026-07-23T09:23:54.582Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/bf/fc9671a7afbd933440c38403c84d918c1022fdeed16e22a6ab3b2aec83ff/langbot_plugin-0.4.13-py3-none-any.whl", hash = "sha256:9d45ebc7a7ee0413d6db9baa009fcbf0ad07e2e1753a6f0a27f37b8b665cd1ee", size = 221884, upload-time = "2026-07-04T05:38:58.525Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5991,9 +5991,9 @@ name = "valkey-glide"
|
||||
version = "2.4.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio", marker = "sys_platform != 'win32'" },
|
||||
{ name = "protobuf", marker = "sys_platform != 'win32'" },
|
||||
{ name = "sniffio", marker = "sys_platform != 'win32'" },
|
||||
{ name = "anyio" },
|
||||
{ name = "protobuf" },
|
||||
{ name = "sniffio" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/72/a2/582b34c6acc8dc857c537f6007459cba48dfa0dc404789a657e5c1a998c0/valkey_glide-2.4.1.tar.gz", hash = "sha256:f1155d84156d11b90488aa67e90102f0bf98a45314f5b99308ac9074c05f7241", size = 898030, upload-time = "2026-05-28T21:41:55.881Z" }
|
||||
wheels = [
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
"dev": "vite",
|
||||
"build": "tsc && vite build",
|
||||
"preview": "vite preview",
|
||||
"test:unit": "node --test tests/unit/*.test.mjs",
|
||||
"test:e2e": "playwright test",
|
||||
"lint": "eslint .",
|
||||
"format": "prettier --write ."
|
||||
@@ -32,7 +31,6 @@
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@fingerprintjs/fingerprintjs": "^4.6.2",
|
||||
"@hookform/resolvers": "^5.0.1",
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
"@radix-ui/react-avatar": "^1.1.11",
|
||||
|
||||
Generated
-9
@@ -24,9 +24,6 @@ dependencies:
|
||||
'@dnd-kit/utilities':
|
||||
specifier: ^3.2.2
|
||||
version: 3.2.2(react@19.2.1)
|
||||
'@fingerprintjs/fingerprintjs':
|
||||
specifier: ^4.6.2
|
||||
version: 4.6.2
|
||||
'@hookform/resolvers':
|
||||
specifier: ^5.0.1
|
||||
version: 5.2.2(react-hook-form@7.71.1)
|
||||
@@ -425,12 +422,6 @@ packages:
|
||||
levn: 0.4.1
|
||||
dev: true
|
||||
|
||||
/@fingerprintjs/fingerprintjs@4.6.2:
|
||||
resolution: {integrity: sha512-g8mXuqcFKbgH2CZKwPfVtsUJDHyvcgIABQI7Y0tzWEFXpGxJaXuAuzlifT2oTakjDBLTK4Gaa9/5PERDhqUjtw==}
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
dev: false
|
||||
|
||||
/@floating-ui/core@1.7.4:
|
||||
resolution: {integrity: sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg==}
|
||||
dependencies:
|
||||
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
FormMessage,
|
||||
} from '@/components/ui/form';
|
||||
import DynamicFormItemComponent from '@/app/home/components/dynamic-form/DynamicFormItemComponent';
|
||||
import { normalizeDynamicFormValuesForSave } from '@/app/home/components/dynamic-form/DynamicFormSaveValues';
|
||||
import QrCodeLoginDialog, {
|
||||
QrLoginPlatform,
|
||||
} from '@/app/home/components/qrcode-login/QrCodeLoginDialog';
|
||||
@@ -632,9 +631,12 @@ export default function DynamicFormComponent({
|
||||
// even if the user saves without modifying any field.
|
||||
// form.watch(callback) only fires on subsequent changes, not on mount.
|
||||
const formValues = form.getValues();
|
||||
const initialFinalValues = normalizeDynamicFormValuesForSave(
|
||||
editableValueSpecs,
|
||||
formValues as Record<string, unknown>,
|
||||
const initialFinalValues = editableValueSpecs.reduce(
|
||||
(acc, item) => {
|
||||
acc[item.name] = formValues[item.name] ?? item.default;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, object>,
|
||||
);
|
||||
onSubmitRef.current?.(initialFinalValues);
|
||||
|
||||
@@ -649,9 +651,12 @@ export default function DynamicFormComponent({
|
||||
|
||||
const subscription = form.watch(() => {
|
||||
const formValues = form.getValues();
|
||||
const finalValues = normalizeDynamicFormValuesForSave(
|
||||
editableValueSpecs,
|
||||
formValues as Record<string, unknown>,
|
||||
const finalValues = editableValueSpecs.reduce(
|
||||
(acc, item) => {
|
||||
acc[item.name] = formValues[item.name] ?? item.default;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, object>,
|
||||
);
|
||||
onSubmitRef.current?.(finalValues);
|
||||
previousInitialValues.current = finalValues as Record<string, object>;
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
import type { IDynamicFormItemSchema } from '@/app/infra/entities/form/dynamic';
|
||||
|
||||
export type DynamicFormSaveValueSpec = Pick<
|
||||
IDynamicFormItemSchema,
|
||||
'default' | 'name' | 'type'
|
||||
>;
|
||||
|
||||
/**
|
||||
* Build the value snapshot emitted to parent forms for persistence.
|
||||
* Only single-line string fields trim surrounding whitespace; multiline text
|
||||
* and every other dynamic form field type preserve their original values.
|
||||
*/
|
||||
export function normalizeDynamicFormValuesForSave(
|
||||
specs: readonly DynamicFormSaveValueSpec[],
|
||||
formValues: Record<string, unknown>,
|
||||
): Record<string, unknown> {
|
||||
return specs.reduce<Record<string, unknown>>((values, spec) => {
|
||||
const value = formValues[spec.name] ?? spec.default;
|
||||
values[spec.name] =
|
||||
spec.type === 'string' && typeof value === 'string'
|
||||
? value.trim()
|
||||
: value;
|
||||
return values;
|
||||
}, {});
|
||||
}
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
} from '@/components/ui/form';
|
||||
import { IDynamicFormItemSchema } from '@/app/infra/entities/form/dynamic';
|
||||
import DynamicFormItemComponent from '@/app/home/components/dynamic-form/DynamicFormItemComponent';
|
||||
import { normalizeDynamicFormValuesForSave } from '@/app/home/components/dynamic-form/DynamicFormSaveValues';
|
||||
import { extractI18nObject } from '@/i18n/I18nProvider';
|
||||
|
||||
/**
|
||||
@@ -151,9 +150,12 @@ export default function N8nAuthFormComponent({
|
||||
// Emit initial form values on mount so the parent form's
|
||||
// initializedStagesRef registers this stage (matches DynamicFormComponent).
|
||||
const formValues = form.getValues();
|
||||
const initialFinalValues = normalizeDynamicFormValuesForSave(
|
||||
itemConfigList,
|
||||
formValues as Record<string, unknown>,
|
||||
const initialFinalValues = itemConfigList.reduce(
|
||||
(acc, item) => {
|
||||
acc[item.name] = formValues[item.name] ?? item.default;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, string>,
|
||||
);
|
||||
onSubmitRef.current?.(initialFinalValues);
|
||||
previousInitialValues.current = initialFinalValues as Record<
|
||||
@@ -169,9 +171,12 @@ export default function N8nAuthFormComponent({
|
||||
|
||||
// 获取完整的表单值,确保包含所有默认值
|
||||
const formValues = form.getValues();
|
||||
const finalValues = normalizeDynamicFormValuesForSave(
|
||||
itemConfigList,
|
||||
formValues as Record<string, unknown>,
|
||||
const finalValues = itemConfigList.reduce(
|
||||
(acc, item) => {
|
||||
acc[item.name] = formValues[item.name] ?? item.default;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, string>,
|
||||
);
|
||||
|
||||
onSubmitRef.current?.(finalValues);
|
||||
|
||||
@@ -435,10 +435,6 @@ const getFormSchema = (t: TFunction) =>
|
||||
.number({ invalid_type_error: t('mcp.timeoutMustBeNumber') })
|
||||
.positive({ message: t('mcp.timeoutMustBePositive') })
|
||||
.default(30),
|
||||
tool_call_timeout_sec: z
|
||||
.number({ invalid_type_error: t('mcp.timeoutMustBeNumber') })
|
||||
.nonnegative({ message: t('mcp.timeoutNonNegative') })
|
||||
.default(300),
|
||||
ssereadtimeout: z
|
||||
.number({ invalid_type_error: t('mcp.sseTimeoutMustBeNumber') })
|
||||
.positive({ message: t('mcp.timeoutMustBePositive') })
|
||||
@@ -478,7 +474,6 @@ const getFormSchema = (t: TFunction) =>
|
||||
|
||||
type FormValues = z.infer<ReturnType<typeof getFormSchema>> & {
|
||||
timeout: number;
|
||||
tool_call_timeout_sec: number;
|
||||
ssereadtimeout: number;
|
||||
};
|
||||
|
||||
@@ -540,7 +535,6 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
|
||||
command: '',
|
||||
args: [],
|
||||
timeout: 30,
|
||||
tool_call_timeout_sec: 300,
|
||||
ssereadtimeout: 300,
|
||||
extra_args: [],
|
||||
...initialDraftRef.current,
|
||||
@@ -615,7 +609,6 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
|
||||
command: '',
|
||||
args: [],
|
||||
timeout: 30,
|
||||
tool_call_timeout_sec: 300,
|
||||
ssereadtimeout: 300,
|
||||
extra_args: [],
|
||||
...initialDraftRef.current,
|
||||
@@ -694,16 +687,10 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
|
||||
command: '',
|
||||
args: [],
|
||||
timeout: 30,
|
||||
tool_call_timeout_sec: 300,
|
||||
ssereadtimeout: 300,
|
||||
extra_args: [],
|
||||
};
|
||||
|
||||
if (typeof server.extra_args.tool_call_timeout_sec === 'number') {
|
||||
formValues.tool_call_timeout_sec =
|
||||
server.extra_args.tool_call_timeout_sec;
|
||||
}
|
||||
|
||||
let newExtraArgs: {
|
||||
key: string;
|
||||
type: 'string' | 'number' | 'boolean';
|
||||
@@ -783,7 +770,6 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
|
||||
url: value.url!,
|
||||
headers,
|
||||
timeout: value.timeout,
|
||||
tool_call_timeout_sec: value.tool_call_timeout_sec,
|
||||
},
|
||||
};
|
||||
} else {
|
||||
@@ -800,7 +786,6 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
|
||||
command: value.command!,
|
||||
args: value.args?.map((arg) => arg.value) || [],
|
||||
env,
|
||||
tool_call_timeout_sec: value.tool_call_timeout_sec,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -850,7 +835,6 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
|
||||
extraArgsData = {
|
||||
url: form.getValues('url')!,
|
||||
timeout: form.getValues('timeout'),
|
||||
tool_call_timeout_sec: form.getValues('tool_call_timeout_sec'),
|
||||
headers: Object.fromEntries(
|
||||
formExtraArgs.map((arg) => [arg.key, arg.value]),
|
||||
),
|
||||
@@ -862,7 +846,6 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
|
||||
env: Object.fromEntries(
|
||||
formExtraArgs.map((arg) => [arg.key, arg.value]),
|
||||
),
|
||||
tool_call_timeout_sec: form.getValues('tool_call_timeout_sec'),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1064,30 +1047,6 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="tool_call_timeout_sec"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t('mcp.toolCallTimeout')}</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
step={1}
|
||||
placeholder="300"
|
||||
{...field}
|
||||
onChange={(e) => field.onChange(Number(e.target.value))}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormDescription>
|
||||
{t('mcp.toolCallTimeoutDescription')}
|
||||
</FormDescription>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
{watchMode === 'remote' && (
|
||||
<>
|
||||
<FormField
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
import {
|
||||
AppWindow,
|
||||
AudioWaveform,
|
||||
Book,
|
||||
FileText,
|
||||
Hash,
|
||||
Wrench,
|
||||
type LucideIcon,
|
||||
} from 'lucide-react';
|
||||
|
||||
export const pluginComponentIconMap: Record<string, LucideIcon> = {
|
||||
Tool: Wrench,
|
||||
EventListener: AudioWaveform,
|
||||
Command: Hash,
|
||||
KnowledgeEngine: Book,
|
||||
Parser: FileText,
|
||||
Page: AppWindow,
|
||||
};
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Fragment } from 'react';
|
||||
import { TFunction } from 'i18next';
|
||||
import { Wrench, AudioWaveform, Hash, Book, FileText } from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { pluginComponentIconMap } from './PluginComponentIcons';
|
||||
|
||||
export default function PluginComponentList({
|
||||
components,
|
||||
@@ -18,6 +18,14 @@ export default function PluginComponentList({
|
||||
t: TFunction;
|
||||
responsive?: boolean;
|
||||
}) {
|
||||
const kindIconMap: Record<string, React.ReactNode> = {
|
||||
Tool: <Wrench className="w-5 h-5" />,
|
||||
EventListener: <AudioWaveform className="w-5 h-5" />,
|
||||
Command: <Hash className="w-5 h-5" />,
|
||||
KnowledgeEngine: <Book className="w-5 h-5" />,
|
||||
Parser: <FileText className="w-5 h-5" />,
|
||||
};
|
||||
|
||||
const componentKindList = Object.keys(components || {});
|
||||
|
||||
return (
|
||||
@@ -26,12 +34,11 @@ export default function PluginComponentList({
|
||||
{componentKindList.length > 0 && (
|
||||
<>
|
||||
{componentKindList.map((kind) => {
|
||||
const ComponentIcon = pluginComponentIconMap[kind];
|
||||
return (
|
||||
<Fragment key={kind}>
|
||||
{useBadge && (
|
||||
<Badge variant="outline" className="flex items-center gap-1">
|
||||
{ComponentIcon && <ComponentIcon className="w-5 h-5" />}
|
||||
{kindIconMap[kind]}
|
||||
{responsive ? (
|
||||
<span className="hidden md:inline">
|
||||
{t('market.componentName.' + kind)}
|
||||
@@ -45,7 +52,7 @@ export default function PluginComponentList({
|
||||
|
||||
{!useBadge && (
|
||||
<div className="flex flex-row items-center justify-start gap-[0.2rem]">
|
||||
{ComponentIcon && <ComponentIcon className="w-5 h-5" />}
|
||||
{kindIconMap[kind]}
|
||||
{responsive ? (
|
||||
<span className="hidden md:inline">
|
||||
{t('market.componentName.' + kind)}
|
||||
|
||||
@@ -127,7 +127,7 @@ function MarketPageContent({
|
||||
// Per-format extension counts shown next to the type filter options.
|
||||
const [typeCounts, setTypeCounts] = useState<Record<string, number>>({});
|
||||
const [sortOption, setSortOption] = useState<string>(
|
||||
() => loadMarketFilters().sortOption ?? 'hot_score_desc',
|
||||
() => loadMarketFilters().sortOption ?? 'install_count_desc',
|
||||
);
|
||||
|
||||
// Persist filter conditions so they survive navigation / reload.
|
||||
@@ -154,12 +154,6 @@ function MarketPageContent({
|
||||
|
||||
// 排序选项
|
||||
const sortOptions: SortOption[] = [
|
||||
{
|
||||
value: 'hot_score_desc',
|
||||
label: t('market.sort.hottest'),
|
||||
sortBy: 'hot_score',
|
||||
sortOrder: 'DESC',
|
||||
},
|
||||
{
|
||||
value: 'created_at_desc',
|
||||
label: t('market.sort.recentlyAdded'),
|
||||
@@ -217,7 +211,7 @@ function MarketPageContent({
|
||||
const option = sortOptions.find((opt) => opt.value === sortOption);
|
||||
return option
|
||||
? { sortBy: option.sortBy, sortOrder: option.sortOrder }
|
||||
: { sortBy: 'hot_score', sortOrder: 'DESC' };
|
||||
: { sortBy: 'install_count', sortOrder: 'DESC' };
|
||||
}, [sortOption]);
|
||||
|
||||
// 将API响应转换为VO对象
|
||||
@@ -239,7 +233,6 @@ function MarketPageContent({
|
||||
description:
|
||||
extractI18nObject(plugin.description) || t('market.noDescription'),
|
||||
installCount: plugin.install_count || 0,
|
||||
likeCount: plugin.like_count || 0,
|
||||
iconURL,
|
||||
githubURL: plugin.repository,
|
||||
version: plugin.latest_version,
|
||||
|
||||
@@ -40,7 +40,6 @@ function pluginToVO(
|
||||
description:
|
||||
extractI18nObject(plugin.description) || t('market.noDescription'),
|
||||
installCount: plugin.install_count,
|
||||
likeCount: plugin.like_count || 0,
|
||||
iconURL,
|
||||
githubURL: plugin.repository,
|
||||
version: plugin.latest_version,
|
||||
|
||||
@@ -1,118 +0,0 @@
|
||||
import FingerprintJS, { type LoadOptions } from '@fingerprintjs/fingerprintjs';
|
||||
import { getCloudServiceClient } from '@/app/infra/http';
|
||||
|
||||
const MARKETPLACE_LIKE_CHANGED_EVENT = 'langbot-marketplace-like-changed';
|
||||
|
||||
export interface MarketplaceLikeChange {
|
||||
key: string;
|
||||
liked: boolean;
|
||||
likeCount: number;
|
||||
}
|
||||
|
||||
let fingerprintPromise: Promise<string> | undefined;
|
||||
let likedKeysPromise: Promise<Set<string>> | undefined;
|
||||
|
||||
export function marketplaceExtensionKey(
|
||||
type: string | undefined,
|
||||
author: string,
|
||||
name: string,
|
||||
): string {
|
||||
return `${type || 'plugin'}:${author}/${name}`;
|
||||
}
|
||||
|
||||
export function getMarketplaceFingerprint(): Promise<string> {
|
||||
if (!fingerprintPromise) {
|
||||
fingerprintPromise = FingerprintJS.load({
|
||||
monitoring: false,
|
||||
} as LoadOptions & { monitoring: boolean })
|
||||
.then((agent) => agent.get())
|
||||
.then((result) => result.visitorId)
|
||||
.catch((error) => {
|
||||
fingerprintPromise = undefined;
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
return fingerprintPromise;
|
||||
}
|
||||
|
||||
async function loadLikedKeys(): Promise<Set<string>> {
|
||||
if (!likedKeysPromise) {
|
||||
likedKeysPromise = Promise.all([
|
||||
getMarketplaceFingerprint(),
|
||||
getCloudServiceClient(),
|
||||
])
|
||||
.then(([fingerprint, client]) =>
|
||||
client.getMarketplaceLikedExtensions(fingerprint),
|
||||
)
|
||||
.then((data) => {
|
||||
const keys = new Set<string>();
|
||||
for (const ref of data.extensions || []) {
|
||||
keys.add(`${ref.type}:${ref.extension_id}`);
|
||||
}
|
||||
return keys;
|
||||
})
|
||||
.catch((error) => {
|
||||
likedKeysPromise = undefined;
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
return likedKeysPromise;
|
||||
}
|
||||
|
||||
export async function getMarketplaceExtensionLiked(
|
||||
type: string | undefined,
|
||||
author: string,
|
||||
name: string,
|
||||
): Promise<boolean> {
|
||||
const likedKeys = await loadLikedKeys();
|
||||
return likedKeys.has(marketplaceExtensionKey(type, author, name));
|
||||
}
|
||||
|
||||
export async function toggleMarketplaceExtensionLike(
|
||||
type: string | undefined,
|
||||
author: string,
|
||||
name: string,
|
||||
liked: boolean,
|
||||
): Promise<MarketplaceLikeChange> {
|
||||
const extensionType = type || 'plugin';
|
||||
const [fingerprint, likedKeys, client] = await Promise.all([
|
||||
getMarketplaceFingerprint(),
|
||||
loadLikedKeys(),
|
||||
getCloudServiceClient(),
|
||||
]);
|
||||
const result = await client.setMarketplaceExtensionLike(
|
||||
extensionType,
|
||||
author,
|
||||
name,
|
||||
fingerprint,
|
||||
liked,
|
||||
);
|
||||
const key = marketplaceExtensionKey(extensionType, author, name);
|
||||
if (result.liked) {
|
||||
likedKeys.add(key);
|
||||
} else {
|
||||
likedKeys.delete(key);
|
||||
}
|
||||
const change: MarketplaceLikeChange = {
|
||||
key,
|
||||
liked: result.liked,
|
||||
likeCount: result.like_count,
|
||||
};
|
||||
window.dispatchEvent(
|
||||
new CustomEvent<MarketplaceLikeChange>(MARKETPLACE_LIKE_CHANGED_EVENT, {
|
||||
detail: change,
|
||||
}),
|
||||
);
|
||||
return change;
|
||||
}
|
||||
|
||||
export function subscribeMarketplaceLikeChanges(
|
||||
listener: (change: MarketplaceLikeChange) => void,
|
||||
): () => void {
|
||||
const handler = (event: Event) => {
|
||||
listener((event as CustomEvent<MarketplaceLikeChange>).detail);
|
||||
};
|
||||
window.addEventListener(MARKETPLACE_LIKE_CHANGED_EVENT, handler);
|
||||
return () =>
|
||||
window.removeEventListener(MARKETPLACE_LIKE_CHANGED_EVENT, handler);
|
||||
}
|
||||
+2
-89
@@ -3,7 +3,7 @@ import { useRef, useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import PluginComponentList from '../PluginComponentList';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Info, Package, ExternalLink, Heart, Loader2 } from 'lucide-react';
|
||||
import { Info, Package, ExternalLink } from 'lucide-react';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
@@ -11,13 +11,6 @@ import {
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
getMarketplaceExtensionLiked,
|
||||
marketplaceExtensionKey,
|
||||
subscribeMarketplaceLikeChanges,
|
||||
toggleMarketplaceExtensionLike,
|
||||
} from '../marketplace-likes';
|
||||
|
||||
export default function PluginMarketCardComponent({
|
||||
cardVO,
|
||||
@@ -32,9 +25,6 @@ export default function PluginMarketCardComponent({
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
const [visibleTags, setVisibleTags] = useState(2);
|
||||
const [iconFailed, setIconFailed] = useState(!cardVO.iconURL);
|
||||
const [liked, setLiked] = useState(false);
|
||||
const [likeCount, setLikeCount] = useState(cardVO.likeCount);
|
||||
const [isLiking, setIsLiking] = useState(false);
|
||||
|
||||
const pluginDetailUrl = `https://space.langbot.app/market/${cardVO.author}/${cardVO.pluginName}`;
|
||||
|
||||
@@ -62,37 +52,6 @@ export default function PluginMarketCardComponent({
|
||||
setIconFailed(!cardVO.iconURL);
|
||||
}, [cardVO.iconURL]);
|
||||
|
||||
useEffect(() => {
|
||||
setLikeCount(cardVO.likeCount);
|
||||
}, [cardVO.likeCount]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
getMarketplaceExtensionLiked(cardVO.type, cardVO.author, cardVO.pluginName)
|
||||
.then((value) => {
|
||||
if (!cancelled) setLiked(value);
|
||||
})
|
||||
.catch(() => {
|
||||
// The count still renders if the viewer-state request is unavailable.
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [cardVO.author, cardVO.pluginName, cardVO.type]);
|
||||
|
||||
useEffect(() => {
|
||||
const key = marketplaceExtensionKey(
|
||||
cardVO.type,
|
||||
cardVO.author,
|
||||
cardVO.pluginName,
|
||||
);
|
||||
return subscribeMarketplaceLikeChanges((change) => {
|
||||
if (change.key !== key) return;
|
||||
setLiked(change.liked);
|
||||
setLikeCount(change.likeCount);
|
||||
});
|
||||
}, [cardVO.author, cardVO.pluginName, cardVO.type]);
|
||||
|
||||
useEffect(() => {
|
||||
const tags = cardVO.tags;
|
||||
if (!bottomRef.current || !tags || tags.length === 0) return;
|
||||
@@ -130,29 +89,6 @@ export default function PluginMarketCardComponent({
|
||||
onInstall?.(cardVO);
|
||||
};
|
||||
|
||||
const handleLikeClick = async (
|
||||
event: React.MouseEvent<HTMLButtonElement>,
|
||||
) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (isLiking) return;
|
||||
setIsLiking(true);
|
||||
try {
|
||||
const change = await toggleMarketplaceExtensionLike(
|
||||
cardVO.type,
|
||||
cardVO.author,
|
||||
cardVO.pluginName,
|
||||
!liked,
|
||||
);
|
||||
setLiked(change.liked);
|
||||
setLikeCount(change.likeCount);
|
||||
} catch {
|
||||
toast.error(t('market.likeFailed'));
|
||||
} finally {
|
||||
setIsLiking(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
role="button"
|
||||
@@ -161,10 +97,7 @@ export default function PluginMarketCardComponent({
|
||||
className="w-[100%] h-[10rem] cursor-pointer bg-white rounded-[10px] border border-border shadow-[0px_1px_2px_0_rgba(0,0,0,0.06)] p-3 sm:p-[1rem] hover:shadow-[0px_2px_5px_0_rgba(0,0,0,0.08)] transition-shadow duration-200 outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 dark:bg-[#1f1f22] dark:shadow-[0px_1px_2px_0_rgba(255,255,255,0.04)] dark:hover:shadow-[0px_2px_5px_0_rgba(255,255,255,0.07)] relative"
|
||||
onClick={handleInstallClick}
|
||||
onKeyDown={(event) => {
|
||||
if (
|
||||
event.target === event.currentTarget &&
|
||||
(event.key === 'Enter' || event.key === ' ')
|
||||
) {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
handleInstallClick();
|
||||
}
|
||||
@@ -244,26 +177,6 @@ export default function PluginMarketCardComponent({
|
||||
</div>
|
||||
|
||||
<div className="flex flex-row items-start justify-center gap-1 flex-shrink-0">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
title={t(liked ? 'market.unlike' : 'market.like')}
|
||||
aria-label={t(liked ? 'market.unlike' : 'market.like')}
|
||||
aria-pressed={liked}
|
||||
disabled={isLiking}
|
||||
className="h-7 min-w-7 gap-1 rounded-md px-1.5 text-muted-foreground hover:bg-red-50 hover:text-red-500 dark:hover:bg-red-950/30"
|
||||
onClick={handleLikeClick}
|
||||
>
|
||||
{isLiking ? (
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Heart
|
||||
className={`h-3.5 w-3.5 ${liked ? 'fill-red-500 text-red-500' : ''}`}
|
||||
/>
|
||||
)}
|
||||
<span className="text-xs tabular-nums">{likeCount}</span>
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
|
||||
-3
@@ -5,7 +5,6 @@ export interface IPluginMarketCardVO {
|
||||
label: string;
|
||||
description: string;
|
||||
installCount: number;
|
||||
likeCount?: number;
|
||||
iconURL: string;
|
||||
githubURL: string;
|
||||
version: string;
|
||||
@@ -23,7 +22,6 @@ export class PluginMarketCardVO implements IPluginMarketCardVO {
|
||||
iconURL: string;
|
||||
githubURL: string;
|
||||
installCount: number;
|
||||
likeCount: number;
|
||||
version: string;
|
||||
components?: Record<string, number>;
|
||||
tags?: string[];
|
||||
@@ -37,7 +35,6 @@ export class PluginMarketCardVO implements IPluginMarketCardVO {
|
||||
this.iconURL = prop.iconURL;
|
||||
this.githubURL = prop.githubURL;
|
||||
this.installCount = prop.installCount;
|
||||
this.likeCount = prop.likeCount ?? 0;
|
||||
this.pluginId = prop.pluginId;
|
||||
this.version = prop.version;
|
||||
this.components = prop.components;
|
||||
|
||||
@@ -519,21 +519,18 @@ export interface MCPServerExtraArgsSSE {
|
||||
headers: Record<string, string>;
|
||||
timeout: number;
|
||||
ssereadtimeout: number;
|
||||
tool_call_timeout_sec?: number;
|
||||
}
|
||||
|
||||
export interface MCPServerExtraArgsStdio {
|
||||
command: string;
|
||||
args: string[];
|
||||
env: Record<string, string>;
|
||||
tool_call_timeout_sec?: number;
|
||||
}
|
||||
|
||||
export interface MCPServerExtraArgsHttp {
|
||||
url: string;
|
||||
headers: Record<string, string>;
|
||||
timeout: number;
|
||||
tool_call_timeout_sec?: number;
|
||||
}
|
||||
|
||||
// "remote" mode: the user only supplies a URL; the backend auto-detects the
|
||||
@@ -543,7 +540,6 @@ export interface MCPServerExtraArgsRemote {
|
||||
url: string;
|
||||
headers?: Record<string, string>;
|
||||
timeout?: number;
|
||||
tool_call_timeout_sec?: number;
|
||||
}
|
||||
|
||||
export enum MCPSessionStatus {
|
||||
|
||||
@@ -48,8 +48,6 @@ export interface PluginV4 {
|
||||
repository: string;
|
||||
tags: string[];
|
||||
install_count: number;
|
||||
like_count?: number;
|
||||
hot_score?: number;
|
||||
latest_version: string;
|
||||
components: Record<string, number>;
|
||||
status: PluginV4Status;
|
||||
|
||||
@@ -117,32 +117,6 @@ export class CloudServiceClient extends BaseHttpClient {
|
||||
.catch(() => this.searchMarketplaceExtensionsLegacy(data));
|
||||
}
|
||||
|
||||
public getMarketplaceLikedExtensions(
|
||||
fingerprint: string,
|
||||
): Promise<{ extensions: Array<{ type: string; extension_id: string }> }> {
|
||||
return this.post('/api/v1/marketplace/extensions/likes/status', {
|
||||
fingerprint,
|
||||
});
|
||||
}
|
||||
|
||||
public setMarketplaceExtensionLike(
|
||||
type: string,
|
||||
author: string,
|
||||
name: string,
|
||||
fingerprint: string,
|
||||
liked: boolean,
|
||||
): Promise<{
|
||||
type: string;
|
||||
extension_id: string;
|
||||
liked: boolean;
|
||||
like_count: number;
|
||||
}> {
|
||||
return this.put(
|
||||
`/api/v1/marketplace/extensions/${encodeURIComponent(type)}/${encodeURIComponent(author)}/${encodeURIComponent(name)}/like`,
|
||||
{ fingerprint, liked },
|
||||
);
|
||||
}
|
||||
|
||||
private async searchMarketplaceExtensionsLegacy(data: {
|
||||
query?: string;
|
||||
page: number;
|
||||
@@ -154,8 +128,6 @@ export class CloudServiceClient extends BaseHttpClient {
|
||||
tags_filter?: string[];
|
||||
}): Promise<ApiRespMarketplacePlugins> {
|
||||
const query = data.query || '';
|
||||
const legacySortBy =
|
||||
data.sort_by === 'hot_score' ? 'install_count' : data.sort_by;
|
||||
|
||||
if (
|
||||
data.type_filter === 'plugin' ||
|
||||
@@ -167,7 +139,7 @@ export class CloudServiceClient extends BaseHttpClient {
|
||||
query,
|
||||
data.page,
|
||||
data.page_size,
|
||||
legacySortBy,
|
||||
data.sort_by,
|
||||
data.sort_order,
|
||||
data.component_filter,
|
||||
data.tags_filter,
|
||||
@@ -185,7 +157,7 @@ export class CloudServiceClient extends BaseHttpClient {
|
||||
query,
|
||||
data.page,
|
||||
data.page_size,
|
||||
legacySortBy,
|
||||
data.sort_by,
|
||||
data.sort_order,
|
||||
undefined,
|
||||
data.tags_filter,
|
||||
@@ -195,7 +167,7 @@ export class CloudServiceClient extends BaseHttpClient {
|
||||
query,
|
||||
data.page,
|
||||
data.page_size,
|
||||
legacySortBy,
|
||||
data.sort_by,
|
||||
data.sort_order,
|
||||
undefined,
|
||||
data.tags_filter,
|
||||
@@ -205,7 +177,7 @@ export class CloudServiceClient extends BaseHttpClient {
|
||||
query,
|
||||
data.page,
|
||||
data.page_size,
|
||||
legacySortBy,
|
||||
data.sort_by,
|
||||
data.sort_order,
|
||||
undefined,
|
||||
data.tags_filter,
|
||||
|
||||
@@ -700,7 +700,6 @@ const enUS = {
|
||||
notFound: 'Plugin information not found',
|
||||
sortBy: 'Sort by',
|
||||
sort: {
|
||||
hottest: 'Most Popular',
|
||||
recentlyAdded: 'Recently Added',
|
||||
recentlyUpdated: 'Recently Updated',
|
||||
mostDownloads: 'Most Downloads',
|
||||
@@ -708,9 +707,6 @@ const enUS = {
|
||||
},
|
||||
downloads: 'downloads',
|
||||
download: 'Download',
|
||||
like: 'Like',
|
||||
unlike: 'Unlike',
|
||||
likeFailed: 'Failed to update like. Please try again.',
|
||||
repository: 'Repository',
|
||||
downloadFailed: 'Download failed',
|
||||
noReadme: 'This plugin does not provide README documentation',
|
||||
@@ -801,9 +797,6 @@ const enUS = {
|
||||
url: 'URL',
|
||||
headers: 'Headers',
|
||||
timeout: 'Timeout',
|
||||
toolCallTimeout: 'Tool call timeout (seconds)',
|
||||
toolCallTimeoutDescription:
|
||||
'Maximum wait for one tool call. Set to 0 for no timeout. Defaults to 300 seconds.',
|
||||
addArgument: 'Add Argument',
|
||||
addEnvVar: 'Add Environment Variable',
|
||||
addHeader: 'Add Header',
|
||||
|
||||
@@ -713,7 +713,6 @@ const esES = {
|
||||
notFound: 'No se encontró la información del plugin',
|
||||
sortBy: 'Ordenar por',
|
||||
sort: {
|
||||
hottest: 'Más populares',
|
||||
recentlyAdded: 'Añadidos recientemente',
|
||||
recentlyUpdated: 'Actualizados recientemente',
|
||||
mostDownloads: 'Más descargas',
|
||||
@@ -721,9 +720,6 @@ const esES = {
|
||||
},
|
||||
downloads: 'descargas',
|
||||
download: 'Descargar',
|
||||
like: 'Me gusta',
|
||||
unlike: 'Ya no me gusta',
|
||||
likeFailed: 'No se pudo actualizar el Me gusta. Inténtalo de nuevo.',
|
||||
repository: 'Repositorio',
|
||||
downloadFailed: 'Error en la descarga',
|
||||
noReadme: 'Este plugin no proporciona documentación README',
|
||||
@@ -815,9 +811,6 @@ const esES = {
|
||||
url: 'URL',
|
||||
headers: 'Encabezados',
|
||||
timeout: 'Tiempo de espera',
|
||||
toolCallTimeout: 'Tiempo de espera de herramienta (segundos)',
|
||||
toolCallTimeoutDescription:
|
||||
'Espera máxima para una llamada de herramienta. Use 0 para no limitar. El valor predeterminado es 300 segundos.',
|
||||
addArgument: 'Añadir argumento',
|
||||
addEnvVar: 'Añadir variable de entorno',
|
||||
addHeader: 'Añadir encabezado',
|
||||
|
||||
@@ -706,7 +706,6 @@ const jaJP = {
|
||||
notFound: 'プラグイン情報が見つかりません',
|
||||
sortBy: '並び順',
|
||||
sort: {
|
||||
hottest: '人気順',
|
||||
recentlyAdded: '最近追加',
|
||||
recentlyUpdated: '最近更新',
|
||||
mostDownloads: 'ダウンロード数多',
|
||||
@@ -714,9 +713,6 @@ const jaJP = {
|
||||
},
|
||||
downloads: '回ダウンロード',
|
||||
download: 'ダウンロード',
|
||||
like: 'いいね',
|
||||
unlike: 'いいねを解除',
|
||||
likeFailed: 'いいねを更新できませんでした。もう一度お試しください。',
|
||||
repository: 'リポジトリ',
|
||||
downloadFailed: 'ダウンロード失敗',
|
||||
noReadme: 'このプラグインはREADMEドキュメントを提供していません',
|
||||
@@ -807,9 +803,6 @@ const jaJP = {
|
||||
url: 'URL',
|
||||
headers: 'ヘッダー',
|
||||
timeout: 'タイムアウト',
|
||||
toolCallTimeout: 'ツール呼び出しタイムアウト(秒)',
|
||||
toolCallTimeoutDescription:
|
||||
'1 回のツール呼び出しの最大待機時間です。0 で無制限、既定値は 300 秒です。',
|
||||
addArgument: '引数を追加',
|
||||
addEnvVar: '環境変数を追加',
|
||||
addHeader: 'ヘッダーを追加',
|
||||
|
||||
@@ -711,7 +711,6 @@ const ruRU = {
|
||||
notFound: 'Информация о плагине не найдена',
|
||||
sortBy: 'Сортировать по',
|
||||
sort: {
|
||||
hottest: 'По популярности',
|
||||
recentlyAdded: 'Недавно добавленные',
|
||||
recentlyUpdated: 'Недавно обновлённые',
|
||||
mostDownloads: 'Больше всего загрузок',
|
||||
@@ -719,9 +718,6 @@ const ruRU = {
|
||||
},
|
||||
downloads: 'загрузок',
|
||||
download: 'Скачать',
|
||||
like: 'Нравится',
|
||||
unlike: 'Убрать отметку',
|
||||
likeFailed: 'Не удалось обновить отметку. Повторите попытку.',
|
||||
repository: 'Репозиторий',
|
||||
downloadFailed: 'Ошибка загрузки',
|
||||
noReadme: 'Этот плагин не предоставляет документацию README',
|
||||
@@ -812,9 +808,6 @@ const ruRU = {
|
||||
url: 'URL',
|
||||
headers: 'Заголовки',
|
||||
timeout: 'Таймаут',
|
||||
toolCallTimeout: 'Таймаут вызова инструмента (секунды)',
|
||||
toolCallTimeoutDescription:
|
||||
'Максимальное ожидание одного вызова. 0 отключает ограничение. По умолчанию 300 секунд.',
|
||||
addArgument: 'Добавить аргумент',
|
||||
addEnvVar: 'Добавить переменную окружения',
|
||||
addHeader: 'Добавить заголовок',
|
||||
|
||||
@@ -690,7 +690,6 @@ const thTH = {
|
||||
notFound: 'ไม่พบข้อมูลปลั๊กอิน',
|
||||
sortBy: 'เรียงตาม',
|
||||
sort: {
|
||||
hottest: 'ยอดนิยมที่สุด',
|
||||
recentlyAdded: 'เพิ่มล่าสุด',
|
||||
recentlyUpdated: 'อัปเดตล่าสุด',
|
||||
mostDownloads: 'ดาวน์โหลดมากที่สุด',
|
||||
@@ -698,9 +697,6 @@ const thTH = {
|
||||
},
|
||||
downloads: 'ดาวน์โหลด',
|
||||
download: 'ดาวน์โหลด',
|
||||
like: 'ถูกใจ',
|
||||
unlike: 'เลิกถูกใจ',
|
||||
likeFailed: 'อัปเดตการถูกใจไม่สำเร็จ โปรดลองอีกครั้ง',
|
||||
repository: 'Repository',
|
||||
downloadFailed: 'ดาวน์โหลดล้มเหลว',
|
||||
noReadme: 'ปลั๊กอินนี้ไม่มีเอกสาร README',
|
||||
@@ -790,9 +786,6 @@ const thTH = {
|
||||
url: 'URL',
|
||||
headers: 'ส่วนหัว',
|
||||
timeout: 'หมดเวลา',
|
||||
toolCallTimeout: 'หมดเวลาการเรียกเครื่องมือ (วินาที)',
|
||||
toolCallTimeoutDescription:
|
||||
'เวลารอสูงสุดต่อการเรียกเครื่องมือหนึ่งครั้ง ตั้งเป็น 0 เพื่อไม่จำกัด ค่าเริ่มต้นคือ 300 วินาที',
|
||||
addArgument: 'เพิ่มอาร์กิวเมนต์',
|
||||
addEnvVar: 'เพิ่มตัวแปรสภาพแวดล้อม',
|
||||
addHeader: 'เพิ่มส่วนหัว',
|
||||
|
||||
@@ -705,7 +705,6 @@ const viVN = {
|
||||
notFound: 'Không tìm thấy thông tin plugin',
|
||||
sortBy: 'Sắp xếp theo',
|
||||
sort: {
|
||||
hottest: 'Phổ biến nhất',
|
||||
recentlyAdded: 'Mới thêm gần đây',
|
||||
recentlyUpdated: 'Mới cập nhật gần đây',
|
||||
mostDownloads: 'Tải nhiều nhất',
|
||||
@@ -713,9 +712,6 @@ const viVN = {
|
||||
},
|
||||
downloads: 'lượt tải',
|
||||
download: 'Tải xuống',
|
||||
like: 'Thích',
|
||||
unlike: 'Bỏ thích',
|
||||
likeFailed: 'Không thể cập nhật lượt thích. Vui lòng thử lại.',
|
||||
repository: 'Kho lưu trữ',
|
||||
downloadFailed: 'Tải xuống thất bại',
|
||||
noReadme: 'Plugin này không cung cấp tài liệu README',
|
||||
@@ -805,9 +801,6 @@ const viVN = {
|
||||
url: 'URL',
|
||||
headers: 'Tiêu đề',
|
||||
timeout: 'Thời gian chờ',
|
||||
toolCallTimeout: 'Thời gian chờ gọi công cụ (giây)',
|
||||
toolCallTimeoutDescription:
|
||||
'Thời gian chờ tối đa cho một lần gọi công cụ. Đặt 0 để không giới hạn. Mặc định là 300 giây.',
|
||||
addArgument: 'Thêm tham số',
|
||||
addEnvVar: 'Thêm biến môi trường',
|
||||
addHeader: 'Thêm tiêu đề',
|
||||
|
||||
@@ -668,7 +668,6 @@ const zhHans = {
|
||||
notFound: '插件信息未找到',
|
||||
sortBy: '排序方式',
|
||||
sort: {
|
||||
hottest: '热度最高',
|
||||
recentlyAdded: '最近新增',
|
||||
recentlyUpdated: '最近更新',
|
||||
mostDownloads: '最多下载',
|
||||
@@ -676,9 +675,6 @@ const zhHans = {
|
||||
},
|
||||
downloads: '次下载',
|
||||
download: '下载',
|
||||
like: '点赞',
|
||||
unlike: '取消点赞',
|
||||
likeFailed: '点赞失败,请稍后重试',
|
||||
repository: '代码仓库',
|
||||
downloadFailed: '下载失败',
|
||||
noReadme: '该插件没有提供 README 文档',
|
||||
@@ -767,9 +763,6 @@ const zhHans = {
|
||||
url: 'URL地址',
|
||||
headers: '请求头',
|
||||
timeout: '超时时间',
|
||||
toolCallTimeout: '工具调用超时(秒)',
|
||||
toolCallTimeoutDescription:
|
||||
'单次工具调用的最长等待时间。设为 0 表示不限制,默认 300 秒。',
|
||||
addArgument: '添加参数',
|
||||
addEnvVar: '添加环境变量',
|
||||
addHeader: '添加请求头',
|
||||
|
||||
@@ -668,7 +668,6 @@ const zhHant = {
|
||||
notFound: '插件資訊未找到',
|
||||
sortBy: '排序方式',
|
||||
sort: {
|
||||
hottest: '熱度最高',
|
||||
recentlyAdded: '最近新增',
|
||||
recentlyUpdated: '最近更新',
|
||||
mostDownloads: '最多下載',
|
||||
@@ -676,9 +675,6 @@ const zhHant = {
|
||||
},
|
||||
downloads: '次下載',
|
||||
download: '下載',
|
||||
like: '按讚',
|
||||
unlike: '取消按讚',
|
||||
likeFailed: '按讚失敗,請稍後重試',
|
||||
repository: '代碼倉庫',
|
||||
downloadFailed: '下載失敗',
|
||||
noReadme: '該插件沒有提供 README 文件',
|
||||
@@ -766,9 +762,6 @@ const zhHant = {
|
||||
url: 'URL位址',
|
||||
headers: '請求標頭',
|
||||
timeout: '逾時時間',
|
||||
toolCallTimeout: '工具呼叫逾時(秒)',
|
||||
toolCallTimeoutDescription:
|
||||
'單次工具呼叫的最長等待時間。設為 0 表示不限制,預設 300 秒。',
|
||||
addArgument: '新增參數',
|
||||
addEnvVar: '新增環境變數',
|
||||
addHeader: '新增請求標頭',
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import ts from 'typescript';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const currentDirectory = path.dirname(fileURLToPath(import.meta.url));
|
||||
const sourcePath = path.resolve(
|
||||
currentDirectory,
|
||||
'../../src/app/home/components/dynamic-form/DynamicFormSaveValues.ts',
|
||||
);
|
||||
|
||||
function loadNormalizer() {
|
||||
const source = fs.readFileSync(sourcePath, 'utf8');
|
||||
const compiled = ts.transpileModule(source, {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS },
|
||||
}).outputText;
|
||||
const loadedModule = { exports: {} };
|
||||
new Function('require', 'module', 'exports', compiled)(
|
||||
() => {
|
||||
throw new Error('DynamicFormSaveValues must not have runtime imports');
|
||||
},
|
||||
loadedModule,
|
||||
loadedModule.exports,
|
||||
);
|
||||
return loadedModule.exports.normalizeDynamicFormValuesForSave;
|
||||
}
|
||||
|
||||
test('normalizes only single-line text fields in a dynamic form save snapshot', () => {
|
||||
const normalizeDynamicFormValuesForSave = loadNormalizer();
|
||||
const specs = [
|
||||
{ name: 'single-line', type: 'string', default: '' },
|
||||
{ name: 'multiline', type: 'text', default: '' },
|
||||
{ name: 'string-list', type: 'array[string]', default: [] },
|
||||
{ name: 'count', type: 'integer', default: 0 },
|
||||
];
|
||||
const values = {
|
||||
'single-line': '\t hello world \n',
|
||||
multiline: ' keep multiline whitespace \n',
|
||||
'string-list': [' first ', ' second '],
|
||||
count: 3,
|
||||
};
|
||||
|
||||
assert.deepEqual(normalizeDynamicFormValuesForSave(specs, values), {
|
||||
'single-line': 'hello world',
|
||||
multiline: ' keep multiline whitespace \n',
|
||||
'string-list': [' first ', ' second '],
|
||||
count: 3,
|
||||
});
|
||||
});
|
||||
@@ -1,86 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const webRoot = path.resolve(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
'../..',
|
||||
);
|
||||
const read = (relativePath) =>
|
||||
fs.readFileSync(path.join(webRoot, relativePath), 'utf8');
|
||||
|
||||
test('marketplace defaults to shared hot sorting and cards support fingerprint likes', () => {
|
||||
const market = read(
|
||||
'src/app/home/plugins/components/plugin-market/PluginMarketComponent.tsx',
|
||||
);
|
||||
const card = read(
|
||||
'src/app/home/plugins/components/plugin-market/plugin-market-card/PluginMarketCardComponent.tsx',
|
||||
);
|
||||
const cardVO = read(
|
||||
'src/app/home/plugins/components/plugin-market/plugin-market-card/PluginMarketCardVO.ts',
|
||||
);
|
||||
const entity = read('src/app/infra/entities/plugin/index.ts');
|
||||
const client = read('src/app/infra/http/CloudServiceClient.ts');
|
||||
const likes = read(
|
||||
'src/app/home/plugins/components/plugin-market/marketplace-likes.ts',
|
||||
);
|
||||
|
||||
assert.match(
|
||||
market,
|
||||
/hot_score_desc/,
|
||||
'market must expose and default to hot-score sorting',
|
||||
);
|
||||
assert.match(
|
||||
market,
|
||||
/sortBy:\s*['"]hot_score['"]/,
|
||||
'hot option must use the shared API field',
|
||||
);
|
||||
assert.match(
|
||||
entity,
|
||||
/like_count\??:\s*number/,
|
||||
'marketplace extension responses must carry like counts',
|
||||
);
|
||||
assert.match(
|
||||
cardVO,
|
||||
/likeCount:\s*number/,
|
||||
'market cards must carry like counts',
|
||||
);
|
||||
assert.match(card, /\bHeart\b/, 'market cards must render a like affordance');
|
||||
assert.match(
|
||||
card,
|
||||
/toggleMarketplaceExtensionLike/,
|
||||
'market cards must support liking and unliking',
|
||||
);
|
||||
assert.match(
|
||||
client,
|
||||
/marketplace\/extensions\/likes/,
|
||||
'client must load browser likes',
|
||||
);
|
||||
assert.match(
|
||||
client,
|
||||
/setMarketplaceExtensionLike\(/,
|
||||
'client must update likes through the shared API',
|
||||
);
|
||||
assert.match(
|
||||
client,
|
||||
/data\.sort_by === ['"]hot_score['"] \? ['"]install_count['"] : data\.sort_by/,
|
||||
'older Space servers must fall back from hot score to install sorting',
|
||||
);
|
||||
assert.match(
|
||||
likes,
|
||||
/@fingerprintjs\/fingerprintjs/,
|
||||
'anonymous likes must use browser fingerprinting',
|
||||
);
|
||||
assert.match(
|
||||
likes,
|
||||
/FingerprintJS\.load\(\{/,
|
||||
'fingerprint agent must be initialized with options',
|
||||
);
|
||||
assert.match(
|
||||
likes,
|
||||
/monitoring:\s*false/,
|
||||
'fingerprinting must not send vendor monitoring requests',
|
||||
);
|
||||
});
|
||||
@@ -1,31 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import { createRequire } from 'node:module';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import ts from 'typescript';
|
||||
|
||||
const currentDirectory = path.dirname(fileURLToPath(import.meta.url));
|
||||
const sourcePath = path.resolve(
|
||||
currentDirectory,
|
||||
'../../src/app/home/plugins/components/plugin-market/PluginComponentIcons.ts',
|
||||
);
|
||||
const source = fs.readFileSync(sourcePath, 'utf8');
|
||||
const compiled = ts.transpileModule(source, {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS },
|
||||
}).outputText;
|
||||
const sourceRequire = createRequire(sourcePath);
|
||||
const loadedModule = { exports: {} };
|
||||
new Function('require', 'module', 'exports', compiled)(
|
||||
sourceRequire,
|
||||
loadedModule,
|
||||
loadedModule.exports,
|
||||
);
|
||||
|
||||
const { AppWindow } = sourceRequire('lucide-react');
|
||||
const { pluginComponentIconMap } = loadedModule.exports;
|
||||
|
||||
test('maps the Page plugin component to the AppWindow icon', () => {
|
||||
assert.equal(pluginComponentIconMap.Page, AppWindow);
|
||||
});
|
||||
Reference in New Issue
Block a user