mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-09-08 18:47:14 +00:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 592b8902a0 | |||
| ff71c1cede |
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
<a href="https://www.producthunt.com/products/langbot/launches/langbot?embed=true&utm_source=badge-featured&utm_medium=badge&utm_campaign=badge-langbot" target="_blank" rel="noopener noreferrer"><img alt="LangBot - Easy-to-use global IM bot platform designed for the LLM era | Product Hunt" width="250" height="54" src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=979554&theme=light&t=1782822143403"></a>
|
<a href="https://www.producthunt.com/products/langbot?utm_source=badge-follow&utm_medium=badge&utm_source=badge-langbot" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/follow.svg?product_id=1077185&theme=light" alt="LangBot - Production-grade IM bot made easy. | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
|
||||||
|
|
||||||
<h3>Production-grade platform for building agentic IM bots.</h3>
|
<h3>Production-grade platform for building agentic IM bots.</h3>
|
||||||
<h4>Quickly build, debug, and ship AI bots to Slack, Discord, Telegram, WeChat, and more.</h4>
|
<h4>Quickly build, debug, and ship AI bots to Slack, Discord, Telegram, WeChat, and more.</h4>
|
||||||
@@ -136,7 +136,7 @@ docker compose --profile all up -d
|
|||||||
| [PPIO](https://ppinfra.com/user/register?invited_by=QJKFYD&utm_source=github_langbot) | GPU Platform | ✅ |
|
| [PPIO](https://ppinfra.com/user/register?invited_by=QJKFYD&utm_source=github_langbot) | GPU Platform | ✅ |
|
||||||
| [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | GPU Platform | ✅ |
|
| [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | GPU Platform | ✅ |
|
||||||
| [接口 AI](https://jiekou.ai/) | Gateway | ✅ |
|
| [接口 AI](https://jiekou.ai/) | Gateway | ✅ |
|
||||||
| [302.AI](https://share.302ai.cn/SuTG99) | Gateway | ✅ |
|
| [302.AI](https://share.302.ai/SuTG99) | Gateway | ✅ |
|
||||||
| [Qiniu](https://www.qiniu.com/ai/agent) | Gateway | ✅ |
|
| [Qiniu](https://www.qiniu.com/ai/agent) | Gateway | ✅ |
|
||||||
|
|
||||||
[→ View all integrations](https://link.langbot.app/en/docs/features)
|
[→ View all integrations](https://link.langbot.app/en/docs/features)
|
||||||
|
|||||||
+1
-1
@@ -136,7 +136,7 @@ docker compose --profile all up -d
|
|||||||
| [优云智算](https://www.compshare.cn/?ytag=GPU_YY-gh_langbot) | GPU 平台 | ✅ |
|
| [优云智算](https://www.compshare.cn/?ytag=GPU_YY-gh_langbot) | GPU 平台 | ✅ |
|
||||||
| [PPIO](https://ppinfra.com/user/register?invited_by=QJKFYD&utm_source=github_langbot) | GPU 平台 | ✅ |
|
| [PPIO](https://ppinfra.com/user/register?invited_by=QJKFYD&utm_source=github_langbot) | GPU 平台 | ✅ |
|
||||||
| [接口 AI](https://jiekou.ai/) | 聚合平台 | ✅ |
|
| [接口 AI](https://jiekou.ai/) | 聚合平台 | ✅ |
|
||||||
| [302.AI](https://share.302ai.cn/SuTG99) | 聚合平台 | ✅ |
|
| [302.AI](https://share.302.ai/SuTG99) | 聚合平台 | ✅ |
|
||||||
| [小马算力](https://www.tokenpony.cn/453z1) | 聚合平台 | ✅ |
|
| [小马算力](https://www.tokenpony.cn/453z1) | 聚合平台 | ✅ |
|
||||||
| [百宝箱Tbox](https://www.tbox.cn/open) | 智能体平台 | ✅ |
|
| [百宝箱Tbox](https://www.tbox.cn/open) | 智能体平台 | ✅ |
|
||||||
| [七牛云Qiniu](https://www.qiniu.com/ai/agent) | 聚合平台 | ✅ |
|
| [七牛云Qiniu](https://www.qiniu.com/ai/agent) | 聚合平台 | ✅ |
|
||||||
|
|||||||
+2
-2
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
<a href="https://www.producthunt.com/products/langbot/launches/langbot?embed=true&utm_source=badge-featured&utm_medium=badge&utm_campaign=badge-langbot" target="_blank" rel="noopener noreferrer"><img alt="LangBot - Easy-to-use global IM bot platform designed for the LLM era | Product Hunt" width="250" height="54" src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=979554&theme=light&t=1782822143403"></a>
|
<a href="https://www.producthunt.com/products/langbot?utm_source=badge-follow&utm_medium=badge&utm_source=badge-langbot" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/follow.svg?product_id=1077185&theme=light" alt="LangBot - Production-grade IM bot made easy. | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
|
||||||
|
|
||||||
<h3>Plataforma de grado de producción para construir bots de mensajería instantánea con agentes de IA.</h3>
|
<h3>Plataforma de grado de producción para construir bots de mensajería instantánea con agentes de IA.</h3>
|
||||||
<h4>Construya, depure y despliegue bots de IA rápidamente en Slack, Discord, Telegram, WeChat y más.</h4>
|
<h4>Construya, depure y despliegue bots de IA rápidamente en Slack, Discord, Telegram, WeChat y más.</h4>
|
||||||
@@ -135,7 +135,7 @@ docker compose --profile all up -d
|
|||||||
| [PPIO](https://ppinfra.com/user/register?invited_by=QJKFYD&utm_source=github_langbot) | Plataforma GPU | ✅ |
|
| [PPIO](https://ppinfra.com/user/register?invited_by=QJKFYD&utm_source=github_langbot) | Plataforma GPU | ✅ |
|
||||||
| [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | Plataforma GPU | ✅ |
|
| [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | Plataforma GPU | ✅ |
|
||||||
| [接口 AI](https://jiekou.ai/) | Pasarela | ✅ |
|
| [接口 AI](https://jiekou.ai/) | Pasarela | ✅ |
|
||||||
| [302.AI](https://share.302ai.cn/SuTG99) | Pasarela | ✅ |
|
| [302.AI](https://share.302.ai/SuTG99) | Pasarela | ✅ |
|
||||||
| [Qiniu](https://www.qiniu.com/ai/agent) | Pasarela | ✅ |
|
| [Qiniu](https://www.qiniu.com/ai/agent) | Pasarela | ✅ |
|
||||||
|
|
||||||
[→ Ver todas las integraciones](https://link.langbot.app/en/docs/features)
|
[→ Ver todas las integraciones](https://link.langbot.app/en/docs/features)
|
||||||
|
|||||||
+2
-2
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
<a href="https://www.producthunt.com/products/langbot/launches/langbot?embed=true&utm_source=badge-featured&utm_medium=badge&utm_campaign=badge-langbot" target="_blank" rel="noopener noreferrer"><img alt="LangBot - Easy-to-use global IM bot platform designed for the LLM era | Product Hunt" width="250" height="54" src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=979554&theme=light&t=1782822143403"></a>
|
<a href="https://www.producthunt.com/products/langbot?utm_source=badge-follow&utm_medium=badge&utm_source=badge-langbot" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/follow.svg?product_id=1077185&theme=light" alt="LangBot - Production-grade IM bot made easy. | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
|
||||||
|
|
||||||
<h3>Plateforme de niveau production pour construire des bots de messagerie instantanée avec agents IA.</h3>
|
<h3>Plateforme de niveau production pour construire des bots de messagerie instantanée avec agents IA.</h3>
|
||||||
<h4>Créez, déboguez et déployez rapidement des bots IA sur Slack, Discord, Telegram, WeChat et plus.</h4>
|
<h4>Créez, déboguez et déployez rapidement des bots IA sur Slack, Discord, Telegram, WeChat et plus.</h4>
|
||||||
@@ -132,7 +132,7 @@ docker compose --profile all up -d
|
|||||||
| [ModelScope](https://modelscope.cn/docs/model-service/API-Inference/intro) | Passerelle | ✅ |
|
| [ModelScope](https://modelscope.cn/docs/model-service/API-Inference/intro) | Passerelle | ✅ |
|
||||||
| [GiteeAI](https://ai.gitee.com/) | Passerelle | ✅ |
|
| [GiteeAI](https://ai.gitee.com/) | Passerelle | ✅ |
|
||||||
| [接口 AI](https://jiekou.ai/) | Passerelle | ✅ |
|
| [接口 AI](https://jiekou.ai/) | Passerelle | ✅ |
|
||||||
| [302.AI](https://share.302ai.cn/SuTG99) | Passerelle | ✅ |
|
| [302.AI](https://share.302.ai/SuTG99) | Passerelle | ✅ |
|
||||||
| [CompShare](https://www.compshare.cn/?ytag=GPU_YY-gh_langbot) | Plateforme GPU | ✅ |
|
| [CompShare](https://www.compshare.cn/?ytag=GPU_YY-gh_langbot) | Plateforme GPU | ✅ |
|
||||||
| [PPIO](https://ppinfra.com/user/register?invited_by=QJKFYD&utm_source=github_langbot) | Plateforme GPU | ✅ |
|
| [PPIO](https://ppinfra.com/user/register?invited_by=QJKFYD&utm_source=github_langbot) | Plateforme GPU | ✅ |
|
||||||
| [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | Plateforme GPU | ✅ |
|
| [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | Plateforme GPU | ✅ |
|
||||||
|
|||||||
+2
-2
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
<a href="https://www.producthunt.com/products/langbot/launches/langbot?embed=true&utm_source=badge-featured&utm_medium=badge&utm_campaign=badge-langbot" target="_blank" rel="noopener noreferrer"><img alt="LangBot - Easy-to-use global IM bot platform designed for the LLM era | Product Hunt" width="250" height="54" src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=979554&theme=light&t=1782822143403"></a>
|
<a href="https://www.producthunt.com/products/langbot?utm_source=badge-follow&utm_medium=badge&utm_source=badge-langbot" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/follow.svg?product_id=1077185&theme=light" alt="LangBot - Production-grade IM bot made easy. | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
|
||||||
|
|
||||||
<h3>AIエージェント搭載IMボットを構築するための本番グレードプラットフォーム。</h3>
|
<h3>AIエージェント搭載IMボットを構築するための本番グレードプラットフォーム。</h3>
|
||||||
<h4>Slack、Discord、Telegram、WeChat などに AI ボットを素早く構築、デバッグ、デプロイ。</h4>
|
<h4>Slack、Discord、Telegram、WeChat などに AI ボットを素早く構築、デバッグ、デプロイ。</h4>
|
||||||
@@ -135,7 +135,7 @@ docker compose --profile all up -d
|
|||||||
| [PPIO](https://ppinfra.com/user/register?invited_by=QJKFYD&utm_source=github_langbot) | GPUプラットフォーム | ✅ |
|
| [PPIO](https://ppinfra.com/user/register?invited_by=QJKFYD&utm_source=github_langbot) | GPUプラットフォーム | ✅ |
|
||||||
| [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | GPUプラットフォーム | ✅ |
|
| [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | GPUプラットフォーム | ✅ |
|
||||||
| [接口 AI](https://jiekou.ai/) | ゲートウェイ | ✅ |
|
| [接口 AI](https://jiekou.ai/) | ゲートウェイ | ✅ |
|
||||||
| [302.AI](https://share.302ai.cn/SuTG99) | ゲートウェイ | ✅ |
|
| [302.AI](https://share.302.ai/SuTG99) | ゲートウェイ | ✅ |
|
||||||
| [Qiniu](https://www.qiniu.com/ai/agent) | ゲートウェイ | ✅ |
|
| [Qiniu](https://www.qiniu.com/ai/agent) | ゲートウェイ | ✅ |
|
||||||
|
|
||||||
[→ すべての統合を表示](https://link.langbot.app/en/docs/features)
|
[→ すべての統合を表示](https://link.langbot.app/en/docs/features)
|
||||||
|
|||||||
+2
-2
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
<a href="https://www.producthunt.com/products/langbot/launches/langbot?embed=true&utm_source=badge-featured&utm_medium=badge&utm_campaign=badge-langbot" target="_blank" rel="noopener noreferrer"><img alt="LangBot - Easy-to-use global IM bot platform designed for the LLM era | Product Hunt" width="250" height="54" src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=979554&theme=light&t=1782822143403"></a>
|
<a href="https://www.producthunt.com/products/langbot?utm_source=badge-follow&utm_medium=badge&utm_source=badge-langbot" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/follow.svg?product_id=1077185&theme=light" alt="LangBot - Production-grade IM bot made easy. | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
|
||||||
|
|
||||||
<h3>AI 에이전트 IM 봇 구축을 위한 프로덕션 등급 플랫폼.</h3>
|
<h3>AI 에이전트 IM 봇 구축을 위한 프로덕션 등급 플랫폼.</h3>
|
||||||
<h4>Slack, Discord, Telegram, WeChat 등에 AI 봇을 빠르게 구축, 디버그 및 배포.</h4>
|
<h4>Slack, Discord, Telegram, WeChat 등에 AI 봇을 빠르게 구축, 디버그 및 배포.</h4>
|
||||||
@@ -135,7 +135,7 @@ docker compose --profile all up -d
|
|||||||
| [PPIO](https://ppinfra.com/user/register?invited_by=QJKFYD&utm_source=github_langbot) | GPU 플랫폼 | ✅ |
|
| [PPIO](https://ppinfra.com/user/register?invited_by=QJKFYD&utm_source=github_langbot) | GPU 플랫폼 | ✅ |
|
||||||
| [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | GPU 플랫폼 | ✅ |
|
| [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | GPU 플랫폼 | ✅ |
|
||||||
| [接口 AI](https://jiekou.ai/) | 게이트웨이 | ✅ |
|
| [接口 AI](https://jiekou.ai/) | 게이트웨이 | ✅ |
|
||||||
| [302.AI](https://share.302ai.cn/SuTG99) | 게이트웨이 | ✅ |
|
| [302.AI](https://share.302.ai/SuTG99) | 게이트웨이 | ✅ |
|
||||||
| [Qiniu](https://www.qiniu.com/ai/agent) | 게이트웨이 | ✅ |
|
| [Qiniu](https://www.qiniu.com/ai/agent) | 게이트웨이 | ✅ |
|
||||||
|
|
||||||
[→ 모든 통합 보기](https://link.langbot.app/en/docs/features)
|
[→ 모든 통합 보기](https://link.langbot.app/en/docs/features)
|
||||||
|
|||||||
+2
-2
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
<a href="https://www.producthunt.com/products/langbot/launches/langbot?embed=true&utm_source=badge-featured&utm_medium=badge&utm_campaign=badge-langbot" target="_blank" rel="noopener noreferrer"><img alt="LangBot - Easy-to-use global IM bot platform designed for the LLM era | Product Hunt" width="250" height="54" src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=979554&theme=light&t=1782822143403"></a>
|
<a href="https://www.producthunt.com/products/langbot?utm_source=badge-follow&utm_medium=badge&utm_source=badge-langbot" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/follow.svg?product_id=1077185&theme=light" alt="LangBot - Production-grade IM bot made easy. | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
|
||||||
|
|
||||||
<h3>Платформа производственного уровня для создания агентных IM-ботов.</h3>
|
<h3>Платформа производственного уровня для создания агентных IM-ботов.</h3>
|
||||||
<h4>Быстро создавайте, отлаживайте и развертывайте ИИ-ботов в Slack, Discord, Telegram, WeChat и других платформах.</h4>
|
<h4>Быстро создавайте, отлаживайте и развертывайте ИИ-ботов в Slack, Discord, Telegram, WeChat и других платформах.</h4>
|
||||||
@@ -131,7 +131,7 @@ docker compose --profile all up -d
|
|||||||
| [Volc Engine Ark](https://console.volcengine.com/ark/region:ark+cn-beijing/model?vendor=Bytedance&view=LIST_VIEW) | Шлюз | ✅ |
|
| [Volc Engine Ark](https://console.volcengine.com/ark/region:ark+cn-beijing/model?vendor=Bytedance&view=LIST_VIEW) | Шлюз | ✅ |
|
||||||
| [ModelScope](https://modelscope.cn/docs/model-service/API-Inference/intro) | Шлюз | ✅ |
|
| [ModelScope](https://modelscope.cn/docs/model-service/API-Inference/intro) | Шлюз | ✅ |
|
||||||
| [GiteeAI](https://ai.gitee.com/) | Шлюз | ✅ |
|
| [GiteeAI](https://ai.gitee.com/) | Шлюз | ✅ |
|
||||||
| [302.AI](https://share.302ai.cn/SuTG99) | Шлюз | ✅ |
|
| [302.AI](https://share.302.ai/SuTG99) | Шлюз | ✅ |
|
||||||
| [接口 AI](https://jiekou.ai/) | Шлюз | ✅ |
|
| [接口 AI](https://jiekou.ai/) | Шлюз | ✅ |
|
||||||
| [CompShare](https://www.compshare.cn/?ytag=GPU_YY-gh_langbot) | Платформа GPU | ✅ |
|
| [CompShare](https://www.compshare.cn/?ytag=GPU_YY-gh_langbot) | Платформа GPU | ✅ |
|
||||||
| [PPIO](https://ppinfra.com/user/register?invited_by=QJKFYD&utm_source=github_langbot) | Платформа GPU | ✅ |
|
| [PPIO](https://ppinfra.com/user/register?invited_by=QJKFYD&utm_source=github_langbot) | Платформа GPU | ✅ |
|
||||||
|
|||||||
+1
-1
@@ -137,7 +137,7 @@ docker compose --profile all up -d
|
|||||||
| [優雲智算](https://www.compshare.cn/?ytag=GPU_YY-gh_langbot) | GPU 平台 | ✅ |
|
| [優雲智算](https://www.compshare.cn/?ytag=GPU_YY-gh_langbot) | GPU 平台 | ✅ |
|
||||||
| [PPIO](https://ppinfra.com/user/register?invited_by=QJKFYD&utm_source=github_langbot) | GPU 平台 | ✅ |
|
| [PPIO](https://ppinfra.com/user/register?invited_by=QJKFYD&utm_source=github_langbot) | GPU 平台 | ✅ |
|
||||||
| [接口 AI](https://jiekou.ai/) | 聚合平台 | ✅ |
|
| [接口 AI](https://jiekou.ai/) | 聚合平台 | ✅ |
|
||||||
| [302.AI](https://share.302ai.cn/SuTG99) | 聚合平台 | ✅ |
|
| [302.AI](https://share.302.ai/SuTG99) | 聚合平台 | ✅ |
|
||||||
| [Qiniu](https://www.qiniu.com/ai/agent) | 聚合平台 | ✅ |
|
| [Qiniu](https://www.qiniu.com/ai/agent) | 聚合平台 | ✅ |
|
||||||
|
|
||||||
### TTS(語音合成)
|
### TTS(語音合成)
|
||||||
|
|||||||
+2
-2
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
<a href="https://www.producthunt.com/products/langbot/launches/langbot?embed=true&utm_source=badge-featured&utm_medium=badge&utm_campaign=badge-langbot" target="_blank" rel="noopener noreferrer"><img alt="LangBot - Easy-to-use global IM bot platform designed for the LLM era | Product Hunt" width="250" height="54" src="https://api.producthunt.com/widgets/embed-image/v1/featured.svg?post_id=979554&theme=light&t=1782822143403"></a>
|
<a href="https://www.producthunt.com/products/langbot?utm_source=badge-follow&utm_medium=badge&utm_source=badge-langbot" target="_blank"><img src="https://api.producthunt.com/widgets/embed-image/v1/follow.svg?product_id=1077185&theme=light" alt="LangBot - Production-grade IM bot made easy. | Product Hunt" style="width: 250px; height: 54px;" width="250" height="54" /></a>
|
||||||
|
|
||||||
<h3>Nền tảng cấp sản xuất để xây dựng bot IM với AI agent.</h3>
|
<h3>Nền tảng cấp sản xuất để xây dựng bot IM với AI agent.</h3>
|
||||||
<h4>Xây dựng, gỡ lỗi và triển khai bot AI nhanh chóng trên Slack, Discord, Telegram, WeChat và nhiều nền tảng khác.</h4>
|
<h4>Xây dựng, gỡ lỗi và triển khai bot AI nhanh chóng trên Slack, Discord, Telegram, WeChat và nhiều nền tảng khác.</h4>
|
||||||
@@ -135,7 +135,7 @@ docker compose --profile all up -d
|
|||||||
| [PPIO](https://ppinfra.com/user/register?invited_by=QJKFYD&utm_source=github_langbot) | Nền tảng GPU | ✅ |
|
| [PPIO](https://ppinfra.com/user/register?invited_by=QJKFYD&utm_source=github_langbot) | Nền tảng GPU | ✅ |
|
||||||
| [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | Nền tảng GPU | ✅ |
|
| [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | Nền tảng GPU | ✅ |
|
||||||
| [接口 AI](https://jiekou.ai/) | Cổng | ✅ |
|
| [接口 AI](https://jiekou.ai/) | Cổng | ✅ |
|
||||||
| [302.AI](https://share.302ai.cn/SuTG99) | Cổng | ✅ |
|
| [302.AI](https://share.302.ai/SuTG99) | Cổng | ✅ |
|
||||||
| [Qiniu](https://www.qiniu.com/ai/agent) | Cổng | ✅ |
|
| [Qiniu](https://www.qiniu.com/ai/agent) | Cổng | ✅ |
|
||||||
|
|
||||||
[→ Xem tất cả tích hợp](https://link.langbot.app/en/docs/features)
|
[→ Xem tất cả tích hợp](https://link.langbot.app/en/docs/features)
|
||||||
|
|||||||
@@ -1,196 +0,0 @@
|
|||||||
# MCP Resources PR #2215 Review
|
|
||||||
|
|
||||||
> 更新日期: 2026-06-29
|
|
||||||
> 分支: `mcp_resources`
|
|
||||||
> PR: langbot-app/LangBot#2215
|
|
||||||
> 主题: MCP Resources 在 LangBot 中的产品价值、AgentRunner 集成方式与后续架构方向
|
|
||||||
|
|
||||||
## 结论
|
|
||||||
|
|
||||||
PR #2215 对 LangBot 有明确价值:它补齐了 MCP 协议中 Resources 这一重要能力,让 MCP server 不再只暴露 tools,也可以暴露文档、代码片段、配置、日志、图片等上下文资源。管理端可以发现和预览资源,Agent 也可以通过当前实现按需列出和读取资源。
|
|
||||||
|
|
||||||
但当前 AgentRunner 层的接入方式更接近一个可用的第一阶段方案,而不是最终架构。现在 MCP Resources 被包装成两个 synthetic tools:
|
|
||||||
|
|
||||||
- `langbot_mcp_list_resources`
|
|
||||||
- `langbot_mcp_read_resource`
|
|
||||||
|
|
||||||
这让模型可以通过 function calling 主动探索资源,落地成本低,也复用了已有 `ToolManager` / `LocalAgentRunner` 的工具调用链路。不过从 MCP 规范和主流实现来看,Resources 更适合作为一种一等上下文来源,而不是长期隐藏在工具列表里。
|
|
||||||
|
|
||||||
建议保留当前 synthetic tools 作为探索能力,同时把后续主线设计调整为:MCP Resources 是 pipeline / conversation / message 级别可选择、可固定、可审计的上下文输入。
|
|
||||||
|
|
||||||
## 当前实现判断
|
|
||||||
|
|
||||||
当前 AgentRunner 集成路径如下:
|
|
||||||
|
|
||||||
```text
|
|
||||||
Pipeline 绑定 MCP server
|
|
||||||
-> query.variables['_pipeline_bound_mcp_servers']
|
|
||||||
-> Preproc 为 local-agent 加载工具
|
|
||||||
-> ToolManager.get_all_tools()
|
|
||||||
-> MCPLoader 注入 synthetic resource tools
|
|
||||||
-> LocalAgentRunner 将工具 schema 传给模型
|
|
||||||
-> 模型发起 list/read tool call
|
|
||||||
-> ToolManager.execute_func_call()
|
|
||||||
-> MCPLoader 调 MCP session.list_resources/read_resource
|
|
||||||
-> tool result 回灌给模型
|
|
||||||
```
|
|
||||||
|
|
||||||
这个路径的优点是:
|
|
||||||
|
|
||||||
- 复用现有工具调用机制,改动范围小。
|
|
||||||
- Agent 可以按需探索资源,不需要每轮预先读取所有资源。
|
|
||||||
- 可以沿用 pipeline 绑定的 MCP server 范围,避免越权读取未绑定 server。
|
|
||||||
- 对已有 MCP tools 行为影响较小。
|
|
||||||
|
|
||||||
主要问题是:
|
|
||||||
|
|
||||||
- Resources 在语义上被降级成 tools,和 MCP 规范里的 resource primitive 不完全一致。
|
|
||||||
- 模型必须先理解并主动调用 `list/read`,资源不会自然成为上下文。
|
|
||||||
- pipeline 不能配置“默认携带某些资源”或“本轮附加某些资源”。
|
|
||||||
- UI 资源 tab 目前是管理端预览能力,和 Agent 上下文选择没有打通。
|
|
||||||
- 对 blob、图片、大文件、结构化资源的处理还比较粗糙。
|
|
||||||
- 缺少 resource templates、订阅更新、缓存、chunk、token budget、trace 与审计策略。
|
|
||||||
|
|
||||||
## 主流项目做法
|
|
||||||
|
|
||||||
### MCP 官方规范
|
|
||||||
|
|
||||||
MCP Resources 是 server 暴露上下文数据的协议能力。规范没有要求 resources 必须以 tool call 形式给模型使用,而是把如何选择、过滤、读取和纳入上下文交给 Host application。
|
|
||||||
|
|
||||||
这意味着比较正统的集成方式是:LangBot 作为 Host,在 pipeline、会话或消息层决定哪些 resources 进入模型上下文。
|
|
||||||
|
|
||||||
参考: https://modelcontextprotocol.io/specification/2025-06-18/server/resources
|
|
||||||
|
|
||||||
### VS Code Copilot
|
|
||||||
|
|
||||||
VS Code 把 MCP Resources 做成 chat context 的一部分。用户可以通过 `Add Context > MCP Resources` 或命令浏览 MCP resources,并把选中的资源附加到一次 chat request。
|
|
||||||
|
|
||||||
这是目前最值得 LangBot 参考的产品形态:资源不是模型工具,而是用户和 Host 可控的上下文附件。
|
|
||||||
|
|
||||||
参考: https://code.visualstudio.com/docs/agent-customization/mcp-servers
|
|
||||||
|
|
||||||
### Anthropic SDK
|
|
||||||
|
|
||||||
Anthropic 的 client-side MCP helpers 提供资源读取和转换能力,例如把 MCP resource 转为 Claude message content 或 file。也就是说,应用先读取 resource,再显式放进模型消息。
|
|
||||||
|
|
||||||
这同样是 application-owned context injection,而不是把 resource 伪装成模型工具。
|
|
||||||
|
|
||||||
参考: https://platform.claude.com/docs/en/agents-and-tools/mcp-connector
|
|
||||||
|
|
||||||
### LangChain MCP Adapters
|
|
||||||
|
|
||||||
LangChain 把 MCP Resources 更像 data loader / document input 来处理,可以把资源加载成 `Blob`,再进入 LangChain 的文档、检索或上下文处理链路。
|
|
||||||
|
|
||||||
这说明 Resources 很适合作为知识源、文档源或上下文源,而不只是即时工具调用。
|
|
||||||
|
|
||||||
参考: https://docs.langchain.com/oss/python/langchain/mcp
|
|
||||||
|
|
||||||
### OpenAI Agents SDK
|
|
||||||
|
|
||||||
OpenAI Agents SDK 主路径仍偏向 MCP tools,但底层 MCP server API 已经有 `list_resources`、`list_resource_templates`、`read_resource` 等能力。当前形态说明 resources 是 client 能力,但并未默认变成 agent-visible tools。
|
|
||||||
|
|
||||||
参考: https://openai.github.io/openai-agents-python/mcp/
|
|
||||||
|
|
||||||
### Cline
|
|
||||||
|
|
||||||
Cline 会拉取 MCP tools、resources、resourceTemplates、prompts,并通过类似 `access_mcp_resource` 的内置访问方式让模型读取资源。这个方向和 LangBot 当前 synthetic tools 比较接近。
|
|
||||||
|
|
||||||
这种模式适合让 Agent 自主探索,但更像 Host 自定义的模型访问协议,不应成为唯一集成路径。
|
|
||||||
|
|
||||||
参考: https://github.com/cline/cline/blob/main/src/services/mcp/McpHub.ts
|
|
||||||
|
|
||||||
## 建议架构方向
|
|
||||||
|
|
||||||
### 1. 保留探索型工具
|
|
||||||
|
|
||||||
保留当前两个 synthetic tools:
|
|
||||||
|
|
||||||
- `langbot_mcp_list_resources`
|
|
||||||
- `langbot_mcp_read_resource`
|
|
||||||
|
|
||||||
它们适合处理“用户没有显式选择资源,但 Agent 判断需要探索 MCP server 上下文”的场景。后续可以优化工具描述、返回格式、资源大小限制和错误信息。
|
|
||||||
|
|
||||||
### 2. 增加一等 Resource Context
|
|
||||||
|
|
||||||
新增一个 Host 层资源上下文概念,例如:
|
|
||||||
|
|
||||||
```text
|
|
||||||
PipelineResourceBinding
|
|
||||||
ConversationResourceAttachment
|
|
||||||
MessageResourceAttachment
|
|
||||||
```
|
|
||||||
|
|
||||||
Preproc 或独立的 `ResourceContextProvider` 在模型调用前读取这些资源,按 MIME 类型、大小、token budget 转为模型可消费的上下文。
|
|
||||||
|
|
||||||
### 3. 打通 UI 与 Agent 上下文
|
|
||||||
|
|
||||||
当前 MCP 详情页的 Resources tab 可以继续作为资源发现和预览入口。建议增加操作:
|
|
||||||
|
|
||||||
- 添加到本轮上下文
|
|
||||||
- 固定到当前 pipeline
|
|
||||||
- 固定到当前 bot / conversation
|
|
||||||
- 查看资源读取历史和错误
|
|
||||||
|
|
||||||
这样 UI 资源管理能力才能真正影响 Agent 行为。
|
|
||||||
|
|
||||||
### 4. 支持 resource templates
|
|
||||||
|
|
||||||
MCP resource templates 允许 server 暴露参数化资源,例如:
|
|
||||||
|
|
||||||
```text
|
|
||||||
repo://{owner}/{repo}/file/{path}
|
|
||||||
log://{service}/{date}
|
|
||||||
```
|
|
||||||
|
|
||||||
LangBot 后续应支持模板发现、参数填写、实例化和绑定。否则只能使用静态 resources,覆盖面会受限。
|
|
||||||
|
|
||||||
### 5. 增加资源处理策略
|
|
||||||
|
|
||||||
建议补齐:
|
|
||||||
|
|
||||||
- 文本资源 token budget 与截断策略。
|
|
||||||
- 大文件 chunk 与摘要策略。
|
|
||||||
- 图片/blob 的模型能力判断与 fallback。
|
|
||||||
- MIME 类型白名单与安全限制。
|
|
||||||
- 缓存与过期策略。
|
|
||||||
- `resources/listChanged` 或订阅更新。
|
|
||||||
- resource read trace,便于审计 Agent 读取了什么上下文。
|
|
||||||
|
|
||||||
## 推荐落地顺序
|
|
||||||
|
|
||||||
### Phase 1: 完成当前 PR 可用性
|
|
||||||
|
|
||||||
- 保留 synthetic tools。
|
|
||||||
- 明确文档说明当前 Agent 集成是 tool-mediated。
|
|
||||||
- 完善资源工具描述,降低模型误用概率。
|
|
||||||
- 给 read/list 增加大小限制和更清晰的 MIME 处理。
|
|
||||||
- 前端 Resources tab 与 Tools tab 分离,保持管理端清晰。
|
|
||||||
|
|
||||||
### Phase 2: 做 Host-owned context attachments
|
|
||||||
|
|
||||||
- 在 pipeline 或 conversation 层新增 resource attachment 配置。
|
|
||||||
- Preproc 读取已绑定 resources,注入模型上下文。
|
|
||||||
- UI 支持“添加到上下文 / 固定到 pipeline”。
|
|
||||||
- 记录每轮实际注入的 resource URI 和 token 消耗。
|
|
||||||
|
|
||||||
### Phase 3: 做完整 MCP Resources 能力
|
|
||||||
|
|
||||||
- 支持 resource templates。
|
|
||||||
- 支持资源订阅更新。
|
|
||||||
- 支持 chunk、summary、RAG 化接入。
|
|
||||||
- 为 DifyAgentRunner、LocalAgentRunner 等不同 runner 定义统一资源上下文接口。
|
|
||||||
|
|
||||||
## 最终建议
|
|
||||||
|
|
||||||
PR #2215 可以作为 MCP Resources 的第一阶段实现继续推进。它让 LangBot 快速拥有“资源发现、预览、按需读取”的闭环,也给 Agent 探索资源提供了可运行路径。
|
|
||||||
|
|
||||||
但在正式设计上,不建议把 “Resources == Tools” 固化为长期抽象。LangBot 更应该把 MCP Resources 定位为上下文来源,与 tools、prompts、knowledge base 并列:
|
|
||||||
|
|
||||||
```text
|
|
||||||
Tools -> Agent 可以执行的动作
|
|
||||||
Resources -> Host/用户/Agent 可以选择的上下文数据
|
|
||||||
Prompts -> 可复用的任务模板
|
|
||||||
Knowledge -> 可检索、可索引的长期知识
|
|
||||||
```
|
|
||||||
|
|
||||||
这样既尊重 MCP 协议语义,也能让 LangBot 在 Agent 工作流、企业知识接入和多 MCP server 管理上走得更稳。
|
|
||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "langbot"
|
name = "langbot"
|
||||||
version = "4.10.5"
|
version = "4.10.4"
|
||||||
description = "Production-grade platform for building agentic IM bots"
|
description = "Production-grade platform for building agentic IM bots"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
license-files = ["LICENSE"]
|
license-files = ["LICENSE"]
|
||||||
@@ -70,7 +70,7 @@ dependencies = [
|
|||||||
"chromadb>=1.0.0,<2.0.0",
|
"chromadb>=1.0.0,<2.0.0",
|
||||||
"qdrant-client (>=1.15.1,<2.0.0)",
|
"qdrant-client (>=1.15.1,<2.0.0)",
|
||||||
"pyseekdb==1.1.0.post3",
|
"pyseekdb==1.1.0.post3",
|
||||||
"langbot-plugin==0.4.10",
|
"langbot-plugin==0.4.6",
|
||||||
"asyncpg>=0.30.0",
|
"asyncpg>=0.30.0",
|
||||||
"line-bot-sdk>=3.19.0",
|
"line-bot-sdk>=3.19.0",
|
||||||
"matrix-nio>=0.25.2",
|
"matrix-nio>=0.25.2",
|
||||||
|
|||||||
Executable → Regular
Executable → Regular
Executable → Regular
Executable → Regular
Executable → Regular
Executable → Regular
Executable → Regular
Executable → Regular
Executable → Regular
Executable → Regular
Executable → Regular
Executable → Regular
Executable → Regular
Executable → Regular
Executable → Regular
Executable → Regular
Executable → Regular
Executable → Regular
Executable → Regular
Executable → Regular
Executable → Regular
Executable → Regular
Executable → Regular
Executable → Regular
Executable → Regular
Executable → Regular
Executable → Regular
Executable → Regular
Executable → Regular
Executable → Regular
Executable → Regular
Executable → Regular
Executable → Regular
@@ -138,39 +138,6 @@ class MonitoringRouterGroup(group.RouterGroup):
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@self.route('/tool-calls', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
|
|
||||||
async def get_tool_calls() -> str:
|
|
||||||
"""Get tool call records"""
|
|
||||||
bot_ids = quart.request.args.getlist('botId')
|
|
||||||
pipeline_ids = quart.request.args.getlist('pipelineId')
|
|
||||||
session_ids = quart.request.args.getlist('sessionId')
|
|
||||||
start_time_str = quart.request.args.get('startTime')
|
|
||||||
end_time_str = quart.request.args.get('endTime')
|
|
||||||
limit = int(quart.request.args.get('limit', 100))
|
|
||||||
offset = int(quart.request.args.get('offset', 0))
|
|
||||||
|
|
||||||
start_time = parse_iso_datetime(start_time_str)
|
|
||||||
end_time = parse_iso_datetime(end_time_str)
|
|
||||||
|
|
||||||
tool_calls, total = await self.ap.monitoring_service.get_tool_calls(
|
|
||||||
bot_ids=bot_ids if bot_ids else None,
|
|
||||||
pipeline_ids=pipeline_ids if pipeline_ids else None,
|
|
||||||
session_ids=session_ids if session_ids else None,
|
|
||||||
start_time=start_time,
|
|
||||||
end_time=end_time,
|
|
||||||
limit=limit,
|
|
||||||
offset=offset,
|
|
||||||
)
|
|
||||||
|
|
||||||
return self.success(
|
|
||||||
data={
|
|
||||||
'tool_calls': tool_calls,
|
|
||||||
'total': total,
|
|
||||||
'limit': limit,
|
|
||||||
'offset': offset,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
@self.route('/embedding-calls', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
|
@self.route('/embedding-calls', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
|
||||||
async def get_embedding_calls() -> str:
|
async def get_embedding_calls() -> str:
|
||||||
"""Get embedding call records"""
|
"""Get embedding call records"""
|
||||||
@@ -317,16 +284,6 @@ class MonitoringRouterGroup(group.RouterGroup):
|
|||||||
offset=0,
|
offset=0,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Get tool calls
|
|
||||||
tool_calls, tool_calls_total = await self.ap.monitoring_service.get_tool_calls(
|
|
||||||
bot_ids=bot_ids if bot_ids else None,
|
|
||||||
pipeline_ids=pipeline_ids if pipeline_ids else None,
|
|
||||||
start_time=start_time,
|
|
||||||
end_time=end_time,
|
|
||||||
limit=limit,
|
|
||||||
offset=0,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Get sessions
|
# Get sessions
|
||||||
sessions, sessions_total = await self.ap.monitoring_service.get_sessions(
|
sessions, sessions_total = await self.ap.monitoring_service.get_sessions(
|
||||||
bot_ids=bot_ids if bot_ids else None,
|
bot_ids=bot_ids if bot_ids else None,
|
||||||
@@ -361,14 +318,12 @@ class MonitoringRouterGroup(group.RouterGroup):
|
|||||||
'overview': overview,
|
'overview': overview,
|
||||||
'messages': messages,
|
'messages': messages,
|
||||||
'llmCalls': llm_calls,
|
'llmCalls': llm_calls,
|
||||||
'toolCalls': tool_calls,
|
|
||||||
'embeddingCalls': embedding_calls,
|
'embeddingCalls': embedding_calls,
|
||||||
'sessions': sessions,
|
'sessions': sessions,
|
||||||
'errors': errors,
|
'errors': errors,
|
||||||
'totalCount': {
|
'totalCount': {
|
||||||
'messages': messages_total,
|
'messages': messages_total,
|
||||||
'llmCalls': llm_calls_total,
|
'llmCalls': llm_calls_total,
|
||||||
'toolCalls': tool_calls_total,
|
|
||||||
'embeddingCalls': embedding_calls_total,
|
'embeddingCalls': embedding_calls_total,
|
||||||
'sessions': sessions_total,
|
'sessions': sessions_total,
|
||||||
'errors': errors_total,
|
'errors': errors_total,
|
||||||
|
|||||||
@@ -86,10 +86,6 @@ class PipelinesRouterGroup(group.RouterGroup):
|
|||||||
'available_plugins': plugins,
|
'available_plugins': plugins,
|
||||||
'bound_mcp_servers': extensions_prefs.get('mcp_servers', []),
|
'bound_mcp_servers': extensions_prefs.get('mcp_servers', []),
|
||||||
'available_mcp_servers': mcp_servers,
|
'available_mcp_servers': mcp_servers,
|
||||||
'bound_mcp_resources': extensions_prefs.get('mcp_resources', []),
|
|
||||||
'mcp_resource_agent_read_enabled': extensions_prefs.get(
|
|
||||||
'mcp_resource_agent_read_enabled', True
|
|
||||||
),
|
|
||||||
'bound_skills': extensions_prefs.get('skills', []),
|
'bound_skills': extensions_prefs.get('skills', []),
|
||||||
'available_skills': available_skills,
|
'available_skills': available_skills,
|
||||||
}
|
}
|
||||||
@@ -103,8 +99,6 @@ class PipelinesRouterGroup(group.RouterGroup):
|
|||||||
bound_plugins = json_data.get('bound_plugins', [])
|
bound_plugins = json_data.get('bound_plugins', [])
|
||||||
bound_mcp_servers = json_data.get('bound_mcp_servers', [])
|
bound_mcp_servers = json_data.get('bound_mcp_servers', [])
|
||||||
bound_skills = json_data.get('bound_skills', [])
|
bound_skills = json_data.get('bound_skills', [])
|
||||||
bound_mcp_resources = json_data.get('bound_mcp_resources')
|
|
||||||
mcp_resource_agent_read_enabled = json_data.get('mcp_resource_agent_read_enabled')
|
|
||||||
|
|
||||||
await self.ap.pipeline_service.update_pipeline_extensions(
|
await self.ap.pipeline_service.update_pipeline_extensions(
|
||||||
pipeline_uuid,
|
pipeline_uuid,
|
||||||
@@ -114,8 +108,6 @@ class PipelinesRouterGroup(group.RouterGroup):
|
|||||||
enable_all_mcp_servers,
|
enable_all_mcp_servers,
|
||||||
bound_skills=bound_skills,
|
bound_skills=bound_skills,
|
||||||
enable_all_skills=enable_all_skills,
|
enable_all_skills=enable_all_skills,
|
||||||
bound_mcp_resources=bound_mcp_resources,
|
|
||||||
mcp_resource_agent_read_enabled=mcp_resource_agent_read_enabled,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
return self.success()
|
return self.success()
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ class BotsRouterGroup(group.RouterGroup):
|
|||||||
@self.route('/<bot_uuid>', methods=['GET', 'PUT', 'DELETE'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
@self.route('/<bot_uuid>', methods=['GET', 'PUT', 'DELETE'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
||||||
async def _(bot_uuid: str) -> str:
|
async def _(bot_uuid: str) -> str:
|
||||||
if quart.request.method == 'GET':
|
if quart.request.method == 'GET':
|
||||||
|
# 返回运行时信息,包括webhook地址等
|
||||||
bot = await self.ap.bot_service.get_runtime_bot_info(bot_uuid)
|
bot = await self.ap.bot_service.get_runtime_bot_info(bot_uuid)
|
||||||
if bot is None:
|
if bot is None:
|
||||||
return self.http_status(404, -1, 'bot not found')
|
return self.http_status(404, -1, 'bot not found')
|
||||||
@@ -36,21 +37,30 @@ class BotsRouterGroup(group.RouterGroup):
|
|||||||
from_index = json_data.get('from_index', -1)
|
from_index = json_data.get('from_index', -1)
|
||||||
max_count = json_data.get('max_count', 10)
|
max_count = json_data.get('max_count', 10)
|
||||||
logs, total_count = await self.ap.bot_service.list_event_logs(bot_uuid, from_index, max_count)
|
logs, total_count = await self.ap.bot_service.list_event_logs(bot_uuid, from_index, max_count)
|
||||||
return self.success(data={'logs': logs, 'total_count': total_count})
|
return self.success(
|
||||||
|
data={
|
||||||
|
'logs': logs,
|
||||||
|
'total_count': total_count,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
@self.route('/<bot_uuid>/send_message', methods=['POST'], auth_type=group.AuthType.API_KEY)
|
@self.route('/<bot_uuid>/send_message', methods=['POST'], auth_type=group.AuthType.API_KEY)
|
||||||
async def _(bot_uuid: str) -> str:
|
async def _(bot_uuid: str) -> str:
|
||||||
|
"""Send message to a specific target via bot"""
|
||||||
json_data = await quart.request.json
|
json_data = await quart.request.json
|
||||||
target_type = json_data.get('target_type')
|
target_type = json_data.get('target_type')
|
||||||
target_id = json_data.get('target_id')
|
target_id = json_data.get('target_id')
|
||||||
message_chain_data = json_data.get('message_chain')
|
message_chain_data = json_data.get('message_chain')
|
||||||
|
|
||||||
|
# Validate required fields
|
||||||
if not target_type:
|
if not target_type:
|
||||||
return self.http_status(400, -1, 'target_type is required')
|
return self.http_status(400, -1, 'target_type is required')
|
||||||
if not target_id:
|
if not target_id:
|
||||||
return self.http_status(400, -1, 'target_id is required')
|
return self.http_status(400, -1, 'target_id is required')
|
||||||
if not message_chain_data:
|
if not message_chain_data:
|
||||||
return self.http_status(400, -1, 'message_chain is required')
|
return self.http_status(400, -1, 'message_chain is required')
|
||||||
|
|
||||||
|
# Validate target_type
|
||||||
if target_type not in ['person', 'group']:
|
if target_type not in ['person', 'group']:
|
||||||
return self.http_status(400, -1, 'target_type must be either "person" or "group"')
|
return self.http_status(400, -1, 'target_type must be either "person" or "group"')
|
||||||
|
|
||||||
@@ -62,29 +72,3 @@ class BotsRouterGroup(group.RouterGroup):
|
|||||||
|
|
||||||
traceback.print_exc()
|
traceback.print_exc()
|
||||||
return self.http_status(500, -1, f'Failed to send message: {str(e)}')
|
return self.http_status(500, -1, f'Failed to send message: {str(e)}')
|
||||||
|
|
||||||
# ============ Bot Admins ============
|
|
||||||
|
|
||||||
@self.route('/<bot_uuid>/admins', methods=['GET', 'POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
|
||||||
async def _(bot_uuid: str) -> str:
|
|
||||||
if quart.request.method == 'GET':
|
|
||||||
admins = await self.ap.bot_service.get_bot_admins(bot_uuid)
|
|
||||||
return self.success(data={'admins': admins})
|
|
||||||
elif quart.request.method == 'POST':
|
|
||||||
json_data = await quart.request.json
|
|
||||||
launcher_type = json_data.get('launcher_type', '').strip()
|
|
||||||
launcher_id = str(json_data.get('launcher_id', '')).strip()
|
|
||||||
if not launcher_type or not launcher_id:
|
|
||||||
return self.http_status(400, -1, 'launcher_type and launcher_id are required')
|
|
||||||
try:
|
|
||||||
admin_id = await self.ap.bot_service.add_bot_admin(bot_uuid, launcher_type, launcher_id)
|
|
||||||
return self.success(data={'id': admin_id})
|
|
||||||
except Exception as e:
|
|
||||||
return self.http_status(409, -1, str(e))
|
|
||||||
|
|
||||||
@self.route(
|
|
||||||
'/<bot_uuid>/admins/<int:admin_id>', methods=['DELETE'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY
|
|
||||||
)
|
|
||||||
async def _(bot_uuid: str, admin_id: int) -> str:
|
|
||||||
await self.ap.bot_service.delete_bot_admin(bot_uuid, admin_id)
|
|
||||||
return self.success()
|
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import quart
|
import quart
|
||||||
import traceback
|
import traceback
|
||||||
from urllib.parse import unquote
|
|
||||||
|
|
||||||
|
|
||||||
from ... import group
|
from ... import group
|
||||||
@@ -29,11 +28,11 @@ class MCPRouterGroup(group.RouterGroup):
|
|||||||
traceback.print_exc()
|
traceback.print_exc()
|
||||||
return self.http_status(500, -1, f'Failed to create MCP server: {str(e)}')
|
return self.http_status(500, -1, f'Failed to create MCP server: {str(e)}')
|
||||||
|
|
||||||
@self.route(
|
@self.route('/servers/<server_name>', methods=['GET', 'PUT', 'DELETE'], auth_type=group.AuthType.USER_TOKEN)
|
||||||
'/servers/<path:server_name>', methods=['GET', 'PUT', 'DELETE'], auth_type=group.AuthType.USER_TOKEN
|
|
||||||
)
|
|
||||||
async def _(server_name: str) -> str:
|
async def _(server_name: str) -> str:
|
||||||
"""获取、更新或删除MCP服务器配置"""
|
"""获取、更新或删除MCP服务器配置"""
|
||||||
|
from urllib.parse import unquote
|
||||||
|
|
||||||
server_name = unquote(server_name)
|
server_name = unquote(server_name)
|
||||||
|
|
||||||
server_data = await self.ap.mcp_service.get_mcp_server_by_name(server_name)
|
server_data = await self.ap.mcp_service.get_mcp_server_by_name(server_name)
|
||||||
@@ -58,72 +57,12 @@ class MCPRouterGroup(group.RouterGroup):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
return self.http_status(500, -1, f'Failed to delete MCP server: {str(e)}')
|
return self.http_status(500, -1, f'Failed to delete MCP server: {str(e)}')
|
||||||
|
|
||||||
@self.route('/servers/<path:server_name>/test', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
|
@self.route('/servers/<server_name>/test', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
|
||||||
async def _(server_name: str) -> str:
|
async def _(server_name: str) -> str:
|
||||||
"""测试MCP服务器连接"""
|
"""测试MCP服务器连接"""
|
||||||
|
from urllib.parse import unquote
|
||||||
|
|
||||||
server_name = unquote(server_name)
|
server_name = unquote(server_name)
|
||||||
server_data = await quart.request.json
|
server_data = await quart.request.json
|
||||||
task_id = await self.ap.mcp_service.test_mcp_server(server_name=server_name, server_data=server_data)
|
task_id = await self.ap.mcp_service.test_mcp_server(server_name=server_name, server_data=server_data)
|
||||||
return self.success(data={'task_id': task_id})
|
return self.success(data={'task_id': task_id})
|
||||||
|
|
||||||
@self.route('/servers/<path:server_name>/resources', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
|
|
||||||
async def _(server_name: str) -> str:
|
|
||||||
"""Get resources from an MCP server"""
|
|
||||||
server_name = unquote(server_name)
|
|
||||||
try:
|
|
||||||
resources = await self.ap.mcp_service.get_mcp_server_resources(server_name)
|
|
||||||
templates = await self.ap.mcp_service.get_mcp_server_resource_templates(server_name)
|
|
||||||
runtime_info = await self.ap.mcp_service.get_runtime_info(server_name)
|
|
||||||
return self.success(
|
|
||||||
data={
|
|
||||||
'resources': resources,
|
|
||||||
'resource_templates': templates,
|
|
||||||
'resource_capabilities': (runtime_info or {}).get('resource_capabilities', {}),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
return self.http_status(500, -1, f'Failed to get resources: {str(e)}')
|
|
||||||
|
|
||||||
@self.route(
|
|
||||||
'/servers/<path:server_name>/resource-templates', methods=['GET'], auth_type=group.AuthType.USER_TOKEN
|
|
||||||
)
|
|
||||||
async def _(server_name: str) -> str:
|
|
||||||
"""Get resource templates from an MCP server"""
|
|
||||||
server_name = unquote(server_name)
|
|
||||||
try:
|
|
||||||
templates = await self.ap.mcp_service.get_mcp_server_resource_templates(server_name)
|
|
||||||
return self.success(data={'resource_templates': templates})
|
|
||||||
except Exception as e:
|
|
||||||
return self.http_status(500, -1, f'Failed to get resource templates: {str(e)}')
|
|
||||||
|
|
||||||
@self.route('/servers/<path:server_name>/logs', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
|
|
||||||
async def _(server_name: str) -> str:
|
|
||||||
"""Get logs from an MCP server"""
|
|
||||||
server_name = unquote(server_name)
|
|
||||||
try:
|
|
||||||
limit = int(quart.request.args.get('limit', 200))
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
limit = 200
|
|
||||||
limit = min(limit, 500)
|
|
||||||
level = quart.request.args.get('level') or None
|
|
||||||
logs = await self.ap.mcp_service.get_mcp_server_logs(server_name, limit=limit, level=level)
|
|
||||||
return self.success(data={'logs': logs})
|
|
||||||
|
|
||||||
@self.route('/servers/<path:server_name>/resources/read', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
|
|
||||||
async def _(server_name: str) -> str:
|
|
||||||
"""Read a resource from an MCP server"""
|
|
||||||
server_name = unquote(server_name)
|
|
||||||
data = await quart.request.json
|
|
||||||
uri = data.get('uri')
|
|
||||||
if not uri:
|
|
||||||
return self.http_status(400, -1, 'URI is required')
|
|
||||||
try:
|
|
||||||
envelope = await self.ap.mcp_service.read_mcp_server_resource_envelope(
|
|
||||||
server_name,
|
|
||||||
uri,
|
|
||||||
max_bytes=data.get('max_bytes'),
|
|
||||||
include_blob=bool(data.get('include_blob', False)),
|
|
||||||
)
|
|
||||||
return self.success(data=envelope)
|
|
||||||
except Exception as e:
|
|
||||||
return self.http_status(500, -1, f'Failed to read resource: {str(e)}')
|
|
||||||
|
|||||||
@@ -1,7 +1,5 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import quart
|
|
||||||
|
|
||||||
from ... import group
|
from ... import group
|
||||||
|
|
||||||
|
|
||||||
@@ -11,41 +9,25 @@ class ToolsRouterGroup(group.RouterGroup):
|
|||||||
@self.route('', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
|
@self.route('', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
|
||||||
async def _() -> str:
|
async def _() -> str:
|
||||||
"""获取所有可用工具列表"""
|
"""获取所有可用工具列表"""
|
||||||
pipeline_uuid = quart.request.args.get('pipeline_uuid') or quart.request.args.get('pipeline_id')
|
tools = await self.ap.tool_mgr.get_all_tools()
|
||||||
bound_plugins: list[str] | None = None
|
|
||||||
bound_mcp_servers: list[str] | None = None
|
|
||||||
|
|
||||||
if pipeline_uuid:
|
tool_list = []
|
||||||
pipeline = await self.ap.pipeline_service.get_pipeline(pipeline_uuid)
|
for tool in tools:
|
||||||
if pipeline is None:
|
tool_list.append(
|
||||||
return self.http_status(404, -1, 'pipeline not found')
|
{
|
||||||
|
'name': tool.name,
|
||||||
extensions_prefs = pipeline.get('extensions_preferences', {}) or {}
|
'description': tool.description,
|
||||||
if not extensions_prefs.get('enable_all_plugins', True):
|
'human_desc': tool.human_desc,
|
||||||
bound_plugins = [
|
'parameters': tool.parameters,
|
||||||
f'{plugin.get("author", "")}/{plugin.get("name", "")}'
|
|
||||||
for plugin in extensions_prefs.get('plugins', [])
|
|
||||||
if isinstance(plugin, dict) and plugin.get('name')
|
|
||||||
]
|
|
||||||
if not extensions_prefs.get('enable_all_mcp_servers', True):
|
|
||||||
bound_mcp_servers = [
|
|
||||||
server for server in (extensions_prefs.get('mcp_servers', []) or []) if isinstance(server, str)
|
|
||||||
]
|
|
||||||
|
|
||||||
return self.success(
|
|
||||||
data={
|
|
||||||
'tools': await self.ap.tool_mgr.get_tool_catalog(
|
|
||||||
bound_plugins,
|
|
||||||
bound_mcp_servers,
|
|
||||||
include_skill_authoring=True,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
return self.success(data={'tools': tool_list})
|
||||||
|
|
||||||
@self.route('/<tool_name>', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
|
@self.route('/<tool_name>', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
|
||||||
async def _(tool_name: str) -> str:
|
async def _(tool_name: str) -> str:
|
||||||
"""获取特定工具详情"""
|
"""获取特定工具详情"""
|
||||||
tools = await self.ap.tool_mgr.get_all_tools(include_skill_authoring=True)
|
tools = await self.ap.tool_mgr.get_all_tools()
|
||||||
|
|
||||||
for tool in tools:
|
for tool in tools:
|
||||||
if tool.name == tool_name:
|
if tool.name == tool_name:
|
||||||
|
|||||||
@@ -195,13 +195,6 @@ class UserRouterGroup(group.RouterGroup):
|
|||||||
@self.route('/set-password', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
|
@self.route('/set-password', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
|
||||||
async def _(user_email: str) -> str:
|
async def _(user_email: str) -> str:
|
||||||
"""Set password for Space account (first time) or change password"""
|
"""Set password for Space account (first time) or change password"""
|
||||||
# Check if modifying login info is allowed
|
|
||||||
allow_modify_login_info = self.ap.instance_config.data.get('system', {}).get(
|
|
||||||
'allow_modify_login_info', True
|
|
||||||
)
|
|
||||||
if not allow_modify_login_info:
|
|
||||||
return self.http_status(403, -1, 'Modifying login info is disabled')
|
|
||||||
|
|
||||||
json_data = await quart.request.json
|
json_data = await quart.request.json
|
||||||
new_password = json_data.get('new_password')
|
new_password = json_data.get('new_password')
|
||||||
current_password = json_data.get('current_password')
|
current_password = json_data.get('current_password')
|
||||||
|
|||||||
@@ -199,35 +199,3 @@ class BotService:
|
|||||||
|
|
||||||
# Send message via adapter
|
# Send message via adapter
|
||||||
await runtime_bot.adapter.send_message(target_type, str(target_id), message_chain)
|
await runtime_bot.adapter.send_message(target_type, str(target_id), message_chain)
|
||||||
|
|
||||||
# ============ Bot Admins ============
|
|
||||||
|
|
||||||
async def get_bot_admins(self, bot_uuid: str) -> list[dict]:
|
|
||||||
from ....entity.persistence import bot as persistence_bot
|
|
||||||
|
|
||||||
result = await self.ap.persistence_mgr.execute_async(
|
|
||||||
sqlalchemy.select(persistence_bot.BotAdmin).where(persistence_bot.BotAdmin.bot_uuid == bot_uuid)
|
|
||||||
)
|
|
||||||
return [{'id': r.id, 'launcher_type': r.launcher_type, 'launcher_id': r.launcher_id} for r in result.all()]
|
|
||||||
|
|
||||||
async def add_bot_admin(self, bot_uuid: str, launcher_type: str, launcher_id: str) -> int:
|
|
||||||
from ....entity.persistence import bot as persistence_bot
|
|
||||||
|
|
||||||
result = await self.ap.persistence_mgr.execute_async(
|
|
||||||
sqlalchemy.insert(persistence_bot.BotAdmin).values(
|
|
||||||
bot_uuid=bot_uuid,
|
|
||||||
launcher_type=launcher_type,
|
|
||||||
launcher_id=launcher_id,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return result.inserted_primary_key[0]
|
|
||||||
|
|
||||||
async def delete_bot_admin(self, bot_uuid: str, admin_id: int) -> None:
|
|
||||||
from ....entity.persistence import bot as persistence_bot
|
|
||||||
|
|
||||||
await self.ap.persistence_mgr.execute_async(
|
|
||||||
sqlalchemy.delete(persistence_bot.BotAdmin).where(
|
|
||||||
persistence_bot.BotAdmin.bot_uuid == bot_uuid,
|
|
||||||
persistence_bot.BotAdmin.id == admin_id,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -243,7 +243,6 @@ class MaintenanceService:
|
|||||||
tables = {
|
tables = {
|
||||||
'messages': persistence_monitoring.MonitoringMessage.id,
|
'messages': persistence_monitoring.MonitoringMessage.id,
|
||||||
'llm_calls': persistence_monitoring.MonitoringLLMCall.id,
|
'llm_calls': persistence_monitoring.MonitoringLLMCall.id,
|
||||||
'tool_calls': persistence_monitoring.MonitoringToolCall.id,
|
|
||||||
'embedding_calls': persistence_monitoring.MonitoringEmbeddingCall.id,
|
'embedding_calls': persistence_monitoring.MonitoringEmbeddingCall.id,
|
||||||
'errors': persistence_monitoring.MonitoringError.id,
|
'errors': persistence_monitoring.MonitoringError.id,
|
||||||
'sessions': persistence_monitoring.MonitoringSession.session_id,
|
'sessions': persistence_monitoring.MonitoringSession.session_id,
|
||||||
|
|||||||
@@ -48,17 +48,6 @@ class MCPService:
|
|||||||
if total_extensions >= max_extensions:
|
if total_extensions >= max_extensions:
|
||||||
raise ValueError(f'Maximum number of extensions ({max_extensions}) reached')
|
raise ValueError(f'Maximum number of extensions ({max_extensions}) reached')
|
||||||
|
|
||||||
server_name = str(server_data.get('name') or '').strip()
|
|
||||||
if not server_name:
|
|
||||||
raise ValueError('MCP server name is required')
|
|
||||||
server_data['name'] = server_name
|
|
||||||
|
|
||||||
existing_result = await self.ap.persistence_mgr.execute_async(
|
|
||||||
sqlalchemy.select(persistence_mcp.MCPServer).where(persistence_mcp.MCPServer.name == server_name)
|
|
||||||
)
|
|
||||||
if existing_result.first() is not None:
|
|
||||||
raise ValueError(f'MCP server already exists: {server_name}')
|
|
||||||
|
|
||||||
server_data['uuid'] = str(uuid.uuid4())
|
server_data['uuid'] = str(uuid.uuid4())
|
||||||
await self.ap.persistence_mgr.execute_async(sqlalchemy.insert(persistence_mcp.MCPServer).values(server_data))
|
await self.ap.persistence_mgr.execute_async(sqlalchemy.insert(persistence_mcp.MCPServer).values(server_data))
|
||||||
|
|
||||||
@@ -147,32 +136,6 @@ class MCPService:
|
|||||||
if server_name in self.ap.tool_mgr.mcp_tool_loader.sessions:
|
if server_name in self.ap.tool_mgr.mcp_tool_loader.sessions:
|
||||||
await self.ap.tool_mgr.mcp_tool_loader.remove_mcp_server(server_name)
|
await self.ap.tool_mgr.mcp_tool_loader.remove_mcp_server(server_name)
|
||||||
|
|
||||||
async def get_mcp_server_resources(self, server_name: str) -> list[dict]:
|
|
||||||
"""Get resources from a specific MCP server."""
|
|
||||||
return await self.ap.tool_mgr.mcp_tool_loader.get_resources(server_name)
|
|
||||||
|
|
||||||
async def get_mcp_server_resource_templates(self, server_name: str) -> list[dict]:
|
|
||||||
"""Get resource templates from a specific MCP server."""
|
|
||||||
return await self.ap.tool_mgr.mcp_tool_loader.get_resource_templates(server_name)
|
|
||||||
|
|
||||||
async def read_mcp_server_resource_envelope(
|
|
||||||
self,
|
|
||||||
server_name: str,
|
|
||||||
uri: str,
|
|
||||||
*,
|
|
||||||
max_bytes: int | None = None,
|
|
||||||
include_blob: bool = False,
|
|
||||||
) -> dict:
|
|
||||||
"""Read a resource from a specific MCP server with metadata."""
|
|
||||||
kwargs = {'include_blob': include_blob, 'source': 'ui_preview'}
|
|
||||||
if max_bytes is not None:
|
|
||||||
kwargs['max_bytes'] = max_bytes
|
|
||||||
return await self.ap.tool_mgr.mcp_tool_loader.read_resource_envelope(server_name, uri, **kwargs)
|
|
||||||
|
|
||||||
async def read_mcp_server_resource(self, server_name: str, uri: str) -> list[dict]:
|
|
||||||
"""Read a resource from a specific MCP server."""
|
|
||||||
return await self.ap.tool_mgr.mcp_tool_loader.read_resource(server_name, uri)
|
|
||||||
|
|
||||||
async def test_mcp_server(self, server_name: str, server_data: dict) -> int:
|
async def test_mcp_server(self, server_name: str, server_data: dict) -> int:
|
||||||
"""测试 MCP 服务器连接并返回任务 ID"""
|
"""测试 MCP 服务器连接并返回任务 ID"""
|
||||||
|
|
||||||
@@ -188,22 +151,10 @@ class MCPService:
|
|||||||
persisted_session = runtime_mcp_session
|
persisted_session = runtime_mcp_session
|
||||||
|
|
||||||
async def _refresh_and_report() -> None:
|
async def _refresh_and_report() -> None:
|
||||||
# Testing a persisted server should REUSE its live shared-session
|
if persisted_session.status == MCPSessionStatus.ERROR:
|
||||||
# process, not rebuild it. Try a lightweight refresh (a real
|
|
||||||
# list_tools probe over the existing connection) first; only fall
|
|
||||||
# back to a full start() when the session has no live connection
|
|
||||||
# to probe (never connected, or the process is actually gone).
|
|
||||||
needs_start = persisted_session.status == MCPSessionStatus.ERROR or persisted_session.session is None
|
|
||||||
if needs_start:
|
|
||||||
await persisted_session.start()
|
await persisted_session.start()
|
||||||
else:
|
else:
|
||||||
try:
|
|
||||||
await persisted_session.refresh()
|
await persisted_session.refresh()
|
||||||
except Exception:
|
|
||||||
# The live connection was stale/dropped: reconnect once
|
|
||||||
# (reusing the live managed process where possible) and
|
|
||||||
# re-probe, instead of reporting a false failure.
|
|
||||||
await persisted_session.start()
|
|
||||||
# Surface the discovered tools so the config page can render them
|
# Surface the discovered tools so the config page can render them
|
||||||
# even for an already-hosted server.
|
# even for an already-hosted server.
|
||||||
ctx.metadata['runtime_info'] = persisted_session.get_runtime_info_dict()
|
ctx.metadata['runtime_info'] = persisted_session.get_runtime_info_dict()
|
||||||
@@ -244,19 +195,3 @@ class MCPService:
|
|||||||
context=ctx,
|
context=ctx,
|
||||||
)
|
)
|
||||||
return wrapper.id
|
return wrapper.id
|
||||||
|
|
||||||
async def get_mcp_server_logs(self, server_name: str, limit: int = 200, level: str | None = None) -> list[dict]:
|
|
||||||
"""Get recent log lines captured from the MCP server's stderr."""
|
|
||||||
session = self.ap.tool_mgr.mcp_tool_loader.get_session(server_name)
|
|
||||||
if not session:
|
|
||||||
return []
|
|
||||||
|
|
||||||
# Get logs from the session's buffer
|
|
||||||
logs = list(session._log_buffer)
|
|
||||||
|
|
||||||
# Filter by level if specified
|
|
||||||
if level:
|
|
||||||
logs = [log for log in logs if log.get('level') == level]
|
|
||||||
|
|
||||||
# Return the most recent 'limit' logs
|
|
||||||
return logs[-limit:]
|
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import uuid
|
import uuid
|
||||||
import datetime
|
import datetime
|
||||||
import json
|
|
||||||
import sqlalchemy
|
import sqlalchemy
|
||||||
|
|
||||||
from ....core import app
|
from ....core import app
|
||||||
@@ -51,12 +50,6 @@ class MonitoringService:
|
|||||||
persistence_monitoring.MonitoringLLMCall.timestamp,
|
persistence_monitoring.MonitoringLLMCall.timestamp,
|
||||||
persistence_monitoring.MonitoringLLMCall.id,
|
persistence_monitoring.MonitoringLLMCall.id,
|
||||||
),
|
),
|
||||||
(
|
|
||||||
'monitoring_tool_calls',
|
|
||||||
persistence_monitoring.MonitoringToolCall,
|
|
||||||
persistence_monitoring.MonitoringToolCall.timestamp,
|
|
||||||
persistence_monitoring.MonitoringToolCall.id,
|
|
||||||
),
|
|
||||||
(
|
(
|
||||||
'monitoring_embedding_calls',
|
'monitoring_embedding_calls',
|
||||||
persistence_monitoring.MonitoringEmbeddingCall,
|
persistence_monitoring.MonitoringEmbeddingCall,
|
||||||
@@ -138,68 +131,6 @@ class MonitoringService:
|
|||||||
await autocommit_conn.execute(sqlalchemy.text('PRAGMA wal_checkpoint(TRUNCATE)'))
|
await autocommit_conn.execute(sqlalchemy.text('PRAGMA wal_checkpoint(TRUNCATE)'))
|
||||||
await autocommit_conn.execute(sqlalchemy.text('VACUUM'))
|
await autocommit_conn.execute(sqlalchemy.text('VACUUM'))
|
||||||
|
|
||||||
def _serialize_tool_payload(self, payload: object, max_length: int = 20000) -> str | None:
|
|
||||||
"""Serialize tool arguments/results for monitoring storage."""
|
|
||||||
if payload is None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
if isinstance(payload, str):
|
|
||||||
text = payload
|
|
||||||
else:
|
|
||||||
try:
|
|
||||||
text = json.dumps(payload, ensure_ascii=False, default=str)
|
|
||||||
except Exception:
|
|
||||||
text = str(payload)
|
|
||||||
|
|
||||||
if len(text) <= max_length:
|
|
||||||
return text
|
|
||||||
|
|
||||||
return f'{text[:max_length]}... [truncated {len(text) - max_length} chars]'
|
|
||||||
|
|
||||||
async def _get_message_for_tool_context(
|
|
||||||
self,
|
|
||||||
message_id: str | None = None,
|
|
||||||
session_id: str | None = None,
|
|
||||||
):
|
|
||||||
if message_id:
|
|
||||||
result = await self.ap.persistence_mgr.execute_async(
|
|
||||||
sqlalchemy.select(persistence_monitoring.MonitoringMessage).where(
|
|
||||||
persistence_monitoring.MonitoringMessage.id == message_id
|
|
||||||
)
|
|
||||||
)
|
|
||||||
row = result.first()
|
|
||||||
if row:
|
|
||||||
return row[0]
|
|
||||||
|
|
||||||
if not session_id:
|
|
||||||
return None
|
|
||||||
|
|
||||||
user_query = (
|
|
||||||
sqlalchemy.select(persistence_monitoring.MonitoringMessage)
|
|
||||||
.where(
|
|
||||||
sqlalchemy.and_(
|
|
||||||
persistence_monitoring.MonitoringMessage.session_id == session_id,
|
|
||||||
persistence_monitoring.MonitoringMessage.role == 'user',
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.order_by(persistence_monitoring.MonitoringMessage.timestamp.desc())
|
|
||||||
.limit(1)
|
|
||||||
)
|
|
||||||
result = await self.ap.persistence_mgr.execute_async(user_query)
|
|
||||||
row = result.first()
|
|
||||||
if row:
|
|
||||||
return row[0]
|
|
||||||
|
|
||||||
any_query = (
|
|
||||||
sqlalchemy.select(persistence_monitoring.MonitoringMessage)
|
|
||||||
.where(persistence_monitoring.MonitoringMessage.session_id == session_id)
|
|
||||||
.order_by(persistence_monitoring.MonitoringMessage.timestamp.desc())
|
|
||||||
.limit(1)
|
|
||||||
)
|
|
||||||
result = await self.ap.persistence_mgr.execute_async(any_query)
|
|
||||||
row = result.first()
|
|
||||||
return row[0] if row else None
|
|
||||||
|
|
||||||
# ========== Recording Methods ==========
|
# ========== Recording Methods ==========
|
||||||
|
|
||||||
async def record_message(
|
async def record_message(
|
||||||
@@ -289,57 +220,6 @@ class MonitoringService:
|
|||||||
|
|
||||||
return call_id
|
return call_id
|
||||||
|
|
||||||
async def record_tool_call(
|
|
||||||
self,
|
|
||||||
tool_name: str,
|
|
||||||
tool_source: str,
|
|
||||||
duration: int,
|
|
||||||
status: str = 'success',
|
|
||||||
bot_id: str | None = None,
|
|
||||||
bot_name: str | None = None,
|
|
||||||
pipeline_id: str | None = None,
|
|
||||||
pipeline_name: str | None = None,
|
|
||||||
session_id: str | None = None,
|
|
||||||
message_id: str | None = None,
|
|
||||||
arguments: object | None = None,
|
|
||||||
result: object | None = None,
|
|
||||||
error_message: str | None = None,
|
|
||||||
) -> str:
|
|
||||||
"""Record a tool call."""
|
|
||||||
context_message = await self._get_message_for_tool_context(message_id=message_id, session_id=session_id)
|
|
||||||
if context_message:
|
|
||||||
bot_id = bot_id or context_message.bot_id
|
|
||||||
bot_name = bot_name or context_message.bot_name
|
|
||||||
pipeline_id = pipeline_id or context_message.pipeline_id
|
|
||||||
pipeline_name = pipeline_name or context_message.pipeline_name
|
|
||||||
session_id = session_id or context_message.session_id
|
|
||||||
message_id = message_id or context_message.id
|
|
||||||
|
|
||||||
call_id = str(uuid.uuid4())
|
|
||||||
call_data = {
|
|
||||||
'id': call_id,
|
|
||||||
'timestamp': datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None),
|
|
||||||
'tool_name': tool_name,
|
|
||||||
'tool_source': tool_source,
|
|
||||||
'duration': max(0, duration),
|
|
||||||
'status': status,
|
|
||||||
'bot_id': bot_id or 'unknown',
|
|
||||||
'bot_name': bot_name or 'Unknown',
|
|
||||||
'pipeline_id': pipeline_id or 'unknown',
|
|
||||||
'pipeline_name': pipeline_name or 'Unknown',
|
|
||||||
'session_id': session_id,
|
|
||||||
'message_id': message_id,
|
|
||||||
'arguments': self._serialize_tool_payload(arguments),
|
|
||||||
'result': self._serialize_tool_payload(result),
|
|
||||||
'error_message': self._serialize_tool_payload(error_message),
|
|
||||||
}
|
|
||||||
|
|
||||||
await self.ap.persistence_mgr.execute_async(
|
|
||||||
sqlalchemy.insert(persistence_monitoring.MonitoringToolCall).values(call_data)
|
|
||||||
)
|
|
||||||
|
|
||||||
return call_id
|
|
||||||
|
|
||||||
async def record_embedding_call(
|
async def record_embedding_call(
|
||||||
self,
|
self,
|
||||||
model_name: str,
|
model_name: str,
|
||||||
@@ -869,58 +749,6 @@ class MonitoringService:
|
|||||||
total,
|
total,
|
||||||
)
|
)
|
||||||
|
|
||||||
async def get_tool_calls(
|
|
||||||
self,
|
|
||||||
bot_ids: list[str] | None = None,
|
|
||||||
pipeline_ids: list[str] | None = None,
|
|
||||||
session_ids: list[str] | None = None,
|
|
||||||
start_time: datetime.datetime | None = None,
|
|
||||||
end_time: datetime.datetime | None = None,
|
|
||||||
limit: int = 100,
|
|
||||||
offset: int = 0,
|
|
||||||
) -> tuple[list[dict], int]:
|
|
||||||
"""Get tool calls with filters"""
|
|
||||||
conditions = []
|
|
||||||
|
|
||||||
if bot_ids:
|
|
||||||
conditions.append(persistence_monitoring.MonitoringToolCall.bot_id.in_(bot_ids))
|
|
||||||
if pipeline_ids:
|
|
||||||
conditions.append(persistence_monitoring.MonitoringToolCall.pipeline_id.in_(pipeline_ids))
|
|
||||||
if session_ids:
|
|
||||||
conditions.append(persistence_monitoring.MonitoringToolCall.session_id.in_(session_ids))
|
|
||||||
if start_time:
|
|
||||||
conditions.append(persistence_monitoring.MonitoringToolCall.timestamp >= start_time)
|
|
||||||
if end_time:
|
|
||||||
conditions.append(persistence_monitoring.MonitoringToolCall.timestamp <= end_time)
|
|
||||||
|
|
||||||
count_query = sqlalchemy.select(sqlalchemy.func.count(persistence_monitoring.MonitoringToolCall.id))
|
|
||||||
if conditions:
|
|
||||||
count_query = count_query.where(sqlalchemy.and_(*conditions))
|
|
||||||
|
|
||||||
count_result = await self.ap.persistence_mgr.execute_async(count_query)
|
|
||||||
total = count_result.scalar() or 0
|
|
||||||
|
|
||||||
query = sqlalchemy.select(persistence_monitoring.MonitoringToolCall).order_by(
|
|
||||||
persistence_monitoring.MonitoringToolCall.timestamp.desc()
|
|
||||||
)
|
|
||||||
if conditions:
|
|
||||||
query = query.where(sqlalchemy.and_(*conditions))
|
|
||||||
|
|
||||||
query = query.limit(limit).offset(offset)
|
|
||||||
|
|
||||||
result = await self.ap.persistence_mgr.execute_async(query)
|
|
||||||
tool_calls_rows = result.all()
|
|
||||||
|
|
||||||
return (
|
|
||||||
[
|
|
||||||
self.ap.persistence_mgr.serialize_model(
|
|
||||||
persistence_monitoring.MonitoringToolCall, row[0] if isinstance(row, tuple) else row
|
|
||||||
)
|
|
||||||
for row in tool_calls_rows
|
|
||||||
],
|
|
||||||
total,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def get_embedding_calls(
|
async def get_embedding_calls(
|
||||||
self,
|
self,
|
||||||
start_time: datetime.datetime | None = None,
|
start_time: datetime.datetime | None = None,
|
||||||
@@ -1143,34 +971,6 @@ class MonitoringService:
|
|||||||
else:
|
else:
|
||||||
error_llm_calls += 1
|
error_llm_calls += 1
|
||||||
|
|
||||||
# Get tool calls for this session
|
|
||||||
tool_query = (
|
|
||||||
sqlalchemy.select(persistence_monitoring.MonitoringToolCall)
|
|
||||||
.where(persistence_monitoring.MonitoringToolCall.session_id == session_id)
|
|
||||||
.order_by(persistence_monitoring.MonitoringToolCall.timestamp.asc())
|
|
||||||
)
|
|
||||||
tool_result = await self.ap.persistence_mgr.execute_async(tool_query)
|
|
||||||
tool_rows = tool_result.all()
|
|
||||||
|
|
||||||
tool_calls = [
|
|
||||||
self.ap.persistence_mgr.serialize_model(
|
|
||||||
persistence_monitoring.MonitoringToolCall, row[0] if isinstance(row, tuple) else row
|
|
||||||
)
|
|
||||||
for row in tool_rows
|
|
||||||
]
|
|
||||||
|
|
||||||
total_tool_calls = len(tool_rows)
|
|
||||||
success_tool_calls = 0
|
|
||||||
error_tool_calls = 0
|
|
||||||
total_tool_duration = 0
|
|
||||||
for row in tool_rows:
|
|
||||||
tool_call = row[0] if isinstance(row, tuple) else row
|
|
||||||
total_tool_duration += tool_call.duration
|
|
||||||
if tool_call.status == 'success':
|
|
||||||
success_tool_calls += 1
|
|
||||||
else:
|
|
||||||
error_tool_calls += 1
|
|
||||||
|
|
||||||
# Get errors for this session
|
# Get errors for this session
|
||||||
error_query = (
|
error_query = (
|
||||||
sqlalchemy.select(persistence_monitoring.MonitoringError)
|
sqlalchemy.select(persistence_monitoring.MonitoringError)
|
||||||
@@ -1214,14 +1014,6 @@ class MonitoringService:
|
|||||||
'total_tokens': total_tokens,
|
'total_tokens': total_tokens,
|
||||||
'average_duration_ms': int(total_duration / total_llm_calls) if total_llm_calls > 0 else 0,
|
'average_duration_ms': int(total_duration / total_llm_calls) if total_llm_calls > 0 else 0,
|
||||||
},
|
},
|
||||||
'tool_calls': tool_calls,
|
|
||||||
'tool_stats': {
|
|
||||||
'total_calls': total_tool_calls,
|
|
||||||
'success_calls': success_tool_calls,
|
|
||||||
'error_calls': error_tool_calls,
|
|
||||||
'total_duration_ms': total_tool_duration,
|
|
||||||
'average_duration_ms': int(total_tool_duration / total_tool_calls) if total_tool_calls > 0 else 0,
|
|
||||||
},
|
|
||||||
'errors': errors,
|
'errors': errors,
|
||||||
'session_duration_seconds': session_duration_seconds,
|
'session_duration_seconds': session_duration_seconds,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -100,8 +100,6 @@ class PipelineService:
|
|||||||
'enable_all_mcp_servers': True,
|
'enable_all_mcp_servers': True,
|
||||||
'plugins': [],
|
'plugins': [],
|
||||||
'mcp_servers': [],
|
'mcp_servers': [],
|
||||||
'mcp_resources': [],
|
|
||||||
'mcp_resource_agent_read_enabled': True,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await self.ap.persistence_mgr.execute_async(
|
await self.ap.persistence_mgr.execute_async(
|
||||||
@@ -195,8 +193,6 @@ class PipelineService:
|
|||||||
'enable_all_mcp_servers': True,
|
'enable_all_mcp_servers': True,
|
||||||
'plugins': [],
|
'plugins': [],
|
||||||
'mcp_servers': [],
|
'mcp_servers': [],
|
||||||
'mcp_resources': [],
|
|
||||||
'mcp_resource_agent_read_enabled': True,
|
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
@@ -221,8 +217,6 @@ class PipelineService:
|
|||||||
enable_all_mcp_servers: bool = True,
|
enable_all_mcp_servers: bool = True,
|
||||||
bound_skills: list[str] = None,
|
bound_skills: list[str] = None,
|
||||||
enable_all_skills: bool = True,
|
enable_all_skills: bool = True,
|
||||||
bound_mcp_resources: list[dict] = None,
|
|
||||||
mcp_resource_agent_read_enabled: bool | None = None,
|
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Update the bound plugins and MCP servers for a pipeline"""
|
"""Update the bound plugins and MCP servers for a pipeline"""
|
||||||
# Get current pipeline
|
# Get current pipeline
|
||||||
@@ -242,14 +236,10 @@ class PipelineService:
|
|||||||
extensions_preferences['enable_all_mcp_servers'] = enable_all_mcp_servers
|
extensions_preferences['enable_all_mcp_servers'] = enable_all_mcp_servers
|
||||||
extensions_preferences['enable_all_skills'] = enable_all_skills
|
extensions_preferences['enable_all_skills'] = enable_all_skills
|
||||||
extensions_preferences['plugins'] = bound_plugins
|
extensions_preferences['plugins'] = bound_plugins
|
||||||
if mcp_resource_agent_read_enabled is not None:
|
|
||||||
extensions_preferences['mcp_resource_agent_read_enabled'] = mcp_resource_agent_read_enabled
|
|
||||||
if bound_mcp_servers is not None:
|
if bound_mcp_servers is not None:
|
||||||
extensions_preferences['mcp_servers'] = bound_mcp_servers
|
extensions_preferences['mcp_servers'] = bound_mcp_servers
|
||||||
if bound_skills is not None:
|
if bound_skills is not None:
|
||||||
extensions_preferences['skills'] = bound_skills
|
extensions_preferences['skills'] = bound_skills
|
||||||
if bound_mcp_resources is not None:
|
|
||||||
extensions_preferences['mcp_resources'] = bound_mcp_resources
|
|
||||||
|
|
||||||
await self.ap.persistence_mgr.execute_async(
|
await self.ap.persistence_mgr.execute_async(
|
||||||
sqlalchemy.update(persistence_pipeline.LegacyPipeline)
|
sqlalchemy.update(persistence_pipeline.LegacyPipeline)
|
||||||
|
|||||||
@@ -84,17 +84,7 @@ class CommandManager:
|
|||||||
|
|
||||||
privilege = 1
|
privilege = 1
|
||||||
|
|
||||||
import sqlalchemy as _sa
|
if f'{query.launcher_type.value}_{query.launcher_id}' in self.ap.instance_config.data['admins']:
|
||||||
from ..entity.persistence.bot import BotAdmin as _BotAdmin
|
|
||||||
|
|
||||||
_admins = await self.ap.persistence_mgr.execute_async(
|
|
||||||
_sa.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:
|
|
||||||
privilege = 2
|
privilege = 2
|
||||||
|
|
||||||
ctx = command_context.ExecuteContext(
|
ctx = command_context.ExecuteContext(
|
||||||
|
|||||||
@@ -3,20 +3,6 @@ import sqlalchemy
|
|||||||
from .base import Base
|
from .base import Base
|
||||||
|
|
||||||
|
|
||||||
class BotAdmin(Base):
|
|
||||||
"""Bot admin — a launcher that has admin privilege for a specific bot's commands"""
|
|
||||||
|
|
||||||
__tablename__ = 'bot_admins'
|
|
||||||
|
|
||||||
id = sqlalchemy.Column(sqlalchemy.Integer, primary_key=True, autoincrement=True)
|
|
||||||
bot_uuid = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
|
|
||||||
launcher_type = sqlalchemy.Column(sqlalchemy.String(64), nullable=False)
|
|
||||||
launcher_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
|
|
||||||
created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now())
|
|
||||||
|
|
||||||
__table_args__ = (sqlalchemy.UniqueConstraint('bot_uuid', 'launcher_type', 'launcher_id', name='uq_bot_admin'),)
|
|
||||||
|
|
||||||
|
|
||||||
class Bot(Base):
|
class Bot(Base):
|
||||||
"""Bot"""
|
"""Bot"""
|
||||||
|
|
||||||
|
|||||||
@@ -49,28 +49,6 @@ class MonitoringLLMCall(Base):
|
|||||||
message_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True) # Associated message ID
|
message_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True) # Associated message ID
|
||||||
|
|
||||||
|
|
||||||
class MonitoringToolCall(Base):
|
|
||||||
"""Tool call records"""
|
|
||||||
|
|
||||||
__tablename__ = 'monitoring_tool_calls'
|
|
||||||
|
|
||||||
id = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True)
|
|
||||||
timestamp = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, index=True)
|
|
||||||
tool_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
|
|
||||||
tool_source = sqlalchemy.Column(sqlalchemy.String(50), nullable=False) # native, plugin, mcp, skill
|
|
||||||
duration = sqlalchemy.Column(sqlalchemy.Integer, nullable=False) # milliseconds
|
|
||||||
status = sqlalchemy.Column(sqlalchemy.String(50), nullable=False) # success, error
|
|
||||||
bot_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, index=True)
|
|
||||||
bot_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
|
|
||||||
pipeline_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False, index=True)
|
|
||||||
pipeline_name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
|
|
||||||
session_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True)
|
|
||||||
message_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=True, index=True)
|
|
||||||
arguments = sqlalchemy.Column(sqlalchemy.Text, nullable=True)
|
|
||||||
result = sqlalchemy.Column(sqlalchemy.Text, nullable=True)
|
|
||||||
error_message = sqlalchemy.Column(sqlalchemy.Text, nullable=True)
|
|
||||||
|
|
||||||
|
|
||||||
class MonitoringSession(Base):
|
class MonitoringSession(Base):
|
||||||
"""Session tracking records"""
|
"""Session tracking records"""
|
||||||
|
|
||||||
|
|||||||
@@ -26,14 +26,7 @@ class LegacyPipeline(Base):
|
|||||||
extensions_preferences = sqlalchemy.Column(
|
extensions_preferences = sqlalchemy.Column(
|
||||||
sqlalchemy.JSON,
|
sqlalchemy.JSON,
|
||||||
nullable=False,
|
nullable=False,
|
||||||
default={
|
default={'enable_all_plugins': True, 'enable_all_mcp_servers': True, 'plugins': [], 'mcp_servers': []},
|
||||||
'enable_all_plugins': True,
|
|
||||||
'enable_all_mcp_servers': True,
|
|
||||||
'plugins': [],
|
|
||||||
'mcp_servers': [],
|
|
||||||
'mcp_resources': [],
|
|
||||||
'mcp_resource_agent_read_enabled': True,
|
|
||||||
},
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,84 +0,0 @@
|
|||||||
"""add bot_admins table and migrate config admins
|
|
||||||
|
|
||||||
Revision ID: 0007_add_bot_admins
|
|
||||||
Revises: 0006_normalize_mcp_remote_mode
|
|
||||||
Create Date: 2026-06-26
|
|
||||||
"""
|
|
||||||
|
|
||||||
import sqlalchemy as sa
|
|
||||||
from alembic import op
|
|
||||||
|
|
||||||
revision = '0007_add_bot_admins'
|
|
||||||
down_revision = '0006_normalize_mcp_remote_mode'
|
|
||||||
branch_labels = None
|
|
||||||
depends_on = None
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
|
||||||
conn = op.get_bind()
|
|
||||||
if 'bot_admins' in sa.inspect(conn).get_table_names():
|
|
||||||
return
|
|
||||||
op.create_table(
|
|
||||||
'bot_admins',
|
|
||||||
sa.Column('id', sa.Integer, primary_key=True, autoincrement=True),
|
|
||||||
sa.Column('bot_uuid', sa.String(255), nullable=False),
|
|
||||||
sa.Column('launcher_type', sa.String(64), nullable=False),
|
|
||||||
sa.Column('launcher_id', sa.String(255), nullable=False),
|
|
||||||
sa.Column('created_at', sa.DateTime, nullable=False, server_default=sa.func.now()),
|
|
||||||
sa.UniqueConstraint('bot_uuid', 'launcher_type', 'launcher_id', name='uq_bot_admin'),
|
|
||||||
)
|
|
||||||
|
|
||||||
# Migrate old config-based admins into the first bot (best-effort)
|
|
||||||
inspector = sa.inspect(conn)
|
|
||||||
tables = inspector.get_table_names()
|
|
||||||
|
|
||||||
if 'bots' not in tables:
|
|
||||||
return
|
|
||||||
|
|
||||||
# Read the first bot uuid
|
|
||||||
row = conn.execute(sa.text('SELECT uuid FROM bots ORDER BY created_at LIMIT 1')).first()
|
|
||||||
if row is None:
|
|
||||||
return
|
|
||||||
first_bot_uuid = row[0]
|
|
||||||
|
|
||||||
# Read instance_config metadata key that holds the admins list
|
|
||||||
if 'metadata' not in tables:
|
|
||||||
return
|
|
||||||
meta_row = conn.execute(sa.text("SELECT value FROM metadata WHERE key = 'instance_config'")).first()
|
|
||||||
if meta_row is None:
|
|
||||||
return
|
|
||||||
|
|
||||||
import json
|
|
||||||
|
|
||||||
try:
|
|
||||||
cfg = json.loads(meta_row[0])
|
|
||||||
except Exception:
|
|
||||||
return
|
|
||||||
|
|
||||||
admins = cfg.get('admins', [])
|
|
||||||
for entry in admins:
|
|
||||||
parts = entry.split('_', 1)
|
|
||||||
if len(parts) != 2:
|
|
||||||
continue
|
|
||||||
launcher_type, launcher_id = parts
|
|
||||||
try:
|
|
||||||
conn.execute(
|
|
||||||
sa.text(
|
|
||||||
'INSERT OR IGNORE INTO bot_admins (bot_uuid, launcher_type, launcher_id) VALUES (:bu, :lt, :li)'
|
|
||||||
),
|
|
||||||
{'bu': first_bot_uuid, 'lt': launcher_type, 'li': launcher_id},
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Remove admins key from stored config
|
|
||||||
if 'admins' in cfg:
|
|
||||||
del cfg['admins']
|
|
||||||
conn.execute(
|
|
||||||
sa.text("UPDATE metadata SET value = :v WHERE key = 'instance_config'"),
|
|
||||||
{'v': json.dumps(cfg)},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
op.drop_table('bot_admins')
|
|
||||||
@@ -1,95 +0,0 @@
|
|||||||
"""add mcp resource preferences to pipelines
|
|
||||||
|
|
||||||
Revision ID: 0008_mcp_resource_prefs
|
|
||||||
Revises: 0007_add_bot_admins
|
|
||||||
Create Date: 2026-06-30
|
|
||||||
"""
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import json
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
import sqlalchemy as sa
|
|
||||||
from alembic import op
|
|
||||||
|
|
||||||
revision = '0008_mcp_resource_prefs'
|
|
||||||
down_revision = '0007_add_bot_admins'
|
|
||||||
branch_labels = None
|
|
||||||
depends_on = None
|
|
||||||
|
|
||||||
|
|
||||||
_PIPELINE_TABLE = sa.table(
|
|
||||||
'legacy_pipelines',
|
|
||||||
sa.column('uuid', sa.String(255)),
|
|
||||||
sa.column('extensions_preferences', sa.JSON()),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _has_extensions_preferences_table(conn: sa.Connection) -> bool:
|
|
||||||
inspector = sa.inspect(conn)
|
|
||||||
if 'legacy_pipelines' not in inspector.get_table_names():
|
|
||||||
return False
|
|
||||||
columns = {column['name'] for column in inspector.get_columns('legacy_pipelines')}
|
|
||||||
return 'extensions_preferences' in columns
|
|
||||||
|
|
||||||
|
|
||||||
def _decode_preferences(value: Any) -> dict[str, Any]:
|
|
||||||
if value is None:
|
|
||||||
return {}
|
|
||||||
if isinstance(value, dict):
|
|
||||||
return dict(value)
|
|
||||||
if isinstance(value, str):
|
|
||||||
try:
|
|
||||||
decoded = json.loads(value)
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
return {}
|
|
||||||
if isinstance(decoded, dict):
|
|
||||||
return decoded
|
|
||||||
return {}
|
|
||||||
|
|
||||||
|
|
||||||
def _update_preferences(conn: sa.Connection, uuid: str, preferences: dict[str, Any]) -> None:
|
|
||||||
conn.execute(
|
|
||||||
_PIPELINE_TABLE.update().where(_PIPELINE_TABLE.c.uuid == uuid).values(extensions_preferences=preferences)
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def upgrade() -> None:
|
|
||||||
conn = op.get_bind()
|
|
||||||
if not _has_extensions_preferences_table(conn):
|
|
||||||
return
|
|
||||||
|
|
||||||
rows = conn.execute(sa.select(_PIPELINE_TABLE.c.uuid, _PIPELINE_TABLE.c.extensions_preferences)).all()
|
|
||||||
for uuid, raw_preferences in rows:
|
|
||||||
preferences = _decode_preferences(raw_preferences)
|
|
||||||
changed = False
|
|
||||||
|
|
||||||
if 'mcp_resources' not in preferences:
|
|
||||||
preferences['mcp_resources'] = []
|
|
||||||
changed = True
|
|
||||||
if 'mcp_resource_agent_read_enabled' not in preferences:
|
|
||||||
preferences['mcp_resource_agent_read_enabled'] = True
|
|
||||||
changed = True
|
|
||||||
|
|
||||||
if changed:
|
|
||||||
_update_preferences(conn, uuid, preferences)
|
|
||||||
|
|
||||||
|
|
||||||
def downgrade() -> None:
|
|
||||||
conn = op.get_bind()
|
|
||||||
if not _has_extensions_preferences_table(conn):
|
|
||||||
return
|
|
||||||
|
|
||||||
rows = conn.execute(sa.select(_PIPELINE_TABLE.c.uuid, _PIPELINE_TABLE.c.extensions_preferences)).all()
|
|
||||||
for uuid, raw_preferences in rows:
|
|
||||||
preferences = _decode_preferences(raw_preferences)
|
|
||||||
changed = False
|
|
||||||
|
|
||||||
for key in ('mcp_resources', 'mcp_resource_agent_read_enabled'):
|
|
||||||
if key in preferences:
|
|
||||||
preferences.pop(key)
|
|
||||||
changed = True
|
|
||||||
|
|
||||||
if changed:
|
|
||||||
_update_preferences(conn, uuid, preferences)
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
from langbot.pkg.entity.persistence import monitoring as persistence_monitoring
|
|
||||||
from .. import migration
|
|
||||||
|
|
||||||
|
|
||||||
@migration.migration_class(26)
|
|
||||||
class DBMigrateMonitoringToolCalls(migration.DBMigration):
|
|
||||||
"""Add monitoring_tool_calls table"""
|
|
||||||
|
|
||||||
async def upgrade(self):
|
|
||||||
"""Upgrade"""
|
|
||||||
async with self.ap.persistence_mgr.get_db_engine().begin() as conn:
|
|
||||||
await conn.run_sync(persistence_monitoring.MonitoringToolCall.__table__.create, checkfirst=True)
|
|
||||||
|
|
||||||
async def downgrade(self):
|
|
||||||
"""Downgrade"""
|
|
||||||
async with self.ap.persistence_mgr.get_db_engine().begin() as conn:
|
|
||||||
await conn.run_sync(persistence_monitoring.MonitoringToolCall.__table__.drop, checkfirst=True)
|
|
||||||
@@ -32,7 +32,7 @@ class MonitoringHelper:
|
|||||||
"""Record the start of query processing, returns message_id"""
|
"""Record the start of query processing, returns message_id"""
|
||||||
try:
|
try:
|
||||||
# Check if session exists, if not, record session start
|
# Check if session exists, if not, record session start
|
||||||
session_id = f'{query.launcher_type.value if hasattr(query.launcher_type, "value") else query.launcher_type}_{query.launcher_id}'
|
session_id = f'{query.launcher_type}_{query.launcher_id}'
|
||||||
|
|
||||||
# Get sender name from message event
|
# Get sender name from message event
|
||||||
sender_name = None
|
sender_name = None
|
||||||
@@ -137,7 +137,7 @@ class MonitoringHelper:
|
|||||||
):
|
):
|
||||||
"""Record bot response message to monitoring"""
|
"""Record bot response message to monitoring"""
|
||||||
try:
|
try:
|
||||||
session_id = f'{query.launcher_type.value if hasattr(query.launcher_type, "value") else query.launcher_type}_{query.launcher_id}'
|
session_id = f'{query.launcher_type}_{query.launcher_id}'
|
||||||
|
|
||||||
# Get sender name from message event
|
# Get sender name from message event
|
||||||
sender_name = None
|
sender_name = None
|
||||||
@@ -202,7 +202,7 @@ class MonitoringHelper:
|
|||||||
) -> str:
|
) -> str:
|
||||||
"""Record query processing error, returns message_id"""
|
"""Record query processing error, returns message_id"""
|
||||||
try:
|
try:
|
||||||
session_id = f'{query.launcher_type.value if hasattr(query.launcher_type, "value") else query.launcher_type}_{query.launcher_id}'
|
session_id = f'{query.launcher_type}_{query.launcher_id}'
|
||||||
|
|
||||||
# Get sender name from message event
|
# Get sender name from message event
|
||||||
sender_name = None
|
sender_name = None
|
||||||
@@ -268,7 +268,7 @@ class MonitoringHelper:
|
|||||||
):
|
):
|
||||||
"""Record LLM call"""
|
"""Record LLM call"""
|
||||||
try:
|
try:
|
||||||
session_id = f'{query.launcher_type.value if hasattr(query.launcher_type, "value") else query.launcher_type}_{query.launcher_id}'
|
session_id = f'{query.launcher_type}_{query.launcher_id}'
|
||||||
|
|
||||||
await ap.monitoring_service.record_llm_call(
|
await ap.monitoring_service.record_llm_call(
|
||||||
bot_id=bot_id,
|
bot_id=bot_id,
|
||||||
|
|||||||
@@ -96,15 +96,6 @@ class RuntimePipeline:
|
|||||||
extensions_prefs = pipeline_entity.extensions_preferences or {}
|
extensions_prefs = pipeline_entity.extensions_preferences or {}
|
||||||
self.enable_all_plugins = extensions_prefs.get('enable_all_plugins', True)
|
self.enable_all_plugins = extensions_prefs.get('enable_all_plugins', True)
|
||||||
self.enable_all_mcp_servers = extensions_prefs.get('enable_all_mcp_servers', True)
|
self.enable_all_mcp_servers = extensions_prefs.get('enable_all_mcp_servers', True)
|
||||||
local_agent_config = (pipeline_entity.config or {}).get('ai', {}).get('local-agent', {})
|
|
||||||
self.mcp_resource_attachments = local_agent_config.get(
|
|
||||||
'mcp-resources',
|
|
||||||
extensions_prefs.get('mcp_resources', []),
|
|
||||||
)
|
|
||||||
self.mcp_resource_agent_read_enabled = local_agent_config.get(
|
|
||||||
'mcp-resource-agent-read-enabled',
|
|
||||||
extensions_prefs.get('mcp_resource_agent_read_enabled', True),
|
|
||||||
)
|
|
||||||
|
|
||||||
if self.enable_all_plugins:
|
if self.enable_all_plugins:
|
||||||
# None indicates to use all available plugins
|
# None indicates to use all available plugins
|
||||||
@@ -125,8 +116,6 @@ class RuntimePipeline:
|
|||||||
# Store bound plugins and MCP servers in query for filtering
|
# Store bound plugins and MCP servers in query for filtering
|
||||||
query.variables['_pipeline_bound_plugins'] = self.bound_plugins
|
query.variables['_pipeline_bound_plugins'] = self.bound_plugins
|
||||||
query.variables['_pipeline_bound_mcp_servers'] = self.bound_mcp_servers
|
query.variables['_pipeline_bound_mcp_servers'] = self.bound_mcp_servers
|
||||||
query.variables['_pipeline_mcp_resource_attachments'] = self.mcp_resource_attachments
|
|
||||||
query.variables['_pipeline_mcp_resource_agent_read_enabled'] = self.mcp_resource_agent_read_enabled
|
|
||||||
|
|
||||||
# Record query start for monitoring
|
# Record query start for monitoring
|
||||||
try:
|
try:
|
||||||
@@ -189,7 +178,7 @@ class RuntimePipeline:
|
|||||||
bot_name = query.variables.get('_monitoring_bot_name', 'Unknown')
|
bot_name = query.variables.get('_monitoring_bot_name', 'Unknown')
|
||||||
pipeline_name = query.variables.get('_monitoring_pipeline_name', 'Unknown')
|
pipeline_name = query.variables.get('_monitoring_pipeline_name', 'Unknown')
|
||||||
message_id = query.variables.get('_monitoring_message_id', '')
|
message_id = query.variables.get('_monitoring_message_id', '')
|
||||||
session_id = f'{query.launcher_type.value if hasattr(query.launcher_type, "value") else query.launcher_type}_{query.launcher_id}'
|
session_id = f'{query.launcher_type}_{query.launcher_id}'
|
||||||
|
|
||||||
# Update message status to error
|
# Update message status to error
|
||||||
if message_id:
|
if message_id:
|
||||||
|
|||||||
@@ -25,21 +25,6 @@ class PreProcessor(stage.PipelineStage):
|
|||||||
- use_funcs
|
- use_funcs
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _filter_selected_tools(
|
|
||||||
tools: list,
|
|
||||||
local_agent_config: dict,
|
|
||||||
) -> list:
|
|
||||||
if local_agent_config.get('enable-all-tools', True) is not False:
|
|
||||||
return tools
|
|
||||||
|
|
||||||
selected_tools = local_agent_config.get('tools', [])
|
|
||||||
if not isinstance(selected_tools, list):
|
|
||||||
return []
|
|
||||||
|
|
||||||
selected_tool_names = {tool for tool in selected_tools if isinstance(tool, str)}
|
|
||||||
return [tool for tool in tools if tool.name in selected_tool_names]
|
|
||||||
|
|
||||||
async def process(
|
async def process(
|
||||||
self,
|
self,
|
||||||
query: pipeline_query.Query,
|
query: pipeline_query.Query,
|
||||||
@@ -47,7 +32,6 @@ class PreProcessor(stage.PipelineStage):
|
|||||||
) -> entities.StageProcessResult:
|
) -> entities.StageProcessResult:
|
||||||
"""Process"""
|
"""Process"""
|
||||||
selected_runner = query.pipeline_config['ai']['runner']['runner']
|
selected_runner = query.pipeline_config['ai']['runner']['runner']
|
||||||
local_agent_config = query.pipeline_config.get('ai', {}).get('local-agent', {})
|
|
||||||
include_skill_authoring = (
|
include_skill_authoring = (
|
||||||
selected_runner == 'local-agent' and getattr(self.ap, 'skill_service', None) is not None
|
selected_runner == 'local-agent' and getattr(self.ap, 'skill_service', None) is not None
|
||||||
)
|
)
|
||||||
@@ -59,7 +43,7 @@ class PreProcessor(stage.PipelineStage):
|
|||||||
if selected_runner == 'local-agent':
|
if selected_runner == 'local-agent':
|
||||||
# Read model config — new format is { primary: str, fallbacks: [str] },
|
# Read model config — new format is { primary: str, fallbacks: [str] },
|
||||||
# but handle legacy plain string for backward compatibility
|
# but handle legacy plain string for backward compatibility
|
||||||
model_config = local_agent_config.get('model', {})
|
model_config = query.pipeline_config['ai']['local-agent'].get('model', {})
|
||||||
if isinstance(model_config, str):
|
if isinstance(model_config, str):
|
||||||
# Legacy format: plain UUID string
|
# Legacy format: plain UUID string
|
||||||
primary_uuid = model_config
|
primary_uuid = model_config
|
||||||
@@ -129,14 +113,11 @@ class PreProcessor(stage.PipelineStage):
|
|||||||
# Get bound plugins and MCP servers for filtering tools
|
# Get bound plugins and MCP servers for filtering tools
|
||||||
bound_plugins = query.variables.get('_pipeline_bound_plugins', None)
|
bound_plugins = query.variables.get('_pipeline_bound_plugins', None)
|
||||||
bound_mcp_servers = query.variables.get('_pipeline_bound_mcp_servers', None)
|
bound_mcp_servers = query.variables.get('_pipeline_bound_mcp_servers', None)
|
||||||
include_mcp_resource_tools = query.variables.get('_pipeline_mcp_resource_agent_read_enabled', True)
|
query.use_funcs = await self.ap.tool_mgr.get_all_tools(
|
||||||
all_tools = await self.ap.tool_mgr.get_all_tools(
|
|
||||||
bound_plugins,
|
bound_plugins,
|
||||||
bound_mcp_servers,
|
bound_mcp_servers,
|
||||||
include_skill_authoring=include_skill_authoring,
|
include_skill_authoring=include_skill_authoring,
|
||||||
include_mcp_resource_tools=include_mcp_resource_tools,
|
|
||||||
)
|
)
|
||||||
query.use_funcs = self._filter_selected_tools(all_tools, local_agent_config)
|
|
||||||
|
|
||||||
self.ap.logger.debug(f'Bound plugins: {bound_plugins}')
|
self.ap.logger.debug(f'Bound plugins: {bound_plugins}')
|
||||||
self.ap.logger.debug(f'Bound MCP servers: {bound_mcp_servers}')
|
self.ap.logger.debug(f'Bound MCP servers: {bound_mcp_servers}')
|
||||||
@@ -147,14 +128,11 @@ class PreProcessor(stage.PipelineStage):
|
|||||||
if not query.use_funcs and query.variables.get('_fallback_model_uuids'):
|
if not query.use_funcs and query.variables.get('_fallback_model_uuids'):
|
||||||
bound_plugins = query.variables.get('_pipeline_bound_plugins', None)
|
bound_plugins = query.variables.get('_pipeline_bound_plugins', None)
|
||||||
bound_mcp_servers = query.variables.get('_pipeline_bound_mcp_servers', None)
|
bound_mcp_servers = query.variables.get('_pipeline_bound_mcp_servers', None)
|
||||||
include_mcp_resource_tools = query.variables.get('_pipeline_mcp_resource_agent_read_enabled', True)
|
query.use_funcs = await self.ap.tool_mgr.get_all_tools(
|
||||||
all_tools = await self.ap.tool_mgr.get_all_tools(
|
|
||||||
bound_plugins,
|
bound_plugins,
|
||||||
bound_mcp_servers,
|
bound_mcp_servers,
|
||||||
include_skill_authoring=include_skill_authoring,
|
include_skill_authoring=include_skill_authoring,
|
||||||
include_mcp_resource_tools=include_mcp_resource_tools,
|
|
||||||
)
|
)
|
||||||
query.use_funcs = self._filter_selected_tools(all_tools, local_agent_config)
|
|
||||||
|
|
||||||
sender_name = ''
|
sender_name = ''
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import asyncio
|
|||||||
import traceback
|
import traceback
|
||||||
import datetime
|
import datetime
|
||||||
import json
|
import json
|
||||||
import time
|
|
||||||
|
|
||||||
import aiocqhttp
|
import aiocqhttp
|
||||||
import pydantic
|
import pydantic
|
||||||
@@ -17,37 +16,6 @@ from ...utils import image
|
|||||||
import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_platform_logger
|
import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_platform_logger
|
||||||
|
|
||||||
|
|
||||||
_GROUP_NAME_CACHE_TTL_SECONDS = 3600
|
|
||||||
_GROUP_NAME_NEGATIVE_CACHE_TTL_SECONDS = 60
|
|
||||||
_GROUP_NAME_LOOKUP_TIMEOUT_SECONDS = 2
|
|
||||||
_GROUP_MEMBER_INFO_CACHE_TTL_SECONDS = 86400
|
|
||||||
_GROUP_MEMBER_INFO_NEGATIVE_CACHE_TTL_SECONDS = 600
|
|
||||||
_GROUP_MEMBER_INFO_LOOKUP_TIMEOUT_SECONDS = 2
|
|
||||||
|
|
||||||
|
|
||||||
def _normalize_base64_payload(value: str) -> str:
|
|
||||||
if value.startswith('base64://'):
|
|
||||||
return value.removeprefix('base64://')
|
|
||||||
if value.startswith('data:') and ';base64,' in value:
|
|
||||||
return value.split(';base64,', 1)[1]
|
|
||||||
return value
|
|
||||||
|
|
||||||
|
|
||||||
def _get_field(data: dict, key: str, default: str = '') -> str:
|
|
||||||
value = data.get(key)
|
|
||||||
if value is None:
|
|
||||||
return default
|
|
||||||
return str(value)
|
|
||||||
|
|
||||||
|
|
||||||
def _get_group_member_name(sender: dict) -> str:
|
|
||||||
return _get_field(sender, 'card') or _get_field(sender, 'nickname') or _get_field(sender, 'user_id')
|
|
||||||
|
|
||||||
|
|
||||||
def _get_group_name_placeholder(group_id: typing.Union[int, str]) -> str:
|
|
||||||
return f'Group {group_id}'
|
|
||||||
|
|
||||||
|
|
||||||
class AiocqhttpMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
|
class AiocqhttpMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def yiri2target(
|
async def yiri2target(
|
||||||
@@ -67,7 +35,7 @@ class AiocqhttpMessageConverter(abstract_platform_adapter.AbstractMessageConvert
|
|||||||
elif type(msg) is platform_message.Image:
|
elif type(msg) is platform_message.Image:
|
||||||
arg = ''
|
arg = ''
|
||||||
if msg.base64:
|
if msg.base64:
|
||||||
arg = _normalize_base64_payload(msg.base64)
|
arg = msg.base64
|
||||||
msg_list.append(aiocqhttp.MessageSegment.image(f'base64://{arg}'))
|
msg_list.append(aiocqhttp.MessageSegment.image(f'base64://{arg}'))
|
||||||
elif msg.url:
|
elif msg.url:
|
||||||
arg = msg.url
|
arg = msg.url
|
||||||
@@ -82,7 +50,7 @@ class AiocqhttpMessageConverter(abstract_platform_adapter.AbstractMessageConvert
|
|||||||
elif type(msg) is platform_message.Voice:
|
elif type(msg) is platform_message.Voice:
|
||||||
arg = ''
|
arg = ''
|
||||||
if msg.base64:
|
if msg.base64:
|
||||||
arg = _normalize_base64_payload(msg.base64)
|
arg = msg.base64
|
||||||
msg_list.append(aiocqhttp.MessageSegment.record(f'base64://{arg}'))
|
msg_list.append(aiocqhttp.MessageSegment.record(f'base64://{arg}'))
|
||||||
elif msg.url:
|
elif msg.url:
|
||||||
arg = msg.url
|
arg = msg.url
|
||||||
@@ -94,10 +62,7 @@ class AiocqhttpMessageConverter(abstract_platform_adapter.AbstractMessageConvert
|
|||||||
for node in msg.node_list:
|
for node in msg.node_list:
|
||||||
msg_list.extend((await AiocqhttpMessageConverter.yiri2target(node.message_chain))[0])
|
msg_list.extend((await AiocqhttpMessageConverter.yiri2target(node.message_chain))[0])
|
||||||
elif isinstance(msg, platform_message.File):
|
elif isinstance(msg, platform_message.File):
|
||||||
file = msg.url or msg.path
|
msg_list.append({'type': 'file', 'data': {'file': msg.url, 'name': msg.name}})
|
||||||
if not file and msg.base64:
|
|
||||||
file = f'base64://{_normalize_base64_payload(msg.base64)}'
|
|
||||||
msg_list.append({'type': 'file', 'data': {'file': file, 'name': msg.name}})
|
|
||||||
elif isinstance(msg, platform_message.Face):
|
elif isinstance(msg, platform_message.Face):
|
||||||
if msg.face_type == 'face':
|
if msg.face_type == 'face':
|
||||||
msg_list.append(aiocqhttp.MessageSegment.face(msg.face_id))
|
msg_list.append(aiocqhttp.MessageSegment.face(msg.face_id))
|
||||||
@@ -359,96 +324,16 @@ class AiocqhttpMessageConverter(abstract_platform_adapter.AbstractMessageConvert
|
|||||||
|
|
||||||
|
|
||||||
class AiocqhttpEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
class AiocqhttpEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||||
def __init__(self):
|
|
||||||
self._group_name_cache: dict[typing.Union[int, str], tuple[str, float]] = {}
|
|
||||||
self._group_name_negative_cache: dict[typing.Union[int, str], float] = {}
|
|
||||||
self._group_member_info_cache: dict[
|
|
||||||
tuple[typing.Union[int, str], typing.Union[int, str]], tuple[dict, float]
|
|
||||||
] = {}
|
|
||||||
self._group_member_info_negative_cache: dict[tuple[typing.Union[int, str], typing.Union[int, str]], float] = {}
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def yiri2target(event: platform_events.MessageEvent, bot_account_id: int):
|
async def yiri2target(event: platform_events.MessageEvent, bot_account_id: int):
|
||||||
return event.source_platform_object
|
return event.source_platform_object
|
||||||
|
|
||||||
async def _get_group_name(self, group_id: typing.Union[int, str], bot=None) -> str:
|
@staticmethod
|
||||||
now = time.monotonic()
|
async def target2yiri(event: aiocqhttp.Event, bot=None):
|
||||||
if group_id in self._group_name_cache:
|
|
||||||
group_name, expires_at = self._group_name_cache[group_id]
|
|
||||||
if expires_at > now:
|
|
||||||
return group_name
|
|
||||||
del self._group_name_cache[group_id]
|
|
||||||
if group_id in self._group_name_negative_cache:
|
|
||||||
expires_at = self._group_name_negative_cache[group_id]
|
|
||||||
if expires_at > now:
|
|
||||||
return ''
|
|
||||||
del self._group_name_negative_cache[group_id]
|
|
||||||
if bot is None:
|
|
||||||
return ''
|
|
||||||
try:
|
|
||||||
group_info = await asyncio.wait_for(
|
|
||||||
bot.get_group_info(group_id=group_id),
|
|
||||||
timeout=_GROUP_NAME_LOOKUP_TIMEOUT_SECONDS,
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
self._group_name_negative_cache[group_id] = now + _GROUP_NAME_NEGATIVE_CACHE_TTL_SECONDS
|
|
||||||
return ''
|
|
||||||
group_name = _get_field(group_info, 'group_name') if isinstance(group_info, dict) else ''
|
|
||||||
if group_name:
|
|
||||||
self._group_name_cache[group_id] = (group_name, now + _GROUP_NAME_CACHE_TTL_SECONDS)
|
|
||||||
self._group_name_negative_cache.pop(group_id, None)
|
|
||||||
else:
|
|
||||||
self._group_name_negative_cache[group_id] = now + _GROUP_NAME_NEGATIVE_CACHE_TTL_SECONDS
|
|
||||||
return group_name
|
|
||||||
|
|
||||||
async def _get_group_member_info(
|
|
||||||
self,
|
|
||||||
group_id: typing.Union[int, str],
|
|
||||||
user_id: typing.Union[int, str],
|
|
||||||
bot=None,
|
|
||||||
) -> dict:
|
|
||||||
now = time.monotonic()
|
|
||||||
cache_key = (group_id, user_id)
|
|
||||||
if cache_key in self._group_member_info_cache:
|
|
||||||
member_info, expires_at = self._group_member_info_cache[cache_key]
|
|
||||||
if expires_at > now:
|
|
||||||
return member_info
|
|
||||||
del self._group_member_info_cache[cache_key]
|
|
||||||
if cache_key in self._group_member_info_negative_cache:
|
|
||||||
expires_at = self._group_member_info_negative_cache[cache_key]
|
|
||||||
if expires_at > now:
|
|
||||||
return {}
|
|
||||||
del self._group_member_info_negative_cache[cache_key]
|
|
||||||
if bot is None:
|
|
||||||
return {}
|
|
||||||
try:
|
|
||||||
member_info = await asyncio.wait_for(
|
|
||||||
bot.get_group_member_info(group_id=group_id, user_id=user_id),
|
|
||||||
timeout=_GROUP_MEMBER_INFO_LOOKUP_TIMEOUT_SECONDS,
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
self._group_member_info_negative_cache[cache_key] = now + _GROUP_MEMBER_INFO_NEGATIVE_CACHE_TTL_SECONDS
|
|
||||||
return {}
|
|
||||||
if isinstance(member_info, dict) and member_info:
|
|
||||||
self._group_member_info_cache[cache_key] = (
|
|
||||||
member_info,
|
|
||||||
now + _GROUP_MEMBER_INFO_CACHE_TTL_SECONDS,
|
|
||||||
)
|
|
||||||
self._group_member_info_negative_cache.pop(cache_key, None)
|
|
||||||
return member_info
|
|
||||||
self._group_member_info_negative_cache[cache_key] = now + _GROUP_MEMBER_INFO_NEGATIVE_CACHE_TTL_SECONDS
|
|
||||||
return {}
|
|
||||||
|
|
||||||
async def target2yiri(self, event: aiocqhttp.Event, bot=None):
|
|
||||||
yiri_chain = await AiocqhttpMessageConverter.target2yiri(event.message, event.message_id, bot)
|
yiri_chain = await AiocqhttpMessageConverter.target2yiri(event.message, event.message_id, bot)
|
||||||
|
|
||||||
if event.message_type == 'group':
|
if event.message_type == 'group':
|
||||||
permission = 'MEMBER'
|
permission = 'MEMBER'
|
||||||
group_name = await self._get_group_name(event.group_id, bot) or _get_group_name_placeholder(event.group_id)
|
|
||||||
special_title = _get_field(event.sender, 'title')
|
|
||||||
if not special_title:
|
|
||||||
member_info = await self._get_group_member_info(event.group_id, event.sender['user_id'], bot)
|
|
||||||
special_title = _get_field(member_info, 'title')
|
|
||||||
|
|
||||||
if 'role' in event.sender:
|
if 'role' in event.sender:
|
||||||
if event.sender['role'] == 'admin':
|
if event.sender['role'] == 'admin':
|
||||||
@@ -458,14 +343,14 @@ class AiocqhttpEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
|||||||
converted_event = platform_events.GroupMessage(
|
converted_event = platform_events.GroupMessage(
|
||||||
sender=platform_entities.GroupMember(
|
sender=platform_entities.GroupMember(
|
||||||
id=event.sender['user_id'], # message_seq 放哪?
|
id=event.sender['user_id'], # message_seq 放哪?
|
||||||
member_name=_get_group_member_name(event.sender),
|
member_name=event.sender['nickname'],
|
||||||
permission=permission,
|
permission=permission,
|
||||||
group=platform_entities.Group(
|
group=platform_entities.Group(
|
||||||
id=event.group_id,
|
id=event.group_id,
|
||||||
name=group_name,
|
name=event.sender['nickname'],
|
||||||
permission=platform_entities.Permission.Member,
|
permission=platform_entities.Permission.Member,
|
||||||
),
|
),
|
||||||
special_title=special_title,
|
special_title=event.sender['title'] if 'title' in event.sender else '',
|
||||||
),
|
),
|
||||||
message_chain=yiri_chain,
|
message_chain=yiri_chain,
|
||||||
time=event.time,
|
time=event.time,
|
||||||
@@ -489,7 +374,7 @@ class AiocqhttpAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
|
|||||||
bot: aiocqhttp.CQHttp = pydantic.Field(exclude=True, default_factory=aiocqhttp.CQHttp)
|
bot: aiocqhttp.CQHttp = pydantic.Field(exclude=True, default_factory=aiocqhttp.CQHttp)
|
||||||
|
|
||||||
message_converter: AiocqhttpMessageConverter = AiocqhttpMessageConverter()
|
message_converter: AiocqhttpMessageConverter = AiocqhttpMessageConverter()
|
||||||
event_converter: AiocqhttpEventConverter = pydantic.Field(default_factory=AiocqhttpEventConverter)
|
event_converter: AiocqhttpEventConverter = AiocqhttpEventConverter()
|
||||||
|
|
||||||
on_websocket_connection_event_cache: typing.List[typing.Callable[[aiocqhttp.Event], None]] = []
|
on_websocket_connection_event_cache: typing.List[typing.Callable[[aiocqhttp.Event], None]] = []
|
||||||
|
|
||||||
@@ -548,7 +433,9 @@ class AiocqhttpAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
|
|||||||
elif isinstance(component, platform_message.Image):
|
elif isinstance(component, platform_message.Image):
|
||||||
img_data = {}
|
img_data = {}
|
||||||
if component.base64:
|
if component.base64:
|
||||||
b64 = _normalize_base64_payload(component.base64)
|
b64 = component.base64
|
||||||
|
if b64.startswith('data:'):
|
||||||
|
b64 = b64.split(',', 1)[-1] if ',' in b64 else b64
|
||||||
img_data['file'] = f'base64://{b64}'
|
img_data['file'] = f'base64://{b64}'
|
||||||
elif component.url:
|
elif component.url:
|
||||||
img_data['file'] = component.url
|
img_data['file'] = component.url
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ from __future__ import annotations
|
|||||||
import typing
|
import typing
|
||||||
import asyncio
|
import asyncio
|
||||||
import traceback
|
import traceback
|
||||||
import uuid
|
|
||||||
|
|
||||||
import datetime
|
import datetime
|
||||||
import pydantic
|
import pydantic
|
||||||
@@ -183,28 +182,7 @@ class WecomCSAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|||||||
)
|
)
|
||||||
|
|
||||||
async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain):
|
async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain):
|
||||||
if target_type != 'person':
|
pass
|
||||||
raise ValueError('WeCom customer service only supports sending messages to person targets')
|
|
||||||
|
|
||||||
open_kfid = self.bot_account_id
|
|
||||||
external_userid = target_id
|
|
||||||
if '|' in target_id:
|
|
||||||
open_kfid, external_userid = target_id.split('|', 1)
|
|
||||||
if external_userid.startswith('u'):
|
|
||||||
external_userid = external_userid[1:]
|
|
||||||
if not open_kfid:
|
|
||||||
raise ValueError('WeCom customer service open_kfid is required before sending messages')
|
|
||||||
|
|
||||||
content_list = await WecomMessageConverter.yiri2target(message, self.bot)
|
|
||||||
for content in content_list:
|
|
||||||
msgid = f'langbot_{uuid.uuid4().hex}'
|
|
||||||
if content['type'] == 'text':
|
|
||||||
await self.bot.send_text_msg(
|
|
||||||
open_kfid=open_kfid,
|
|
||||||
external_userid=external_userid,
|
|
||||||
msgid=msgid,
|
|
||||||
content=content['content'],
|
|
||||||
)
|
|
||||||
|
|
||||||
def set_bot_uuid(self, bot_uuid: str):
|
def set_bot_uuid(self, bot_uuid: str):
|
||||||
"""设置 bot UUID(用于生成 webhook URL)"""
|
"""设置 bot UUID(用于生成 webhook URL)"""
|
||||||
|
|||||||
@@ -417,30 +417,6 @@ class LocalAgentRunner(runner.RequestRunner):
|
|||||||
ce.text = final_user_message_text
|
ce.text = final_user_message_text
|
||||||
break
|
break
|
||||||
|
|
||||||
mcp_loader = getattr(getattr(self.ap, 'tool_mgr', None), 'mcp_tool_loader', None)
|
|
||||||
if mcp_loader is not None:
|
|
||||||
resource_context = await mcp_loader.build_resource_context_for_query(query)
|
|
||||||
if resource_context:
|
|
||||||
resource_addition = (
|
|
||||||
'\n\nMCP resource context selected by LangBot host:\n'
|
|
||||||
f'{resource_context}\n\n'
|
|
||||||
'Use this context as read-only reference material. If it conflicts with the user message, '
|
|
||||||
'ask for clarification before taking external actions.'
|
|
||||||
)
|
|
||||||
if isinstance(user_message.content, str):
|
|
||||||
user_message.content += resource_addition
|
|
||||||
elif isinstance(user_message.content, list):
|
|
||||||
appended = False
|
|
||||||
for ce in user_message.content:
|
|
||||||
if ce.type == 'text':
|
|
||||||
ce.text = (ce.text or '') + resource_addition
|
|
||||||
appended = True
|
|
||||||
break
|
|
||||||
if not appended:
|
|
||||||
user_message.content.append(
|
|
||||||
provider_message.ContentElement.from_text(resource_addition.strip())
|
|
||||||
)
|
|
||||||
|
|
||||||
req_messages = self._build_request_messages(query, user_message)
|
req_messages = self._build_request_messages(query, user_message)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -6,7 +6,7 @@ import os
|
|||||||
import shutil
|
import shutil
|
||||||
import shlex
|
import shlex
|
||||||
import threading
|
import threading
|
||||||
from contextlib import suppress, AsyncExitStack
|
from contextlib import suppress
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
import pydantic
|
import pydantic
|
||||||
@@ -74,35 +74,6 @@ class MCPServerBoxConfig(pydantic.BaseModel):
|
|||||||
model_config = pydantic.ConfigDict(extra='ignore')
|
model_config = pydantic.ConfigDict(extra='ignore')
|
||||||
|
|
||||||
|
|
||||||
_HANDSHAKE_ATTEMPT_TIMEOUT_SEC = 10.0
|
|
||||||
|
|
||||||
|
|
||||||
class _TransferredStack:
|
|
||||||
"""Adapts an already-populated AsyncExitStack into an async context manager
|
|
||||||
so ownership of its resources can be transferred into another exit stack.
|
|
||||||
Entering is a no-op; exiting closes the wrapped stack (and thus the live WS
|
|
||||||
transport + ClientSession) when the owning session shuts down."""
|
|
||||||
|
|
||||||
def __init__(self, stack: AsyncExitStack):
|
|
||||||
self._stack = stack
|
|
||||||
|
|
||||||
async def __aenter__(self):
|
|
||||||
return self
|
|
||||||
|
|
||||||
async def __aexit__(self, exc_type, exc, tb):
|
|
||||||
await self._stack.aclose()
|
|
||||||
return False
|
|
||||||
|
|
||||||
|
|
||||||
class _ColdStartRetry(Exception):
|
|
||||||
"""Signal: the managed process is alive but not yet answering the MCP
|
|
||||||
handshake because it is still cold-starting (e.g. `npx -y <pkg>` is still
|
|
||||||
installing). The outer lifecycle retry treats this like a transient
|
|
||||||
reconnect: it reuses the live process and does not count toward the fatal
|
|
||||||
retry budget, so a slow cold start is waited out rather than failing.
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
|
||||||
class BoxStdioSessionRuntime:
|
class BoxStdioSessionRuntime:
|
||||||
"""Encapsulate Box-backed stdio MCP session orchestration."""
|
"""Encapsulate Box-backed stdio MCP session orchestration."""
|
||||||
|
|
||||||
@@ -142,15 +113,7 @@ class BoxStdioSessionRuntime:
|
|||||||
read_only_rootfs=self.config.read_only_rootfs if self.config.read_only_rootfs is not None else False,
|
read_only_rootfs=self.config.read_only_rootfs if self.config.read_only_rootfs is not None else False,
|
||||||
image=self.config.image,
|
image=self.config.image,
|
||||||
cpus=self.config.cpus,
|
cpus=self.config.cpus,
|
||||||
# Node.js runtimes (npx/bunx) reserve large virtual address space and
|
memory_mb=self.config.memory_mb,
|
||||||
# load WebAssembly modules (llhttp) on startup; the default 512 MB
|
|
||||||
# cgroup_mem_max is too small and causes OOM kills (return_code=137).
|
|
||||||
# Auto-bump to 1024 MB when the runner is npx/bunx/pnpm dlx.
|
|
||||||
memory_mb=(
|
|
||||||
(self.config.memory_mb or 1024)
|
|
||||||
if self.server_config.get('command', '') in ('npx', 'bunx', 'pnpm')
|
|
||||||
else self.config.memory_mb
|
|
||||||
),
|
|
||||||
pids_limit=self.config.pids_limit,
|
pids_limit=self.config.pids_limit,
|
||||||
persistent=True,
|
persistent=True,
|
||||||
)
|
)
|
||||||
@@ -210,12 +173,6 @@ class BoxStdioSessionRuntime:
|
|||||||
stderr_preview = (result.stderr or '')[:500]
|
stderr_preview = (result.stderr or '')[:500]
|
||||||
raise Exception(f'Dependency install failed (exit code {result.exit_code}): {stderr_preview}')
|
raise Exception(f'Dependency install failed (exit code {result.exit_code}): {stderr_preview}')
|
||||||
|
|
||||||
# Reuse an already-running managed process instead of rebuilding it.
|
|
||||||
# The Box runtime keeps the managed process alive across a transient
|
|
||||||
# WebSocket transport drop, so on a reconnect we only need to re-attach
|
|
||||||
# the WS below. Rebuilding here would needlessly stop a healthy process
|
|
||||||
# and re-run the (slow, network-touching) dependency bootstrap.
|
|
||||||
if not await self._managed_process_is_running():
|
|
||||||
try:
|
try:
|
||||||
process_workspace = (
|
process_workspace = (
|
||||||
self._build_workspace(host_path=host_path, workdir=process_cwd, mount_path=process_cwd)
|
self._build_workspace(host_path=host_path, workdir=process_cwd, mount_path=process_cwd)
|
||||||
@@ -235,30 +192,9 @@ class BoxStdioSessionRuntime:
|
|||||||
except Exception:
|
except Exception:
|
||||||
self.owner.error_phase = MCPSessionErrorPhase.PROCESS_START
|
self.owner.error_phase = MCPSessionErrorPhase.PROCESS_START
|
||||||
raise
|
raise
|
||||||
else:
|
|
||||||
self.ap.logger.info(
|
|
||||||
f'MCP server {self.server_name}: reusing live managed process '
|
|
||||||
f'process_id={self.process_id} (transport reconnect)'
|
|
||||||
)
|
|
||||||
|
|
||||||
websocket_url = workspace.get_managed_process_websocket_url(self.process_id)
|
|
||||||
|
|
||||||
# Attach the WS transport + MCP session ONCE, on the owner's exit stack,
|
|
||||||
# in the same task as the serve loop that follows. websocket_client and
|
|
||||||
# ClientSession use anyio task groups whose cancel scope is bound to the
|
|
||||||
# frame/stack that entered them, so they must live on the owner exit
|
|
||||||
# stack (not a deferred/transferred one) or the streams close the moment
|
|
||||||
# initialize() returns and the next request fails with "Connection
|
|
||||||
# closed".
|
|
||||||
#
|
|
||||||
# A slow (`npx -y <pkg>`) cold start makes this single attempt fail
|
|
||||||
# while the process is still alive — the package is still installing and
|
|
||||||
# cannot answer the handshake. We surface that to the outer retry loop
|
|
||||||
# as a _ColdStartRetry: it must NOT stop the process (it is healthy and
|
|
||||||
# will be reused) and must NOT consume the fatal retry budget. The next
|
|
||||||
# attempt re-attaches to the same live process; once it has finished
|
|
||||||
# cold start the handshake succeeds and stays healthy.
|
|
||||||
try:
|
try:
|
||||||
|
websocket_url = workspace.get_managed_process_websocket_url(self.process_id)
|
||||||
transport = await self.owner.exit_stack.enter_async_context(websocket_client(websocket_url))
|
transport = await self.owner.exit_stack.enter_async_context(websocket_client(websocket_url))
|
||||||
read_stream, write_stream = transport
|
read_stream, write_stream = transport
|
||||||
self.owner.session = await self.owner.exit_stack.enter_async_context(
|
self.owner.session = await self.owner.exit_stack.enter_async_context(
|
||||||
@@ -266,19 +202,12 @@ class BoxStdioSessionRuntime:
|
|||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
self.owner.error_phase = MCPSessionErrorPhase.RELAY_CONNECT
|
self.owner.error_phase = MCPSessionErrorPhase.RELAY_CONNECT
|
||||||
if not await self._managed_process_has_exited():
|
|
||||||
# Process is alive but not yet serving (cold start) — reconnect.
|
|
||||||
raise _ColdStartRetry(f'{self.server_name}: transport not ready during cold start')
|
|
||||||
raise
|
raise
|
||||||
|
|
||||||
try:
|
try:
|
||||||
await asyncio.wait_for(self.owner.session.initialize(), timeout=_HANDSHAKE_ATTEMPT_TIMEOUT_SEC)
|
await self.owner.session.initialize()
|
||||||
except Exception as exc:
|
except Exception:
|
||||||
self.owner.error_phase = MCPSessionErrorPhase.MCP_INIT
|
self.owner.error_phase = MCPSessionErrorPhase.MCP_INIT
|
||||||
if not await self._managed_process_has_exited():
|
|
||||||
raise _ColdStartRetry(
|
|
||||||
f'{self.server_name}: handshake not ready during cold start ({type(exc).__name__})'
|
|
||||||
)
|
|
||||||
raise
|
raise
|
||||||
|
|
||||||
async def monitor_process_health(self) -> None:
|
async def monitor_process_health(self) -> None:
|
||||||
@@ -305,74 +234,8 @@ class BoxStdioSessionRuntime:
|
|||||||
)
|
)
|
||||||
if consecutive_errors >= self.owner._MONITOR_MAX_CONSECUTIVE_ERRORS:
|
if consecutive_errors >= self.owner._MONITOR_MAX_CONSECUTIVE_ERRORS:
|
||||||
return
|
return
|
||||||
|
|
||||||
# Capture stderr logs from the managed process
|
|
||||||
if isinstance(info, dict):
|
|
||||||
stderr_text = info.get('stderr', '') or info.get('stderr_preview', '')
|
|
||||||
else:
|
|
||||||
stderr_text = getattr(info, 'stderr', '') or getattr(info, 'stderr_preview', '')
|
|
||||||
|
|
||||||
if stderr_text and stderr_text != self.owner._last_stderr_text:
|
|
||||||
# Find new lines not in the previous snapshot
|
|
||||||
old_lines = set(self.owner._last_stderr_text.splitlines()) if self.owner._last_stderr_text else set()
|
|
||||||
new_lines = [l for l in stderr_text.splitlines() if l and l not in old_lines]
|
|
||||||
self.owner._last_stderr_text = stderr_text
|
|
||||||
|
|
||||||
import time as _time
|
|
||||||
|
|
||||||
for line in new_lines:
|
|
||||||
level = (
|
|
||||||
'error'
|
|
||||||
if any(k in line.upper() for k in ('ERROR', 'CRITICAL'))
|
|
||||||
else 'warning'
|
|
||||||
if 'WARNING' in line.upper()
|
|
||||||
else 'debug'
|
|
||||||
if 'DEBUG' in line.upper()
|
|
||||||
else 'info'
|
|
||||||
)
|
|
||||||
self.owner._log_buffer.append({'ts': _time.time(), 'level': level, 'text': line})
|
|
||||||
|
|
||||||
await asyncio.sleep(self.owner._MONITOR_POLL_INTERVAL)
|
await asyncio.sleep(self.owner._MONITOR_POLL_INTERVAL)
|
||||||
|
|
||||||
async def _managed_process_is_running(self) -> bool:
|
|
||||||
"""Return True if this server's managed process exists and is running.
|
|
||||||
|
|
||||||
Used to decide whether initialize() must (re)start the process or can
|
|
||||||
simply re-attach the WebSocket transport to a process the Box runtime
|
|
||||||
kept alive across a transient transport drop.
|
|
||||||
"""
|
|
||||||
from langbot_plugin.box.models import BoxManagedProcessStatus
|
|
||||||
|
|
||||||
workspace = self._build_workspace()
|
|
||||||
try:
|
|
||||||
info = await workspace.get_managed_process(self.process_id)
|
|
||||||
except Exception:
|
|
||||||
return False
|
|
||||||
status = info.get('status', '') if isinstance(info, dict) else getattr(info, 'status', '')
|
|
||||||
return status in (BoxManagedProcessStatus.RUNNING.value, BoxManagedProcessStatus.RUNNING)
|
|
||||||
|
|
||||||
async def _managed_process_has_exited(self) -> bool:
|
|
||||||
"""Return True only if the process is DEFINITIVELY gone (reports EXITED).
|
|
||||||
|
|
||||||
Distinct from ``not _managed_process_is_running()``: a process that has
|
|
||||||
just been spawned may not yet report RUNNING, and a transient query
|
|
||||||
error is not proof of exit. During the cold-start handshake retry we
|
|
||||||
must NOT treat 'not yet running' or 'query failed' as a terminal
|
|
||||||
failure, or we bail out to the outer rebuild path and churn the
|
|
||||||
process (relay then rejects the early re-attach with HTTP 400). Only a
|
|
||||||
successful query that reports EXITED stops the retry loop.
|
|
||||||
"""
|
|
||||||
from langbot_plugin.box.models import BoxManagedProcessStatus
|
|
||||||
|
|
||||||
workspace = self._build_workspace()
|
|
||||||
try:
|
|
||||||
info = await workspace.get_managed_process(self.process_id)
|
|
||||||
except Exception:
|
|
||||||
# Unknown — treat as 'still coming up', not exited.
|
|
||||||
return False
|
|
||||||
status = info.get('status', '') if isinstance(info, dict) else getattr(info, 'status', '')
|
|
||||||
return status in (BoxManagedProcessStatus.EXITED.value, BoxManagedProcessStatus.EXITED)
|
|
||||||
|
|
||||||
async def _stage_host_path_to_shared_workspace(self, host_path: str) -> str:
|
async def _stage_host_path_to_shared_workspace(self, host_path: str) -> str:
|
||||||
source_path = normalize_host_path(host_path)
|
source_path = normalize_host_path(host_path)
|
||||||
if not source_path:
|
if not source_path:
|
||||||
@@ -479,20 +342,16 @@ class BoxStdioSessionRuntime:
|
|||||||
|
|
||||||
workspace = self._build_workspace(host_path=None)
|
workspace = self._build_workspace(host_path=None)
|
||||||
|
|
||||||
# Transient config-page tests now share the same 'mcp-shared' Box
|
# Transient test sessions own their isolated Box session, so tear the
|
||||||
# session as live servers, so we must NOT tear the session down here —
|
# whole session down rather than leaking it. This cannot affect live
|
||||||
# that would kill every other MCP server in the container. A test is
|
# servers because they live in the separate shared session.
|
||||||
# isolated at the process level: it ran under its own process_id, so we
|
|
||||||
# stop only that process, exactly like a live server does below. The
|
|
||||||
# shared session and all other servers' live processes are untouched.
|
|
||||||
# (Staged per-test workspace files are still cleaned up.)
|
|
||||||
if getattr(self.owner, 'is_transient', False):
|
if getattr(self.owner, 'is_transient', False):
|
||||||
try:
|
try:
|
||||||
await workspace.stop_managed_process(self.process_id)
|
await workspace.cleanup()
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
self.ap.logger.warning(
|
self.ap.logger.warning(
|
||||||
f'MCP server {self.server_name}: failed to stop transient test process '
|
f'MCP server {self.server_name}: failed to delete transient test session '
|
||||||
f'process_id={self.process_id}: {type(exc).__name__}: {exc}'
|
f'{self.owner._build_box_session_id()}: {type(exc).__name__}: {exc}'
|
||||||
)
|
)
|
||||||
await self._cleanup_staged_workspace()
|
await self._cleanup_staged_workspace()
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -33,24 +33,6 @@ class PluginToolLoader(loader.ToolLoader):
|
|||||||
|
|
||||||
return all_functions
|
return all_functions
|
||||||
|
|
||||||
async def get_tool_catalog(self, bound_plugins: list[str] | None = None) -> list[dict[str, typing.Any]]:
|
|
||||||
catalog: list[dict[str, typing.Any]] = []
|
|
||||||
|
|
||||||
for tool in await self.ap.plugin_connector.list_tools(bound_plugins):
|
|
||||||
catalog.append(
|
|
||||||
{
|
|
||||||
'name': tool.metadata.name,
|
|
||||||
'description': tool.spec['llm_prompt'],
|
|
||||||
'human_desc': tool.metadata.description.en_US,
|
|
||||||
'parameters': tool.spec['parameters'],
|
|
||||||
'source': 'plugin',
|
|
||||||
'source_name': tool.owner,
|
|
||||||
'source_id': tool.owner,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
return catalog
|
|
||||||
|
|
||||||
async def has_tool(self, name: str) -> bool:
|
async def has_tool(self, name: str) -> bool:
|
||||||
"""检查工具是否存在"""
|
"""检查工具是否存在"""
|
||||||
for tool in await self.ap.plugin_connector.list_tools():
|
for tool in await self.ap.plugin_connector.list_tools():
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import typing
|
import typing
|
||||||
import time
|
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
import langbot_plugin.api.entities.builtin.resource.tool as resource_tool
|
import langbot_plugin.api.entities.builtin.resource.tool as resource_tool
|
||||||
@@ -60,7 +59,6 @@ class ToolManager:
|
|||||||
bound_plugins: list[str] | None = None,
|
bound_plugins: list[str] | None = None,
|
||||||
bound_mcp_servers: list[str] | None = None,
|
bound_mcp_servers: list[str] | None = None,
|
||||||
include_skill_authoring: bool = False,
|
include_skill_authoring: bool = False,
|
||||||
include_mcp_resource_tools: bool = True,
|
|
||||||
) -> list[resource_tool.LLMTool]:
|
) -> list[resource_tool.LLMTool]:
|
||||||
all_functions: list[resource_tool.LLMTool] = []
|
all_functions: list[resource_tool.LLMTool] = []
|
||||||
|
|
||||||
@@ -68,51 +66,10 @@ class ToolManager:
|
|||||||
if include_skill_authoring:
|
if include_skill_authoring:
|
||||||
all_functions.extend(await self.skill_tool_loader.get_tools())
|
all_functions.extend(await self.skill_tool_loader.get_tools())
|
||||||
all_functions.extend(await self.plugin_tool_loader.get_tools(bound_plugins))
|
all_functions.extend(await self.plugin_tool_loader.get_tools(bound_plugins))
|
||||||
all_functions.extend(
|
all_functions.extend(await self.mcp_tool_loader.get_tools(bound_mcp_servers))
|
||||||
await self.mcp_tool_loader.get_tools(
|
|
||||||
bound_mcp_servers,
|
|
||||||
include_resource_tools=include_mcp_resource_tools,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
return all_functions
|
return all_functions
|
||||||
|
|
||||||
async def get_tool_catalog(
|
|
||||||
self,
|
|
||||||
bound_plugins: list[str] | None = None,
|
|
||||||
bound_mcp_servers: list[str] | None = None,
|
|
||||||
include_skill_authoring: bool = False,
|
|
||||||
include_mcp_resource_tools: bool = False,
|
|
||||||
) -> list[dict[str, typing.Any]]:
|
|
||||||
catalog: list[dict[str, typing.Any]] = []
|
|
||||||
|
|
||||||
def append_tools(source: str, source_name: str, tools: list[resource_tool.LLMTool]) -> None:
|
|
||||||
for tool in tools:
|
|
||||||
catalog.append(
|
|
||||||
{
|
|
||||||
'name': tool.name,
|
|
||||||
'description': tool.description,
|
|
||||||
'human_desc': tool.human_desc,
|
|
||||||
'parameters': tool.parameters,
|
|
||||||
'source': source,
|
|
||||||
'source_name': source_name,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
append_tools('builtin', 'LangBot', await self.native_tool_loader.get_tools())
|
|
||||||
if include_skill_authoring:
|
|
||||||
append_tools('skill', 'LangBot', await self.skill_tool_loader.get_tools())
|
|
||||||
catalog.extend(await self.plugin_tool_loader.get_tool_catalog(bound_plugins))
|
|
||||||
|
|
||||||
if self.mcp_tool_loader:
|
|
||||||
for item in await self.mcp_tool_loader.get_tool_catalog(
|
|
||||||
bound_mcp_servers,
|
|
||||||
include_resource_tools=include_mcp_resource_tools,
|
|
||||||
):
|
|
||||||
catalog.append(item)
|
|
||||||
|
|
||||||
return catalog
|
|
||||||
|
|
||||||
async def get_tool_by_name(self, name: str) -> tool_loader.ToolLookupResult | None:
|
async def get_tool_by_name(self, name: str) -> tool_loader.ToolLookupResult | None:
|
||||||
"""Get tool by name from any active loader."""
|
"""Get tool by name from any active loader."""
|
||||||
for active_loader in (
|
for active_loader in (
|
||||||
@@ -143,130 +100,21 @@ class ToolManager:
|
|||||||
|
|
||||||
return tools
|
return tools
|
||||||
|
|
||||||
def _get_query_session_id(self, query: pipeline_query.Query) -> str | None:
|
|
||||||
launcher_type = getattr(query, 'launcher_type', None)
|
|
||||||
launcher_id = getattr(query, 'launcher_id', None)
|
|
||||||
if launcher_type is None or launcher_id is None:
|
|
||||||
return None
|
|
||||||
|
|
||||||
launcher_type_value = launcher_type.value if hasattr(launcher_type, 'value') else launcher_type
|
|
||||||
return f'{launcher_type_value}_{launcher_id}'
|
|
||||||
|
|
||||||
async def _record_tool_call(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
name: str,
|
|
||||||
source: str,
|
|
||||||
parameters: dict,
|
|
||||||
query: pipeline_query.Query,
|
|
||||||
duration_ms: int,
|
|
||||||
status: str,
|
|
||||||
result: typing.Any = None,
|
|
||||||
error_message: str | None = None,
|
|
||||||
) -> None:
|
|
||||||
monitoring_service = getattr(self.ap, 'monitoring_service', None)
|
|
||||||
if not monitoring_service:
|
|
||||||
return
|
|
||||||
|
|
||||||
variables = getattr(query, 'variables', {}) or {}
|
|
||||||
message_id = variables.get('_monitoring_message_id') if isinstance(variables, dict) else None
|
|
||||||
bot_name = variables.get('_monitoring_bot_name') if isinstance(variables, dict) else None
|
|
||||||
pipeline_name = variables.get('_monitoring_pipeline_name') if isinstance(variables, dict) else None
|
|
||||||
|
|
||||||
try:
|
|
||||||
await monitoring_service.record_tool_call(
|
|
||||||
tool_name=name,
|
|
||||||
tool_source=source,
|
|
||||||
duration=duration_ms,
|
|
||||||
status=status,
|
|
||||||
bot_id=getattr(query, 'bot_uuid', None),
|
|
||||||
bot_name=bot_name,
|
|
||||||
pipeline_name=pipeline_name,
|
|
||||||
session_id=self._get_query_session_id(query),
|
|
||||||
message_id=message_id,
|
|
||||||
arguments=parameters,
|
|
||||||
result=result,
|
|
||||||
error_message=error_message,
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
self.ap.logger.warning(f'Failed to record tool call: {e}')
|
|
||||||
|
|
||||||
async def _invoke_tool_with_monitoring(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
source: str,
|
|
||||||
name: str,
|
|
||||||
parameters: dict,
|
|
||||||
query: pipeline_query.Query,
|
|
||||||
invoke: typing.Callable[[], typing.Awaitable[typing.Any]],
|
|
||||||
) -> typing.Any:
|
|
||||||
start_time = time.perf_counter()
|
|
||||||
try:
|
|
||||||
result = await invoke()
|
|
||||||
except Exception as e:
|
|
||||||
duration_ms = int((time.perf_counter() - start_time) * 1000)
|
|
||||||
await self._record_tool_call(
|
|
||||||
name=name,
|
|
||||||
source=source,
|
|
||||||
parameters=parameters,
|
|
||||||
query=query,
|
|
||||||
duration_ms=duration_ms,
|
|
||||||
status='error',
|
|
||||||
error_message=str(e),
|
|
||||||
)
|
|
||||||
raise
|
|
||||||
|
|
||||||
duration_ms = int((time.perf_counter() - start_time) * 1000)
|
|
||||||
await self._record_tool_call(
|
|
||||||
name=name,
|
|
||||||
source=source,
|
|
||||||
parameters=parameters,
|
|
||||||
query=query,
|
|
||||||
duration_ms=duration_ms,
|
|
||||||
status='success',
|
|
||||||
result=result,
|
|
||||||
)
|
|
||||||
return result
|
|
||||||
|
|
||||||
async def execute_func_call(self, name: str, parameters: dict, query: pipeline_query.Query) -> typing.Any:
|
async def execute_func_call(self, name: str, parameters: dict, query: pipeline_query.Query) -> typing.Any:
|
||||||
from langbot.pkg.telemetry import features as telemetry_features
|
from langbot.pkg.telemetry import features as telemetry_features
|
||||||
|
|
||||||
if await self.native_tool_loader.has_tool(name):
|
if await self.native_tool_loader.has_tool(name):
|
||||||
telemetry_features.increment(query, 'tool_calls', 'native')
|
telemetry_features.increment(query, 'tool_calls', 'native')
|
||||||
return await self._invoke_tool_with_monitoring(
|
return await self.native_tool_loader.invoke_tool(name, parameters, query)
|
||||||
source='native',
|
|
||||||
name=name,
|
|
||||||
parameters=parameters,
|
|
||||||
query=query,
|
|
||||||
invoke=lambda: self.native_tool_loader.invoke_tool(name, parameters, query),
|
|
||||||
)
|
|
||||||
if await self.plugin_tool_loader.has_tool(name):
|
if await self.plugin_tool_loader.has_tool(name):
|
||||||
telemetry_features.increment(query, 'tool_calls', 'plugin')
|
telemetry_features.increment(query, 'tool_calls', 'plugin')
|
||||||
return await self._invoke_tool_with_monitoring(
|
return await self.plugin_tool_loader.invoke_tool(name, parameters, query)
|
||||||
source='plugin',
|
|
||||||
name=name,
|
|
||||||
parameters=parameters,
|
|
||||||
query=query,
|
|
||||||
invoke=lambda: self.plugin_tool_loader.invoke_tool(name, parameters, query),
|
|
||||||
)
|
|
||||||
if await self.mcp_tool_loader.has_tool(name):
|
if await self.mcp_tool_loader.has_tool(name):
|
||||||
telemetry_features.increment(query, 'tool_calls', 'mcp')
|
telemetry_features.increment(query, 'tool_calls', 'mcp')
|
||||||
return await self._invoke_tool_with_monitoring(
|
return await self.mcp_tool_loader.invoke_tool(name, parameters, query)
|
||||||
source='mcp',
|
|
||||||
name=name,
|
|
||||||
parameters=parameters,
|
|
||||||
query=query,
|
|
||||||
invoke=lambda: self.mcp_tool_loader.invoke_tool(name, parameters, query),
|
|
||||||
)
|
|
||||||
if await self.skill_tool_loader.has_tool(name):
|
if await self.skill_tool_loader.has_tool(name):
|
||||||
telemetry_features.increment(query, 'tool_calls', 'skill')
|
telemetry_features.increment(query, 'tool_calls', 'skill')
|
||||||
return await self._invoke_tool_with_monitoring(
|
return await self.skill_tool_loader.invoke_tool(name, parameters, query)
|
||||||
source='skill',
|
|
||||||
name=name,
|
|
||||||
parameters=parameters,
|
|
||||||
query=query,
|
|
||||||
invoke=lambda: self.skill_tool_loader.invoke_tool(name, parameters, query),
|
|
||||||
)
|
|
||||||
raise ToolNotFoundError(name)
|
raise ToolNotFoundError(name)
|
||||||
|
|
||||||
async def shutdown(self):
|
async def shutdown(self):
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
admins: []
|
||||||
api:
|
api:
|
||||||
port: 5300
|
port: 5300
|
||||||
webhook_prefix: 'http://127.0.0.1:5300'
|
webhook_prefix: 'http://127.0.0.1:5300'
|
||||||
|
|||||||
@@ -118,6 +118,20 @@ stages:
|
|||||||
default:
|
default:
|
||||||
- role: system
|
- role: system
|
||||||
content: "You are a helpful assistant."
|
content: "You are a helpful assistant."
|
||||||
|
- name: knowledge-bases
|
||||||
|
label:
|
||||||
|
en_US: Knowledge Bases
|
||||||
|
zh_Hans: 知识库
|
||||||
|
description:
|
||||||
|
en_US: Configure the knowledge bases to use for the agent, if not selected, the agent will directly use the LLM to reply
|
||||||
|
zh_Hans: 配置用于提升回复质量的知识库,若不选择,则直接使用大模型回复
|
||||||
|
type: knowledge-base-multi-selector
|
||||||
|
required: false
|
||||||
|
default: []
|
||||||
|
show_if:
|
||||||
|
field: __system.is_wizard
|
||||||
|
operator: neq
|
||||||
|
value: true
|
||||||
- name: box-session-id-template
|
- name: box-session-id-template
|
||||||
label:
|
label:
|
||||||
en_US: Sandbox Scope
|
en_US: Sandbox Scope
|
||||||
@@ -240,34 +254,6 @@ stages:
|
|||||||
field: rerank-model
|
field: rerank-model
|
||||||
operator: neq
|
operator: neq
|
||||||
value: ''
|
value: ''
|
||||||
- name: tools
|
|
||||||
label:
|
|
||||||
en_US: Tools
|
|
||||||
zh_Hans: 工具
|
|
||||||
description:
|
|
||||||
en_US: Select plugin, MCP, skill, and built-in tools available to this Local Agent.
|
|
||||||
zh_Hans: 选择此内置 Agent 可以调用的插件、MCP、技能和内置工具。
|
|
||||||
type: rich-tools-selector
|
|
||||||
required: false
|
|
||||||
default: []
|
|
||||||
show_if:
|
|
||||||
field: __system.is_wizard
|
|
||||||
operator: neq
|
|
||||||
value: true
|
|
||||||
- name: knowledge-bases
|
|
||||||
label:
|
|
||||||
en_US: Resources
|
|
||||||
zh_Hans: 资源
|
|
||||||
description:
|
|
||||||
en_US: Select MCP resources and knowledge bases available to this Local Agent.
|
|
||||||
zh_Hans: 选择此内置 Agent 可以读取的 MCP 资源和知识库。
|
|
||||||
type: resources-selector
|
|
||||||
required: false
|
|
||||||
default: []
|
|
||||||
show_if:
|
|
||||||
field: __system.is_wizard
|
|
||||||
operator: neq
|
|
||||||
value: true
|
|
||||||
- name: dify-service-api
|
- name: dify-service-api
|
||||||
label:
|
label:
|
||||||
en_US: Dify Service API
|
en_US: Dify Service API
|
||||||
|
|||||||
@@ -81,7 +81,6 @@ def fake_monitoring_app():
|
|||||||
)
|
)
|
||||||
app.monitoring_service.get_messages = AsyncMock(return_value=([{'id': 'msg-1', 'content': 'test'}], 100))
|
app.monitoring_service.get_messages = AsyncMock(return_value=([{'id': 'msg-1', 'content': 'test'}], 100))
|
||||||
app.monitoring_service.get_llm_calls = AsyncMock(return_value=([{'id': 'llm-1'}], 50))
|
app.monitoring_service.get_llm_calls = AsyncMock(return_value=([{'id': 'llm-1'}], 50))
|
||||||
app.monitoring_service.get_tool_calls = AsyncMock(return_value=([{'id': 'tool-1'}], 5))
|
|
||||||
app.monitoring_service.get_embedding_calls = AsyncMock(return_value=([{'id': 'emb-1'}], 10))
|
app.monitoring_service.get_embedding_calls = AsyncMock(return_value=([{'id': 'emb-1'}], 10))
|
||||||
app.monitoring_service.get_sessions = AsyncMock(return_value=([{'session_id': 'sess-1'}], 20))
|
app.monitoring_service.get_sessions = AsyncMock(return_value=([{'session_id': 'sess-1'}], 20))
|
||||||
app.monitoring_service.get_errors = AsyncMock(return_value=([{'id': 'err-1'}], 2))
|
app.monitoring_service.get_errors = AsyncMock(return_value=([{'id': 'err-1'}], 2))
|
||||||
|
|||||||
@@ -90,56 +90,6 @@ class TestMCPServiceGetRuntimeInfo:
|
|||||||
assert result is None
|
assert result is None
|
||||||
|
|
||||||
|
|
||||||
class TestMCPServiceResources:
|
|
||||||
"""Tests for MCP resource helpers."""
|
|
||||||
|
|
||||||
async def test_get_resource_templates_delegates_to_loader(self):
|
|
||||||
ap = SimpleNamespace()
|
|
||||||
ap.tool_mgr = SimpleNamespace()
|
|
||||||
ap.tool_mgr.mcp_tool_loader = SimpleNamespace()
|
|
||||||
ap.tool_mgr.mcp_tool_loader.get_resource_templates = AsyncMock(
|
|
||||||
return_value=[{'uri_template': 'file:///{path}', 'name': 'files'}]
|
|
||||||
)
|
|
||||||
|
|
||||||
service = MCPService(ap)
|
|
||||||
|
|
||||||
result = await service.get_mcp_server_resource_templates('docs')
|
|
||||||
|
|
||||||
assert result == [{'uri_template': 'file:///{path}', 'name': 'files'}]
|
|
||||||
ap.tool_mgr.mcp_tool_loader.get_resource_templates.assert_awaited_once_with('docs')
|
|
||||||
|
|
||||||
async def test_read_resource_envelope_uses_ui_preview_source(self):
|
|
||||||
ap = SimpleNamespace()
|
|
||||||
ap.tool_mgr = SimpleNamespace()
|
|
||||||
ap.tool_mgr.mcp_tool_loader = SimpleNamespace()
|
|
||||||
ap.tool_mgr.mcp_tool_loader.read_resource_envelope = AsyncMock(
|
|
||||||
return_value={
|
|
||||||
'server_name': 'docs',
|
|
||||||
'uri': 'file:///README.md',
|
|
||||||
'contents': [],
|
|
||||||
'source': 'ui_preview',
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
service = MCPService(ap)
|
|
||||||
|
|
||||||
result = await service.read_mcp_server_resource_envelope(
|
|
||||||
'docs',
|
|
||||||
'file:///README.md',
|
|
||||||
max_bytes=4096,
|
|
||||||
include_blob=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert result['source'] == 'ui_preview'
|
|
||||||
ap.tool_mgr.mcp_tool_loader.read_resource_envelope.assert_awaited_once_with(
|
|
||||||
'docs',
|
|
||||||
'file:///README.md',
|
|
||||||
include_blob=True,
|
|
||||||
source='ui_preview',
|
|
||||||
max_bytes=4096,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TestMCPServiceGetMCPServers:
|
class TestMCPServiceGetMCPServers:
|
||||||
"""Tests for get_mcp_servers method."""
|
"""Tests for get_mcp_servers method."""
|
||||||
|
|
||||||
@@ -280,25 +230,6 @@ class TestMCPServiceCreateMCPServer:
|
|||||||
assert server_uuid is not None
|
assert server_uuid is not None
|
||||||
assert len(server_uuid) == 36 # UUID format
|
assert len(server_uuid) == 36 # UUID format
|
||||||
|
|
||||||
async def test_create_mcp_server_duplicate_name_raises(self):
|
|
||||||
"""Rejects duplicate MCP server names."""
|
|
||||||
# Setup
|
|
||||||
ap = SimpleNamespace()
|
|
||||||
ap.persistence_mgr = SimpleNamespace()
|
|
||||||
ap.instance_config = SimpleNamespace()
|
|
||||||
ap.instance_config.data = {'system': {'limitation': {'max_extensions': -1}}}
|
|
||||||
ap.tool_mgr = None
|
|
||||||
|
|
||||||
existing_server = _create_mock_mcp_server(name='Existing Server')
|
|
||||||
ap.persistence_mgr.execute_async = AsyncMock(return_value=_create_mock_result(first_item=existing_server))
|
|
||||||
ap.persistence_mgr.serialize_model = Mock(return_value={})
|
|
||||||
|
|
||||||
service = MCPService(ap)
|
|
||||||
|
|
||||||
# Execute & Verify
|
|
||||||
with pytest.raises(ValueError, match='MCP server already exists: Existing Server'):
|
|
||||||
await service.create_mcp_server({'name': 'Existing Server'})
|
|
||||||
|
|
||||||
async def test_create_mcp_server_loads_server(self):
|
async def test_create_mcp_server_loads_server(self):
|
||||||
"""Loads server into tool_mgr when enabled."""
|
"""Loads server into tool_mgr when enabled."""
|
||||||
# Setup
|
# Setup
|
||||||
@@ -320,7 +251,7 @@ class TestMCPServiceCreateMCPServer:
|
|||||||
nonlocal call_count
|
nonlocal call_count
|
||||||
call_count += 1
|
call_count += 1
|
||||||
if call_count == 1:
|
if call_count == 1:
|
||||||
return _create_mock_result([]) # Empty result for duplicate-name check
|
return _create_mock_result([]) # Empty list for limit check
|
||||||
elif call_count == 2:
|
elif call_count == 2:
|
||||||
return Mock() # Insert
|
return Mock() # Insert
|
||||||
return _create_mock_result(first_item=server_entity) # Select created
|
return _create_mock_result(first_item=server_entity) # Select created
|
||||||
|
|||||||
@@ -348,8 +348,6 @@ class TestPipelineServiceCreatePipeline:
|
|||||||
'enable_all_mcp_servers': True,
|
'enable_all_mcp_servers': True,
|
||||||
'plugins': [],
|
'plugins': [],
|
||||||
'mcp_servers': [],
|
'mcp_servers': [],
|
||||||
'mcp_resources': [],
|
|
||||||
'mcp_resource_agent_read_enabled': True,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -816,47 +814,6 @@ class TestPipelineServiceUpdatePipelineExtensions:
|
|||||||
# Verify - persistence was called
|
# Verify - persistence was called
|
||||||
ap.persistence_mgr.execute_async.assert_called()
|
ap.persistence_mgr.execute_async.assert_called()
|
||||||
|
|
||||||
async def test_update_extensions_preserves_mcp_resource_agent_read_when_omitted(self):
|
|
||||||
"""Does not reset mcp_resource_agent_read_enabled when omitted by older clients."""
|
|
||||||
ap = SimpleNamespace()
|
|
||||||
ap.persistence_mgr = SimpleNamespace()
|
|
||||||
ap.pipeline_mgr = SimpleNamespace()
|
|
||||||
ap.pipeline_mgr.remove_pipeline = AsyncMock()
|
|
||||||
ap.pipeline_mgr.load_pipeline = AsyncMock()
|
|
||||||
|
|
||||||
original_pipeline = _create_mock_pipeline(
|
|
||||||
extensions_preferences={
|
|
||||||
'enable_all_plugins': True,
|
|
||||||
'enable_all_mcp_servers': True,
|
|
||||||
'plugins': [],
|
|
||||||
'mcp_servers': [],
|
|
||||||
'mcp_resources': [{'server_uuid': 'srv-1', 'uri': 'file:///README.md'}],
|
|
||||||
'mcp_resource_agent_read_enabled': False,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
call_count = 0
|
|
||||||
|
|
||||||
async def mock_execute(query):
|
|
||||||
nonlocal call_count
|
|
||||||
call_count += 1
|
|
||||||
if call_count == 1:
|
|
||||||
return _create_mock_result(first_item=original_pipeline)
|
|
||||||
return Mock()
|
|
||||||
|
|
||||||
ap.persistence_mgr.execute_async = AsyncMock(side_effect=mock_execute)
|
|
||||||
ap.persistence_mgr.serialize_model = Mock(return_value={'uuid': 'test-uuid'})
|
|
||||||
|
|
||||||
service = PipelineService(ap)
|
|
||||||
service.get_pipeline = AsyncMock(return_value={'uuid': 'test-uuid'})
|
|
||||||
|
|
||||||
await service.update_pipeline_extensions('test-uuid', bound_plugins=[])
|
|
||||||
|
|
||||||
assert original_pipeline.extensions_preferences['mcp_resource_agent_read_enabled'] is False
|
|
||||||
assert original_pipeline.extensions_preferences['mcp_resources'] == [
|
|
||||||
{'server_uuid': 'srv-1', 'uri': 'file:///README.md'}
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
class TestDefaultStageOrder:
|
class TestDefaultStageOrder:
|
||||||
"""Tests for default_stage_order constant."""
|
"""Tests for default_stage_order constant."""
|
||||||
|
|||||||
@@ -1,76 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import sys
|
|
||||||
import types
|
|
||||||
from importlib import import_module
|
|
||||||
from types import SimpleNamespace
|
|
||||||
from unittest.mock import AsyncMock
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
import quart
|
|
||||||
|
|
||||||
core_app_module = types.ModuleType('langbot.pkg.core.app')
|
|
||||||
core_app_module.Application = object
|
|
||||||
sys.modules.setdefault('langbot.pkg.core.app', core_app_module)
|
|
||||||
|
|
||||||
|
|
||||||
pytestmark = pytest.mark.asyncio
|
|
||||||
|
|
||||||
|
|
||||||
async def _create_test_client(mcp_service: SimpleNamespace):
|
|
||||||
app = quart.Quart(__name__)
|
|
||||||
user_service = SimpleNamespace(
|
|
||||||
verify_jwt_token=AsyncMock(return_value='test@example.com'),
|
|
||||||
get_user_by_email=AsyncMock(return_value=SimpleNamespace(user='test@example.com')),
|
|
||||||
)
|
|
||||||
ap = SimpleNamespace(mcp_service=mcp_service, user_service=user_service)
|
|
||||||
MCPRouterGroup = import_module('langbot.pkg.api.http.controller.groups.resources.mcp').MCPRouterGroup
|
|
||||||
group = MCPRouterGroup(ap, app)
|
|
||||||
await group.initialize()
|
|
||||||
return app.test_client()
|
|
||||||
|
|
||||||
|
|
||||||
async def test_mcp_server_route_accepts_encoded_slash_name():
|
|
||||||
mcp_service = SimpleNamespace(
|
|
||||||
get_mcp_server_by_name=AsyncMock(
|
|
||||||
return_value={
|
|
||||||
'uuid': 'test-uuid',
|
|
||||||
'name': 'pab1it0/prometheus',
|
|
||||||
'enable': True,
|
|
||||||
'mode': 'stdio',
|
|
||||||
'extra_args': {},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
client = await _create_test_client(mcp_service)
|
|
||||||
|
|
||||||
response = await client.get(
|
|
||||||
'/api/v1/mcp/servers/pab1it0%2Fprometheus',
|
|
||||||
headers={'Authorization': 'Bearer test-token'},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
mcp_service.get_mcp_server_by_name.assert_awaited_once_with('pab1it0/prometheus')
|
|
||||||
payload = await response.get_json()
|
|
||||||
assert payload['data']['server']['name'] == 'pab1it0/prometheus'
|
|
||||||
|
|
||||||
|
|
||||||
async def test_mcp_resource_route_accepts_encoded_slash_name():
|
|
||||||
mcp_service = SimpleNamespace(
|
|
||||||
get_mcp_server_by_name=AsyncMock(),
|
|
||||||
get_mcp_server_resources=AsyncMock(return_value=[]),
|
|
||||||
get_mcp_server_resource_templates=AsyncMock(return_value=[]),
|
|
||||||
get_runtime_info=AsyncMock(return_value={'resource_capabilities': {'subscribe': False}}),
|
|
||||||
)
|
|
||||||
client = await _create_test_client(mcp_service)
|
|
||||||
|
|
||||||
response = await client.get(
|
|
||||||
'/api/v1/mcp/servers/pab1it0%2Fprometheus/resources',
|
|
||||||
headers={'Authorization': 'Bearer test-token'},
|
|
||||||
)
|
|
||||||
|
|
||||||
assert response.status_code == 200
|
|
||||||
mcp_service.get_mcp_server_by_name.assert_not_awaited()
|
|
||||||
mcp_service.get_mcp_server_resources.assert_awaited_once_with('pab1it0/prometheus')
|
|
||||||
payload = await response.get_json()
|
|
||||||
assert payload['data']['resource_capabilities'] == {'subscribe': False}
|
|
||||||
@@ -162,46 +162,3 @@ async def test_runtime_pipeline_execute(mock_app, sample_query):
|
|||||||
|
|
||||||
# Verify stage was called
|
# Verify stage was called
|
||||||
mock_stage.process.assert_called_once()
|
mock_stage.process.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
def test_runtime_pipeline_prefers_local_agent_mcp_resources(mock_app):
|
|
||||||
"""Local Agent resource selection should override legacy extension prefs."""
|
|
||||||
pipelinemgr = get_pipelinemgr_module()
|
|
||||||
persistence_pipeline = get_persistence_pipeline_module()
|
|
||||||
|
|
||||||
pipeline_entity = Mock(spec=persistence_pipeline.LegacyPipeline)
|
|
||||||
pipeline_entity.config = {
|
|
||||||
'ai': {
|
|
||||||
'local-agent': {
|
|
||||||
'mcp-resources': [{'server_uuid': 'srv-new', 'uri': 'file:///new.md'}],
|
|
||||||
'mcp-resource-agent-read-enabled': False,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
pipeline_entity.extensions_preferences = {
|
|
||||||
'mcp_resources': [{'server_uuid': 'srv-old', 'uri': 'file:///old.md'}],
|
|
||||||
'mcp_resource_agent_read_enabled': True,
|
|
||||||
}
|
|
||||||
|
|
||||||
runtime_pipeline = pipelinemgr.RuntimePipeline(mock_app, pipeline_entity, [])
|
|
||||||
|
|
||||||
assert runtime_pipeline.mcp_resource_attachments == [{'server_uuid': 'srv-new', 'uri': 'file:///new.md'}]
|
|
||||||
assert runtime_pipeline.mcp_resource_agent_read_enabled is False
|
|
||||||
|
|
||||||
|
|
||||||
def test_runtime_pipeline_falls_back_to_extension_mcp_resources(mock_app):
|
|
||||||
"""Existing extension prefs remain compatible until a Local Agent value exists."""
|
|
||||||
pipelinemgr = get_pipelinemgr_module()
|
|
||||||
persistence_pipeline = get_persistence_pipeline_module()
|
|
||||||
|
|
||||||
pipeline_entity = Mock(spec=persistence_pipeline.LegacyPipeline)
|
|
||||||
pipeline_entity.config = {'ai': {'local-agent': {}}}
|
|
||||||
pipeline_entity.extensions_preferences = {
|
|
||||||
'mcp_resources': [{'server_uuid': 'srv-old', 'uri': 'file:///old.md'}],
|
|
||||||
'mcp_resource_agent_read_enabled': False,
|
|
||||||
}
|
|
||||||
|
|
||||||
runtime_pipeline = pipelinemgr.RuntimePipeline(mock_app, pipeline_entity, [])
|
|
||||||
|
|
||||||
assert runtime_pipeline.mcp_resource_attachments == [{'server_uuid': 'srv-old', 'uri': 'file:///old.md'}]
|
|
||||||
assert runtime_pipeline.mcp_resource_agent_read_enabled is False
|
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ from __future__ import annotations
|
|||||||
import pytest
|
import pytest
|
||||||
from unittest.mock import AsyncMock, Mock
|
from unittest.mock import AsyncMock, Mock
|
||||||
from importlib import import_module
|
from importlib import import_module
|
||||||
from types import SimpleNamespace
|
|
||||||
|
|
||||||
from tests.factories import (
|
from tests.factories import (
|
||||||
FakeApp,
|
FakeApp,
|
||||||
@@ -432,60 +431,3 @@ class TestPreProcessorVariables:
|
|||||||
variables = result.new_query.variables
|
variables = result.new_query.variables
|
||||||
assert 'group_name' in variables
|
assert 'group_name' in variables
|
||||||
assert 'sender_name' in variables
|
assert 'sender_name' in variables
|
||||||
|
|
||||||
|
|
||||||
class TestPreProcessorToolSelection:
|
|
||||||
"""Tests for Local Agent tool selection."""
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_local_agent_filters_selected_tools(self):
|
|
||||||
"""Only selected tools should be exposed when all-tools mode is off."""
|
|
||||||
preproc = get_preproc_module()
|
|
||||||
|
|
||||||
app = FakeApp()
|
|
||||||
mock_session = Mock()
|
|
||||||
mock_session.launcher_type = Mock(value='person')
|
|
||||||
mock_session.launcher_id = 12345
|
|
||||||
app.sess_mgr.get_session = AsyncMock(return_value=mock_session)
|
|
||||||
|
|
||||||
mock_conversation = Mock()
|
|
||||||
mock_conversation.prompt = Mock(messages=[])
|
|
||||||
mock_conversation.prompt.copy = Mock(return_value=Mock(messages=[]))
|
|
||||||
mock_conversation.messages = []
|
|
||||||
mock_conversation.uuid = None
|
|
||||||
app.sess_mgr.get_conversation = AsyncMock(return_value=mock_conversation)
|
|
||||||
|
|
||||||
mock_model = Mock()
|
|
||||||
mock_model.model_entity = Mock(uuid='primary-model-uuid', abilities=['func_call'])
|
|
||||||
app.model_mgr.get_model_by_uuid = AsyncMock(return_value=mock_model)
|
|
||||||
app.tool_mgr.get_all_tools = AsyncMock(
|
|
||||||
return_value=[
|
|
||||||
SimpleNamespace(name='exec'),
|
|
||||||
SimpleNamespace(name='plugin_tool'),
|
|
||||||
SimpleNamespace(name='mcp_tool'),
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
mock_event_ctx = Mock()
|
|
||||||
mock_event_ctx.event = Mock(default_prompt=[], prompt=[])
|
|
||||||
app.plugin_connector.emit_event = AsyncMock(return_value=mock_event_ctx)
|
|
||||||
|
|
||||||
stage = preproc.PreProcessor(app)
|
|
||||||
query = text_query('hello')
|
|
||||||
query.pipeline_config = {
|
|
||||||
'ai': {
|
|
||||||
'runner': {'runner': 'local-agent'},
|
|
||||||
'local-agent': {
|
|
||||||
'model': {'primary': 'primary-model-uuid', 'fallbacks': []},
|
|
||||||
'prompt': 'default',
|
|
||||||
'enable-all-tools': False,
|
|
||||||
'tools': ['plugin_tool'],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
'output': {'misc': {'at-sender': False}},
|
|
||||||
'trigger': {'misc': {}},
|
|
||||||
}
|
|
||||||
|
|
||||||
result = await stage.process(query, 'PreProcessor')
|
|
||||||
|
|
||||||
assert [tool.name for tool in result.new_query.use_funcs] == ['plugin_tool']
|
|
||||||
|
|||||||
@@ -1,538 +0,0 @@
|
|||||||
import pytest
|
|
||||||
import aiocqhttp
|
|
||||||
|
|
||||||
import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
|
||||||
from langbot.pkg.platform.sources.aiocqhttp import (
|
|
||||||
AiocqhttpAdapter,
|
|
||||||
AiocqhttpEventConverter,
|
|
||||||
AiocqhttpMessageConverter,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
async def _convert_single(component: platform_message.MessageComponent):
|
|
||||||
chain = platform_message.MessageChain([component])
|
|
||||||
message, _, _ = await AiocqhttpMessageConverter.yiri2target(chain)
|
|
||||||
return message[0]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
('payload', 'expected'),
|
|
||||||
[
|
|
||||||
('data:image/jpeg;base64,raw-image', 'base64://raw-image'),
|
|
||||||
('raw-image', 'base64://raw-image'),
|
|
||||||
('base64://raw-image', 'base64://raw-image'),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
async def test_image_base64_payload_is_normalized(payload, expected):
|
|
||||||
segment = await _convert_single(platform_message.Image(base64=payload))
|
|
||||||
|
|
||||||
assert segment.type == 'image'
|
|
||||||
assert segment.data['file'] == expected
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_voice_data_uri_base64_payload_is_normalized():
|
|
||||||
segment = await _convert_single(platform_message.Voice(base64='data:audio/wav;base64,raw-voice'))
|
|
||||||
|
|
||||||
assert segment.type == 'record'
|
|
||||||
assert segment.data['file'] == 'base64://raw-voice'
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
@pytest.mark.parametrize(
|
|
||||||
('component', 'expected'),
|
|
||||||
[
|
|
||||||
(
|
|
||||||
platform_message.File(name='report.txt', base64='data:text/plain;base64,raw-file'),
|
|
||||||
{'file': 'base64://raw-file', 'name': 'report.txt'},
|
|
||||||
),
|
|
||||||
(
|
|
||||||
platform_message.File(name='report.txt', base64='raw-file'),
|
|
||||||
{'file': 'base64://raw-file', 'name': 'report.txt'},
|
|
||||||
),
|
|
||||||
(
|
|
||||||
platform_message.File(name='a.txt', url='http://example.com/a.txt'),
|
|
||||||
{'file': 'http://example.com/a.txt', 'name': 'a.txt'},
|
|
||||||
),
|
|
||||||
(
|
|
||||||
platform_message.File(name='a.txt', path='/tmp/a.txt'),
|
|
||||||
{'file': '/tmp/a.txt', 'name': 'a.txt'},
|
|
||||||
),
|
|
||||||
],
|
|
||||||
)
|
|
||||||
async def test_file_message_uses_available_file_source(component, expected):
|
|
||||||
segment = await _convert_single(component)
|
|
||||||
|
|
||||||
assert segment.type == 'file'
|
|
||||||
assert segment.data == expected
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_forward_image_base64_payload_is_normalized():
|
|
||||||
forward = platform_message.Forward(
|
|
||||||
node_list=[
|
|
||||||
platform_message.ForwardMessageNode(
|
|
||||||
sender_id='10001',
|
|
||||||
sender_name='Tester',
|
|
||||||
message_chain=platform_message.MessageChain(
|
|
||||||
[platform_message.Image(base64='data:image/png;base64,raw-forward-image')]
|
|
||||||
),
|
|
||||||
)
|
|
||||||
]
|
|
||||||
)
|
|
||||||
messages = []
|
|
||||||
|
|
||||||
class Logger:
|
|
||||||
async def info(self, _message):
|
|
||||||
return None
|
|
||||||
|
|
||||||
async def error(self, _message):
|
|
||||||
return None
|
|
||||||
|
|
||||||
class Bot:
|
|
||||||
async def call_action(self, action, **kwargs):
|
|
||||||
assert action == 'send_forward_msg'
|
|
||||||
messages.append(kwargs)
|
|
||||||
|
|
||||||
platform = AiocqhttpAdapter.model_construct(
|
|
||||||
bot_account_id='10000',
|
|
||||||
config={},
|
|
||||||
logger=Logger(),
|
|
||||||
bot=Bot(),
|
|
||||||
)
|
|
||||||
|
|
||||||
await platform._send_forward_message(1000, forward)
|
|
||||||
|
|
||||||
assert messages[0]['messages'][0]['data']['content'][0] == {
|
|
||||||
'type': 'image',
|
|
||||||
'data': {'file': 'base64://raw-forward-image'},
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_group_message_member_name_prefers_group_card():
|
|
||||||
event = aiocqhttp.Event(
|
|
||||||
{
|
|
||||||
'post_type': 'message',
|
|
||||||
'message_type': 'group',
|
|
||||||
'message_id': 1000,
|
|
||||||
'message': '',
|
|
||||||
'time': 1776491725,
|
|
||||||
'group_id': 2000,
|
|
||||||
'sender': {
|
|
||||||
'user_id': 3000,
|
|
||||||
'nickname': 'QQ Nickname',
|
|
||||||
'card': 'Group Card',
|
|
||||||
'role': 'member',
|
|
||||||
'title': 'Special Title',
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
class Bot:
|
|
||||||
async def get_group_info(self, group_id):
|
|
||||||
assert group_id == 2000
|
|
||||||
return {'group_id': group_id, 'group_name': 'Test Group'}
|
|
||||||
|
|
||||||
converted = await AiocqhttpEventConverter().target2yiri(event, Bot())
|
|
||||||
|
|
||||||
assert converted.sender.member_name == 'Group Card'
|
|
||||||
assert converted.sender.group.id == 2000
|
|
||||||
assert converted.sender.group.name == 'Test Group'
|
|
||||||
assert converted.sender.special_title == 'Special Title'
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_group_message_member_name_falls_back_to_nickname():
|
|
||||||
event = aiocqhttp.Event(
|
|
||||||
{
|
|
||||||
'post_type': 'message',
|
|
||||||
'message_type': 'group',
|
|
||||||
'message_id': 1000,
|
|
||||||
'message': '',
|
|
||||||
'time': 1776491725,
|
|
||||||
'group_id': 2000,
|
|
||||||
'sender': {
|
|
||||||
'user_id': 3000,
|
|
||||||
'nickname': 'QQ Nickname',
|
|
||||||
'card': '',
|
|
||||||
'role': 'member',
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
converted = await AiocqhttpEventConverter().target2yiri(event)
|
|
||||||
|
|
||||||
assert converted.sender.member_name == 'QQ Nickname'
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_group_message_special_title_uses_group_member_info_when_sender_title_is_empty():
|
|
||||||
event = aiocqhttp.Event(
|
|
||||||
{
|
|
||||||
'post_type': 'message',
|
|
||||||
'message_type': 'group',
|
|
||||||
'message_id': 1000,
|
|
||||||
'message': '',
|
|
||||||
'time': 1776491725,
|
|
||||||
'group_id': 2000,
|
|
||||||
'sender': {
|
|
||||||
'user_id': 3000,
|
|
||||||
'nickname': 'QQ Nickname',
|
|
||||||
'card': 'Group Card',
|
|
||||||
'role': 'member',
|
|
||||||
'title': '',
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
class Bot:
|
|
||||||
async def get_group_info(self, group_id):
|
|
||||||
return {'group_id': group_id, 'group_name': 'Test Group'}
|
|
||||||
|
|
||||||
async def get_group_member_info(self, group_id, user_id):
|
|
||||||
assert group_id == 2000
|
|
||||||
assert user_id == 3000
|
|
||||||
return {'group_id': group_id, 'user_id': user_id, 'title': 'Member Title'}
|
|
||||||
|
|
||||||
converted = await AiocqhttpEventConverter().target2yiri(event, Bot())
|
|
||||||
|
|
||||||
assert converted.sender.special_title == 'Member Title'
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_group_message_special_title_does_not_lookup_when_sender_title_exists():
|
|
||||||
event = aiocqhttp.Event(
|
|
||||||
{
|
|
||||||
'post_type': 'message',
|
|
||||||
'message_type': 'group',
|
|
||||||
'message_id': 1000,
|
|
||||||
'message': '',
|
|
||||||
'time': 1776491725,
|
|
||||||
'group_id': 2000,
|
|
||||||
'sender': {
|
|
||||||
'user_id': 3000,
|
|
||||||
'nickname': 'QQ Nickname',
|
|
||||||
'card': 'Group Card',
|
|
||||||
'role': 'member',
|
|
||||||
'title': 'Event Title',
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
class Bot:
|
|
||||||
async def get_group_info(self, group_id):
|
|
||||||
return {'group_id': group_id, 'group_name': 'Test Group'}
|
|
||||||
|
|
||||||
async def get_group_member_info(self, group_id, user_id):
|
|
||||||
raise AssertionError('get_group_member_info should not be called')
|
|
||||||
|
|
||||||
converted = await AiocqhttpEventConverter().target2yiri(event, Bot())
|
|
||||||
|
|
||||||
assert converted.sender.special_title == 'Event Title'
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_group_message_special_title_member_info_failure_is_cached(monkeypatch):
|
|
||||||
event = aiocqhttp.Event(
|
|
||||||
{
|
|
||||||
'post_type': 'message',
|
|
||||||
'message_type': 'group',
|
|
||||||
'message_id': 1000,
|
|
||||||
'message': '',
|
|
||||||
'time': 1776491725,
|
|
||||||
'group_id': 2000,
|
|
||||||
'sender': {
|
|
||||||
'user_id': 3000,
|
|
||||||
'nickname': 'QQ Nickname',
|
|
||||||
'card': 'Group Card',
|
|
||||||
'role': 'member',
|
|
||||||
'title': '',
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
now = 1000.0
|
|
||||||
|
|
||||||
class Bot:
|
|
||||||
member_info_calls = 0
|
|
||||||
|
|
||||||
async def get_group_info(self, group_id):
|
|
||||||
return {'group_id': group_id, 'group_name': 'Test Group'}
|
|
||||||
|
|
||||||
async def get_group_member_info(self, group_id, user_id):
|
|
||||||
self.member_info_calls += 1
|
|
||||||
raise RuntimeError('api unavailable')
|
|
||||||
|
|
||||||
monkeypatch.setattr('langbot.pkg.platform.sources.aiocqhttp.time.monotonic', lambda: now)
|
|
||||||
|
|
||||||
bot = Bot()
|
|
||||||
converter = AiocqhttpEventConverter()
|
|
||||||
|
|
||||||
first = await converter.target2yiri(event, bot)
|
|
||||||
second = await converter.target2yiri(event, bot)
|
|
||||||
|
|
||||||
assert first.sender.special_title == ''
|
|
||||||
assert second.sender.special_title == ''
|
|
||||||
assert bot.member_info_calls == 1
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_group_message_special_title_member_info_cache_expires(monkeypatch):
|
|
||||||
event = aiocqhttp.Event(
|
|
||||||
{
|
|
||||||
'post_type': 'message',
|
|
||||||
'message_type': 'group',
|
|
||||||
'message_id': 1000,
|
|
||||||
'message': '',
|
|
||||||
'time': 1776491725,
|
|
||||||
'group_id': 2000,
|
|
||||||
'sender': {
|
|
||||||
'user_id': 3000,
|
|
||||||
'nickname': 'QQ Nickname',
|
|
||||||
'card': 'Group Card',
|
|
||||||
'role': 'member',
|
|
||||||
'title': '',
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
now = 1000.0
|
|
||||||
|
|
||||||
class Bot:
|
|
||||||
member_info_calls = 0
|
|
||||||
|
|
||||||
async def get_group_info(self, group_id):
|
|
||||||
return {'group_id': group_id, 'group_name': 'Test Group'}
|
|
||||||
|
|
||||||
async def get_group_member_info(self, group_id, user_id):
|
|
||||||
self.member_info_calls += 1
|
|
||||||
return {
|
|
||||||
'group_id': group_id,
|
|
||||||
'user_id': user_id,
|
|
||||||
'title': f'Member Title {self.member_info_calls}',
|
|
||||||
}
|
|
||||||
|
|
||||||
monkeypatch.setattr('langbot.pkg.platform.sources.aiocqhttp.time.monotonic', lambda: now)
|
|
||||||
|
|
||||||
bot = Bot()
|
|
||||||
converter = AiocqhttpEventConverter()
|
|
||||||
|
|
||||||
first = await converter.target2yiri(event, bot)
|
|
||||||
now = 87401.0
|
|
||||||
second = await converter.target2yiri(event, bot)
|
|
||||||
|
|
||||||
assert first.sender.special_title == 'Member Title 1'
|
|
||||||
assert second.sender.special_title == 'Member Title 2'
|
|
||||||
assert bot.member_info_calls == 2
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_group_message_special_title_retries_after_negative_cache_expires(monkeypatch):
|
|
||||||
event = aiocqhttp.Event(
|
|
||||||
{
|
|
||||||
'post_type': 'message',
|
|
||||||
'message_type': 'group',
|
|
||||||
'message_id': 1000,
|
|
||||||
'message': '',
|
|
||||||
'time': 1776491725,
|
|
||||||
'group_id': 2000,
|
|
||||||
'sender': {
|
|
||||||
'user_id': 3000,
|
|
||||||
'nickname': 'QQ Nickname',
|
|
||||||
'card': 'Group Card',
|
|
||||||
'role': 'member',
|
|
||||||
'title': '',
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
now = 1000.0
|
|
||||||
|
|
||||||
class Bot:
|
|
||||||
member_info_calls = 0
|
|
||||||
|
|
||||||
async def get_group_info(self, group_id):
|
|
||||||
return {'group_id': group_id, 'group_name': 'Test Group'}
|
|
||||||
|
|
||||||
async def get_group_member_info(self, group_id, user_id):
|
|
||||||
self.member_info_calls += 1
|
|
||||||
if self.member_info_calls == 1:
|
|
||||||
raise RuntimeError('api unavailable')
|
|
||||||
return {'group_id': group_id, 'user_id': user_id, 'title': 'Recovered Title'}
|
|
||||||
|
|
||||||
monkeypatch.setattr('langbot.pkg.platform.sources.aiocqhttp.time.monotonic', lambda: now)
|
|
||||||
|
|
||||||
bot = Bot()
|
|
||||||
converter = AiocqhttpEventConverter()
|
|
||||||
|
|
||||||
failed = await converter.target2yiri(event, bot)
|
|
||||||
now = 1601.0
|
|
||||||
recovered = await converter.target2yiri(event, bot)
|
|
||||||
|
|
||||||
assert failed.sender.special_title == ''
|
|
||||||
assert recovered.sender.special_title == 'Recovered Title'
|
|
||||||
assert bot.member_info_calls == 2
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_group_message_group_name_is_cached(monkeypatch):
|
|
||||||
event = aiocqhttp.Event(
|
|
||||||
{
|
|
||||||
'post_type': 'message',
|
|
||||||
'message_type': 'group',
|
|
||||||
'message_id': 1000,
|
|
||||||
'message': '',
|
|
||||||
'time': 1776491725,
|
|
||||||
'group_id': 2000,
|
|
||||||
'sender': {
|
|
||||||
'user_id': 3000,
|
|
||||||
'nickname': 'QQ Nickname',
|
|
||||||
'card': 'Group Card',
|
|
||||||
'role': 'member',
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
class Bot:
|
|
||||||
calls = 0
|
|
||||||
|
|
||||||
async def get_group_info(self, group_id):
|
|
||||||
self.calls += 1
|
|
||||||
assert group_id == 2000
|
|
||||||
return {'group_id': group_id, 'group_name': 'Cached Group'}
|
|
||||||
|
|
||||||
monotonic = 1000.0
|
|
||||||
monkeypatch.setattr('langbot.pkg.platform.sources.aiocqhttp.time.monotonic', lambda: monotonic)
|
|
||||||
|
|
||||||
bot = Bot()
|
|
||||||
converter = AiocqhttpEventConverter()
|
|
||||||
|
|
||||||
first = await converter.target2yiri(event, bot)
|
|
||||||
second = await converter.target2yiri(event, bot)
|
|
||||||
|
|
||||||
assert first.sender.group.name == 'Cached Group'
|
|
||||||
assert second.sender.group.name == 'Cached Group'
|
|
||||||
assert bot.calls == 1
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_group_message_group_name_cache_expires(monkeypatch):
|
|
||||||
event = aiocqhttp.Event(
|
|
||||||
{
|
|
||||||
'post_type': 'message',
|
|
||||||
'message_type': 'group',
|
|
||||||
'message_id': 1000,
|
|
||||||
'message': '',
|
|
||||||
'time': 1776491725,
|
|
||||||
'group_id': 2000,
|
|
||||||
'sender': {
|
|
||||||
'user_id': 3000,
|
|
||||||
'nickname': 'QQ Nickname',
|
|
||||||
'card': 'Group Card',
|
|
||||||
'role': 'member',
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
now = 1000.0
|
|
||||||
|
|
||||||
class Bot:
|
|
||||||
calls = 0
|
|
||||||
|
|
||||||
async def get_group_info(self, group_id):
|
|
||||||
self.calls += 1
|
|
||||||
return {'group_id': group_id, 'group_name': f'Group Name {self.calls}'}
|
|
||||||
|
|
||||||
monkeypatch.setattr('langbot.pkg.platform.sources.aiocqhttp.time.monotonic', lambda: now)
|
|
||||||
|
|
||||||
bot = Bot()
|
|
||||||
converter = AiocqhttpEventConverter()
|
|
||||||
|
|
||||||
first = await converter.target2yiri(event, bot)
|
|
||||||
now = 4601.0
|
|
||||||
second = await converter.target2yiri(event, bot)
|
|
||||||
|
|
||||||
assert first.sender.group.name == 'Group Name 1'
|
|
||||||
assert second.sender.group.name == 'Group Name 2'
|
|
||||||
assert bot.calls == 2
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_group_message_group_name_uses_placeholder_when_lookup_fails(monkeypatch):
|
|
||||||
event = aiocqhttp.Event(
|
|
||||||
{
|
|
||||||
'post_type': 'message',
|
|
||||||
'message_type': 'group',
|
|
||||||
'message_id': 1000,
|
|
||||||
'message': '',
|
|
||||||
'time': 1776491725,
|
|
||||||
'group_id': 2000,
|
|
||||||
'sender': {
|
|
||||||
'user_id': 3000,
|
|
||||||
'nickname': 'QQ Nickname',
|
|
||||||
'card': 'Group Card',
|
|
||||||
'role': 'member',
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
now = 1000.0
|
|
||||||
|
|
||||||
class Bot:
|
|
||||||
calls = 0
|
|
||||||
|
|
||||||
async def get_group_info(self, group_id):
|
|
||||||
self.calls += 1
|
|
||||||
raise RuntimeError('api unavailable')
|
|
||||||
|
|
||||||
monkeypatch.setattr('langbot.pkg.platform.sources.aiocqhttp.time.monotonic', lambda: now)
|
|
||||||
|
|
||||||
bot = Bot()
|
|
||||||
converter = AiocqhttpEventConverter()
|
|
||||||
|
|
||||||
converted = await converter.target2yiri(event, bot)
|
|
||||||
cached_failure = await converter.target2yiri(event, bot)
|
|
||||||
|
|
||||||
assert converted.sender.group.name == 'Group 2000'
|
|
||||||
assert cached_failure.sender.group.name == 'Group 2000'
|
|
||||||
assert bot.calls == 1
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_group_message_group_name_retries_after_negative_cache_expires(monkeypatch):
|
|
||||||
event = aiocqhttp.Event(
|
|
||||||
{
|
|
||||||
'post_type': 'message',
|
|
||||||
'message_type': 'group',
|
|
||||||
'message_id': 1000,
|
|
||||||
'message': '',
|
|
||||||
'time': 1776491725,
|
|
||||||
'group_id': 2000,
|
|
||||||
'sender': {
|
|
||||||
'user_id': 3000,
|
|
||||||
'nickname': 'QQ Nickname',
|
|
||||||
'card': 'Group Card',
|
|
||||||
'role': 'member',
|
|
||||||
},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
now = 1000.0
|
|
||||||
|
|
||||||
class Bot:
|
|
||||||
calls = 0
|
|
||||||
|
|
||||||
async def get_group_info(self, group_id):
|
|
||||||
self.calls += 1
|
|
||||||
if self.calls == 1:
|
|
||||||
raise RuntimeError('api unavailable')
|
|
||||||
return {'group_id': group_id, 'group_name': 'Recovered Group'}
|
|
||||||
|
|
||||||
monkeypatch.setattr('langbot.pkg.platform.sources.aiocqhttp.time.monotonic', lambda: now)
|
|
||||||
|
|
||||||
bot = Bot()
|
|
||||||
converter = AiocqhttpEventConverter()
|
|
||||||
|
|
||||||
failed = await converter.target2yiri(event, bot)
|
|
||||||
now = 1061.0
|
|
||||||
recovered = await converter.target2yiri(event, bot)
|
|
||||||
|
|
||||||
assert failed.sender.group.name == 'Group 2000'
|
|
||||||
assert recovered.sender.group.name == 'Recovered Group'
|
|
||||||
assert bot.calls == 2
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
from types import SimpleNamespace
|
|
||||||
from unittest.mock import AsyncMock
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
|
|
||||||
import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_platform_logger
|
|
||||||
import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
|
||||||
from langbot.pkg.platform.sources.wecomcs import WecomCSAdapter
|
|
||||||
|
|
||||||
|
|
||||||
class DummyLogger(abstract_platform_logger.AbstractEventLogger):
|
|
||||||
async def info(self, *args, **kwargs):
|
|
||||||
pass
|
|
||||||
|
|
||||||
async def debug(self, *args, **kwargs):
|
|
||||||
pass
|
|
||||||
|
|
||||||
async def warning(self, *args, **kwargs):
|
|
||||||
pass
|
|
||||||
|
|
||||||
async def error(self, *args, **kwargs):
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def make_adapter():
|
|
||||||
return WecomCSAdapter(
|
|
||||||
config={
|
|
||||||
'corpid': 'corp-id',
|
|
||||||
'secret': 'secret',
|
|
||||||
'token': 'token',
|
|
||||||
'EncodingAESKey': 'encoding-key',
|
|
||||||
},
|
|
||||||
logger=DummyLogger(),
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_send_message_sends_text_to_customer_service_user():
|
|
||||||
adapter = make_adapter()
|
|
||||||
adapter.bot_account_id = 'kf-test'
|
|
||||||
adapter.bot = SimpleNamespace(send_text_msg=AsyncMock())
|
|
||||||
|
|
||||||
message = platform_message.MessageChain([platform_message.Plain(text='hello')])
|
|
||||||
|
|
||||||
await adapter.send_message('person', 'uexternal-user', message)
|
|
||||||
|
|
||||||
adapter.bot.send_text_msg.assert_awaited_once()
|
|
||||||
kwargs = adapter.bot.send_text_msg.await_args.kwargs
|
|
||||||
assert kwargs['open_kfid'] == 'kf-test'
|
|
||||||
assert kwargs['external_userid'] == 'external-user'
|
|
||||||
assert kwargs['content'] == 'hello'
|
|
||||||
assert kwargs['msgid'].startswith('langbot_')
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_send_message_allows_explicit_open_kfid_in_target_id():
|
|
||||||
adapter = make_adapter()
|
|
||||||
adapter.bot = SimpleNamespace(send_text_msg=AsyncMock())
|
|
||||||
|
|
||||||
message = platform_message.MessageChain([platform_message.Plain(text='hello')])
|
|
||||||
|
|
||||||
await adapter.send_message('person', 'kf-explicit|uexternal-user', message)
|
|
||||||
|
|
||||||
kwargs = adapter.bot.send_text_msg.await_args.kwargs
|
|
||||||
assert kwargs['open_kfid'] == 'kf-explicit'
|
|
||||||
assert kwargs['external_userid'] == 'external-user'
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_send_message_requires_open_kfid():
|
|
||||||
adapter = make_adapter()
|
|
||||||
adapter.bot = SimpleNamespace(send_text_msg=AsyncMock())
|
|
||||||
message = platform_message.MessageChain([platform_message.Plain(text='hello')])
|
|
||||||
|
|
||||||
with pytest.raises(ValueError, match='open_kfid is required'):
|
|
||||||
await adapter.send_message('person', 'uexternal-user', message)
|
|
||||||
|
|
||||||
adapter.bot.send_text_msg.assert_not_called()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_send_message_rejects_group_targets():
|
|
||||||
adapter = make_adapter()
|
|
||||||
adapter.bot_account_id = 'kf-test'
|
|
||||||
adapter.bot = SimpleNamespace(send_text_msg=AsyncMock())
|
|
||||||
message = platform_message.MessageChain([platform_message.Plain(text='hello')])
|
|
||||||
|
|
||||||
with pytest.raises(ValueError, match='only supports sending messages to person'):
|
|
||||||
await adapter.send_message('group', 'group-id', message)
|
|
||||||
|
|
||||||
adapter.bot.send_text_msg.assert_not_called()
|
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from langbot.pkg.plugin.connector import PluginRuntimeConnector
|
from src.langbot.pkg.plugin.connector import PluginRuntimeConnector
|
||||||
|
|
||||||
|
|
||||||
def test_parse_plugin_id_accepts_author_name():
|
def test_parse_plugin_id_accepts_author_name():
|
||||||
|
|||||||
@@ -639,13 +639,10 @@ class TestGetRuntimeInfoDict:
|
|||||||
assert info['box_session_id'] == 'mcp-shared'
|
assert info['box_session_id'] == 'mcp-shared'
|
||||||
assert info['box_enabled'] is True
|
assert info['box_enabled'] is True
|
||||||
|
|
||||||
def test_transient_test_shares_session_but_isolated_by_process(self, mcp_module):
|
def test_transient_test_session_is_isolated_from_shared(self, mcp_module):
|
||||||
"""A transient config-page "test" now shares the same 'mcp-shared' Box
|
"""A transient test session (config-page "test", no persisted UUID)
|
||||||
session as live servers (so a test reuses the running container / live
|
must NOT share the live "mcp-shared" Box session. Regression: a failing
|
||||||
process instead of a cold per-test session bootstrap). Isolation is at
|
test churned the shared session and tore down healthy live servers."""
|
||||||
the PROCESS level: the test runs under its own process_id and only ever
|
|
||||||
stops that process_id, so it cannot disturb another server's live
|
|
||||||
process or the shared session itself."""
|
|
||||||
ap = _make_ap()
|
ap = _make_ap()
|
||||||
ap.box_service.available = True
|
ap.box_service.available = True
|
||||||
transient = _make_session(
|
transient = _make_session(
|
||||||
@@ -673,12 +670,10 @@ class TestGetRuntimeInfoDict:
|
|||||||
)
|
)
|
||||||
assert transient.is_transient is True
|
assert transient.is_transient is True
|
||||||
assert live.is_transient is False
|
assert live.is_transient is False
|
||||||
# Both share ONE Box session ...
|
# Isolated session id for the test, shared for the live server.
|
||||||
assert transient._build_box_session_id() == 'mcp-shared'
|
assert transient._build_box_session_id() == 'mcp-test-gen-uuid-123'
|
||||||
assert live._build_box_session_id() == 'mcp-shared'
|
assert live._build_box_session_id() == 'mcp-shared'
|
||||||
assert transient._build_box_session_id() == live._build_box_session_id()
|
assert transient._build_box_session_id() != live._build_box_session_id()
|
||||||
# ... 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_stdio_session_refuses_when_box_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
|
"""Policy: when Box is configured but unavailable (disabled in config
|
||||||
@@ -829,129 +824,3 @@ async def test_init_box_stdio_server_stages_host_path_in_shared_workspace(mcp_mo
|
|||||||
assert process_payload['command'] == 'python'
|
assert process_payload['command'] == 'python'
|
||||||
assert process_payload['args'] == ['/workspace/.mcp/u1/workspace/server.py']
|
assert process_payload['args'] == ['/workspace/.mcp/u1/workspace/server.py']
|
||||||
assert process_payload['cwd'] == '/workspace/.mcp/u1/workspace'
|
assert process_payload['cwd'] == '/workspace/.mcp/u1/workspace'
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_stdio_handshake_raises_coldstart_retry_while_process_alive(mcp_module, tmp_path, monkeypatch):
|
|
||||||
"""During a slow (npx) cold start the handshake fails while the managed
|
|
||||||
process is still alive. initialize() must raise _ColdStartRetry (so the
|
|
||||||
outer lifecycle loop reuses the live process and retries without stopping it
|
|
||||||
or consuming the fatal budget), NOT a fatal error."""
|
|
||||||
from contextlib import asynccontextmanager
|
|
||||||
|
|
||||||
mcp_stdio_module = sys.modules['langbot.pkg.provider.tools.loaders.mcp_stdio']
|
|
||||||
|
|
||||||
class ColdClientSession:
|
|
||||||
def __init__(self, *_args):
|
|
||||||
pass
|
|
||||||
|
|
||||||
async def __aenter__(self):
|
|
||||||
return self
|
|
||||||
|
|
||||||
async def __aexit__(self, exc_type, exc, tb):
|
|
||||||
return False
|
|
||||||
|
|
||||||
async def initialize(self):
|
|
||||||
# Process still cold-starting: handshake fails.
|
|
||||||
raise Exception('Connection closed')
|
|
||||||
|
|
||||||
@asynccontextmanager
|
|
||||||
async def fake_websocket_client(_url: str):
|
|
||||||
yield ('read-stream', 'write-stream')
|
|
||||||
|
|
||||||
monkeypatch.setattr(mcp_stdio_module, 'ClientSession', ColdClientSession)
|
|
||||||
monkeypatch.setattr(mcp_stdio_module, 'websocket_client', fake_websocket_client)
|
|
||||||
monkeypatch.setattr(mcp_stdio_module, '_HANDSHAKE_ATTEMPT_TIMEOUT_SEC', 1.0, raising=False)
|
|
||||||
|
|
||||||
ap = _make_ap()
|
|
||||||
ap.box_service.available = True
|
|
||||||
ap.box_service.create_session = AsyncMock(return_value={})
|
|
||||||
ap.box_service.start_managed_process = AsyncMock(return_value={})
|
|
||||||
ap.box_service.get_managed_process_websocket_url = Mock(return_value='ws://box/p')
|
|
||||||
|
|
||||||
session = _make_session(
|
|
||||||
mcp_module,
|
|
||||||
{
|
|
||||||
'name': 'slow',
|
|
||||||
'uuid': 'slow-uuid',
|
|
||||||
'mode': 'stdio',
|
|
||||||
'command': 'npx',
|
|
||||||
'args': ['-y', 'some-mcp'],
|
|
||||||
},
|
|
||||||
ap=ap,
|
|
||||||
)
|
|
||||||
|
|
||||||
# Process is NOT exited (still cold-starting) and not yet running for reuse.
|
|
||||||
async def _not_exited():
|
|
||||||
return False
|
|
||||||
|
|
||||||
session._box_stdio_runtime._managed_process_has_exited = _not_exited
|
|
||||||
|
|
||||||
async def _not_running():
|
|
||||||
return False
|
|
||||||
|
|
||||||
session._box_stdio_runtime._managed_process_is_running = _not_running
|
|
||||||
|
|
||||||
with pytest.raises(mcp_stdio_module._ColdStartRetry):
|
|
||||||
await session._init_box_stdio_server()
|
|
||||||
|
|
||||||
# Process was started exactly once (the retry will reuse it, not rebuild).
|
|
||||||
assert ap.box_service.start_managed_process.await_count == 1
|
|
||||||
await session.exit_stack.aclose()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_stdio_handshake_raises_fatal_when_process_exited(mcp_module, tmp_path, monkeypatch):
|
|
||||||
"""If the handshake fails AND the process has definitively exited, that is a
|
|
||||||
real failure — initialize() must NOT swallow it as a cold-start retry."""
|
|
||||||
from contextlib import asynccontextmanager
|
|
||||||
|
|
||||||
mcp_stdio_module = sys.modules['langbot.pkg.provider.tools.loaders.mcp_stdio']
|
|
||||||
|
|
||||||
class DeadClientSession:
|
|
||||||
def __init__(self, *_args):
|
|
||||||
pass
|
|
||||||
|
|
||||||
async def __aenter__(self):
|
|
||||||
return self
|
|
||||||
|
|
||||||
async def __aexit__(self, exc_type, exc, tb):
|
|
||||||
return False
|
|
||||||
|
|
||||||
async def initialize(self):
|
|
||||||
raise Exception('Connection closed')
|
|
||||||
|
|
||||||
@asynccontextmanager
|
|
||||||
async def fake_websocket_client(_url: str):
|
|
||||||
yield ('read-stream', 'write-stream')
|
|
||||||
|
|
||||||
monkeypatch.setattr(mcp_stdio_module, 'ClientSession', DeadClientSession)
|
|
||||||
monkeypatch.setattr(mcp_stdio_module, 'websocket_client', fake_websocket_client)
|
|
||||||
monkeypatch.setattr(mcp_stdio_module, '_HANDSHAKE_ATTEMPT_TIMEOUT_SEC', 1.0, raising=False)
|
|
||||||
|
|
||||||
ap = _make_ap()
|
|
||||||
ap.box_service.available = True
|
|
||||||
ap.box_service.create_session = AsyncMock(return_value={})
|
|
||||||
ap.box_service.start_managed_process = AsyncMock(return_value={})
|
|
||||||
ap.box_service.get_managed_process_websocket_url = Mock(return_value='ws://box/p')
|
|
||||||
|
|
||||||
session = _make_session(
|
|
||||||
mcp_module,
|
|
||||||
{'name': 'dead', 'uuid': 'dead-uuid', 'mode': 'stdio', 'command': 'npx', 'args': ['-y', 'x']},
|
|
||||||
ap=ap,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def _exited():
|
|
||||||
return True
|
|
||||||
|
|
||||||
session._box_stdio_runtime._managed_process_has_exited = _exited
|
|
||||||
|
|
||||||
async def _not_running():
|
|
||||||
return False
|
|
||||||
|
|
||||||
session._box_stdio_runtime._managed_process_is_running = _not_running
|
|
||||||
|
|
||||||
with pytest.raises(Exception) as ei:
|
|
||||||
await session._init_box_stdio_server()
|
|
||||||
assert not isinstance(ei.value, mcp_stdio_module._ColdStartRetry)
|
|
||||||
await session.exit_stack.aclose()
|
|
||||||
|
|||||||
@@ -1,278 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import base64
|
|
||||||
from types import SimpleNamespace
|
|
||||||
from unittest.mock import AsyncMock, Mock
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
from mcp import types as mcp_types
|
|
||||||
|
|
||||||
from langbot.pkg.provider.tools.loaders.mcp import (
|
|
||||||
MCP_RESOURCE_CONTEXT_QUERY_KEY,
|
|
||||||
MCP_RESOURCE_TRACE_QUERY_KEY,
|
|
||||||
MCP_TOOL_LIST_RESOURCES,
|
|
||||||
MCP_TOOL_READ_RESOURCE,
|
|
||||||
MCPLoader,
|
|
||||||
MCPSessionStatus,
|
|
||||||
RuntimeMCPSession,
|
|
||||||
)
|
|
||||||
from langbot.pkg.telemetry import features as telemetry_features
|
|
||||||
|
|
||||||
|
|
||||||
def _app() -> SimpleNamespace:
|
|
||||||
return SimpleNamespace(logger=Mock())
|
|
||||||
|
|
||||||
|
|
||||||
def _connected_session(
|
|
||||||
*,
|
|
||||||
name: str = 'docs',
|
|
||||||
uuid: str = 'srv-1',
|
|
||||||
resources: list[dict] | None = None,
|
|
||||||
templates: list[dict] | None = None,
|
|
||||||
) -> RuntimeMCPSession:
|
|
||||||
session = RuntimeMCPSession(name, {'uuid': uuid, 'mode': 'remote'}, True, _app())
|
|
||||||
session.status = MCPSessionStatus.CONNECTED
|
|
||||||
session.session = SimpleNamespace(read_resource=AsyncMock())
|
|
||||||
session.resources = resources or [
|
|
||||||
{
|
|
||||||
'uri': 'file:///README.md',
|
|
||||||
'name': 'README.md',
|
|
||||||
'title': '',
|
|
||||||
'description': '',
|
|
||||||
'mime_type': 'text/markdown',
|
|
||||||
'size': None,
|
|
||||||
'icons': [],
|
|
||||||
'annotations': {},
|
|
||||||
'_meta': {},
|
|
||||||
}
|
|
||||||
]
|
|
||||||
session.resource_templates = templates or []
|
|
||||||
return session
|
|
||||||
|
|
||||||
|
|
||||||
def _query() -> SimpleNamespace:
|
|
||||||
return SimpleNamespace(variables={})
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_read_resource_envelope_truncates_caches_and_records_trace():
|
|
||||||
session = _connected_session()
|
|
||||||
session.session.read_resource.return_value = mcp_types.ReadResourceResult(
|
|
||||||
contents=[
|
|
||||||
mcp_types.TextResourceContents(
|
|
||||||
uri='file:///README.md',
|
|
||||||
mimeType='text/markdown',
|
|
||||||
text='abcdef',
|
|
||||||
)
|
|
||||||
]
|
|
||||||
)
|
|
||||||
query = _query()
|
|
||||||
|
|
||||||
first = await session.read_resource_envelope(
|
|
||||||
'file:///README.md',
|
|
||||||
max_bytes=4,
|
|
||||||
source='ui_preview',
|
|
||||||
query=query,
|
|
||||||
)
|
|
||||||
second = await session.read_resource_envelope(
|
|
||||||
'file:///README.md',
|
|
||||||
max_bytes=4,
|
|
||||||
source='agent_tool',
|
|
||||||
query=query,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert first['contents'][0]['text'] == 'abcd'
|
|
||||||
assert first['contents'][0]['bytes'] == 6
|
|
||||||
assert first['truncated'] is True
|
|
||||||
assert first['cache_hit'] is False
|
|
||||||
assert second['cache_hit'] is True
|
|
||||||
assert second['source'] == 'agent_tool'
|
|
||||||
assert session.session.read_resource.await_count == 1
|
|
||||||
|
|
||||||
traces = query.variables[MCP_RESOURCE_TRACE_QUERY_KEY]
|
|
||||||
assert [trace['source'] for trace in traces] == ['ui_preview', 'agent_tool']
|
|
||||||
assert traces[1]['cache_hit'] is True
|
|
||||||
assert query.variables[telemetry_features.FEATURES_KEY]['mcp_resource_reads'] == {
|
|
||||||
'ui_preview': 1,
|
|
||||||
'agent_tool': 1,
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_read_resource_envelope_shares_byte_budget_across_text_contents():
|
|
||||||
session = _connected_session()
|
|
||||||
session.session.read_resource.return_value = mcp_types.ReadResourceResult(
|
|
||||||
contents=[
|
|
||||||
mcp_types.TextResourceContents(
|
|
||||||
uri='file:///README.md#first',
|
|
||||||
mimeType='text/plain',
|
|
||||||
text='abc',
|
|
||||||
),
|
|
||||||
mcp_types.TextResourceContents(
|
|
||||||
uri='file:///README.md#second',
|
|
||||||
mimeType='text/plain',
|
|
||||||
text='def',
|
|
||||||
),
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
envelope = await session.read_resource_envelope('file:///README.md', max_bytes=4)
|
|
||||||
|
|
||||||
assert [item['text'] for item in envelope['contents']] == ['abc', 'd']
|
|
||||||
assert envelope['contents'][0]['truncated'] is False
|
|
||||||
assert envelope['contents'][1]['truncated'] is True
|
|
||||||
assert envelope['bytes'] == 6
|
|
||||||
assert envelope['truncated'] is True
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_read_resource_envelope_omits_binary_by_default():
|
|
||||||
session = _connected_session(
|
|
||||||
resources=[
|
|
||||||
{
|
|
||||||
'uri': 'file:///image.png',
|
|
||||||
'name': 'image.png',
|
|
||||||
'title': '',
|
|
||||||
'description': '',
|
|
||||||
'mime_type': 'image/png',
|
|
||||||
'size': 4,
|
|
||||||
'icons': [],
|
|
||||||
'annotations': {},
|
|
||||||
'_meta': {},
|
|
||||||
}
|
|
||||||
]
|
|
||||||
)
|
|
||||||
session.session.read_resource.return_value = mcp_types.ReadResourceResult(
|
|
||||||
contents=[
|
|
||||||
mcp_types.BlobResourceContents(
|
|
||||||
uri='file:///image.png',
|
|
||||||
mimeType='image/png',
|
|
||||||
blob=base64.b64encode(b'\x00\x01\x02\x03').decode(),
|
|
||||||
)
|
|
||||||
]
|
|
||||||
)
|
|
||||||
|
|
||||||
envelope = await session.read_resource_envelope('file:///image.png')
|
|
||||||
|
|
||||||
content = envelope['contents'][0]
|
|
||||||
assert content['type'] == 'blob'
|
|
||||||
assert content['blob'] is None
|
|
||||||
assert content['bytes'] == 4
|
|
||||||
assert content['binary_omitted'] is True
|
|
||||||
assert envelope['truncated'] is True
|
|
||||||
assert envelope['warnings'] == ['Binary resource content omitted from response.']
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_read_resource_envelope_rejects_unlisted_uri():
|
|
||||||
session = _connected_session()
|
|
||||||
|
|
||||||
with pytest.raises(ValueError, match='Resource URI is not available'):
|
|
||||||
await session.read_resource_envelope('file:///secret.txt')
|
|
||||||
|
|
||||||
session.session.read_resource.assert_not_called()
|
|
||||||
|
|
||||||
|
|
||||||
def test_resource_uri_allowed_supports_listed_templates_conservatively():
|
|
||||||
session = _connected_session(
|
|
||||||
resources=[],
|
|
||||||
templates=[
|
|
||||||
{
|
|
||||||
'uri_template': 'repo://{owner}/{repo}/file/{path}',
|
|
||||||
'name': 'repository file',
|
|
||||||
'title': '',
|
|
||||||
'description': '',
|
|
||||||
'mime_type': 'text/plain',
|
|
||||||
'icons': [],
|
|
||||||
'annotations': {},
|
|
||||||
'_meta': {},
|
|
||||||
}
|
|
||||||
],
|
|
||||||
)
|
|
||||||
|
|
||||||
assert session.resource_uri_allowed('repo://langbot-app/LangBot/file/src/main.py') is True
|
|
||||||
assert session.resource_uri_allowed('repo://langbot-app/LangBot/issues/1') is False
|
|
||||||
assert session.resource_uri_allowed('https://example.com/secret') is False
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_mcp_loader_can_hide_synthetic_resource_tools():
|
|
||||||
loader = MCPLoader(_app())
|
|
||||||
session = _connected_session()
|
|
||||||
loader.sessions = {'docs': session}
|
|
||||||
|
|
||||||
with_resource_tools = await loader.get_tools(['srv-1'], include_resource_tools=True)
|
|
||||||
without_resource_tools = await loader.get_tools(['srv-1'], include_resource_tools=False)
|
|
||||||
|
|
||||||
assert {tool.name for tool in with_resource_tools} == {
|
|
||||||
MCP_TOOL_LIST_RESOURCES,
|
|
||||||
MCP_TOOL_READ_RESOURCE,
|
|
||||||
}
|
|
||||||
assert without_resource_tools == []
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_mcp_loader_refuses_resource_tool_calls_when_agent_read_disabled():
|
|
||||||
loader = MCPLoader(_app())
|
|
||||||
session = _connected_session()
|
|
||||||
loader.sessions = {'docs': session}
|
|
||||||
query = SimpleNamespace(
|
|
||||||
variables={
|
|
||||||
'_pipeline_bound_mcp_servers': ['srv-1'],
|
|
||||||
'_pipeline_mcp_resource_agent_read_enabled': False,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
result = await loader.invoke_tool(
|
|
||||||
MCP_TOOL_READ_RESOURCE,
|
|
||||||
{'server_name': 'docs', 'uri': 'file:///README.md'},
|
|
||||||
query,
|
|
||||||
)
|
|
||||||
|
|
||||||
assert result[0].text == 'Error: MCP resource agent reads are disabled.'
|
|
||||||
session.session.read_resource.assert_not_called()
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_build_resource_context_for_query_uses_only_bound_attached_text_resources():
|
|
||||||
loader = MCPLoader(_app())
|
|
||||||
docs = _connected_session(name='docs', uuid='srv-1')
|
|
||||||
docs.session.read_resource.return_value = mcp_types.ReadResourceResult(
|
|
||||||
contents=[
|
|
||||||
mcp_types.TextResourceContents(
|
|
||||||
uri='file:///README.md',
|
|
||||||
mimeType='text/markdown',
|
|
||||||
text='LangBot MCP resource context',
|
|
||||||
)
|
|
||||||
]
|
|
||||||
)
|
|
||||||
other = _connected_session(name='other', uuid='srv-2')
|
|
||||||
other.session.read_resource.return_value = mcp_types.ReadResourceResult(
|
|
||||||
contents=[
|
|
||||||
mcp_types.TextResourceContents(
|
|
||||||
uri='file:///README.md',
|
|
||||||
mimeType='text/markdown',
|
|
||||||
text='must not be injected',
|
|
||||||
)
|
|
||||||
]
|
|
||||||
)
|
|
||||||
loader.sessions = {'docs': docs, 'other': other}
|
|
||||||
query = SimpleNamespace(
|
|
||||||
variables={
|
|
||||||
'_pipeline_bound_mcp_servers': ['srv-1'],
|
|
||||||
'_pipeline_mcp_resource_attachments': [
|
|
||||||
{'server_uuid': 'srv-1', 'server_name': 'docs', 'uri': 'file:///README.md', 'mode': 'pinned'},
|
|
||||||
{'server_uuid': 'srv-2', 'server_name': 'other', 'uri': 'file:///README.md', 'mode': 'pinned'},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
context = await loader.build_resource_context_for_query(query)
|
|
||||||
|
|
||||||
assert '<mcp_resource ' in context
|
|
||||||
assert 'server="docs"' in context
|
|
||||||
assert 'LangBot MCP resource context' in context
|
|
||||||
assert 'must not be injected' not in context
|
|
||||||
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()
|
|
||||||
@@ -15,35 +15,13 @@ from langbot.pkg.provider.tools.toolmgr import ToolManager
|
|||||||
|
|
||||||
|
|
||||||
class StubLoader:
|
class StubLoader:
|
||||||
def __init__(
|
def __init__(self, tools: list[resource_tool.LLMTool] | None = None, invoke_result=None):
|
||||||
self,
|
|
||||||
tools: list[resource_tool.LLMTool] | None = None,
|
|
||||||
invoke_result=None,
|
|
||||||
catalog_source: str = 'mcp',
|
|
||||||
catalog_source_name: str = 'fixture-server',
|
|
||||||
):
|
|
||||||
self._tools = tools or []
|
self._tools = tools or []
|
||||||
self._invoke_result = invoke_result
|
self._invoke_result = invoke_result
|
||||||
self._catalog_source = catalog_source
|
|
||||||
self._catalog_source_name = catalog_source_name
|
|
||||||
|
|
||||||
async def get_tools(self, *_args, **_kwargs):
|
async def get_tools(self, *_args, **_kwargs):
|
||||||
return self._tools
|
return self._tools
|
||||||
|
|
||||||
async def get_tool_catalog(self, *_args, **_kwargs):
|
|
||||||
return [
|
|
||||||
{
|
|
||||||
'name': tool.name,
|
|
||||||
'description': tool.description,
|
|
||||||
'human_desc': tool.human_desc,
|
|
||||||
'parameters': tool.parameters,
|
|
||||||
'source': self._catalog_source,
|
|
||||||
'source_name': self._catalog_source_name,
|
|
||||||
'source_id': self._catalog_source_name,
|
|
||||||
}
|
|
||||||
for tool in self._tools
|
|
||||||
]
|
|
||||||
|
|
||||||
async def has_tool(self, name: str) -> bool:
|
async def has_tool(self, name: str) -> bool:
|
||||||
return any(tool.name == name for tool in self._tools)
|
return any(tool.name == name for tool in self._tools)
|
||||||
|
|
||||||
@@ -90,28 +68,6 @@ async def test_tool_manager_includes_skill_authoring_tools_when_requested():
|
|||||||
assert [tool.name for tool in tools] == ['exec', 'activate', 'plugin_tool', 'mcp_tool']
|
assert [tool.name for tool in tools] == ['exec', 'activate', 'plugin_tool', 'mcp_tool']
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_tool_manager_catalog_labels_tool_sources():
|
|
||||||
manager = ToolManager(SimpleNamespace())
|
|
||||||
manager.native_tool_loader = StubLoader([make_tool('exec')])
|
|
||||||
manager.skill_tool_loader = StubLoader([make_tool('activate')])
|
|
||||||
manager.plugin_tool_loader = StubLoader(
|
|
||||||
[make_tool('plugin_tool')],
|
|
||||||
catalog_source='plugin',
|
|
||||||
catalog_source_name='fixture-plugin',
|
|
||||||
)
|
|
||||||
manager.mcp_tool_loader = StubLoader([make_tool('mcp_tool')])
|
|
||||||
|
|
||||||
catalog = await manager.get_tool_catalog(include_skill_authoring=True)
|
|
||||||
|
|
||||||
assert [(item['name'], item['source'], item['source_name']) for item in catalog] == [
|
|
||||||
('exec', 'builtin', 'LangBot'),
|
|
||||||
('activate', 'skill', 'LangBot'),
|
|
||||||
('plugin_tool', 'plugin', 'fixture-plugin'),
|
|
||||||
('mcp_tool', 'mcp', 'fixture-server'),
|
|
||||||
]
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_tool_manager_routes_native_tool_calls():
|
async def test_tool_manager_routes_native_tool_calls():
|
||||||
app = SimpleNamespace()
|
app = SimpleNamespace()
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from langbot.pkg.utils import paths
|
from src.langbot.pkg.utils import paths
|
||||||
|
|
||||||
|
|
||||||
def test_get_data_root_uses_source_root_in_repo_checkout():
|
def test_get_data_root_uses_source_root_in_repo_checkout():
|
||||||
|
|||||||
@@ -118,12 +118,7 @@ async def test_preproc_enables_skill_authoring_tools_when_skill_service_availabl
|
|||||||
result = await stage.process(_make_query(), 'PreProcessor')
|
result = await stage.process(_make_query(), 'PreProcessor')
|
||||||
|
|
||||||
assert result.result_type == entities_module.ResultType.CONTINUE
|
assert result.result_type == entities_module.ResultType.CONTINUE
|
||||||
app.tool_mgr.get_all_tools.assert_awaited_once_with(
|
app.tool_mgr.get_all_tools.assert_awaited_once_with(None, None, include_skill_authoring=True)
|
||||||
None,
|
|
||||||
None,
|
|
||||||
include_skill_authoring=True,
|
|
||||||
include_mcp_resource_tools=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -136,32 +131,7 @@ async def test_preproc_disables_skill_authoring_tools_when_skill_service_missing
|
|||||||
result = await stage.process(_make_query(), 'PreProcessor')
|
result = await stage.process(_make_query(), 'PreProcessor')
|
||||||
|
|
||||||
assert result.result_type == entities_module.ResultType.CONTINUE
|
assert result.result_type == entities_module.ResultType.CONTINUE
|
||||||
app.tool_mgr.get_all_tools.assert_awaited_once_with(
|
app.tool_mgr.get_all_tools.assert_awaited_once_with(None, None, include_skill_authoring=False)
|
||||||
None,
|
|
||||||
None,
|
|
||||||
include_skill_authoring=False,
|
|
||||||
include_mcp_resource_tools=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_preproc_disables_mcp_resource_tools_when_agent_reading_is_disabled():
|
|
||||||
preproc_module, entities_module = _import_preproc_modules()
|
|
||||||
|
|
||||||
app = _make_app(skill_service=SimpleNamespace())
|
|
||||||
stage = preproc_module.PreProcessor(app)
|
|
||||||
query = _make_query()
|
|
||||||
query.variables['_pipeline_mcp_resource_agent_read_enabled'] = False
|
|
||||||
|
|
||||||
result = await stage.process(query, 'PreProcessor')
|
|
||||||
|
|
||||||
assert result.result_type == entities_module.ResultType.CONTINUE
|
|
||||||
app.tool_mgr.get_all_tools.assert_awaited_once_with(
|
|
||||||
None,
|
|
||||||
None,
|
|
||||||
include_skill_authoring=True,
|
|
||||||
include_mcp_resource_tools=False,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|||||||
@@ -138,7 +138,7 @@ class TestReadResourceFile:
|
|||||||
from langbot.pkg.utils import importutil
|
from langbot.pkg.utils import importutil
|
||||||
|
|
||||||
content = importutil.read_resource_file('templates/config.yaml')
|
content = importutil.read_resource_file('templates/config.yaml')
|
||||||
assert 'api:' in content
|
assert 'admins:' in content
|
||||||
assert 'edition: community' in content
|
assert 'edition: community' in content
|
||||||
|
|
||||||
def test_raises_for_nonexistent_file(self):
|
def test_raises_for_nonexistent_file(self):
|
||||||
@@ -157,7 +157,7 @@ class TestReadResourceFileBytes:
|
|||||||
from langbot.pkg.utils import importutil
|
from langbot.pkg.utils import importutil
|
||||||
|
|
||||||
content = importutil.read_resource_file_bytes('templates/config.yaml')
|
content = importutil.read_resource_file_bytes('templates/config.yaml')
|
||||||
assert b'api:' in content
|
assert b'admins:' in content
|
||||||
assert b'edition: community' in content
|
assert b'edition: community' in content
|
||||||
|
|
||||||
def test_raises_for_nonexistent_file_bytes(self):
|
def test_raises_for_nonexistent_file_bytes(self):
|
||||||
|
|||||||
@@ -2008,7 +2008,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "langbot"
|
name = "langbot"
|
||||||
version = "4.10.5"
|
version = "4.10.4"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "aiocqhttp" },
|
{ name = "aiocqhttp" },
|
||||||
@@ -2123,7 +2123,7 @@ requires-dist = [
|
|||||||
{ name = "ebooklib", specifier = ">=0.18" },
|
{ name = "ebooklib", specifier = ">=0.18" },
|
||||||
{ name = "gewechat-client", specifier = ">=0.1.5" },
|
{ name = "gewechat-client", specifier = ">=0.1.5" },
|
||||||
{ name = "html2text", specifier = ">=2024.2.26" },
|
{ name = "html2text", specifier = ">=2024.2.26" },
|
||||||
{ name = "langbot-plugin", specifier = "==0.4.9" },
|
{ name = "langbot-plugin", specifier = "==0.4.6" },
|
||||||
{ name = "langchain", specifier = ">=1.3.9" },
|
{ name = "langchain", specifier = ">=1.3.9" },
|
||||||
{ name = "langchain-core", specifier = ">=1.3.3" },
|
{ name = "langchain-core", specifier = ">=1.3.3" },
|
||||||
{ name = "langchain-text-splitters", specifier = ">=1.1.2" },
|
{ name = "langchain-text-splitters", specifier = ">=1.1.2" },
|
||||||
@@ -2187,7 +2187,7 @@ dev = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "langbot-plugin"
|
name = "langbot-plugin"
|
||||||
version = "0.4.9"
|
version = "0.4.6"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "aiofiles" },
|
{ name = "aiofiles" },
|
||||||
@@ -2208,9 +2208,9 @@ dependencies = [
|
|||||||
{ name = "watchdog" },
|
{ name = "watchdog" },
|
||||||
{ name = "websockets" },
|
{ name = "websockets" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/54/52/f85939d3be929696dc5f9f3369f18bd6652981a8985c6e6f0dfe48e04f75/langbot_plugin-0.4.9.tar.gz", hash = "sha256:1cc2882f1be96cd52be6c392c3925e44cf785c97c10f82a321339636da318c6e", size = 334682, upload-time = "2026-07-03T04:57:42.813Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/b4/6a/5fdb5365ad04aaa61344e92578d73eb1577af35783b80767c7d6c51cb8b9/langbot_plugin-0.4.6.tar.gz", hash = "sha256:838e3cd45ed795ed4c3299c73f141b217adfa05f09937a01694e7158619e4f6e", size = 334171, upload-time = "2026-06-22T15:06:56.565Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/47/12/1cf535377a81cf01607bbee5e3f4745a8c9f6dbb561887467001a182a533/langbot_plugin-0.4.9-py3-none-any.whl", hash = "sha256:693225a089ba38bc8d0b58d9e0fdf07b027b5be6b5e858cf7b4e56d095b68ba5", size = 221722, upload-time = "2026-07-03T04:57:41.578Z" },
|
{ url = "https://files.pythonhosted.org/packages/6b/55/7adc2e180a299ed58613e159c64195477e09c05136f949942c6cec5219e8/langbot_plugin-0.4.6-py3-none-any.whl", hash = "sha256:30eb47efc0b703818ac003a5cd67caf720d9749dd503155eb65cce0c28b194a7", size = 217434, upload-time = "2026-06-22T15:06:55.237Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
Executable → Regular
Executable → Regular
Executable → Regular
Executable → Regular
Executable → Regular
@@ -191,8 +191,7 @@ export default function BotDetailContent({ id }: { id: string }) {
|
|||||||
onValueChange={setActiveTab}
|
onValueChange={setActiveTab}
|
||||||
className="flex flex-1 flex-col min-h-0"
|
className="flex flex-1 flex-col min-h-0"
|
||||||
>
|
>
|
||||||
<div className="flex shrink-0 items-center gap-1">
|
<TabsList className="shrink-0">
|
||||||
<TabsList>
|
|
||||||
<TabsTrigger value="config" className="gap-1.5">
|
<TabsTrigger value="config" className="gap-1.5">
|
||||||
<Settings className="size-3.5" />
|
<Settings className="size-3.5" />
|
||||||
{t('bots.configuration')}
|
{t('bots.configuration')}
|
||||||
@@ -204,16 +203,14 @@ export default function BotDetailContent({ id }: { id: string }) {
|
|||||||
<TabsTrigger value="sessions" className="gap-1.5">
|
<TabsTrigger value="sessions" className="gap-1.5">
|
||||||
<Users className="size-3.5" />
|
<Users className="size-3.5" />
|
||||||
{t('bots.sessionMonitor.title')}
|
{t('bots.sessionMonitor.title')}
|
||||||
</TabsTrigger>
|
|
||||||
</TabsList>
|
|
||||||
{activeTab === 'sessions' && (
|
{activeTab === 'sessions' && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label={t('bots.sessionMonitor.refresh')}
|
className="inline-flex items-center justify-center ml-0.5"
|
||||||
title={t('bots.sessionMonitor.refresh')}
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
className="inline-flex h-8 w-8 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground disabled:pointer-events-none disabled:opacity-50"
|
onClick={(e) => {
|
||||||
disabled={isRefreshingSessions}
|
e.stopPropagation();
|
||||||
onClick={() => {
|
e.preventDefault();
|
||||||
if (isRefreshingSessions) return;
|
if (isRefreshingSessions) return;
|
||||||
setIsRefreshingSessions(true);
|
setIsRefreshingSessions(true);
|
||||||
const minDelay = new Promise((r) => setTimeout(r, 500));
|
const minDelay = new Promise((r) => setTimeout(r, 500));
|
||||||
@@ -225,13 +222,14 @@ export default function BotDetailContent({ id }: { id: string }) {
|
|||||||
>
|
>
|
||||||
<RefreshCw
|
<RefreshCw
|
||||||
className={cn(
|
className={cn(
|
||||||
'size-3.5',
|
'size-3 text-muted-foreground hover:text-foreground transition-colors',
|
||||||
isRefreshingSessions && 'animate-spin',
|
isRefreshingSessions && 'animate-spin',
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
{/* Tab: Configuration */}
|
{/* Tab: Configuration */}
|
||||||
<TabsContent
|
<TabsContent
|
||||||
|
|||||||
@@ -1,199 +0,0 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Input } from '@/components/ui/input';
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogDescription,
|
|
||||||
} from '@/components/ui/dialog';
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from '@/components/ui/select';
|
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
|
||||||
import { Trash2, Plus, ShieldCheck } from 'lucide-react';
|
|
||||||
import { toast } from 'sonner';
|
|
||||||
|
|
||||||
export interface BotAdmin {
|
|
||||||
id: number;
|
|
||||||
launcher_type: string;
|
|
||||||
launcher_id: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface BotAdminsDialogProps {
|
|
||||||
botId: string;
|
|
||||||
open: boolean;
|
|
||||||
onOpenChange: (open: boolean) => void;
|
|
||||||
admins: BotAdmin[];
|
|
||||||
onAdminsChange: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function BotAdminsDialog({
|
|
||||||
botId,
|
|
||||||
open,
|
|
||||||
onOpenChange,
|
|
||||||
admins,
|
|
||||||
onAdminsChange,
|
|
||||||
}: BotAdminsDialogProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [newType, setNewType] = useState('person');
|
|
||||||
const [newId, setNewId] = useState('');
|
|
||||||
const [adding, setAdding] = useState(false);
|
|
||||||
|
|
||||||
async function handleAdd() {
|
|
||||||
if (!newId.trim()) return;
|
|
||||||
setAdding(true);
|
|
||||||
try {
|
|
||||||
await httpClient.addBotAdmin(botId, newType, newId.trim());
|
|
||||||
toast.success(t('bots.admins.addSuccess'));
|
|
||||||
setNewId('');
|
|
||||||
onAdminsChange();
|
|
||||||
} catch (e: unknown) {
|
|
||||||
const err = e as { msg?: string; message?: string };
|
|
||||||
toast.error(t('bots.admins.addError') + (err?.msg ?? err?.message ?? ''));
|
|
||||||
} finally {
|
|
||||||
setAdding(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleDelete(id: number) {
|
|
||||||
try {
|
|
||||||
await httpClient.deleteBotAdmin(botId, id);
|
|
||||||
toast.success(t('bots.admins.deleteSuccess'));
|
|
||||||
onAdminsChange();
|
|
||||||
} catch (e: unknown) {
|
|
||||||
const err = e as { msg?: string; message?: string };
|
|
||||||
toast.error(
|
|
||||||
t('bots.admins.deleteError') + (err?.msg ?? err?.message ?? ''),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
||||||
<DialogContent className="max-w-lg">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle className="flex items-center gap-2">
|
|
||||||
<ShieldCheck className="size-4" />
|
|
||||||
{t('bots.admins.title')}
|
|
||||||
</DialogTitle>
|
|
||||||
<DialogDescription>{t('bots.admins.description')}</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<div className="space-y-4">
|
|
||||||
{/* Add row */}
|
|
||||||
<div className="flex gap-2 items-center">
|
|
||||||
<Select value={newType} onValueChange={setNewType}>
|
|
||||||
<SelectTrigger className="w-28 shrink-0">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="person">
|
|
||||||
{t('bots.admins.typePerson')}
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="group">
|
|
||||||
{t('bots.admins.typeGroup')}
|
|
||||||
</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<Input
|
|
||||||
className="flex-1"
|
|
||||||
placeholder={t('bots.admins.placeholderId')}
|
|
||||||
value={newId}
|
|
||||||
onChange={(e) => setNewId(e.target.value)}
|
|
||||||
onKeyDown={(e) => e.key === 'Enter' && handleAdd()}
|
|
||||||
/>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
onClick={handleAdd}
|
|
||||||
disabled={adding || !newId.trim()}
|
|
||||||
>
|
|
||||||
<Plus className="size-4 mr-1" />
|
|
||||||
{t('bots.admins.addAdmin')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* List */}
|
|
||||||
{admins.length === 0 ? (
|
|
||||||
<div className="text-sm text-muted-foreground py-6 text-center">
|
|
||||||
{t('bots.admins.noAdmins')}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<ScrollArea className="max-h-64">
|
|
||||||
<div className="border rounded-md overflow-hidden">
|
|
||||||
<table className="w-full text-sm">
|
|
||||||
<thead>
|
|
||||||
<tr className="border-b bg-muted/40">
|
|
||||||
<th className="text-left px-3 py-2 font-medium text-muted-foreground w-28">
|
|
||||||
{t('bots.admins.launcherType')}
|
|
||||||
</th>
|
|
||||||
<th className="text-left px-3 py-2 font-medium text-muted-foreground">
|
|
||||||
{t('bots.admins.launcherId')}
|
|
||||||
</th>
|
|
||||||
<th className="w-10" />
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{admins.map((admin) => (
|
|
||||||
<tr
|
|
||||||
key={admin.id}
|
|
||||||
className="border-b last:border-0 hover:bg-muted/30"
|
|
||||||
>
|
|
||||||
<td className="px-3 py-2">
|
|
||||||
<span className="px-1.5 py-0.5 rounded bg-muted text-xs">
|
|
||||||
{admin.launcher_type === 'person'
|
|
||||||
? t('bots.admins.typePerson')
|
|
||||||
: t('bots.admins.typeGroup')}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-2 font-mono">
|
|
||||||
{admin.launcher_id}
|
|
||||||
</td>
|
|
||||||
<td className="px-2 py-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="text-muted-foreground hover:text-destructive transition-colors"
|
|
||||||
onClick={() => handleDelete(admin.id)}
|
|
||||||
>
|
|
||||||
<Trash2 className="size-4" />
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</ScrollArea>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Shared hook so the session monitor and the dialog stay in sync.
|
|
||||||
export function useBotAdmins(botId: string) {
|
|
||||||
const [admins, setAdmins] = useState<BotAdmin[]>([]);
|
|
||||||
|
|
||||||
const reload = useCallback(async () => {
|
|
||||||
try {
|
|
||||||
const res = await httpClient.getBotAdmins(botId);
|
|
||||||
setAdmins(res.admins ?? []);
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to load bot admins:', error);
|
|
||||||
}
|
|
||||||
}, [botId]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
reload();
|
|
||||||
}, [reload]);
|
|
||||||
|
|
||||||
return { admins, reload };
|
|
||||||
}
|
|
||||||
@@ -3,7 +3,6 @@ import React, {
|
|||||||
useEffect,
|
useEffect,
|
||||||
useRef,
|
useRef,
|
||||||
useCallback,
|
useCallback,
|
||||||
useMemo,
|
|
||||||
forwardRef,
|
forwardRef,
|
||||||
useImperativeHandle,
|
useImperativeHandle,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
@@ -16,20 +15,10 @@ import {
|
|||||||
Bot,
|
Bot,
|
||||||
Copy,
|
Copy,
|
||||||
Check,
|
Check,
|
||||||
ChevronDown,
|
|
||||||
ChevronRight,
|
|
||||||
Workflow,
|
Workflow,
|
||||||
ThumbsUp,
|
ThumbsUp,
|
||||||
ThumbsDown,
|
ThumbsDown,
|
||||||
ShieldCheck,
|
|
||||||
ShieldOff,
|
|
||||||
Wrench,
|
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
|
||||||
import BotAdminsDialog, {
|
|
||||||
useBotAdmins,
|
|
||||||
} from '@/app/home/bots/components/bot-admins/BotAdminsDialog';
|
|
||||||
import type { BotAdmin } from '@/app/home/bots/components/bot-admins/BotAdminsDialog';
|
|
||||||
import { copyToClipboard } from '@/app/utils/clipboard';
|
import { copyToClipboard } from '@/app/utils/clipboard';
|
||||||
import {
|
import {
|
||||||
MessageChainComponent,
|
MessageChainComponent,
|
||||||
@@ -80,35 +69,6 @@ interface SessionFeedback {
|
|||||||
stream_id?: string | null;
|
stream_id?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface SessionToolCall {
|
|
||||||
id: string;
|
|
||||||
timestamp: string;
|
|
||||||
tool_name: string;
|
|
||||||
tool_source: string;
|
|
||||||
duration: number;
|
|
||||||
status: string;
|
|
||||||
message_id?: string | null;
|
|
||||||
arguments?: string | null;
|
|
||||||
result?: string | null;
|
|
||||||
error_message?: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
type SessionTimelineItem =
|
|
||||||
| {
|
|
||||||
id: string;
|
|
||||||
type: 'message';
|
|
||||||
timestamp: number;
|
|
||||||
order: number;
|
|
||||||
message: SessionMessage;
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
id: string;
|
|
||||||
type: 'tool';
|
|
||||||
timestamp: number;
|
|
||||||
order: number;
|
|
||||||
toolCall: SessionToolCall;
|
|
||||||
};
|
|
||||||
|
|
||||||
export interface BotSessionMonitorHandle {
|
export interface BotSessionMonitorHandle {
|
||||||
refreshSessions: () => Promise<void>;
|
refreshSessions: () => Promise<void>;
|
||||||
}
|
}
|
||||||
@@ -133,65 +93,16 @@ const BotSessionMonitor = forwardRef<
|
|||||||
const [feedbackMap, setFeedbackMap] = useState<
|
const [feedbackMap, setFeedbackMap] = useState<
|
||||||
Record<string, SessionFeedback>
|
Record<string, SessionFeedback>
|
||||||
>({});
|
>({});
|
||||||
const [toolCalls, setToolCalls] = useState<SessionToolCall[]>([]);
|
|
||||||
const [expandedToolCallIds, setExpandedToolCallIds] = useState<
|
|
||||||
Record<string, boolean>
|
|
||||||
>({});
|
|
||||||
const messagesContainerRef = useRef<HTMLDivElement>(null);
|
const messagesContainerRef = useRef<HTMLDivElement>(null);
|
||||||
const { admins, reload: reloadAdmins } = useBotAdmins(botId);
|
|
||||||
const [adminsDialogOpen, setAdminsDialogOpen] = useState(false);
|
|
||||||
const [togglingAdmin, setTogglingAdmin] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const parseSessionType = (sessionId: string): string | null => {
|
const parseSessionType = (sessionId: string): string | null => {
|
||||||
const lower = sessionId.toLowerCase();
|
const idx = sessionId.indexOf('_');
|
||||||
if (lower.includes('person')) return 'person';
|
if (idx === -1) return null;
|
||||||
if (lower.includes('group')) return 'group';
|
const type = sessionId.slice(0, idx);
|
||||||
|
if (type === 'person' || type === 'group') return type;
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const isSessionAdmin = (session: SessionInfo): boolean => {
|
|
||||||
const type = parseSessionType(session.session_id);
|
|
||||||
const lid =
|
|
||||||
session.user_id ??
|
|
||||||
session.session_id.replace(
|
|
||||||
/^.*?[._](?:PERSON|GROUP|person|group)[._]/i,
|
|
||||||
'',
|
|
||||||
);
|
|
||||||
return admins.some(
|
|
||||||
(a: BotAdmin) => a.launcher_type === type && a.launcher_id === lid,
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleAdmin = async (session: SessionInfo) => {
|
|
||||||
const type = parseSessionType(session.session_id);
|
|
||||||
if (!type) return;
|
|
||||||
const lid =
|
|
||||||
session.user_id ??
|
|
||||||
session.session_id.replace(
|
|
||||||
/^.*?[._](?:PERSON|GROUP|person|group)[._]/i,
|
|
||||||
'',
|
|
||||||
);
|
|
||||||
const key = session.session_id;
|
|
||||||
setTogglingAdmin(key);
|
|
||||||
try {
|
|
||||||
const existing = admins.find(
|
|
||||||
(a: BotAdmin) => a.launcher_type === type && a.launcher_id === lid,
|
|
||||||
);
|
|
||||||
if (existing) {
|
|
||||||
await httpClient.deleteBotAdmin(botId, existing.id);
|
|
||||||
toast.success(t('bots.admins.deleteSuccess'));
|
|
||||||
} else {
|
|
||||||
await httpClient.addBotAdmin(botId, type, lid);
|
|
||||||
toast.success(t('bots.admins.addSuccess'));
|
|
||||||
}
|
|
||||||
await reloadAdmins();
|
|
||||||
} catch {
|
|
||||||
toast.error(t('bots.admins.addError'));
|
|
||||||
} finally {
|
|
||||||
setTogglingAdmin(null);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const abbreviateId = (id: string): string => {
|
const abbreviateId = (id: string): string => {
|
||||||
if (id.length <= 10) return id;
|
if (id.length <= 10) return id;
|
||||||
return `${id.slice(0, 4)}..${id.slice(-4)}`;
|
return `${id.slice(0, 4)}..${id.slice(-4)}`;
|
||||||
@@ -226,7 +137,6 @@ const BotSessionMonitor = forwardRef<
|
|||||||
const loadMessages = useCallback(
|
const loadMessages = useCallback(
|
||||||
async (sessionId: string) => {
|
async (sessionId: string) => {
|
||||||
setLoadingMessages(true);
|
setLoadingMessages(true);
|
||||||
setExpandedToolCallIds({});
|
|
||||||
try {
|
try {
|
||||||
const messagesRes = await httpClient.getSessionMessages(sessionId);
|
const messagesRes = await httpClient.getSessionMessages(sessionId);
|
||||||
const sorted = (messagesRes.messages ?? []).sort(
|
const sorted = (messagesRes.messages ?? []).sort(
|
||||||
@@ -235,18 +145,6 @@ const BotSessionMonitor = forwardRef<
|
|||||||
);
|
);
|
||||||
setMessages(sorted);
|
setMessages(sorted);
|
||||||
|
|
||||||
try {
|
|
||||||
const analysisRes = await httpClient.get<{
|
|
||||||
tool_calls?: SessionToolCall[];
|
|
||||||
}>(
|
|
||||||
`/api/v1/monitoring/sessions/${encodeURIComponent(sessionId)}/analysis`,
|
|
||||||
);
|
|
||||||
setToolCalls(analysisRes?.tool_calls ?? []);
|
|
||||||
} catch (analysisError) {
|
|
||||||
console.error('Failed to load session tool calls:', analysisError);
|
|
||||||
setToolCalls([]);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Collect user message IDs for feedback matching
|
// Collect user message IDs for feedback matching
|
||||||
const userMsgIds = new Set(
|
const userMsgIds = new Set(
|
||||||
sorted.filter((m) => !m.role || m.role === 'user').map((m) => m.id),
|
sorted.filter((m) => !m.role || m.role === 'user').map((m) => m.id),
|
||||||
@@ -290,14 +188,11 @@ const BotSessionMonitor = forwardRef<
|
|||||||
loadMessages(selectedSessionId);
|
loadMessages(selectedSessionId);
|
||||||
} else {
|
} else {
|
||||||
setMessages([]);
|
setMessages([]);
|
||||||
setToolCalls([]);
|
|
||||||
setExpandedToolCallIds({});
|
|
||||||
setFeedbackMap({});
|
|
||||||
}
|
}
|
||||||
}, [selectedSessionId, loadMessages]);
|
}, [selectedSessionId, loadMessages]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (messages.length === 0 && toolCalls.length === 0) return;
|
if (messages.length === 0) return;
|
||||||
// Wait for DOM to render the new messages before scrolling
|
// Wait for DOM to render the new messages before scrolling
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
const container = messagesContainerRef.current;
|
const container = messagesContainerRef.current;
|
||||||
@@ -309,7 +204,7 @@ const BotSessionMonitor = forwardRef<
|
|||||||
scrollTarget.scrollTop = scrollTarget.scrollHeight;
|
scrollTarget.scrollTop = scrollTarget.scrollHeight;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}, [messages, toolCalls]);
|
}, [messages]);
|
||||||
|
|
||||||
const parseMessageChain = (content: string): MessageChainComponent[] => {
|
const parseMessageChain = (content: string): MessageChainComponent[] => {
|
||||||
try {
|
try {
|
||||||
@@ -484,98 +379,14 @@ const BotSessionMonitor = forwardRef<
|
|||||||
return `${diffDays}d`;
|
return `${diffDays}d`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatDuration = (durationMs: number): string => {
|
|
||||||
if (!durationMs) return '0ms';
|
|
||||||
if (durationMs < 1000) return `${durationMs}ms`;
|
|
||||||
return `${(durationMs / 1000).toFixed(2)}s`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const truncateToolDetail = (value?: string | null): string => {
|
|
||||||
if (!value) return '';
|
|
||||||
return value.length > 600 ? `${value.slice(0, 600)}...` : value;
|
|
||||||
};
|
|
||||||
|
|
||||||
const toggleToolCallDetails = (toolCallId: string) => {
|
|
||||||
setExpandedToolCallIds((previous) => ({
|
|
||||||
...previous,
|
|
||||||
[toolCallId]: !previous[toolCallId],
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
const feedbackByMessageId = useMemo(() => {
|
|
||||||
const map: Record<string, SessionFeedback> = {};
|
|
||||||
|
|
||||||
for (let index = 0; index < messages.length; index++) {
|
|
||||||
const msg = messages[index];
|
|
||||||
if (isUserMessage(msg)) continue;
|
|
||||||
|
|
||||||
for (let previousIndex = index - 1; previousIndex >= 0; previousIndex--) {
|
|
||||||
const previousMessage = messages[previousIndex];
|
|
||||||
if (isUserMessage(previousMessage)) {
|
|
||||||
const feedback = feedbackMap[previousMessage.id];
|
|
||||||
if (feedback) {
|
|
||||||
map[msg.id] = feedback;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return map;
|
|
||||||
}, [feedbackMap, messages]);
|
|
||||||
|
|
||||||
const timelineItems = useMemo<SessionTimelineItem[]>(() => {
|
|
||||||
const messageItems: SessionTimelineItem[] = messages.map(
|
|
||||||
(message, index) => ({
|
|
||||||
id: `message-${message.id}`,
|
|
||||||
type: 'message',
|
|
||||||
timestamp: parseTimestamp(message.timestamp).getTime(),
|
|
||||||
order: index * 2,
|
|
||||||
message,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
const toolItems: SessionTimelineItem[] = toolCalls.map(
|
|
||||||
(toolCall, index) => ({
|
|
||||||
id: `tool-${toolCall.id}`,
|
|
||||||
type: 'tool',
|
|
||||||
timestamp: parseTimestamp(toolCall.timestamp).getTime(),
|
|
||||||
order: index * 2 + 1,
|
|
||||||
toolCall,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
return [...messageItems, ...toolItems].sort(
|
|
||||||
(a, b) => a.timestamp - b.timestamp || a.order - b.order,
|
|
||||||
);
|
|
||||||
}, [messages, toolCalls]);
|
|
||||||
|
|
||||||
const selectedSession = sessions.find(
|
const selectedSession = sessions.find(
|
||||||
(s) => s.session_id === selectedSessionId,
|
(s) => s.session_id === selectedSessionId,
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
|
||||||
<div className="flex flex-col md:flex-row h-full min-h-0 rounded-lg border overflow-hidden">
|
<div className="flex flex-col md:flex-row h-full min-h-0 rounded-lg border overflow-hidden">
|
||||||
{/* Left Panel: Session List */}
|
{/* Left Panel: Session List */}
|
||||||
<div className="max-h-48 md:max-h-none md:w-60 flex-shrink-0 border-b md:border-b-0 md:border-r flex flex-col min-h-0">
|
<div className="max-h-48 md:max-h-none md:w-60 flex-shrink-0 border-b md:border-b-0 md:border-r flex flex-col min-h-0">
|
||||||
{/* Admin header */}
|
|
||||||
<div className="px-2 py-1.5 border-b shrink-0 flex items-center justify-between">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="inline-flex items-center gap-1.5 text-sm font-medium hover:text-foreground transition-colors"
|
|
||||||
onClick={() => setAdminsDialogOpen(true)}
|
|
||||||
>
|
|
||||||
<ShieldCheck className="size-4" />
|
|
||||||
<span>
|
|
||||||
{t('bots.admins.configureAdmins')}
|
|
||||||
{admins.length > 0 && (
|
|
||||||
<span className="ml-1 tabular-nums text-xs text-muted-foreground">
|
|
||||||
({admins.length})
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{/* Session List */}
|
{/* Session List */}
|
||||||
<ScrollArea className="flex-1 min-h-0">
|
<ScrollArea className="flex-1 min-h-0">
|
||||||
{loadingSessions && sessions.length === 0 ? (
|
{loadingSessions && sessions.length === 0 ? (
|
||||||
@@ -590,15 +401,12 @@ const BotSessionMonitor = forwardRef<
|
|||||||
<div className="p-1.5">
|
<div className="p-1.5">
|
||||||
{sessions.map((session) => {
|
{sessions.map((session) => {
|
||||||
const isSelected = selectedSessionId === session.session_id;
|
const isSelected = selectedSessionId === session.session_id;
|
||||||
const sessionType = parseSessionType(session.session_id);
|
|
||||||
const sessionIsAdmin = isSessionAdmin(session);
|
|
||||||
return (
|
return (
|
||||||
<div
|
<button
|
||||||
key={session.session_id}
|
key={session.session_id}
|
||||||
role="button"
|
type="button"
|
||||||
tabIndex={0}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
'w-full text-left px-2.5 py-2 rounded-md transition-colors cursor-pointer',
|
'w-full text-left px-2.5 py-2 rounded-md transition-colors',
|
||||||
isSelected ? 'bg-accent' : 'hover:bg-accent/50',
|
isSelected ? 'bg-accent' : 'hover:bg-accent/50',
|
||||||
)}
|
)}
|
||||||
onClick={() => setSelectedSessionId(session.session_id)}
|
onClick={() => setSelectedSessionId(session.session_id)}
|
||||||
@@ -614,12 +422,16 @@ const BotSessionMonitor = forwardRef<
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||||
{sessionType && (
|
{parseSessionType(session.session_id) && (
|
||||||
<span className="px-1 py-0.5 rounded bg-muted text-[10px]">
|
<span className="px-1 py-0.5 rounded bg-muted text-[10px]">
|
||||||
{sessionType}
|
{parseSessionType(session.session_id)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{session.platform && (
|
||||||
|
<span className="px-1 py-0.5 rounded bg-muted text-[10px]">
|
||||||
|
{session.platform}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{session.user_id && (
|
{session.user_id && (
|
||||||
<span className="truncate text-[10px]">
|
<span className="truncate text-[10px]">
|
||||||
{abbreviateId(session.user_id)}
|
{abbreviateId(session.user_id)}
|
||||||
@@ -631,7 +443,7 @@ const BotSessionMonitor = forwardRef<
|
|||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</button>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
@@ -648,8 +460,8 @@ const BotSessionMonitor = forwardRef<
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{/* Chat Header */}
|
{/* Chat Header */}
|
||||||
<div className="px-4 py-2.5 border-b shrink-0 flex items-center justify-between">
|
<div className="px-4 py-2.5 border-b shrink-0">
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0">
|
||||||
<div className="text-sm font-medium truncate">
|
<div className="text-sm font-medium truncate">
|
||||||
{selectedSession?.user_name ||
|
{selectedSession?.user_name ||
|
||||||
selectedSession?.user_id ||
|
selectedSession?.user_id ||
|
||||||
@@ -659,6 +471,12 @@ const BotSessionMonitor = forwardRef<
|
|||||||
{parseSessionType(selectedSessionId) && (
|
{parseSessionType(selectedSessionId) && (
|
||||||
<span>{parseSessionType(selectedSessionId)}</span>
|
<span>{parseSessionType(selectedSessionId)}</span>
|
||||||
)}
|
)}
|
||||||
|
{selectedSession?.platform && (
|
||||||
|
<>
|
||||||
|
{parseSessionType(selectedSessionId) && <span>·</span>}
|
||||||
|
<span>{selectedSession.platform}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
{selectedSession?.user_id && (
|
{selectedSession?.user_id && (
|
||||||
<>
|
<>
|
||||||
<span>·</span>
|
<span>·</span>
|
||||||
@@ -688,34 +506,6 @@ const BotSessionMonitor = forwardRef<
|
|||||||
</span>
|
</span>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
{selectedSession && parseSessionType(selectedSessionId) && (
|
|
||||||
<>
|
|
||||||
<span>·</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={cn(
|
|
||||||
'inline-flex items-center gap-1 transition-colors',
|
|
||||||
isSessionAdmin(selectedSession)
|
|
||||||
? 'text-blue-500'
|
|
||||||
: 'text-muted-foreground hover:text-blue-500',
|
|
||||||
)}
|
|
||||||
disabled={togglingAdmin === selectedSessionId}
|
|
||||||
title={
|
|
||||||
isSessionAdmin(selectedSession)
|
|
||||||
? t('bots.admins.removeAdminTitle')
|
|
||||||
: t('bots.admins.setAdminTitle')
|
|
||||||
}
|
|
||||||
onClick={() => toggleAdmin(selectedSession)}
|
|
||||||
>
|
|
||||||
{isSessionAdmin(selectedSession) ? (
|
|
||||||
<ShieldCheck className="size-3.5" />
|
|
||||||
) : (
|
|
||||||
<ShieldOff className="size-3.5" />
|
|
||||||
)}
|
|
||||||
<span>{t('bots.admins.adminBadge')}</span>
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -730,162 +520,29 @@ const BotSessionMonitor = forwardRef<
|
|||||||
<div className="text-center text-muted-foreground py-12 text-sm">
|
<div className="text-center text-muted-foreground py-12 text-sm">
|
||||||
{t('bots.sessionMonitor.loading')}
|
{t('bots.sessionMonitor.loading')}
|
||||||
</div>
|
</div>
|
||||||
) : timelineItems.length === 0 ? (
|
) : messages.length === 0 ? (
|
||||||
<div className="text-center text-muted-foreground py-12 text-sm">
|
<div className="text-center text-muted-foreground py-12 text-sm">
|
||||||
{t('bots.sessionMonitor.noMessages')}
|
{t('bots.sessionMonitor.noMessages')}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
timelineItems.map((item) => {
|
messages.map((msg, msgIndex) => {
|
||||||
if (item.type === 'tool') {
|
|
||||||
const call = item.toolCall;
|
|
||||||
const hasToolDetails = Boolean(
|
|
||||||
call.arguments || call.result || call.error_message,
|
|
||||||
);
|
|
||||||
const expandedToolCall = Boolean(
|
|
||||||
expandedToolCallIds[call.id],
|
|
||||||
);
|
|
||||||
const detailsId = `tool-call-details-${call.id}`;
|
|
||||||
return (
|
|
||||||
<div key={item.id} className="flex justify-start">
|
|
||||||
<div className="max-w-2xl rounded-xl rounded-bl-sm border border-border/60 bg-muted/25 px-2.5 py-1.5 text-xs text-muted-foreground">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={cn(
|
|
||||||
'flex w-full items-center justify-between gap-3 rounded-md text-left outline-none transition-colors',
|
|
||||||
hasToolDetails &&
|
|
||||||
'cursor-pointer hover:bg-muted/40 focus-visible:ring-2 focus-visible:ring-ring',
|
|
||||||
)}
|
|
||||||
aria-expanded={
|
|
||||||
hasToolDetails ? expandedToolCall : undefined
|
|
||||||
}
|
|
||||||
aria-controls={
|
|
||||||
hasToolDetails ? detailsId : undefined
|
|
||||||
}
|
|
||||||
aria-disabled={!hasToolDetails}
|
|
||||||
onClick={() =>
|
|
||||||
hasToolDetails &&
|
|
||||||
toggleToolCallDetails(call.id)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div className="flex min-w-0 flex-wrap items-center gap-1.5">
|
|
||||||
{hasToolDetails &&
|
|
||||||
(expandedToolCall ? (
|
|
||||||
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted-foreground/70" />
|
|
||||||
) : (
|
|
||||||
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-muted-foreground/70" />
|
|
||||||
))}
|
|
||||||
<Wrench className="h-3.5 w-3.5 shrink-0 text-muted-foreground/70" />
|
|
||||||
<span className="min-w-0 max-w-[18rem] truncate text-[13px] font-medium text-foreground/75">
|
|
||||||
{call.tool_name}
|
|
||||||
</span>
|
|
||||||
<span className="rounded border border-border/50 bg-background/60 px-1.5 py-0.5 text-[10px] leading-none text-muted-foreground">
|
|
||||||
{call.tool_source}
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
'rounded px-1.5 py-0.5 text-[10px] font-medium leading-none',
|
|
||||||
call.status === 'success'
|
|
||||||
? 'bg-green-100/70 text-green-700 dark:bg-green-950/60 dark:text-green-300'
|
|
||||||
: 'bg-red-100/70 text-red-700 dark:bg-red-950/60 dark:text-red-300',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{call.status}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<span className="shrink-0 text-[11px] tabular-nums text-muted-foreground/80">
|
|
||||||
{formatDuration(call.duration)}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{hasToolDetails && expandedToolCall && (
|
|
||||||
<div
|
|
||||||
id={detailsId}
|
|
||||||
className="mt-2 space-y-1.5"
|
|
||||||
>
|
|
||||||
{(call.arguments || call.result) && (
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
{call.arguments && (
|
|
||||||
<div>
|
|
||||||
<div className="mb-1 text-[11px] font-medium text-muted-foreground">
|
|
||||||
{t(
|
|
||||||
'monitoring.toolCalls.arguments',
|
|
||||||
{
|
|
||||||
defaultValue: '参数',
|
|
||||||
},
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<pre className="whitespace-pre-wrap break-words rounded bg-background/80 p-2 font-mono text-[11px] leading-4 text-muted-foreground">
|
|
||||||
{truncateToolDetail(call.arguments)}
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{call.result && (
|
|
||||||
<div>
|
|
||||||
<div className="mb-1 text-[11px] font-medium text-muted-foreground">
|
|
||||||
{t('monitoring.toolCalls.result', {
|
|
||||||
defaultValue: '结果',
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
<pre className="whitespace-pre-wrap break-words rounded bg-background/80 p-2 font-mono text-[11px] leading-4 text-muted-foreground">
|
|
||||||
{truncateToolDetail(call.result)}
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{call.error_message && (
|
|
||||||
<div className="whitespace-pre-wrap break-words rounded bg-red-50 p-2 text-[11px] text-red-600 dark:bg-red-950/40 dark:text-red-400">
|
|
||||||
{call.error_message}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="mt-1.5 flex items-center gap-1.5 text-[11px] text-muted-foreground">
|
|
||||||
<span>
|
|
||||||
{t('monitoring.toolCalls.title', {
|
|
||||||
defaultValue: '工具调用',
|
|
||||||
})}
|
|
||||||
</span>
|
|
||||||
<span className="tabular-nums">
|
|
||||||
{formatTime(call.timestamp)}
|
|
||||||
</span>
|
|
||||||
{hasToolDetails && (
|
|
||||||
<>
|
|
||||||
<span>·</span>
|
|
||||||
<span>
|
|
||||||
{expandedToolCall
|
|
||||||
? t(
|
|
||||||
'monitoring.toolCalls.hideDetails',
|
|
||||||
{
|
|
||||||
defaultValue: '隐藏详情',
|
|
||||||
},
|
|
||||||
)
|
|
||||||
: t(
|
|
||||||
'monitoring.toolCalls.showDetails',
|
|
||||||
{
|
|
||||||
defaultValue: '查看详情',
|
|
||||||
},
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const msg = item.message;
|
|
||||||
const isUser = isUserMessage(msg);
|
const isUser = isUserMessage(msg);
|
||||||
const isDiscarded =
|
const isDiscarded =
|
||||||
msg.status === 'discarded' ||
|
msg.status === 'discarded' ||
|
||||||
msg.pipeline_id === PIPELINE_DISCARD;
|
msg.pipeline_id === PIPELINE_DISCARD;
|
||||||
const msgFeedback = feedbackByMessageId[msg.id];
|
// For bot replies, find feedback linked to the preceding user message
|
||||||
|
let msgFeedback: SessionFeedback | undefined;
|
||||||
|
if (!isUser) {
|
||||||
|
for (let i = msgIndex - 1; i >= 0; i--) {
|
||||||
|
if (isUserMessage(messages[i])) {
|
||||||
|
msgFeedback = feedbackMap[messages[i].id];
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={item.id}
|
key={msg.id}
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex',
|
'flex',
|
||||||
isUser ? 'justify-end' : 'justify-start',
|
isUser ? 'justify-end' : 'justify-start',
|
||||||
@@ -897,8 +554,7 @@ const BotSessionMonitor = forwardRef<
|
|||||||
isUser
|
isUser
|
||||||
? 'bg-primary/10 rounded-br-sm'
|
? 'bg-primary/10 rounded-br-sm'
|
||||||
: 'bg-muted rounded-bl-sm',
|
: 'bg-muted rounded-bl-sm',
|
||||||
msg.status === 'error' &&
|
msg.status === 'error' && 'ring-1 ring-red-400/50',
|
||||||
'ring-1 ring-red-400/50',
|
|
||||||
isDiscarded && 'opacity-60',
|
isDiscarded && 'opacity-60',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
@@ -979,15 +635,6 @@ const BotSessionMonitor = forwardRef<
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<BotAdminsDialog
|
|
||||||
botId={botId}
|
|
||||||
open={adminsDialogOpen}
|
|
||||||
onOpenChange={setAdminsDialogOpen}
|
|
||||||
admins={admins}
|
|
||||||
onAdminsChange={reloadAdmins}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import {
|
import {
|
||||||
DynamicFormItemType,
|
|
||||||
IDynamicFormItemSchema,
|
IDynamicFormItemSchema,
|
||||||
SYSTEM_FIELD_PREFIX,
|
SYSTEM_FIELD_PREFIX,
|
||||||
} from '@/app/infra/entities/form/dynamic';
|
} from '@/app/infra/entities/form/dynamic';
|
||||||
@@ -58,95 +57,6 @@ function resolveShowIfValue(
|
|||||||
return externalDependentValues?.[field];
|
return externalDependentValues?.[field];
|
||||||
}
|
}
|
||||||
|
|
||||||
type DynamicFormValueSpec = Pick<
|
|
||||||
IDynamicFormItemSchema,
|
|
||||||
'default' | 'name' | 'required' | 'type'
|
|
||||||
>;
|
|
||||||
|
|
||||||
function getValueSpecs(item: IDynamicFormItemSchema): DynamicFormValueSpec[] {
|
|
||||||
if (item.type === DynamicFormItemType.RICH_TOOLS_SELECTOR) {
|
|
||||||
return [
|
|
||||||
item,
|
|
||||||
{
|
|
||||||
name: 'enable-all-tools',
|
|
||||||
type: DynamicFormItemType.BOOLEAN,
|
|
||||||
required: false,
|
|
||||||
default: true,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (item.type === DynamicFormItemType.RESOURCES_SELECTOR) {
|
|
||||||
return [
|
|
||||||
item,
|
|
||||||
{
|
|
||||||
name: 'mcp-resources',
|
|
||||||
type: DynamicFormItemType.UNKNOWN,
|
|
||||||
required: false,
|
|
||||||
default: [],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'mcp-resource-agent-read-enabled',
|
|
||||||
type: DynamicFormItemType.BOOLEAN,
|
|
||||||
required: false,
|
|
||||||
default: true,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
return [item];
|
|
||||||
}
|
|
||||||
|
|
||||||
function getValueSchema(spec: DynamicFormValueSpec) {
|
|
||||||
if (spec.name === 'mcp-resources') {
|
|
||||||
return z.array(z.any());
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (spec.type) {
|
|
||||||
case DynamicFormItemType.INT:
|
|
||||||
return z.number();
|
|
||||||
case DynamicFormItemType.FLOAT:
|
|
||||||
return z.number();
|
|
||||||
case DynamicFormItemType.BOOLEAN:
|
|
||||||
return z.boolean();
|
|
||||||
case DynamicFormItemType.STRING:
|
|
||||||
return z.string();
|
|
||||||
case DynamicFormItemType.STRING_ARRAY:
|
|
||||||
return z.array(z.string());
|
|
||||||
case DynamicFormItemType.SELECT:
|
|
||||||
return z.string();
|
|
||||||
case DynamicFormItemType.LLM_MODEL_SELECTOR:
|
|
||||||
return z.string();
|
|
||||||
case DynamicFormItemType.EMBEDDING_MODEL_SELECTOR:
|
|
||||||
return z.string();
|
|
||||||
case DynamicFormItemType.RERANK_MODEL_SELECTOR:
|
|
||||||
return z.string();
|
|
||||||
case DynamicFormItemType.KNOWLEDGE_BASE_SELECTOR:
|
|
||||||
return z.string();
|
|
||||||
case DynamicFormItemType.KNOWLEDGE_BASE_MULTI_SELECTOR:
|
|
||||||
case DynamicFormItemType.RESOURCES_SELECTOR:
|
|
||||||
case DynamicFormItemType.RICH_TOOLS_SELECTOR:
|
|
||||||
case DynamicFormItemType.TOOLS_SELECTOR:
|
|
||||||
return z.array(z.string());
|
|
||||||
case DynamicFormItemType.BOT_SELECTOR:
|
|
||||||
return z.string();
|
|
||||||
case DynamicFormItemType.MODEL_FALLBACK_SELECTOR:
|
|
||||||
return z.object({
|
|
||||||
primary: z.string(),
|
|
||||||
fallbacks: z.array(z.string()),
|
|
||||||
});
|
|
||||||
case DynamicFormItemType.PROMPT_EDITOR:
|
|
||||||
return z.array(
|
|
||||||
z.object({
|
|
||||||
content: z.string(),
|
|
||||||
role: z.string(),
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
default:
|
|
||||||
return z.string();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Display-only component for embed code fields with copy animation.
|
* Display-only component for embed code fields with copy animation.
|
||||||
*/
|
*/
|
||||||
@@ -412,16 +322,9 @@ export default function DynamicFormComponent({
|
|||||||
// model-fallback-selector) is coerced to the expected shape
|
// model-fallback-selector) is coerced to the expected shape
|
||||||
// so that downstream components never crash.
|
// so that downstream components never crash.
|
||||||
const normalizeFieldValue = (
|
const normalizeFieldValue = (
|
||||||
item: DynamicFormValueSpec,
|
item: IDynamicFormItemSchema,
|
||||||
value: unknown,
|
value: unknown,
|
||||||
): unknown => {
|
): unknown => {
|
||||||
if (
|
|
||||||
item.name === 'mcp-resources' ||
|
|
||||||
item.type === DynamicFormItemType.RESOURCES_SELECTOR ||
|
|
||||||
item.type === DynamicFormItemType.RICH_TOOLS_SELECTOR
|
|
||||||
) {
|
|
||||||
return Array.isArray(value) ? value : [];
|
|
||||||
}
|
|
||||||
if (item.type === 'model-fallback-selector') {
|
if (item.type === 'model-fallback-selector') {
|
||||||
if (value != null && typeof value === 'object' && !Array.isArray(value)) {
|
if (value != null && typeof value === 'object' && !Array.isArray(value)) {
|
||||||
const obj = value as Record<string, unknown>;
|
const obj = value as Record<string, unknown>;
|
||||||
@@ -465,16 +368,68 @@ export default function DynamicFormComponent({
|
|||||||
[itemConfigList],
|
[itemConfigList],
|
||||||
);
|
);
|
||||||
|
|
||||||
const editableValueSpecs = useMemo(
|
|
||||||
() => editableItems.flatMap(getValueSpecs),
|
|
||||||
[editableItems],
|
|
||||||
);
|
|
||||||
|
|
||||||
// 根据 itemConfigList 动态生成 zod schema
|
// 根据 itemConfigList 动态生成 zod schema
|
||||||
const formSchema = z.object(
|
const formSchema = z.object(
|
||||||
editableValueSpecs.reduce(
|
editableItems.reduce(
|
||||||
(acc, item) => {
|
(acc, item) => {
|
||||||
let fieldSchema = getValueSchema(item);
|
let fieldSchema;
|
||||||
|
switch (item.type) {
|
||||||
|
case 'integer':
|
||||||
|
fieldSchema = z.number();
|
||||||
|
break;
|
||||||
|
case 'float':
|
||||||
|
fieldSchema = z.number();
|
||||||
|
break;
|
||||||
|
case 'boolean':
|
||||||
|
fieldSchema = z.boolean();
|
||||||
|
break;
|
||||||
|
case 'string':
|
||||||
|
fieldSchema = z.string();
|
||||||
|
break;
|
||||||
|
case 'array[string]':
|
||||||
|
fieldSchema = z.array(z.string());
|
||||||
|
break;
|
||||||
|
case 'select':
|
||||||
|
fieldSchema = z.string();
|
||||||
|
break;
|
||||||
|
case 'llm-model-selector':
|
||||||
|
fieldSchema = z.string();
|
||||||
|
break;
|
||||||
|
case 'embedding-model-selector':
|
||||||
|
fieldSchema = z.string();
|
||||||
|
break;
|
||||||
|
case 'rerank-model-selector':
|
||||||
|
fieldSchema = z.string();
|
||||||
|
break;
|
||||||
|
case 'knowledge-base-selector':
|
||||||
|
fieldSchema = z.string();
|
||||||
|
break;
|
||||||
|
case 'knowledge-base-multi-selector':
|
||||||
|
fieldSchema = z.array(z.string());
|
||||||
|
break;
|
||||||
|
case 'bot-selector':
|
||||||
|
fieldSchema = z.string();
|
||||||
|
break;
|
||||||
|
case 'tools-selector':
|
||||||
|
fieldSchema = z.array(z.string());
|
||||||
|
break;
|
||||||
|
case 'model-fallback-selector':
|
||||||
|
fieldSchema = z.object({
|
||||||
|
primary: z.string(),
|
||||||
|
fallbacks: z.array(z.string()),
|
||||||
|
});
|
||||||
|
break;
|
||||||
|
case 'prompt-editor':
|
||||||
|
fieldSchema = z.array(
|
||||||
|
z.object({
|
||||||
|
content: z.string(),
|
||||||
|
role: z.string(),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
fieldSchema = z.string();
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
item.required &&
|
item.required &&
|
||||||
@@ -499,7 +454,7 @@ export default function DynamicFormComponent({
|
|||||||
|
|
||||||
const form = useForm<FormValues>({
|
const form = useForm<FormValues>({
|
||||||
resolver: zodResolver(formSchema),
|
resolver: zodResolver(formSchema),
|
||||||
defaultValues: editableValueSpecs.reduce((acc, item) => {
|
defaultValues: editableItems.reduce((acc, item) => {
|
||||||
// 优先使用 initialValues,如果没有则使用默认值
|
// 优先使用 initialValues,如果没有则使用默认值
|
||||||
const rawValue = initialValues?.[item.name] ?? item.default;
|
const rawValue = initialValues?.[item.name] ?? item.default;
|
||||||
return {
|
return {
|
||||||
@@ -538,7 +493,7 @@ export default function DynamicFormComponent({
|
|||||||
|
|
||||||
if (initialValues && hasRealChange) {
|
if (initialValues && hasRealChange) {
|
||||||
// 合并默认值和初始值
|
// 合并默认值和初始值
|
||||||
const mergedValues = editableValueSpecs.reduce(
|
const mergedValues = editableItems.reduce(
|
||||||
(acc, item) => {
|
(acc, item) => {
|
||||||
const rawValue = initialValues[item.name] ?? item.default;
|
const rawValue = initialValues[item.name] ?? item.default;
|
||||||
acc[item.name] = normalizeFieldValue(item, rawValue) as object;
|
acc[item.name] = normalizeFieldValue(item, rawValue) as object;
|
||||||
@@ -553,16 +508,10 @@ export default function DynamicFormComponent({
|
|||||||
|
|
||||||
previousInitialValues.current = initialValues;
|
previousInitialValues.current = initialValues;
|
||||||
}
|
}
|
||||||
}, [initialValues, form, editableValueSpecs]);
|
}, [initialValues, form, editableItems]);
|
||||||
|
|
||||||
// Get reactive form values for conditional rendering
|
// Get reactive form values for conditional rendering
|
||||||
const watchedValues = form.watch();
|
const watchedValues = form.watch();
|
||||||
const setFormValue = (name: string, value: unknown) => {
|
|
||||||
form.setValue(name as keyof FormValues, value as never, {
|
|
||||||
shouldDirty: true,
|
|
||||||
shouldValidate: true,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// Stable ref for onSubmit to avoid re-triggering the effect when the
|
// Stable ref for onSubmit to avoid re-triggering the effect when the
|
||||||
// parent passes a new closure on every render.
|
// parent passes a new closure on every render.
|
||||||
@@ -575,7 +524,7 @@ export default function DynamicFormComponent({
|
|||||||
// even if the user saves without modifying any field.
|
// even if the user saves without modifying any field.
|
||||||
// form.watch(callback) only fires on subsequent changes, not on mount.
|
// form.watch(callback) only fires on subsequent changes, not on mount.
|
||||||
const formValues = form.getValues();
|
const formValues = form.getValues();
|
||||||
const initialFinalValues = editableValueSpecs.reduce(
|
const initialFinalValues = editableItems.reduce(
|
||||||
(acc, item) => {
|
(acc, item) => {
|
||||||
acc[item.name] = formValues[item.name] ?? item.default;
|
acc[item.name] = formValues[item.name] ?? item.default;
|
||||||
return acc;
|
return acc;
|
||||||
@@ -595,7 +544,7 @@ export default function DynamicFormComponent({
|
|||||||
|
|
||||||
const subscription = form.watch(() => {
|
const subscription = form.watch(() => {
|
||||||
const formValues = form.getValues();
|
const formValues = form.getValues();
|
||||||
const finalValues = editableValueSpecs.reduce(
|
const finalValues = editableItems.reduce(
|
||||||
(acc, item) => {
|
(acc, item) => {
|
||||||
acc[item.name] = formValues[item.name] ?? item.default;
|
acc[item.name] = formValues[item.name] ?? item.default;
|
||||||
return acc;
|
return acc;
|
||||||
@@ -606,7 +555,7 @@ export default function DynamicFormComponent({
|
|||||||
previousInitialValues.current = finalValues as Record<string, object>;
|
previousInitialValues.current = finalValues as Record<string, object>;
|
||||||
});
|
});
|
||||||
return () => subscription.unsubscribe();
|
return () => subscription.unsubscribe();
|
||||||
}, [form, editableValueSpecs]);
|
}, [form, editableItems]);
|
||||||
|
|
||||||
// State for QR code login dialog
|
// State for QR code login dialog
|
||||||
const [qrDialogOpen, setQrDialogOpen] = useState(false);
|
const [qrDialogOpen, setQrDialogOpen] = useState(false);
|
||||||
@@ -837,41 +786,6 @@ export default function DynamicFormComponent({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
|
||||||
config.type === DynamicFormItemType.RICH_TOOLS_SELECTOR ||
|
|
||||||
config.type === DynamicFormItemType.RESOURCES_SELECTOR
|
|
||||||
) {
|
|
||||||
return (
|
|
||||||
<FormField
|
|
||||||
key={config.id}
|
|
||||||
control={form.control}
|
|
||||||
name={config.name as keyof FormValues}
|
|
||||||
render={({ field }) => (
|
|
||||||
<FormItem className="min-w-0">
|
|
||||||
<FormControl>
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'min-w-0 max-w-full overflow-x-hidden',
|
|
||||||
isFieldDisabled && 'pointer-events-none opacity-60',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<DynamicFormItemComponent
|
|
||||||
config={config}
|
|
||||||
field={field}
|
|
||||||
formValues={watchedValues as Record<string, unknown>}
|
|
||||||
onFileUploaded={onFileUploaded}
|
|
||||||
setFormValue={setFormValue}
|
|
||||||
systemContext={systemContext}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</FormControl>
|
|
||||||
<FormMessage />
|
|
||||||
</FormItem>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Boolean fields use a special inline layout
|
// Boolean fields use a special inline layout
|
||||||
if (config.type === 'boolean') {
|
if (config.type === 'boolean') {
|
||||||
return (
|
return (
|
||||||
@@ -902,10 +816,7 @@ export default function DynamicFormComponent({
|
|||||||
<DynamicFormItemComponent
|
<DynamicFormItemComponent
|
||||||
config={config}
|
config={config}
|
||||||
field={field}
|
field={field}
|
||||||
formValues={watchedValues as Record<string, unknown>}
|
|
||||||
onFileUploaded={onFileUploaded}
|
onFileUploaded={onFileUploaded}
|
||||||
setFormValue={setFormValue}
|
|
||||||
systemContext={systemContext}
|
|
||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
</div>
|
</div>
|
||||||
@@ -942,10 +853,7 @@ export default function DynamicFormComponent({
|
|||||||
<DynamicFormItemComponent
|
<DynamicFormItemComponent
|
||||||
config={config}
|
config={config}
|
||||||
field={field}
|
field={field}
|
||||||
formValues={watchedValues as Record<string, unknown>}
|
|
||||||
onFileUploaded={onFileUploaded}
|
onFileUploaded={onFileUploaded}
|
||||||
setFormValue={setFormValue}
|
|
||||||
systemContext={systemContext}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
|
|||||||
@@ -64,33 +64,15 @@ import {
|
|||||||
import SettingsDialog, {
|
import SettingsDialog, {
|
||||||
SettingsSection,
|
SettingsSection,
|
||||||
} from '@/app/home/components/settings-dialog/SettingsDialog';
|
} from '@/app/home/components/settings-dialog/SettingsDialog';
|
||||||
import ToolResourceSelectors from '@/app/home/components/dynamic-form/ToolResourceSelectors';
|
|
||||||
import { LANGBOT_MODELS_PROVIDER_REQUESTER } from '@/app/home/components/models-dialog/types';
|
|
||||||
|
|
||||||
function hasUsableUuid<T extends { uuid?: string | null }>(
|
|
||||||
item: T,
|
|
||||||
): item is T & { uuid: string } {
|
|
||||||
return typeof item.uuid === 'string' && item.uuid.trim().length > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
function hasUsableOptionName(option: { name?: string | null }): boolean {
|
|
||||||
return typeof option.name === 'string' && option.name.trim().length > 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function DynamicFormItemComponent({
|
export default function DynamicFormItemComponent({
|
||||||
config,
|
config,
|
||||||
field,
|
field,
|
||||||
formValues,
|
|
||||||
onFileUploaded,
|
onFileUploaded,
|
||||||
setFormValue,
|
|
||||||
systemContext,
|
|
||||||
}: {
|
}: {
|
||||||
config: IDynamicFormItemSchema;
|
config: IDynamicFormItemSchema;
|
||||||
field: ControllerRenderProps<any, any>;
|
field: ControllerRenderProps<any, any>;
|
||||||
formValues?: Record<string, unknown>;
|
|
||||||
onFileUploaded?: (fileKey: string) => void;
|
onFileUploaded?: (fileKey: string) => void;
|
||||||
setFormValue?: (name: string, value: unknown) => void;
|
|
||||||
systemContext?: Record<string, unknown>;
|
|
||||||
}) {
|
}) {
|
||||||
const [llmModels, setLlmModels] = useState<LLMModel[]>([]);
|
const [llmModels, setLlmModels] = useState<LLMModel[]>([]);
|
||||||
const [embeddingModels, setEmbeddingModels] = useState<EmbeddingModel[]>([]);
|
const [embeddingModels, setEmbeddingModels] = useState<EmbeddingModel[]>([]);
|
||||||
@@ -114,41 +96,17 @@ export default function DynamicFormItemComponent({
|
|||||||
httpClient
|
httpClient
|
||||||
.getProviderLLMModels()
|
.getProviderLLMModels()
|
||||||
.then((resp) => {
|
.then((resp) => {
|
||||||
setLlmModels(resp.models.filter(hasUsableUuid));
|
setLlmModels(resp.models);
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
toast.error(t('models.getModelListError') + err.msg);
|
toast.error(t('models.getModelListError') + err.msg);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const fetchEmbeddingModels = () => {
|
|
||||||
httpClient
|
|
||||||
.getProviderEmbeddingModels()
|
|
||||||
.then((resp) => {
|
|
||||||
setEmbeddingModels(resp.models.filter(hasUsableUuid));
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
toast.error(t('embedding.getModelListError') + err.msg);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const fetchRerankModels = () => {
|
|
||||||
httpClient
|
|
||||||
.getProviderRerankModels()
|
|
||||||
.then((resp) => {
|
|
||||||
setRerankModels(resp.models.filter(hasUsableUuid));
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
toast.error('Failed to load rerank models: ' + err.msg);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleModelsDialogChange = (open: boolean) => {
|
const handleModelsDialogChange = (open: boolean) => {
|
||||||
setModelsDialogOpen(open);
|
setModelsDialogOpen(open);
|
||||||
if (!open) {
|
if (!open) {
|
||||||
fetchLlmModels();
|
fetchLlmModels();
|
||||||
fetchEmbeddingModels();
|
|
||||||
fetchRerankModels();
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -216,13 +174,27 @@ export default function DynamicFormItemComponent({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (config.type === DynamicFormItemType.EMBEDDING_MODEL_SELECTOR) {
|
if (config.type === DynamicFormItemType.EMBEDDING_MODEL_SELECTOR) {
|
||||||
fetchEmbeddingModels();
|
httpClient
|
||||||
|
.getProviderEmbeddingModels()
|
||||||
|
.then((resp) => {
|
||||||
|
setEmbeddingModels(resp.models);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
toast.error(t('embedding.getModelListError') + err.msg);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}, [config.type]);
|
}, [config.type]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (config.type === DynamicFormItemType.RERANK_MODEL_SELECTOR) {
|
if (config.type === DynamicFormItemType.RERANK_MODEL_SELECTOR) {
|
||||||
fetchRerankModels();
|
httpClient
|
||||||
|
.getProviderRerankModels()
|
||||||
|
.then((resp) => {
|
||||||
|
setRerankModels(resp.models);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
toast.error('Failed to load rerank models: ' + err.msg);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}, [config.type]);
|
}, [config.type]);
|
||||||
|
|
||||||
@@ -240,7 +212,7 @@ export default function DynamicFormItemComponent({
|
|||||||
httpClient
|
httpClient
|
||||||
.getKnowledgeBases()
|
.getKnowledgeBases()
|
||||||
.then((resp) => {
|
.then((resp) => {
|
||||||
setKnowledgeBases(resp.bases.filter(hasUsableUuid));
|
setKnowledgeBases(resp.bases);
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
toast.error(t('knowledge.getKnowledgeBaseListError') + err.msg);
|
toast.error(t('knowledge.getKnowledgeBaseListError') + err.msg);
|
||||||
@@ -253,7 +225,7 @@ export default function DynamicFormItemComponent({
|
|||||||
httpClient
|
httpClient
|
||||||
.getBots()
|
.getBots()
|
||||||
.then((resp) => {
|
.then((resp) => {
|
||||||
setBots(resp.bots.filter(hasUsableUuid));
|
setBots(resp.bots);
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
toast.error(t('bots.getBotListError') + err.msg);
|
toast.error(t('bots.getBotListError') + err.msg);
|
||||||
@@ -276,16 +248,6 @@ export default function DynamicFormItemComponent({
|
|||||||
}
|
}
|
||||||
}, [config.type]);
|
}, [config.type]);
|
||||||
|
|
||||||
const handleCompositePatch = (patch: Record<string, unknown>) => {
|
|
||||||
for (const [name, value] of Object.entries(patch)) {
|
|
||||||
if (setFormValue) {
|
|
||||||
setFormValue(name, value);
|
|
||||||
} else if (name === field.name) {
|
|
||||||
field.onChange(value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
switch (config.type) {
|
switch (config.type) {
|
||||||
case DynamicFormItemType.INT:
|
case DynamicFormItemType.INT:
|
||||||
case DynamicFormItemType.FLOAT:
|
case DynamicFormItemType.FLOAT:
|
||||||
@@ -398,7 +360,7 @@ export default function DynamicFormItemComponent({
|
|||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectGroup>
|
<SelectGroup>
|
||||||
{config.options?.filter(hasUsableOptionName).map((option) => (
|
{config.options?.map((option) => (
|
||||||
<SelectItem
|
<SelectItem
|
||||||
key={option.name}
|
key={option.name}
|
||||||
value={option.name}
|
value={option.name}
|
||||||
@@ -415,10 +377,10 @@ export default function DynamicFormItemComponent({
|
|||||||
case DynamicFormItemType.LLM_MODEL_SELECTOR:
|
case DynamicFormItemType.LLM_MODEL_SELECTOR:
|
||||||
// Separate space models from regular models
|
// Separate space models from regular models
|
||||||
const spaceModels = llmModels.filter(
|
const spaceModels = llmModels.filter(
|
||||||
(m) => m.provider?.requester === LANGBOT_MODELS_PROVIDER_REQUESTER,
|
(m) => m.provider?.requester === 'space-chat-completions',
|
||||||
);
|
);
|
||||||
const regularModels = llmModels.filter(
|
const regularModels = llmModels.filter(
|
||||||
(m) => m.provider?.requester !== LANGBOT_MODELS_PROVIDER_REQUESTER,
|
(m) => m.provider?.requester !== 'space-chat-completions',
|
||||||
);
|
);
|
||||||
|
|
||||||
// Group regular models by provider
|
// Group regular models by provider
|
||||||
@@ -519,7 +481,7 @@ export default function DynamicFormItemComponent({
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{/* Blurred remaining models with login overlay */}
|
{/* Blurred remaining models with login overlay */}
|
||||||
<div className="relative min-h-10">
|
<div className="relative">
|
||||||
<div
|
<div
|
||||||
className="select-none overflow-hidden"
|
className="select-none overflow-hidden"
|
||||||
style={{ maxHeight: '3rem' }}
|
style={{ maxHeight: '3rem' }}
|
||||||
@@ -596,10 +558,7 @@ export default function DynamicFormItemComponent({
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="icon"
|
size="icon"
|
||||||
className="h-9 w-9 shrink-0"
|
className="h-9 w-9 shrink-0"
|
||||||
onClick={() => {
|
onClick={() => setModelsDialogOpen(true)}
|
||||||
setSettingsSection('models');
|
|
||||||
setModelsDialogOpen(true);
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<Settings className="h-4 w-4 text-muted-foreground" />
|
<Settings className="h-4 w-4 text-muted-foreground" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -615,15 +574,9 @@ export default function DynamicFormItemComponent({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
case DynamicFormItemType.EMBEDDING_MODEL_SELECTOR: {
|
case DynamicFormItemType.EMBEDDING_MODEL_SELECTOR:
|
||||||
const spaceEmbeddingModels = embeddingModels.filter(
|
// Group embedding models by provider
|
||||||
(m) => m.provider?.requester === LANGBOT_MODELS_PROVIDER_REQUESTER,
|
const groupedEmbeddingModels = embeddingModels.reduce(
|
||||||
);
|
|
||||||
const regularEmbeddingModels = embeddingModels.filter(
|
|
||||||
(m) => m.provider?.requester !== LANGBOT_MODELS_PROVIDER_REQUESTER,
|
|
||||||
);
|
|
||||||
|
|
||||||
const groupedEmbeddingModels = regularEmbeddingModels.reduce(
|
|
||||||
(acc, model) => {
|
(acc, model) => {
|
||||||
const providerName = model.provider?.name || 'Unknown';
|
const providerName = model.provider?.name || 'Unknown';
|
||||||
if (!acc[providerName]) acc[providerName] = [];
|
if (!acc[providerName]) acc[providerName] = [];
|
||||||
@@ -633,33 +586,11 @@ export default function DynamicFormItemComponent({
|
|||||||
{} as Record<string, EmbeddingModel[]>,
|
{} as Record<string, EmbeddingModel[]>,
|
||||||
);
|
);
|
||||||
|
|
||||||
const groupedSpaceEmbeddingModels = spaceEmbeddingModels.reduce(
|
|
||||||
(acc, model) => {
|
|
||||||
const providerName =
|
|
||||||
model.provider?.name || model.provider?.requester || 'Unknown';
|
|
||||||
if (!acc[providerName]) acc[providerName] = [];
|
|
||||||
acc[providerName].push(model);
|
|
||||||
return acc;
|
|
||||||
},
|
|
||||||
{} as Record<string, EmbeddingModel[]>,
|
|
||||||
);
|
|
||||||
|
|
||||||
const previewEmbeddingModelNames = [
|
|
||||||
'text-embedding-3-large',
|
|
||||||
'text-embedding-3-small',
|
|
||||||
'bge-m3',
|
|
||||||
'jina-embeddings-v3',
|
|
||||||
'qwen3-embedding-8b',
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex w-full max-w-md min-w-0 items-center gap-1.5">
|
<div className="w-full max-w-md min-w-0">
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<Select value={field.value} onValueChange={field.onChange}>
|
<Select value={field.value} onValueChange={field.onChange}>
|
||||||
<SelectTrigger className="min-w-0 bg-[#ffffff] dark:bg-[#2a2a2e]">
|
<SelectTrigger className="min-w-0 bg-[#ffffff] dark:bg-[#2a2a2e]">
|
||||||
<SelectValue
|
<SelectValue placeholder={t('knowledge.selectEmbeddingModel')} />
|
||||||
placeholder={t('knowledge.selectEmbeddingModel')}
|
|
||||||
/>
|
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{Object.entries(groupedEmbeddingModels).map(
|
{Object.entries(groupedEmbeddingModels).map(
|
||||||
@@ -674,128 +605,10 @@ export default function DynamicFormItemComponent({
|
|||||||
</SelectGroup>
|
</SelectGroup>
|
||||||
),
|
),
|
||||||
)}
|
)}
|
||||||
{showSpaceLoginCTA ? (
|
|
||||||
<SelectGroup>
|
|
||||||
<SelectLabel>
|
|
||||||
<span className="inline-flex items-center gap-1.5">
|
|
||||||
<Sparkles className="h-3.5 w-3.5 text-purple-500" />
|
|
||||||
{t('models.langbotModels')}
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger
|
|
||||||
asChild
|
|
||||||
onMouseDown={(e) => e.preventDefault()}
|
|
||||||
>
|
|
||||||
<Info className="h-3 w-3 text-muted-foreground cursor-help" />
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="top" className="max-w-[240px]">
|
|
||||||
{t('models.spaceTrialTooltip')}
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</span>
|
|
||||||
</SelectLabel>
|
|
||||||
<div
|
|
||||||
className="relative"
|
|
||||||
onMouseDown={(e) => e.preventDefault()}
|
|
||||||
>
|
|
||||||
{(spaceEmbeddingModels.length > 0
|
|
||||||
? spaceEmbeddingModels.map((m) => m.name)
|
|
||||||
: previewEmbeddingModelNames
|
|
||||||
)
|
|
||||||
.slice(0, 3)
|
|
||||||
.map((name) => (
|
|
||||||
<div
|
|
||||||
key={name}
|
|
||||||
className="relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm text-muted-foreground/60"
|
|
||||||
>
|
|
||||||
{name}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
<div className="relative min-h-10">
|
|
||||||
<div
|
|
||||||
className="select-none overflow-hidden"
|
|
||||||
style={{ maxHeight: '3rem' }}
|
|
||||||
>
|
|
||||||
{(spaceEmbeddingModels.length > 0
|
|
||||||
? spaceEmbeddingModels.map((m) => m.name)
|
|
||||||
: previewEmbeddingModelNames
|
|
||||||
)
|
|
||||||
.slice(3)
|
|
||||||
.map((name) => (
|
|
||||||
<div
|
|
||||||
key={name}
|
|
||||||
className="flex w-full items-center py-1.5 pl-8 pr-2 text-sm text-muted-foreground/40 blur-[2px]"
|
|
||||||
>
|
|
||||||
{name}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
<div className="absolute inset-0 flex items-center justify-center bg-gradient-to-b from-transparent to-background/80">
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="h-7 text-xs px-3 gap-1.5 shadow-sm"
|
|
||||||
onMouseDown={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
handleSpaceLogin();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Sparkles className="h-3 w-3" />
|
|
||||||
{t('models.unlockModels')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</SelectGroup>
|
|
||||||
) : !systemInfo.disable_models_service ? (
|
|
||||||
Object.entries(groupedSpaceEmbeddingModels).map(
|
|
||||||
([providerName, models]) => (
|
|
||||||
<SelectGroup key={providerName}>
|
|
||||||
<SelectLabel>
|
|
||||||
<span className="inline-flex items-center gap-1.5">
|
|
||||||
<Sparkles className="h-3.5 w-3.5 text-purple-500" />
|
|
||||||
{providerName}
|
|
||||||
</span>
|
|
||||||
</SelectLabel>
|
|
||||||
{models.map((model) => (
|
|
||||||
<SelectItem key={model.uuid} value={model.uuid}>
|
|
||||||
{model.name}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectGroup>
|
|
||||||
),
|
|
||||||
)
|
|
||||||
) : null}
|
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-9 w-9 shrink-0"
|
|
||||||
onClick={() => {
|
|
||||||
setSettingsSection('models');
|
|
||||||
setModelsDialogOpen(true);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Settings className="h-4 w-4 text-muted-foreground" />
|
|
||||||
</Button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent side="right">{t('models.title')}</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
<SettingsDialog
|
|
||||||
open={modelsDialogOpen}
|
|
||||||
onOpenChange={handleModelsDialogChange}
|
|
||||||
section={settingsSection}
|
|
||||||
onSectionChange={setSettingsSection}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
|
||||||
|
|
||||||
case DynamicFormItemType.RERANK_MODEL_SELECTOR:
|
case DynamicFormItemType.RERANK_MODEL_SELECTOR:
|
||||||
const groupedRerankModels = rerankModels.reduce(
|
const groupedRerankModels = rerankModels.reduce(
|
||||||
@@ -839,10 +652,10 @@ export default function DynamicFormItemComponent({
|
|||||||
case DynamicFormItemType.MODEL_FALLBACK_SELECTOR: {
|
case DynamicFormItemType.MODEL_FALLBACK_SELECTOR: {
|
||||||
// Separate space models from regular models
|
// Separate space models from regular models
|
||||||
const fbSpaceModels = llmModels.filter(
|
const fbSpaceModels = llmModels.filter(
|
||||||
(m) => m.provider?.requester === LANGBOT_MODELS_PROVIDER_REQUESTER,
|
(m) => m.provider?.requester === 'space-chat-completions',
|
||||||
);
|
);
|
||||||
const fbRegularModels = llmModels.filter(
|
const fbRegularModels = llmModels.filter(
|
||||||
(m) => m.provider?.requester !== LANGBOT_MODELS_PROVIDER_REQUESTER,
|
(m) => m.provider?.requester !== 'space-chat-completions',
|
||||||
);
|
);
|
||||||
|
|
||||||
// Group regular models by provider
|
// Group regular models by provider
|
||||||
@@ -973,7 +786,7 @@ export default function DynamicFormItemComponent({
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{/* Blurred remaining models with login overlay */}
|
{/* Blurred remaining models with login overlay */}
|
||||||
<div className="relative min-h-10">
|
<div className="relative">
|
||||||
<div
|
<div
|
||||||
className="select-none overflow-hidden"
|
className="select-none overflow-hidden"
|
||||||
style={{ maxHeight: '3rem' }}
|
style={{ maxHeight: '3rem' }}
|
||||||
@@ -1186,8 +999,7 @@ export default function DynamicFormItemComponent({
|
|||||||
|
|
||||||
case DynamicFormItemType.KNOWLEDGE_BASE_SELECTOR:
|
case DynamicFormItemType.KNOWLEDGE_BASE_SELECTOR:
|
||||||
// Group KBs by Knowledge Engine name
|
// Group KBs by Knowledge Engine name
|
||||||
const validKnowledgeBases = knowledgeBases.filter(hasUsableUuid);
|
const kbsByEngine = knowledgeBases.reduce(
|
||||||
const kbsByEngine = validKnowledgeBases.reduce(
|
|
||||||
(acc, kb) => {
|
(acc, kb) => {
|
||||||
const engineName = kb.knowledge_engine?.name
|
const engineName = kb.knowledge_engine?.name
|
||||||
? extractI18nObject(kb.knowledge_engine.name)
|
? extractI18nObject(kb.knowledge_engine.name)
|
||||||
@@ -1198,7 +1010,7 @@ export default function DynamicFormItemComponent({
|
|||||||
acc[engineName].push(kb);
|
acc[engineName].push(kb);
|
||||||
return acc;
|
return acc;
|
||||||
},
|
},
|
||||||
{} as Record<string, typeof validKnowledgeBases>,
|
{} as Record<string, typeof knowledgeBases>,
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -1206,7 +1018,7 @@ export default function DynamicFormItemComponent({
|
|||||||
<SelectTrigger className="min-w-0 bg-[#ffffff] dark:bg-[#2a2a2e]">
|
<SelectTrigger className="min-w-0 bg-[#ffffff] dark:bg-[#2a2a2e]">
|
||||||
{field.value && field.value !== '__none__' ? (
|
{field.value && field.value !== '__none__' ? (
|
||||||
(() => {
|
(() => {
|
||||||
const selectedKb = validKnowledgeBases.find(
|
const selectedKb = knowledgeBases.find(
|
||||||
(kb) => kb.uuid === field.value,
|
(kb) => kb.uuid === field.value,
|
||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
@@ -1235,7 +1047,7 @@ export default function DynamicFormItemComponent({
|
|||||||
<SelectGroup key={engineName}>
|
<SelectGroup key={engineName}>
|
||||||
<SelectLabel>{engineName}</SelectLabel>
|
<SelectLabel>{engineName}</SelectLabel>
|
||||||
{kbs.map((base) => (
|
{kbs.map((base) => (
|
||||||
<SelectItem key={base.uuid} value={base.uuid}>
|
<SelectItem key={base.uuid} value={base.uuid ?? ''}>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{base.emoji && (
|
{base.emoji && (
|
||||||
<span className="text-sm shrink-0">{base.emoji}</span>
|
<span className="text-sm shrink-0">{base.emoji}</span>
|
||||||
@@ -1252,8 +1064,7 @@ export default function DynamicFormItemComponent({
|
|||||||
|
|
||||||
case DynamicFormItemType.KNOWLEDGE_BASE_MULTI_SELECTOR:
|
case DynamicFormItemType.KNOWLEDGE_BASE_MULTI_SELECTOR:
|
||||||
// Group KBs by Knowledge Engine name for multi-selector
|
// Group KBs by Knowledge Engine name for multi-selector
|
||||||
const validMultiKnowledgeBases = knowledgeBases.filter(hasUsableUuid);
|
const multiKbsByEngine = knowledgeBases.reduce(
|
||||||
const multiKbsByEngine = validMultiKnowledgeBases.reduce(
|
|
||||||
(acc, kb) => {
|
(acc, kb) => {
|
||||||
const engineName = kb.knowledge_engine?.name
|
const engineName = kb.knowledge_engine?.name
|
||||||
? extractI18nObject(kb.knowledge_engine.name)
|
? extractI18nObject(kb.knowledge_engine.name)
|
||||||
@@ -1264,7 +1075,7 @@ export default function DynamicFormItemComponent({
|
|||||||
acc[engineName].push(kb);
|
acc[engineName].push(kb);
|
||||||
return acc;
|
return acc;
|
||||||
},
|
},
|
||||||
{} as Record<string, typeof validMultiKnowledgeBases>,
|
{} as Record<string, typeof knowledgeBases>,
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -1273,7 +1084,7 @@ export default function DynamicFormItemComponent({
|
|||||||
{field.value && field.value.length > 0 ? (
|
{field.value && field.value.length > 0 ? (
|
||||||
<div className="min-w-0 space-y-2">
|
<div className="min-w-0 space-y-2">
|
||||||
{field.value.map((kbId: string) => {
|
{field.value.map((kbId: string) => {
|
||||||
const currentKb = validMultiKnowledgeBases.find(
|
const currentKb = knowledgeBases.find(
|
||||||
(base) => base.uuid === kbId,
|
(base) => base.uuid === kbId,
|
||||||
);
|
);
|
||||||
if (!currentKb) return null;
|
if (!currentKb) return null;
|
||||||
@@ -1359,13 +1170,15 @@ export default function DynamicFormItemComponent({
|
|||||||
{engineName}
|
{engineName}
|
||||||
</div>
|
</div>
|
||||||
{kbs.map((base) => {
|
{kbs.map((base) => {
|
||||||
const isSelected = tempSelectedKBIds.includes(base.uuid);
|
const isSelected = tempSelectedKBIds.includes(
|
||||||
|
base.uuid ?? '',
|
||||||
|
);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={base.uuid}
|
key={base.uuid}
|
||||||
className="flex items-center gap-3 rounded-lg border p-3 hover:bg-accent cursor-pointer"
|
className="flex items-center gap-3 rounded-lg border p-3 hover:bg-accent cursor-pointer"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
const kbId = base.uuid;
|
const kbId = base.uuid ?? '';
|
||||||
setTempSelectedKBIds((prev) =>
|
setTempSelectedKBIds((prev) =>
|
||||||
prev.includes(kbId)
|
prev.includes(kbId)
|
||||||
? prev.filter((id) => id !== kbId)
|
? prev.filter((id) => id !== kbId)
|
||||||
@@ -1427,8 +1240,8 @@ export default function DynamicFormItemComponent({
|
|||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectGroup>
|
<SelectGroup>
|
||||||
{bots.filter(hasUsableUuid).map((bot) => (
|
{bots.map((bot) => (
|
||||||
<SelectItem key={bot.uuid} value={bot.uuid}>
|
<SelectItem key={bot.uuid} value={bot.uuid ?? ''}>
|
||||||
{bot.name}
|
{bot.name}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
@@ -1570,32 +1383,6 @@ export default function DynamicFormItemComponent({
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
case DynamicFormItemType.RICH_TOOLS_SELECTOR:
|
|
||||||
return (
|
|
||||||
<ToolResourceSelectors
|
|
||||||
mode="tools"
|
|
||||||
pipelineId={systemContext?.pipeline_id as string | undefined}
|
|
||||||
value={{
|
|
||||||
...(formValues || {}),
|
|
||||||
[field.name]: field.value,
|
|
||||||
}}
|
|
||||||
onChange={handleCompositePatch}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
case DynamicFormItemType.RESOURCES_SELECTOR:
|
|
||||||
return (
|
|
||||||
<ToolResourceSelectors
|
|
||||||
mode="resources"
|
|
||||||
pipelineId={systemContext?.pipeline_id as string | undefined}
|
|
||||||
value={{
|
|
||||||
...(formValues || {}),
|
|
||||||
[field.name]: field.value,
|
|
||||||
}}
|
|
||||||
onChange={handleCompositePatch}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
case DynamicFormItemType.PROMPT_EDITOR: {
|
case DynamicFormItemType.PROMPT_EDITOR: {
|
||||||
// Guard: field.value may be undefined when the form resets or
|
// Guard: field.value may be undefined when the form resets or
|
||||||
// initialValues haven't propagated yet. Fall back to a default
|
// initialValues haven't propagated yet. Fall back to a default
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user