mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-07-18 10:26:07 +00:00
Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f89611f39a | |||
| 04a199a3b2 | |||
| f85278f98b | |||
| 0ce382fc8b | |||
| e32e515bc9 | |||
| c809e3d14f | |||
| b1bc05d5d3 | |||
| 13dba887d5 | |||
| cc7a13158e | |||
| 7de7c7f714 | |||
| 85923e3d7b | |||
| c7c6c5dc51 | |||
| b056646696 | |||
| 096ec1a8ce | |||
| 2618e06492 | |||
| 5c8c0eb17b | |||
| ccc51522cf | |||
| 6a99a83f2d | |||
| ebab5343cf | |||
| b97cc800d3 | |||
| 48905ea080 | |||
| ddb77fc43c |
@@ -5,7 +5,7 @@
|
|||||||
|
|
||||||
<div align="center">
|
<div align="center">
|
||||||
|
|
||||||
<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>
|
<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>
|
||||||
|
|
||||||
<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.302.ai/SuTG99) | Gateway | ✅ |
|
| [302.AI](https://share.302ai.cn/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.302.ai/SuTG99) | 聚合平台 | ✅ |
|
| [302.AI](https://share.302ai.cn/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?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>
|
<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>
|
||||||
|
|
||||||
<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.302.ai/SuTG99) | Pasarela | ✅ |
|
| [302.AI](https://share.302ai.cn/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?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>
|
<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>
|
||||||
|
|
||||||
<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.302.ai/SuTG99) | Passerelle | ✅ |
|
| [302.AI](https://share.302ai.cn/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?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>
|
<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>
|
||||||
|
|
||||||
<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.302.ai/SuTG99) | ゲートウェイ | ✅ |
|
| [302.AI](https://share.302ai.cn/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?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>
|
<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>
|
||||||
|
|
||||||
<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.302.ai/SuTG99) | 게이트웨이 | ✅ |
|
| [302.AI](https://share.302ai.cn/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?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>
|
<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>
|
||||||
|
|
||||||
<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.302.ai/SuTG99) | Шлюз | ✅ |
|
| [302.AI](https://share.302ai.cn/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.302.ai/SuTG99) | 聚合平台 | ✅ |
|
| [302.AI](https://share.302ai.cn/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?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>
|
<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>
|
||||||
|
|
||||||
<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.302.ai/SuTG99) | Cổng | ✅ |
|
| [302.AI](https://share.302ai.cn/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)
|
||||||
|
|||||||
@@ -0,0 +1,196 @@
|
|||||||
|
# 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.4"
|
version = "4.10.5"
|
||||||
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.6",
|
"langbot-plugin==0.4.7",
|
||||||
"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",
|
||||||
|
|||||||
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 → Executable
@@ -138,6 +138,39 @@ 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"""
|
||||||
@@ -284,6 +317,16 @@ 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,
|
||||||
@@ -318,12 +361,14 @@ 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,6 +86,10 @@ 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,
|
||||||
}
|
}
|
||||||
@@ -99,6 +103,8 @@ 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,
|
||||||
@@ -108,6 +114,8 @@ class PipelinesRouterGroup(group.RouterGroup):
|
|||||||
enable_all_mcp_servers,
|
enable_all_mcp_servers,
|
||||||
bound_skills=bound_skills,
|
bound_skills=bound_skills,
|
||||||
enable_all_skills=enable_all_skills,
|
enable_all_skills=enable_all_skills,
|
||||||
|
bound_mcp_resources=bound_mcp_resources,
|
||||||
|
mcp_resource_agent_read_enabled=mcp_resource_agent_read_enabled,
|
||||||
)
|
)
|
||||||
|
|
||||||
return self.success()
|
return self.success()
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ class BotsRouterGroup(group.RouterGroup):
|
|||||||
@self.route('/<bot_uuid>', methods=['GET', 'PUT', 'DELETE'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
@self.route('/<bot_uuid>', methods=['GET', 'PUT', 'DELETE'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
||||||
async def _(bot_uuid: str) -> str:
|
async def _(bot_uuid: str) -> str:
|
||||||
if quart.request.method == 'GET':
|
if quart.request.method == 'GET':
|
||||||
# 返回运行时信息,包括webhook地址等
|
|
||||||
bot = await self.ap.bot_service.get_runtime_bot_info(bot_uuid)
|
bot = await self.ap.bot_service.get_runtime_bot_info(bot_uuid)
|
||||||
if bot is None:
|
if bot is None:
|
||||||
return self.http_status(404, -1, 'bot not found')
|
return self.http_status(404, -1, 'bot not found')
|
||||||
@@ -37,30 +36,21 @@ class BotsRouterGroup(group.RouterGroup):
|
|||||||
from_index = json_data.get('from_index', -1)
|
from_index = json_data.get('from_index', -1)
|
||||||
max_count = json_data.get('max_count', 10)
|
max_count = json_data.get('max_count', 10)
|
||||||
logs, total_count = await self.ap.bot_service.list_event_logs(bot_uuid, from_index, max_count)
|
logs, total_count = await self.ap.bot_service.list_event_logs(bot_uuid, from_index, max_count)
|
||||||
return self.success(
|
return self.success(data={'logs': logs, 'total_count': total_count})
|
||||||
data={
|
|
||||||
'logs': logs,
|
|
||||||
'total_count': total_count,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
@self.route('/<bot_uuid>/send_message', methods=['POST'], auth_type=group.AuthType.API_KEY)
|
@self.route('/<bot_uuid>/send_message', methods=['POST'], auth_type=group.AuthType.API_KEY)
|
||||||
async def _(bot_uuid: str) -> str:
|
async def _(bot_uuid: str) -> str:
|
||||||
"""Send message to a specific target via bot"""
|
|
||||||
json_data = await quart.request.json
|
json_data = await quart.request.json
|
||||||
target_type = json_data.get('target_type')
|
target_type = json_data.get('target_type')
|
||||||
target_id = json_data.get('target_id')
|
target_id = json_data.get('target_id')
|
||||||
message_chain_data = json_data.get('message_chain')
|
message_chain_data = json_data.get('message_chain')
|
||||||
|
|
||||||
# Validate required fields
|
|
||||||
if not target_type:
|
if not target_type:
|
||||||
return self.http_status(400, -1, 'target_type is required')
|
return self.http_status(400, -1, 'target_type is required')
|
||||||
if not target_id:
|
if not target_id:
|
||||||
return self.http_status(400, -1, 'target_id is required')
|
return self.http_status(400, -1, 'target_id is required')
|
||||||
if not message_chain_data:
|
if not message_chain_data:
|
||||||
return self.http_status(400, -1, 'message_chain is required')
|
return self.http_status(400, -1, 'message_chain is required')
|
||||||
|
|
||||||
# Validate target_type
|
|
||||||
if target_type not in ['person', 'group']:
|
if target_type not in ['person', 'group']:
|
||||||
return self.http_status(400, -1, 'target_type must be either "person" or "group"')
|
return self.http_status(400, -1, 'target_type must be either "person" or "group"')
|
||||||
|
|
||||||
@@ -72,3 +62,29 @@ class BotsRouterGroup(group.RouterGroup):
|
|||||||
|
|
||||||
traceback.print_exc()
|
traceback.print_exc()
|
||||||
return self.http_status(500, -1, f'Failed to send message: {str(e)}')
|
return self.http_status(500, -1, f'Failed to send message: {str(e)}')
|
||||||
|
|
||||||
|
# ============ Bot Admins ============
|
||||||
|
|
||||||
|
@self.route('/<bot_uuid>/admins', methods=['GET', 'POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
||||||
|
async def _(bot_uuid: str) -> str:
|
||||||
|
if quart.request.method == 'GET':
|
||||||
|
admins = await self.ap.bot_service.get_bot_admins(bot_uuid)
|
||||||
|
return self.success(data={'admins': admins})
|
||||||
|
elif quart.request.method == 'POST':
|
||||||
|
json_data = await quart.request.json
|
||||||
|
launcher_type = json_data.get('launcher_type', '').strip()
|
||||||
|
launcher_id = str(json_data.get('launcher_id', '')).strip()
|
||||||
|
if not launcher_type or not launcher_id:
|
||||||
|
return self.http_status(400, -1, 'launcher_type and launcher_id are required')
|
||||||
|
try:
|
||||||
|
admin_id = await self.ap.bot_service.add_bot_admin(bot_uuid, launcher_type, launcher_id)
|
||||||
|
return self.success(data={'id': admin_id})
|
||||||
|
except Exception as e:
|
||||||
|
return self.http_status(409, -1, str(e))
|
||||||
|
|
||||||
|
@self.route(
|
||||||
|
'/<bot_uuid>/admins/<int:admin_id>', methods=['DELETE'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY
|
||||||
|
)
|
||||||
|
async def _(bot_uuid: str, admin_id: int) -> str:
|
||||||
|
await self.ap.bot_service.delete_bot_admin(bot_uuid, admin_id)
|
||||||
|
return self.success()
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import quart
|
import quart
|
||||||
import traceback
|
import traceback
|
||||||
|
from urllib.parse import unquote
|
||||||
|
|
||||||
|
|
||||||
from ... import group
|
from ... import group
|
||||||
@@ -28,11 +29,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('/servers/<server_name>', methods=['GET', 'PUT', 'DELETE'], auth_type=group.AuthType.USER_TOKEN)
|
@self.route(
|
||||||
|
'/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)
|
||||||
@@ -57,12 +58,59 @@ 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/<server_name>/test', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
|
@self.route('/servers/<path: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>/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,5 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import quart
|
||||||
|
|
||||||
from ... import group
|
from ... import group
|
||||||
|
|
||||||
|
|
||||||
@@ -9,25 +11,41 @@ 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:
|
||||||
"""获取所有可用工具列表"""
|
"""获取所有可用工具列表"""
|
||||||
tools = await self.ap.tool_mgr.get_all_tools()
|
pipeline_uuid = quart.request.args.get('pipeline_uuid') or quart.request.args.get('pipeline_id')
|
||||||
|
bound_plugins: list[str] | None = None
|
||||||
|
bound_mcp_servers: list[str] | None = None
|
||||||
|
|
||||||
tool_list = []
|
if pipeline_uuid:
|
||||||
for tool in tools:
|
pipeline = await self.ap.pipeline_service.get_pipeline(pipeline_uuid)
|
||||||
tool_list.append(
|
if pipeline is None:
|
||||||
{
|
return self.http_status(404, -1, 'pipeline not found')
|
||||||
'name': tool.name,
|
|
||||||
'description': tool.description,
|
|
||||||
'human_desc': tool.human_desc,
|
|
||||||
'parameters': tool.parameters,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
return self.success(data={'tools': tool_list})
|
extensions_prefs = pipeline.get('extensions_preferences', {}) or {}
|
||||||
|
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()
|
tools = await self.ap.tool_mgr.get_all_tools(include_skill_authoring=True)
|
||||||
|
|
||||||
for tool in tools:
|
for tool in tools:
|
||||||
if tool.name == tool_name:
|
if tool.name == tool_name:
|
||||||
|
|||||||
@@ -195,6 +195,13 @@ class UserRouterGroup(group.RouterGroup):
|
|||||||
@self.route('/set-password', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
|
@self.route('/set-password', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
|
||||||
async def _(user_email: str) -> str:
|
async def _(user_email: str) -> str:
|
||||||
"""Set password for Space account (first time) or change password"""
|
"""Set password for Space account (first time) or change password"""
|
||||||
|
# Check if modifying login info is allowed
|
||||||
|
allow_modify_login_info = self.ap.instance_config.data.get('system', {}).get(
|
||||||
|
'allow_modify_login_info', True
|
||||||
|
)
|
||||||
|
if not allow_modify_login_info:
|
||||||
|
return self.http_status(403, -1, 'Modifying login info is disabled')
|
||||||
|
|
||||||
json_data = await quart.request.json
|
json_data = await quart.request.json
|
||||||
new_password = json_data.get('new_password')
|
new_password = json_data.get('new_password')
|
||||||
current_password = json_data.get('current_password')
|
current_password = json_data.get('current_password')
|
||||||
|
|||||||
@@ -199,3 +199,35 @@ class BotService:
|
|||||||
|
|
||||||
# Send message via adapter
|
# Send message via adapter
|
||||||
await runtime_bot.adapter.send_message(target_type, str(target_id), message_chain)
|
await runtime_bot.adapter.send_message(target_type, str(target_id), message_chain)
|
||||||
|
|
||||||
|
# ============ Bot Admins ============
|
||||||
|
|
||||||
|
async def get_bot_admins(self, bot_uuid: str) -> list[dict]:
|
||||||
|
from ....entity.persistence import bot as persistence_bot
|
||||||
|
|
||||||
|
result = await self.ap.persistence_mgr.execute_async(
|
||||||
|
sqlalchemy.select(persistence_bot.BotAdmin).where(persistence_bot.BotAdmin.bot_uuid == bot_uuid)
|
||||||
|
)
|
||||||
|
return [{'id': r.id, 'launcher_type': r.launcher_type, 'launcher_id': r.launcher_id} for r in result.all()]
|
||||||
|
|
||||||
|
async def add_bot_admin(self, bot_uuid: str, launcher_type: str, launcher_id: str) -> int:
|
||||||
|
from ....entity.persistence import bot as persistence_bot
|
||||||
|
|
||||||
|
result = await self.ap.persistence_mgr.execute_async(
|
||||||
|
sqlalchemy.insert(persistence_bot.BotAdmin).values(
|
||||||
|
bot_uuid=bot_uuid,
|
||||||
|
launcher_type=launcher_type,
|
||||||
|
launcher_id=launcher_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return result.inserted_primary_key[0]
|
||||||
|
|
||||||
|
async def delete_bot_admin(self, bot_uuid: str, admin_id: int) -> None:
|
||||||
|
from ....entity.persistence import bot as persistence_bot
|
||||||
|
|
||||||
|
await self.ap.persistence_mgr.execute_async(
|
||||||
|
sqlalchemy.delete(persistence_bot.BotAdmin).where(
|
||||||
|
persistence_bot.BotAdmin.bot_uuid == bot_uuid,
|
||||||
|
persistence_bot.BotAdmin.id == admin_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|||||||
@@ -243,6 +243,7 @@ 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,6 +48,17 @@ 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))
|
||||||
|
|
||||||
@@ -136,6 +147,32 @@ 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"""
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ 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
|
||||||
@@ -50,6 +51,12 @@ 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,
|
||||||
@@ -131,6 +138,68 @@ 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(
|
||||||
@@ -220,6 +289,57 @@ 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,
|
||||||
@@ -749,6 +869,58 @@ 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,
|
||||||
@@ -971,6 +1143,34 @@ 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)
|
||||||
@@ -1014,6 +1214,14 @@ 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,6 +100,8 @@ 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(
|
||||||
@@ -193,6 +195,8 @@ 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,
|
||||||
}
|
}
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
@@ -217,6 +221,8 @@ 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
|
||||||
@@ -236,10 +242,14 @@ class PipelineService:
|
|||||||
extensions_preferences['enable_all_mcp_servers'] = enable_all_mcp_servers
|
extensions_preferences['enable_all_mcp_servers'] = enable_all_mcp_servers
|
||||||
extensions_preferences['enable_all_skills'] = enable_all_skills
|
extensions_preferences['enable_all_skills'] = enable_all_skills
|
||||||
extensions_preferences['plugins'] = bound_plugins
|
extensions_preferences['plugins'] = bound_plugins
|
||||||
|
if mcp_resource_agent_read_enabled is not None:
|
||||||
|
extensions_preferences['mcp_resource_agent_read_enabled'] = mcp_resource_agent_read_enabled
|
||||||
if bound_mcp_servers is not None:
|
if bound_mcp_servers is not None:
|
||||||
extensions_preferences['mcp_servers'] = bound_mcp_servers
|
extensions_preferences['mcp_servers'] = bound_mcp_servers
|
||||||
if bound_skills is not None:
|
if bound_skills is not None:
|
||||||
extensions_preferences['skills'] = bound_skills
|
extensions_preferences['skills'] = bound_skills
|
||||||
|
if bound_mcp_resources is not None:
|
||||||
|
extensions_preferences['mcp_resources'] = bound_mcp_resources
|
||||||
|
|
||||||
await self.ap.persistence_mgr.execute_async(
|
await self.ap.persistence_mgr.execute_async(
|
||||||
sqlalchemy.update(persistence_pipeline.LegacyPipeline)
|
sqlalchemy.update(persistence_pipeline.LegacyPipeline)
|
||||||
|
|||||||
@@ -84,7 +84,17 @@ class CommandManager:
|
|||||||
|
|
||||||
privilege = 1
|
privilege = 1
|
||||||
|
|
||||||
if f'{query.launcher_type.value}_{query.launcher_id}' in self.ap.instance_config.data['admins']:
|
import sqlalchemy as _sa
|
||||||
|
from ..entity.persistence.bot import BotAdmin as _BotAdmin
|
||||||
|
|
||||||
|
_admins = await self.ap.persistence_mgr.execute_async(
|
||||||
|
_sa.select(_BotAdmin).where(
|
||||||
|
_BotAdmin.bot_uuid == (query.bot_uuid or ''),
|
||||||
|
_BotAdmin.launcher_type == query.launcher_type.value,
|
||||||
|
_BotAdmin.launcher_id == str(query.launcher_id),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if _admins.first() is not None:
|
||||||
privilege = 2
|
privilege = 2
|
||||||
|
|
||||||
ctx = command_context.ExecuteContext(
|
ctx = command_context.ExecuteContext(
|
||||||
|
|||||||
@@ -3,6 +3,20 @@ import sqlalchemy
|
|||||||
from .base import Base
|
from .base import Base
|
||||||
|
|
||||||
|
|
||||||
|
class BotAdmin(Base):
|
||||||
|
"""Bot admin — a launcher that has admin privilege for a specific bot's commands"""
|
||||||
|
|
||||||
|
__tablename__ = 'bot_admins'
|
||||||
|
|
||||||
|
id = sqlalchemy.Column(sqlalchemy.Integer, primary_key=True, autoincrement=True)
|
||||||
|
bot_uuid = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
|
||||||
|
launcher_type = sqlalchemy.Column(sqlalchemy.String(64), nullable=False)
|
||||||
|
launcher_id = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
|
||||||
|
created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now())
|
||||||
|
|
||||||
|
__table_args__ = (sqlalchemy.UniqueConstraint('bot_uuid', 'launcher_type', 'launcher_id', name='uq_bot_admin'),)
|
||||||
|
|
||||||
|
|
||||||
class Bot(Base):
|
class Bot(Base):
|
||||||
"""Bot"""
|
"""Bot"""
|
||||||
|
|
||||||
|
|||||||
@@ -49,6 +49,28 @@ 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,7 +26,14 @@ class LegacyPipeline(Base):
|
|||||||
extensions_preferences = sqlalchemy.Column(
|
extensions_preferences = sqlalchemy.Column(
|
||||||
sqlalchemy.JSON,
|
sqlalchemy.JSON,
|
||||||
nullable=False,
|
nullable=False,
|
||||||
default={'enable_all_plugins': True, 'enable_all_mcp_servers': True, 'plugins': [], 'mcp_servers': []},
|
default={
|
||||||
|
'enable_all_plugins': True,
|
||||||
|
'enable_all_mcp_servers': True,
|
||||||
|
'plugins': [],
|
||||||
|
'mcp_servers': [],
|
||||||
|
'mcp_resources': [],
|
||||||
|
'mcp_resource_agent_read_enabled': True,
|
||||||
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
"""add bot_admins table and migrate config admins
|
||||||
|
|
||||||
|
Revision ID: 0007_add_bot_admins
|
||||||
|
Revises: 0006_normalize_mcp_remote_mode
|
||||||
|
Create Date: 2026-06-26
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision = '0007_add_bot_admins'
|
||||||
|
down_revision = '0006_normalize_mcp_remote_mode'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
conn = op.get_bind()
|
||||||
|
if 'bot_admins' in sa.inspect(conn).get_table_names():
|
||||||
|
return
|
||||||
|
op.create_table(
|
||||||
|
'bot_admins',
|
||||||
|
sa.Column('id', sa.Integer, primary_key=True, autoincrement=True),
|
||||||
|
sa.Column('bot_uuid', sa.String(255), nullable=False),
|
||||||
|
sa.Column('launcher_type', sa.String(64), nullable=False),
|
||||||
|
sa.Column('launcher_id', sa.String(255), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime, nullable=False, server_default=sa.func.now()),
|
||||||
|
sa.UniqueConstraint('bot_uuid', 'launcher_type', 'launcher_id', name='uq_bot_admin'),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Migrate old config-based admins into the first bot (best-effort)
|
||||||
|
inspector = sa.inspect(conn)
|
||||||
|
tables = inspector.get_table_names()
|
||||||
|
|
||||||
|
if 'bots' not in tables:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Read the first bot uuid
|
||||||
|
row = conn.execute(sa.text('SELECT uuid FROM bots ORDER BY created_at LIMIT 1')).first()
|
||||||
|
if row is None:
|
||||||
|
return
|
||||||
|
first_bot_uuid = row[0]
|
||||||
|
|
||||||
|
# Read instance_config metadata key that holds the admins list
|
||||||
|
if 'metadata' not in tables:
|
||||||
|
return
|
||||||
|
meta_row = conn.execute(sa.text("SELECT value FROM metadata WHERE key = 'instance_config'")).first()
|
||||||
|
if meta_row is None:
|
||||||
|
return
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
try:
|
||||||
|
cfg = json.loads(meta_row[0])
|
||||||
|
except Exception:
|
||||||
|
return
|
||||||
|
|
||||||
|
admins = cfg.get('admins', [])
|
||||||
|
for entry in admins:
|
||||||
|
parts = entry.split('_', 1)
|
||||||
|
if len(parts) != 2:
|
||||||
|
continue
|
||||||
|
launcher_type, launcher_id = parts
|
||||||
|
try:
|
||||||
|
conn.execute(
|
||||||
|
sa.text(
|
||||||
|
'INSERT OR IGNORE INTO bot_admins (bot_uuid, launcher_type, launcher_id) VALUES (:bu, :lt, :li)'
|
||||||
|
),
|
||||||
|
{'bu': first_bot_uuid, 'lt': launcher_type, 'li': launcher_id},
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
# Remove admins key from stored config
|
||||||
|
if 'admins' in cfg:
|
||||||
|
del cfg['admins']
|
||||||
|
conn.execute(
|
||||||
|
sa.text("UPDATE metadata SET value = :v WHERE key = 'instance_config'"),
|
||||||
|
{'v': json.dumps(cfg)},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_table('bot_admins')
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
"""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)
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
from langbot.pkg.entity.persistence import monitoring as persistence_monitoring
|
||||||
|
from .. import migration
|
||||||
|
|
||||||
|
|
||||||
|
@migration.migration_class(26)
|
||||||
|
class DBMigrateMonitoringToolCalls(migration.DBMigration):
|
||||||
|
"""Add monitoring_tool_calls table"""
|
||||||
|
|
||||||
|
async def upgrade(self):
|
||||||
|
"""Upgrade"""
|
||||||
|
async with self.ap.persistence_mgr.get_db_engine().begin() as conn:
|
||||||
|
await conn.run_sync(persistence_monitoring.MonitoringToolCall.__table__.create, checkfirst=True)
|
||||||
|
|
||||||
|
async def downgrade(self):
|
||||||
|
"""Downgrade"""
|
||||||
|
async with self.ap.persistence_mgr.get_db_engine().begin() as conn:
|
||||||
|
await conn.run_sync(persistence_monitoring.MonitoringToolCall.__table__.drop, checkfirst=True)
|
||||||
@@ -32,7 +32,7 @@ class MonitoringHelper:
|
|||||||
"""Record the start of query processing, returns message_id"""
|
"""Record the start of query processing, returns message_id"""
|
||||||
try:
|
try:
|
||||||
# Check if session exists, if not, record session start
|
# Check if session exists, if not, record session start
|
||||||
session_id = f'{query.launcher_type}_{query.launcher_id}'
|
session_id = f'{query.launcher_type.value if hasattr(query.launcher_type, "value") else query.launcher_type}_{query.launcher_id}'
|
||||||
|
|
||||||
# Get sender name from message event
|
# Get sender name from message event
|
||||||
sender_name = None
|
sender_name = None
|
||||||
@@ -137,7 +137,7 @@ class MonitoringHelper:
|
|||||||
):
|
):
|
||||||
"""Record bot response message to monitoring"""
|
"""Record bot response message to monitoring"""
|
||||||
try:
|
try:
|
||||||
session_id = f'{query.launcher_type}_{query.launcher_id}'
|
session_id = f'{query.launcher_type.value if hasattr(query.launcher_type, "value") else query.launcher_type}_{query.launcher_id}'
|
||||||
|
|
||||||
# Get sender name from message event
|
# Get sender name from message event
|
||||||
sender_name = None
|
sender_name = None
|
||||||
@@ -202,7 +202,7 @@ class MonitoringHelper:
|
|||||||
) -> str:
|
) -> str:
|
||||||
"""Record query processing error, returns message_id"""
|
"""Record query processing error, returns message_id"""
|
||||||
try:
|
try:
|
||||||
session_id = f'{query.launcher_type}_{query.launcher_id}'
|
session_id = f'{query.launcher_type.value if hasattr(query.launcher_type, "value") else query.launcher_type}_{query.launcher_id}'
|
||||||
|
|
||||||
# Get sender name from message event
|
# Get sender name from message event
|
||||||
sender_name = None
|
sender_name = None
|
||||||
@@ -268,7 +268,7 @@ class MonitoringHelper:
|
|||||||
):
|
):
|
||||||
"""Record LLM call"""
|
"""Record LLM call"""
|
||||||
try:
|
try:
|
||||||
session_id = f'{query.launcher_type}_{query.launcher_id}'
|
session_id = f'{query.launcher_type.value if hasattr(query.launcher_type, "value") else query.launcher_type}_{query.launcher_id}'
|
||||||
|
|
||||||
await ap.monitoring_service.record_llm_call(
|
await ap.monitoring_service.record_llm_call(
|
||||||
bot_id=bot_id,
|
bot_id=bot_id,
|
||||||
|
|||||||
@@ -96,6 +96,15 @@ 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
|
||||||
@@ -116,6 +125,8 @@ 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:
|
||||||
@@ -178,7 +189,7 @@ class RuntimePipeline:
|
|||||||
bot_name = query.variables.get('_monitoring_bot_name', 'Unknown')
|
bot_name = query.variables.get('_monitoring_bot_name', 'Unknown')
|
||||||
pipeline_name = query.variables.get('_monitoring_pipeline_name', 'Unknown')
|
pipeline_name = query.variables.get('_monitoring_pipeline_name', 'Unknown')
|
||||||
message_id = query.variables.get('_monitoring_message_id', '')
|
message_id = query.variables.get('_monitoring_message_id', '')
|
||||||
session_id = f'{query.launcher_type}_{query.launcher_id}'
|
session_id = f'{query.launcher_type.value if hasattr(query.launcher_type, "value") else query.launcher_type}_{query.launcher_id}'
|
||||||
|
|
||||||
# Update message status to error
|
# Update message status to error
|
||||||
if message_id:
|
if message_id:
|
||||||
|
|||||||
@@ -25,6 +25,21 @@ 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,
|
||||||
@@ -32,6 +47,7 @@ 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
|
||||||
)
|
)
|
||||||
@@ -43,7 +59,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 = query.pipeline_config['ai']['local-agent'].get('model', {})
|
model_config = local_agent_config.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
|
||||||
@@ -113,11 +129,14 @@ 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)
|
||||||
query.use_funcs = await self.ap.tool_mgr.get_all_tools(
|
include_mcp_resource_tools = query.variables.get('_pipeline_mcp_resource_agent_read_enabled', True)
|
||||||
|
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}')
|
||||||
@@ -128,11 +147,14 @@ 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)
|
||||||
query.use_funcs = await self.ap.tool_mgr.get_all_tools(
|
include_mcp_resource_tools = query.variables.get('_pipeline_mcp_resource_agent_read_enabled', True)
|
||||||
|
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,6 +4,7 @@ import asyncio
|
|||||||
import traceback
|
import traceback
|
||||||
import datetime
|
import datetime
|
||||||
import json
|
import json
|
||||||
|
import time
|
||||||
|
|
||||||
import aiocqhttp
|
import aiocqhttp
|
||||||
import pydantic
|
import pydantic
|
||||||
@@ -16,6 +17,37 @@ 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(
|
||||||
@@ -35,7 +67,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 = msg.base64
|
arg = _normalize_base64_payload(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
|
||||||
@@ -50,7 +82,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 = msg.base64
|
arg = _normalize_base64_payload(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
|
||||||
@@ -62,7 +94,10 @@ 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):
|
||||||
msg_list.append({'type': 'file', 'data': {'file': msg.url, 'name': msg.name}})
|
file = msg.url or msg.path
|
||||||
|
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))
|
||||||
@@ -324,16 +359,96 @@ 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
|
||||||
|
|
||||||
@staticmethod
|
async def _get_group_name(self, group_id: typing.Union[int, str], bot=None) -> str:
|
||||||
async def target2yiri(event: aiocqhttp.Event, bot=None):
|
now = time.monotonic()
|
||||||
|
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':
|
||||||
@@ -343,14 +458,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=event.sender['nickname'],
|
member_name=_get_group_member_name(event.sender),
|
||||||
permission=permission,
|
permission=permission,
|
||||||
group=platform_entities.Group(
|
group=platform_entities.Group(
|
||||||
id=event.group_id,
|
id=event.group_id,
|
||||||
name=event.sender['nickname'],
|
name=group_name,
|
||||||
permission=platform_entities.Permission.Member,
|
permission=platform_entities.Permission.Member,
|
||||||
),
|
),
|
||||||
special_title=event.sender['title'] if 'title' in event.sender else '',
|
special_title=special_title,
|
||||||
),
|
),
|
||||||
message_chain=yiri_chain,
|
message_chain=yiri_chain,
|
||||||
time=event.time,
|
time=event.time,
|
||||||
@@ -374,7 +489,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 = AiocqhttpEventConverter()
|
event_converter: AiocqhttpEventConverter = pydantic.Field(default_factory=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]] = []
|
||||||
|
|
||||||
@@ -433,9 +548,7 @@ 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 = component.base64
|
b64 = _normalize_base64_payload(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,6 +2,7 @@ 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
|
||||||
@@ -182,7 +183,28 @@ 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):
|
||||||
pass
|
if target_type != 'person':
|
||||||
|
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,6 +417,30 @@ 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
@@ -33,6 +33,24 @@ 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,6 +1,7 @@
|
|||||||
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
|
||||||
@@ -59,6 +60,7 @@ 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] = []
|
||||||
|
|
||||||
@@ -66,10 +68,51 @@ 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(await self.mcp_tool_loader.get_tools(bound_mcp_servers))
|
all_functions.extend(
|
||||||
|
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 (
|
||||||
@@ -100,21 +143,130 @@ 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.native_tool_loader.invoke_tool(name, parameters, query)
|
return await self._invoke_tool_with_monitoring(
|
||||||
|
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.plugin_tool_loader.invoke_tool(name, parameters, query)
|
return await self._invoke_tool_with_monitoring(
|
||||||
|
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.mcp_tool_loader.invoke_tool(name, parameters, query)
|
return await self._invoke_tool_with_monitoring(
|
||||||
|
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.skill_tool_loader.invoke_tool(name, parameters, query)
|
return await self._invoke_tool_with_monitoring(
|
||||||
|
source='skill',
|
||||||
|
name=name,
|
||||||
|
parameters=parameters,
|
||||||
|
query=query,
|
||||||
|
invoke=lambda: self.skill_tool_loader.invoke_tool(name, parameters, query),
|
||||||
|
)
|
||||||
raise ToolNotFoundError(name)
|
raise ToolNotFoundError(name)
|
||||||
|
|
||||||
async def shutdown(self):
|
async def shutdown(self):
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
admins: []
|
|
||||||
api:
|
api:
|
||||||
port: 5300
|
port: 5300
|
||||||
webhook_prefix: 'http://127.0.0.1:5300'
|
webhook_prefix: 'http://127.0.0.1:5300'
|
||||||
|
|||||||
@@ -118,20 +118,6 @@ 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
|
||||||
@@ -254,6 +240,34 @@ 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,6 +81,7 @@ 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,6 +90,56 @@ 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."""
|
||||||
|
|
||||||
@@ -230,6 +280,25 @@ 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
|
||||||
@@ -251,7 +320,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 list for limit check
|
return _create_mock_result([]) # Empty result for duplicate-name 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,6 +348,8 @@ 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,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -814,6 +816,47 @@ 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."""
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
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,3 +162,46 @@ 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,6 +14,7 @@ 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,
|
||||||
@@ -431,3 +432,60 @@ 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']
|
||||||
|
|||||||
@@ -0,0 +1,538 @@
|
|||||||
|
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
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
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 src.langbot.pkg.plugin.connector import PluginRuntimeConnector
|
from langbot.pkg.plugin.connector import PluginRuntimeConnector
|
||||||
|
|
||||||
|
|
||||||
def test_parse_plugin_id_accepts_author_name():
|
def test_parse_plugin_id_accepts_author_name():
|
||||||
|
|||||||
@@ -0,0 +1,278 @@
|
|||||||
|
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,13 +15,35 @@ from langbot.pkg.provider.tools.toolmgr import ToolManager
|
|||||||
|
|
||||||
|
|
||||||
class StubLoader:
|
class StubLoader:
|
||||||
def __init__(self, tools: list[resource_tool.LLMTool] | None = None, invoke_result=None):
|
def __init__(
|
||||||
|
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)
|
||||||
|
|
||||||
@@ -68,6 +90,28 @@ 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 src.langbot.pkg.utils import paths
|
from 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,7 +118,12 @@ 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(None, None, include_skill_authoring=True)
|
app.tool_mgr.get_all_tools.assert_awaited_once_with(
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
include_skill_authoring=True,
|
||||||
|
include_mcp_resource_tools=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
@@ -131,7 +136,32 @@ 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(None, None, include_skill_authoring=False)
|
app.tool_mgr.get_all_tools.assert_awaited_once_with(
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
include_skill_authoring=False,
|
||||||
|
include_mcp_resource_tools=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_preproc_disables_mcp_resource_tools_when_agent_reading_is_disabled():
|
||||||
|
preproc_module, entities_module = _import_preproc_modules()
|
||||||
|
|
||||||
|
app = _make_app(skill_service=SimpleNamespace())
|
||||||
|
stage = preproc_module.PreProcessor(app)
|
||||||
|
query = _make_query()
|
||||||
|
query.variables['_pipeline_mcp_resource_agent_read_enabled'] = False
|
||||||
|
|
||||||
|
result = await stage.process(query, 'PreProcessor')
|
||||||
|
|
||||||
|
assert result.result_type == entities_module.ResultType.CONTINUE
|
||||||
|
app.tool_mgr.get_all_tools.assert_awaited_once_with(
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
include_skill_authoring=True,
|
||||||
|
include_mcp_resource_tools=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
|
|||||||
@@ -138,7 +138,7 @@ class TestReadResourceFile:
|
|||||||
from langbot.pkg.utils import importutil
|
from langbot.pkg.utils import importutil
|
||||||
|
|
||||||
content = importutil.read_resource_file('templates/config.yaml')
|
content = importutil.read_resource_file('templates/config.yaml')
|
||||||
assert 'admins:' in content
|
assert 'api:' in content
|
||||||
assert 'edition: community' in content
|
assert 'edition: community' in content
|
||||||
|
|
||||||
def test_raises_for_nonexistent_file(self):
|
def test_raises_for_nonexistent_file(self):
|
||||||
@@ -157,7 +157,7 @@ class TestReadResourceFileBytes:
|
|||||||
from langbot.pkg.utils import importutil
|
from langbot.pkg.utils import importutil
|
||||||
|
|
||||||
content = importutil.read_resource_file_bytes('templates/config.yaml')
|
content = importutil.read_resource_file_bytes('templates/config.yaml')
|
||||||
assert b'admins:' in content
|
assert b'api:' in content
|
||||||
assert b'edition: community' in content
|
assert b'edition: community' in content
|
||||||
|
|
||||||
def test_raises_for_nonexistent_file_bytes(self):
|
def test_raises_for_nonexistent_file_bytes(self):
|
||||||
|
|||||||
@@ -2008,7 +2008,7 @@ wheels = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "langbot"
|
name = "langbot"
|
||||||
version = "4.10.4"
|
version = "4.10.5"
|
||||||
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.6" },
|
{ name = "langbot-plugin", specifier = "==0.4.7" },
|
||||||
{ 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.6"
|
version = "0.4.7"
|
||||||
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/b4/6a/5fdb5365ad04aaa61344e92578d73eb1577af35783b80767c7d6c51cb8b9/langbot_plugin-0.4.6.tar.gz", hash = "sha256:838e3cd45ed795ed4c3299c73f141b217adfa05f09937a01694e7158619e4f6e", size = 334171, upload-time = "2026-06-22T15:06:56.565Z" }
|
sdist = { url = "https://files.pythonhosted.org/packages/00/6c/850a92cc05583ab71e6ac527ff50fa5f3db43cc16e86f4f6d5ac684da9a8/langbot_plugin-0.4.7.tar.gz", hash = "sha256:16b24d79fc55c0a6b15d901b1134de9e298e2cadab9a9b25c9231f731333ebe9", size = 333867, upload-time = "2026-07-02T16:49:46.258Z" }
|
||||||
wheels = [
|
wheels = [
|
||||||
{ 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" },
|
{ url = "https://files.pythonhosted.org/packages/c6/f5/a00a929ffb4dcbd958e5f5249516f19ecc720f25f072c9bfda8bbb56851c/langbot_plugin-0.4.7-py3-none-any.whl", hash = "sha256:1364f80fcf448f4503e87d1367af60438abd2cf16e78be6807032cbdb4710a56", size = 220999, upload-time = "2026-07-02T16:49:44.868Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|||||||
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
Regular → Executable
@@ -191,45 +191,47 @@ 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"
|
||||||
>
|
>
|
||||||
<TabsList className="shrink-0">
|
<div className="flex shrink-0 items-center gap-1">
|
||||||
<TabsTrigger value="config" className="gap-1.5">
|
<TabsList>
|
||||||
<Settings className="size-3.5" />
|
<TabsTrigger value="config" className="gap-1.5">
|
||||||
{t('bots.configuration')}
|
<Settings className="size-3.5" />
|
||||||
</TabsTrigger>
|
{t('bots.configuration')}
|
||||||
<TabsTrigger value="logs" className="gap-1.5">
|
</TabsTrigger>
|
||||||
<FileText className="size-3.5" />
|
<TabsTrigger value="logs" className="gap-1.5">
|
||||||
{t('bots.logs')}
|
<FileText className="size-3.5" />
|
||||||
</TabsTrigger>
|
{t('bots.logs')}
|
||||||
<TabsTrigger value="sessions" className="gap-1.5">
|
</TabsTrigger>
|
||||||
<Users className="size-3.5" />
|
<TabsTrigger value="sessions" className="gap-1.5">
|
||||||
{t('bots.sessionMonitor.title')}
|
<Users className="size-3.5" />
|
||||||
{activeTab === 'sessions' && (
|
{t('bots.sessionMonitor.title')}
|
||||||
<button
|
</TabsTrigger>
|
||||||
type="button"
|
</TabsList>
|
||||||
className="inline-flex items-center justify-center ml-0.5"
|
{activeTab === 'sessions' && (
|
||||||
onPointerDown={(e) => e.stopPropagation()}
|
<button
|
||||||
onClick={(e) => {
|
type="button"
|
||||||
e.stopPropagation();
|
aria-label={t('bots.sessionMonitor.refresh')}
|
||||||
e.preventDefault();
|
title={t('bots.sessionMonitor.refresh')}
|
||||||
if (isRefreshingSessions) return;
|
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"
|
||||||
setIsRefreshingSessions(true);
|
disabled={isRefreshingSessions}
|
||||||
const minDelay = new Promise((r) => setTimeout(r, 500));
|
onClick={() => {
|
||||||
Promise.all([
|
if (isRefreshingSessions) return;
|
||||||
sessionMonitorRef.current?.refreshSessions(),
|
setIsRefreshingSessions(true);
|
||||||
minDelay,
|
const minDelay = new Promise((r) => setTimeout(r, 500));
|
||||||
]).finally(() => setIsRefreshingSessions(false));
|
Promise.all([
|
||||||
}}
|
sessionMonitorRef.current?.refreshSessions(),
|
||||||
>
|
minDelay,
|
||||||
<RefreshCw
|
]).finally(() => setIsRefreshingSessions(false));
|
||||||
className={cn(
|
}}
|
||||||
'size-3 text-muted-foreground hover:text-foreground transition-colors',
|
>
|
||||||
isRefreshingSessions && 'animate-spin',
|
<RefreshCw
|
||||||
)}
|
className={cn(
|
||||||
/>
|
'size-3.5',
|
||||||
</button>
|
isRefreshingSessions && 'animate-spin',
|
||||||
)}
|
)}
|
||||||
</TabsTrigger>
|
/>
|
||||||
</TabsList>
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Tab: Configuration */}
|
{/* Tab: Configuration */}
|
||||||
<TabsContent
|
<TabsContent
|
||||||
|
|||||||
@@ -0,0 +1,199 @@
|
|||||||
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogDescription,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||||
|
import { Trash2, Plus, ShieldCheck } from 'lucide-react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
export interface BotAdmin {
|
||||||
|
id: number;
|
||||||
|
launcher_type: string;
|
||||||
|
launcher_id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BotAdminsDialogProps {
|
||||||
|
botId: string;
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
admins: BotAdmin[];
|
||||||
|
onAdminsChange: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BotAdminsDialog({
|
||||||
|
botId,
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
admins,
|
||||||
|
onAdminsChange,
|
||||||
|
}: BotAdminsDialogProps) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const [newType, setNewType] = useState('person');
|
||||||
|
const [newId, setNewId] = useState('');
|
||||||
|
const [adding, setAdding] = useState(false);
|
||||||
|
|
||||||
|
async function handleAdd() {
|
||||||
|
if (!newId.trim()) return;
|
||||||
|
setAdding(true);
|
||||||
|
try {
|
||||||
|
await httpClient.addBotAdmin(botId, newType, newId.trim());
|
||||||
|
toast.success(t('bots.admins.addSuccess'));
|
||||||
|
setNewId('');
|
||||||
|
onAdminsChange();
|
||||||
|
} catch (e: unknown) {
|
||||||
|
const err = e as { msg?: string; message?: string };
|
||||||
|
toast.error(t('bots.admins.addError') + (err?.msg ?? err?.message ?? ''));
|
||||||
|
} finally {
|
||||||
|
setAdding(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(id: number) {
|
||||||
|
try {
|
||||||
|
await httpClient.deleteBotAdmin(botId, id);
|
||||||
|
toast.success(t('bots.admins.deleteSuccess'));
|
||||||
|
onAdminsChange();
|
||||||
|
} catch (e: unknown) {
|
||||||
|
const err = e as { msg?: string; message?: string };
|
||||||
|
toast.error(
|
||||||
|
t('bots.admins.deleteError') + (err?.msg ?? err?.message ?? ''),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-lg">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<ShieldCheck className="size-4" />
|
||||||
|
{t('bots.admins.title')}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>{t('bots.admins.description')}</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Add row */}
|
||||||
|
<div className="flex gap-2 items-center">
|
||||||
|
<Select value={newType} onValueChange={setNewType}>
|
||||||
|
<SelectTrigger className="w-28 shrink-0">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="person">
|
||||||
|
{t('bots.admins.typePerson')}
|
||||||
|
</SelectItem>
|
||||||
|
<SelectItem value="group">
|
||||||
|
{t('bots.admins.typeGroup')}
|
||||||
|
</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
<Input
|
||||||
|
className="flex-1"
|
||||||
|
placeholder={t('bots.admins.placeholderId')}
|
||||||
|
value={newId}
|
||||||
|
onChange={(e) => setNewId(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && handleAdd()}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={handleAdd}
|
||||||
|
disabled={adding || !newId.trim()}
|
||||||
|
>
|
||||||
|
<Plus className="size-4 mr-1" />
|
||||||
|
{t('bots.admins.addAdmin')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* List */}
|
||||||
|
{admins.length === 0 ? (
|
||||||
|
<div className="text-sm text-muted-foreground py-6 text-center">
|
||||||
|
{t('bots.admins.noAdmins')}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<ScrollArea className="max-h-64">
|
||||||
|
<div className="border rounded-md overflow-hidden">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b bg-muted/40">
|
||||||
|
<th className="text-left px-3 py-2 font-medium text-muted-foreground w-28">
|
||||||
|
{t('bots.admins.launcherType')}
|
||||||
|
</th>
|
||||||
|
<th className="text-left px-3 py-2 font-medium text-muted-foreground">
|
||||||
|
{t('bots.admins.launcherId')}
|
||||||
|
</th>
|
||||||
|
<th className="w-10" />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{admins.map((admin) => (
|
||||||
|
<tr
|
||||||
|
key={admin.id}
|
||||||
|
className="border-b last:border-0 hover:bg-muted/30"
|
||||||
|
>
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
<span className="px-1.5 py-0.5 rounded bg-muted text-xs">
|
||||||
|
{admin.launcher_type === 'person'
|
||||||
|
? t('bots.admins.typePerson')
|
||||||
|
: t('bots.admins.typeGroup')}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 font-mono">
|
||||||
|
{admin.launcher_id}
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="text-muted-foreground hover:text-destructive transition-colors"
|
||||||
|
onClick={() => handleDelete(admin.id)}
|
||||||
|
>
|
||||||
|
<Trash2 className="size-4" />
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shared hook so the session monitor and the dialog stay in sync.
|
||||||
|
export function useBotAdmins(botId: string) {
|
||||||
|
const [admins, setAdmins] = useState<BotAdmin[]>([]);
|
||||||
|
|
||||||
|
const reload = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const res = await httpClient.getBotAdmins(botId);
|
||||||
|
setAdmins(res.admins ?? []);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load bot admins:', error);
|
||||||
|
}
|
||||||
|
}, [botId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
reload();
|
||||||
|
}, [reload]);
|
||||||
|
|
||||||
|
return { admins, reload };
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ import React, {
|
|||||||
useEffect,
|
useEffect,
|
||||||
useRef,
|
useRef,
|
||||||
useCallback,
|
useCallback,
|
||||||
|
useMemo,
|
||||||
forwardRef,
|
forwardRef,
|
||||||
useImperativeHandle,
|
useImperativeHandle,
|
||||||
} from 'react';
|
} from 'react';
|
||||||
@@ -15,10 +16,20 @@ import {
|
|||||||
Bot,
|
Bot,
|
||||||
Copy,
|
Copy,
|
||||||
Check,
|
Check,
|
||||||
|
ChevronDown,
|
||||||
|
ChevronRight,
|
||||||
Workflow,
|
Workflow,
|
||||||
ThumbsUp,
|
ThumbsUp,
|
||||||
ThumbsDown,
|
ThumbsDown,
|
||||||
|
ShieldCheck,
|
||||||
|
ShieldOff,
|
||||||
|
Wrench,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import BotAdminsDialog, {
|
||||||
|
useBotAdmins,
|
||||||
|
} from '@/app/home/bots/components/bot-admins/BotAdminsDialog';
|
||||||
|
import type { BotAdmin } from '@/app/home/bots/components/bot-admins/BotAdminsDialog';
|
||||||
import { copyToClipboard } from '@/app/utils/clipboard';
|
import { copyToClipboard } from '@/app/utils/clipboard';
|
||||||
import {
|
import {
|
||||||
MessageChainComponent,
|
MessageChainComponent,
|
||||||
@@ -69,6 +80,35 @@ 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>;
|
||||||
}
|
}
|
||||||
@@ -93,16 +133,65 @@ const BotSessionMonitor = forwardRef<
|
|||||||
const [feedbackMap, setFeedbackMap] = useState<
|
const [feedbackMap, setFeedbackMap] = useState<
|
||||||
Record<string, SessionFeedback>
|
Record<string, SessionFeedback>
|
||||||
>({});
|
>({});
|
||||||
|
const [toolCalls, setToolCalls] = useState<SessionToolCall[]>([]);
|
||||||
|
const [expandedToolCallIds, setExpandedToolCallIds] = useState<
|
||||||
|
Record<string, boolean>
|
||||||
|
>({});
|
||||||
const messagesContainerRef = useRef<HTMLDivElement>(null);
|
const messagesContainerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const { admins, reload: reloadAdmins } = useBotAdmins(botId);
|
||||||
|
const [adminsDialogOpen, setAdminsDialogOpen] = useState(false);
|
||||||
|
const [togglingAdmin, setTogglingAdmin] = useState<string | null>(null);
|
||||||
|
|
||||||
const parseSessionType = (sessionId: string): string | null => {
|
const parseSessionType = (sessionId: string): string | null => {
|
||||||
const idx = sessionId.indexOf('_');
|
const lower = sessionId.toLowerCase();
|
||||||
if (idx === -1) return null;
|
if (lower.includes('person')) return 'person';
|
||||||
const type = sessionId.slice(0, idx);
|
if (lower.includes('group')) return 'group';
|
||||||
if (type === 'person' || type === 'group') return type;
|
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isSessionAdmin = (session: SessionInfo): boolean => {
|
||||||
|
const type = parseSessionType(session.session_id);
|
||||||
|
const lid =
|
||||||
|
session.user_id ??
|
||||||
|
session.session_id.replace(
|
||||||
|
/^.*?[._](?:PERSON|GROUP|person|group)[._]/i,
|
||||||
|
'',
|
||||||
|
);
|
||||||
|
return admins.some(
|
||||||
|
(a: BotAdmin) => a.launcher_type === type && a.launcher_id === lid,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleAdmin = async (session: SessionInfo) => {
|
||||||
|
const type = parseSessionType(session.session_id);
|
||||||
|
if (!type) return;
|
||||||
|
const lid =
|
||||||
|
session.user_id ??
|
||||||
|
session.session_id.replace(
|
||||||
|
/^.*?[._](?:PERSON|GROUP|person|group)[._]/i,
|
||||||
|
'',
|
||||||
|
);
|
||||||
|
const key = session.session_id;
|
||||||
|
setTogglingAdmin(key);
|
||||||
|
try {
|
||||||
|
const existing = admins.find(
|
||||||
|
(a: BotAdmin) => a.launcher_type === type && a.launcher_id === lid,
|
||||||
|
);
|
||||||
|
if (existing) {
|
||||||
|
await httpClient.deleteBotAdmin(botId, existing.id);
|
||||||
|
toast.success(t('bots.admins.deleteSuccess'));
|
||||||
|
} else {
|
||||||
|
await httpClient.addBotAdmin(botId, type, lid);
|
||||||
|
toast.success(t('bots.admins.addSuccess'));
|
||||||
|
}
|
||||||
|
await reloadAdmins();
|
||||||
|
} catch {
|
||||||
|
toast.error(t('bots.admins.addError'));
|
||||||
|
} finally {
|
||||||
|
setTogglingAdmin(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const abbreviateId = (id: string): string => {
|
const abbreviateId = (id: string): string => {
|
||||||
if (id.length <= 10) return id;
|
if (id.length <= 10) return id;
|
||||||
return `${id.slice(0, 4)}..${id.slice(-4)}`;
|
return `${id.slice(0, 4)}..${id.slice(-4)}`;
|
||||||
@@ -137,6 +226,7 @@ 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(
|
||||||
@@ -145,6 +235,18 @@ 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),
|
||||||
@@ -188,11 +290,14 @@ const BotSessionMonitor = forwardRef<
|
|||||||
loadMessages(selectedSessionId);
|
loadMessages(selectedSessionId);
|
||||||
} else {
|
} else {
|
||||||
setMessages([]);
|
setMessages([]);
|
||||||
|
setToolCalls([]);
|
||||||
|
setExpandedToolCallIds({});
|
||||||
|
setFeedbackMap({});
|
||||||
}
|
}
|
||||||
}, [selectedSessionId, loadMessages]);
|
}, [selectedSessionId, loadMessages]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (messages.length === 0) return;
|
if (messages.length === 0 && toolCalls.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;
|
||||||
@@ -204,7 +309,7 @@ const BotSessionMonitor = forwardRef<
|
|||||||
scrollTarget.scrollTop = scrollTarget.scrollHeight;
|
scrollTarget.scrollTop = scrollTarget.scrollHeight;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}, [messages]);
|
}, [messages, toolCalls]);
|
||||||
|
|
||||||
const parseMessageChain = (content: string): MessageChainComponent[] => {
|
const parseMessageChain = (content: string): MessageChainComponent[] => {
|
||||||
try {
|
try {
|
||||||
@@ -379,262 +484,510 @@ const BotSessionMonitor = forwardRef<
|
|||||||
return `${diffDays}d`;
|
return `${diffDays}d`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const formatDuration = (durationMs: number): string => {
|
||||||
|
if (!durationMs) return '0ms';
|
||||||
|
if (durationMs < 1000) return `${durationMs}ms`;
|
||||||
|
return `${(durationMs / 1000).toFixed(2)}s`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const truncateToolDetail = (value?: string | null): string => {
|
||||||
|
if (!value) return '';
|
||||||
|
return value.length > 600 ? `${value.slice(0, 600)}...` : value;
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleToolCallDetails = (toolCallId: string) => {
|
||||||
|
setExpandedToolCallIds((previous) => ({
|
||||||
|
...previous,
|
||||||
|
[toolCallId]: !previous[toolCallId],
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const feedbackByMessageId = useMemo(() => {
|
||||||
|
const map: Record<string, SessionFeedback> = {};
|
||||||
|
|
||||||
|
for (let index = 0; index < messages.length; index++) {
|
||||||
|
const msg = messages[index];
|
||||||
|
if (isUserMessage(msg)) continue;
|
||||||
|
|
||||||
|
for (let previousIndex = index - 1; previousIndex >= 0; previousIndex--) {
|
||||||
|
const previousMessage = messages[previousIndex];
|
||||||
|
if (isUserMessage(previousMessage)) {
|
||||||
|
const feedback = feedbackMap[previousMessage.id];
|
||||||
|
if (feedback) {
|
||||||
|
map[msg.id] = feedback;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return map;
|
||||||
|
}, [feedbackMap, messages]);
|
||||||
|
|
||||||
|
const timelineItems = useMemo<SessionTimelineItem[]>(() => {
|
||||||
|
const messageItems: SessionTimelineItem[] = messages.map(
|
||||||
|
(message, index) => ({
|
||||||
|
id: `message-${message.id}`,
|
||||||
|
type: 'message',
|
||||||
|
timestamp: parseTimestamp(message.timestamp).getTime(),
|
||||||
|
order: index * 2,
|
||||||
|
message,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const toolItems: SessionTimelineItem[] = toolCalls.map(
|
||||||
|
(toolCall, index) => ({
|
||||||
|
id: `tool-${toolCall.id}`,
|
||||||
|
type: 'tool',
|
||||||
|
timestamp: parseTimestamp(toolCall.timestamp).getTime(),
|
||||||
|
order: index * 2 + 1,
|
||||||
|
toolCall,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
return [...messageItems, ...toolItems].sort(
|
||||||
|
(a, b) => a.timestamp - b.timestamp || a.order - b.order,
|
||||||
|
);
|
||||||
|
}, [messages, toolCalls]);
|
||||||
|
|
||||||
const selectedSession = sessions.find(
|
const selectedSession = sessions.find(
|
||||||
(s) => s.session_id === selectedSessionId,
|
(s) => s.session_id === selectedSessionId,
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col md:flex-row h-full min-h-0 rounded-lg border overflow-hidden">
|
<>
|
||||||
{/* Left Panel: Session List */}
|
<div className="flex flex-col md:flex-row h-full min-h-0 rounded-lg border overflow-hidden">
|
||||||
<div className="max-h-48 md:max-h-none md:w-60 flex-shrink-0 border-b md:border-b-0 md:border-r flex flex-col min-h-0">
|
{/* Left Panel: Session List */}
|
||||||
{/* Session List */}
|
<div className="max-h-48 md:max-h-none md:w-60 flex-shrink-0 border-b md:border-b-0 md:border-r flex flex-col min-h-0">
|
||||||
<ScrollArea className="flex-1 min-h-0">
|
{/* Admin header */}
|
||||||
{loadingSessions && sessions.length === 0 ? (
|
<div className="px-2 py-1.5 border-b shrink-0 flex items-center justify-between">
|
||||||
<div className="flex items-center justify-center py-12 text-sm text-muted-foreground">
|
<button
|
||||||
{t('bots.sessionMonitor.loading')}
|
type="button"
|
||||||
</div>
|
className="inline-flex items-center gap-1.5 text-sm font-medium hover:text-foreground transition-colors"
|
||||||
) : sessions.length === 0 ? (
|
onClick={() => setAdminsDialogOpen(true)}
|
||||||
<div className="text-center text-muted-foreground py-12 text-sm">
|
>
|
||||||
{t('bots.sessionMonitor.noSessions')}
|
<ShieldCheck className="size-4" />
|
||||||
|
<span>
|
||||||
|
{t('bots.admins.configureAdmins')}
|
||||||
|
{admins.length > 0 && (
|
||||||
|
<span className="ml-1 tabular-nums text-xs text-muted-foreground">
|
||||||
|
({admins.length})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/* Session List */}
|
||||||
|
<ScrollArea className="flex-1 min-h-0">
|
||||||
|
{loadingSessions && sessions.length === 0 ? (
|
||||||
|
<div className="flex items-center justify-center py-12 text-sm text-muted-foreground">
|
||||||
|
{t('bots.sessionMonitor.loading')}
|
||||||
|
</div>
|
||||||
|
) : sessions.length === 0 ? (
|
||||||
|
<div className="text-center text-muted-foreground py-12 text-sm">
|
||||||
|
{t('bots.sessionMonitor.noSessions')}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="p-1.5">
|
||||||
|
{sessions.map((session) => {
|
||||||
|
const isSelected = selectedSessionId === session.session_id;
|
||||||
|
const sessionType = parseSessionType(session.session_id);
|
||||||
|
const sessionIsAdmin = isSessionAdmin(session);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={session.session_id}
|
||||||
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
|
className={cn(
|
||||||
|
'w-full text-left px-2.5 py-2 rounded-md transition-colors cursor-pointer',
|
||||||
|
isSelected ? 'bg-accent' : 'hover:bg-accent/50',
|
||||||
|
)}
|
||||||
|
onClick={() => setSelectedSessionId(session.session_id)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-0.5">
|
||||||
|
<span className="text-sm font-medium truncate mr-2">
|
||||||
|
{session.user_name ||
|
||||||
|
session.user_id ||
|
||||||
|
session.session_id.slice(0, 12)}
|
||||||
|
</span>
|
||||||
|
<span className="text-[11px] text-muted-foreground tabular-nums flex-shrink-0">
|
||||||
|
{formatRelativeTime(session.last_activity)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||||
|
{sessionType && (
|
||||||
|
<span className="px-1 py-0.5 rounded bg-muted text-[10px]">
|
||||||
|
{sessionType}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{session.user_id && (
|
||||||
|
<span className="truncate text-[10px]">
|
||||||
|
{abbreviateId(session.user_id)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{session.is_active && (
|
||||||
|
<span className="flex items-center gap-0.5 text-green-600 dark:text-green-400">
|
||||||
|
<span className="w-1.5 h-1.5 rounded-full bg-green-500 inline-block" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ScrollArea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right Panel: Messages */}
|
||||||
|
<div className="flex-1 flex flex-col min-h-0 min-w-0">
|
||||||
|
{!selectedSessionId ? (
|
||||||
|
<div className="text-center text-muted-foreground text-sm flex-1 flex items-center justify-center">
|
||||||
|
{t('bots.sessionMonitor.selectSession')}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="p-1.5">
|
<>
|
||||||
{sessions.map((session) => {
|
{/* Chat Header */}
|
||||||
const isSelected = selectedSessionId === session.session_id;
|
<div className="px-4 py-2.5 border-b shrink-0 flex items-center justify-between">
|
||||||
return (
|
<div className="min-w-0 flex-1">
|
||||||
<button
|
<div className="text-sm font-medium truncate">
|
||||||
key={session.session_id}
|
{selectedSession?.user_name ||
|
||||||
type="button"
|
selectedSession?.user_id ||
|
||||||
className={cn(
|
selectedSessionId.slice(0, 20)}
|
||||||
'w-full text-left px-2.5 py-2 rounded-md transition-colors',
|
</div>
|
||||||
isSelected ? 'bg-accent' : 'hover:bg-accent/50',
|
<div className="flex items-center gap-1.5 text-xs text-muted-foreground mt-0.5">
|
||||||
|
{parseSessionType(selectedSessionId) && (
|
||||||
|
<span>{parseSessionType(selectedSessionId)}</span>
|
||||||
)}
|
)}
|
||||||
onClick={() => setSelectedSessionId(session.session_id)}
|
{selectedSession?.user_id && (
|
||||||
>
|
<>
|
||||||
<div className="flex items-center justify-between mb-0.5">
|
<span>·</span>
|
||||||
<span className="text-sm font-medium truncate mr-2">
|
<span className="font-mono">
|
||||||
{session.user_name ||
|
{selectedSession.user_id}
|
||||||
session.user_id ||
|
|
||||||
session.session_id.slice(0, 12)}
|
|
||||||
</span>
|
|
||||||
<span className="text-[11px] text-muted-foreground tabular-nums flex-shrink-0">
|
|
||||||
{formatRelativeTime(session.last_activity)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
|
||||||
{parseSessionType(session.session_id) && (
|
|
||||||
<span className="px-1 py-0.5 rounded bg-muted text-[10px]">
|
|
||||||
{parseSessionType(session.session_id)}
|
|
||||||
</span>
|
</span>
|
||||||
)}
|
<button
|
||||||
{session.platform && (
|
type="button"
|
||||||
<span className="px-1 py-0.5 rounded bg-muted text-[10px]">
|
onClick={() => copyUserId(selectedSession.user_id!)}
|
||||||
{session.platform}
|
className="inline-flex items-center text-muted-foreground hover:text-foreground transition-colors"
|
||||||
</span>
|
title={t('common.copy')}
|
||||||
)}
|
>
|
||||||
{session.user_id && (
|
{copiedUserId ? (
|
||||||
<span className="truncate text-[10px]">
|
<Check className="w-3 h-3 text-green-600" />
|
||||||
{abbreviateId(session.user_id)}
|
) : (
|
||||||
</span>
|
<Copy className="w-3 h-3" />
|
||||||
)}
|
)}
|
||||||
{session.is_active && (
|
</button>
|
||||||
<span className="flex items-center gap-0.5 text-green-600 dark:text-green-400">
|
</>
|
||||||
|
)}
|
||||||
|
{selectedSession?.is_active && (
|
||||||
|
<>
|
||||||
|
<span>·</span>
|
||||||
|
<span className="flex items-center gap-1 text-green-600 dark:text-green-400">
|
||||||
<span className="w-1.5 h-1.5 rounded-full bg-green-500 inline-block" />
|
<span className="w-1.5 h-1.5 rounded-full bg-green-500 inline-block" />
|
||||||
|
Active
|
||||||
</span>
|
</span>
|
||||||
)}
|
</>
|
||||||
</div>
|
)}
|
||||||
</button>
|
{selectedSession && parseSessionType(selectedSessionId) && (
|
||||||
);
|
<>
|
||||||
})}
|
<span>·</span>
|
||||||
</div>
|
<button
|
||||||
)}
|
type="button"
|
||||||
</ScrollArea>
|
className={cn(
|
||||||
</div>
|
'inline-flex items-center gap-1 transition-colors',
|
||||||
|
isSessionAdmin(selectedSession)
|
||||||
{/* Right Panel: Messages */}
|
? 'text-blue-500'
|
||||||
<div className="flex-1 flex flex-col min-h-0 min-w-0">
|
: 'text-muted-foreground hover:text-blue-500',
|
||||||
{!selectedSessionId ? (
|
)}
|
||||||
<div className="text-center text-muted-foreground text-sm flex-1 flex items-center justify-center">
|
disabled={togglingAdmin === selectedSessionId}
|
||||||
{t('bots.sessionMonitor.selectSession')}
|
title={
|
||||||
</div>
|
isSessionAdmin(selectedSession)
|
||||||
) : (
|
? t('bots.admins.removeAdminTitle')
|
||||||
<>
|
: t('bots.admins.setAdminTitle')
|
||||||
{/* Chat Header */}
|
}
|
||||||
<div className="px-4 py-2.5 border-b shrink-0">
|
onClick={() => toggleAdmin(selectedSession)}
|
||||||
<div className="min-w-0">
|
>
|
||||||
<div className="text-sm font-medium truncate">
|
{isSessionAdmin(selectedSession) ? (
|
||||||
{selectedSession?.user_name ||
|
<ShieldCheck className="size-3.5" />
|
||||||
selectedSession?.user_id ||
|
) : (
|
||||||
selectedSessionId.slice(0, 20)}
|
<ShieldOff className="size-3.5" />
|
||||||
</div>
|
)}
|
||||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground mt-0.5">
|
<span>{t('bots.admins.adminBadge')}</span>
|
||||||
{parseSessionType(selectedSessionId) && (
|
</button>
|
||||||
<span>{parseSessionType(selectedSessionId)}</span>
|
</>
|
||||||
)}
|
)}
|
||||||
{selectedSession?.platform && (
|
</div>
|
||||||
<>
|
|
||||||
{parseSessionType(selectedSessionId) && <span>·</span>}
|
|
||||||
<span>{selectedSession.platform}</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{selectedSession?.user_id && (
|
|
||||||
<>
|
|
||||||
<span>·</span>
|
|
||||||
<span className="font-mono">
|
|
||||||
{selectedSession.user_id}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => copyUserId(selectedSession.user_id!)}
|
|
||||||
className="inline-flex items-center text-muted-foreground hover:text-foreground transition-colors"
|
|
||||||
title={t('common.copy')}
|
|
||||||
>
|
|
||||||
{copiedUserId ? (
|
|
||||||
<Check className="w-3 h-3 text-green-600" />
|
|
||||||
) : (
|
|
||||||
<Copy className="w-3 h-3" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{selectedSession?.is_active && (
|
|
||||||
<>
|
|
||||||
<span>·</span>
|
|
||||||
<span className="flex items-center gap-1 text-green-600 dark:text-green-400">
|
|
||||||
<span className="w-1.5 h-1.5 rounded-full bg-green-500 inline-block" />
|
|
||||||
Active
|
|
||||||
</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Messages Area */}
|
{/* Messages Area */}
|
||||||
<ScrollArea
|
<ScrollArea
|
||||||
ref={messagesContainerRef}
|
ref={messagesContainerRef}
|
||||||
className="flex-1 px-4 py-4 overflow-y-auto min-h-0"
|
className="flex-1 px-4 py-4 overflow-y-auto min-h-0"
|
||||||
>
|
>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{loadingMessages ? (
|
{loadingMessages ? (
|
||||||
<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>
|
||||||
) : messages.length === 0 ? (
|
) : timelineItems.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>
|
||||||
) : (
|
) : (
|
||||||
messages.map((msg, msgIndex) => {
|
timelineItems.map((item) => {
|
||||||
const isUser = isUserMessage(msg);
|
if (item.type === 'tool') {
|
||||||
const isDiscarded =
|
const call = item.toolCall;
|
||||||
msg.status === 'discarded' ||
|
const hasToolDetails = Boolean(
|
||||||
msg.pipeline_id === PIPELINE_DISCARD;
|
call.arguments || call.result || call.error_message,
|
||||||
// For bot replies, find feedback linked to the preceding user message
|
);
|
||||||
let msgFeedback: SessionFeedback | undefined;
|
const expandedToolCall = Boolean(
|
||||||
if (!isUser) {
|
expandedToolCallIds[call.id],
|
||||||
for (let i = msgIndex - 1; i >= 0; i--) {
|
);
|
||||||
if (isUserMessage(messages[i])) {
|
const detailsId = `tool-call-details-${call.id}`;
|
||||||
msgFeedback = feedbackMap[messages[i].id];
|
return (
|
||||||
break;
|
<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>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
return (
|
const msg = item.message;
|
||||||
<div
|
const isUser = isUserMessage(msg);
|
||||||
key={msg.id}
|
const isDiscarded =
|
||||||
className={cn(
|
msg.status === 'discarded' ||
|
||||||
'flex',
|
msg.pipeline_id === PIPELINE_DISCARD;
|
||||||
isUser ? 'justify-end' : 'justify-start',
|
const msgFeedback = feedbackByMessageId[msg.id];
|
||||||
)}
|
return (
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
|
key={item.id}
|
||||||
className={cn(
|
className={cn(
|
||||||
'max-w-3xl px-4 py-2.5 rounded-2xl text-sm',
|
'flex',
|
||||||
isUser
|
isUser ? 'justify-end' : 'justify-start',
|
||||||
? 'bg-primary/10 rounded-br-sm'
|
|
||||||
: 'bg-muted rounded-bl-sm',
|
|
||||||
msg.status === 'error' && 'ring-1 ring-red-400/50',
|
|
||||||
isDiscarded && 'opacity-60',
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{renderMessageContent(msg)}
|
|
||||||
{/* Role label + pipeline + timestamp */}
|
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'text-[11px] mt-1.5 flex items-center gap-1.5 text-muted-foreground',
|
'max-w-3xl px-4 py-2.5 rounded-2xl text-sm',
|
||||||
|
isUser
|
||||||
|
? 'bg-primary/10 rounded-br-sm'
|
||||||
|
: 'bg-muted rounded-bl-sm',
|
||||||
|
msg.status === 'error' &&
|
||||||
|
'ring-1 ring-red-400/50',
|
||||||
|
isDiscarded && 'opacity-60',
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<span>
|
{renderMessageContent(msg)}
|
||||||
{isUser
|
{/* Role label + pipeline + timestamp */}
|
||||||
? t('bots.sessionMonitor.userMessage', {
|
<div
|
||||||
defaultValue: 'User',
|
className={cn(
|
||||||
})
|
'text-[11px] mt-1.5 flex items-center gap-1.5 text-muted-foreground',
|
||||||
: t('bots.sessionMonitor.botMessage', {
|
)}
|
||||||
defaultValue: 'Assistant',
|
>
|
||||||
|
<span>
|
||||||
|
{isUser
|
||||||
|
? t('bots.sessionMonitor.userMessage', {
|
||||||
|
defaultValue: 'User',
|
||||||
|
})
|
||||||
|
: t('bots.sessionMonitor.botMessage', {
|
||||||
|
defaultValue: 'Assistant',
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
<span className="tabular-nums">
|
||||||
|
{formatTime(msg.timestamp)}
|
||||||
|
</span>
|
||||||
|
{isDiscarded ? (
|
||||||
|
<span className="inline-flex items-center gap-0.5 text-destructive">
|
||||||
|
<Ban className="w-3 h-3" />
|
||||||
|
{t('bots.sessionMonitor.discarded', {
|
||||||
|
defaultValue: 'Discarded',
|
||||||
})}
|
})}
|
||||||
</span>
|
|
||||||
<span className="tabular-nums">
|
|
||||||
{formatTime(msg.timestamp)}
|
|
||||||
</span>
|
|
||||||
{isDiscarded ? (
|
|
||||||
<span className="inline-flex items-center gap-0.5 text-destructive">
|
|
||||||
<Ban className="w-3 h-3" />
|
|
||||||
{t('bots.sessionMonitor.discarded', {
|
|
||||||
defaultValue: 'Discarded',
|
|
||||||
})}
|
|
||||||
</span>
|
|
||||||
) : msg.pipeline_name ? (
|
|
||||||
<span className="inline-flex items-center gap-0.5 opacity-70">
|
|
||||||
<Workflow className="w-3 h-3" />
|
|
||||||
{msg.pipeline_name}
|
|
||||||
</span>
|
|
||||||
) : null}
|
|
||||||
{msg.status === 'error' && (
|
|
||||||
<span className="text-red-500">error</span>
|
|
||||||
)}
|
|
||||||
{msg.runner_name && (
|
|
||||||
<span className="inline-flex items-center gap-0.5 opacity-70">
|
|
||||||
<Bot className="w-3 h-3" />
|
|
||||||
{msg.runner_name}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{/* Feedback indicator — same line, pushed right */}
|
|
||||||
{!isUser &&
|
|
||||||
msgFeedback &&
|
|
||||||
(msgFeedback.feedback_type === 1 ? (
|
|
||||||
<span className="inline-flex items-center gap-1 ml-auto text-green-600 dark:text-green-400 cursor-default relative group">
|
|
||||||
<ThumbsUp className="w-3 h-3 flex-shrink-0" />
|
|
||||||
{t('monitoring.feedback.like')}
|
|
||||||
{msgFeedback.feedback_content && (
|
|
||||||
<span className="hidden group-hover:block absolute bottom-full right-0 mb-1 px-3 py-1.5 rounded-lg bg-popover border text-popover-foreground text-xs whitespace-nowrap shadow-md z-10">
|
|
||||||
{msgFeedback.feedback_content}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : msg.pipeline_name ? (
|
||||||
<span className="inline-flex items-center gap-1 ml-auto text-red-500 dark:text-red-400 cursor-default relative group">
|
<span className="inline-flex items-center gap-0.5 opacity-70">
|
||||||
<ThumbsDown className="w-3 h-3 flex-shrink-0" />
|
<Workflow className="w-3 h-3" />
|
||||||
{t('monitoring.feedback.dislike')}
|
{msg.pipeline_name}
|
||||||
{msgFeedback.feedback_content && (
|
|
||||||
<span className="hidden group-hover:block absolute bottom-full right-0 mb-1 px-3 py-1.5 rounded-lg bg-popover border text-popover-foreground text-xs whitespace-nowrap shadow-md z-10">
|
|
||||||
{msgFeedback.feedback_content}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</span>
|
</span>
|
||||||
))}
|
) : null}
|
||||||
|
{msg.status === 'error' && (
|
||||||
|
<span className="text-red-500">error</span>
|
||||||
|
)}
|
||||||
|
{msg.runner_name && (
|
||||||
|
<span className="inline-flex items-center gap-0.5 opacity-70">
|
||||||
|
<Bot className="w-3 h-3" />
|
||||||
|
{msg.runner_name}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{/* Feedback indicator — same line, pushed right */}
|
||||||
|
{!isUser &&
|
||||||
|
msgFeedback &&
|
||||||
|
(msgFeedback.feedback_type === 1 ? (
|
||||||
|
<span className="inline-flex items-center gap-1 ml-auto text-green-600 dark:text-green-400 cursor-default relative group">
|
||||||
|
<ThumbsUp className="w-3 h-3 flex-shrink-0" />
|
||||||
|
{t('monitoring.feedback.like')}
|
||||||
|
{msgFeedback.feedback_content && (
|
||||||
|
<span className="hidden group-hover:block absolute bottom-full right-0 mb-1 px-3 py-1.5 rounded-lg bg-popover border text-popover-foreground text-xs whitespace-nowrap shadow-md z-10">
|
||||||
|
{msgFeedback.feedback_content}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="inline-flex items-center gap-1 ml-auto text-red-500 dark:text-red-400 cursor-default relative group">
|
||||||
|
<ThumbsDown className="w-3 h-3 flex-shrink-0" />
|
||||||
|
{t('monitoring.feedback.dislike')}
|
||||||
|
{msgFeedback.feedback_content && (
|
||||||
|
<span className="hidden group-hover:block absolute bottom-full right-0 mb-1 px-3 py-1.5 rounded-lg bg-popover border text-popover-foreground text-xs whitespace-nowrap shadow-md z-10">
|
||||||
|
{msgFeedback.feedback_content}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
);
|
||||||
);
|
})
|
||||||
})
|
)}
|
||||||
)}
|
</div>
|
||||||
</div>
|
</ScrollArea>
|
||||||
</ScrollArea>
|
</>
|
||||||
</>
|
)}
|
||||||
)}
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
<BotAdminsDialog
|
||||||
|
botId={botId}
|
||||||
|
open={adminsDialogOpen}
|
||||||
|
onOpenChange={setAdminsDialogOpen}
|
||||||
|
admins={admins}
|
||||||
|
onAdminsChange={reloadAdmins}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
DynamicFormItemType,
|
||||||
IDynamicFormItemSchema,
|
IDynamicFormItemSchema,
|
||||||
SYSTEM_FIELD_PREFIX,
|
SYSTEM_FIELD_PREFIX,
|
||||||
} from '@/app/infra/entities/form/dynamic';
|
} from '@/app/infra/entities/form/dynamic';
|
||||||
@@ -57,6 +58,95 @@ 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.
|
||||||
*/
|
*/
|
||||||
@@ -322,9 +412,16 @@ 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: IDynamicFormItemSchema,
|
item: DynamicFormValueSpec,
|
||||||
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>;
|
||||||
@@ -368,68 +465,16 @@ 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(
|
||||||
editableItems.reduce(
|
editableValueSpecs.reduce(
|
||||||
(acc, item) => {
|
(acc, item) => {
|
||||||
let fieldSchema;
|
let fieldSchema = getValueSchema(item);
|
||||||
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 &&
|
||||||
@@ -454,7 +499,7 @@ export default function DynamicFormComponent({
|
|||||||
|
|
||||||
const form = useForm<FormValues>({
|
const form = useForm<FormValues>({
|
||||||
resolver: zodResolver(formSchema),
|
resolver: zodResolver(formSchema),
|
||||||
defaultValues: editableItems.reduce((acc, item) => {
|
defaultValues: editableValueSpecs.reduce((acc, item) => {
|
||||||
// 优先使用 initialValues,如果没有则使用默认值
|
// 优先使用 initialValues,如果没有则使用默认值
|
||||||
const rawValue = initialValues?.[item.name] ?? item.default;
|
const rawValue = initialValues?.[item.name] ?? item.default;
|
||||||
return {
|
return {
|
||||||
@@ -493,7 +538,7 @@ export default function DynamicFormComponent({
|
|||||||
|
|
||||||
if (initialValues && hasRealChange) {
|
if (initialValues && hasRealChange) {
|
||||||
// 合并默认值和初始值
|
// 合并默认值和初始值
|
||||||
const mergedValues = editableItems.reduce(
|
const mergedValues = editableValueSpecs.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;
|
||||||
@@ -508,10 +553,16 @@ export default function DynamicFormComponent({
|
|||||||
|
|
||||||
previousInitialValues.current = initialValues;
|
previousInitialValues.current = initialValues;
|
||||||
}
|
}
|
||||||
}, [initialValues, form, editableItems]);
|
}, [initialValues, form, editableValueSpecs]);
|
||||||
|
|
||||||
// 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.
|
||||||
@@ -524,7 +575,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 = editableItems.reduce(
|
const initialFinalValues = editableValueSpecs.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;
|
||||||
@@ -544,7 +595,7 @@ export default function DynamicFormComponent({
|
|||||||
|
|
||||||
const subscription = form.watch(() => {
|
const subscription = form.watch(() => {
|
||||||
const formValues = form.getValues();
|
const formValues = form.getValues();
|
||||||
const finalValues = editableItems.reduce(
|
const finalValues = editableValueSpecs.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;
|
||||||
@@ -555,7 +606,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, editableItems]);
|
}, [form, editableValueSpecs]);
|
||||||
|
|
||||||
// State for QR code login dialog
|
// State for QR code login dialog
|
||||||
const [qrDialogOpen, setQrDialogOpen] = useState(false);
|
const [qrDialogOpen, setQrDialogOpen] = useState(false);
|
||||||
@@ -786,6 +837,41 @@ 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 (
|
||||||
@@ -816,7 +902,10 @@ 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>
|
||||||
@@ -853,7 +942,10 @@ 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,15 +64,33 @@ 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[]>([]);
|
||||||
@@ -96,17 +114,41 @@ export default function DynamicFormItemComponent({
|
|||||||
httpClient
|
httpClient
|
||||||
.getProviderLLMModels()
|
.getProviderLLMModels()
|
||||||
.then((resp) => {
|
.then((resp) => {
|
||||||
setLlmModels(resp.models);
|
setLlmModels(resp.models.filter(hasUsableUuid));
|
||||||
})
|
})
|
||||||
.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();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -174,27 +216,13 @@ export default function DynamicFormItemComponent({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (config.type === DynamicFormItemType.EMBEDDING_MODEL_SELECTOR) {
|
if (config.type === DynamicFormItemType.EMBEDDING_MODEL_SELECTOR) {
|
||||||
httpClient
|
fetchEmbeddingModels();
|
||||||
.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) {
|
||||||
httpClient
|
fetchRerankModels();
|
||||||
.getProviderRerankModels()
|
|
||||||
.then((resp) => {
|
|
||||||
setRerankModels(resp.models);
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
toast.error('Failed to load rerank models: ' + err.msg);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}, [config.type]);
|
}, [config.type]);
|
||||||
|
|
||||||
@@ -212,7 +240,7 @@ export default function DynamicFormItemComponent({
|
|||||||
httpClient
|
httpClient
|
||||||
.getKnowledgeBases()
|
.getKnowledgeBases()
|
||||||
.then((resp) => {
|
.then((resp) => {
|
||||||
setKnowledgeBases(resp.bases);
|
setKnowledgeBases(resp.bases.filter(hasUsableUuid));
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
toast.error(t('knowledge.getKnowledgeBaseListError') + err.msg);
|
toast.error(t('knowledge.getKnowledgeBaseListError') + err.msg);
|
||||||
@@ -225,7 +253,7 @@ export default function DynamicFormItemComponent({
|
|||||||
httpClient
|
httpClient
|
||||||
.getBots()
|
.getBots()
|
||||||
.then((resp) => {
|
.then((resp) => {
|
||||||
setBots(resp.bots);
|
setBots(resp.bots.filter(hasUsableUuid));
|
||||||
})
|
})
|
||||||
.catch((err) => {
|
.catch((err) => {
|
||||||
toast.error(t('bots.getBotListError') + err.msg);
|
toast.error(t('bots.getBotListError') + err.msg);
|
||||||
@@ -248,6 +276,16 @@ 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:
|
||||||
@@ -360,7 +398,7 @@ export default function DynamicFormItemComponent({
|
|||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectGroup>
|
<SelectGroup>
|
||||||
{config.options?.map((option) => (
|
{config.options?.filter(hasUsableOptionName).map((option) => (
|
||||||
<SelectItem
|
<SelectItem
|
||||||
key={option.name}
|
key={option.name}
|
||||||
value={option.name}
|
value={option.name}
|
||||||
@@ -377,10 +415,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 === 'space-chat-completions',
|
(m) => m.provider?.requester === LANGBOT_MODELS_PROVIDER_REQUESTER,
|
||||||
);
|
);
|
||||||
const regularModels = llmModels.filter(
|
const regularModels = llmModels.filter(
|
||||||
(m) => m.provider?.requester !== 'space-chat-completions',
|
(m) => m.provider?.requester !== LANGBOT_MODELS_PROVIDER_REQUESTER,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Group regular models by provider
|
// Group regular models by provider
|
||||||
@@ -481,7 +519,7 @@ export default function DynamicFormItemComponent({
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{/* Blurred remaining models with login overlay */}
|
{/* Blurred remaining models with login overlay */}
|
||||||
<div className="relative">
|
<div className="relative min-h-10">
|
||||||
<div
|
<div
|
||||||
className="select-none overflow-hidden"
|
className="select-none overflow-hidden"
|
||||||
style={{ maxHeight: '3rem' }}
|
style={{ maxHeight: '3rem' }}
|
||||||
@@ -558,7 +596,10 @@ 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={() => setModelsDialogOpen(true)}
|
onClick={() => {
|
||||||
|
setSettingsSection('models');
|
||||||
|
setModelsDialogOpen(true);
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<Settings className="h-4 w-4 text-muted-foreground" />
|
<Settings className="h-4 w-4 text-muted-foreground" />
|
||||||
</Button>
|
</Button>
|
||||||
@@ -574,9 +615,15 @@ export default function DynamicFormItemComponent({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
case DynamicFormItemType.EMBEDDING_MODEL_SELECTOR:
|
case DynamicFormItemType.EMBEDDING_MODEL_SELECTOR: {
|
||||||
// Group embedding models by provider
|
const spaceEmbeddingModels = embeddingModels.filter(
|
||||||
const groupedEmbeddingModels = embeddingModels.reduce(
|
(m) => m.provider?.requester === LANGBOT_MODELS_PROVIDER_REQUESTER,
|
||||||
|
);
|
||||||
|
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] = [];
|
||||||
@@ -586,29 +633,169 @@ 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="w-full max-w-md min-w-0">
|
<div className="flex w-full max-w-md min-w-0 items-center gap-1.5">
|
||||||
<Select value={field.value} onValueChange={field.onChange}>
|
<div className="min-w-0 flex-1">
|
||||||
<SelectTrigger className="min-w-0 bg-[#ffffff] dark:bg-[#2a2a2e]">
|
<Select value={field.value} onValueChange={field.onChange}>
|
||||||
<SelectValue placeholder={t('knowledge.selectEmbeddingModel')} />
|
<SelectTrigger className="min-w-0 bg-[#ffffff] dark:bg-[#2a2a2e]">
|
||||||
</SelectTrigger>
|
<SelectValue
|
||||||
<SelectContent>
|
placeholder={t('knowledge.selectEmbeddingModel')}
|
||||||
{Object.entries(groupedEmbeddingModels).map(
|
/>
|
||||||
([providerName, models]) => (
|
</SelectTrigger>
|
||||||
<SelectGroup key={providerName}>
|
<SelectContent>
|
||||||
<SelectLabel>{providerName}</SelectLabel>
|
{Object.entries(groupedEmbeddingModels).map(
|
||||||
{models.map((model) => (
|
([providerName, models]) => (
|
||||||
<SelectItem key={model.uuid} value={model.uuid}>
|
<SelectGroup key={providerName}>
|
||||||
{model.name}
|
<SelectLabel>{providerName}</SelectLabel>
|
||||||
</SelectItem>
|
{models.map((model) => (
|
||||||
))}
|
<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(
|
||||||
</SelectContent>
|
([providerName, models]) => (
|
||||||
</Select>
|
<SelectGroup key={providerName}>
|
||||||
|
<SelectLabel>
|
||||||
|
<span className="inline-flex items-center gap-1.5">
|
||||||
|
<Sparkles className="h-3.5 w-3.5 text-purple-500" />
|
||||||
|
{providerName}
|
||||||
|
</span>
|
||||||
|
</SelectLabel>
|
||||||
|
{models.map((model) => (
|
||||||
|
<SelectItem key={model.uuid} value={model.uuid}>
|
||||||
|
{model.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectGroup>
|
||||||
|
),
|
||||||
|
)
|
||||||
|
) : null}
|
||||||
|
</SelectContent>
|
||||||
|
</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(
|
||||||
@@ -652,10 +839,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 === 'space-chat-completions',
|
(m) => m.provider?.requester === LANGBOT_MODELS_PROVIDER_REQUESTER,
|
||||||
);
|
);
|
||||||
const fbRegularModels = llmModels.filter(
|
const fbRegularModels = llmModels.filter(
|
||||||
(m) => m.provider?.requester !== 'space-chat-completions',
|
(m) => m.provider?.requester !== LANGBOT_MODELS_PROVIDER_REQUESTER,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Group regular models by provider
|
// Group regular models by provider
|
||||||
@@ -786,7 +973,7 @@ export default function DynamicFormItemComponent({
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
{/* Blurred remaining models with login overlay */}
|
{/* Blurred remaining models with login overlay */}
|
||||||
<div className="relative">
|
<div className="relative min-h-10">
|
||||||
<div
|
<div
|
||||||
className="select-none overflow-hidden"
|
className="select-none overflow-hidden"
|
||||||
style={{ maxHeight: '3rem' }}
|
style={{ maxHeight: '3rem' }}
|
||||||
@@ -999,7 +1186,8 @@ 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 kbsByEngine = knowledgeBases.reduce(
|
const validKnowledgeBases = knowledgeBases.filter(hasUsableUuid);
|
||||||
|
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)
|
||||||
@@ -1010,7 +1198,7 @@ export default function DynamicFormItemComponent({
|
|||||||
acc[engineName].push(kb);
|
acc[engineName].push(kb);
|
||||||
return acc;
|
return acc;
|
||||||
},
|
},
|
||||||
{} as Record<string, typeof knowledgeBases>,
|
{} as Record<string, typeof validKnowledgeBases>,
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -1018,7 +1206,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 = knowledgeBases.find(
|
const selectedKb = validKnowledgeBases.find(
|
||||||
(kb) => kb.uuid === field.value,
|
(kb) => kb.uuid === field.value,
|
||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
@@ -1047,7 +1235,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>
|
||||||
@@ -1064,7 +1252,8 @@ 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 multiKbsByEngine = knowledgeBases.reduce(
|
const validMultiKnowledgeBases = knowledgeBases.filter(hasUsableUuid);
|
||||||
|
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)
|
||||||
@@ -1075,7 +1264,7 @@ export default function DynamicFormItemComponent({
|
|||||||
acc[engineName].push(kb);
|
acc[engineName].push(kb);
|
||||||
return acc;
|
return acc;
|
||||||
},
|
},
|
||||||
{} as Record<string, typeof knowledgeBases>,
|
{} as Record<string, typeof validMultiKnowledgeBases>,
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -1084,7 +1273,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 = knowledgeBases.find(
|
const currentKb = validMultiKnowledgeBases.find(
|
||||||
(base) => base.uuid === kbId,
|
(base) => base.uuid === kbId,
|
||||||
);
|
);
|
||||||
if (!currentKb) return null;
|
if (!currentKb) return null;
|
||||||
@@ -1170,15 +1359,13 @@ export default function DynamicFormItemComponent({
|
|||||||
{engineName}
|
{engineName}
|
||||||
</div>
|
</div>
|
||||||
{kbs.map((base) => {
|
{kbs.map((base) => {
|
||||||
const isSelected = tempSelectedKBIds.includes(
|
const isSelected = tempSelectedKBIds.includes(base.uuid);
|
||||||
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)
|
||||||
@@ -1240,8 +1427,8 @@ export default function DynamicFormItemComponent({
|
|||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectGroup>
|
<SelectGroup>
|
||||||
{bots.map((bot) => (
|
{bots.filter(hasUsableUuid).map((bot) => (
|
||||||
<SelectItem key={bot.uuid} value={bot.uuid ?? ''}>
|
<SelectItem key={bot.uuid} value={bot.uuid}>
|
||||||
{bot.name}
|
{bot.name}
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
))}
|
))}
|
||||||
@@ -1383,6 +1570,32 @@ 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,6 +56,13 @@ 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
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user