mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-07-16 01:16:07 +00:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2383485b86 | |||
| 9ab346c678 | |||
| a0ea0704fc | |||
| 6c5b01fa3c | |||
| 2ef3aebe16 |
@@ -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()
|
||||||
|
|||||||
@@ -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,
|
||||||
|
'description': tool.description,
|
||||||
|
'human_desc': tool.human_desc,
|
||||||
|
'parameters': tool.parameters,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
extensions_prefs = pipeline.get('extensions_preferences', {}) or {}
|
return self.success(data={'tools': tool_list})
|
||||||
if not extensions_prefs.get('enable_all_plugins', True):
|
|
||||||
bound_plugins = [
|
|
||||||
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,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
@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:
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,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)
|
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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,55 +173,28 @@ 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:
|
|
||||||
process_workspace = (
|
|
||||||
self._build_workspace(host_path=host_path, workdir=process_cwd, mount_path=process_cwd)
|
|
||||||
if host_path
|
|
||||||
else workspace
|
|
||||||
)
|
|
||||||
payload = process_workspace.build_process_payload(
|
|
||||||
self.server_config['command'],
|
|
||||||
self.server_config.get('args', []),
|
|
||||||
env=self.server_config.get('env', {}),
|
|
||||||
cwd=process_cwd,
|
|
||||||
)
|
|
||||||
if install_cmd:
|
|
||||||
payload = self._wrap_process_payload_with_python_env(payload, process_cwd)
|
|
||||||
payload['process_id'] = self.process_id
|
|
||||||
await workspace.box_service.start_managed_process(workspace.session_id, payload)
|
|
||||||
except Exception:
|
|
||||||
self.owner.error_phase = MCPSessionErrorPhase.PROCESS_START
|
|
||||||
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:
|
||||||
|
process_workspace = (
|
||||||
|
self._build_workspace(host_path=host_path, workdir=process_cwd, mount_path=process_cwd)
|
||||||
|
if host_path
|
||||||
|
else workspace
|
||||||
|
)
|
||||||
|
payload = process_workspace.build_process_payload(
|
||||||
|
self.server_config['command'],
|
||||||
|
self.server_config.get('args', []),
|
||||||
|
env=self.server_config.get('env', {}),
|
||||||
|
cwd=process_cwd,
|
||||||
|
)
|
||||||
|
if install_cmd:
|
||||||
|
payload = self._wrap_process_payload_with_python_env(payload, process_cwd)
|
||||||
|
payload['process_id'] = self.process_id
|
||||||
|
await workspace.box_service.start_managed_process(workspace.session_id, payload)
|
||||||
|
except Exception:
|
||||||
|
self.owner.error_phase = MCPSessionErrorPhase.PROCESS_START
|
||||||
|
raise
|
||||||
|
|
||||||
|
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):
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,47 +191,45 @@ 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')}
|
</TabsTrigger>
|
||||||
</TabsTrigger>
|
<TabsTrigger value="logs" className="gap-1.5">
|
||||||
<TabsTrigger value="logs" className="gap-1.5">
|
<FileText className="size-3.5" />
|
||||||
<FileText className="size-3.5" />
|
{t('bots.logs')}
|
||||||
{t('bots.logs')}
|
</TabsTrigger>
|
||||||
</TabsTrigger>
|
<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')}
|
{activeTab === 'sessions' && (
|
||||||
</TabsTrigger>
|
<button
|
||||||
</TabsList>
|
type="button"
|
||||||
{activeTab === 'sessions' && (
|
className="inline-flex items-center justify-center ml-0.5"
|
||||||
<button
|
onPointerDown={(e) => e.stopPropagation()}
|
||||||
type="button"
|
onClick={(e) => {
|
||||||
aria-label={t('bots.sessionMonitor.refresh')}
|
e.stopPropagation();
|
||||||
title={t('bots.sessionMonitor.refresh')}
|
e.preventDefault();
|
||||||
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"
|
if (isRefreshingSessions) return;
|
||||||
disabled={isRefreshingSessions}
|
setIsRefreshingSessions(true);
|
||||||
onClick={() => {
|
const minDelay = new Promise((r) => setTimeout(r, 500));
|
||||||
if (isRefreshingSessions) return;
|
Promise.all([
|
||||||
setIsRefreshingSessions(true);
|
sessionMonitorRef.current?.refreshSessions(),
|
||||||
const minDelay = new Promise((r) => setTimeout(r, 500));
|
minDelay,
|
||||||
Promise.all([
|
]).finally(() => setIsRefreshingSessions(false));
|
||||||
sessionMonitorRef.current?.refreshSessions(),
|
}}
|
||||||
minDelay,
|
>
|
||||||
]).finally(() => setIsRefreshingSessions(false));
|
<RefreshCw
|
||||||
}}
|
className={cn(
|
||||||
>
|
'size-3 text-muted-foreground hover:text-foreground transition-colors',
|
||||||
<RefreshCw
|
isRefreshingSessions && 'animate-spin',
|
||||||
className={cn(
|
)}
|
||||||
'size-3.5',
|
/>
|
||||||
isRefreshingSessions && 'animate-spin',
|
</button>
|
||||||
)}
|
)}
|
||||||
/>
|
</TabsTrigger>
|
||||||
</button>
|
</TabsList>
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Tab: Configuration */}
|
{/* Tab: Configuration */}
|
||||||
<TabsContent
|
<TabsContent
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import React, {
|
|||||||
useEffect,
|
useEffect,
|
||||||
useRef,
|
useRef,
|
||||||
useCallback,
|
useCallback,
|
||||||
useMemo,
|
|
||||||
forwardRef,
|
forwardRef,
|
||||||
useImperativeHandle,
|
useImperativeHandle,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
@@ -16,14 +15,11 @@ import {
|
|||||||
Bot,
|
Bot,
|
||||||
Copy,
|
Copy,
|
||||||
Check,
|
Check,
|
||||||
ChevronDown,
|
|
||||||
ChevronRight,
|
|
||||||
Workflow,
|
Workflow,
|
||||||
ThumbsUp,
|
ThumbsUp,
|
||||||
ThumbsDown,
|
ThumbsDown,
|
||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
ShieldOff,
|
ShieldOff,
|
||||||
Wrench,
|
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import BotAdminsDialog, {
|
import BotAdminsDialog, {
|
||||||
@@ -80,35 +76,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,10 +100,6 @@ 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 { admins, reload: reloadAdmins } = useBotAdmins(botId);
|
||||||
const [adminsDialogOpen, setAdminsDialogOpen] = useState(false);
|
const [adminsDialogOpen, setAdminsDialogOpen] = useState(false);
|
||||||
@@ -226,7 +189,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 +197,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 +240,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 +256,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,71 +431,6 @@ 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,
|
||||||
);
|
);
|
||||||
@@ -730,162 +612,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',
|
||||||
|
|||||||
@@ -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,169 +586,29 @@ 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 placeholder={t('knowledge.selectEmbeddingModel')} />
|
||||||
<SelectValue
|
</SelectTrigger>
|
||||||
placeholder={t('knowledge.selectEmbeddingModel')}
|
<SelectContent>
|
||||||
/>
|
{Object.entries(groupedEmbeddingModels).map(
|
||||||
</SelectTrigger>
|
([providerName, models]) => (
|
||||||
<SelectContent>
|
<SelectGroup key={providerName}>
|
||||||
{Object.entries(groupedEmbeddingModels).map(
|
<SelectLabel>{providerName}</SelectLabel>
|
||||||
([providerName, models]) => (
|
{models.map((model) => (
|
||||||
<SelectGroup key={providerName}>
|
<SelectItem key={model.uuid} value={model.uuid}>
|
||||||
<SelectLabel>{providerName}</SelectLabel>
|
{model.name}
|
||||||
{models.map((model) => (
|
</SelectItem>
|
||||||
<SelectItem key={model.uuid} value={model.uuid}>
|
))}
|
||||||
{model.name}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</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>
|
</SelectGroup>
|
||||||
) : !systemInfo.disable_models_service ? (
|
),
|
||||||
Object.entries(groupedSpaceEmbeddingModels).map(
|
)}
|
||||||
([providerName, models]) => (
|
</SelectContent>
|
||||||
<SelectGroup key={providerName}>
|
</Select>
|
||||||
<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>
|
|
||||||
</Select>
|
|
||||||
</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>
|
</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
|
||||||
|
|||||||
@@ -56,13 +56,6 @@ export function getDefaultValues(
|
|||||||
return acc;
|
return acc;
|
||||||
}
|
}
|
||||||
acc[item.name] = item.default;
|
acc[item.name] = item.default;
|
||||||
if (item.type === DynamicFormItemType.RICH_TOOLS_SELECTOR) {
|
|
||||||
acc['enable-all-tools'] = true;
|
|
||||||
}
|
|
||||||
if (item.type === DynamicFormItemType.RESOURCES_SELECTOR) {
|
|
||||||
acc['mcp-resources'] = [];
|
|
||||||
acc['mcp-resource-agent-read-enabled'] = true;
|
|
||||||
}
|
|
||||||
return acc;
|
return acc;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -264,48 +264,6 @@ function saveListExpansionState(state: SidebarListExpansionState) {
|
|||||||
|
|
||||||
// Maximum number of entity sub-items visible before "More" toggle
|
// Maximum number of entity sub-items visible before "More" toggle
|
||||||
const MAX_VISIBLE_ITEMS = 5;
|
const MAX_VISIBLE_ITEMS = 5;
|
||||||
const MCP_REFRESH_POLL_INTERVAL_MS = 1000;
|
|
||||||
const MCP_REFRESH_TIMEOUT_MS = 60000;
|
|
||||||
|
|
||||||
function sleep(ms: number) {
|
|
||||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
||||||
}
|
|
||||||
|
|
||||||
async function waitForMCPRefreshTask(taskId: number) {
|
|
||||||
const deadline = Date.now() + MCP_REFRESH_TIMEOUT_MS;
|
|
||||||
|
|
||||||
while (Date.now() < deadline) {
|
|
||||||
const task = await httpClient.getAsyncTask(taskId);
|
|
||||||
if (task.runtime.done) return task;
|
|
||||||
await sleep(MCP_REFRESH_POLL_INTERVAL_MS);
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error(`Timed out waiting for MCP refresh task ${taskId}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function refreshEnabledMCPConnections() {
|
|
||||||
const resp = await httpClient.getMCPServers();
|
|
||||||
const enabledServers = resp.servers.filter((server) => server.enable);
|
|
||||||
if (enabledServers.length === 0) return;
|
|
||||||
|
|
||||||
const taskResults = await Promise.allSettled(
|
|
||||||
enabledServers.map((server) => httpClient.testMCPServer(server.name, {})),
|
|
||||||
);
|
|
||||||
const taskIds: number[] = [];
|
|
||||||
|
|
||||||
for (const result of taskResults) {
|
|
||||||
if (
|
|
||||||
result.status === 'fulfilled' &&
|
|
||||||
typeof result.value.task_id === 'number'
|
|
||||||
) {
|
|
||||||
taskIds.push(result.value.task_id);
|
|
||||||
} else if (result.status === 'rejected') {
|
|
||||||
console.error('Failed to start MCP refresh task:', result.reason);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
await Promise.allSettled(taskIds.map(waitForMCPRefreshTask));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sort entity items by updatedAt descending (most recent first), items without updatedAt go last
|
// Sort entity items by updatedAt descending (most recent first), items without updatedAt go last
|
||||||
function sortByRecent(items: SidebarEntityItem[]): SidebarEntityItem[] {
|
function sortByRecent(items: SidebarEntityItem[]): SidebarEntityItem[] {
|
||||||
@@ -394,19 +352,11 @@ function NavItems({
|
|||||||
if (extRefreshing) return;
|
if (extRefreshing) return;
|
||||||
setExtRefreshing(true);
|
setExtRefreshing(true);
|
||||||
try {
|
try {
|
||||||
const results = await Promise.allSettled([
|
await Promise.all([
|
||||||
sidebarData.refreshPlugins(),
|
sidebarData.refreshPlugins(),
|
||||||
|
sidebarData.refreshMCPServers(),
|
||||||
sidebarData.refreshSkills(),
|
sidebarData.refreshSkills(),
|
||||||
refreshEnabledMCPConnections(),
|
|
||||||
]);
|
]);
|
||||||
const mcpRefreshResult = results[2];
|
|
||||||
if (mcpRefreshResult.status === 'rejected') {
|
|
||||||
console.error(
|
|
||||||
'Failed to refresh MCP connections:',
|
|
||||||
mcpRefreshResult.reason,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
await sidebarData.refreshMCPServers();
|
|
||||||
} finally {
|
} finally {
|
||||||
setExtRefreshing(false);
|
setExtRefreshing(false);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -157,10 +157,6 @@ export default function MCPDetailContent({ id }: { id: string }) {
|
|||||||
navigate(`/home/mcp?id=${encodeURIComponent(serverName)}`);
|
navigate(`/home/mcp?id=${encodeURIComponent(serverName)}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const handlePersistedTestComplete = useCallback(async () => {
|
|
||||||
await refreshMCPServers();
|
|
||||||
}, [refreshMCPServers]);
|
|
||||||
|
|
||||||
function confirmDelete() {
|
function confirmDelete() {
|
||||||
httpClient
|
httpClient
|
||||||
.deleteMCPServer(id)
|
.deleteMCPServer(id)
|
||||||
@@ -368,7 +364,6 @@ export default function MCPDetailContent({ id }: { id: string }) {
|
|||||||
onRuntimeInfoChange={(runtimeInfo) =>
|
onRuntimeInfoChange={(runtimeInfo) =>
|
||||||
setDetailRuntimeStatus(runtimeInfo?.status ?? null)
|
setDetailRuntimeStatus(runtimeInfo?.status ?? null)
|
||||||
}
|
}
|
||||||
onPersistedTestComplete={handlePersistedTestComplete}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -41,13 +41,10 @@ import {
|
|||||||
} from '@/components/ui/card';
|
} from '@/components/ui/card';
|
||||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||||
import MCPLogs from '@/app/home/mcp/components/mcp-form/MCPLogs';
|
|
||||||
import MCPReadme from '@/app/home/mcp/components/mcp-form/MCPReadme';
|
import MCPReadme from '@/app/home/mcp/components/mcp-form/MCPReadme';
|
||||||
import {
|
import {
|
||||||
MCPServerRuntimeInfo,
|
MCPServerRuntimeInfo,
|
||||||
MCPTool,
|
MCPTool,
|
||||||
MCPResource,
|
|
||||||
MCPResourceContent,
|
|
||||||
MCPServer,
|
MCPServer,
|
||||||
MCPSessionStatus,
|
MCPSessionStatus,
|
||||||
MCPServerExtraArgsRemote,
|
MCPServerExtraArgsRemote,
|
||||||
@@ -247,121 +244,13 @@ function ToolsList({ tools, t }: { tools: MCPTool[]; t: TFunction }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ResourcesList({
|
|
||||||
resources,
|
|
||||||
serverName,
|
|
||||||
t,
|
|
||||||
}: {
|
|
||||||
resources: MCPResource[];
|
|
||||||
serverName: string;
|
|
||||||
t: (key: string) => string;
|
|
||||||
}) {
|
|
||||||
const [expandedUri, setExpandedUri] = React.useState<string | null>(null);
|
|
||||||
const [resourceContent, setResourceContent] =
|
|
||||||
React.useState<MCPResourceContent | null>(null);
|
|
||||||
const [loadingContent, setLoadingContent] = React.useState(false);
|
|
||||||
|
|
||||||
const handleToggleResource = async (uri: string) => {
|
|
||||||
if (expandedUri === uri) {
|
|
||||||
setExpandedUri(null);
|
|
||||||
setResourceContent(null);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setExpandedUri(uri);
|
|
||||||
setResourceContent(null);
|
|
||||||
setLoadingContent(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const resp = await httpClient.readMCPServerResource(
|
|
||||||
serverName,
|
|
||||||
uri,
|
|
||||||
65536,
|
|
||||||
);
|
|
||||||
if (resp.contents && resp.contents.length > 0) {
|
|
||||||
setResourceContent(resp.contents[0]);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
setResourceContent(null);
|
|
||||||
} finally {
|
|
||||||
setLoadingContent(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-2 pb-6">
|
|
||||||
{resources.map((resource, index) => (
|
|
||||||
<Card key={index} className="py-3 shadow-none">
|
|
||||||
<CardHeader
|
|
||||||
className="cursor-pointer"
|
|
||||||
onClick={() => handleToggleResource(resource.uri)}
|
|
||||||
>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<CardTitle className="text-sm">{resource.name}</CardTitle>
|
|
||||||
{resource.mime_type && (
|
|
||||||
<span className="text-xs text-muted-foreground font-mono">
|
|
||||||
{resource.mime_type}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{resource.description && (
|
|
||||||
<CardDescription className="text-xs">
|
|
||||||
{resource.description}
|
|
||||||
</CardDescription>
|
|
||||||
)}
|
|
||||||
<div className="text-xs text-muted-foreground font-mono break-all">
|
|
||||||
{resource.uri}
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
{expandedUri === resource.uri && (
|
|
||||||
<CardContent>
|
|
||||||
{loadingContent ? (
|
|
||||||
<div className="text-xs text-muted-foreground">
|
|
||||||
{t('mcp.loading')}
|
|
||||||
</div>
|
|
||||||
) : resourceContent?.type === 'text' && resourceContent.text ? (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<pre className="text-xs bg-muted p-3 rounded-md overflow-auto max-h-[200px] whitespace-pre-wrap break-all">
|
|
||||||
{resourceContent.text}
|
|
||||||
</pre>
|
|
||||||
{resourceContent.truncated && (
|
|
||||||
<div className="text-xs text-muted-foreground">
|
|
||||||
{t('mcp.resourceTruncated')}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : resourceContent?.type === 'blob' ? (
|
|
||||||
<div className="text-xs text-muted-foreground">
|
|
||||||
{resourceContent.binary_omitted
|
|
||||||
? t('mcp.resourceBinaryOmitted')
|
|
||||||
: t('mcp.resourceBinaryContent')}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="text-xs text-muted-foreground">
|
|
||||||
{t('mcp.resourceReadFailed')}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
)}
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
type RuntimePanelContent = 'all' | 'tools' | 'resources';
|
|
||||||
|
|
||||||
function RuntimePanel({
|
function RuntimePanel({
|
||||||
mcpTesting,
|
mcpTesting,
|
||||||
runtimeInfo,
|
runtimeInfo,
|
||||||
serverName,
|
|
||||||
content = 'all',
|
|
||||||
t,
|
t,
|
||||||
}: {
|
}: {
|
||||||
mcpTesting: boolean;
|
mcpTesting: boolean;
|
||||||
runtimeInfo: MCPServerRuntimeInfo | null;
|
runtimeInfo: MCPServerRuntimeInfo | null;
|
||||||
serverName: string;
|
|
||||||
content?: RuntimePanelContent;
|
|
||||||
t: TFunction;
|
t: TFunction;
|
||||||
}) {
|
}) {
|
||||||
// Show tools whenever we have runtime info — either an edit-mode server or a
|
// Show tools whenever we have runtime info — either an edit-mode server or a
|
||||||
@@ -370,9 +259,7 @@ function RuntimePanel({
|
|||||||
if (!runtimeInfo) {
|
if (!runtimeInfo) {
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-[280px] items-center justify-center rounded-lg border border-dashed text-sm text-muted-foreground">
|
<div className="flex min-h-[280px] items-center justify-center rounded-lg border border-dashed text-sm text-muted-foreground">
|
||||||
{content === 'resources'
|
{t('mcp.noToolsFound')}
|
||||||
? t('mcp.noResourcesFound')
|
|
||||||
: t('mcp.noToolsFound')}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -380,15 +267,6 @@ function RuntimePanel({
|
|||||||
const isConnected =
|
const isConnected =
|
||||||
!mcpTesting && runtimeInfo.status === MCPSessionStatus.CONNECTED;
|
!mcpTesting && runtimeInfo.status === MCPSessionStatus.CONNECTED;
|
||||||
const tools = runtimeInfo.tools || [];
|
const tools = runtimeInfo.tools || [];
|
||||||
const resources = runtimeInfo.resources || [];
|
|
||||||
const showTools = content === 'all' || content === 'tools';
|
|
||||||
const showResources = content === 'all' || content === 'resources';
|
|
||||||
const empty =
|
|
||||||
content === 'tools'
|
|
||||||
? tools.length === 0
|
|
||||||
: content === 'resources'
|
|
||||||
? resources.length === 0
|
|
||||||
: tools.length === 0 && resources.length === 0;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="space-y-4">
|
<section className="space-y-4">
|
||||||
@@ -398,26 +276,11 @@ function RuntimePanel({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isConnected && showTools && tools.length > 0 && (
|
{isConnected && tools.length > 0 && <ToolsList tools={tools} t={t} />}
|
||||||
<ToolsList tools={tools} t={t} />
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isConnected && showResources && resources.length > 0 && (
|
{isConnected && tools.length === 0 && (
|
||||||
<div className="space-y-2">
|
|
||||||
{content === 'all' && (
|
|
||||||
<div className="text-sm font-medium">
|
|
||||||
{t('mcp.resourceCount', { count: resources.length })}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<ResourcesList resources={resources} serverName={serverName} t={t} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{isConnected && empty && (
|
|
||||||
<div className="flex min-h-[220px] items-center justify-center rounded-lg border border-dashed text-sm text-muted-foreground">
|
<div className="flex min-h-[220px] items-center justify-center rounded-lg border border-dashed text-sm text-muted-foreground">
|
||||||
{content === 'resources'
|
{t('mcp.noToolsFound')}
|
||||||
? t('mcp.noResourcesFound')
|
|
||||||
: t('mcp.noToolsFound')}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
@@ -488,7 +351,6 @@ interface MCPFormProps {
|
|||||||
onDirtyChange?: (dirty: boolean) => void;
|
onDirtyChange?: (dirty: boolean) => void;
|
||||||
onTestingChange?: (testing: boolean) => void;
|
onTestingChange?: (testing: boolean) => void;
|
||||||
onRuntimeInfoChange?: (runtimeInfo: MCPServerRuntimeInfo | null) => void;
|
onRuntimeInfoChange?: (runtimeInfo: MCPServerRuntimeInfo | null) => void;
|
||||||
onPersistedTestComplete?: (serverName: string) => void | Promise<void>;
|
|
||||||
/** Reported when the form cannot be saved because the current mode is
|
/** Reported when the form cannot be saved because the current mode is
|
||||||
* ``stdio`` and the Box sandbox is disabled/unavailable. Parents that
|
* ``stdio`` and the Box sandbox is disabled/unavailable. Parents that
|
||||||
* render the Save button outside this component should disable it. */
|
* render the Save button outside this component should disable it. */
|
||||||
@@ -513,7 +375,6 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
|
|||||||
onDirtyChange,
|
onDirtyChange,
|
||||||
onTestingChange,
|
onTestingChange,
|
||||||
onRuntimeInfoChange,
|
onRuntimeInfoChange,
|
||||||
onPersistedTestComplete,
|
|
||||||
onSaveBlockedChange,
|
onSaveBlockedChange,
|
||||||
layout = 'stacked',
|
layout = 'stacked',
|
||||||
sideHeader,
|
sideHeader,
|
||||||
@@ -753,8 +614,6 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
let serverConfig: MCPServer;
|
let serverConfig: MCPServer;
|
||||||
const serverName =
|
|
||||||
isEditMode && initServerName ? initServerName : value.name;
|
|
||||||
|
|
||||||
if (value.mode === 'remote') {
|
if (value.mode === 'remote') {
|
||||||
const headers: Record<string, string> = {};
|
const headers: Record<string, string> = {};
|
||||||
@@ -763,7 +622,7 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
|
|||||||
});
|
});
|
||||||
|
|
||||||
serverConfig = {
|
serverConfig = {
|
||||||
name: serverName,
|
name: value.name,
|
||||||
mode: 'remote',
|
mode: 'remote',
|
||||||
enable: true,
|
enable: true,
|
||||||
extra_args: {
|
extra_args: {
|
||||||
@@ -779,7 +638,7 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
|
|||||||
});
|
});
|
||||||
|
|
||||||
serverConfig = {
|
serverConfig = {
|
||||||
name: serverName,
|
name: value.name,
|
||||||
mode: 'stdio',
|
mode: 'stdio',
|
||||||
enable: true,
|
enable: true,
|
||||||
extra_args: {
|
extra_args: {
|
||||||
@@ -823,10 +682,6 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
|
|||||||
// `uvx` with no package (exit 2 / "Connection closed", no detail).
|
// `uvx` with no package (exit 2 / "Connection closed", no detail).
|
||||||
// The form values are kept in sync on every edit and on load, so they
|
// The form values are kept in sync on every edit and on load, so they
|
||||||
// are always current.
|
// are always current.
|
||||||
const serverName =
|
|
||||||
isEditMode && initServerName ? initServerName : form.getValues('name');
|
|
||||||
const shouldTestPersistedServer =
|
|
||||||
isEditMode && !!initServerName && !form.formState.isDirty;
|
|
||||||
const formExtraArgs = form.getValues('extra_args') ?? [];
|
const formExtraArgs = form.getValues('extra_args') ?? [];
|
||||||
const formStdioArgs = form.getValues('args') ?? [];
|
const formStdioArgs = form.getValues('args') ?? [];
|
||||||
let extraArgsData: MCPServerExtraArgsRemote | MCPServerExtraArgsStdio;
|
let extraArgsData: MCPServerExtraArgsRemote | MCPServerExtraArgsStdio;
|
||||||
@@ -849,20 +704,12 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const testTarget = shouldTestPersistedServer ? serverName : '_';
|
const { task_id } = await httpClient.testMCPServer('_', {
|
||||||
const testPayload = shouldTestPersistedServer
|
name: form.getValues('name'),
|
||||||
? {}
|
mode,
|
||||||
: ({
|
enable: true,
|
||||||
name: serverName,
|
extra_args: extraArgsData,
|
||||||
mode,
|
} as MCPServer);
|
||||||
enable: true,
|
|
||||||
extra_args: extraArgsData,
|
|
||||||
} as MCPServer);
|
|
||||||
|
|
||||||
const { task_id } = await httpClient.testMCPServer(
|
|
||||||
testTarget,
|
|
||||||
testPayload,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!task_id) {
|
if (!task_id) {
|
||||||
throw new Error(t('mcp.noTaskId'));
|
throw new Error(t('mcp.noTaskId'));
|
||||||
@@ -885,21 +732,15 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
|
|||||||
error_message: errorMsg,
|
error_message: errorMsg,
|
||||||
tool_count: 0,
|
tool_count: 0,
|
||||||
tools: [],
|
tools: [],
|
||||||
resource_count: 0,
|
|
||||||
resources: [],
|
|
||||||
});
|
});
|
||||||
if (shouldTestPersistedServer) {
|
|
||||||
await onPersistedTestComplete?.(serverName);
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
if (shouldTestPersistedServer) {
|
if (isEditMode) {
|
||||||
await loadServerForEdit(serverName);
|
await loadServerForEdit(form.getValues('name'));
|
||||||
await onPersistedTestComplete?.(serverName);
|
|
||||||
} else {
|
} else {
|
||||||
// Transient tests have no persisted server to reload tools from.
|
// Create mode has no persisted server to reload tools from.
|
||||||
// The backend stashes the discovered runtime info (status +
|
// The backend stashes the discovered runtime info (status +
|
||||||
// tools) in the task metadata before tearing the transient
|
// tools) in the test task's metadata before tearing the
|
||||||
// session down — surface it so a successful test
|
// transient session down — surface it so a successful test
|
||||||
// shows the tool list instead of "no tools found".
|
// shows the tool list instead of "no tools found".
|
||||||
const runtimeInfoFromTest = taskResp.task_context?.metadata
|
const runtimeInfoFromTest = taskResp.task_context?.metadata
|
||||||
?.runtime_info as MCPServerRuntimeInfo | undefined;
|
?.runtime_info as MCPServerRuntimeInfo | undefined;
|
||||||
@@ -1184,33 +1025,21 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
|
|||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|
||||||
const persistedServerName =
|
|
||||||
isEditMode && initServerName ? initServerName : form.getValues('name');
|
|
||||||
|
|
||||||
const runtimePanel = (
|
const runtimePanel = (
|
||||||
<RuntimePanel
|
<RuntimePanel mcpTesting={mcpTesting} runtimeInfo={runtimeInfo} t={t} />
|
||||||
mcpTesting={mcpTesting}
|
|
||||||
runtimeInfo={runtimeInfo}
|
|
||||||
serverName={persistedServerName}
|
|
||||||
t={t}
|
|
||||||
/>
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// In edit mode the right side shows a tablist switching between the live
|
// In edit mode the right side shows a tablist switching between the live
|
||||||
// Tools/resources lists and the Docs (README captured from LangBot Space at install).
|
// Tools list and the Docs (README captured from LangBot Space at install).
|
||||||
// Create mode has neither, so it falls back to the bare runtime placeholder.
|
// Create mode has neither, so it falls back to the bare runtime placeholder.
|
||||||
// Counts live in the tab labels (only when connected); the panel
|
// The tool count lives in the tab label (only when connected); the panel
|
||||||
// body itself no longer repeats a title/subtitle.
|
// body itself no longer repeats a title/subtitle.
|
||||||
const runtimeConnected =
|
const toolsConnected =
|
||||||
!mcpTesting && runtimeInfo?.status === MCPSessionStatus.CONNECTED;
|
!mcpTesting && runtimeInfo?.status === MCPSessionStatus.CONNECTED;
|
||||||
const toolsCount = runtimeInfo?.tools?.length ?? 0;
|
const toolsCount = runtimeInfo?.tools?.length ?? 0;
|
||||||
const resourcesCount = runtimeInfo?.resources?.length ?? 0;
|
const toolsTabLabel = toolsConnected
|
||||||
const toolsTabLabel = runtimeConnected
|
|
||||||
? `${t('mcp.tabTools')} ${toolsCount}`
|
? `${t('mcp.tabTools')} ${toolsCount}`
|
||||||
: t('mcp.tabTools');
|
: t('mcp.tabTools');
|
||||||
const resourcesTabLabel = runtimeConnected
|
|
||||||
? `${t('mcp.tabResources')} ${resourcesCount}`
|
|
||||||
: t('mcp.tabResources');
|
|
||||||
|
|
||||||
const detailPanel = isEditMode ? (
|
const detailPanel = isEditMode ? (
|
||||||
<Tabs defaultValue="tools" className="flex h-full min-h-0 flex-col">
|
<Tabs defaultValue="tools" className="flex h-full min-h-0 flex-col">
|
||||||
@@ -1221,12 +1050,6 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
|
|||||||
<TabsTrigger value="tools" className="flex-none px-4">
|
<TabsTrigger value="tools" className="flex-none px-4">
|
||||||
{toolsTabLabel}
|
{toolsTabLabel}
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
<TabsTrigger value="resources" className="flex-none px-4">
|
|
||||||
{resourcesTabLabel}
|
|
||||||
</TabsTrigger>
|
|
||||||
<TabsTrigger value="logs" className="flex-none px-4">
|
|
||||||
{t('mcp.tabLogs')}
|
|
||||||
</TabsTrigger>
|
|
||||||
</TabsList>
|
</TabsList>
|
||||||
<TabsContent value="docs" className="mt-4 min-h-0 flex-1 overflow-y-auto">
|
<TabsContent value="docs" className="mt-4 min-h-0 flex-1 overflow-y-auto">
|
||||||
<MCPReadme readme={readme} />
|
<MCPReadme readme={readme} />
|
||||||
@@ -1235,28 +1058,7 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
|
|||||||
value="tools"
|
value="tools"
|
||||||
className="mt-4 min-h-0 flex-1 overflow-y-auto"
|
className="mt-4 min-h-0 flex-1 overflow-y-auto"
|
||||||
>
|
>
|
||||||
<RuntimePanel
|
{runtimePanel}
|
||||||
mcpTesting={mcpTesting}
|
|
||||||
runtimeInfo={runtimeInfo}
|
|
||||||
serverName={persistedServerName}
|
|
||||||
content="tools"
|
|
||||||
t={t}
|
|
||||||
/>
|
|
||||||
</TabsContent>
|
|
||||||
<TabsContent
|
|
||||||
value="resources"
|
|
||||||
className="mt-4 min-h-0 flex-1 overflow-y-auto"
|
|
||||||
>
|
|
||||||
<RuntimePanel
|
|
||||||
mcpTesting={mcpTesting}
|
|
||||||
runtimeInfo={runtimeInfo}
|
|
||||||
serverName={persistedServerName}
|
|
||||||
content="resources"
|
|
||||||
t={t}
|
|
||||||
/>
|
|
||||||
</TabsContent>
|
|
||||||
<TabsContent value="logs" className="mt-4 min-h-0 flex-1 overflow-y-auto">
|
|
||||||
{persistedServerName && <MCPLogs serverName={persistedServerName} />}
|
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -1,149 +0,0 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
|
||||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import { PluginLogEntry } from '@/app/infra/entities/plugin';
|
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Switch } from '@/components/ui/switch';
|
|
||||||
import { Label } from '@/components/ui/label';
|
|
||||||
import {
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
} from '@/components/ui/select';
|
|
||||||
import { RefreshCw } from 'lucide-react';
|
|
||||||
|
|
||||||
const LEVEL_OPTIONS = ['ALL', 'DEBUG', 'INFO', 'WARNING', 'ERROR'] as const;
|
|
||||||
|
|
||||||
function levelClassName(level: string): string {
|
|
||||||
switch (level) {
|
|
||||||
case 'ERROR':
|
|
||||||
case 'CRITICAL':
|
|
||||||
return 'text-red-500';
|
|
||||||
case 'WARNING':
|
|
||||||
return 'text-amber-500';
|
|
||||||
case 'DEBUG':
|
|
||||||
return 'text-gray-400 dark:text-gray-500';
|
|
||||||
default:
|
|
||||||
return 'text-gray-700 dark:text-gray-300';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function MCPLogs({ serverName }: { serverName: string }) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [logs, setLogs] = useState<PluginLogEntry[]>([]);
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
const [level, setLevel] = useState<string>('ALL');
|
|
||||||
const [autoRefresh, setAutoRefresh] = useState(true);
|
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
|
||||||
const atBottomRef = useRef(true);
|
|
||||||
|
|
||||||
const fetchLogs = useCallback(() => {
|
|
||||||
setIsLoading(true);
|
|
||||||
httpClient
|
|
||||||
.getMcpServerLogs(serverName, 500, level === 'ALL' ? undefined : level)
|
|
||||||
.then((res) => {
|
|
||||||
setLogs(res.logs ?? []);
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
setLogs([]);
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
setIsLoading(false);
|
|
||||||
});
|
|
||||||
}, [serverName, level]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
fetchLogs();
|
|
||||||
}, [fetchLogs]);
|
|
||||||
|
|
||||||
// Auto-refresh poll loop.
|
|
||||||
useEffect(() => {
|
|
||||||
if (!autoRefresh) return;
|
|
||||||
const timer = setInterval(fetchLogs, 3000);
|
|
||||||
return () => clearInterval(timer);
|
|
||||||
}, [autoRefresh, fetchLogs]);
|
|
||||||
|
|
||||||
// Keep view pinned to bottom when the user is already at the bottom.
|
|
||||||
useEffect(() => {
|
|
||||||
const el = scrollRef.current;
|
|
||||||
if (el && atBottomRef.current) {
|
|
||||||
el.scrollTop = el.scrollHeight;
|
|
||||||
}
|
|
||||||
}, [logs]);
|
|
||||||
|
|
||||||
function handleScroll() {
|
|
||||||
const el = scrollRef.current;
|
|
||||||
if (!el) return;
|
|
||||||
atBottomRef.current = el.scrollHeight - el.scrollTop - el.clientHeight < 40;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex h-full flex-col">
|
|
||||||
<div className="flex shrink-0 flex-wrap items-center gap-2 px-1 pb-3 sm:px-6">
|
|
||||||
<Select value={level} onValueChange={setLevel}>
|
|
||||||
<SelectTrigger className="h-8 w-[130px]">
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{LEVEL_OPTIONS.map((opt) => (
|
|
||||||
<SelectItem key={opt} value={opt}>
|
|
||||||
{opt === 'ALL' ? t('mcp.logsLevelAll') : opt}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
className="h-8"
|
|
||||||
onClick={fetchLogs}
|
|
||||||
disabled={isLoading}
|
|
||||||
>
|
|
||||||
<RefreshCw
|
|
||||||
className={`mr-1.5 size-3.5 ${isLoading ? 'animate-spin' : ''}`}
|
|
||||||
/>
|
|
||||||
{t('mcp.logsRefresh')}
|
|
||||||
</Button>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Switch
|
|
||||||
id="mcp-logs-auto-refresh"
|
|
||||||
checked={autoRefresh}
|
|
||||||
onCheckedChange={setAutoRefresh}
|
|
||||||
/>
|
|
||||||
<Label
|
|
||||||
htmlFor="mcp-logs-auto-refresh"
|
|
||||||
className="cursor-pointer text-sm font-normal text-muted-foreground"
|
|
||||||
>
|
|
||||||
{t('mcp.logsAutoRefresh')}
|
|
||||||
</Label>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
|
||||||
ref={scrollRef}
|
|
||||||
onScroll={handleScroll}
|
|
||||||
className="min-h-0 flex-1 overflow-auto bg-gray-50 px-3 py-3 font-mono text-xs leading-relaxed dark:bg-gray-900/40 sm:px-6"
|
|
||||||
>
|
|
||||||
{logs.length === 0 ? (
|
|
||||||
<div className="py-8 text-center text-sm text-gray-500 dark:text-gray-400">
|
|
||||||
{t('mcp.logsEmpty')}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
logs.map((entry, idx) => (
|
|
||||||
<div
|
|
||||||
key={`${entry.ts}-${idx}`}
|
|
||||||
className={`whitespace-pre-wrap break-all ${levelClassName(
|
|
||||||
entry.level,
|
|
||||||
)}`}
|
|
||||||
>
|
|
||||||
{entry.text}
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,649 +0,0 @@
|
|||||||
import React from 'react';
|
|
||||||
import { useTranslation } from 'react-i18next';
|
|
||||||
import {
|
|
||||||
AlertCircle,
|
|
||||||
Bot,
|
|
||||||
ChevronDown,
|
|
||||||
ChevronRight,
|
|
||||||
Clock,
|
|
||||||
Cpu,
|
|
||||||
Hash,
|
|
||||||
User,
|
|
||||||
Wrench,
|
|
||||||
} from 'lucide-react';
|
|
||||||
import { cn } from '@/lib/utils';
|
|
||||||
import { MessageContentRenderer } from './MessageContentRenderer';
|
|
||||||
import {
|
|
||||||
ConversationTurn,
|
|
||||||
hasRenderableMessageContent,
|
|
||||||
} from '../utils/conversationTurns';
|
|
||||||
import { MonitoringMessage } from '../types/monitoring';
|
|
||||||
|
|
||||||
interface ConversationTurnListProps {
|
|
||||||
turns: ConversationTurn[];
|
|
||||||
expandedTurnId: string | null;
|
|
||||||
onToggleTurn: (turnId: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
function shortId(id?: string) {
|
|
||||||
if (!id) return '-';
|
|
||||||
if (id.length <= 12) return id;
|
|
||||||
return `${id.slice(0, 8)}...${id.slice(-4)}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDuration(ms: number) {
|
|
||||||
if (!ms) return '0ms';
|
|
||||||
if (ms < 1000) return `${ms}ms`;
|
|
||||||
return `${(ms / 1000).toFixed(2)}s`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function truncateDetail(value?: string) {
|
|
||||||
if (!value) return '';
|
|
||||||
return value.length > 1200 ? `${value.slice(0, 1200)}...` : value;
|
|
||||||
}
|
|
||||||
|
|
||||||
function roleLabel(message: MonitoringMessage | undefined) {
|
|
||||||
const role = message?.role?.toLowerCase();
|
|
||||||
if (role === 'assistant') return 'assistant';
|
|
||||||
if (role === 'user') return 'user';
|
|
||||||
return 'message';
|
|
||||||
}
|
|
||||||
|
|
||||||
function statusClass(level: ConversationTurn['level']) {
|
|
||||||
if (level === 'error') {
|
|
||||||
return 'border-red-200 bg-red-50 text-red-700 dark:border-red-900 dark:bg-red-950/40 dark:text-red-300';
|
|
||||||
}
|
|
||||||
if (level === 'warning') {
|
|
||||||
return 'border-yellow-200 bg-yellow-50 text-yellow-700 dark:border-yellow-900 dark:bg-yellow-950/40 dark:text-yellow-300';
|
|
||||||
}
|
|
||||||
return 'border-green-200 bg-green-50 text-green-700 dark:border-green-900 dark:bg-green-950/40 dark:text-green-300';
|
|
||||||
}
|
|
||||||
|
|
||||||
function Metric({
|
|
||||||
icon,
|
|
||||||
label,
|
|
||||||
tone = 'default',
|
|
||||||
}: {
|
|
||||||
icon: React.ReactNode;
|
|
||||||
label: string;
|
|
||||||
tone?: 'default' | 'error';
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
'inline-flex h-7 items-center gap-1.5 rounded-md border px-2 text-xs font-medium',
|
|
||||||
tone === 'error'
|
|
||||||
? 'border-red-200 bg-red-50 text-red-700 dark:border-red-900 dark:bg-red-950/40 dark:text-red-300'
|
|
||||||
: 'border-border bg-background text-muted-foreground',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{icon}
|
|
||||||
{label}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function MetaItem({ label, value }: { label: string; value?: string }) {
|
|
||||||
return (
|
|
||||||
<div className="min-w-0 rounded-md bg-background px-3 py-2">
|
|
||||||
<div className="text-xs text-muted-foreground">{label}</div>
|
|
||||||
<div className="truncate text-sm font-medium text-foreground">
|
|
||||||
{value || '-'}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function MessageLane({
|
|
||||||
label,
|
|
||||||
icon,
|
|
||||||
content,
|
|
||||||
empty,
|
|
||||||
maxLines,
|
|
||||||
}: {
|
|
||||||
label: string;
|
|
||||||
icon: React.ReactNode;
|
|
||||||
content?: string;
|
|
||||||
empty: string;
|
|
||||||
maxLines: number;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="grid grid-cols-[5.25rem_minmax(0,1fr)] items-start gap-3 text-sm sm:grid-cols-[6rem_minmax(0,1fr)]">
|
|
||||||
<div className="flex h-7 items-center gap-1.5 text-xs font-medium text-muted-foreground">
|
|
||||||
{icon}
|
|
||||||
<span>{label}</span>
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0 rounded-md bg-muted/45 px-3 py-2 text-foreground">
|
|
||||||
{content && hasRenderableMessageContent(content) ? (
|
|
||||||
<MessageContentRenderer content={content} maxLines={maxLines} />
|
|
||||||
) : (
|
|
||||||
<span className="italic text-muted-foreground">{empty}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ExpandedMessage({
|
|
||||||
message,
|
|
||||||
label,
|
|
||||||
}: {
|
|
||||||
message: MonitoringMessage;
|
|
||||||
label: string;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="border-t border-border/70 py-3 first:border-t-0 first:pt-0 last:pb-0">
|
|
||||||
<div className="mb-2 flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
|
|
||||||
<span className="rounded-md bg-muted px-2 py-1 font-medium text-foreground">
|
|
||||||
{label}
|
|
||||||
</span>
|
|
||||||
<span>{message.timestamp.toLocaleString()}</span>
|
|
||||||
<span className="font-mono">ID: {shortId(message.id)}</span>
|
|
||||||
</div>
|
|
||||||
<div className="text-sm leading-6 text-foreground">
|
|
||||||
<MessageContentRenderer content={message.messageContent} maxLines={4} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ConversationTurnList({
|
|
||||||
turns,
|
|
||||||
expandedTurnId,
|
|
||||||
onToggleTurn,
|
|
||||||
}: ConversationTurnListProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [expandedToolCallIds, setExpandedToolCallIds] = React.useState<
|
|
||||||
Record<string, boolean>
|
|
||||||
>({});
|
|
||||||
|
|
||||||
const toggleToolCallDetails = (toolCallKey: string) => {
|
|
||||||
setExpandedToolCallIds((previous) => ({
|
|
||||||
...previous,
|
|
||||||
[toolCallKey]: !previous[toolCallKey],
|
|
||||||
}));
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-4">
|
|
||||||
<div className="flex items-center justify-between text-sm text-muted-foreground">
|
|
||||||
<span className="font-medium text-foreground">
|
|
||||||
{t('monitoring.messageList.turns', {
|
|
||||||
defaultValue: '{{count}} 轮对话',
|
|
||||||
count: turns.length,
|
|
||||||
})}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{turns.map((turn) => {
|
|
||||||
const expanded = expandedTurnId === turn.id;
|
|
||||||
const firstAssistant = turn.assistantMessages[0];
|
|
||||||
const assistantOverflow = Math.max(
|
|
||||||
turn.assistantMessages.length - 1,
|
|
||||||
0,
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={turn.id}
|
|
||||||
className={cn(
|
|
||||||
'overflow-hidden rounded-xl border bg-card transition-colors',
|
|
||||||
turn.level === 'error' && 'border-red-200 dark:border-red-900',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
role="button"
|
|
||||||
tabIndex={0}
|
|
||||||
className="cursor-pointer p-3 outline-none transition-colors hover:bg-accent/60 focus-visible:ring-2 focus-visible:ring-ring sm:p-5"
|
|
||||||
onClick={() => onToggleTurn(turn.id)}
|
|
||||||
onKeyDown={(event) => {
|
|
||||||
if (event.key === 'Enter' || event.key === ' ') {
|
|
||||||
event.preventDefault();
|
|
||||||
onToggleTurn(turn.id);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<div className="mb-2 flex min-w-0 items-center gap-2">
|
|
||||||
{expanded ? (
|
|
||||||
<ChevronDown className="h-5 w-5 shrink-0 text-muted-foreground" />
|
|
||||||
) : (
|
|
||||||
<ChevronRight className="h-5 w-5 shrink-0 text-muted-foreground" />
|
|
||||||
)}
|
|
||||||
<span className="truncate font-mono text-xs text-muted-foreground">
|
|
||||||
Turn: {shortId(turn.id)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mb-3 flex min-w-0 flex-wrap items-center gap-2">
|
|
||||||
<span className="truncate text-sm font-medium text-foreground">
|
|
||||||
{turn.botName}
|
|
||||||
</span>
|
|
||||||
<span className="text-muted-foreground">→</span>
|
|
||||||
<span className="truncate text-sm text-muted-foreground">
|
|
||||||
{turn.pipelineName}
|
|
||||||
</span>
|
|
||||||
{turn.runnerName && (
|
|
||||||
<>
|
|
||||||
<span className="text-muted-foreground">→</span>
|
|
||||||
<span className="truncate text-sm text-muted-foreground">
|
|
||||||
{turn.runnerName}
|
|
||||||
</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<MessageLane
|
|
||||||
label={t('monitoring.messageList.userMessage', {
|
|
||||||
defaultValue: '用户',
|
|
||||||
})}
|
|
||||||
icon={<User className="h-3.5 w-3.5" />}
|
|
||||||
content={turn.userMessage?.messageContent}
|
|
||||||
empty={t('monitoring.messageList.noUserMessage', {
|
|
||||||
defaultValue: '未记录用户输入',
|
|
||||||
})}
|
|
||||||
maxLines={2}
|
|
||||||
/>
|
|
||||||
<MessageLane
|
|
||||||
label={
|
|
||||||
assistantOverflow > 0
|
|
||||||
? t('monitoring.messageList.assistantMessageCount', {
|
|
||||||
defaultValue: '助手 +{{count}}',
|
|
||||||
count: assistantOverflow,
|
|
||||||
})
|
|
||||||
: t('monitoring.messageList.assistantMessage', {
|
|
||||||
defaultValue: '助手',
|
|
||||||
})
|
|
||||||
}
|
|
||||||
icon={<Bot className="h-3.5 w-3.5" />}
|
|
||||||
content={firstAssistant?.messageContent}
|
|
||||||
empty={t('monitoring.messageList.noAssistantMessage', {
|
|
||||||
defaultValue: '未记录助手回复',
|
|
||||||
})}
|
|
||||||
maxLines={2}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex shrink-0 flex-col gap-2 lg:items-end">
|
|
||||||
<div className="text-xs text-muted-foreground">
|
|
||||||
{turn.lastActivityAt.toLocaleString()}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
'inline-flex h-7 items-center rounded-md border px-2 text-xs font-medium',
|
|
||||||
statusClass(turn.level),
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{turn.level}
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap gap-2 lg:justify-end">
|
|
||||||
<Metric
|
|
||||||
icon={<Cpu className="h-3.5 w-3.5" />}
|
|
||||||
label={`${turn.llmCalls.length} LLM`}
|
|
||||||
/>
|
|
||||||
{turn.toolCalls.length > 0 && (
|
|
||||||
<Metric
|
|
||||||
icon={<Wrench className="h-3.5 w-3.5" />}
|
|
||||||
label={`${turn.toolCalls.length} tools`}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
<Metric
|
|
||||||
icon={<Hash className="h-3.5 w-3.5" />}
|
|
||||||
label={`${turn.totalTokens.toLocaleString()} tokens`}
|
|
||||||
/>
|
|
||||||
<Metric
|
|
||||||
icon={<Clock className="h-3.5 w-3.5" />}
|
|
||||||
label={formatDuration(turn.totalDuration)}
|
|
||||||
/>
|
|
||||||
{turn.errors.length > 0 && (
|
|
||||||
<Metric
|
|
||||||
icon={<AlertCircle className="h-3.5 w-3.5" />}
|
|
||||||
label={`${turn.errors.length} errors`}
|
|
||||||
tone="error"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{expanded && (
|
|
||||||
<div className="border-t bg-muted/40 p-3 sm:p-5">
|
|
||||||
<div className="space-y-5 border-l-2 border-border pl-4 sm:pl-6">
|
|
||||||
<div className="grid grid-cols-2 gap-2 lg:grid-cols-5">
|
|
||||||
<MetaItem
|
|
||||||
label={t('monitoring.messageList.platform', {
|
|
||||||
defaultValue: '平台',
|
|
||||||
})}
|
|
||||||
value={turn.platform}
|
|
||||||
/>
|
|
||||||
<MetaItem
|
|
||||||
label={t('monitoring.messageList.user', {
|
|
||||||
defaultValue: '用户',
|
|
||||||
})}
|
|
||||||
value={turn.userName || turn.userId}
|
|
||||||
/>
|
|
||||||
<MetaItem
|
|
||||||
label={t('monitoring.messageList.runner', {
|
|
||||||
defaultValue: '执行器',
|
|
||||||
})}
|
|
||||||
value={turn.runnerName}
|
|
||||||
/>
|
|
||||||
<MetaItem
|
|
||||||
label={t('monitoring.sessions.sessionId', {
|
|
||||||
defaultValue: '会话 ID',
|
|
||||||
})}
|
|
||||||
value={turn.sessionId}
|
|
||||||
/>
|
|
||||||
<MetaItem
|
|
||||||
label={t('monitoring.messageList.messageCount', {
|
|
||||||
defaultValue: '消息数',
|
|
||||||
})}
|
|
||||||
value={String(turn.messages.length)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<section>
|
|
||||||
<h4 className="mb-3 flex items-center gap-2 text-sm font-semibold text-foreground">
|
|
||||||
<Bot className="h-4 w-4" />
|
|
||||||
{t('monitoring.messageList.conversationTrace', {
|
|
||||||
defaultValue: '消息链路',
|
|
||||||
})}
|
|
||||||
</h4>
|
|
||||||
<div className="rounded-lg bg-background px-3 py-3">
|
|
||||||
{turn.messages.map((message) => (
|
|
||||||
<ExpandedMessage
|
|
||||||
key={message.id}
|
|
||||||
message={message}
|
|
||||||
label={t(
|
|
||||||
`monitoring.messageList.roles.${roleLabel(message)}`,
|
|
||||||
{
|
|
||||||
defaultValue:
|
|
||||||
roleLabel(message) === 'assistant'
|
|
||||||
? '助手'
|
|
||||||
: roleLabel(message) === 'user'
|
|
||||||
? '用户'
|
|
||||||
: '消息',
|
|
||||||
},
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section>
|
|
||||||
<h4 className="mb-3 flex items-center gap-2 text-sm font-semibold text-foreground">
|
|
||||||
<Cpu className="h-4 w-4" />
|
|
||||||
{t('monitoring.llmCalls.title', {
|
|
||||||
defaultValue: 'LLM 调用',
|
|
||||||
})}{' '}
|
|
||||||
({turn.llmCalls.length})
|
|
||||||
</h4>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-3 gap-2">
|
|
||||||
<MetaItem
|
|
||||||
label={t('monitoring.llmCalls.totalTokens', {
|
|
||||||
defaultValue: '总 Token',
|
|
||||||
})}
|
|
||||||
value={turn.totalTokens.toLocaleString()}
|
|
||||||
/>
|
|
||||||
<MetaItem
|
|
||||||
label={t('monitoring.llmCalls.inputTokens', {
|
|
||||||
defaultValue: '输入 Token',
|
|
||||||
})}
|
|
||||||
value={turn.inputTokens.toLocaleString()}
|
|
||||||
/>
|
|
||||||
<MetaItem
|
|
||||||
label={t('monitoring.llmCalls.duration', {
|
|
||||||
defaultValue: '耗时',
|
|
||||||
})}
|
|
||||||
value={formatDuration(turn.totalDuration)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-3 rounded-lg bg-background px-3 py-3">
|
|
||||||
{turn.llmCalls.length > 0 ? (
|
|
||||||
turn.llmCalls.map((call, index) => (
|
|
||||||
<div
|
|
||||||
key={call.id}
|
|
||||||
className="border-t border-border/70 py-3 first:border-t-0 first:pt-0 last:pb-0"
|
|
||||||
>
|
|
||||||
<div className="mb-2 flex flex-wrap items-center justify-between gap-2">
|
|
||||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
|
||||||
<span className="text-sm font-medium text-foreground">
|
|
||||||
#{index + 1} {call.modelName}
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
'rounded-md px-2 py-1 text-xs font-medium',
|
|
||||||
call.status === 'success'
|
|
||||||
? 'bg-green-100 text-green-700 dark:bg-green-950 dark:text-green-300'
|
|
||||||
: 'bg-red-100 text-red-700 dark:bg-red-950 dark:text-red-300',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{call.status}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
{formatDuration(call.duration)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap gap-x-8 gap-y-1 text-xs text-muted-foreground">
|
|
||||||
<span>In: {call.tokens.input}</span>
|
|
||||||
<span>Out: {call.tokens.output}</span>
|
|
||||||
<span>Total: {call.tokens.total}</span>
|
|
||||||
<span className="font-mono">
|
|
||||||
ID: {shortId(call.id)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
{call.errorMessage && (
|
|
||||||
<div className="mt-2 whitespace-pre-wrap break-words text-xs text-red-600 dark:text-red-400">
|
|
||||||
{call.errorMessage}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<div className="py-4 text-center text-sm text-muted-foreground">
|
|
||||||
{t('monitoring.messageList.noLlmCalls', {
|
|
||||||
defaultValue: '未记录模型调用',
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section>
|
|
||||||
<h4 className="mb-3 flex items-center gap-2 text-sm font-semibold text-foreground">
|
|
||||||
<Wrench className="h-4 w-4" />
|
|
||||||
{t('monitoring.toolCalls.title', {
|
|
||||||
defaultValue: '工具调用',
|
|
||||||
})}{' '}
|
|
||||||
({turn.toolCalls.length})
|
|
||||||
</h4>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-2 lg:grid-cols-3">
|
|
||||||
<MetaItem
|
|
||||||
label={t('monitoring.toolCalls.totalCalls', {
|
|
||||||
defaultValue: '调用次数',
|
|
||||||
})}
|
|
||||||
value={String(turn.toolCalls.length)}
|
|
||||||
/>
|
|
||||||
<MetaItem
|
|
||||||
label={t('monitoring.toolCalls.duration', {
|
|
||||||
defaultValue: '工具耗时',
|
|
||||||
})}
|
|
||||||
value={formatDuration(turn.totalToolDuration)}
|
|
||||||
/>
|
|
||||||
<MetaItem
|
|
||||||
label={t('monitoring.toolCalls.errorCalls', {
|
|
||||||
defaultValue: '失败次数',
|
|
||||||
})}
|
|
||||||
value={String(
|
|
||||||
turn.toolCalls.filter(
|
|
||||||
(call) => call.status === 'error',
|
|
||||||
).length,
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-3 rounded-lg bg-background px-3 py-3">
|
|
||||||
{turn.toolCalls.length > 0 ? (
|
|
||||||
turn.toolCalls.map((call, index) => {
|
|
||||||
const toolCallKey = `${turn.id}:${call.id}`;
|
|
||||||
const hasToolDetails = Boolean(
|
|
||||||
call.arguments || call.result || call.errorMessage,
|
|
||||||
);
|
|
||||||
const expandedToolCall = Boolean(
|
|
||||||
expandedToolCallIds[toolCallKey],
|
|
||||||
);
|
|
||||||
const detailsId = `monitoring-tool-call-details-${call.id}`;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={call.id}
|
|
||||||
className="border-t border-border/70 py-2 first:border-t-0 first:pt-0 last:pb-0"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={cn(
|
|
||||||
'flex w-full items-start justify-between gap-3 rounded-md px-2 py-2 text-left outline-none transition-colors',
|
|
||||||
hasToolDetails &&
|
|
||||||
'cursor-pointer hover:bg-muted/60 focus-visible:ring-2 focus-visible:ring-ring',
|
|
||||||
)}
|
|
||||||
aria-expanded={
|
|
||||||
hasToolDetails ? expandedToolCall : undefined
|
|
||||||
}
|
|
||||||
aria-controls={
|
|
||||||
hasToolDetails ? detailsId : undefined
|
|
||||||
}
|
|
||||||
aria-disabled={!hasToolDetails}
|
|
||||||
onClick={() =>
|
|
||||||
hasToolDetails &&
|
|
||||||
toggleToolCallDetails(toolCallKey)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
|
||||||
{hasToolDetails &&
|
|
||||||
(expandedToolCall ? (
|
|
||||||
<ChevronDown className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
|
|
||||||
) : (
|
|
||||||
<ChevronRight className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
|
|
||||||
))}
|
|
||||||
<span className="min-w-0 truncate text-sm font-medium text-foreground">
|
|
||||||
#{index + 1} {call.toolName}
|
|
||||||
</span>
|
|
||||||
<span className="rounded-md bg-muted px-2 py-1 text-xs font-medium text-muted-foreground">
|
|
||||||
{call.toolSource}
|
|
||||||
</span>
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
'rounded-md px-2 py-1 text-xs font-medium',
|
|
||||||
call.status === 'success'
|
|
||||||
? 'bg-green-100 text-green-700 dark:bg-green-950 dark:text-green-300'
|
|
||||||
: 'bg-red-100 text-red-700 dark:bg-red-950 dark:text-red-300',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{call.status}
|
|
||||||
</span>
|
|
||||||
<span className="font-mono text-xs text-muted-foreground">
|
|
||||||
ID: {shortId(call.id)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<span className="shrink-0 text-xs text-muted-foreground">
|
|
||||||
{formatDuration(call.duration)}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{hasToolDetails && expandedToolCall && (
|
|
||||||
<div
|
|
||||||
id={detailsId}
|
|
||||||
className="mt-1 grid gap-2 px-2 pb-2 text-xs lg:grid-cols-2"
|
|
||||||
>
|
|
||||||
{call.arguments && (
|
|
||||||
<div className="min-w-0 rounded-md bg-muted/50 p-2">
|
|
||||||
<div className="mb-1 font-medium text-foreground">
|
|
||||||
{t('monitoring.toolCalls.arguments', {
|
|
||||||
defaultValue: '参数',
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
<pre className="whitespace-pre-wrap break-words font-mono text-muted-foreground">
|
|
||||||
{truncateDetail(call.arguments)}
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{call.result && (
|
|
||||||
<div className="min-w-0 rounded-md bg-muted/50 p-2">
|
|
||||||
<div className="mb-1 font-medium text-foreground">
|
|
||||||
{t('monitoring.toolCalls.result', {
|
|
||||||
defaultValue: '结果',
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
<pre className="whitespace-pre-wrap break-words font-mono text-muted-foreground">
|
|
||||||
{truncateDetail(call.result)}
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{call.errorMessage && (
|
|
||||||
<div className="min-w-0 whitespace-pre-wrap break-words rounded-md bg-red-50 p-2 text-red-600 dark:bg-red-950/40 dark:text-red-400 lg:col-span-2">
|
|
||||||
{call.errorMessage}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
) : (
|
|
||||||
<div className="py-4 text-center text-sm text-muted-foreground">
|
|
||||||
{t('monitoring.toolCalls.noToolCalls', {
|
|
||||||
defaultValue: '未记录工具调用',
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{turn.errors.length > 0 && (
|
|
||||||
<section>
|
|
||||||
<h4 className="mb-3 flex items-center gap-2 text-sm font-semibold text-red-700 dark:text-red-300">
|
|
||||||
<AlertCircle className="h-4 w-4" />
|
|
||||||
{t('monitoring.errors.title', {
|
|
||||||
defaultValue: '错误日志',
|
|
||||||
})}{' '}
|
|
||||||
({turn.errors.length})
|
|
||||||
</h4>
|
|
||||||
<div className="rounded-lg bg-background px-3 py-3">
|
|
||||||
{turn.errors.map((error) => (
|
|
||||||
<div
|
|
||||||
key={error.id}
|
|
||||||
className="border-t border-red-200/80 py-3 first:border-t-0 first:pt-0 last:pb-0 dark:border-red-900"
|
|
||||||
>
|
|
||||||
<div className="mb-2 flex flex-wrap items-center justify-between gap-2">
|
|
||||||
<span className="text-sm font-medium text-red-700 dark:text-red-300">
|
|
||||||
{error.errorType}
|
|
||||||
</span>
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
{error.timestamp.toLocaleString()}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="whitespace-pre-wrap break-words text-sm text-red-600 dark:text-red-400">
|
|
||||||
{error.errorMessage}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -106,9 +106,6 @@ export function useMonitoringData(filterState: FilterState) {
|
|||||||
const llmCalls = Array.isArray(response?.llmCalls)
|
const llmCalls = Array.isArray(response?.llmCalls)
|
||||||
? response.llmCalls
|
? response.llmCalls
|
||||||
: [];
|
: [];
|
||||||
const toolCalls = Array.isArray(response?.toolCalls)
|
|
||||||
? response.toolCalls
|
|
||||||
: [];
|
|
||||||
const embeddingCalls = Array.isArray(response?.embeddingCalls)
|
const embeddingCalls = Array.isArray(response?.embeddingCalls)
|
||||||
? response.embeddingCalls
|
? response.embeddingCalls
|
||||||
: [];
|
: [];
|
||||||
@@ -119,7 +116,6 @@ export function useMonitoringData(filterState: FilterState) {
|
|||||||
const totalCount = response?.totalCount ?? {
|
const totalCount = response?.totalCount ?? {
|
||||||
messages: messages.length,
|
messages: messages.length,
|
||||||
llmCalls: llmCalls.length,
|
llmCalls: llmCalls.length,
|
||||||
toolCalls: toolCalls.length,
|
|
||||||
embeddingCalls: embeddingCalls.length,
|
embeddingCalls: embeddingCalls.length,
|
||||||
sessions: sessions.length,
|
sessions: sessions.length,
|
||||||
errors: errors.length,
|
errors: errors.length,
|
||||||
@@ -149,10 +145,8 @@ export function useMonitoringData(filterState: FilterState) {
|
|||||||
level: string;
|
level: string;
|
||||||
platform?: string;
|
platform?: string;
|
||||||
user_id?: string;
|
user_id?: string;
|
||||||
user_name?: string;
|
|
||||||
runner_name?: string;
|
runner_name?: string;
|
||||||
variables?: string;
|
variables?: string;
|
||||||
role?: string;
|
|
||||||
}) => ({
|
}) => ({
|
||||||
id: msg.id,
|
id: msg.id,
|
||||||
timestamp: parseUTCTimestamp(msg.timestamp),
|
timestamp: parseUTCTimestamp(msg.timestamp),
|
||||||
@@ -166,10 +160,8 @@ export function useMonitoringData(filterState: FilterState) {
|
|||||||
level: msg.level as 'info' | 'warning' | 'error' | 'debug',
|
level: msg.level as 'info' | 'warning' | 'error' | 'debug',
|
||||||
platform: msg.platform,
|
platform: msg.platform,
|
||||||
userId: msg.user_id,
|
userId: msg.user_id,
|
||||||
userName: msg.user_name,
|
|
||||||
runnerName: msg.runner_name,
|
runnerName: msg.runner_name,
|
||||||
variables: msg.variables,
|
variables: msg.variables,
|
||||||
role: msg.role,
|
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
llmCalls: llmCalls.map(
|
llmCalls: llmCalls.map(
|
||||||
@@ -187,7 +179,6 @@ export function useMonitoringData(filterState: FilterState) {
|
|||||||
bot_name: string;
|
bot_name: string;
|
||||||
pipeline_id: string;
|
pipeline_id: string;
|
||||||
pipeline_name: string;
|
pipeline_name: string;
|
||||||
session_id?: string;
|
|
||||||
error_message?: string;
|
error_message?: string;
|
||||||
message_id?: string;
|
message_id?: string;
|
||||||
}) => ({
|
}) => ({
|
||||||
@@ -206,46 +197,10 @@ export function useMonitoringData(filterState: FilterState) {
|
|||||||
botName: call.bot_name,
|
botName: call.bot_name,
|
||||||
pipelineId: call.pipeline_id,
|
pipelineId: call.pipeline_id,
|
||||||
pipelineName: call.pipeline_name,
|
pipelineName: call.pipeline_name,
|
||||||
sessionId: call.session_id,
|
|
||||||
errorMessage: call.error_message,
|
errorMessage: call.error_message,
|
||||||
messageId: call.message_id,
|
messageId: call.message_id,
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
toolCalls: toolCalls.map(
|
|
||||||
(call: {
|
|
||||||
id: string;
|
|
||||||
timestamp: string;
|
|
||||||
tool_name: string;
|
|
||||||
tool_source: string;
|
|
||||||
duration: number;
|
|
||||||
status: string;
|
|
||||||
bot_id: string;
|
|
||||||
bot_name: string;
|
|
||||||
pipeline_id: string;
|
|
||||||
pipeline_name: string;
|
|
||||||
session_id?: string;
|
|
||||||
message_id?: string;
|
|
||||||
arguments?: string;
|
|
||||||
result?: string;
|
|
||||||
error_message?: string;
|
|
||||||
}) => ({
|
|
||||||
id: call.id,
|
|
||||||
timestamp: parseUTCTimestamp(call.timestamp),
|
|
||||||
toolName: call.tool_name,
|
|
||||||
toolSource: call.tool_source,
|
|
||||||
duration: call.duration,
|
|
||||||
status: call.status as 'success' | 'error',
|
|
||||||
botId: call.bot_id,
|
|
||||||
botName: call.bot_name,
|
|
||||||
pipelineId: call.pipeline_id,
|
|
||||||
pipelineName: call.pipeline_name,
|
|
||||||
sessionId: call.session_id,
|
|
||||||
messageId: call.message_id,
|
|
||||||
arguments: call.arguments,
|
|
||||||
result: call.result,
|
|
||||||
errorMessage: call.error_message,
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
embeddingCalls: embeddingCalls.map(
|
embeddingCalls: embeddingCalls.map(
|
||||||
(call: {
|
(call: {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -339,7 +294,6 @@ export function useMonitoringData(filterState: FilterState) {
|
|||||||
totalCount: {
|
totalCount: {
|
||||||
messages: totalCount.messages,
|
messages: totalCount.messages,
|
||||||
llmCalls: totalCount.llmCalls,
|
llmCalls: totalCount.llmCalls,
|
||||||
toolCalls: totalCount.toolCalls ?? toolCalls.length,
|
|
||||||
embeddingCalls: totalCount.embeddingCalls || 0,
|
embeddingCalls: totalCount.embeddingCalls || 0,
|
||||||
sessions: totalCount.sessions,
|
sessions: totalCount.sessions,
|
||||||
errors: totalCount.errors,
|
errors: totalCount.errors,
|
||||||
@@ -363,7 +317,6 @@ export function useMonitoringData(filterState: FilterState) {
|
|||||||
botName: call.botName,
|
botName: call.botName,
|
||||||
pipelineId: call.pipelineId,
|
pipelineId: call.pipelineId,
|
||||||
pipelineName: call.pipelineName,
|
pipelineName: call.pipelineName,
|
||||||
sessionId: call.sessionId,
|
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -18,12 +18,60 @@ import { ExportDropdown } from './components/ExportDropdown';
|
|||||||
import { useMonitoringFilters } from './hooks/useMonitoringFilters';
|
import { useMonitoringFilters } from './hooks/useMonitoringFilters';
|
||||||
import { useMonitoringData } from './hooks/useMonitoringData';
|
import { useMonitoringData } from './hooks/useMonitoringData';
|
||||||
import { useFeedbackData } from './hooks/useFeedbackData';
|
import { useFeedbackData } from './hooks/useFeedbackData';
|
||||||
import { ConversationTurnList } from './components/ConversationTurnList';
|
import { MessageDetailsCard } from './components/MessageDetailsCard';
|
||||||
|
import { MessageContentRenderer } from './components/MessageContentRenderer';
|
||||||
import { FeedbackStatsCards } from './components/FeedbackCard';
|
import { FeedbackStatsCards } from './components/FeedbackCard';
|
||||||
import { FeedbackList } from './components/FeedbackList';
|
import { FeedbackList } from './components/FeedbackList';
|
||||||
import { buildConversationTurns } from './utils/conversationTurns';
|
import { MessageDetails } from './types/monitoring';
|
||||||
|
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||||
import { LoadingSpinner, LoadingPage } from '@/components/ui/loading-spinner';
|
import { LoadingSpinner, LoadingPage } from '@/components/ui/loading-spinner';
|
||||||
|
|
||||||
|
interface RawMessageData {
|
||||||
|
id: string;
|
||||||
|
timestamp: string;
|
||||||
|
bot_id: string;
|
||||||
|
bot_name: string;
|
||||||
|
pipeline_id: string;
|
||||||
|
pipeline_name: string;
|
||||||
|
message_content: string;
|
||||||
|
session_id: string;
|
||||||
|
status: string;
|
||||||
|
level: string;
|
||||||
|
platform: string;
|
||||||
|
user_id: string;
|
||||||
|
runner_name: string;
|
||||||
|
variables: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RawLLMCallData {
|
||||||
|
id: string;
|
||||||
|
timestamp: string;
|
||||||
|
model_name: string;
|
||||||
|
status: string;
|
||||||
|
duration: number;
|
||||||
|
error_message: string | null;
|
||||||
|
input_tokens: number;
|
||||||
|
output_tokens: number;
|
||||||
|
total_tokens: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RawLLMStatsData {
|
||||||
|
total_calls: number;
|
||||||
|
total_input_tokens: number;
|
||||||
|
total_output_tokens: number;
|
||||||
|
total_tokens: number;
|
||||||
|
total_duration_ms: number;
|
||||||
|
average_duration_ms: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface RawErrorData {
|
||||||
|
id: string;
|
||||||
|
timestamp: string;
|
||||||
|
error_type: string;
|
||||||
|
error_message: string;
|
||||||
|
stack_trace: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
function MonitoringPageContent() {
|
function MonitoringPageContent() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { filterState, setSelectedBots, setSelectedPipelines, setTimeRange } =
|
const { filterState, setSelectedBots, setSelectedPipelines, setTimeRange } =
|
||||||
@@ -98,37 +146,115 @@ function MonitoringPageContent() {
|
|||||||
setFeedbackRefreshKey((k) => k + 1);
|
setFeedbackRefreshKey((k) => k + 1);
|
||||||
}, [refetch]);
|
}, [refetch]);
|
||||||
|
|
||||||
const conversationTurns = useMemo(
|
const [expandedMessageId, setExpandedMessageId] = useState<string | null>(
|
||||||
() =>
|
null,
|
||||||
buildConversationTurns(
|
);
|
||||||
data?.messages || [],
|
const [messageDetails, setMessageDetails] = useState<
|
||||||
data?.llmCalls || [],
|
Record<string, MessageDetails>
|
||||||
data?.errors || [],
|
>({});
|
||||||
data?.toolCalls || [],
|
const [loadingDetails, setLoadingDetails] = useState<Record<string, boolean>>(
|
||||||
),
|
{},
|
||||||
[data?.messages, data?.llmCalls, data?.errors, data?.toolCalls],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// State for expanded errors
|
// State for expanded errors
|
||||||
const [expandedErrorId, setExpandedErrorId] = useState<string | null>(null);
|
const [expandedErrorId, setExpandedErrorId] = useState<string | null>(null);
|
||||||
const [expandedTurnId, setExpandedTurnId] = useState<string | null>(null);
|
|
||||||
|
|
||||||
// State for controlled tabs
|
// State for controlled tabs
|
||||||
const [activeTab, setActiveTab] = useState<string>('messages');
|
const [activeTab, setActiveTab] = useState<string>('messages');
|
||||||
|
|
||||||
// Function to jump to a message record
|
// Function to jump to a message record
|
||||||
const jumpToMessage = (messageId: string) => {
|
const jumpToMessage = async (messageId: string) => {
|
||||||
setActiveTab('messages');
|
setActiveTab('messages');
|
||||||
|
// Small delay to ensure tab switch completes
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
const turn = conversationTurns.find((item) =>
|
toggleMessageExpand(messageId);
|
||||||
item.messages.some((message) => message.id === messageId),
|
|
||||||
);
|
|
||||||
setExpandedTurnId(turn?.id ?? messageId);
|
|
||||||
}, 100);
|
}, 100);
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleTurnExpand = (turnId: string) => {
|
const toggleMessageExpand = async (messageId: string) => {
|
||||||
setExpandedTurnId((current) => (current === turnId ? null : turnId));
|
if (expandedMessageId === messageId) {
|
||||||
|
// Collapse
|
||||||
|
setExpandedMessageId(null);
|
||||||
|
} else {
|
||||||
|
// Expand
|
||||||
|
setExpandedMessageId(messageId);
|
||||||
|
|
||||||
|
// Fetch details if not already loaded
|
||||||
|
if (!messageDetails[messageId]) {
|
||||||
|
setLoadingDetails({ ...loadingDetails, [messageId]: true });
|
||||||
|
try {
|
||||||
|
// httpClient.get() returns the inner data directly (response.data.data)
|
||||||
|
const result = await httpClient.get<{
|
||||||
|
message_id: string;
|
||||||
|
found: boolean;
|
||||||
|
message: RawMessageData | null;
|
||||||
|
llm_calls: RawLLMCallData[];
|
||||||
|
llm_stats: RawLLMStatsData;
|
||||||
|
errors: RawErrorData[];
|
||||||
|
}>(`/api/v1/monitoring/messages/${messageId}/details`);
|
||||||
|
|
||||||
|
if (result) {
|
||||||
|
setMessageDetails((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[messageId]: {
|
||||||
|
messageId: result.message_id,
|
||||||
|
found: result.found,
|
||||||
|
message: result.message
|
||||||
|
? {
|
||||||
|
id: result.message.id,
|
||||||
|
timestamp: new Date(result.message.timestamp),
|
||||||
|
botId: result.message.bot_id,
|
||||||
|
botName: result.message.bot_name,
|
||||||
|
pipelineId: result.message.pipeline_id,
|
||||||
|
pipelineName: result.message.pipeline_name,
|
||||||
|
messageContent: result.message.message_content,
|
||||||
|
sessionId: result.message.session_id,
|
||||||
|
status: result.message.status,
|
||||||
|
level: result.message.level,
|
||||||
|
platform: result.message.platform,
|
||||||
|
userId: result.message.user_id,
|
||||||
|
runnerName: result.message.runner_name,
|
||||||
|
variables: result.message.variables,
|
||||||
|
}
|
||||||
|
: undefined,
|
||||||
|
llmCalls: result.llm_calls.map((call: RawLLMCallData) => ({
|
||||||
|
id: call.id,
|
||||||
|
timestamp: new Date(call.timestamp),
|
||||||
|
modelName: call.model_name,
|
||||||
|
status: call.status,
|
||||||
|
duration: call.duration,
|
||||||
|
errorMessage: call.error_message,
|
||||||
|
tokens: {
|
||||||
|
input: call.input_tokens || 0,
|
||||||
|
output: call.output_tokens || 0,
|
||||||
|
total: call.total_tokens || 0,
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
errors: result.errors.map((error: RawErrorData) => ({
|
||||||
|
id: error.id,
|
||||||
|
timestamp: new Date(error.timestamp),
|
||||||
|
errorType: error.error_type,
|
||||||
|
errorMessage: error.error_message,
|
||||||
|
stackTrace: error.stack_trace,
|
||||||
|
})),
|
||||||
|
llmStats: {
|
||||||
|
totalCalls: result.llm_stats.total_calls,
|
||||||
|
totalInputTokens: result.llm_stats.total_input_tokens,
|
||||||
|
totalOutputTokens: result.llm_stats.total_output_tokens,
|
||||||
|
totalTokens: result.llm_stats.total_tokens,
|
||||||
|
totalDurationMs: result.llm_stats.total_duration_ms,
|
||||||
|
averageDurationMs: result.llm_stats.average_duration_ms,
|
||||||
|
},
|
||||||
|
} as MessageDetails,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch message details:', error);
|
||||||
|
} finally {
|
||||||
|
setLoadingDetails({ ...loadingDetails, [messageId]: false });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleErrorExpand = (errorId: string) => {
|
const toggleErrorExpand = (errorId: string) => {
|
||||||
@@ -216,22 +342,127 @@ function MonitoringPageContent() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!loading && data && conversationTurns.length > 0 && (
|
{!loading &&
|
||||||
<ConversationTurnList
|
data &&
|
||||||
turns={conversationTurns}
|
data.messages &&
|
||||||
expandedTurnId={expandedTurnId}
|
data.messages.length > 0 && (
|
||||||
onToggleTurn={toggleTurnExpand}
|
<div className="space-y-4">
|
||||||
/>
|
{data.messages
|
||||||
)}
|
.filter((msg) => {
|
||||||
|
// Filter out messages with empty content
|
||||||
|
const content = msg.messageContent?.trim();
|
||||||
|
return (
|
||||||
|
content && content !== '[]' && content !== '""'
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.map((msg) => (
|
||||||
|
<div
|
||||||
|
key={msg.id}
|
||||||
|
className="border rounded-xl overflow-hidden transition-all duration-200"
|
||||||
|
>
|
||||||
|
{/* Message Header - Always Visible */}
|
||||||
|
<div
|
||||||
|
className="p-3 cursor-pointer hover:bg-accent transition-colors sm:p-5"
|
||||||
|
onClick={() => toggleMessageExpand(msg.id)}
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex items-start flex-1">
|
||||||
|
{/* Expand Icon */}
|
||||||
|
<div className="mr-3 mt-0.5">
|
||||||
|
{expandedMessageId === msg.id ? (
|
||||||
|
<ChevronDown className="w-5 h-5 text-muted-foreground" />
|
||||||
|
) : (
|
||||||
|
<ChevronRight className="w-5 h-5 text-muted-foreground" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{!loading && (!data || conversationTurns.length === 0) && (
|
{/* Message Info */}
|
||||||
<div className="flex flex-col items-center justify-center text-muted-foreground py-16 gap-2">
|
<div className="flex-1">
|
||||||
<MessageSquare className="h-[3rem] w-[3rem]" />
|
<div className="flex items-center gap-2 mb-1">
|
||||||
<div className="text-sm">
|
<span className="text-xs text-muted-foreground font-mono">
|
||||||
{t('monitoring.messageList.noMessages')}
|
ID: {msg.id}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 mb-2">
|
||||||
|
<span className="font-medium text-sm text-foreground">
|
||||||
|
{msg.botName}
|
||||||
|
</span>
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
→
|
||||||
|
</span>
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{msg.pipelineName}
|
||||||
|
</span>
|
||||||
|
{msg.runnerName && (
|
||||||
|
<>
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
→
|
||||||
|
</span>
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
{msg.runnerName}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="text-base text-foreground">
|
||||||
|
<MessageContentRenderer
|
||||||
|
content={msg.messageContent}
|
||||||
|
maxLines={3}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status and Timestamp */}
|
||||||
|
<div className="flex flex-col items-end gap-2 ml-4">
|
||||||
|
<span className="text-xs text-muted-foreground whitespace-nowrap">
|
||||||
|
{msg.timestamp.toLocaleString()}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={`text-xs px-2 py-1 rounded ${
|
||||||
|
msg.level === 'error'
|
||||||
|
? 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200'
|
||||||
|
: msg.level === 'warning'
|
||||||
|
? 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200'
|
||||||
|
: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{msg.level}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Expanded Details */}
|
||||||
|
{expandedMessageId === msg.id && (
|
||||||
|
<div className="border-t p-4 bg-muted">
|
||||||
|
{loadingDetails[msg.id] && (
|
||||||
|
<div className="py-4 flex justify-center">
|
||||||
|
<LoadingSpinner size="sm" text="" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{!loadingDetails[msg.id] &&
|
||||||
|
messageDetails[msg.id] && (
|
||||||
|
<MessageDetailsCard
|
||||||
|
details={messageDetails[msg.id]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
)}
|
|
||||||
|
{!loading &&
|
||||||
|
(!data || !data.messages || data.messages.length === 0) && (
|
||||||
|
<div className="flex flex-col items-center justify-center text-muted-foreground py-16 gap-2">
|
||||||
|
<MessageSquare className="h-[3rem] w-[3rem]" />
|
||||||
|
<div className="text-sm">
|
||||||
|
{t('monitoring.messageList.noMessages')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
|
|
||||||
|
|||||||
@@ -11,10 +11,8 @@ export interface MonitoringMessage {
|
|||||||
level: 'info' | 'warning' | 'error' | 'debug';
|
level: 'info' | 'warning' | 'error' | 'debug';
|
||||||
platform?: string;
|
platform?: string;
|
||||||
userId?: string;
|
userId?: string;
|
||||||
userName?: string;
|
|
||||||
runnerName?: string;
|
runnerName?: string;
|
||||||
variables?: string;
|
variables?: string;
|
||||||
role?: 'user' | 'assistant' | string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface LLMCall {
|
export interface LLMCall {
|
||||||
@@ -33,29 +31,10 @@ export interface LLMCall {
|
|||||||
botName: string;
|
botName: string;
|
||||||
pipelineId: string;
|
pipelineId: string;
|
||||||
pipelineName: string;
|
pipelineName: string;
|
||||||
sessionId?: string;
|
|
||||||
errorMessage?: string;
|
errorMessage?: string;
|
||||||
messageId?: string;
|
messageId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ToolCall {
|
|
||||||
id: string;
|
|
||||||
timestamp: Date;
|
|
||||||
toolName: string;
|
|
||||||
toolSource: 'native' | 'plugin' | 'mcp' | 'skill' | string;
|
|
||||||
duration: number;
|
|
||||||
status: 'success' | 'error';
|
|
||||||
botId: string;
|
|
||||||
botName: string;
|
|
||||||
pipelineId: string;
|
|
||||||
pipelineName: string;
|
|
||||||
sessionId?: string;
|
|
||||||
messageId?: string;
|
|
||||||
arguments?: string;
|
|
||||||
result?: string;
|
|
||||||
errorMessage?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface EmbeddingCall {
|
export interface EmbeddingCall {
|
||||||
id: string;
|
id: string;
|
||||||
timestamp: Date;
|
timestamp: Date;
|
||||||
@@ -220,7 +199,6 @@ export interface MonitoringData {
|
|||||||
overview: OverviewMetrics;
|
overview: OverviewMetrics;
|
||||||
messages: MonitoringMessage[];
|
messages: MonitoringMessage[];
|
||||||
llmCalls: LLMCall[];
|
llmCalls: LLMCall[];
|
||||||
toolCalls: ToolCall[];
|
|
||||||
embeddingCalls: EmbeddingCall[];
|
embeddingCalls: EmbeddingCall[];
|
||||||
modelCalls: ModelCall[];
|
modelCalls: ModelCall[];
|
||||||
sessions: SessionInfo[];
|
sessions: SessionInfo[];
|
||||||
@@ -230,7 +208,6 @@ export interface MonitoringData {
|
|||||||
totalCount: {
|
totalCount: {
|
||||||
messages: number;
|
messages: number;
|
||||||
llmCalls: number;
|
llmCalls: number;
|
||||||
toolCalls?: number;
|
|
||||||
embeddingCalls: number;
|
embeddingCalls: number;
|
||||||
sessions: number;
|
sessions: number;
|
||||||
errors: number;
|
errors: number;
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user