mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-02 12:05:54 +00:00
Compare commits
4 Commits
feat/lark_
...
feat/litel
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8dd16aac51 | ||
|
|
d170bdd343 | ||
|
|
b33d05f99a | ||
|
|
de61b5d368 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -47,7 +47,6 @@ plugins.bak
|
||||
coverage.xml
|
||||
.coverage
|
||||
src/langbot/web/
|
||||
testsdk/
|
||||
|
||||
# Build artifacts
|
||||
/dist
|
||||
|
||||
80
README.md
80
README.md
@@ -84,48 +84,45 @@ docker compose up -d
|
||||
|
||||
| Platform | Status | Notes |
|
||||
|----------|--------|-------|
|
||||
| Discord | ✅ | Official |
|
||||
| Telegram | ✅ | Official |
|
||||
| Slack | ✅ | Official |
|
||||
| LINE | ✅ | Official |
|
||||
| QQ | ✅ | Personal & Official API (Channel, DM, Group) |
|
||||
| Discord | ✅ | |
|
||||
| Telegram | ✅ | |
|
||||
| Slack | ✅ | |
|
||||
| LINE | ✅ | |
|
||||
| QQ | ✅ | Personal & Official API |
|
||||
| WeCom | ✅ | Enterprise WeChat, External CS, AI Bot |
|
||||
| WeChat | ✅ | Personal & Official Account |
|
||||
| Lark | ✅ | Official |
|
||||
| DingTalk | ✅ | Official |
|
||||
| KOOK | ✅ | Official |
|
||||
| Lark | ✅ | |
|
||||
| DingTalk | ✅ | |
|
||||
| KOOK | ✅ | |
|
||||
| Satori | ✅ | |
|
||||
| Email | ✅ | Matrix, Satori |
|
||||
| Matrix | ✅ | Supports multiple bridged platforms such as Signal, WhatsApp, Messenger, iMessage, Mattermost, Google Chat, IRC, XMPP, Zulip, and more |
|
||||
|
||||
---
|
||||
|
||||
## Supported LLMs & Integrations
|
||||
|
||||
| Provider | Type | Status |
|
||||
| ----------------------------------------------------------------------------------------------------------------- | ------------ | ------ |
|
||||
| [OpenAI](https://platform.openai.com/) | LLM | ✅ |
|
||||
| [Anthropic](https://www.anthropic.com/) | LLM | ✅ |
|
||||
| [DeepSeek](https://www.deepseek.com/) | LLM | ✅ |
|
||||
| [Google Gemini](https://aistudio.google.com/prompts/new_chat) | LLM | ✅ |
|
||||
| [xAI](https://x.ai/) | LLM | ✅ |
|
||||
| [Moonshot](https://www.moonshot.cn/) | LLM | ✅ |
|
||||
| [Zhipu AI](https://open.bigmodel.cn/) | LLM | ✅ |
|
||||
| [Ollama](https://ollama.com/) | Local LLM | ✅ |
|
||||
| [LM Studio](https://lmstudio.ai/) | Local LLM | ✅ |
|
||||
| [Dify](https://dify.ai) | LLMOps | ✅ |
|
||||
| [MCP](https://modelcontextprotocol.io/) | Protocol | ✅ |
|
||||
| [SiliconFlow](https://siliconflow.cn/) | Gateway | ✅ |
|
||||
| [Aliyun Bailian](https://bailian.console.aliyun.com/) | Gateway | ✅ |
|
||||
| [Volc Engine Ark](https://console.volcengine.com/ark/region:ark+cn-beijing/model?vendor=Bytedance&view=LIST_VIEW) | Gateway | ✅ |
|
||||
| [ModelScope](https://modelscope.cn/docs/model-service/API-Inference/intro) | Gateway | ✅ |
|
||||
| [GiteeAI](https://ai.gitee.com/) | Gateway | ✅ |
|
||||
| [CompShare](https://www.compshare.cn/?ytag=GPU_YY-gh_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 | ✅ |
|
||||
| [接口 AI](https://jiekou.ai/) | Gateway | ✅ |
|
||||
| [302.AI](https://share.302.ai/SuTG99) | Gateway | ✅ |
|
||||
| [Qiniu](https://www.qiniu.com/ai/agent) | Gateway | ✅ |
|
||||
| Provider | Type | Status |
|
||||
|----------|------|--------|
|
||||
| [OpenAI](https://platform.openai.com/) | LLM | ✅ |
|
||||
| [Anthropic](https://www.anthropic.com/) | LLM | ✅ |
|
||||
| [DeepSeek](https://www.deepseek.com/) | LLM | ✅ |
|
||||
| [Google Gemini](https://aistudio.google.com/prompts/new_chat) | LLM | ✅ |
|
||||
| [xAI](https://x.ai/) | LLM | ✅ |
|
||||
| [Moonshot](https://www.moonshot.cn/) | LLM | ✅ |
|
||||
| [Zhipu AI](https://open.bigmodel.cn/) | LLM | ✅ |
|
||||
| [Ollama](https://ollama.com/) | Local LLM | ✅ |
|
||||
| [LM Studio](https://lmstudio.ai/) | Local LLM | ✅ |
|
||||
| [Dify](https://dify.ai) | LLMOps | ✅ |
|
||||
| [MCP](https://modelcontextprotocol.io/) | Protocol | ✅ |
|
||||
| [SiliconFlow](https://siliconflow.cn/) | Gateway | ✅ |
|
||||
| [Aliyun Bailian](https://bailian.console.aliyun.com/) | Gateway | ✅ |
|
||||
| [Volc Engine Ark](https://console.volcengine.com/ark/region:ark+cn-beijing/model?vendor=Bytedance&view=LIST_VIEW) | Gateway | ✅ |
|
||||
| [ModelScope](https://modelscope.cn/docs/model-service/API-Inference/intro) | Gateway | ✅ |
|
||||
| [GiteeAI](https://ai.gitee.com/) | Gateway | ✅ |
|
||||
| [CompShare](https://www.compshare.cn/?ytag=GPU_YY-gh_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 | ✅ |
|
||||
| [接口 AI](https://jiekou.ai/) | Gateway | ✅ |
|
||||
| [302.AI](https://share.302.ai/SuTG99) | Gateway | ✅ |
|
||||
|
||||
[→ View all integrations](https://link.langbot.app/en/docs/features)
|
||||
|
||||
@@ -133,23 +130,22 @@ docker compose up -d
|
||||
|
||||
## Why LangBot?
|
||||
|
||||
| Use Case | How LangBot Helps |
|
||||
| --------------------------- | ------------------------------------------------------------------------------------------ |
|
||||
| **Customer Support** | Deploy AI agents to Slack/Discord/Telegram that answer questions using your knowledge base |
|
||||
| **Internal Tools** | Connect n8n/Dify workflows to WeCom/DingTalk for automated business processes |
|
||||
| **Community Management** | Moderate QQ/Discord groups with AI-powered content filtering and interaction |
|
||||
| **Multi-Platform Presence** | One bot, all platforms. Manage from a single dashboard |
|
||||
| Use Case | How LangBot Helps |
|
||||
|----------|-------------------|
|
||||
| **Customer Support** | Deploy AI agents to Slack/Discord/Telegram that answer questions using your knowledge base |
|
||||
| **Internal Tools** | Connect n8n/Dify workflows to WeCom/DingTalk for automated business processes |
|
||||
| **Community Management** | Moderate QQ/Discord groups with AI-powered content filtering and interaction |
|
||||
| **Multi-Platform Presence** | One bot, all platforms. Manage from a single dashboard |
|
||||
|
||||
---
|
||||
|
||||
## Live Demo
|
||||
|
||||
**Try it now:** https://demo.langbot.dev/
|
||||
|
||||
- Email: `demo@langbot.app`
|
||||
- Password: `langbot123456`
|
||||
|
||||
_Note: Public demo environment. Do not enter sensitive information._
|
||||
*Note: Public demo environment. Do not enter sensitive information.*
|
||||
|
||||
---
|
||||
|
||||
|
||||
18
README_CN.md
18
README_CN.md
@@ -87,16 +87,13 @@ docker compose up -d
|
||||
| QQ | ✅ | 个人号、官方机器人(频道、私聊、群聊) |
|
||||
| 微信 | ✅ | 个人微信、微信公众号 |
|
||||
| 企业微信 | ✅ | 应用消息、对外客服、智能机器人 |
|
||||
| 飞书 | ✅ | 官方 |
|
||||
| 钉钉 | ✅ | 官方 |
|
||||
| Satori | ✅ | |
|
||||
| Discord | ✅ | 官方 |
|
||||
| Telegram | ✅ | 官方 |
|
||||
| Slack | ✅ | 官方 |
|
||||
| LINE | ✅ | 官方 |
|
||||
| KOOK | ✅ | 官方 |
|
||||
| Email | ✅ | 只 Matrix、Satori |
|
||||
| Matrix | ✅ | 支持多种桥接平台,如 Signal、WhatsApp、Messenger、iMessage、Mattermost、Google Chat、IRC、XMPP、Zulip 等 |
|
||||
| 飞书 | ✅ | |
|
||||
| 钉钉 | ✅ | |
|
||||
| Discord | ✅ | |
|
||||
| Telegram | ✅ | |
|
||||
| Slack | ✅ | |
|
||||
| LINE | ✅ | |
|
||||
| KOOK | ✅ | |
|
||||
|
||||
---
|
||||
|
||||
@@ -127,7 +124,6 @@ docker compose up -d
|
||||
| [302.AI](https://share.302.ai/SuTG99) | 聚合平台 | ✅ |
|
||||
| [小马算力](https://www.tokenpony.cn/453z1) | 聚合平台 | ✅ |
|
||||
| [百宝箱Tbox](https://www.tbox.cn/open) | 智能体平台 | ✅ |
|
||||
| [七牛云Qiniu](https://www.qiniu.com/ai/agent) | 聚合平台 | ✅ |
|
||||
|
||||
[→ 查看完整集成列表](https://link.langbot.app/zh/docs/features)
|
||||
|
||||
|
||||
19
README_ES.md
19
README_ES.md
@@ -83,19 +83,17 @@ docker compose up -d
|
||||
|
||||
| Plataforma | Estado | Notas |
|
||||
|----------|--------|-------|
|
||||
| Discord | ✅ | Oficial |
|
||||
| Telegram | ✅ | Oficial |
|
||||
| Slack | ✅ | Oficial |
|
||||
| LINE | ✅ | Oficial |
|
||||
| QQ | ✅ | Personal y API Oficial (Canal, DM, Grupo) |
|
||||
| Discord | ✅ | |
|
||||
| Telegram | ✅ | |
|
||||
| Slack | ✅ | |
|
||||
| LINE | ✅ | |
|
||||
| QQ | ✅ | Personal y API Oficial |
|
||||
| WeCom | ✅ | WeChat Empresarial, CS Externo, AI Bot |
|
||||
| WeChat | ✅ | Personal y Cuenta Oficial |
|
||||
| Lark | ✅ | Oficial |
|
||||
| DingTalk | ✅ | Oficial |
|
||||
| KOOK | ✅ | Oficial |
|
||||
| Lark | ✅ | |
|
||||
| DingTalk | ✅ | |
|
||||
| KOOK | ✅ | |
|
||||
| Satori | ✅ | |
|
||||
| Email | ✅ | Matrix, Satori |
|
||||
| Matrix | ✅ | Admite varias plataformas puenteadas como Signal, WhatsApp, Messenger, iMessage, Mattermost, Google Chat, IRC, XMPP, Zulip y más |
|
||||
|
||||
---
|
||||
|
||||
@@ -124,7 +122,6 @@ docker compose up -d
|
||||
| [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | Plataforma GPU | ✅ |
|
||||
| [接口 AI](https://jiekou.ai/) | Pasarela | ✅ |
|
||||
| [302.AI](https://share.302.ai/SuTG99) | Pasarela | ✅ |
|
||||
| [Qiniu](https://www.qiniu.com/ai/agent) | Pasarela | ✅ |
|
||||
|
||||
[→ Ver todas las integraciones](https://link.langbot.app/en/docs/features)
|
||||
|
||||
|
||||
19
README_FR.md
19
README_FR.md
@@ -83,19 +83,17 @@ docker compose up -d
|
||||
|
||||
| Plateforme | Statut | Notes |
|
||||
|----------|--------|-------|
|
||||
| Discord | ✅ | Officiel |
|
||||
| Telegram | ✅ | Officiel |
|
||||
| Slack | ✅ | Officiel |
|
||||
| LINE | ✅ | Officiel |
|
||||
| QQ | ✅ | Personnel & API Officielle (Canal, DM, Groupe) |
|
||||
| Discord | ✅ | |
|
||||
| Telegram | ✅ | |
|
||||
| Slack | ✅ | |
|
||||
| LINE | ✅ | |
|
||||
| QQ | ✅ | Personnel & API Officielle |
|
||||
| WeCom | ✅ | WeChat Entreprise, CS Externe, AI Bot |
|
||||
| WeChat | ✅ | Personnel & Compte Officiel |
|
||||
| Lark | ✅ | Officiel |
|
||||
| DingTalk | ✅ | Officiel |
|
||||
| KOOK | ✅ | Officiel |
|
||||
| Lark | ✅ | |
|
||||
| DingTalk | ✅ | |
|
||||
| KOOK | ✅ | |
|
||||
| Satori | ✅ | |
|
||||
| Email | ✅ | Matrix, Satori |
|
||||
| Matrix | ✅ | Prend en charge plusieurs plateformes via ponts, comme Signal, WhatsApp, Messenger, iMessage, Mattermost, Google Chat, IRC, XMPP, Zulip, etc. |
|
||||
|
||||
---
|
||||
|
||||
@@ -124,7 +122,6 @@ docker compose up -d
|
||||
| [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 | ✅ |
|
||||
| [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | Plateforme GPU | ✅ |
|
||||
| [Qiniu](https://www.qiniu.com/ai/agent) | Passerelle | ✅ |
|
||||
|
||||
[→ Voir toutes les intégrations](https://link.langbot.app/en/docs/features)
|
||||
|
||||
|
||||
21
README_JP.md
21
README_JP.md
@@ -83,19 +83,17 @@ docker compose up -d
|
||||
|
||||
| プラットフォーム | ステータス | 備考 |
|
||||
|----------|--------|-------|
|
||||
| Discord | ✅ | 公式 |
|
||||
| Telegram | ✅ | 公式 |
|
||||
| Slack | ✅ | 公式 |
|
||||
| LINE | ✅ | 公式 |
|
||||
| QQ | ✅ | 個人・公式API(チャンネル・DM・グループ) |
|
||||
| Discord | ✅ | |
|
||||
| Telegram | ✅ | |
|
||||
| Slack | ✅ | |
|
||||
| LINE | ✅ | |
|
||||
| QQ | ✅ | 個人 & 公式API |
|
||||
| WeCom | ✅ | 企業WeChat、外部CS、AIボット |
|
||||
| WeChat | ✅ | 個人・公式アカウント |
|
||||
| Lark | ✅ | 公式 |
|
||||
| DingTalk | ✅ | 公式 |
|
||||
| KOOK | ✅ | 公式 |
|
||||
| WeChat | ✅ | 個人 & 公式アカウント |
|
||||
| Lark | ✅ | |
|
||||
| DingTalk | ✅ | |
|
||||
| KOOK | ✅ | |
|
||||
| Satori | ✅ | |
|
||||
| Email | ✅ | Matrix、Satori |
|
||||
| Matrix | ✅ | Signal、WhatsApp、Messenger、iMessage、Mattermost、Google Chat、IRC、XMPP、Zulip など複数のブリッジ先プラットフォームに対応 |
|
||||
|
||||
---
|
||||
|
||||
@@ -124,7 +122,6 @@ docker compose up -d
|
||||
| [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | GPUプラットフォーム | ✅ |
|
||||
| [接口 AI](https://jiekou.ai/) | ゲートウェイ | ✅ |
|
||||
| [302.AI](https://share.302.ai/SuTG99) | ゲートウェイ | ✅ |
|
||||
| [Qiniu](https://www.qiniu.com/ai/agent) | ゲートウェイ | ✅ |
|
||||
|
||||
[→ すべての統合を表示](https://link.langbot.app/en/docs/features)
|
||||
|
||||
|
||||
19
README_KO.md
19
README_KO.md
@@ -83,19 +83,17 @@ docker compose up -d
|
||||
|
||||
| 플랫폼 | 상태 | 비고 |
|
||||
|--------|------|------|
|
||||
| Discord | ✅ | 공식 |
|
||||
| Telegram | ✅ | 공식 |
|
||||
| Slack | ✅ | 공식 |
|
||||
| LINE | ✅ | 공식 |
|
||||
| QQ | ✅ | 개인 및 공식 API (채널, DM, 그룹) |
|
||||
| Discord | ✅ | |
|
||||
| Telegram | ✅ | |
|
||||
| Slack | ✅ | |
|
||||
| LINE | ✅ | |
|
||||
| QQ | ✅ | 개인 및 공식 API |
|
||||
| WeCom | ✅ | 기업 WeChat, 외부 CS, AI Bot |
|
||||
| WeChat | ✅ | 개인 및 공식 계정 |
|
||||
| Lark | ✅ | 공식 |
|
||||
| DingTalk | ✅ | 공식 |
|
||||
| KOOK | ✅ | 공식 |
|
||||
| Lark | ✅ | |
|
||||
| DingTalk | ✅ | |
|
||||
| KOOK | ✅ | |
|
||||
| Satori | ✅ | |
|
||||
| Email | ✅ | Matrix, Satori |
|
||||
| Matrix | ✅ | Signal, WhatsApp, Messenger, iMessage, Mattermost, Google Chat, IRC, XMPP, Zulip 등 여러 브리지 플랫폼 지원 |
|
||||
|
||||
---
|
||||
|
||||
@@ -124,7 +122,6 @@ docker compose up -d
|
||||
| [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | GPU 플랫폼 | ✅ |
|
||||
| [接口 AI](https://jiekou.ai/) | 게이트웨이 | ✅ |
|
||||
| [302.AI](https://share.302.ai/SuTG99) | 게이트웨이 | ✅ |
|
||||
| [Qiniu](https://www.qiniu.com/ai/agent) | 게이트웨이 | ✅ |
|
||||
|
||||
[→ 모든 통합 보기](https://link.langbot.app/en/docs/features)
|
||||
|
||||
|
||||
19
README_RU.md
19
README_RU.md
@@ -83,19 +83,17 @@ docker compose up -d
|
||||
|
||||
| Платформа | Статус | Примечания |
|
||||
|-----------|--------|------------|
|
||||
| Discord | ✅ | Официальный |
|
||||
| Telegram | ✅ | Официальный |
|
||||
| Slack | ✅ | Официальный |
|
||||
| LINE | ✅ | Официальный |
|
||||
| QQ | ✅ | Личный и официальный API (Канал, ЛС, Группа) |
|
||||
| Discord | ✅ | |
|
||||
| Telegram | ✅ | |
|
||||
| Slack | ✅ | |
|
||||
| LINE | ✅ | |
|
||||
| QQ | ✅ | Личный и официальный API |
|
||||
| WeCom | ✅ | Корпоративный WeChat, внешний CS, AI-бот |
|
||||
| WeChat | ✅ | Личный и официальный аккаунт |
|
||||
| Lark | ✅ | Официальный |
|
||||
| DingTalk | ✅ | Официальный |
|
||||
| KOOK | ✅ | Официальный |
|
||||
| Lark | ✅ | |
|
||||
| DingTalk | ✅ | |
|
||||
| KOOK | ✅ | |
|
||||
| Satori | ✅ | |
|
||||
| Email | ✅ | Matrix, Satori |
|
||||
| Matrix | ✅ | Поддерживает несколько платформ через мосты, включая Signal, WhatsApp, Messenger, iMessage, Mattermost, Google Chat, IRC, XMPP, Zulip и другие |
|
||||
|
||||
---
|
||||
|
||||
@@ -124,7 +122,6 @@ docker compose up -d
|
||||
| [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 | ✅ |
|
||||
| [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | Платформа GPU | ✅ |
|
||||
| [Qiniu](https://www.qiniu.com/ai/agent) | Шлюз | ✅ |
|
||||
|
||||
[→ Смотреть все интеграции](https://link.langbot.app/en/docs/features)
|
||||
|
||||
|
||||
19
README_TW.md
19
README_TW.md
@@ -85,19 +85,17 @@ docker compose up -d
|
||||
|
||||
| 平台 | 狀態 | 備註 |
|
||||
|------|------|------|
|
||||
| Discord | ✅ | 官方 |
|
||||
| Telegram | ✅ | 官方 |
|
||||
| Slack | ✅ | 官方 |
|
||||
| LINE | ✅ | 官方 |
|
||||
| QQ | ✅ | 個人號、官方機器人(頻道、私聊、群聊) |
|
||||
| 企業微信 | ✅ | 應用訊息、對外客服、智能機器人 |
|
||||
| 微信 | ✅ | 個人微信、微信公眾號 |
|
||||
| 飛書 | ✅ | 官方 |
|
||||
| 釘釘 | ✅ | 官方 |
|
||||
| KOOK | ✅ | 官方 |
|
||||
| 企業微信 | ✅ | 應用訊息、對外客服、智能機器人 |
|
||||
| 飛書 | ✅ | |
|
||||
| 釘釘 | ✅ | |
|
||||
| Discord | ✅ | |
|
||||
| Telegram | ✅ | |
|
||||
| Slack | ✅ | |
|
||||
| LINE | ✅ | |
|
||||
| KOOK | ✅ | |
|
||||
| Satori | ✅ | |
|
||||
| Email | ✅ | 只 Matrix、Satori |
|
||||
| Matrix | ✅ | 支援多種橋接平台,如 Signal、WhatsApp、Messenger、iMessage、Mattermost、Google Chat、IRC、XMPP、Zulip 等 |
|
||||
|
||||
---
|
||||
|
||||
@@ -126,7 +124,6 @@ docker compose up -d
|
||||
| [PPIO](https://ppinfra.com/user/register?invited_by=QJKFYD&utm_source=github_langbot) | GPU 平台 | ✅ |
|
||||
| [接口 AI](https://jiekou.ai/) | 聚合平台 | ✅ |
|
||||
| [302.AI](https://share.302.ai/SuTG99) | 聚合平台 | ✅ |
|
||||
| [Qiniu](https://www.qiniu.com/ai/agent) | 聚合平台 | ✅ |
|
||||
|
||||
### TTS(語音合成)
|
||||
|
||||
|
||||
19
README_VI.md
19
README_VI.md
@@ -83,19 +83,17 @@ docker compose up -d
|
||||
|
||||
| Nền tảng | Trạng thái | Ghi chú |
|
||||
|----------|--------|-------|
|
||||
| Discord | ✅ | Chính thức |
|
||||
| Telegram | ✅ | Chính thức |
|
||||
| Slack | ✅ | Chính thức |
|
||||
| LINE | ✅ | Chính thức |
|
||||
| QQ | ✅ | Cá nhân & API chính thức (Kênh, DM, Nhóm) |
|
||||
| Discord | ✅ | |
|
||||
| Telegram | ✅ | |
|
||||
| Slack | ✅ | |
|
||||
| LINE | ✅ | |
|
||||
| QQ | ✅ | Cá nhân & API chính thức |
|
||||
| WeCom | ✅ | WeChat doanh nghiệp, CS bên ngoài, AI Bot |
|
||||
| WeChat | ✅ | Cá nhân & Tài khoản công khai |
|
||||
| Lark | ✅ | Chính thức |
|
||||
| DingTalk | ✅ | Chính thức |
|
||||
| KOOK | ✅ | Chính thức |
|
||||
| Lark | ✅ | |
|
||||
| DingTalk | ✅ | |
|
||||
| KOOK | ✅ | |
|
||||
| Satori | ✅ | |
|
||||
| Email | ✅ | Matrix, Satori |
|
||||
| Matrix | ✅ | Hỗ trợ nhiều nền tảng qua bridge như Signal, WhatsApp, Messenger, iMessage, Mattermost, Google Chat, IRC, XMPP, Zulip và hơn thế nữa |
|
||||
|
||||
---
|
||||
|
||||
@@ -124,7 +122,6 @@ docker compose up -d
|
||||
| [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | Nền tảng GPU | ✅ |
|
||||
| [接口 AI](https://jiekou.ai/) | Cổng | ✅ |
|
||||
| [302.AI](https://share.302.ai/SuTG99) | 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)
|
||||
|
||||
|
||||
@@ -69,15 +69,15 @@ dependencies = [
|
||||
"chromadb>=1.0.0,<2.0.0",
|
||||
"qdrant-client (>=1.15.1,<2.0.0)",
|
||||
"pyseekdb==1.1.0.post3",
|
||||
"langbot-plugin==0.3.10",
|
||||
"langbot-plugin==0.3.8",
|
||||
"asyncpg>=0.30.0",
|
||||
"line-bot-sdk>=3.19.0",
|
||||
"matrix-nio>=0.25.2",
|
||||
"tboxsdk>=0.0.10",
|
||||
"boto3>=1.35.0",
|
||||
"pymilvus>=2.6.4",
|
||||
"pgvector>=0.4.1",
|
||||
"botocore>=1.42.39",
|
||||
"litellm>=1.0.0",
|
||||
]
|
||||
keywords = [
|
||||
"bot",
|
||||
|
||||
@@ -481,12 +481,6 @@ class DingTalkClient:
|
||||
card_data['config'] = json.dumps({'autoLayout': card_auto_layout})
|
||||
card_data['content'] = ''
|
||||
|
||||
# 将用户的消息内容作为卡片的查询参数,方便后续处理
|
||||
if incoming_message.message_type == 'text':
|
||||
card_data['query'] = incoming_message.get_text_list()[0]
|
||||
else:
|
||||
card_data['query'] = '...'
|
||||
|
||||
card_instance = dingtalk_stream.AICardReplier(self.client, incoming_message)
|
||||
# print(card_instance)
|
||||
# 先投放卡片: https://open.dingtalk.com/document/orgapp/create-and-deliver-cards
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
import re
|
||||
import time
|
||||
import asyncio
|
||||
from quart import request
|
||||
import httpx
|
||||
from quart import Quart
|
||||
from typing import Callable, Dict, Any, Optional
|
||||
from typing import Callable, Dict, Any
|
||||
import langbot_plugin.api.entities.builtin.platform.events as platform_events
|
||||
from .qqofficialevent import QQOfficialEvent
|
||||
import json
|
||||
@@ -34,8 +32,6 @@ class QQOfficialClient:
|
||||
self.access_token = ''
|
||||
self.access_token_expiry_time = None
|
||||
self.logger = logger
|
||||
self._msg_seq_counter = 0
|
||||
self._token_refresh_task: Optional[asyncio.Task] = None
|
||||
|
||||
async def check_access_token(self):
|
||||
"""检查access_token是否存在"""
|
||||
@@ -54,18 +50,18 @@ class QQOfficialClient:
|
||||
headers = {
|
||||
'content-type': 'application/json',
|
||||
}
|
||||
response = await client.post(url, json=params, headers=headers)
|
||||
if response.status_code != 200:
|
||||
raise Exception(f'Failed to get access_token: HTTP {response.status_code} {response.text}')
|
||||
response_data = response.json()
|
||||
access_token = response_data.get('access_token')
|
||||
expires_in = int(response_data.get('expires_in', 7200))
|
||||
self.access_token_expiry_time = time.time() + expires_in - 60
|
||||
if access_token:
|
||||
self.access_token = access_token
|
||||
await self.logger.info(f'access_token obtained, expires_in={expires_in}s')
|
||||
else:
|
||||
raise Exception('Failed to get access_token: no access_token in response')
|
||||
try:
|
||||
response = await client.post(url, json=params, headers=headers)
|
||||
if response.status_code == 200:
|
||||
response_data = response.json()
|
||||
access_token = response_data.get('access_token')
|
||||
expires_in = int(response_data.get('expires_in', 7200))
|
||||
self.access_token_expiry_time = time.time() + expires_in - 60
|
||||
if access_token:
|
||||
self.access_token = access_token
|
||||
except Exception as e:
|
||||
await self.logger.error(f'获取access_token失败: {response_data}')
|
||||
raise Exception(f'获取access_token失败: {e}')
|
||||
|
||||
async def handle_callback_request(self):
|
||||
"""处理回调请求(独立端口模式,使用全局 request)"""
|
||||
@@ -91,10 +87,10 @@ class QQOfficialClient:
|
||||
try:
|
||||
body = await req.get_data()
|
||||
|
||||
await self.logger.info(f'Received request, body length: {len(body)}')
|
||||
print(f'[QQ Official] Received request, body length: {len(body)}')
|
||||
|
||||
if not body or len(body) == 0:
|
||||
await self.logger.info('Received empty body, might be health check or GET request')
|
||||
print('[QQ Official] Received empty body, might be health check or GET request')
|
||||
return {'code': 0, 'message': 'ok'}, 200
|
||||
|
||||
payload = json.loads(body)
|
||||
@@ -115,6 +111,7 @@ class QQOfficialClient:
|
||||
return {'code': 0, 'message': 'success'}
|
||||
|
||||
except Exception as e:
|
||||
print(f'[QQ Official] ERROR: {traceback.format_exc()}')
|
||||
await self.logger.error(f'Error in handle_callback_request: {traceback.format_exc()}')
|
||||
return {'error': str(e)}, 400
|
||||
|
||||
@@ -142,24 +139,21 @@ class QQOfficialClient:
|
||||
|
||||
async def get_message(self, msg: dict) -> Dict[str, Any]:
|
||||
"""获取消息"""
|
||||
d = msg.get('d', {})
|
||||
if not isinstance(d, dict):
|
||||
return {}
|
||||
message_data = {
|
||||
't': msg.get('t', {}),
|
||||
'user_openid': d.get('author', {}).get('user_openid', {}),
|
||||
'timestamp': d.get('timestamp', {}),
|
||||
'd_author_id': d.get('author', {}).get('id', {}),
|
||||
'content': d.get('content', {}),
|
||||
'd_id': d.get('id', {}),
|
||||
'user_openid': msg.get('d', {}).get('author', {}).get('user_openid', {}),
|
||||
'timestamp': msg.get('d', {}).get('timestamp', {}),
|
||||
'd_author_id': msg.get('d', {}).get('author', {}).get('id', {}),
|
||||
'content': msg.get('d', {}).get('content', {}),
|
||||
'd_id': msg.get('d', {}).get('id', {}),
|
||||
'id': msg.get('id', {}),
|
||||
'channel_id': d.get('channel_id', {}),
|
||||
'username': d.get('author', {}).get('username', {}),
|
||||
'guild_id': d.get('guild_id', {}),
|
||||
'member_openid': d.get('author', {}).get('openid', {}),
|
||||
'group_openid': d.get('group_openid', {}),
|
||||
'channel_id': msg.get('d', {}).get('channel_id', {}),
|
||||
'username': msg.get('d', {}).get('author', {}).get('username', {}),
|
||||
'guild_id': msg.get('d', {}).get('guild_id', {}),
|
||||
'member_openid': msg.get('d', {}).get('author', {}).get('openid', {}),
|
||||
'group_openid': msg.get('d', {}).get('group_openid', {}),
|
||||
}
|
||||
attachments = d.get('attachments', [])
|
||||
attachments = msg.get('d', {}).get('attachments', [])
|
||||
image_attachments = [attachment['url'] for attachment in attachments if await self.is_image(attachment)]
|
||||
image_attachments_type = [
|
||||
attachment['content_type'] for attachment in attachments if await self.is_image(attachment)
|
||||
@@ -198,7 +192,7 @@ class QQOfficialClient:
|
||||
if response.status_code == 200:
|
||||
return
|
||||
else:
|
||||
await self.logger.error(f'Failed to send private message: {response_data}')
|
||||
await self.logger.error(f'发送私聊消息失败: {response_data}')
|
||||
raise ValueError(response)
|
||||
|
||||
async def send_group_text_msg(self, group_openid: str, content: str, msg_id: str):
|
||||
@@ -221,7 +215,7 @@ class QQOfficialClient:
|
||||
if response.status_code == 200:
|
||||
return
|
||||
else:
|
||||
await self.logger.error(f'Failed to send group message: {response.json()}')
|
||||
await self.logger.error(f'发送群聊消息失败:{response.json()}')
|
||||
raise Exception(response.read().decode())
|
||||
|
||||
async def send_channle_group_text_msg(self, channel_id: str, content: str, msg_id: str):
|
||||
@@ -244,7 +238,7 @@ class QQOfficialClient:
|
||||
if response.status_code == 200:
|
||||
return True
|
||||
else:
|
||||
await self.logger.error(f'Failed to send channel group message: {response.json()}')
|
||||
await self.logger.error(f'发送频道群聊消息失败: {response.json()}')
|
||||
raise Exception(response)
|
||||
|
||||
async def send_channle_private_text_msg(self, guild_id: str, content: str, msg_id: str):
|
||||
@@ -267,224 +261,9 @@ class QQOfficialClient:
|
||||
if response.status_code == 200:
|
||||
return True
|
||||
else:
|
||||
await self.logger.error(f'Failed to send channel private message: {response.json()}')
|
||||
await self.logger.error(f'发送频道私聊消息失败: {response.json()}')
|
||||
raise Exception(response)
|
||||
|
||||
# ---- 富媒体消息 ----
|
||||
|
||||
# 媒体文件类型
|
||||
MEDIA_TYPE_IMAGE = 1
|
||||
MEDIA_TYPE_VIDEO = 2
|
||||
MEDIA_TYPE_VOICE = 3
|
||||
MEDIA_TYPE_FILE = 4
|
||||
|
||||
async def upload_media(
|
||||
self,
|
||||
target_type: str,
|
||||
target_id: str,
|
||||
file_type: int,
|
||||
file_url: str = None,
|
||||
file_data: str = None,
|
||||
file_name: str = None,
|
||||
) -> str:
|
||||
"""上传媒体文件,返回 file_info。
|
||||
|
||||
Args:
|
||||
target_type: 'c2c' | 'group'
|
||||
target_id: 用户 openid 或群 openid
|
||||
file_type: 1=图片, 2=视频, 3=语音, 4=文件
|
||||
file_url: 在线 URL(与 file_data 二选一)
|
||||
file_data: base64 编码的文件数据或 data URL(与 file_url 二选一)
|
||||
file_name: 文件名(file_type=4 时必填)
|
||||
"""
|
||||
if not await self.check_access_token():
|
||||
await self.get_access_token()
|
||||
|
||||
if target_type == 'c2c':
|
||||
url = f'{self.base_url}/v2/users/{target_id}/files'
|
||||
elif target_type == 'group':
|
||||
url = f'{self.base_url}/v2/groups/{target_id}/files'
|
||||
else:
|
||||
raise ValueError(f'Unsupported target_type: {target_type}')
|
||||
|
||||
body = {
|
||||
'file_type': file_type,
|
||||
'srv_send_msg': False,
|
||||
}
|
||||
if file_url:
|
||||
body['url'] = file_url
|
||||
elif file_data:
|
||||
# 处理 data URL 格式: data:image/png;base64,xxxxx
|
||||
if file_data.startswith('data:'):
|
||||
match = re.match(r'^data:[^;]+;base64,(.+)$', file_data, re.DOTALL)
|
||||
if match:
|
||||
body['file_data'] = match.group(1)
|
||||
else:
|
||||
body['file_data'] = file_data
|
||||
else:
|
||||
body['file_data'] = file_data
|
||||
else:
|
||||
raise ValueError('file_url or file_data is required')
|
||||
|
||||
if file_type == self.MEDIA_TYPE_FILE and file_name:
|
||||
body['file_name'] = file_name
|
||||
|
||||
async with httpx.AsyncClient(timeout=120) as client:
|
||||
headers = {
|
||||
'Authorization': f'QQBot {self.access_token}',
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
response = await client.post(url, headers=headers, json=body)
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
file_info = data.get('file_info', '')
|
||||
preview = file_info[:80] + '...' if len(file_info) > 80 else file_info
|
||||
await self.logger.info(f'Upload media success, file_info={preview}')
|
||||
return file_info
|
||||
else:
|
||||
raise Exception(f'Failed to upload media: HTTP {response.status_code} {response.text}')
|
||||
|
||||
async def _send_media_msg(
|
||||
self,
|
||||
target_type: str,
|
||||
target_id: str,
|
||||
file_info: str,
|
||||
msg_id: str = None,
|
||||
content: str = None,
|
||||
):
|
||||
"""发送富媒体消息(msg_type=7)"""
|
||||
if not await self.check_access_token():
|
||||
await self.get_access_token()
|
||||
|
||||
if target_type == 'c2c':
|
||||
url = f'{self.base_url}/v2/users/{target_id}/messages'
|
||||
elif target_type == 'group':
|
||||
url = f'{self.base_url}/v2/groups/{target_id}/messages'
|
||||
else:
|
||||
raise ValueError(f'Unsupported target_type: {target_type}')
|
||||
|
||||
self._msg_seq_counter += 1
|
||||
msg_seq = self._msg_seq_counter
|
||||
body = {
|
||||
'msg_type': 7,
|
||||
'media': {'file_info': file_info},
|
||||
'msg_seq': msg_seq,
|
||||
}
|
||||
if content:
|
||||
body['content'] = content
|
||||
if msg_id:
|
||||
body['msg_id'] = msg_id
|
||||
|
||||
async with httpx.AsyncClient(timeout=120) as client:
|
||||
headers = {
|
||||
'Authorization': f'QQBot {self.access_token}',
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
await self.logger.info(f'Sending rich media: {json.dumps(body, ensure_ascii=False)[:200]}')
|
||||
response = await client.post(url, headers=headers, json=body)
|
||||
if response.status_code != 200:
|
||||
raise Exception(f'Failed to send rich media message: HTTP {response.status_code} {response.text}')
|
||||
|
||||
async def send_image_msg(
|
||||
self,
|
||||
target_type: str,
|
||||
target_id: str,
|
||||
file_url: str = None,
|
||||
file_data: str = None,
|
||||
msg_id: str = None,
|
||||
content: str = None,
|
||||
):
|
||||
"""发送图片消息"""
|
||||
file_info = await self.upload_media(
|
||||
target_type,
|
||||
target_id,
|
||||
self.MEDIA_TYPE_IMAGE,
|
||||
file_url=file_url,
|
||||
file_data=file_data,
|
||||
)
|
||||
await self._send_media_msg(target_type, target_id, file_info, msg_id, content)
|
||||
|
||||
async def send_voice_msg(
|
||||
self,
|
||||
target_type: str,
|
||||
target_id: str,
|
||||
file_url: str = None,
|
||||
file_data: str = None,
|
||||
msg_id: str = None,
|
||||
):
|
||||
"""发送语音消息"""
|
||||
file_info = await self.upload_media(
|
||||
target_type,
|
||||
target_id,
|
||||
self.MEDIA_TYPE_VOICE,
|
||||
file_url=file_url,
|
||||
file_data=file_data,
|
||||
)
|
||||
await self._send_media_msg(target_type, target_id, file_info, msg_id)
|
||||
|
||||
async def send_file_msg(
|
||||
self,
|
||||
target_type: str,
|
||||
target_id: str,
|
||||
file_url: str = None,
|
||||
file_data: str = None,
|
||||
file_name: str = None,
|
||||
msg_id: str = None,
|
||||
):
|
||||
"""发送文件消息(含视频)"""
|
||||
file_info = await self.upload_media(
|
||||
target_type,
|
||||
target_id,
|
||||
self.MEDIA_TYPE_FILE,
|
||||
file_url=file_url,
|
||||
file_data=file_data,
|
||||
file_name=file_name,
|
||||
)
|
||||
await self._send_media_msg(target_type, target_id, file_info, msg_id)
|
||||
|
||||
async def send_stream_msg(
|
||||
self,
|
||||
user_openid: str,
|
||||
content: str,
|
||||
event_id: str,
|
||||
msg_id: str,
|
||||
msg_seq: int = 1,
|
||||
index: int = 0,
|
||||
stream_msg_id: str = None,
|
||||
input_state: int = 1,
|
||||
):
|
||||
"""发送流式消息(C2C 私聊)。
|
||||
|
||||
Args:
|
||||
input_state: 1=生成中, 10=生成结束
|
||||
"""
|
||||
if not await self.check_access_token():
|
||||
await self.get_access_token()
|
||||
|
||||
url = f'{self.base_url}/v2/users/{user_openid}/stream_messages'
|
||||
body = {
|
||||
'input_mode': 'replace',
|
||||
'input_state': input_state,
|
||||
'content_type': 'markdown',
|
||||
'content_raw': content,
|
||||
'event_id': event_id,
|
||||
'msg_id': msg_id,
|
||||
'msg_seq': msg_seq,
|
||||
'index': index,
|
||||
}
|
||||
if stream_msg_id:
|
||||
body['stream_msg_id'] = stream_msg_id
|
||||
|
||||
async with httpx.AsyncClient(timeout=120) as client:
|
||||
headers = {
|
||||
'Authorization': f'QQBot {self.access_token}',
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
response = await client.post(url, headers=headers, json=body)
|
||||
if response.status_code != 200:
|
||||
raise Exception(f'Failed to send stream message: HTTP {response.status_code} {response.text}')
|
||||
return response.json()
|
||||
|
||||
async def is_token_expired(self):
|
||||
"""检查token是否过期"""
|
||||
if self.access_token_expiry_time is None:
|
||||
@@ -513,325 +292,3 @@ class QQOfficialClient:
|
||||
'signature': signature,
|
||||
}
|
||||
return response
|
||||
|
||||
# ---- WebSocket Gateway ----
|
||||
# Reference: https://bot.q.qq.com/wiki/develop/api-v2/dev-prepare/interface-framework/event-emit.html
|
||||
|
||||
INTENT_GUILDS = 1 << 0
|
||||
INTENT_GUILD_MEMBERS = 1 << 1
|
||||
INTENT_PUBLIC_GUILD_MESSAGES = 1 << 30
|
||||
INTENT_DIRECT_MESSAGE = 1 << 12
|
||||
INTENT_GROUP_AND_C2C = 1 << 25
|
||||
INTENT_INTERACTION = 1 << 26
|
||||
|
||||
FULL_INTENTS = (
|
||||
INTENT_GUILDS
|
||||
| INTENT_GUILD_MEMBERS
|
||||
| INTENT_PUBLIC_GUILD_MESSAGES
|
||||
| INTENT_DIRECT_MESSAGE
|
||||
| INTENT_GROUP_AND_C2C
|
||||
| INTENT_INTERACTION
|
||||
)
|
||||
|
||||
async def get_gateway_url(self) -> str:
|
||||
"""获取 WebSocket 网关地址"""
|
||||
if not await self.check_access_token():
|
||||
await self.get_access_token()
|
||||
|
||||
url = f'{self.base_url}/gateway'
|
||||
async with httpx.AsyncClient() as client:
|
||||
headers = {
|
||||
'Authorization': f'QQBot {self.access_token}',
|
||||
}
|
||||
response = await client.get(url, headers=headers)
|
||||
if response.status_code == 200:
|
||||
data = response.json()
|
||||
ws_url = data.get('url', '')
|
||||
if not ws_url:
|
||||
raise Exception('Gateway URL is empty')
|
||||
return ws_url
|
||||
else:
|
||||
raise Exception(f'Failed to get Gateway URL: HTTP {response.status_code} {response.text}')
|
||||
|
||||
async def _background_token_refresh(self):
|
||||
"""在 token 到期前主动刷新"""
|
||||
try:
|
||||
while True:
|
||||
if self.access_token_expiry_time:
|
||||
remain = self.access_token_expiry_time - time.time()
|
||||
if remain > 120:
|
||||
await asyncio.sleep(remain - 60)
|
||||
continue
|
||||
self.access_token = ''
|
||||
self.access_token_expiry_time = None
|
||||
if await self.check_access_token():
|
||||
await asyncio.sleep(60)
|
||||
else:
|
||||
await self.get_access_token()
|
||||
await asyncio.sleep(60)
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
async def connect_gateway(
|
||||
self,
|
||||
on_event: Callable[[str, dict], Any],
|
||||
on_ready: Optional[Callable[[], Any]] = None,
|
||||
on_error: Optional[Callable[[Exception], Any]] = None,
|
||||
):
|
||||
"""WebSocket 网关连接,含重连逻辑。持续重连直到达到最大次数或被取消。
|
||||
|
||||
Args:
|
||||
on_event: 收到 op=0 Dispatch 事件时的回调,参数为 (event_type, event_data)
|
||||
on_ready: 连接就绪 (收到 READY) 时的回调
|
||||
on_error: 发生错误时的回调
|
||||
"""
|
||||
import websockets
|
||||
|
||||
session_id = ''
|
||||
last_seq = 0
|
||||
reconnect_attempts = 0
|
||||
max_reconnect_attempts = 100
|
||||
backoff_delays = [1, 2, 5, 10, 30, 60]
|
||||
rate_limit_delay = 60
|
||||
|
||||
# Cancel previous token refresh task if any
|
||||
if self._token_refresh_task and not self._token_refresh_task.done():
|
||||
self._token_refresh_task.cancel()
|
||||
try:
|
||||
await self._token_refresh_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
self._token_refresh_task = None
|
||||
|
||||
while reconnect_attempts <= max_reconnect_attempts:
|
||||
heartbeat_interval = 45000
|
||||
should_refresh_token = False
|
||||
ws = None
|
||||
heartbeat_task = None
|
||||
|
||||
# Refresh token if needed
|
||||
if should_refresh_token:
|
||||
self.access_token = ''
|
||||
self.access_token_expiry_time = None
|
||||
|
||||
try:
|
||||
ws_url = await self.get_gateway_url()
|
||||
await self.logger.info(f'Gateway URL obtained: {ws_url[:60]}...')
|
||||
except Exception as e:
|
||||
error_msg = str(e)
|
||||
await self.logger.error(f'Failed to get gateway URL: {e}')
|
||||
reconnect_attempts += 1
|
||||
if '100017' in error_msg or '频率' in error_msg or 'Too many' in error_msg:
|
||||
delay = rate_limit_delay
|
||||
else:
|
||||
delay = backoff_delays[min(reconnect_attempts - 1, len(backoff_delays) - 1)]
|
||||
await self.logger.info(f'Reconnecting in {delay}s (attempt {reconnect_attempts})')
|
||||
await asyncio.sleep(delay)
|
||||
continue
|
||||
|
||||
try:
|
||||
await self.logger.info('Connecting to WebSocket gateway...')
|
||||
ws = await websockets.connect(ws_url)
|
||||
await self.logger.info('WebSocket connected')
|
||||
except Exception as e:
|
||||
await self.logger.error(f'WebSocket connection failed: {e}')
|
||||
reconnect_attempts += 1
|
||||
delay = backoff_delays[min(reconnect_attempts - 1, len(backoff_delays) - 1)]
|
||||
await self.logger.info(f'Reconnecting in {delay}s (attempt {reconnect_attempts})')
|
||||
await asyncio.sleep(delay)
|
||||
continue
|
||||
|
||||
try:
|
||||
async for raw_msg in ws:
|
||||
try:
|
||||
payload = json.loads(raw_msg)
|
||||
except json.JSONDecodeError:
|
||||
await self.logger.error(f'Failed to parse message: {raw_msg}')
|
||||
continue
|
||||
|
||||
op = payload.get('op')
|
||||
d = payload.get('d', {})
|
||||
s = payload.get('s')
|
||||
t = payload.get('t')
|
||||
|
||||
if not isinstance(d, dict):
|
||||
d = {}
|
||||
|
||||
if op == 10: # Hello
|
||||
heartbeat_interval = d.get('heartbeat_interval', 45000)
|
||||
await self.logger.info(f'Received Hello, heartbeat_interval={heartbeat_interval}ms')
|
||||
|
||||
# Send Identify or Resume
|
||||
if session_id and last_seq > 0:
|
||||
resume_payload = {
|
||||
'op': 6,
|
||||
'd': {
|
||||
'token': f'QQBot {self.access_token}',
|
||||
'session_id': session_id,
|
||||
'seq': last_seq,
|
||||
},
|
||||
}
|
||||
await ws.send(json.dumps(resume_payload))
|
||||
await self.logger.info(f'Sent Resume, session_id={session_id}, seq={last_seq}')
|
||||
else:
|
||||
identify_payload = {
|
||||
'op': 2,
|
||||
'd': {
|
||||
'token': f'QQBot {self.access_token}',
|
||||
'intents': self.FULL_INTENTS,
|
||||
'shard': [0, 1],
|
||||
},
|
||||
}
|
||||
await ws.send(json.dumps(identify_payload))
|
||||
await self.logger.info(f'Sent Identify, intents={self.FULL_INTENTS}')
|
||||
|
||||
# Start heartbeat
|
||||
async def _heartbeat_loop(conn, interval_ms):
|
||||
interval_sec = interval_ms / 1000.0
|
||||
try:
|
||||
while True:
|
||||
await asyncio.sleep(interval_sec)
|
||||
try:
|
||||
hb_payload = {'op': 1, 'd': last_seq}
|
||||
await conn.send(json.dumps(hb_payload))
|
||||
except Exception:
|
||||
break
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
|
||||
heartbeat_task = asyncio.create_task(_heartbeat_loop(ws, heartbeat_interval))
|
||||
|
||||
elif op == 0: # Dispatch
|
||||
if s is not None:
|
||||
last_seq = s
|
||||
|
||||
if t == 'READY':
|
||||
session_id = d.get('session_id', '')
|
||||
reconnect_attempts = 0
|
||||
await self.logger.info(f'READY, session_id={session_id}')
|
||||
if on_ready:
|
||||
try:
|
||||
result = on_ready()
|
||||
if asyncio.iscoroutine(result):
|
||||
await result
|
||||
except Exception:
|
||||
pass
|
||||
# Track token refresh task to avoid leaks
|
||||
if self._token_refresh_task and not self._token_refresh_task.done():
|
||||
self._token_refresh_task.cancel()
|
||||
try:
|
||||
await self._token_refresh_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
self._token_refresh_task = asyncio.create_task(self._background_token_refresh())
|
||||
|
||||
elif t == 'RESUMED':
|
||||
reconnect_attempts = 0
|
||||
await self.logger.info('RESUMED')
|
||||
|
||||
else:
|
||||
await self.logger.debug(f'Received event: {t}, seq={s}')
|
||||
if on_event:
|
||||
try:
|
||||
result = on_event(t, d)
|
||||
if asyncio.iscoroutine(result):
|
||||
await result
|
||||
except Exception:
|
||||
await self.logger.error(f'Error handling event {t}: {traceback.format_exc()}')
|
||||
|
||||
elif op == 11: # Heartbeat ACK
|
||||
pass
|
||||
|
||||
elif op == 7: # Reconnect
|
||||
await self.logger.info('Received Reconnect directive')
|
||||
break
|
||||
|
||||
elif op == 9: # Invalid Session
|
||||
can_resume = d.get('can_resume', False)
|
||||
await self.logger.warning(f'Invalid Session, can_resume={can_resume}')
|
||||
if not can_resume:
|
||||
session_id = ''
|
||||
last_seq = 0
|
||||
should_refresh_token = True
|
||||
break
|
||||
|
||||
# Connection closed normally (end of async for)
|
||||
try:
|
||||
close_code = ws.close_code
|
||||
close_reason = ws.close_reason or ''
|
||||
except Exception:
|
||||
close_code = None
|
||||
close_reason = ''
|
||||
await self.logger.info(f'Connection closed, code={close_code}, reason={close_reason}')
|
||||
|
||||
if close_code == 4004:
|
||||
should_refresh_token = True
|
||||
elif close_code in (4006, 4007, 4009):
|
||||
session_id = ''
|
||||
last_seq = 0
|
||||
should_refresh_token = True
|
||||
elif close_code == 4008:
|
||||
reconnect_attempts += 1
|
||||
delay = rate_limit_delay
|
||||
await self.logger.info(
|
||||
f'Rate limited, waiting {delay}s before reconnect (attempt {reconnect_attempts})'
|
||||
)
|
||||
await asyncio.sleep(delay)
|
||||
continue
|
||||
elif close_code in (4914, 4915):
|
||||
err = Exception(f'Bot disconnected/banned (close_code={close_code})')
|
||||
if on_error:
|
||||
await self._safe_callback(on_error, err)
|
||||
return
|
||||
elif close_code in (4900, 4901, 4902, 4903, 4904, 4905, 4906, 4907, 4908, 4909, 4910, 4911, 4912, 4913):
|
||||
session_id = ''
|
||||
last_seq = 0
|
||||
|
||||
if close_code == 1000:
|
||||
return
|
||||
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception:
|
||||
await self.logger.error(f'Unexpected error in WebSocket loop: {traceback.format_exc()}')
|
||||
finally:
|
||||
if heartbeat_task:
|
||||
heartbeat_task.cancel()
|
||||
try:
|
||||
await heartbeat_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
if ws:
|
||||
try:
|
||||
await ws.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# If we reach here, we need to reconnect
|
||||
reconnect_attempts += 1
|
||||
if reconnect_attempts > max_reconnect_attempts:
|
||||
await self.logger.error(f'Max reconnect attempts ({max_reconnect_attempts}) reached, stopping')
|
||||
if on_error:
|
||||
await self._safe_callback(on_error, Exception('Max reconnect attempts reached'))
|
||||
return
|
||||
delay = backoff_delays[min(reconnect_attempts - 1, len(backoff_delays) - 1)]
|
||||
await self.logger.info(f'Reconnecting in {delay}s (attempt {reconnect_attempts})')
|
||||
await asyncio.sleep(delay)
|
||||
|
||||
async def _safe_callback(self, callback, *args):
|
||||
"""Safely invoke a callback, handling both sync and async functions."""
|
||||
try:
|
||||
result = callback(*args)
|
||||
if asyncio.iscoroutine(result):
|
||||
await result
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
async def connect_gateway_loop(
|
||||
self,
|
||||
on_event: Callable[[str, dict], Any],
|
||||
on_ready: Optional[Callable[[], Any]] = None,
|
||||
on_error: Optional[Callable[[Exception], Any]] = None,
|
||||
):
|
||||
"""持续重连的网关循环。"""
|
||||
await self.connect_gateway(on_event, on_ready, on_error)
|
||||
|
||||
@@ -1,384 +0,0 @@
|
||||
"""Embed widget routes - serve embeddable chat widget for external websites.
|
||||
|
||||
All user-facing URLs are keyed by **bot_uuid** (not pipeline_uuid) so that
|
||||
internal pipeline identifiers are never exposed to end-users. Each handler
|
||||
resolves the bot_uuid to the owning ``web_page_bot`` RuntimeBot and extracts
|
||||
the bound pipeline_uuid for internal routing.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import datetime
|
||||
import json
|
||||
import logging
|
||||
import uuid
|
||||
import hmac
|
||||
import hashlib
|
||||
import time
|
||||
import re
|
||||
import httpx
|
||||
|
||||
import quart
|
||||
|
||||
from ... import group
|
||||
from ......utils import paths
|
||||
from ......platform.sources.websocket_manager import ws_connection_manager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Cache the widget template content
|
||||
_widget_template_cache: str | None = None
|
||||
_logo_bytes_cache: bytes | None = None
|
||||
|
||||
|
||||
def _is_valid_uuid(s: str) -> bool:
|
||||
return bool(re.match(r'^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$', s))
|
||||
|
||||
|
||||
def _get_widget_template() -> str:
|
||||
"""Load and cache the widget JS template."""
|
||||
global _widget_template_cache
|
||||
if _widget_template_cache is None:
|
||||
template_path = paths.get_resource_path('templates/embed/widget.js')
|
||||
with open(template_path, 'r', encoding='utf-8') as f:
|
||||
_widget_template_cache = f.read()
|
||||
return _widget_template_cache
|
||||
|
||||
|
||||
def _get_logo_bytes() -> bytes:
|
||||
"""Load and cache the logo image."""
|
||||
global _logo_bytes_cache
|
||||
if _logo_bytes_cache is None:
|
||||
logo_path = paths.get_resource_path('templates/embed/logo.webp')
|
||||
with open(logo_path, 'rb') as f:
|
||||
_logo_bytes_cache = f.read()
|
||||
return _logo_bytes_cache
|
||||
|
||||
|
||||
@group.group_class('embed', '/api/v1/embed')
|
||||
class EmbedRouterGroup(group.RouterGroup):
|
||||
# -- helpers -------------------------------------------------------------
|
||||
|
||||
def _resolve_bot(self, bot_uuid: str):
|
||||
"""Resolve *bot_uuid* to ``(runtime_bot, pipeline_uuid)``.
|
||||
|
||||
Returns ``(None, None)`` when the bot does not exist, is not a
|
||||
``web_page_bot``, is disabled, or has no pipeline bound.
|
||||
"""
|
||||
for bot in self.ap.platform_mgr.bots:
|
||||
if (
|
||||
bot.bot_entity.uuid == bot_uuid
|
||||
and bot.bot_entity.adapter == 'web_page_bot'
|
||||
and bot.bot_entity.enable
|
||||
and bot.bot_entity.use_pipeline_uuid
|
||||
):
|
||||
return bot, bot.bot_entity.use_pipeline_uuid
|
||||
return None, None
|
||||
|
||||
def _get_bot_config(self, bot_uuid: str) -> dict:
|
||||
for bot in self.ap.platform_mgr.bots:
|
||||
if bot.bot_entity.uuid == bot_uuid and bot.bot_entity.adapter == 'web_page_bot':
|
||||
return bot.bot_entity.adapter_config
|
||||
return {}
|
||||
|
||||
async def _verify_session_token(self, request, bot_uuid: str) -> bool:
|
||||
config = self._get_bot_config(bot_uuid)
|
||||
secret = config.get('turnstile_secret_key', '')
|
||||
if not secret:
|
||||
return True
|
||||
auth_header = request.headers.get('Authorization', '')
|
||||
if not auth_header.startswith('Bearer '):
|
||||
return False
|
||||
token = auth_header[7:]
|
||||
try:
|
||||
ts_str, mac = token.split('.', 1)
|
||||
ts = float(ts_str)
|
||||
if time.time() - ts > 86400:
|
||||
return False
|
||||
expected_mac = hmac.new(secret.encode(), f'{ts_str}'.encode(), hashlib.sha256).hexdigest()
|
||||
return hmac.compare_digest(mac, expected_mac)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
# -- routes --------------------------------------------------------------
|
||||
|
||||
async def initialize(self) -> None:
|
||||
@self.route('/<bot_uuid>/turnstile/verify', methods=['POST'], auth_type=group.AuthType.NONE)
|
||||
async def verify_turnstile(bot_uuid: str) -> str:
|
||||
if not _is_valid_uuid(bot_uuid):
|
||||
return self.http_status(400, -1, 'Invalid bot_uuid format')
|
||||
runtime_bot, pipeline_uuid = self._resolve_bot(bot_uuid)
|
||||
if runtime_bot is None:
|
||||
return self.http_status(404, -1, 'Bot not found or not available')
|
||||
try:
|
||||
data = await quart.request.get_json()
|
||||
token = data.get('token')
|
||||
if not token:
|
||||
return self.http_status(400, -1, 'Token is required')
|
||||
|
||||
config = self._get_bot_config(bot_uuid)
|
||||
secret = config.get('turnstile_secret_key', '')
|
||||
if not secret:
|
||||
ts = time.time()
|
||||
return self.success(data={'token': f'{ts}.dummy'})
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
resp = await client.post(
|
||||
'https://challenges.cloudflare.com/turnstile/v0/siteverify',
|
||||
data={'secret': secret, 'response': token},
|
||||
)
|
||||
result = resp.json()
|
||||
|
||||
if not result.get('success'):
|
||||
return self.http_status(403, -1, 'Turnstile verification failed')
|
||||
|
||||
ts = time.time()
|
||||
mac = hmac.new(secret.encode(), f'{ts}'.encode(), hashlib.sha256).hexdigest()
|
||||
session_token = f'{ts}.{mac}'
|
||||
|
||||
return self.success(data={'token': session_token})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'Turnstile verify failed: {e}', exc_info=True)
|
||||
return self.http_status(500, -1, 'Internal server error')
|
||||
|
||||
@self.route('/<bot_uuid>/widget.js', methods=['GET'], auth_type=group.AuthType.NONE)
|
||||
async def serve_widget(bot_uuid: str) -> quart.Response:
|
||||
"""Serve the embed widget JavaScript with injected configuration."""
|
||||
if not _is_valid_uuid(bot_uuid):
|
||||
return self.http_status(400, -1, 'Invalid bot_uuid format')
|
||||
runtime_bot, pipeline_uuid = self._resolve_bot(bot_uuid)
|
||||
if runtime_bot is None:
|
||||
return quart.Response(
|
||||
'// Bot not found or not available', status=404, content_type='application/javascript'
|
||||
)
|
||||
try:
|
||||
template = _get_widget_template()
|
||||
except FileNotFoundError:
|
||||
return quart.Response('// Widget template not found', status=404, content_type='application/javascript')
|
||||
|
||||
base_url = quart.request.host_url.rstrip('/')
|
||||
webhook_prefix = self.ap.instance_config.data.get('api', {}).get('webhook_prefix', '')
|
||||
if webhook_prefix:
|
||||
base_url = webhook_prefix.rstrip('/')
|
||||
|
||||
if not re.match(r'^https?://[a-zA-Z0-9._:/-]+$', base_url):
|
||||
base_url = quart.request.host_url.rstrip('/')
|
||||
|
||||
config = self._get_bot_config(bot_uuid)
|
||||
site_key = config.get('turnstile_site_key', '')
|
||||
locale = config.get('language', 'en_US') or 'en_US'
|
||||
bubble_icon = config.get('bubble_icon', 'logo') or 'logo'
|
||||
widget_js = template.replace('__LANGBOT_TURNSTILE_SITE_KEY__', site_key)
|
||||
widget_js = widget_js.replace('__LANGBOT_BOT_UUID__', bot_uuid)
|
||||
widget_js = widget_js.replace('__LANGBOT_BASE_URL__', base_url)
|
||||
widget_js = widget_js.replace('__LANGBOT_LOCALE__', locale)
|
||||
widget_js = widget_js.replace('__LANGBOT_BUBBLE_ICON__', bubble_icon)
|
||||
|
||||
response = quart.Response(widget_js, content_type='application/javascript; charset=utf-8')
|
||||
response.headers['Cache-Control'] = 'public, max-age=300'
|
||||
return response
|
||||
|
||||
@self.route('/logo', methods=['GET'], auth_type=group.AuthType.NONE)
|
||||
async def serve_logo() -> quart.Response:
|
||||
"""Serve the LangBot logo for the embed widget."""
|
||||
try:
|
||||
logo_data = _get_logo_bytes()
|
||||
except FileNotFoundError:
|
||||
return quart.Response('', status=404)
|
||||
|
||||
response = quart.Response(logo_data, content_type='image/webp')
|
||||
response.headers['Cache-Control'] = 'public, max-age=86400'
|
||||
return response
|
||||
|
||||
@self.route('/<bot_uuid>/messages/<session_type>', methods=['GET'], auth_type=group.AuthType.NONE)
|
||||
async def get_embed_messages(bot_uuid: str, session_type: str) -> str:
|
||||
if not _is_valid_uuid(bot_uuid):
|
||||
return self.http_status(400, -1, 'Invalid bot_uuid format')
|
||||
runtime_bot, pipeline_uuid = self._resolve_bot(bot_uuid)
|
||||
if runtime_bot is None:
|
||||
return self.http_status(404, -1, 'Bot not found or not available')
|
||||
if not await self._verify_session_token(quart.request, bot_uuid):
|
||||
return self.http_status(403, -1, 'Unauthorized or session expired')
|
||||
try:
|
||||
if session_type not in ['person', 'group']:
|
||||
return self.http_status(400, -1, 'session_type must be person or group')
|
||||
|
||||
websocket_adapter = self.ap.platform_mgr.websocket_proxy_bot.adapter
|
||||
if not websocket_adapter:
|
||||
return self.http_status(404, -1, 'WebSocket adapter not found')
|
||||
|
||||
messages = websocket_adapter.get_websocket_messages(pipeline_uuid, session_type)
|
||||
return self.success(data={'messages': messages})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'Failed to get embed messages: {e}', exc_info=True)
|
||||
return self.http_status(500, -1, 'Internal server error')
|
||||
|
||||
@self.route('/<bot_uuid>/reset/<session_type>', methods=['POST'], auth_type=group.AuthType.NONE)
|
||||
async def reset_embed_session(bot_uuid: str, session_type: str) -> str:
|
||||
if not _is_valid_uuid(bot_uuid):
|
||||
return self.http_status(400, -1, 'Invalid bot_uuid format')
|
||||
runtime_bot, pipeline_uuid = self._resolve_bot(bot_uuid)
|
||||
if runtime_bot is None:
|
||||
return self.http_status(404, -1, 'Bot not found or not available')
|
||||
if not await self._verify_session_token(quart.request, bot_uuid):
|
||||
return self.http_status(403, -1, 'Unauthorized or session expired')
|
||||
try:
|
||||
if session_type not in ['person', 'group']:
|
||||
return self.http_status(400, -1, 'session_type must be person or group')
|
||||
|
||||
websocket_adapter = self.ap.platform_mgr.websocket_proxy_bot.adapter
|
||||
if not websocket_adapter:
|
||||
return self.http_status(404, -1, 'WebSocket adapter not found')
|
||||
|
||||
websocket_adapter.reset_session(pipeline_uuid, session_type)
|
||||
return self.success(data={'message': 'Session reset successfully'})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'Failed to reset embed session: {e}', exc_info=True)
|
||||
return self.http_status(500, -1, 'Internal server error')
|
||||
|
||||
@self.route('/<bot_uuid>/feedback', methods=['POST'], auth_type=group.AuthType.NONE)
|
||||
async def submit_feedback(bot_uuid: str) -> str:
|
||||
if not _is_valid_uuid(bot_uuid):
|
||||
return self.http_status(400, -1, 'Invalid bot_uuid format')
|
||||
runtime_bot, pipeline_uuid = self._resolve_bot(bot_uuid)
|
||||
if runtime_bot is None:
|
||||
return self.http_status(404, -1, 'Bot not found or not available')
|
||||
if not await self._verify_session_token(quart.request, bot_uuid):
|
||||
return self.http_status(403, -1, 'Unauthorized or session expired')
|
||||
try:
|
||||
data = await quart.request.get_json()
|
||||
message_id = data.get('message_id', '')
|
||||
feedback_type = data.get('feedback_type')
|
||||
|
||||
if feedback_type not in (1, 2, 3):
|
||||
return self.http_status(400, -1, 'feedback_type must be 1 (like), 2 (dislike), or 3 (cancel)')
|
||||
|
||||
feedback_id = f'embed_{uuid.uuid4().hex[:12]}'
|
||||
|
||||
await self.ap.monitoring_service.record_feedback(
|
||||
feedback_id=feedback_id,
|
||||
feedback_type=feedback_type,
|
||||
bot_id=runtime_bot.bot_entity.uuid,
|
||||
bot_name=runtime_bot.bot_entity.name or bot_uuid,
|
||||
pipeline_id=pipeline_uuid,
|
||||
message_id=str(message_id),
|
||||
platform='web_page_bot',
|
||||
)
|
||||
|
||||
return self.success(data={'feedback_id': feedback_id})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'Failed to record feedback: {e}', exc_info=True)
|
||||
return self.http_status(500, -1, 'Internal server error')
|
||||
|
||||
# -- Embed WebSocket endpoint ----------------------------------------
|
||||
|
||||
@self.quart_app.websocket(self.path + '/<bot_uuid>/ws/connect')
|
||||
async def embed_websocket_connect(bot_uuid: str):
|
||||
"""WebSocket connection for embed widget, keyed by bot_uuid."""
|
||||
if not _is_valid_uuid(bot_uuid):
|
||||
await quart.websocket.send(json.dumps({'type': 'error', 'message': 'Invalid bot_uuid format'}))
|
||||
return
|
||||
|
||||
runtime_bot, pipeline_uuid = self._resolve_bot(bot_uuid)
|
||||
if runtime_bot is None:
|
||||
await quart.websocket.send(json.dumps({'type': 'error', 'message': 'Bot not found or not available'}))
|
||||
return
|
||||
|
||||
session_type = quart.websocket.args.get('session_type', 'person')
|
||||
if session_type not in ['person', 'group']:
|
||||
await quart.websocket.send(
|
||||
json.dumps({'type': 'error', 'message': 'session_type must be person or group'})
|
||||
)
|
||||
return
|
||||
|
||||
websocket_adapter = self.ap.platform_mgr.websocket_proxy_bot.adapter
|
||||
if not websocket_adapter:
|
||||
await quart.websocket.send(json.dumps({'type': 'error', 'message': 'WebSocket adapter not found'}))
|
||||
return
|
||||
|
||||
try:
|
||||
connection = await ws_connection_manager.add_connection(
|
||||
websocket=quart.websocket._get_current_object(),
|
||||
pipeline_uuid=pipeline_uuid,
|
||||
session_type=session_type,
|
||||
metadata={'user_agent': quart.websocket.headers.get('User-Agent', '')},
|
||||
)
|
||||
|
||||
await quart.websocket.send(
|
||||
json.dumps(
|
||||
{
|
||||
'type': 'connected',
|
||||
'connection_id': connection.connection_id,
|
||||
'bot_uuid': bot_uuid,
|
||||
'session_type': session_type,
|
||||
'timestamp': connection.created_at.isoformat(),
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
logger.debug(
|
||||
f'Embed WebSocket connected: {connection.connection_id} '
|
||||
f'(bot={bot_uuid}, pipeline={pipeline_uuid}, session_type={session_type})'
|
||||
)
|
||||
|
||||
receive_task = asyncio.create_task(self._handle_receive(connection, websocket_adapter, runtime_bot))
|
||||
send_task = asyncio.create_task(self._handle_send(connection))
|
||||
|
||||
try:
|
||||
await asyncio.gather(receive_task, send_task)
|
||||
except Exception as e:
|
||||
logger.error(f'Embed WebSocket task error: {e}')
|
||||
finally:
|
||||
await ws_connection_manager.remove_connection(connection.connection_id)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'Embed WebSocket connection error: {e}', exc_info=True)
|
||||
try:
|
||||
await quart.websocket.send(json.dumps({'type': 'error', 'message': 'Internal server error'}))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# -- WebSocket receive/send helpers --------------------------------------
|
||||
|
||||
async def _handle_receive(self, connection, websocket_adapter, owner_bot):
|
||||
try:
|
||||
while connection.is_active:
|
||||
message = await quart.websocket.receive()
|
||||
await ws_connection_manager.update_activity(connection.connection_id)
|
||||
|
||||
try:
|
||||
data = json.loads(message)
|
||||
message_type = data.get('type', 'message')
|
||||
|
||||
if message_type == 'ping':
|
||||
await connection.send_queue.put(
|
||||
{'type': 'pong', 'timestamp': datetime.datetime.now().isoformat()}
|
||||
)
|
||||
elif message_type == 'message':
|
||||
await websocket_adapter.handle_websocket_message(connection, data, owner_bot=owner_bot)
|
||||
elif message_type == 'disconnect':
|
||||
break
|
||||
|
||||
except json.JSONDecodeError:
|
||||
await connection.send_queue.put({'type': 'error', 'message': 'Invalid JSON format'})
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f'Embed receive error: {e}', exc_info=True)
|
||||
finally:
|
||||
connection.is_active = False
|
||||
|
||||
async def _handle_send(self, connection):
|
||||
try:
|
||||
while connection.is_active:
|
||||
try:
|
||||
message = await asyncio.wait_for(connection.send_queue.get(), timeout=1.0)
|
||||
await quart.websocket.send(json.dumps(message))
|
||||
except asyncio.TimeoutError:
|
||||
continue
|
||||
except Exception as e:
|
||||
logger.error(f'Embed send error: {e}', exc_info=True)
|
||||
finally:
|
||||
connection.is_active = False
|
||||
@@ -43,9 +43,6 @@ class WebSocketChatRouterGroup(group.RouterGroup):
|
||||
await quart.websocket.send(json.dumps({'type': 'error', 'message': 'WebSocket adapter not found'}))
|
||||
return
|
||||
|
||||
# Find the owning bot for this pipeline (e.g. a web_page_bot)
|
||||
owner_bot = self._find_owner_bot(pipeline_uuid)
|
||||
|
||||
# 注册连接
|
||||
connection = await ws_connection_manager.add_connection(
|
||||
websocket=quart.websocket._get_current_object(),
|
||||
@@ -73,7 +70,7 @@ class WebSocketChatRouterGroup(group.RouterGroup):
|
||||
)
|
||||
|
||||
# 创建接收和发送任务
|
||||
receive_task = asyncio.create_task(self._handle_receive(connection, websocket_adapter, owner_bot))
|
||||
receive_task = asyncio.create_task(self._handle_receive(connection, websocket_adapter))
|
||||
send_task = asyncio.create_task(self._handle_send(connection))
|
||||
|
||||
# 等待任务完成
|
||||
@@ -181,14 +178,7 @@ class WebSocketChatRouterGroup(group.RouterGroup):
|
||||
except Exception as e:
|
||||
return self.http_status(500, -1, f'Internal server error: {str(e)}')
|
||||
|
||||
def _find_owner_bot(self, pipeline_uuid: str):
|
||||
"""Find a user-created bot (e.g. web_page_bot) that owns this pipeline."""
|
||||
for bot in self.ap.platform_mgr.bots:
|
||||
if bot.bot_entity.adapter == 'web_page_bot' and bot.bot_entity.use_pipeline_uuid == pipeline_uuid:
|
||||
return bot
|
||||
return None
|
||||
|
||||
async def _handle_receive(self, connection, websocket_adapter, owner_bot=None):
|
||||
async def _handle_receive(self, connection, websocket_adapter):
|
||||
"""处理接收消息的任务"""
|
||||
try:
|
||||
while connection.is_active:
|
||||
@@ -213,7 +203,7 @@ class WebSocketChatRouterGroup(group.RouterGroup):
|
||||
logger.debug(f'收到消息: {data} from {connection.connection_id}')
|
||||
|
||||
# 处理消息(不等待响应,响应会通过broadcast异步发送)
|
||||
await websocket_adapter.handle_websocket_message(connection, data, owner_bot=owner_bot)
|
||||
await websocket_adapter.handle_websocket_message(connection, data)
|
||||
|
||||
elif message_type == 'disconnect':
|
||||
# 客户端主动断开
|
||||
|
||||
@@ -6,38 +6,11 @@ import re
|
||||
import httpx
|
||||
import uuid
|
||||
import os
|
||||
import posixpath
|
||||
|
||||
from .....core import taskmgr
|
||||
from .. import group
|
||||
from langbot_plugin.runtime.plugin.mgr import PluginInstallSource
|
||||
|
||||
# Resolve the built-in page SDK JS from the langbot_plugin package
|
||||
_PAGE_SDK_PATH = None
|
||||
try:
|
||||
import langbot_plugin.assets as _assets_pkg
|
||||
|
||||
_candidate = os.path.join(os.path.dirname(_assets_pkg.__file__), 'langbot-page-sdk.js')
|
||||
if os.path.exists(_candidate):
|
||||
_PAGE_SDK_PATH = _candidate
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _normalize_plugin_asset_path(filepath: str) -> str | None:
|
||||
filepath = filepath.replace('\\', '/')
|
||||
if filepath.startswith('/'):
|
||||
return None
|
||||
|
||||
normalized = posixpath.normpath(filepath)
|
||||
if normalized == '.' or normalized.startswith('../') or normalized == '..':
|
||||
return None
|
||||
|
||||
if normalized.startswith('components/pages/'):
|
||||
return normalized
|
||||
|
||||
return f'assets/{normalized}'
|
||||
|
||||
|
||||
@group.group_class('plugins', '/api/v1/plugins')
|
||||
class PluginsRouterGroup(group.RouterGroup):
|
||||
@@ -54,15 +27,6 @@ class PluginsRouterGroup(group.RouterGroup):
|
||||
return None
|
||||
|
||||
async def initialize(self) -> None:
|
||||
@self.route('/_sdk/page-sdk.js', methods=['GET'], auth_type=group.AuthType.NONE)
|
||||
async def _() -> quart.Response:
|
||||
"""Serve the built-in LangBot page SDK JavaScript."""
|
||||
if _PAGE_SDK_PATH and os.path.exists(_PAGE_SDK_PATH):
|
||||
with open(_PAGE_SDK_PATH, 'r') as f:
|
||||
content = f.read()
|
||||
return quart.Response(content, mimetype='application/javascript')
|
||||
return quart.Response('// SDK not found', status=404, mimetype='application/javascript')
|
||||
|
||||
@self.route('', methods=['GET'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
||||
async def _() -> str:
|
||||
plugins = await self.ap.plugin_connector.list_plugins()
|
||||
@@ -171,62 +135,15 @@ class PluginsRouterGroup(group.RouterGroup):
|
||||
return quart.Response(icon_data, mimetype=mime_type)
|
||||
|
||||
@self.route(
|
||||
'/<author>/<plugin_name>/assets/<path:filepath>',
|
||||
'/<author>/<plugin_name>/assets/<filepath>',
|
||||
methods=['GET'],
|
||||
auth_type=group.AuthType.NONE,
|
||||
)
|
||||
async def _(author: str, plugin_name: str, filepath: str) -> quart.Response:
|
||||
asset_path = _normalize_plugin_asset_path(filepath)
|
||||
if asset_path is None:
|
||||
return quart.Response('Asset not found', status=404)
|
||||
|
||||
asset_data = await self.ap.plugin_connector.get_plugin_assets(author, plugin_name, asset_path)
|
||||
if not asset_data.get('asset_base64'):
|
||||
return quart.Response('Asset not found', status=404)
|
||||
asset_data = await self.ap.plugin_connector.get_plugin_assets(author, plugin_name, filepath)
|
||||
asset_bytes = base64.b64decode(asset_data['asset_base64'])
|
||||
mime_type = asset_data['mime_type']
|
||||
resp = quart.Response(asset_bytes, mimetype=mime_type)
|
||||
# CSP for HTML pages served to sandboxed iframes (opaque origin).
|
||||
# 'self' doesn't work in sandboxed iframes — use actual server origin.
|
||||
if mime_type and mime_type.startswith('text/html'):
|
||||
origin = f'{quart.request.scheme}://{quart.request.host}'
|
||||
resp.headers['Content-Security-Policy'] = (
|
||||
f'default-src {origin}; '
|
||||
f"script-src {origin} 'unsafe-inline'; "
|
||||
f"style-src {origin} 'unsafe-inline'; "
|
||||
f'img-src {origin} data:; '
|
||||
f'connect-src {origin}; '
|
||||
"frame-src 'none'; "
|
||||
"object-src 'none'"
|
||||
)
|
||||
return resp
|
||||
|
||||
@self.route(
|
||||
'/<author>/<plugin_name>/page-api',
|
||||
methods=['POST'],
|
||||
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
|
||||
)
|
||||
async def _(author: str, plugin_name: str) -> str:
|
||||
"""Forward a page API request to the plugin."""
|
||||
data = await quart.request.json
|
||||
if not isinstance(data, dict):
|
||||
return self.http_status(400, -1, 'invalid request body')
|
||||
|
||||
page_id = data.get('page_id', '')
|
||||
endpoint = data.get('endpoint', '')
|
||||
method = data.get('method', 'POST')
|
||||
body = data.get('body')
|
||||
if not isinstance(page_id, str) or not isinstance(endpoint, str) or not isinstance(method, str):
|
||||
return self.http_status(400, -1, 'invalid page api request')
|
||||
if not endpoint.startswith('/') or '..' in endpoint:
|
||||
return self.http_status(400, -1, 'invalid endpoint')
|
||||
|
||||
result = await self.ap.plugin_connector.handle_page_api(
|
||||
author, plugin_name, page_id, endpoint, method.upper(), body
|
||||
)
|
||||
if result.get('error'):
|
||||
return self.http_status(400, -1, result['error'])
|
||||
return self.success(data=result.get('data'))
|
||||
return quart.Response(asset_bytes, mimetype=mime_type)
|
||||
|
||||
@self.route('/github/releases', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
|
||||
async def _() -> str:
|
||||
|
||||
@@ -136,10 +136,6 @@ class SystemRouterGroup(group.RouterGroup):
|
||||
|
||||
return self.success(data=task.to_dict())
|
||||
|
||||
@self.route('/storage-analysis', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
|
||||
async def _() -> str:
|
||||
return self.success(data=await self.ap.maintenance_service.get_storage_analysis())
|
||||
|
||||
@self.route('/debug/exec', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
|
||||
async def _() -> str:
|
||||
if not constants.debug_mode:
|
||||
|
||||
@@ -1,309 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import sqlalchemy
|
||||
|
||||
from ....core import app
|
||||
from ....entity.persistence import bstorage as persistence_bstorage
|
||||
from ....entity.persistence import monitoring as persistence_monitoring
|
||||
|
||||
|
||||
LOG_FILE_PATTERN = re.compile(r'^langbot-(\d{4}-\d{2}-\d{2})\.log(?:\.\d+)?$')
|
||||
DEFAULT_UPLOAD_FILE_RETENTION_DAYS = 7
|
||||
DEFAULT_LOG_RETENTION_DAYS = 3
|
||||
|
||||
|
||||
class MaintenanceService:
|
||||
"""Storage maintenance and diagnostics."""
|
||||
|
||||
ap: app.Application
|
||||
|
||||
def __init__(self, ap: app.Application) -> None:
|
||||
self.ap = ap
|
||||
|
||||
async def cleanup_expired_files(self) -> dict[str, int]:
|
||||
cleanup_cfg = self.ap.instance_config.data.get('storage', {}).get('cleanup', {})
|
||||
upload_retention_days = self._positive_int(
|
||||
cleanup_cfg.get('uploaded_file_retention_days'),
|
||||
DEFAULT_UPLOAD_FILE_RETENTION_DAYS,
|
||||
'storage.cleanup.uploaded_file_retention_days',
|
||||
)
|
||||
log_retention_days = self._positive_int(
|
||||
cleanup_cfg.get('log_retention_days'),
|
||||
DEFAULT_LOG_RETENTION_DAYS,
|
||||
'storage.cleanup.log_retention_days',
|
||||
)
|
||||
|
||||
return {
|
||||
'uploaded_files': await self._cleanup_expired_uploaded_files(upload_retention_days),
|
||||
'log_files': self._cleanup_expired_log_files(log_retention_days),
|
||||
}
|
||||
|
||||
async def get_storage_analysis(self) -> dict[str, Any]:
|
||||
cleanup_cfg = self.ap.instance_config.data.get('storage', {}).get('cleanup', {})
|
||||
upload_retention_days = self._positive_int(
|
||||
cleanup_cfg.get('uploaded_file_retention_days'),
|
||||
DEFAULT_UPLOAD_FILE_RETENTION_DAYS,
|
||||
'storage.cleanup.uploaded_file_retention_days',
|
||||
)
|
||||
log_retention_days = self._positive_int(
|
||||
cleanup_cfg.get('log_retention_days'),
|
||||
DEFAULT_LOG_RETENTION_DAYS,
|
||||
'storage.cleanup.log_retention_days',
|
||||
)
|
||||
|
||||
database_cfg = self.ap.instance_config.data.get('database', {})
|
||||
database_type = database_cfg.get('use', 'sqlite')
|
||||
database_path = (
|
||||
Path(database_cfg.get('sqlite', {}).get('path', 'data/langbot.db')) if database_type == 'sqlite' else None
|
||||
)
|
||||
roots: list[tuple[str, Path | None]] = [
|
||||
('database', database_path),
|
||||
('logs', Path('data/logs')),
|
||||
('storage', Path('data/storage')),
|
||||
('vector_store', Path('data/chroma')),
|
||||
('plugins', Path('data/plugins')),
|
||||
('mcp', Path('data/mcp')),
|
||||
('temp', Path('data/temp')),
|
||||
]
|
||||
|
||||
sections = []
|
||||
for key, path in roots:
|
||||
sections.append(
|
||||
{
|
||||
'key': key,
|
||||
'path': str(path) if path else '',
|
||||
'exists': path.exists() if path else False,
|
||||
'size_bytes': self._path_size(path) if path else 0,
|
||||
'file_count': self._file_count(path) if path else 0,
|
||||
}
|
||||
)
|
||||
|
||||
monitoring_counts = await self._monitoring_counts()
|
||||
binary_storage = await self._binary_storage_stats()
|
||||
upload_candidates = await self._expired_uploaded_candidates(upload_retention_days)
|
||||
log_candidates = self._expired_log_candidates(log_retention_days)
|
||||
|
||||
return {
|
||||
'generated_at': datetime.datetime.now(datetime.timezone.utc).isoformat(),
|
||||
'cleanup_policy': {
|
||||
'uploaded_file_retention_days': upload_retention_days,
|
||||
'log_retention_days': log_retention_days,
|
||||
},
|
||||
'sections': sections,
|
||||
'database': {
|
||||
'type': database_type,
|
||||
'monitoring_counts': monitoring_counts,
|
||||
'binary_storage': binary_storage,
|
||||
},
|
||||
'cleanup_candidates': {
|
||||
'uploaded_files': upload_candidates,
|
||||
'log_files': log_candidates,
|
||||
},
|
||||
'tasks': self.ap.task_mgr.get_stats() if self.ap.task_mgr else {},
|
||||
}
|
||||
|
||||
async def _cleanup_expired_uploaded_files(self, retention_days: int) -> int:
|
||||
provider = self.ap.storage_mgr.storage_provider
|
||||
provider_name = provider.__class__.__name__
|
||||
if provider_name == 'LocalStorageProvider':
|
||||
candidates = self._expired_local_upload_candidates(retention_days, include_paths=True)
|
||||
deleted = 0
|
||||
for item in candidates:
|
||||
try:
|
||||
os.remove(item['path'])
|
||||
deleted += 1
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
except Exception as e:
|
||||
self.ap.logger.warning(f'Failed to delete expired uploaded file {item["key"]}: {e}')
|
||||
return deleted
|
||||
|
||||
if provider_name == 'S3StorageProvider':
|
||||
return await self._cleanup_expired_s3_uploaded_files(retention_days)
|
||||
|
||||
return 0
|
||||
|
||||
async def _expired_uploaded_candidates(self, retention_days: int) -> list[dict[str, Any]]:
|
||||
provider_name = self.ap.storage_mgr.storage_provider.__class__.__name__
|
||||
if provider_name == 'LocalStorageProvider':
|
||||
return self._expired_local_upload_candidates(retention_days)
|
||||
if provider_name == 'S3StorageProvider':
|
||||
return await self._expired_s3_upload_candidates(retention_days)
|
||||
return []
|
||||
|
||||
async def _cleanup_expired_s3_uploaded_files(self, retention_days: int) -> int:
|
||||
provider = self.ap.storage_mgr.storage_provider
|
||||
candidates = await self._expired_s3_upload_candidates(retention_days)
|
||||
deleted = 0
|
||||
for item in candidates:
|
||||
await provider.delete(item['key'])
|
||||
deleted += 1
|
||||
return deleted
|
||||
|
||||
async def _expired_s3_upload_candidates(self, retention_days: int) -> list[dict[str, Any]]:
|
||||
provider = self.ap.storage_mgr.storage_provider
|
||||
cutoff = datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(days=retention_days)
|
||||
candidates = []
|
||||
paginator = provider.s3_client.get_paginator('list_objects_v2')
|
||||
|
||||
for page in paginator.paginate(Bucket=provider.bucket_name):
|
||||
for obj in page.get('Contents', []):
|
||||
key = obj.get('Key', '')
|
||||
last_modified = obj.get('LastModified')
|
||||
if not self._is_uploaded_file_key(key):
|
||||
continue
|
||||
if last_modified and last_modified < cutoff:
|
||||
candidates.append(
|
||||
{
|
||||
'key': key,
|
||||
'size_bytes': obj.get('Size', 0),
|
||||
'modified_at': last_modified.isoformat(),
|
||||
}
|
||||
)
|
||||
|
||||
return candidates
|
||||
|
||||
def _cleanup_expired_log_files(self, retention_days: int) -> int:
|
||||
deleted = 0
|
||||
for item in self._expired_log_candidates(retention_days, include_paths=True):
|
||||
try:
|
||||
os.remove(item['path'])
|
||||
deleted += 1
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
except Exception as e:
|
||||
self.ap.logger.warning(f'Failed to delete expired log file {item["name"]}: {e}')
|
||||
return deleted
|
||||
|
||||
def _expired_local_upload_candidates(
|
||||
self, retention_days: int, include_paths: bool = False
|
||||
) -> list[dict[str, Any]]:
|
||||
storage_root = Path('data/storage')
|
||||
if not storage_root.exists():
|
||||
return []
|
||||
|
||||
cutoff = datetime.datetime.now().timestamp() - retention_days * 86400
|
||||
candidates = []
|
||||
for entry in storage_root.iterdir():
|
||||
if not entry.is_file() or not self._is_uploaded_file_key(entry.name):
|
||||
continue
|
||||
stat = entry.stat()
|
||||
if stat.st_mtime >= cutoff:
|
||||
continue
|
||||
item = {
|
||||
'key': entry.name,
|
||||
'size_bytes': stat.st_size,
|
||||
'modified_at': datetime.datetime.fromtimestamp(stat.st_mtime, datetime.timezone.utc).isoformat(),
|
||||
}
|
||||
if include_paths:
|
||||
item['path'] = str(entry)
|
||||
candidates.append(item)
|
||||
return candidates
|
||||
|
||||
def _expired_log_candidates(self, retention_days: int, include_paths: bool = False) -> list[dict[str, Any]]:
|
||||
log_root = Path('data/logs')
|
||||
if not log_root.exists():
|
||||
return []
|
||||
|
||||
cutoff_date = datetime.date.today() - datetime.timedelta(days=retention_days - 1)
|
||||
candidates = []
|
||||
for entry in log_root.iterdir():
|
||||
if not entry.is_file():
|
||||
continue
|
||||
match = LOG_FILE_PATTERN.match(entry.name)
|
||||
if not match:
|
||||
continue
|
||||
try:
|
||||
file_date = datetime.date.fromisoformat(match.group(1))
|
||||
except ValueError:
|
||||
continue
|
||||
if file_date >= cutoff_date:
|
||||
continue
|
||||
stat = entry.stat()
|
||||
item = {
|
||||
'name': entry.name,
|
||||
'date': file_date.isoformat(),
|
||||
'size_bytes': stat.st_size,
|
||||
}
|
||||
if include_paths:
|
||||
item['path'] = str(entry)
|
||||
candidates.append(item)
|
||||
return candidates
|
||||
|
||||
def _is_uploaded_file_key(self, key: str) -> bool:
|
||||
return '/' not in key and not key.startswith('plugin_config_')
|
||||
|
||||
async def _monitoring_counts(self) -> dict[str, int]:
|
||||
tables = {
|
||||
'messages': persistence_monitoring.MonitoringMessage.id,
|
||||
'llm_calls': persistence_monitoring.MonitoringLLMCall.id,
|
||||
'embedding_calls': persistence_monitoring.MonitoringEmbeddingCall.id,
|
||||
'errors': persistence_monitoring.MonitoringError.id,
|
||||
'sessions': persistence_monitoring.MonitoringSession.session_id,
|
||||
'feedback': persistence_monitoring.MonitoringFeedback.id,
|
||||
}
|
||||
counts: dict[str, int] = {}
|
||||
for key, column in tables.items():
|
||||
result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(sqlalchemy.func.count(column)))
|
||||
counts[key] = result.scalar() or 0
|
||||
return counts
|
||||
|
||||
async def _binary_storage_stats(self) -> dict[str, Any]:
|
||||
count_result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.select(sqlalchemy.func.count(persistence_bstorage.BinaryStorage.unique_key))
|
||||
)
|
||||
size_bytes = None
|
||||
try:
|
||||
size_result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.select(sqlalchemy.func.sum(sqlalchemy.func.length(persistence_bstorage.BinaryStorage.value)))
|
||||
)
|
||||
size_bytes = size_result.scalar() or 0
|
||||
except Exception as e:
|
||||
self.ap.logger.warning(f'Failed to estimate binary storage size: {e}')
|
||||
|
||||
return {
|
||||
'count': count_result.scalar() or 0,
|
||||
'size_bytes': size_bytes,
|
||||
}
|
||||
|
||||
def _path_size(self, path: Path) -> int:
|
||||
if not path.exists():
|
||||
return 0
|
||||
if path.is_file():
|
||||
return path.stat().st_size
|
||||
total = 0
|
||||
for root, _, files in os.walk(path):
|
||||
for file_name in files:
|
||||
file_path = Path(root) / file_name
|
||||
try:
|
||||
total += file_path.stat().st_size
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
return total
|
||||
|
||||
def _file_count(self, path: Path) -> int:
|
||||
if not path.exists():
|
||||
return 0
|
||||
if path.is_file():
|
||||
return 1
|
||||
count = 0
|
||||
for _, _, files in os.walk(path):
|
||||
count += len(files)
|
||||
return count
|
||||
|
||||
def _positive_int(self, value: Any, default: int, name: str) -> int:
|
||||
try:
|
||||
parsed = int(value)
|
||||
except (TypeError, ValueError):
|
||||
self.ap.logger.warning(f'Invalid {name}: {value!r}, using {default}')
|
||||
return default
|
||||
if parsed < 1:
|
||||
self.ap.logger.warning(f'Invalid {name}: {value!r}, using {default}')
|
||||
return default
|
||||
return parsed
|
||||
@@ -23,17 +23,6 @@ def _parse_provider_api_keys(provider_dict: dict) -> dict:
|
||||
return provider_dict
|
||||
|
||||
|
||||
def _runtime_model_data(model_uuid: str, model_data: dict) -> dict:
|
||||
"""Return model data for rebuilding runtime models after an update.
|
||||
|
||||
Update payloads intentionally omit uuid before writing to the database.
|
||||
Runtime model entities still need the stable uuid so pipeline configs can
|
||||
resolve the in-memory model immediately after an edit, without requiring a
|
||||
process restart.
|
||||
"""
|
||||
return {**model_data, 'uuid': model_uuid}
|
||||
|
||||
|
||||
class LLMModelsService:
|
||||
ap: app.Application
|
||||
|
||||
@@ -184,7 +173,7 @@ class LLMModelsService:
|
||||
raise Exception('provider not found')
|
||||
|
||||
runtime_llm_model = await self.ap.model_mgr.load_llm_model_with_provider(
|
||||
persistence_model.LLMModel(**_runtime_model_data(model_uuid, model_data)),
|
||||
persistence_model.LLMModel(**model_data),
|
||||
runtime_provider,
|
||||
)
|
||||
self.ap.model_mgr.llm_models.append(runtime_llm_model)
|
||||
@@ -345,7 +334,7 @@ class EmbeddingModelsService:
|
||||
raise Exception('provider not found')
|
||||
|
||||
runtime_embedding_model = await self.ap.model_mgr.load_embedding_model_with_provider(
|
||||
persistence_model.EmbeddingModel(**_runtime_model_data(model_uuid, model_data)),
|
||||
persistence_model.EmbeddingModel(**model_data),
|
||||
runtime_provider,
|
||||
)
|
||||
self.ap.model_mgr.embedding_models.append(runtime_embedding_model)
|
||||
@@ -503,7 +492,7 @@ class RerankModelsService:
|
||||
raise Exception('provider not found')
|
||||
|
||||
runtime_rerank_model = await self.ap.model_mgr.load_rerank_model_with_provider(
|
||||
persistence_model.RerankModel(**_runtime_model_data(model_uuid, model_data)),
|
||||
persistence_model.RerankModel(**model_data),
|
||||
runtime_provider,
|
||||
)
|
||||
self.ap.model_mgr.rerank_models.append(runtime_rerank_model)
|
||||
|
||||
@@ -18,119 +18,55 @@ class MonitoringService:
|
||||
|
||||
# ========== Cleanup Methods ==========
|
||||
|
||||
async def cleanup_expired_records(self, retention_days: int, batch_size: int = 1000) -> dict[str, int]:
|
||||
async def cleanup_expired_records(self, retention_days: int) -> dict[str, int]:
|
||||
"""Delete monitoring records older than the specified retention period.
|
||||
|
||||
Args:
|
||||
retention_days: Number of days to retain records.
|
||||
batch_size: Maximum rows to delete per table batch.
|
||||
|
||||
Returns:
|
||||
A dict mapping table name to the number of deleted rows.
|
||||
"""
|
||||
if retention_days < 1:
|
||||
raise ValueError('retention_days must be >= 1')
|
||||
if batch_size < 1:
|
||||
raise ValueError('batch_size must be >= 1')
|
||||
|
||||
cutoff = datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None) - datetime.timedelta(
|
||||
days=retention_days
|
||||
)
|
||||
|
||||
tables_and_columns: list[tuple[str, type, sqlalchemy.Column, sqlalchemy.Column]] = [
|
||||
tables_and_columns: list[tuple[str, type, sqlalchemy.Column]] = [
|
||||
(
|
||||
'monitoring_messages',
|
||||
persistence_monitoring.MonitoringMessage,
|
||||
persistence_monitoring.MonitoringMessage.timestamp,
|
||||
persistence_monitoring.MonitoringMessage.id,
|
||||
),
|
||||
(
|
||||
'monitoring_llm_calls',
|
||||
persistence_monitoring.MonitoringLLMCall,
|
||||
persistence_monitoring.MonitoringLLMCall.timestamp,
|
||||
persistence_monitoring.MonitoringLLMCall.id,
|
||||
),
|
||||
(
|
||||
'monitoring_embedding_calls',
|
||||
persistence_monitoring.MonitoringEmbeddingCall,
|
||||
persistence_monitoring.MonitoringEmbeddingCall.timestamp,
|
||||
persistence_monitoring.MonitoringEmbeddingCall.id,
|
||||
),
|
||||
(
|
||||
'monitoring_errors',
|
||||
persistence_monitoring.MonitoringError,
|
||||
persistence_monitoring.MonitoringError.timestamp,
|
||||
persistence_monitoring.MonitoringError.id,
|
||||
),
|
||||
(
|
||||
'monitoring_sessions',
|
||||
persistence_monitoring.MonitoringSession,
|
||||
persistence_monitoring.MonitoringSession.last_activity,
|
||||
persistence_monitoring.MonitoringSession.session_id,
|
||||
),
|
||||
(
|
||||
'monitoring_feedback',
|
||||
persistence_monitoring.MonitoringFeedback,
|
||||
persistence_monitoring.MonitoringFeedback.timestamp,
|
||||
persistence_monitoring.MonitoringFeedback.id,
|
||||
),
|
||||
]
|
||||
|
||||
deleted_counts: dict[str, int] = {}
|
||||
|
||||
for table_name, model_cls, ts_column, pk_column in tables_and_columns:
|
||||
deleted_counts[table_name] = await self._delete_expired_in_batches(
|
||||
model_cls=model_cls,
|
||||
ts_column=ts_column,
|
||||
pk_column=pk_column,
|
||||
cutoff=cutoff,
|
||||
batch_size=batch_size,
|
||||
)
|
||||
|
||||
if sum(deleted_counts.values()) > 0:
|
||||
await self._release_sqlite_space()
|
||||
for table_name, model_cls, ts_column in tables_and_columns:
|
||||
result = await self.ap.persistence_mgr.execute_async(sqlalchemy.delete(model_cls).where(ts_column < cutoff))
|
||||
deleted_counts[table_name] = result.rowcount
|
||||
|
||||
return deleted_counts
|
||||
|
||||
async def _delete_expired_in_batches(
|
||||
self,
|
||||
model_cls: type,
|
||||
ts_column: sqlalchemy.Column,
|
||||
pk_column: sqlalchemy.Column,
|
||||
cutoff: datetime.datetime,
|
||||
batch_size: int,
|
||||
) -> int:
|
||||
deleted_total = 0
|
||||
|
||||
while True:
|
||||
select_result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.select(pk_column).where(ts_column < cutoff).limit(batch_size)
|
||||
)
|
||||
pk_values = list(select_result.scalars().all())
|
||||
if not pk_values:
|
||||
break
|
||||
|
||||
delete_result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.delete(model_cls).where(pk_column.in_(pk_values))
|
||||
)
|
||||
deleted = delete_result.rowcount or 0
|
||||
deleted_total += deleted
|
||||
|
||||
if len(pk_values) < batch_size:
|
||||
break
|
||||
|
||||
return deleted_total
|
||||
|
||||
async def _release_sqlite_space(self) -> None:
|
||||
database_type = self.ap.instance_config.data.get('database', {}).get('use', 'sqlite')
|
||||
if database_type != 'sqlite':
|
||||
return
|
||||
|
||||
async with self.ap.persistence_mgr.get_db_engine().connect() as conn:
|
||||
autocommit_conn = await conn.execution_options(isolation_level='AUTOCOMMIT')
|
||||
await autocommit_conn.execute(sqlalchemy.text('PRAGMA wal_checkpoint(TRUNCATE)'))
|
||||
await autocommit_conn.execute(sqlalchemy.text('VACUUM'))
|
||||
|
||||
# ========== Recording Methods ==========
|
||||
|
||||
async def record_message(
|
||||
|
||||
@@ -31,7 +31,6 @@ from ..api.http.service import mcp as mcp_service
|
||||
from ..api.http.service import apikey as apikey_service
|
||||
from ..api.http.service import webhook as webhook_service
|
||||
from ..api.http.service import monitoring as monitoring_service
|
||||
from ..api.http.service import maintenance as maintenance_service
|
||||
|
||||
from ..discover import engine as discover_engine
|
||||
from ..storage import mgr as storagemgr
|
||||
@@ -156,8 +155,6 @@ class Application:
|
||||
|
||||
monitoring_service: monitoring_service.MonitoringService = None
|
||||
|
||||
maintenance_service: maintenance_service.MaintenanceService = None
|
||||
|
||||
def __init__(self):
|
||||
pass
|
||||
|
||||
@@ -197,30 +194,14 @@ class Application:
|
||||
monitoring_cfg = self.instance_config.data.get('monitoring', {})
|
||||
auto_cleanup_cfg = monitoring_cfg.get('auto_cleanup', {})
|
||||
if auto_cleanup_cfg.get('enabled', True):
|
||||
retention_days = self._get_positive_int_config(
|
||||
auto_cleanup_cfg.get('retention_days', 30),
|
||||
default=30,
|
||||
name='monitoring.auto_cleanup.retention_days',
|
||||
)
|
||||
delete_batch_size = self._get_positive_int_config(
|
||||
auto_cleanup_cfg.get('delete_batch_size', 1000),
|
||||
default=1000,
|
||||
name='monitoring.auto_cleanup.delete_batch_size',
|
||||
)
|
||||
check_interval_hours = self._get_positive_float_config(
|
||||
auto_cleanup_cfg.get('check_interval_hours', 1),
|
||||
default=1,
|
||||
name='monitoring.auto_cleanup.check_interval_hours',
|
||||
)
|
||||
retention_days = auto_cleanup_cfg.get('retention_days', 30)
|
||||
check_interval_hours = auto_cleanup_cfg.get('check_interval_hours', 1)
|
||||
|
||||
async def monitoring_cleanup_loop():
|
||||
check_interval_seconds = check_interval_hours * 3600
|
||||
while True:
|
||||
try:
|
||||
deleted = await self.monitoring_service.cleanup_expired_records(
|
||||
retention_days,
|
||||
batch_size=delete_batch_size,
|
||||
)
|
||||
deleted = await self.monitoring_service.cleanup_expired_records(retention_days)
|
||||
total_deleted = sum(deleted.values())
|
||||
if total_deleted > 0:
|
||||
self.logger.info(
|
||||
@@ -237,33 +218,6 @@ class Application:
|
||||
scopes=[core_entities.LifecycleControlScope.APPLICATION],
|
||||
)
|
||||
|
||||
# Start storage/log maintenance task if enabled
|
||||
storage_cleanup_cfg = self.instance_config.data.get('storage', {}).get('cleanup', {})
|
||||
if storage_cleanup_cfg.get('enabled', True) and self.maintenance_service is not None:
|
||||
check_interval_hours = self._get_positive_float_config(
|
||||
storage_cleanup_cfg.get('check_interval_hours', 1),
|
||||
default=1,
|
||||
name='storage.cleanup.check_interval_hours',
|
||||
)
|
||||
|
||||
async def storage_cleanup_loop():
|
||||
check_interval_seconds = check_interval_hours * 3600
|
||||
while True:
|
||||
try:
|
||||
deleted = await self.maintenance_service.cleanup_expired_files()
|
||||
total_deleted = sum(deleted.values())
|
||||
if total_deleted > 0:
|
||||
self.logger.info(f'Storage maintenance: deleted expired files: {deleted}')
|
||||
except Exception as e:
|
||||
self.logger.warning(f'Storage maintenance error: {e}')
|
||||
await asyncio.sleep(check_interval_seconds)
|
||||
|
||||
self.task_mgr.create_task(
|
||||
storage_cleanup_loop(),
|
||||
name='storage-maintenance',
|
||||
scopes=[core_entities.LifecycleControlScope.APPLICATION],
|
||||
)
|
||||
|
||||
self.task_mgr.create_task(
|
||||
never_ending(),
|
||||
name='never-ending-task',
|
||||
@@ -278,28 +232,6 @@ class Application:
|
||||
self.logger.error(f'Application runtime fatal exception: {e}')
|
||||
self.logger.debug(f'Traceback: {traceback.format_exc()}')
|
||||
|
||||
def _get_positive_int_config(self, value, default: int, name: str) -> int:
|
||||
try:
|
||||
parsed = int(value)
|
||||
except (TypeError, ValueError):
|
||||
self.logger.warning(f'Invalid {name}: {value!r}, using {default}')
|
||||
return default
|
||||
if parsed < 1:
|
||||
self.logger.warning(f'Invalid {name}: {value!r}, using {default}')
|
||||
return default
|
||||
return parsed
|
||||
|
||||
def _get_positive_float_config(self, value, default: float, name: str) -> float:
|
||||
try:
|
||||
parsed = float(value)
|
||||
except (TypeError, ValueError):
|
||||
self.logger.warning(f'Invalid {name}: {value!r}, using {default}')
|
||||
return default
|
||||
if parsed <= 0:
|
||||
self.logger.warning(f'Invalid {name}: {value!r}, using {default}')
|
||||
return default
|
||||
return parsed
|
||||
|
||||
def dispose(self):
|
||||
self.plugin_connector.dispose()
|
||||
|
||||
|
||||
@@ -28,7 +28,6 @@ from ...api.http.service import mcp as mcp_service
|
||||
from ...api.http.service import apikey as apikey_service
|
||||
from ...api.http.service import webhook as webhook_service
|
||||
from ...api.http.service import monitoring as monitoring_service
|
||||
from ...api.http.service import maintenance as maintenance_service
|
||||
from ...discover import engine as discover_engine
|
||||
from ...storage import mgr as storagemgr
|
||||
from ...utils import logcache
|
||||
@@ -168,9 +167,6 @@ class BuildAppStage(stage.BootingStage):
|
||||
monitoring_service_inst = monitoring_service.MonitoringService(ap)
|
||||
ap.monitoring_service = monitoring_service_inst
|
||||
|
||||
maintenance_service_inst = maintenance_service.MaintenanceService(ap)
|
||||
ap.maintenance_service = maintenance_service_inst
|
||||
|
||||
async def runtime_disconnect_callback(connector: plugin_connector.PluginRuntimeConnector) -> None:
|
||||
await asyncio.sleep(3)
|
||||
await plugin_connector_inst.initialize()
|
||||
|
||||
@@ -3,7 +3,6 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import typing
|
||||
import datetime
|
||||
import time
|
||||
|
||||
from . import app
|
||||
from . import entities as core_entities
|
||||
@@ -120,7 +119,6 @@ class TaskWrapper:
|
||||
self.label = label if label != '' else name
|
||||
self.task.set_name(name)
|
||||
self.scopes = scopes
|
||||
self.created_at = time.time()
|
||||
|
||||
def assume_exception(self):
|
||||
try:
|
||||
@@ -156,7 +154,6 @@ class TaskWrapper:
|
||||
'name': self.name,
|
||||
'label': self.label,
|
||||
'scopes': [scope.value for scope in self.scopes],
|
||||
'created_at': self.created_at,
|
||||
'task_context': self.task_context.to_dict(),
|
||||
'runtime': {
|
||||
'done': self.task.done(),
|
||||
@@ -196,8 +193,6 @@ class AsyncTaskManager:
|
||||
) -> TaskWrapper:
|
||||
wrapper = TaskWrapper(self.ap, coro, task_type, kind, name, label, context, scopes)
|
||||
self.tasks.append(wrapper)
|
||||
wrapper.task.add_done_callback(lambda _: self._prune_completed_tasks())
|
||||
self._prune_completed_tasks()
|
||||
return wrapper
|
||||
|
||||
def create_user_task(
|
||||
@@ -231,15 +226,6 @@ class AsyncTaskManager:
|
||||
'id_index': TaskWrapper._id_index,
|
||||
}
|
||||
|
||||
def get_stats(self) -> dict:
|
||||
completed = sum(1 for t in self.tasks if t.task.done())
|
||||
return {
|
||||
'total': len(self.tasks),
|
||||
'running': len(self.tasks) - completed,
|
||||
'completed': completed,
|
||||
'id_index': TaskWrapper._id_index,
|
||||
}
|
||||
|
||||
def get_task_by_id(self, id: int) -> TaskWrapper | None:
|
||||
for t in self.tasks:
|
||||
if t.id == id:
|
||||
@@ -257,27 +243,3 @@ class AsyncTaskManager:
|
||||
if not wrapper.task.done():
|
||||
wrapper.task.cancel()
|
||||
return
|
||||
|
||||
def _prune_completed_tasks(self):
|
||||
completed_limit = (
|
||||
self.ap.instance_config.data.get('system', {})
|
||||
.get('task_retention', {})
|
||||
.get(
|
||||
'completed_limit',
|
||||
200,
|
||||
)
|
||||
)
|
||||
try:
|
||||
completed_limit = int(completed_limit)
|
||||
except (TypeError, ValueError):
|
||||
completed_limit = 200
|
||||
if completed_limit < 1:
|
||||
completed_limit = 1
|
||||
|
||||
completed_tasks = [wrapper for wrapper in self.tasks if wrapper.task.done()]
|
||||
overflow = len(completed_tasks) - completed_limit
|
||||
if overflow <= 0:
|
||||
return
|
||||
|
||||
remove_ids = {wrapper.id for wrapper in completed_tasks[:overflow]}
|
||||
self.tasks = [wrapper for wrapper in self.tasks if wrapper.id not in remove_ids]
|
||||
|
||||
@@ -75,27 +75,6 @@ class PreProcessor(stage.PipelineStage):
|
||||
query.bot_uuid,
|
||||
)
|
||||
|
||||
# Expire externally managed conversation ids after the conversation has
|
||||
# been idle for longer than the configured conversation expire time.
|
||||
# The idle window is measured from the last preprocess/update time, not
|
||||
# from the conversation creation time.
|
||||
conversation_expire_time = query.pipeline_config.get('ai', {}).get('runner', {}).get('expire-time', None)
|
||||
now = datetime.datetime.now()
|
||||
if conversation_expire_time is not None and conversation_expire_time > 0:
|
||||
last_update_time = getattr(conversation, 'update_time', None) or getattr(conversation, 'create_time', None)
|
||||
if last_update_time is not None:
|
||||
conversation_idle_time = now.timestamp() - last_update_time.timestamp()
|
||||
if conversation_idle_time > conversation_expire_time:
|
||||
self.ap.logger.info(
|
||||
f'Conversation({query.query_id}) is expired (idle: {conversation_idle_time}s), create new conversation'
|
||||
)
|
||||
conversation.uuid = None
|
||||
|
||||
# Treat every preprocess pass as a conversation activity update. This
|
||||
# makes future expiry checks use the latest incoming message/preprocess
|
||||
# time instead of the first message/creation time.
|
||||
conversation.update_time = now
|
||||
|
||||
# 设置query
|
||||
query.session = session
|
||||
query.prompt = conversation.prompt.copy()
|
||||
@@ -181,10 +160,7 @@ class PreProcessor(stage.PipelineStage):
|
||||
elif me.url:
|
||||
content_list.append(provider_message.ContentElement.from_file_url(me.url, 'voice'))
|
||||
elif isinstance(me, platform_message.File):
|
||||
if me.base64:
|
||||
content_list.append(provider_message.ContentElement.from_file_base64(me.base64, me.name))
|
||||
elif me.url:
|
||||
content_list.append(provider_message.ContentElement.from_file_url(me.url, me.name))
|
||||
content_list.append(provider_message.ContentElement.from_file_url(me.url, me.name))
|
||||
elif isinstance(me, platform_message.Quote) and quote_msg:
|
||||
for msg in me.origin:
|
||||
if isinstance(msg, platform_message.Plain):
|
||||
@@ -196,10 +172,7 @@ class PreProcessor(stage.PipelineStage):
|
||||
if msg.base64 is not None:
|
||||
content_list.append(provider_message.ContentElement.from_image_base64(msg.base64))
|
||||
elif isinstance(msg, platform_message.File):
|
||||
if msg.base64:
|
||||
content_list.append(provider_message.ContentElement.from_file_base64(msg.base64, msg.name))
|
||||
elif msg.url:
|
||||
content_list.append(provider_message.ContentElement.from_file_url(msg.url, msg.name))
|
||||
content_list.append(provider_message.ContentElement.from_file_url(msg.url, msg.name))
|
||||
elif isinstance(msg, platform_message.Voice):
|
||||
if msg.base64:
|
||||
content_list.append(
|
||||
|
||||
@@ -523,7 +523,7 @@ class PlatformManager:
|
||||
return None
|
||||
|
||||
async def remove_bot(self, bot_uuid: str):
|
||||
for bot in self.bots[:]:
|
||||
for bot in self.bots:
|
||||
if bot.bot_entity.uuid == bot_uuid:
|
||||
if bot.enable:
|
||||
await bot.shutdown()
|
||||
|
||||
@@ -434,43 +434,6 @@ class LarkMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
|
||||
'duration': message_content.get('duration', 0),
|
||||
}
|
||||
]
|
||||
elif message.message_type == 'interactive':
|
||||
# Card messages have a different structure:
|
||||
# {"title": "...", "elements": [[{tag, ...}, ...], ...]}
|
||||
# Each top-level array in "elements" is a group of elements on the same row.
|
||||
card_text_parts = []
|
||||
|
||||
title = message_content.get('title', '')
|
||||
if title:
|
||||
card_text_parts.append(title)
|
||||
|
||||
for group in message_content.get('elements', []):
|
||||
if not isinstance(group, list):
|
||||
group = [group]
|
||||
for element in group:
|
||||
if not isinstance(element, dict):
|
||||
continue
|
||||
tag = element.get('tag', '')
|
||||
if tag == 'text':
|
||||
t = element.get('text', '')
|
||||
if t:
|
||||
card_text_parts.append(t)
|
||||
elif tag == 'a':
|
||||
t = element.get('text', '')
|
||||
href = element.get('href', '')
|
||||
if t:
|
||||
card_text_parts.append(f'[{t}]({href})' if href else t)
|
||||
elif tag == 'at':
|
||||
pass # skip @mentions in card content
|
||||
elif tag == 'markdown':
|
||||
t = element.get('content', '')
|
||||
if t:
|
||||
card_text_parts.append(t)
|
||||
|
||||
if not card_text_parts:
|
||||
card_text_parts.append('[Card Message]')
|
||||
|
||||
message_content['content'] = [{'tag': 'text', 'text': '\n'.join(card_text_parts), 'style': []}]
|
||||
|
||||
for ele in message_content['content']:
|
||||
if ele['tag'] == 'text':
|
||||
@@ -909,17 +872,10 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
|
||||
return P2CardActionTriggerResponse({'toast': {'type': 'error', 'content': '反馈处理失败'}})
|
||||
|
||||
def sync_on_bot_p2p_chat_entered(event):
|
||||
# No-op: this event fires when a user opens a P2P chat with the bot.
|
||||
# LangBot does not need to process it; the handler is registered to
|
||||
# suppress the "processor not found" error from the Lark SDK.
|
||||
pass
|
||||
|
||||
event_handler = (
|
||||
lark_oapi.EventDispatcherHandler.builder('', '')
|
||||
.register_p2_im_message_receive_v1(sync_on_message)
|
||||
.register_p2_card_action_trigger(sync_on_card_action)
|
||||
.register_p2_im_chat_access_event_bot_p2p_chat_entered_v1(sync_on_bot_p2p_chat_entered)
|
||||
.build()
|
||||
)
|
||||
|
||||
@@ -1678,9 +1634,6 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
await self.logger.error(f'Error in lark card action callback: {traceback.format_exc()}')
|
||||
return {'toast': {'type': 'error', 'content': '反馈处理失败'}}
|
||||
|
||||
elif 'im.chat.access_event.bot_p2p_chat_entered_v1' == type:
|
||||
# No-op: this event fires when a user opens a P2P chat with the bot.
|
||||
pass
|
||||
elif 'im.chat.member.bot.added_v1' == type:
|
||||
try:
|
||||
bot_added_welcome_msg = self.config.get('bot_added_welcome', '')
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 3.4 KiB |
@@ -1,693 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import typing
|
||||
import asyncio
|
||||
import traceback
|
||||
import base64
|
||||
import json
|
||||
|
||||
import nio
|
||||
|
||||
from langbot.pkg.utils import httpclient
|
||||
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
|
||||
import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
||||
import langbot_plugin.api.entities.builtin.platform.events as platform_events
|
||||
import langbot_plugin.api.entities.builtin.platform.entities as platform_entities
|
||||
import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_platform_logger
|
||||
|
||||
|
||||
class MatrixMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
|
||||
@staticmethod
|
||||
async def yiri2target(message_chain: platform_message.MessageChain, client: nio.AsyncClient) -> list[dict]:
|
||||
components = []
|
||||
for component in message_chain:
|
||||
if isinstance(component, platform_message.Plain):
|
||||
components.append({'type': 'text', 'text': component.text})
|
||||
elif isinstance(component, platform_message.Image):
|
||||
image_bytes = None
|
||||
if component.base64:
|
||||
b64_data = component.base64
|
||||
if ';base64,' in b64_data:
|
||||
b64_data = b64_data.split(';base64,', 1)[1]
|
||||
image_bytes = base64.b64decode(b64_data)
|
||||
elif component.url:
|
||||
session = httpclient.get_session()
|
||||
async with session.get(component.url) as response:
|
||||
image_bytes = await response.read()
|
||||
elif component.path:
|
||||
with open(component.path, 'rb') as f:
|
||||
image_bytes = f.read()
|
||||
if image_bytes:
|
||||
resp = await client.upload(image_bytes, content_type='image/png')
|
||||
if isinstance(resp, nio.UploadResponse):
|
||||
components.append({'type': 'image', 'mxc_url': resp.content_uri})
|
||||
elif isinstance(component, platform_message.File):
|
||||
file_bytes = None
|
||||
if component.base64:
|
||||
b64_data = component.base64
|
||||
if ';base64,' in b64_data:
|
||||
b64_data = b64_data.split(';base64,', 1)[1]
|
||||
file_bytes = base64.b64decode(b64_data)
|
||||
elif component.url:
|
||||
session = httpclient.get_session()
|
||||
async with session.get(component.url) as response:
|
||||
file_bytes = await response.read()
|
||||
elif component.path:
|
||||
with open(component.path, 'rb') as f:
|
||||
file_bytes = f.read()
|
||||
if file_bytes:
|
||||
file_name = getattr(component, 'name', None) or 'file'
|
||||
resp = await client.upload(file_bytes, content_type='application/octet-stream', filename=file_name)
|
||||
if isinstance(resp, nio.UploadResponse):
|
||||
components.append(
|
||||
{
|
||||
'type': 'file',
|
||||
'mxc_url': resp.content_uri,
|
||||
'filename': file_name,
|
||||
'size': len(file_bytes),
|
||||
}
|
||||
)
|
||||
elif isinstance(component, platform_message.Forward):
|
||||
for node in component.node_list:
|
||||
components.extend(await MatrixMessageConverter.yiri2target(node.message_chain, client))
|
||||
return components
|
||||
|
||||
@staticmethod
|
||||
async def target2yiri(event: nio.RoomMessageText | nio.RoomMessageImage, client: nio.AsyncClient, bot_user_id: str):
|
||||
message_components = []
|
||||
|
||||
if isinstance(event, nio.RoomMessageText):
|
||||
text = event.body
|
||||
if bot_user_id and bot_user_id in text:
|
||||
message_components.append(platform_message.At(target=bot_user_id))
|
||||
text = text.replace(bot_user_id, '').strip()
|
||||
message_components.append(platform_message.Plain(text=text))
|
||||
|
||||
elif isinstance(event, nio.RoomMessageImage):
|
||||
mxc_url = event.url
|
||||
if mxc_url:
|
||||
resp = await client.download(mxc_url)
|
||||
if isinstance(resp, nio.DownloadResponse):
|
||||
b64 = base64.b64encode(resp.body).decode('utf-8')
|
||||
content_type = resp.content_type or 'image/png'
|
||||
message_components.append(platform_message.Image(base64=f'data:{content_type};base64,{b64}'))
|
||||
if event.body:
|
||||
message_components.append(platform_message.Plain(text=event.body))
|
||||
|
||||
return platform_message.MessageChain(message_components)
|
||||
|
||||
|
||||
class MatrixEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
||||
@staticmethod
|
||||
async def yiri2target(event: platform_events.MessageEvent):
|
||||
return event.source_platform_object
|
||||
|
||||
@staticmethod
|
||||
async def target2yiri(
|
||||
event: nio.RoomMessageText | nio.RoomMessageImage,
|
||||
room: nio.MatrixRoom,
|
||||
client: nio.AsyncClient,
|
||||
bot_user_id: str,
|
||||
bridge_user_ids: list[str] | None = None,
|
||||
):
|
||||
lb_message = await MatrixMessageConverter.target2yiri(event, client, bot_user_id)
|
||||
|
||||
# Determine if this is a direct/private chat or a group chat.
|
||||
# Exclude bot itself and bridge bots, count remaining real users.
|
||||
exclude_ids = {bot_user_id}
|
||||
if bridge_user_ids:
|
||||
exclude_ids.update(bridge_user_ids)
|
||||
real_users = [uid for uid in room.users if uid not in exclude_ids]
|
||||
is_direct = len(real_users) <= 1
|
||||
|
||||
if is_direct:
|
||||
return platform_events.FriendMessage(
|
||||
sender=platform_entities.Friend(
|
||||
id=event.sender,
|
||||
nickname=room.user_name(event.sender) or event.sender,
|
||||
remark='',
|
||||
),
|
||||
message_chain=lb_message,
|
||||
time=event.server_timestamp / 1000.0,
|
||||
source_platform_object={'event': event, 'room': room},
|
||||
)
|
||||
else:
|
||||
return platform_events.GroupMessage(
|
||||
sender=platform_entities.GroupMember(
|
||||
id=event.sender,
|
||||
member_name=room.user_name(event.sender) or event.sender,
|
||||
permission=platform_entities.Permission.Member,
|
||||
group=platform_entities.Group(
|
||||
id=room.room_id,
|
||||
name=room.display_name or room.room_id,
|
||||
permission=platform_entities.Permission.Member,
|
||||
),
|
||||
special_title='',
|
||||
),
|
||||
message_chain=lb_message,
|
||||
time=event.server_timestamp / 1000.0,
|
||||
source_platform_object={'event': event, 'room': room},
|
||||
)
|
||||
|
||||
|
||||
class BridgeState:
|
||||
"""Per-bridge runtime state."""
|
||||
|
||||
def __init__(self, user_id: str, login_command: str, logout_command: str, success_keyword: str, check_command: str):
|
||||
self.user_id = user_id
|
||||
self.login_command = login_command
|
||||
self.logout_command = logout_command
|
||||
self.success_keyword = success_keyword
|
||||
self.check_command = check_command or login_command
|
||||
self.logged_in = False
|
||||
self.dm_room_id: str | None = None
|
||||
self.login_task: asyncio.Task | None = None
|
||||
self.check_task: asyncio.Task | None = None
|
||||
self.check_responded = False
|
||||
|
||||
|
||||
class MatrixAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
client: typing.Any = None
|
||||
message_converter: MatrixMessageConverter = MatrixMessageConverter()
|
||||
event_converter: MatrixEventConverter = MatrixEventConverter()
|
||||
config: dict
|
||||
listeners: typing.Dict[typing.Type[platform_events.Event], typing.Callable] = {}
|
||||
_running: bool = False
|
||||
_initial_sync_done: bool = False
|
||||
_bridges: list = []
|
||||
|
||||
def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger):
|
||||
homeserver_url = config.get('homeserver_url', '')
|
||||
access_token = config.get('access_token', '')
|
||||
user_id = config.get('user_id', '')
|
||||
|
||||
if not homeserver_url or not access_token or not user_id:
|
||||
raise ValueError('Matrix 机器人缺少必要配置项 (homeserver_url, user_id, access_token)')
|
||||
|
||||
client = nio.AsyncClient(homeserver_url, user_id)
|
||||
client.access_token = access_token
|
||||
client.user_id = user_id
|
||||
|
||||
super().__init__(
|
||||
config=config,
|
||||
logger=logger,
|
||||
bot_account_id=user_id,
|
||||
client=client,
|
||||
listeners={},
|
||||
)
|
||||
|
||||
# Parse bridges config AFTER super().__init__() to avoid Pydantic resetting _bridges
|
||||
self._bridges = []
|
||||
bridges_raw = config.get('bridges', '')
|
||||
if bridges_raw:
|
||||
if isinstance(bridges_raw, str):
|
||||
try:
|
||||
bridges_list = json.loads(bridges_raw)
|
||||
except (json.JSONDecodeError, TypeError) as e:
|
||||
raise ValueError(f'bridges 配置 JSON 解析失败: {e}\n原始值: {bridges_raw}')
|
||||
else:
|
||||
bridges_list = bridges_raw
|
||||
for b in bridges_list:
|
||||
if isinstance(b, dict) and b.get('user_id', '').strip():
|
||||
self._bridges.append(
|
||||
BridgeState(
|
||||
user_id=b['user_id'].strip(),
|
||||
login_command=b.get('login_command', '').strip(),
|
||||
logout_command=b.get('logout_command', '').strip(),
|
||||
success_keyword=b.get('success_keyword', 'Successfully logged in').strip(),
|
||||
check_command=b.get('check_command', '').strip(),
|
||||
)
|
||||
)
|
||||
# Backward compatibility: old single-bridge config
|
||||
if not self._bridges:
|
||||
old_user_id = config.get('bridge_user_id', '').strip()
|
||||
old_command = config.get('bridge_login_command', '').strip()
|
||||
old_keyword = config.get('bridge_login_success_keyword', 'Successfully logged in').strip()
|
||||
old_check = config.get('bridge_check_command', '').strip()
|
||||
old_logout = config.get('bridge_logout_command', '').strip()
|
||||
if old_user_id:
|
||||
self._bridges.append(
|
||||
BridgeState(
|
||||
user_id=old_user_id,
|
||||
login_command=old_command,
|
||||
logout_command=old_logout,
|
||||
success_keyword=old_keyword,
|
||||
check_command=old_check,
|
||||
)
|
||||
)
|
||||
|
||||
async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain):
|
||||
components = await self.message_converter.yiri2target(message, self.client)
|
||||
for component in components:
|
||||
await self._send_component(target_id, component)
|
||||
|
||||
async def reply_message(
|
||||
self,
|
||||
message_source: platform_events.MessageEvent,
|
||||
message: platform_message.MessageChain,
|
||||
quote_origin: bool = False,
|
||||
):
|
||||
source_obj = message_source.source_platform_object
|
||||
room_id = source_obj['room'].room_id
|
||||
components = await self.message_converter.yiri2target(message, self.client)
|
||||
|
||||
for component in components:
|
||||
if quote_origin:
|
||||
original_event = source_obj['event']
|
||||
await self._send_component(room_id, component, reply_to=original_event.event_id)
|
||||
else:
|
||||
await self._send_component(room_id, component)
|
||||
|
||||
async def _send_component(self, room_id: str, component: dict, reply_to: str | None = None):
|
||||
content = {}
|
||||
if component['type'] == 'text':
|
||||
content = {
|
||||
'msgtype': 'm.text',
|
||||
'body': component['text'],
|
||||
}
|
||||
elif component['type'] == 'image':
|
||||
content = {
|
||||
'msgtype': 'm.image',
|
||||
'body': 'image.png',
|
||||
'url': component['mxc_url'],
|
||||
}
|
||||
elif component['type'] == 'file':
|
||||
content = {
|
||||
'msgtype': 'm.file',
|
||||
'body': component.get('filename', 'file'),
|
||||
'url': component['mxc_url'],
|
||||
'info': {'size': component.get('size', 0)},
|
||||
}
|
||||
|
||||
if reply_to and content:
|
||||
content['m.relates_to'] = {
|
||||
'm.in_reply_to': {'event_id': reply_to},
|
||||
}
|
||||
|
||||
if content:
|
||||
await self.client.room_send(
|
||||
room_id=room_id,
|
||||
message_type='m.room.message',
|
||||
content=content,
|
||||
)
|
||||
|
||||
def register_listener(
|
||||
self,
|
||||
event_type: typing.Type[platform_events.Event],
|
||||
callback: typing.Callable[
|
||||
[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None
|
||||
],
|
||||
):
|
||||
self.listeners[event_type] = callback
|
||||
|
||||
async def run_async(self):
|
||||
self._running = True
|
||||
await self.logger.info('Matrix adapter starting...')
|
||||
|
||||
# Debug: log bridge parsing result
|
||||
bridges_raw = self.config.get('bridges', '')
|
||||
await self.logger.debug(f'bridges config raw: type={type(bridges_raw).__name__}, repr={repr(bridges_raw)}')
|
||||
await self.logger.debug(
|
||||
f'parsed _bridges count: {len(self._bridges)}, ids: {[b.user_id for b in self._bridges]}'
|
||||
)
|
||||
|
||||
# Collect all bridge bot user IDs for filtering
|
||||
_bridge_user_ids = [b.user_id for b in self._bridges]
|
||||
_bridge_user_id_set = set(_bridge_user_ids)
|
||||
|
||||
# Auto-join invited rooms
|
||||
async def on_invite(room: nio.MatrixRoom, event: nio.InviteMemberEvent):
|
||||
if event.membership == 'invite' and event.state_key == self.client.user_id:
|
||||
await self.client.join(room.room_id)
|
||||
await self.logger.debug(f'Auto-joined room: {room.display_name or room.room_id}')
|
||||
|
||||
self.client.add_event_callback(on_invite, nio.InviteMemberEvent)
|
||||
|
||||
# Handle text messages
|
||||
async def on_message(room: nio.MatrixRoom, event: nio.RoomMessageText):
|
||||
if not self._initial_sync_done:
|
||||
return
|
||||
if event.sender == self.client.user_id:
|
||||
return
|
||||
|
||||
# Admin commands (from any non-bridge user)
|
||||
if event.sender not in _bridge_user_id_set:
|
||||
body = (event.body or '').strip()
|
||||
if body == '!relogin':
|
||||
await self._handle_relogin_command(room.room_id)
|
||||
return
|
||||
if body == '!status':
|
||||
await self._handle_status_command(room.room_id)
|
||||
return
|
||||
|
||||
if event.sender in _bridge_user_id_set:
|
||||
return
|
||||
try:
|
||||
lb_event = await self.event_converter.target2yiri(
|
||||
event, room, self.client, self.bot_account_id, _bridge_user_ids
|
||||
)
|
||||
if type(lb_event) in self.listeners:
|
||||
result = self.listeners[type(lb_event)](lb_event, self)
|
||||
if asyncio.iscoroutine(result):
|
||||
await result
|
||||
except Exception:
|
||||
await self.logger.error(f'Error handling Matrix message: {traceback.format_exc()}')
|
||||
|
||||
self.client.add_event_callback(on_message, nio.RoomMessageText)
|
||||
|
||||
# Handle image messages
|
||||
async def on_image(room: nio.MatrixRoom, event: nio.RoomMessageImage):
|
||||
if not self._initial_sync_done:
|
||||
return
|
||||
if event.sender == self.client.user_id:
|
||||
return
|
||||
if event.sender in _bridge_user_id_set:
|
||||
return
|
||||
try:
|
||||
lb_event = await self.event_converter.target2yiri(
|
||||
event, room, self.client, self.bot_account_id, _bridge_user_ids
|
||||
)
|
||||
if type(lb_event) in self.listeners:
|
||||
result = self.listeners[type(lb_event)](lb_event, self)
|
||||
if asyncio.iscoroutine(result):
|
||||
await result
|
||||
except Exception:
|
||||
await self.logger.error(f'Error handling Matrix image: {traceback.format_exc()}')
|
||||
|
||||
self.client.add_event_callback(on_image, nio.RoomMessageImage)
|
||||
|
||||
# Set up bridge-specific callbacks for each bridge
|
||||
_disconnect_keywords = ['disconnected', 'logged out', 'connection lost', 'session expired', 'token expired']
|
||||
|
||||
for bridge in self._bridges:
|
||||
# Login success detection (notice)
|
||||
async def on_bridge_notice(room: nio.MatrixRoom, event: nio.RoomMessageNotice, _b=bridge):
|
||||
if not self._initial_sync_done:
|
||||
return
|
||||
if event.sender != _b.user_id:
|
||||
return
|
||||
_b.check_responded = True
|
||||
if _b.success_keyword in (event.body or ''):
|
||||
_b.logged_in = True
|
||||
await self.logger.info(f'[{_b.user_id}] Bridge login succeeded.')
|
||||
# Disconnect detection
|
||||
body_lower = (event.body or '').lower()
|
||||
for kw in _disconnect_keywords:
|
||||
if kw in body_lower and _b.logged_in:
|
||||
_b.logged_in = False
|
||||
await self.logger.info(f'[{_b.user_id}] Bridge 账号掉线 (检测到: "{kw}"), 将自动重新登录...')
|
||||
self._restart_bridge_login(_b)
|
||||
break
|
||||
|
||||
self.client.add_event_callback(on_bridge_notice, nio.RoomMessageNotice)
|
||||
|
||||
# Login success + disconnect detection (text)
|
||||
async def on_bridge_text(room: nio.MatrixRoom, event: nio.RoomMessageText, _b=bridge):
|
||||
if not self._initial_sync_done:
|
||||
return
|
||||
if event.sender != _b.user_id:
|
||||
return
|
||||
_b.check_responded = True
|
||||
if _b.success_keyword in (event.body or ''):
|
||||
_b.logged_in = True
|
||||
await self.logger.info(f'[{_b.user_id}] Bridge login succeeded.')
|
||||
body_lower = (event.body or '').lower()
|
||||
for kw in _disconnect_keywords:
|
||||
if kw in body_lower and _b.logged_in:
|
||||
_b.logged_in = False
|
||||
await self.logger.info(f'[{_b.user_id}] Bridge 账号掉线 (检测到: "{kw}"), 将自动重新登录...')
|
||||
self._restart_bridge_login(_b)
|
||||
break
|
||||
|
||||
self.client.add_event_callback(on_bridge_text, nio.RoomMessageText)
|
||||
|
||||
# QR code image forwarding
|
||||
async def on_bridge_image(room: nio.MatrixRoom, event: nio.RoomMessageImage, _b=bridge):
|
||||
if not self._initial_sync_done:
|
||||
return
|
||||
if event.sender != _b.user_id:
|
||||
return
|
||||
mxc_url = event.url
|
||||
if not mxc_url:
|
||||
return
|
||||
try:
|
||||
resp = await self.client.download(mxc_url)
|
||||
if isinstance(resp, nio.DownloadResponse):
|
||||
b64 = base64.b64encode(resp.body).decode('utf-8')
|
||||
content_type = resp.content_type or 'image/png'
|
||||
await self.logger.info(
|
||||
f'[{_b.user_id}] Bridge 发送了二维码,请扫码登录:',
|
||||
images=[platform_message.Image(base64=f'data:{content_type};base64,{b64}')],
|
||||
)
|
||||
except Exception:
|
||||
await self.logger.error(
|
||||
f'[{_b.user_id}] Failed to download bridge QR image: {traceback.format_exc()}'
|
||||
)
|
||||
|
||||
self.client.add_event_callback(on_bridge_image, nio.RoomMessageImage)
|
||||
|
||||
await self.logger.debug('Matrix adapter running, starting sync...')
|
||||
|
||||
# Initial sync to skip old messages
|
||||
resp = await self.client.sync(timeout=10000)
|
||||
if isinstance(resp, nio.SyncResponse):
|
||||
await self.logger.debug(f'Matrix initial sync done, next_batch: {resp.next_batch}')
|
||||
self._initial_sync_done = True
|
||||
|
||||
# Display account info
|
||||
display_name = self.client.user_id
|
||||
try:
|
||||
profile_resp = await self.client.get_displayname(self.client.user_id)
|
||||
if isinstance(profile_resp, nio.ProfileGetDisplayNameResponse) and profile_resp.displayname:
|
||||
display_name = profile_resp.displayname
|
||||
except Exception:
|
||||
pass
|
||||
joined_rooms = len(self.client.rooms)
|
||||
homeserver = self.config.get('homeserver_url', '')
|
||||
bridge_info = ''
|
||||
if self._bridges:
|
||||
bridge_names = ', '.join(b.user_id for b in self._bridges)
|
||||
bridge_info = f' | 桥接: [{bridge_names}]'
|
||||
await self.logger.info(
|
||||
f'Matrix 账号: {display_name} ({self.client.user_id}) | '
|
||||
f'服务器: {homeserver} | 已加入 {joined_rooms} 个房间{bridge_info}'
|
||||
)
|
||||
|
||||
# Start bridge login and status check tasks for each bridge
|
||||
for bridge in self._bridges:
|
||||
if bridge.login_command:
|
||||
await self.logger.info(
|
||||
f'[{bridge.user_id}] Bridge login enabled (命令: "{bridge.login_command}", '
|
||||
f'关键词: "{bridge.success_keyword}")'
|
||||
)
|
||||
bridge.login_task = asyncio.create_task(self._periodic_bridge_login(bridge))
|
||||
bridge.check_task = asyncio.create_task(self._periodic_bridge_check(bridge))
|
||||
else:
|
||||
await self.logger.debug(f'[{bridge.user_id}] Bridge login not configured (no login_command)')
|
||||
|
||||
# Main sync loop
|
||||
while self._running:
|
||||
try:
|
||||
await self.client.sync(timeout=30000)
|
||||
except Exception:
|
||||
await self.logger.error(f'Matrix sync error: {traceback.format_exc()}')
|
||||
await asyncio.sleep(5)
|
||||
|
||||
async def _periodic_bridge_login(self, bridge: BridgeState):
|
||||
"""Periodically send login command to a bridge bot until login succeeds."""
|
||||
try:
|
||||
await self.logger.info(f'[{bridge.user_id}] Bridge login task started, looking for DM room...')
|
||||
dm_room_id = None
|
||||
for room_id, room in self.client.rooms.items():
|
||||
if room.member_count == 2 and bridge.user_id in [m for m in room.users]:
|
||||
dm_room_id = room_id
|
||||
break
|
||||
|
||||
if not dm_room_id:
|
||||
resp = await self.client.room_create(
|
||||
is_direct=True,
|
||||
invite=[bridge.user_id],
|
||||
)
|
||||
if isinstance(resp, nio.RoomCreateResponse):
|
||||
dm_room_id = resp.room_id
|
||||
await self.logger.debug(f'[{bridge.user_id}] Created DM room: {dm_room_id}')
|
||||
else:
|
||||
await self.logger.error(f'[{bridge.user_id}] Failed to create DM room: {resp}')
|
||||
return
|
||||
|
||||
bridge.dm_room_id = dm_room_id
|
||||
|
||||
# Force logout first on every adapter start
|
||||
logout_cmd = bridge.logout_command or bridge.login_command.replace('login', 'logout')
|
||||
await self.logger.info(f'[{bridge.user_id}] 强制登出: "{logout_cmd}"')
|
||||
await self.client.room_send(
|
||||
room_id=dm_room_id,
|
||||
message_type='m.room.message',
|
||||
content={'msgtype': 'm.text', 'body': logout_cmd},
|
||||
)
|
||||
await asyncio.sleep(3)
|
||||
|
||||
while self._running and not bridge.logged_in:
|
||||
await self.logger.debug(f'[{bridge.user_id}] Sending "{bridge.login_command}" in room {dm_room_id}')
|
||||
await self.client.room_send(
|
||||
room_id=dm_room_id,
|
||||
message_type='m.room.message',
|
||||
content={'msgtype': 'm.text', 'body': bridge.login_command},
|
||||
)
|
||||
for _ in range(60):
|
||||
if not self._running or bridge.logged_in:
|
||||
break
|
||||
await asyncio.sleep(1)
|
||||
|
||||
if bridge.logged_in:
|
||||
await self.logger.debug(f'[{bridge.user_id}] Bridge login confirmed, periodic login stopped.')
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
except Exception:
|
||||
await self.logger.error(f'[{bridge.user_id}] Bridge periodic login error: {traceback.format_exc()}')
|
||||
|
||||
def _restart_bridge_login(self, bridge: BridgeState):
|
||||
"""Cancel existing login task and start a new one."""
|
||||
if bridge.login_task and not bridge.login_task.done():
|
||||
bridge.login_task.cancel()
|
||||
bridge.login_task = asyncio.create_task(self._periodic_bridge_login(bridge))
|
||||
|
||||
async def _periodic_bridge_check(self, bridge: BridgeState):
|
||||
"""Periodically check a bridge's login status."""
|
||||
try:
|
||||
while self._running and not bridge.logged_in:
|
||||
await asyncio.sleep(5)
|
||||
|
||||
check_interval = 300 # 5 minutes
|
||||
response_timeout = 30
|
||||
await self.logger.debug(f'[{bridge.user_id}] Bridge status check started (interval: {check_interval}s)')
|
||||
|
||||
while self._running:
|
||||
for _ in range(check_interval):
|
||||
if not self._running:
|
||||
return
|
||||
await asyncio.sleep(1)
|
||||
|
||||
if not bridge.logged_in or not bridge.dm_room_id:
|
||||
continue
|
||||
|
||||
try:
|
||||
bridge.check_responded = False
|
||||
await self.client.room_send(
|
||||
room_id=bridge.dm_room_id,
|
||||
message_type='m.room.message',
|
||||
content={'msgtype': 'm.text', 'body': bridge.check_command},
|
||||
)
|
||||
await self.logger.debug(f'[{bridge.user_id}] Bridge status check: sent "{bridge.check_command}"')
|
||||
|
||||
for _ in range(response_timeout):
|
||||
if bridge.check_responded or not self._running:
|
||||
break
|
||||
await asyncio.sleep(1)
|
||||
|
||||
if bridge.check_responded:
|
||||
await self.logger.debug(f'[{bridge.user_id}] Bridge status check: OK')
|
||||
else:
|
||||
await self.logger.info(
|
||||
f'[{bridge.user_id}] Bridge status check: 无响应, 可能已掉线, 尝试重新登录...'
|
||||
)
|
||||
bridge.logged_in = False
|
||||
self._restart_bridge_login(bridge)
|
||||
except Exception:
|
||||
await self.logger.error(f'[{bridge.user_id}] Bridge status check error: {traceback.format_exc()}')
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
except Exception:
|
||||
await self.logger.error(f'[{bridge.user_id}] Bridge status check fatal error: {traceback.format_exc()}')
|
||||
|
||||
async def _handle_relogin_command(self, room_id: str):
|
||||
"""Handle !relogin command: logout then re-login all bridges."""
|
||||
if not self._bridges:
|
||||
await self.client.room_send(
|
||||
room_id=room_id,
|
||||
message_type='m.room.message',
|
||||
content={'msgtype': 'm.text', 'body': '没有配置任何桥。'},
|
||||
)
|
||||
return
|
||||
|
||||
lines = ['开始重新登录所有桥...']
|
||||
for bridge in self._bridges:
|
||||
if not bridge.login_command or not bridge.dm_room_id:
|
||||
lines.append(f'[{bridge.user_id}] 跳过(未配置登录命令或无DM房间)')
|
||||
continue
|
||||
|
||||
# Use configured logout command, fallback to deriving from login command
|
||||
logout_cmd = bridge.logout_command or bridge.login_command.replace('login', 'logout')
|
||||
lines.append(f'[{bridge.user_id}] 发送 "{logout_cmd}"...')
|
||||
|
||||
# Cancel existing tasks
|
||||
if bridge.login_task and not bridge.login_task.done():
|
||||
bridge.login_task.cancel()
|
||||
if bridge.check_task and not bridge.check_task.done():
|
||||
bridge.check_task.cancel()
|
||||
|
||||
# Send logout
|
||||
try:
|
||||
await self.client.room_send(
|
||||
room_id=bridge.dm_room_id,
|
||||
message_type='m.room.message',
|
||||
content={'msgtype': 'm.text', 'body': logout_cmd},
|
||||
)
|
||||
except Exception as e:
|
||||
lines.append(f'[{bridge.user_id}] logout 发送失败: {e}')
|
||||
|
||||
await asyncio.sleep(2)
|
||||
|
||||
# Reset state and restart login
|
||||
bridge.logged_in = False
|
||||
self._restart_bridge_login(bridge)
|
||||
lines.append(f'[{bridge.user_id}] 已触发重新登录')
|
||||
|
||||
await self.client.room_send(
|
||||
room_id=room_id,
|
||||
message_type='m.room.message',
|
||||
content={'msgtype': 'm.text', 'body': '\n'.join(lines)},
|
||||
)
|
||||
|
||||
async def _handle_status_command(self, room_id: str):
|
||||
"""Handle !status command: show bridge states."""
|
||||
if not self._bridges:
|
||||
await self.client.room_send(
|
||||
room_id=room_id,
|
||||
message_type='m.room.message',
|
||||
content={'msgtype': 'm.text', 'body': '没有配置任何桥。'},
|
||||
)
|
||||
return
|
||||
|
||||
lines = ['桥状态:']
|
||||
for bridge in self._bridges:
|
||||
status = '已登录 ✓' if bridge.logged_in else '未登录 ✗'
|
||||
dm = bridge.dm_room_id or '无'
|
||||
lines.append(f'• {bridge.user_id}: {status} (DM: {dm})')
|
||||
await self.client.room_send(
|
||||
room_id=room_id,
|
||||
message_type='m.room.message',
|
||||
content={'msgtype': 'm.text', 'body': '\n'.join(lines)},
|
||||
)
|
||||
|
||||
async def kill(self) -> bool:
|
||||
self._running = False
|
||||
for bridge in self._bridges:
|
||||
if bridge.login_task and not bridge.login_task.done():
|
||||
bridge.login_task.cancel()
|
||||
if bridge.check_task and not bridge.check_task.done():
|
||||
bridge.check_task.cancel()
|
||||
if self.client:
|
||||
await self.client.close()
|
||||
await self.logger.debug('Matrix adapter stopped')
|
||||
return True
|
||||
|
||||
async def unregister_listener(
|
||||
self,
|
||||
event_type: typing.Type[platform_events.Event],
|
||||
callback: typing.Callable[
|
||||
[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None
|
||||
],
|
||||
):
|
||||
if event_type in self.listeners:
|
||||
del self.listeners[event_type]
|
||||
@@ -1,123 +0,0 @@
|
||||
apiVersion: v1
|
||||
kind: MessagePlatformAdapter
|
||||
metadata:
|
||||
name: matrix
|
||||
label:
|
||||
en_US: Matrix
|
||||
zh_Hans: Matrix
|
||||
zh_Hant: Matrix
|
||||
ja_JP: Matrix
|
||||
th_TH: Matrix
|
||||
vi_VN: Matrix
|
||||
es_ES: Matrix
|
||||
description:
|
||||
en_US: Matrix protocol adapter, supports self-hosted Synapse servers and any Matrix-compatible homeserver
|
||||
zh_Hans: Matrix 协议适配器,支持自建 Synapse 服务器及任何 Matrix 兼容的 Homeserver
|
||||
zh_Hant: Matrix 協議適配器,支持自建 Synapse 伺服器及任何 Matrix 相容的 Homeserver
|
||||
ja_JP: Matrix プロトコルアダプター、セルフホストの Synapse サーバーおよび Matrix 互換のホームサーバーをサポート
|
||||
th_TH: อะแดปเตอร์โปรโตคอล Matrix รองรับเซิร์ฟเวอร์ Synapse ที่โฮสต์เองและ Homeserver ที่เข้ากันได้กับ Matrix
|
||||
vi_VN: Bộ điều hợp giao thức Matrix, hỗ trợ máy chủ Synapse tự lưu trữ và bất kỳ Homeserver tương thích Matrix nào
|
||||
es_ES: Adaptador del protocolo Matrix, compatible con servidores Synapse autoalojados y cualquier Homeserver compatible con Matrix
|
||||
icon: matrix.png
|
||||
spec:
|
||||
categories:
|
||||
- global
|
||||
- protocol
|
||||
config:
|
||||
- name: homeserver_url
|
||||
label:
|
||||
en_US: Homeserver URL
|
||||
zh_Hans: Homeserver 地址
|
||||
zh_Hant: Homeserver 地址
|
||||
ja_JP: Homeserver URL
|
||||
th_TH: URL ของ Homeserver
|
||||
vi_VN: URL Homeserver
|
||||
es_ES: URL del Homeserver
|
||||
description:
|
||||
en_US: "The URL of the Matrix homeserver, e.g. http://localhost:8008"
|
||||
zh_Hans: "Matrix Homeserver 的地址,例如 http://localhost:8008"
|
||||
type: string
|
||||
required: true
|
||||
default: "http://localhost:8008"
|
||||
- name: user_id
|
||||
label:
|
||||
en_US: Bot User ID
|
||||
zh_Hans: 机器人用户 ID
|
||||
zh_Hant: 機器人用戶 ID
|
||||
ja_JP: ボットユーザー ID
|
||||
th_TH: ID ผู้ใช้บอท
|
||||
vi_VN: ID người dùng bot
|
||||
es_ES: ID de usuario del bot
|
||||
description:
|
||||
en_US: "The full Matrix user ID, e.g. @bot:localhost"
|
||||
zh_Hans: "完整的 Matrix 用户 ID,例如 @bot:localhost"
|
||||
type: string
|
||||
required: true
|
||||
default: "@langbot:localhost"
|
||||
- name: access_token
|
||||
label:
|
||||
en_US: Access Token
|
||||
zh_Hans: 访问令牌
|
||||
zh_Hant: 訪問令牌
|
||||
ja_JP: アクセストークン
|
||||
th_TH: โทเค็นการเข้าถึง
|
||||
vi_VN: Mã truy cập
|
||||
es_ES: Token de acceso
|
||||
description:
|
||||
en_US: "Access token obtained by logging in via the Matrix client API"
|
||||
zh_Hans: "通过 Matrix Client API 登录获取的访问令牌"
|
||||
type: string
|
||||
required: true
|
||||
default: ""
|
||||
- name: bridge_user_id
|
||||
label:
|
||||
en_US: Bridge Bot User ID (single bridge, legacy)
|
||||
zh_Hans: 桥机器人用户 ID(单桥兼容)
|
||||
description:
|
||||
en_US: "Single bridge bot user ID (legacy). Prefer 'bridges' for multi-bridge. e.g. @discordbot:localhost"
|
||||
zh_Hans: "单桥机器人用户 ID(旧格式兼容)。推荐使用 bridges 配置多桥。例如 @discordbot:localhost"
|
||||
type: string
|
||||
required: false
|
||||
default: ""
|
||||
- name: bridge_login_command
|
||||
label:
|
||||
en_US: Bridge Login Command (single bridge, legacy)
|
||||
zh_Hans: 桥登录命令(单桥兼容)
|
||||
description:
|
||||
en_US: "Login command for single bridge (legacy). e.g. !discord login"
|
||||
zh_Hans: "单桥登录命令(旧格式兼容)。例如 !discord login"
|
||||
type: string
|
||||
required: false
|
||||
default: ""
|
||||
- name: bridge_login_success_keyword
|
||||
label:
|
||||
en_US: Bridge Login Success Keyword (single bridge, legacy)
|
||||
zh_Hans: 桥登录成功关键词(单桥兼容)
|
||||
description:
|
||||
en_US: "Success keyword for single bridge (legacy). e.g. Successfully logged in"
|
||||
zh_Hans: "单桥登录成功关键词(旧格式兼容)。例如 Successfully logged in"
|
||||
type: string
|
||||
required: false
|
||||
default: "Successfully logged in"
|
||||
- name: bridges
|
||||
label:
|
||||
en_US: Bridges Config (Multi-bridge)
|
||||
zh_Hans: 桥配置(多桥)
|
||||
description:
|
||||
en_US: >
|
||||
JSON array of bridge configs. Each bridge: {"user_id": "@bot:host", "login_command": "!xx login",
|
||||
"success_keyword": "logged in", "check_command": "!xx ping"}.
|
||||
Example: [{"user_id":"@discordbot:localhost","login_command":"!discord login","success_keyword":"logged in"},
|
||||
{"user_id":"@telegrambot:localhost","login_command":"!tg login","success_keyword":"logged in"}]
|
||||
zh_Hans: >
|
||||
JSON 数组格式的多桥配置。每个桥: {"user_id": "@bot:host", "login_command": "!xx login",
|
||||
"success_keyword": "logged in", "check_command": "!xx ping"}。
|
||||
示例: [{"user_id":"@discordbot:localhost","login_command":"!discord login","success_keyword":"logged in"},
|
||||
{"user_id":"@telegrambot:localhost","login_command":"!tg login","success_keyword":"logged in"}]
|
||||
type: string
|
||||
required: false
|
||||
default: ""
|
||||
execution:
|
||||
python:
|
||||
path: ./matrix.py
|
||||
attr: MatrixAdapter
|
||||
@@ -1,11 +1,9 @@
|
||||
from __future__ import annotations
|
||||
import typing
|
||||
import re
|
||||
import asyncio
|
||||
import traceback
|
||||
|
||||
import datetime
|
||||
import time
|
||||
|
||||
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
|
||||
import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
||||
@@ -17,25 +15,11 @@ from ...utils import image
|
||||
from ..logger import EventLogger
|
||||
|
||||
|
||||
def _is_base64_data(value: str) -> bool:
|
||||
"""Check if a string contains base64-encoded data rather than a URL."""
|
||||
if not value:
|
||||
return False
|
||||
# data: URI scheme (e.g. data:image/png;base64,xxx)
|
||||
if value.startswith('data:'):
|
||||
return True
|
||||
# Only treat as base64 if it doesn't look like a URL/path and has valid base64 chars
|
||||
if value.startswith(('http://', 'https://', '/', './', '../')):
|
||||
return False
|
||||
# Check if it looks like base64 (only valid chars, reasonable length)
|
||||
return bool(re.fullmatch(r'[A-Za-z0-9+/=\s]{20,}', value))
|
||||
|
||||
|
||||
class QQOfficialMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
|
||||
@staticmethod
|
||||
async def yiri2target(message_chain: platform_message.MessageChain):
|
||||
"""将 LangBot 消息链转换为 QQ Official 消息格式列表。"""
|
||||
content_list = []
|
||||
# 只实现了发文字
|
||||
for msg in message_chain:
|
||||
if type(msg) is platform_message.Plain:
|
||||
content_list.append(
|
||||
@@ -44,49 +28,6 @@ class QQOfficialMessageConverter(abstract_platform_adapter.AbstractMessageConver
|
||||
'content': msg.text,
|
||||
}
|
||||
)
|
||||
elif type(msg) is platform_message.Image:
|
||||
url = msg.url if hasattr(msg, 'url') and msg.url else None
|
||||
b64 = msg.base64 if hasattr(msg, 'base64') and msg.base64 else None
|
||||
# Some plugins (e.g. MimoTTS) store base64 data in the url field
|
||||
if url and not b64 and _is_base64_data(url):
|
||||
b64 = url
|
||||
url = None
|
||||
content_list.append(
|
||||
{
|
||||
'type': 'image',
|
||||
'url': url,
|
||||
'base64': b64,
|
||||
}
|
||||
)
|
||||
elif type(msg) is platform_message.Voice:
|
||||
url = msg.url if hasattr(msg, 'url') and msg.url else None
|
||||
b64 = msg.base64 if hasattr(msg, 'base64') and msg.base64 else None
|
||||
# Some plugins (e.g. MimoTTS) store base64 data in the url field
|
||||
if url and not b64 and _is_base64_data(url):
|
||||
b64 = url
|
||||
url = None
|
||||
content_list.append(
|
||||
{
|
||||
'type': 'voice',
|
||||
'url': url,
|
||||
'base64': b64,
|
||||
}
|
||||
)
|
||||
elif type(msg) is platform_message.File:
|
||||
url = msg.url if hasattr(msg, 'url') and msg.url else None
|
||||
b64 = msg.base64 if hasattr(msg, 'base64') and msg.base64 else None
|
||||
# Some plugins store base64 data in the url field
|
||||
if url and not b64 and _is_base64_data(url):
|
||||
b64 = url
|
||||
url = None
|
||||
content_list.append(
|
||||
{
|
||||
'type': 'file',
|
||||
'url': url,
|
||||
'base64': b64,
|
||||
'name': msg.name if hasattr(msg, 'name') else 'file',
|
||||
}
|
||||
)
|
||||
|
||||
return content_list
|
||||
|
||||
@@ -188,19 +129,12 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
|
||||
config: dict
|
||||
bot_account_id: str
|
||||
bot_uuid: str = None
|
||||
enable_webhook: bool = False
|
||||
message_converter: QQOfficialMessageConverter = QQOfficialMessageConverter()
|
||||
event_converter: QQOfficialEventConverter = QQOfficialEventConverter()
|
||||
|
||||
def __init__(self, config: dict, logger: EventLogger):
|
||||
enable_webhook = config.get('enable-webhook', False)
|
||||
|
||||
bot = QQOfficialClient(
|
||||
app_id=config['appid'],
|
||||
secret=config['secret'],
|
||||
token=config['token'],
|
||||
logger=logger,
|
||||
unified_mode=enable_webhook,
|
||||
app_id=config['appid'], secret=config['secret'], token=config['token'], logger=logger, unified_mode=True
|
||||
)
|
||||
|
||||
super().__init__(
|
||||
@@ -210,13 +144,6 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
|
||||
bot_account_id=config['appid'],
|
||||
)
|
||||
|
||||
self.enable_webhook = enable_webhook
|
||||
self._ws_task: asyncio.Task = None
|
||||
self._stream_ctx: dict = {}
|
||||
self._stream_ctx_ts: dict[str, float] = {}
|
||||
self._fallback_text: dict[str, str] = {}
|
||||
self._fallback_text_ts: dict[str, float] = {}
|
||||
|
||||
async def reply_message(
|
||||
self,
|
||||
message_source: platform_events.MessageEvent,
|
||||
@@ -229,18 +156,28 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
|
||||
|
||||
content_list = await QQOfficialMessageConverter.yiri2target(message)
|
||||
|
||||
# 确定 target_type 和 target_id
|
||||
target_type = None
|
||||
target_id = None
|
||||
|
||||
# 私聊消息
|
||||
if qq_official_event.t == 'C2C_MESSAGE_CREATE':
|
||||
target_type = 'c2c'
|
||||
target_id = qq_official_event.user_openid
|
||||
elif qq_official_event.t == 'GROUP_AT_MESSAGE_CREATE':
|
||||
target_type = 'group'
|
||||
target_id = qq_official_event.group_openid
|
||||
elif qq_official_event.t == 'AT_MESSAGE_CREATE':
|
||||
# 频道群聊使用频道 API,暂不支持富媒体
|
||||
for content in content_list:
|
||||
if content['type'] == 'text':
|
||||
await self.bot.send_private_text_msg(
|
||||
qq_official_event.user_openid,
|
||||
content['content'],
|
||||
qq_official_event.d_id,
|
||||
)
|
||||
|
||||
# 群聊消息
|
||||
if qq_official_event.t == 'GROUP_AT_MESSAGE_CREATE':
|
||||
for content in content_list:
|
||||
if content['type'] == 'text':
|
||||
await self.bot.send_group_text_msg(
|
||||
qq_official_event.group_openid,
|
||||
content['content'],
|
||||
qq_official_event.d_id,
|
||||
)
|
||||
|
||||
# 频道群聊
|
||||
if qq_official_event.t == 'AT_MESSAGE_CREATE':
|
||||
for content in content_list:
|
||||
if content['type'] == 'text':
|
||||
await self.bot.send_channle_group_text_msg(
|
||||
@@ -248,9 +185,9 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
|
||||
content['content'],
|
||||
qq_official_event.d_id,
|
||||
)
|
||||
return
|
||||
elif qq_official_event.t == 'DIRECT_MESSAGE_CREATE':
|
||||
# 频道私聊使用频道 API,暂不支持富媒体
|
||||
|
||||
# 频道私聊
|
||||
if qq_official_event.t == 'DIRECT_MESSAGE_CREATE':
|
||||
for content in content_list:
|
||||
if content['type'] == 'text':
|
||||
await self.bot.send_channle_private_text_msg(
|
||||
@@ -258,63 +195,6 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
|
||||
content['content'],
|
||||
qq_official_event.d_id,
|
||||
)
|
||||
return
|
||||
|
||||
# C2C 和群聊:支持文字 + 富媒体
|
||||
for content in content_list:
|
||||
content_type = content.get('type', 'text')
|
||||
|
||||
if content_type == 'text':
|
||||
if target_type == 'c2c':
|
||||
await self.bot.send_private_text_msg(
|
||||
target_id,
|
||||
content['content'],
|
||||
qq_official_event.d_id,
|
||||
)
|
||||
elif target_type == 'group':
|
||||
await self.bot.send_group_text_msg(
|
||||
target_id,
|
||||
content['content'],
|
||||
qq_official_event.d_id,
|
||||
)
|
||||
|
||||
elif content_type == 'image':
|
||||
file_url = content.get('url')
|
||||
file_data = content.get('base64')
|
||||
if file_url or file_data:
|
||||
await self.bot.send_image_msg(
|
||||
target_type,
|
||||
target_id,
|
||||
file_url=file_url,
|
||||
file_data=file_data,
|
||||
msg_id=qq_official_event.d_id,
|
||||
)
|
||||
|
||||
elif content_type == 'voice':
|
||||
file_url = content.get('url')
|
||||
file_data = content.get('base64')
|
||||
if file_url or file_data:
|
||||
await self.bot.send_voice_msg(
|
||||
target_type,
|
||||
target_id,
|
||||
file_url=file_url,
|
||||
file_data=file_data,
|
||||
msg_id=qq_official_event.d_id,
|
||||
)
|
||||
|
||||
elif content_type == 'file':
|
||||
file_url = content.get('url')
|
||||
file_data = content.get('base64')
|
||||
file_name = content.get('name', 'file')
|
||||
if file_url or file_data:
|
||||
await self.bot.send_file_msg(
|
||||
target_type,
|
||||
target_id,
|
||||
file_url=file_url,
|
||||
file_data=file_data,
|
||||
file_name=file_name,
|
||||
msg_id=qq_official_event.d_id,
|
||||
)
|
||||
|
||||
async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain):
|
||||
pass
|
||||
@@ -358,196 +238,17 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
|
||||
return await self.bot.handle_unified_webhook(request)
|
||||
|
||||
async def run_async(self):
|
||||
if not self.enable_webhook:
|
||||
await self._run_websocket()
|
||||
else:
|
||||
# 统一 webhook 模式下,不启动独立的 Quart 应用
|
||||
async def keep_alive():
|
||||
while True:
|
||||
await asyncio.sleep(1)
|
||||
# 统一 webhook 模式下,不启动独立的 Quart 应用
|
||||
# 保持运行但不启动独立端口
|
||||
|
||||
await keep_alive()
|
||||
async def keep_alive():
|
||||
while True:
|
||||
await asyncio.sleep(1)
|
||||
|
||||
async def _run_websocket(self):
|
||||
"""以 WebSocket 模式运行网关连接"""
|
||||
await self.logger.info('QQ Official adapter starting in WebSocket mode')
|
||||
|
||||
async def on_ready():
|
||||
await self.logger.info('QQ Official WebSocket connected and ready')
|
||||
|
||||
async def on_event(event_type: str, event_data: dict):
|
||||
# 只处理消息事件,忽略 READY/RESUMED 等系统事件
|
||||
message_event_types = {
|
||||
'C2C_MESSAGE_CREATE',
|
||||
'DIRECT_MESSAGE_CREATE',
|
||||
'GROUP_AT_MESSAGE_CREATE',
|
||||
'AT_MESSAGE_CREATE',
|
||||
}
|
||||
if event_type not in message_event_types:
|
||||
return
|
||||
if not isinstance(event_data, dict):
|
||||
await self.logger.warning(f'Event data is not dict, skipping: {event_type} -> {type(event_data)}')
|
||||
return
|
||||
await self.logger.info(f'Processing message event: {event_type}')
|
||||
# 构造与 webhook 模式相同的 payload 结构
|
||||
payload = {'t': event_type, 'd': event_data}
|
||||
message_data = await self.bot.get_message(payload)
|
||||
if message_data:
|
||||
event = QQOfficialEvent.from_payload(message_data)
|
||||
await self.bot._handle_message(event)
|
||||
|
||||
async def on_error(error: Exception):
|
||||
await self.logger.error(f'WebSocket error: {error}')
|
||||
await self.logger.error(f'QQ Official WebSocket error: {error}')
|
||||
|
||||
self._ws_task = asyncio.create_task(self.bot.connect_gateway_loop(on_event, on_ready, on_error))
|
||||
try:
|
||||
await self._ws_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
await keep_alive()
|
||||
|
||||
async def kill(self) -> bool:
|
||||
if self._ws_task:
|
||||
self._ws_task.cancel()
|
||||
try:
|
||||
await self._ws_task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
self._ws_task = None
|
||||
return True
|
||||
|
||||
# --------------- 流式输出 ---------------
|
||||
|
||||
_STREAM_CTX_TTL = 300 # seconds
|
||||
|
||||
async def _cleanup_stale_streams(self):
|
||||
"""Remove stream contexts that have not been updated for more than _STREAM_CTX_TTL seconds."""
|
||||
now = time.time()
|
||||
stale_ids = [mid for mid, ts in self._stream_ctx_ts.items() if now - ts > self._STREAM_CTX_TTL]
|
||||
for mid in stale_ids:
|
||||
self._stream_ctx.pop(mid, None)
|
||||
self._stream_ctx_ts.pop(mid, None)
|
||||
stale_fb = [mid for mid, ts in self._fallback_text_ts.items() if now - ts > self._STREAM_CTX_TTL]
|
||||
for mid in stale_fb:
|
||||
self._fallback_text.pop(mid, None)
|
||||
self._fallback_text_ts.pop(mid, None)
|
||||
if stale_ids or stale_fb:
|
||||
await self.logger.debug(f'Cleaned up {len(stale_ids)} stream contexts, {len(stale_fb)} fallback texts')
|
||||
|
||||
async def is_stream_output_supported(self) -> bool:
|
||||
return self.config.get('enable-stream-reply', False)
|
||||
|
||||
async def create_message_card(self, message_id: str, event: platform_events.MessageEvent) -> bool:
|
||||
source = event.source_platform_object
|
||||
# Streaming API only supports C2C private chat
|
||||
if source.t != 'C2C_MESSAGE_CREATE':
|
||||
return False
|
||||
|
||||
ctx = {
|
||||
'user_openid': source.user_openid,
|
||||
'msg_id': source.d_id,
|
||||
'stream_msg_id': None,
|
||||
'msg_seq': 1,
|
||||
'index': 0,
|
||||
'last_update_ts': 0,
|
||||
'accumulated_text': '',
|
||||
'sent_length': 0,
|
||||
'session_started': False,
|
||||
}
|
||||
|
||||
self._stream_ctx[message_id] = ctx
|
||||
self._stream_ctx_ts[message_id] = time.time()
|
||||
return True
|
||||
|
||||
async def reply_message_chunk(
|
||||
self,
|
||||
message_source: platform_events.MessageEvent,
|
||||
bot_message: dict,
|
||||
message: platform_message.MessageChain,
|
||||
quote_origin: bool = False,
|
||||
is_final: bool = False,
|
||||
):
|
||||
# Periodically clean up stale stream contexts
|
||||
await self._cleanup_stale_streams()
|
||||
# 提取纯文本内容(当前 chunk 的文本)
|
||||
text_parts = []
|
||||
for msg in message:
|
||||
if type(msg) is platform_message.Plain:
|
||||
text_parts.append(msg.text)
|
||||
chunk_text = '\n\n'.join(text_parts)
|
||||
|
||||
message_id = (
|
||||
bot_message.get('resp_message_id')
|
||||
if isinstance(bot_message, dict)
|
||||
else getattr(bot_message, 'resp_message_id', None)
|
||||
)
|
||||
if not message_id or message_id not in self._stream_ctx:
|
||||
# 非流式场景(如群聊不支持流式),累积文本后一次性回复
|
||||
if chunk_text:
|
||||
self._fallback_text[message_id] = self._fallback_text.get(message_id, '') + chunk_text
|
||||
self._fallback_text_ts[message_id] = time.time()
|
||||
if is_final:
|
||||
full_text = self._fallback_text.pop(message_id, '')
|
||||
if full_text:
|
||||
fallback_msg = platform_message.MessageChain([platform_message.Plain(text=full_text)])
|
||||
await self.reply_message(message_source, fallback_msg, quote_origin)
|
||||
return
|
||||
|
||||
ctx = self._stream_ctx[message_id]
|
||||
|
||||
# 累积文本
|
||||
if chunk_text:
|
||||
ctx['accumulated_text'] += chunk_text
|
||||
|
||||
# 未启动会话时,等第一个有内容的 chunk 来建立会话
|
||||
if not ctx['session_started']:
|
||||
if not ctx['accumulated_text']:
|
||||
return
|
||||
# 用第一个 chunk 的文本建立会话(不发 "..." 避免污染前缀)
|
||||
ctx['session_started'] = True
|
||||
|
||||
# 发送内容 = 全量累积文本
|
||||
# QQ API 的 replace 模式不允许修改已下发前缀,所以:
|
||||
# - 首次:发送全部文本,建立会话
|
||||
# - 后续:只能发送新增部分(append 行为)
|
||||
content_to_send = ctx['accumulated_text'][ctx['sent_length'] :]
|
||||
if not content_to_send and not is_final:
|
||||
return
|
||||
|
||||
input_state = 10 if is_final else 1
|
||||
|
||||
# Rate limiting: skip non-final updates if last update was <0.5s ago
|
||||
now = time.time()
|
||||
if not is_final and (now - ctx['last_update_ts']) < 0.5:
|
||||
return
|
||||
ctx['last_update_ts'] = now
|
||||
|
||||
try:
|
||||
resp = await self.bot.send_stream_msg(
|
||||
user_openid=ctx['user_openid'],
|
||||
content=content_to_send,
|
||||
event_id=ctx['msg_id'],
|
||||
msg_id=ctx['msg_id'],
|
||||
msg_seq=ctx['msg_seq'],
|
||||
index=ctx['index'],
|
||||
stream_msg_id=ctx['stream_msg_id'],
|
||||
input_state=input_state,
|
||||
)
|
||||
if resp and isinstance(resp, dict):
|
||||
new_stream_id = resp.get('id')
|
||||
if new_stream_id:
|
||||
ctx['stream_msg_id'] = new_stream_id
|
||||
ctx['sent_length'] = len(ctx['accumulated_text'])
|
||||
ctx['index'] += 1
|
||||
await self.logger.debug(
|
||||
f'[QQ Official] 流式 chunk 已发送, index={ctx["index"]}, '
|
||||
f'sent_len={ctx["sent_length"]}, is_final={is_final}'
|
||||
)
|
||||
except Exception as e:
|
||||
await self.logger.error(f'Failed to send stream message: {e}')
|
||||
|
||||
if is_final:
|
||||
self._stream_ctx.pop(message_id, None)
|
||||
return False
|
||||
|
||||
def unregister_listener(
|
||||
self,
|
||||
|
||||
@@ -7,9 +7,9 @@ metadata:
|
||||
zh_Hans: QQ 官方 API
|
||||
zh_Hant: QQ 官方 API
|
||||
description:
|
||||
en_US: QQ Official API (Webhook / WebSocket)
|
||||
zh_Hans: QQ 官方 API,支持 Webhook 和 WebSocket 两种连接模式
|
||||
zh_Hant: QQ 官方 API,支援 Webhook 和 WebSocket 兩種連線模式
|
||||
en_US: QQ Official API (Webhook)
|
||||
zh_Hans: QQ 官方 API (Webhook),需要公网地址以接收消息推送,请查看文档了解使用方式
|
||||
zh_Hant: QQ 官方 API (Webhook),需要公網地址以接收訊息推送,請查看文件了解使用方式
|
||||
icon: qqofficial.svg
|
||||
spec:
|
||||
categories:
|
||||
@@ -19,6 +19,18 @@ spec:
|
||||
en: https://link.langbot.app/en/platforms/qqofficial
|
||||
ja: https://link.langbot.app/ja/platforms/qqofficial
|
||||
config:
|
||||
- name: webhook_url
|
||||
label:
|
||||
en_US: Webhook Callback URL
|
||||
zh_Hans: Webhook 回调地址
|
||||
zh_Hant: Webhook 回調地址
|
||||
description:
|
||||
en_US: Copy this URL and paste it into your QQ Official API webhook configuration
|
||||
zh_Hans: 复制此地址并粘贴到 QQ 官方 API 的 Webhook 配置中
|
||||
zh_Hant: 複製此地址並貼到 QQ 官方 API 的 Webhook 設定中
|
||||
type: webhook-url
|
||||
required: false
|
||||
default: ""
|
||||
- name: appid
|
||||
label:
|
||||
en_US: App ID
|
||||
@@ -43,46 +55,6 @@ spec:
|
||||
type: string
|
||||
required: true
|
||||
default: ""
|
||||
- name: enable-webhook
|
||||
label:
|
||||
en_US: Enable Webhook Mode
|
||||
zh_Hans: 启用Webhook模式
|
||||
zh_Hant: 啟用 Webhook 模式
|
||||
description:
|
||||
en_US: If enabled, the bot will use webhook mode to receive messages. Otherwise, it will use WebSocket mode
|
||||
zh_Hans: 如果启用,机器人将使用 Webhook 模式接收消息。否则,将使用 WebSocket 模式
|
||||
zh_Hant: 如果啟用,機器人將使用 Webhook 模式接收訊息。否則,將使用 WebSocket 模式
|
||||
type: boolean
|
||||
required: true
|
||||
default: false
|
||||
- name: enable-stream-reply
|
||||
label:
|
||||
en_US: Enable Stream Reply Mode
|
||||
zh_Hans: 启用流式回复模式
|
||||
zh_Hant: 啟用串流回覆模式
|
||||
description:
|
||||
en_US: If enabled, the bot will use streaming mode to reply messages (C2C only)
|
||||
zh_Hans: 如果启用,机器人将使用流式方式回复消息(仅私聊)
|
||||
zh_Hant: 如果啟用,機器人將使用串流方式回覆訊息(僅私聊)
|
||||
type: boolean
|
||||
required: true
|
||||
default: false
|
||||
- name: webhook_url
|
||||
label:
|
||||
en_US: Webhook Callback URL
|
||||
zh_Hans: Webhook 回调地址
|
||||
zh_Hant: Webhook 回調地址
|
||||
description:
|
||||
en_US: Copy this URL and paste it into your QQ Official API webhook configuration
|
||||
zh_Hans: 复制此地址并粘贴到 QQ 官方 API 的 Webhook 配置中
|
||||
zh_Hant: 複製此地址並貼到 QQ 官方 API 的 Webhook 設定中
|
||||
type: webhook-url
|
||||
required: false
|
||||
default: ""
|
||||
show_if:
|
||||
field: enable-webhook
|
||||
operator: eq
|
||||
value: true
|
||||
execution:
|
||||
python:
|
||||
path: ./qqofficial.py
|
||||
|
||||
@@ -1,177 +0,0 @@
|
||||
apiVersion: v1
|
||||
kind: MessagePlatformAdapter
|
||||
metadata:
|
||||
name: web_page_bot
|
||||
label:
|
||||
en_US: "Page Bot"
|
||||
zh_Hans: "页面机器人"
|
||||
zh_Hant: "頁面機器人"
|
||||
ja_JP: "ページボット"
|
||||
th_TH: "บอทหน้าเว็บ"
|
||||
vi_VN: "Bot trang web"
|
||||
es_ES: "Bot de página"
|
||||
description:
|
||||
en_US: "Embed a chat widget on any website with a simple script tag"
|
||||
zh_Hans: "通过一行脚本标签将聊天组件嵌入到任何网站"
|
||||
zh_Hant: "透過一行腳本標籤將聊天元件嵌入到任何網站"
|
||||
ja_JP: "シンプルなスクリプトタグで任意のウェブサイトにチャットウィジェットを埋め込みます"
|
||||
th_TH: "ฝังวิดเจ็ตแชทในเว็บไซต์ใดก็ได้ด้วยแท็กสคริปต์"
|
||||
vi_VN: "Nhúng widget trò chuyện vào bất kỳ trang web nào bằng thẻ script"
|
||||
es_ES: "Incrusta un widget de chat en cualquier sitio web con una etiqueta de script"
|
||||
icon: "webpage.webp"
|
||||
spec:
|
||||
categories:
|
||||
- popular
|
||||
config:
|
||||
- name: title
|
||||
label:
|
||||
en_US: Widget Title
|
||||
zh_Hans: 组件标题
|
||||
zh_Hant: 元件標題
|
||||
ja_JP: ウィジェットタイトル
|
||||
th_TH: ชื่อวิดเจ็ต
|
||||
vi_VN: Tiêu đề widget
|
||||
es_ES: Título del widget
|
||||
description:
|
||||
en_US: The title displayed in the chat widget header
|
||||
zh_Hans: 显示在聊天组件顶部的标题
|
||||
zh_Hant: 顯示在聊天元件頂部的標題
|
||||
ja_JP: チャットウィジェットのヘッダーに表示されるタイトル
|
||||
th_TH: ชื่อที่แสดงในส่วนหัวของวิดเจ็ตแชท
|
||||
vi_VN: Tiêu đề hiển thị trong đầu widget trò chuyện
|
||||
es_ES: El título que se muestra en el encabezado del widget de chat
|
||||
type: string
|
||||
required: false
|
||||
default: "LangBot"
|
||||
- name: bubble_icon
|
||||
label:
|
||||
en_US: Bubble Icon
|
||||
zh_Hans: 气泡图标
|
||||
zh_Hant: 氣泡圖示
|
||||
ja_JP: バブルアイコン
|
||||
th_TH: ไอคอนบับเบิล
|
||||
vi_VN: Biểu tượng bong bóng
|
||||
es_ES: Icono de burbuja
|
||||
ru_RU: Иконка пузырька
|
||||
description:
|
||||
en_US: "Icon displayed on the floating chat bubble"
|
||||
zh_Hans: "浮动聊天气泡上显示的图标"
|
||||
type: select
|
||||
required: false
|
||||
default: "logo"
|
||||
options:
|
||||
- name: "logo"
|
||||
label:
|
||||
en_US: "LangBot Logo"
|
||||
zh_Hans: "LangBot 图标"
|
||||
- name: "chat"
|
||||
label:
|
||||
en_US: "Chat Bubble"
|
||||
zh_Hans: "聊天气泡"
|
||||
- name: "robot"
|
||||
label:
|
||||
en_US: "Robot"
|
||||
zh_Hans: "机器人"
|
||||
- name: "headset"
|
||||
label:
|
||||
en_US: "Headset"
|
||||
zh_Hans: "客服耳机"
|
||||
- name: "sparkle"
|
||||
label:
|
||||
en_US: "Sparkle"
|
||||
zh_Hans: "星光"
|
||||
- name: "message"
|
||||
label:
|
||||
en_US: "Message"
|
||||
zh_Hans: "消息"
|
||||
- name: language
|
||||
label:
|
||||
en_US: Widget Language
|
||||
zh_Hans: 组件语言
|
||||
zh_Hant: 元件語言
|
||||
ja_JP: ウィジェット言語
|
||||
th_TH: ภาษาวิดเจ็ต
|
||||
vi_VN: Ngôn ngữ widget
|
||||
es_ES: Idioma del widget
|
||||
ru_RU: Язык виджета
|
||||
description:
|
||||
en_US: "Display language of the chat widget"
|
||||
zh_Hans: "聊天组件的显示语言"
|
||||
zh_Hant: "聊天元件的顯示語言"
|
||||
ja_JP: "チャットウィジェットの表示言語"
|
||||
th_TH: "ภาษาแสดงผลของวิดเจ็ตแชท"
|
||||
vi_VN: "Ngôn ngữ hiển thị của widget trò chuyện"
|
||||
es_ES: "Idioma de visualización del widget de chat"
|
||||
ru_RU: "Язык отображения виджета чата"
|
||||
type: select
|
||||
required: false
|
||||
default: "en_US"
|
||||
options:
|
||||
- name: "en_US"
|
||||
label:
|
||||
en_US: "English"
|
||||
- name: "zh_Hans"
|
||||
label:
|
||||
en_US: "简体中文"
|
||||
- name: "zh_Hant"
|
||||
label:
|
||||
en_US: "繁體中文"
|
||||
- name: "ja_JP"
|
||||
label:
|
||||
en_US: "日本語"
|
||||
- name: "es_ES"
|
||||
label:
|
||||
en_US: "Español"
|
||||
- name: "ru_RU"
|
||||
label:
|
||||
en_US: "Русский"
|
||||
- name: "th_TH"
|
||||
label:
|
||||
en_US: "ไทย"
|
||||
- name: "vi_VN"
|
||||
label:
|
||||
en_US: "Tiếng Việt"
|
||||
- name: embed_code
|
||||
label:
|
||||
en_US: Embed Code
|
||||
zh_Hans: 嵌入代码
|
||||
zh_Hant: 嵌入代碼
|
||||
ja_JP: 埋め込みコード
|
||||
th_TH: โค้ดฝังตัว
|
||||
vi_VN: Mã nhúng
|
||||
es_ES: Código de incrustación
|
||||
description:
|
||||
en_US: "Copy this code and paste it into your website HTML. The code will be generated after saving."
|
||||
zh_Hans: "复制此代码并粘贴到你的网站 HTML 中。保存后将自动生成。"
|
||||
zh_Hant: "複製此代碼並貼到你的網站 HTML 中。儲存後將自動生成。"
|
||||
ja_JP: "このコードをコピーしてウェブサイトのHTMLに貼り付けてください。保存後に自動生成されます。"
|
||||
th_TH: "คัดลอกโค้ดนี้และวางในHTML ของเว็บไซต์ของคุณ จะสร้างอัตโนมัติหลังจากบันทึก"
|
||||
vi_VN: "Sao chép mã này và dán vào HTML trang web của bạn. Mã sẽ được tạo tự động sau khi lưu."
|
||||
es_ES: "Copia este código y pégalo en el HTML de tu sitio web. El código se generará después de guardar."
|
||||
type: embed-code
|
||||
required: false
|
||||
default: ""
|
||||
- name: turnstile_site_key
|
||||
label:
|
||||
en_US: Turnstile Site Key
|
||||
zh_Hans: Turnstile 站点密钥
|
||||
description:
|
||||
en_US: "Cloudflare Turnstile site key for bot protection. Get it from the Cloudflare dashboard (Turnstile > Add Site). Leave empty to disable."
|
||||
zh_Hans: "Cloudflare Turnstile 站点密钥,用于防止机器人滥用。在 Cloudflare 控制台(Turnstile > 添加站点)中获取。留空则不启用。"
|
||||
type: string
|
||||
required: false
|
||||
default: ""
|
||||
- name: turnstile_secret_key
|
||||
label:
|
||||
en_US: Turnstile Secret Key
|
||||
zh_Hans: Turnstile 服务端密钥
|
||||
description:
|
||||
en_US: "Cloudflare Turnstile secret key for server-side token verification. Found alongside the site key in the Cloudflare dashboard. Required if site key is set."
|
||||
zh_Hans: "Cloudflare Turnstile 服务端密钥,用于服务端验证令牌。与站点密钥一起在 Cloudflare 控制台中获取。设置了站点密钥时必填。"
|
||||
type: string
|
||||
required: false
|
||||
default: ""
|
||||
execution:
|
||||
python:
|
||||
path: "web_page_bot_adapter.py"
|
||||
attr: "WebPageBotAdapter"
|
||||
@@ -1,97 +0,0 @@
|
||||
"""Web Page Bot adapter - lightweight adapter for embeddable chat widget"""
|
||||
|
||||
import typing
|
||||
|
||||
import pydantic
|
||||
|
||||
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
|
||||
import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
||||
import langbot_plugin.api.entities.builtin.platform.events as platform_events
|
||||
import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_platform_logger
|
||||
|
||||
|
||||
class WebPageBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
"""Lightweight adapter for the embeddable page bot.
|
||||
|
||||
This adapter does not handle messages itself. The actual WebSocket
|
||||
communication is handled by the singleton websocket_proxy_bot.
|
||||
This adapter stores event listeners so that RuntimeBot can register
|
||||
its handlers, which are then called by the websocket adapter when
|
||||
a message arrives for this bot's pipeline.
|
||||
|
||||
Message sending/replying is delegated to the websocket_proxy_bot's
|
||||
adapter so that replies are actually delivered over the WebSocket
|
||||
connection while the dashboard correctly shows this adapter's name.
|
||||
"""
|
||||
|
||||
listeners: dict = pydantic.Field(default_factory=dict, exclude=True)
|
||||
_ws_adapter: typing.Any = None
|
||||
|
||||
class Config:
|
||||
arbitrary_types_allowed = True
|
||||
# Allow private attributes
|
||||
underscore_attrs_are_private = True
|
||||
|
||||
def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger, **kwargs):
|
||||
super().__init__(config=config, logger=logger, **kwargs)
|
||||
|
||||
def set_ws_adapter(self, ws_adapter) -> None:
|
||||
"""Set the underlying WebSocket adapter used for actual message delivery."""
|
||||
object.__setattr__(self, '_ws_adapter', ws_adapter)
|
||||
|
||||
async def send_message(
|
||||
self,
|
||||
target_type: str,
|
||||
target_id: str,
|
||||
message: platform_message.MessageChain,
|
||||
) -> dict:
|
||||
if self._ws_adapter is not None:
|
||||
return await self._ws_adapter.send_message(target_type, target_id, message)
|
||||
return {}
|
||||
|
||||
async def reply_message(
|
||||
self,
|
||||
message_source: platform_events.MessageEvent,
|
||||
message: platform_message.MessageChain,
|
||||
quote_origin: bool = False,
|
||||
) -> dict:
|
||||
if self._ws_adapter is not None:
|
||||
return await self._ws_adapter.reply_message(message_source, message, quote_origin)
|
||||
return {}
|
||||
|
||||
async def reply_message_chunk(
|
||||
self,
|
||||
message_source: platform_events.MessageEvent,
|
||||
bot_message,
|
||||
message: platform_message.MessageChain,
|
||||
quote_origin: bool = False,
|
||||
is_final: bool = False,
|
||||
) -> dict:
|
||||
if self._ws_adapter is not None:
|
||||
return await self._ws_adapter.reply_message_chunk(
|
||||
message_source, bot_message, message, quote_origin, is_final
|
||||
)
|
||||
return {}
|
||||
|
||||
def register_listener(
|
||||
self,
|
||||
event_type: typing.Type[platform_events.Event],
|
||||
func: typing.Callable,
|
||||
):
|
||||
self.listeners[event_type] = func
|
||||
|
||||
def unregister_listener(
|
||||
self,
|
||||
event_type: typing.Type[platform_events.Event],
|
||||
func: typing.Callable,
|
||||
):
|
||||
self.listeners.pop(event_type, None)
|
||||
|
||||
async def is_muted(self, group_id: int) -> bool:
|
||||
return False
|
||||
|
||||
async def run_async(self):
|
||||
pass
|
||||
|
||||
async def kill(self):
|
||||
pass
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 14 KiB |
@@ -312,7 +312,7 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
|
||||
|
||||
async def _process_image_components(self, message_chain_obj: list):
|
||||
"""
|
||||
处理消息链中的图片和文件组件,将path转换为base64
|
||||
处理消息链中的图片组件,将path转换为base64
|
||||
|
||||
Args:
|
||||
message_chain_obj: 消息链对象列表
|
||||
@@ -322,18 +322,16 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
|
||||
storage_mgr = self.ap.storage_mgr
|
||||
|
||||
for component in message_chain_obj:
|
||||
comp_type = component.get('type', '')
|
||||
comp_path = component.get('path', '')
|
||||
|
||||
if not comp_path:
|
||||
continue
|
||||
|
||||
if comp_type == 'Image':
|
||||
if component.get('type') == 'Image' and component.get('path'):
|
||||
try:
|
||||
file_content = await storage_mgr.storage_provider.load(comp_path)
|
||||
# 从storage读取文件
|
||||
file_content = await storage_mgr.storage_provider.load(component['path'])
|
||||
|
||||
# 转换为base64
|
||||
base64_str = base64.b64encode(file_content).decode('utf-8')
|
||||
|
||||
file_key = comp_path
|
||||
# 添加data URI前缀(根据文件扩展名判断MIME类型)
|
||||
file_key = component['path']
|
||||
if file_key.lower().endswith(('.jpg', '.jpeg')):
|
||||
mime_type = 'image/jpeg'
|
||||
elif file_key.lower().endswith('.png'):
|
||||
@@ -343,19 +341,19 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
|
||||
elif file_key.lower().endswith('.webp'):
|
||||
mime_type = 'image/webp'
|
||||
else:
|
||||
mime_type = 'image/png'
|
||||
mime_type = 'image/png' # 默认
|
||||
|
||||
component['base64'] = f'data:{mime_type};base64,{base64_str}'
|
||||
await storage_mgr.storage_provider.delete(comp_path)
|
||||
await storage_mgr.storage_provider.delete(component['path'])
|
||||
component['path'] = ''
|
||||
# 保留path字段用于后端处理,前端使用base64显示
|
||||
except Exception as e:
|
||||
await self.logger.error(f'Failed to load image file {comp_path}: {e}')
|
||||
await self.logger.error(f'加载图片文件失败 {component["path"]}: {e}')
|
||||
|
||||
async def handle_websocket_message(
|
||||
self,
|
||||
connection: WebSocketConnection,
|
||||
message_data: dict,
|
||||
owner_bot=None,
|
||||
):
|
||||
"""
|
||||
处理从WebSocket接收的消息
|
||||
@@ -368,8 +366,6 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
|
||||
message_data: 消息数据,包含:
|
||||
- message: 消息链
|
||||
- stream: 是否启用流式输出 (可选,默认True)
|
||||
owner_bot: Optional RuntimeBot that owns this pipeline (e.g. a web_page_bot).
|
||||
When provided, its identity is used for logging and session tracking.
|
||||
"""
|
||||
pipeline_uuid = connection.pipeline_uuid
|
||||
session_type = connection.session_type
|
||||
@@ -439,26 +435,12 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
|
||||
sender=sender, message_chain=message_chain, time=datetime.now().timestamp()
|
||||
)
|
||||
|
||||
# 设置流水线UUID (proxy bot always needs it for reply_message routing)
|
||||
# 设置流水线UUID
|
||||
self.ap.platform_mgr.websocket_proxy_bot.bot_entity.use_pipeline_uuid = pipeline_uuid
|
||||
if owner_bot is not None:
|
||||
owner_bot.bot_entity.use_pipeline_uuid = pipeline_uuid
|
||||
|
||||
# 异步触发事件处理
|
||||
# Use owner_bot's listeners if available, otherwise fall back to proxy bot
|
||||
listeners = (
|
||||
owner_bot.adapter.listeners
|
||||
if (owner_bot and hasattr(owner_bot.adapter, 'listeners') and owner_bot.adapter.listeners)
|
||||
else self.listeners
|
||||
)
|
||||
# Pass owner_bot's adapter so that downstream logging / dashboard
|
||||
# attributes the message to the correct bot adapter name.
|
||||
# Wire the ws adapter into the owner so replies are actually delivered.
|
||||
if owner_bot and hasattr(owner_bot.adapter, 'set_ws_adapter'):
|
||||
owner_bot.adapter.set_ws_adapter(self)
|
||||
callback_adapter = owner_bot.adapter if (owner_bot and hasattr(owner_bot, 'adapter')) else self
|
||||
if event.__class__ in listeners:
|
||||
asyncio.create_task(listeners[event.__class__](event, callback_adapter))
|
||||
# 异步触发事件处理(不等待结果)
|
||||
if event.__class__ in self.listeners:
|
||||
asyncio.create_task(self.listeners[event.__class__](event, self))
|
||||
|
||||
def get_websocket_messages(self, pipeline_uuid: str, session_type: str) -> list[dict]:
|
||||
"""获取消息历史"""
|
||||
|
||||
@@ -431,17 +431,6 @@ class PluginRuntimeConnector:
|
||||
async def get_plugin_assets(self, plugin_author: str, plugin_name: str, filepath: str) -> dict[str, Any]:
|
||||
return await self.handler.get_plugin_assets(plugin_author, plugin_name, filepath)
|
||||
|
||||
async def handle_page_api(
|
||||
self,
|
||||
plugin_author: str,
|
||||
plugin_name: str,
|
||||
page_id: str,
|
||||
endpoint: str,
|
||||
method: str,
|
||||
body: Any = None,
|
||||
) -> dict[str, Any]:
|
||||
return await self.handler.handle_page_api(plugin_author, plugin_name, page_id, endpoint, method, body)
|
||||
|
||||
async def get_debug_info(self) -> dict[str, Any]:
|
||||
"""Get debug information including debug key and WS URL"""
|
||||
if not self.is_enable_plugin:
|
||||
|
||||
@@ -367,22 +367,6 @@ class RuntimeConnectionHandler(handler.Handler):
|
||||
owner_type = data['owner_type']
|
||||
owner = data['owner']
|
||||
value = base64.b64decode(data['value_base64'])
|
||||
max_value_bytes = (
|
||||
self.ap.instance_config.data.get('plugin', {})
|
||||
.get('binary_storage', {})
|
||||
.get(
|
||||
'max_value_bytes',
|
||||
10 * 1024 * 1024,
|
||||
)
|
||||
)
|
||||
try:
|
||||
max_value_bytes = int(max_value_bytes)
|
||||
except (TypeError, ValueError):
|
||||
max_value_bytes = 10 * 1024 * 1024
|
||||
if max_value_bytes >= 0 and len(value) > max_value_bytes:
|
||||
return handler.ActionResponse.error(
|
||||
message=f'Binary storage value exceeds limit ({len(value)} > {max_value_bytes} bytes)',
|
||||
)
|
||||
|
||||
result = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.select(persistence_bstorage.BinaryStorage)
|
||||
@@ -955,11 +939,6 @@ class RuntimeConnectionHandler(handler.Handler):
|
||||
timeout=20,
|
||||
)
|
||||
asset_file_key = result['file_file_key']
|
||||
if not asset_file_key:
|
||||
return {
|
||||
'asset_base64': '',
|
||||
'mime_type': '',
|
||||
}
|
||||
mime_type = result['mime_type']
|
||||
asset_bytes = await self.read_local_file(asset_file_key)
|
||||
await self.delete_local_file(asset_file_key)
|
||||
@@ -968,30 +947,6 @@ class RuntimeConnectionHandler(handler.Handler):
|
||||
'mime_type': mime_type,
|
||||
}
|
||||
|
||||
async def handle_page_api(
|
||||
self,
|
||||
plugin_author: str,
|
||||
plugin_name: str,
|
||||
page_id: str,
|
||||
endpoint: str,
|
||||
method: str,
|
||||
body: Any = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Forward a page API call to the plugin via runtime."""
|
||||
result = await self.call_action(
|
||||
LangBotToRuntimeAction.PAGE_API,
|
||||
{
|
||||
'plugin_author': plugin_author,
|
||||
'plugin_name': plugin_name,
|
||||
'page_id': page_id,
|
||||
'endpoint': endpoint,
|
||||
'method': method,
|
||||
'body': body,
|
||||
},
|
||||
timeout=30,
|
||||
)
|
||||
return result
|
||||
|
||||
async def cleanup_plugin_data(self, plugin_author: str, plugin_name: str) -> None:
|
||||
"""Cleanup plugin settings and binary storage"""
|
||||
# Delete plugin settings
|
||||
|
||||
@@ -4,6 +4,7 @@ import sqlalchemy
|
||||
import traceback
|
||||
|
||||
from . import requester
|
||||
from .requesters import litellmchat
|
||||
from ...core import app
|
||||
from ...discover import engine
|
||||
from . import token
|
||||
@@ -42,6 +43,13 @@ class ModelManager:
|
||||
|
||||
requester_dict: dict[str, type[requester.ProviderAPIRequester]] = {}
|
||||
for component in self.requester_components:
|
||||
# Skip components that use litellm_provider (they will use litellmchat.py instead)
|
||||
if component.spec.get('litellm_provider'):
|
||||
self.ap.logger.debug(
|
||||
f'Skipping Python class loading for {component.metadata.name} '
|
||||
f'(uses litellm_provider={component.spec.get("litellm_provider")})'
|
||||
)
|
||||
continue
|
||||
requester_dict[component.metadata.name] = component.get_python_component_class()
|
||||
|
||||
self.requester_dict = requester_dict
|
||||
@@ -260,13 +268,34 @@ class ModelManager:
|
||||
else:
|
||||
provider_entity = provider_info
|
||||
|
||||
if provider_entity.requester not in self.requester_dict:
|
||||
raise provider_errors.RequesterNotFoundError(provider_entity.requester)
|
||||
# Get requester manifest to check for litellm_provider
|
||||
requester_manifest = self.get_available_requester_manifest_by_name(provider_entity.requester)
|
||||
|
||||
# Build config from base_url
|
||||
config = {'base_url': provider_entity.base_url}
|
||||
|
||||
# Check if requester manifest specifies litellm_provider
|
||||
if requester_manifest and requester_manifest.spec.get('litellm_provider'):
|
||||
# Use unified LiteLLMRequester with provider prefix
|
||||
# Map litellm_provider (YAML spec) to custom_llm_provider (config)
|
||||
config['custom_llm_provider'] = requester_manifest.spec['litellm_provider']
|
||||
requester_inst = litellmchat.LiteLLMRequester(
|
||||
ap=self.ap,
|
||||
config=config,
|
||||
)
|
||||
self.ap.logger.debug(
|
||||
f'Using LiteLLMRequester for {provider_entity.requester} '
|
||||
f'with custom_llm_provider={config["custom_llm_provider"]}'
|
||||
)
|
||||
else:
|
||||
# Use original requester class (for backward compatibility)
|
||||
if provider_entity.requester not in self.requester_dict:
|
||||
raise provider_errors.RequesterNotFoundError(provider_entity.requester)
|
||||
requester_inst = self.requester_dict[provider_entity.requester](
|
||||
ap=self.ap,
|
||||
config=config,
|
||||
)
|
||||
|
||||
requester_inst = self.requester_dict[provider_entity.requester](
|
||||
ap=self.ap,
|
||||
config={'base_url': provider_entity.base_url},
|
||||
)
|
||||
await requester_inst.initialize()
|
||||
|
||||
token_mgr = token.TokenManager(name=provider_entity.uuid, tokens=provider_entity.api_keys or [])
|
||||
|
||||
@@ -67,8 +67,8 @@ class RuntimeProvider:
|
||||
if isinstance(result, tuple):
|
||||
msg, usage_info = result
|
||||
if usage_info:
|
||||
input_tokens = usage_info.get('input_tokens', 0)
|
||||
output_tokens = usage_info.get('output_tokens', 0)
|
||||
input_tokens = usage_info.get('prompt_tokens', 0)
|
||||
output_tokens = usage_info.get('completion_tokens', 0)
|
||||
return msg
|
||||
else:
|
||||
return result
|
||||
@@ -128,7 +128,6 @@ class RuntimeProvider:
|
||||
start_time = time.time()
|
||||
status = 'success'
|
||||
error_message = None
|
||||
# Note: Stream doesn't easily provide token counts, set to 0
|
||||
input_tokens = 0
|
||||
output_tokens = 0
|
||||
|
||||
@@ -143,6 +142,15 @@ class RuntimeProvider:
|
||||
remove_think=remove_think,
|
||||
):
|
||||
yield chunk
|
||||
# Extract usage from stream if available (stored by LiteLLM requester)
|
||||
if query:
|
||||
if query.variables is None:
|
||||
query.variables = {}
|
||||
if '_stream_usage' in query.variables:
|
||||
usage_info = query.variables['_stream_usage']
|
||||
input_tokens = usage_info.get('prompt_tokens', 0)
|
||||
output_tokens = usage_info.get('completion_tokens', 0)
|
||||
del query.variables['_stream_usage']
|
||||
except Exception as e:
|
||||
status = 'error'
|
||||
error_message = str(e)
|
||||
|
||||
397
src/langbot/pkg/provider/modelmgr/requesters/litellmchat.py
Normal file
397
src/langbot/pkg/provider/modelmgr/requesters/litellmchat.py
Normal file
@@ -0,0 +1,397 @@
|
||||
"""LiteLLM unified requester for chat, embedding, and rerank."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import typing
|
||||
|
||||
import litellm
|
||||
from litellm import acompletion, aembedding, arerank
|
||||
|
||||
from .. import errors, requester
|
||||
import langbot_plugin.api.entities.builtin.resource.tool as resource_tool
|
||||
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
|
||||
import langbot_plugin.api.entities.builtin.provider.message as provider_message
|
||||
|
||||
|
||||
class LiteLLMRequester(requester.ProviderAPIRequester):
|
||||
"""LiteLLM unified API requester supporting chat, embedding, and rerank."""
|
||||
|
||||
default_config: dict[str, typing.Any] = {
|
||||
'base_url': '',
|
||||
'timeout': 120,
|
||||
'custom_llm_provider': '',
|
||||
'drop_params': False,
|
||||
'num_retries': 0,
|
||||
'api_version': '',
|
||||
}
|
||||
|
||||
async def initialize(self):
|
||||
"""Initialize LiteLLM client settings."""
|
||||
# LiteLLM doesn't require explicit client initialization
|
||||
# Configuration is passed per-request via litellm params
|
||||
pass
|
||||
|
||||
def _build_litellm_model_name(self, model_name: str, custom_llm_provider: str | None = None) -> str:
|
||||
"""Build LiteLLM model name with provider prefix if needed."""
|
||||
provider = custom_llm_provider or self.requester_cfg.get('custom_llm_provider', '')
|
||||
if provider:
|
||||
# LiteLLM format: provider/model_name
|
||||
return f'{provider}/{model_name}'
|
||||
# If no custom provider, assume model_name already includes prefix or is OpenAI-compatible
|
||||
return model_name
|
||||
|
||||
def _convert_messages(self, messages: typing.List[provider_message.Message]) -> list[dict]:
|
||||
"""Convert LangBot messages to LiteLLM/OpenAI format."""
|
||||
req_messages = []
|
||||
for m in messages:
|
||||
msg_dict = m.dict(exclude_none=True)
|
||||
content = msg_dict.get('content')
|
||||
|
||||
if isinstance(content, list):
|
||||
for part in content:
|
||||
if isinstance(part, dict) and part.get('type') == 'image_base64':
|
||||
part['image_url'] = {'url': part['image_base64']}
|
||||
part['type'] = 'image_url'
|
||||
del part['image_base64']
|
||||
|
||||
req_messages.append(msg_dict)
|
||||
|
||||
return req_messages
|
||||
|
||||
def _process_thinking_content(self, content: str, reasoning_content: str | None, remove_think: bool) -> str:
|
||||
"""Process thinking/reasoning content.
|
||||
|
||||
Args:
|
||||
content: The main content from response
|
||||
reasoning_content: Separate reasoning content from model
|
||||
remove_think: If True, remove thinking markers; if False, preserve them
|
||||
|
||||
Returns:
|
||||
Processed content string
|
||||
"""
|
||||
# Extract and handle thinking tags
|
||||
if content and 'CRETIRE_REASONING_BEGINk' in content and 'CRETIRE_REASONING_ENDk' in content:
|
||||
import re
|
||||
|
||||
think_pattern = r'CRETIRE_REASONING_BEGINk(.*?)CRETIRE_REASONING_ENDk'
|
||||
|
||||
if remove_think:
|
||||
# Remove thinking tags and their content from output
|
||||
content = re.sub(think_pattern, '', content, flags=re.DOTALL).strip()
|
||||
# else: preserve thinking content as-is
|
||||
|
||||
# Handle separate reasoning_content field
|
||||
# Currently we don't include reasoning_content in user-facing output regardless of remove_think
|
||||
# because it's typically internal model reasoning, not user-visible thinking
|
||||
return content or ''
|
||||
|
||||
def _extract_usage(self, response) -> dict:
|
||||
"""Extract usage info from LiteLLM response."""
|
||||
usage = response.usage
|
||||
return {
|
||||
'prompt_tokens': usage.prompt_tokens or 0,
|
||||
'completion_tokens': usage.completion_tokens or 0,
|
||||
'total_tokens': usage.total_tokens or 0,
|
||||
}
|
||||
|
||||
def _build_common_args(self, args: dict, include_retry_params: bool = True) -> dict:
|
||||
"""Apply common requester config to args dict."""
|
||||
if self.requester_cfg.get('base_url'):
|
||||
args['api_base'] = self.requester_cfg['base_url']
|
||||
if self.requester_cfg.get('timeout'):
|
||||
args['timeout'] = self.requester_cfg['timeout']
|
||||
if include_retry_params:
|
||||
if self.requester_cfg.get('drop_params'):
|
||||
args['drop_params'] = self.requester_cfg['drop_params']
|
||||
if self.requester_cfg.get('num_retries'):
|
||||
args['num_retries'] = self.requester_cfg['num_retries']
|
||||
if self.requester_cfg.get('api_version'):
|
||||
args['api_version'] = self.requester_cfg['api_version']
|
||||
return args
|
||||
|
||||
def _handle_litellm_error(self, e: Exception) -> None:
|
||||
"""Convert LiteLLM exceptions to RequesterError. Never returns, always raises."""
|
||||
# Check more specific exceptions first (they inherit from base exceptions)
|
||||
if isinstance(e, litellm.ContextWindowExceededError):
|
||||
raise errors.RequesterError(f'上下文长度超限: {str(e)}')
|
||||
if isinstance(e, litellm.BadRequestError):
|
||||
raise errors.RequesterError(f'请求参数错误: {str(e)}')
|
||||
if isinstance(e, litellm.AuthenticationError):
|
||||
raise errors.RequesterError(f'API key 无效: {str(e)}')
|
||||
if isinstance(e, litellm.NotFoundError):
|
||||
raise errors.RequesterError(f'模型或路径无效: {str(e)}')
|
||||
if isinstance(e, litellm.RateLimitError):
|
||||
raise errors.RequesterError(f'请求过于频繁或余额不足: {str(e)}')
|
||||
if isinstance(e, litellm.Timeout):
|
||||
raise errors.RequesterError(f'请求超时: {str(e)}')
|
||||
if isinstance(e, litellm.APIConnectionError):
|
||||
raise errors.RequesterError(f'连接错误: {str(e)}')
|
||||
if isinstance(e, litellm.APIError):
|
||||
raise errors.RequesterError(f'API 错误: {str(e)}')
|
||||
raise errors.RequesterError(f'未知错误: {str(e)}')
|
||||
|
||||
async def _build_completion_args(
|
||||
self,
|
||||
model: requester.RuntimeLLMModel,
|
||||
messages: typing.List[provider_message.Message],
|
||||
funcs: typing.List[resource_tool.LLMTool] = None,
|
||||
extra_args: dict[str, typing.Any] = {},
|
||||
stream: bool = False,
|
||||
) -> dict:
|
||||
"""Build common completion arguments for invoke_llm and invoke_llm_stream."""
|
||||
req_messages = self._convert_messages(messages)
|
||||
model_name = self._build_litellm_model_name(model.model_entity.name)
|
||||
api_key = model.provider.token_mgr.get_token()
|
||||
|
||||
args = {
|
||||
'model': model_name,
|
||||
'messages': req_messages,
|
||||
'api_key': api_key,
|
||||
}
|
||||
if stream:
|
||||
args['stream'] = True
|
||||
args['stream_options'] = {'include_usage': True}
|
||||
self._build_common_args(args)
|
||||
args.update(extra_args)
|
||||
|
||||
if funcs:
|
||||
tools = await self.ap.tool_mgr.generate_tools_for_openai(funcs)
|
||||
if tools:
|
||||
args['tools'] = tools
|
||||
|
||||
return args
|
||||
|
||||
async def invoke_llm(
|
||||
self,
|
||||
query: pipeline_query.Query,
|
||||
model: requester.RuntimeLLMModel,
|
||||
messages: typing.List[provider_message.Message],
|
||||
funcs: typing.List[resource_tool.LLMTool] = None,
|
||||
extra_args: dict[str, typing.Any] = {},
|
||||
remove_think: bool = False,
|
||||
) -> tuple[provider_message.Message, dict]:
|
||||
"""Invoke LLM and return message with usage info."""
|
||||
args = await self._build_completion_args(model, messages, funcs, extra_args, stream=False)
|
||||
|
||||
try:
|
||||
response = await acompletion(**args)
|
||||
|
||||
message_data = response.choices[0].message.model_dump()
|
||||
if 'role' not in message_data or message_data['role'] is None:
|
||||
message_data['role'] = 'assistant'
|
||||
|
||||
content = message_data.get('content', '')
|
||||
reasoning_content = message_data.get('reasoning_content', None)
|
||||
message_data['content'] = self._process_thinking_content(content, reasoning_content, remove_think)
|
||||
|
||||
if 'reasoning_content' in message_data:
|
||||
del message_data['reasoning_content']
|
||||
|
||||
message = provider_message.Message(**message_data)
|
||||
usage_info = self._extract_usage(response)
|
||||
|
||||
return message, usage_info
|
||||
|
||||
except Exception as e:
|
||||
self._handle_litellm_error(e)
|
||||
|
||||
async def invoke_llm_stream(
|
||||
self,
|
||||
query: pipeline_query.Query,
|
||||
model: requester.RuntimeLLMModel,
|
||||
messages: typing.List[provider_message.Message],
|
||||
funcs: typing.List[resource_tool.LLMTool] = None,
|
||||
extra_args: dict[str, typing.Any] = {},
|
||||
remove_think: bool = False,
|
||||
) -> provider_message.MessageChunk:
|
||||
"""Invoke LLM streaming and yield chunks."""
|
||||
args = await self._build_completion_args(model, messages, funcs, extra_args, stream=True)
|
||||
|
||||
chunk_idx = 0
|
||||
role = 'assistant'
|
||||
|
||||
try:
|
||||
response = await acompletion(**args)
|
||||
async for chunk in response:
|
||||
# Check for usage chunk (final chunk with stream_options include_usage)
|
||||
if hasattr(chunk, 'usage') and chunk.usage and (not hasattr(chunk, 'choices') or not chunk.choices):
|
||||
usage_info = {
|
||||
'prompt_tokens': chunk.usage.prompt_tokens or 0,
|
||||
'completion_tokens': chunk.usage.completion_tokens or 0,
|
||||
'total_tokens': chunk.usage.total_tokens or 0,
|
||||
}
|
||||
if query:
|
||||
if query.variables is None:
|
||||
query.variables = {}
|
||||
query.variables['_stream_usage'] = usage_info
|
||||
continue
|
||||
|
||||
if not hasattr(chunk, 'choices') or not chunk.choices:
|
||||
continue
|
||||
|
||||
choice = chunk.choices[0]
|
||||
delta = choice.delta.model_dump() if hasattr(choice, 'delta') else {}
|
||||
finish_reason = getattr(choice, 'finish_reason', None)
|
||||
|
||||
if 'role' in delta and delta['role']:
|
||||
role = delta['role']
|
||||
|
||||
delta_content = delta.get('content', '')
|
||||
reasoning_content = delta.get('reasoning_content', '')
|
||||
|
||||
if reasoning_content:
|
||||
chunk_idx += 1
|
||||
continue
|
||||
|
||||
if chunk_idx == 0 and not delta_content and not delta.get('tool_calls'):
|
||||
chunk_idx += 1
|
||||
continue
|
||||
|
||||
chunk_data = {
|
||||
'role': role,
|
||||
'content': delta_content if delta_content else None,
|
||||
'tool_calls': delta.get('tool_calls'),
|
||||
'is_final': bool(finish_reason),
|
||||
}
|
||||
|
||||
chunk_data = {k: v for k, v in chunk_data.items() if v is not None}
|
||||
yield provider_message.MessageChunk(**chunk_data)
|
||||
chunk_idx += 1
|
||||
|
||||
except Exception as e:
|
||||
self._handle_litellm_error(e)
|
||||
|
||||
async def invoke_embedding(
|
||||
self,
|
||||
model: requester.RuntimeEmbeddingModel,
|
||||
input_text: list[str],
|
||||
extra_args: dict[str, typing.Any] = {},
|
||||
) -> tuple[list[list[float]], dict]:
|
||||
"""Invoke embedding and return vectors with usage info."""
|
||||
model_name = self._build_litellm_model_name(model.model_entity.name)
|
||||
api_key = model.provider.token_mgr.get_token()
|
||||
|
||||
args = {
|
||||
'model': model_name,
|
||||
'input': input_text,
|
||||
'api_key': api_key,
|
||||
}
|
||||
self._build_common_args(args, include_retry_params=False)
|
||||
|
||||
if model.model_entity.extra_args:
|
||||
args.update(model.model_entity.extra_args)
|
||||
|
||||
args.update(extra_args)
|
||||
|
||||
try:
|
||||
response = await aembedding(**args)
|
||||
|
||||
embeddings = [d.embedding for d in response.data]
|
||||
usage_info = self._extract_usage(response)
|
||||
|
||||
return embeddings, usage_info
|
||||
|
||||
except Exception as e:
|
||||
self._handle_litellm_error(e)
|
||||
|
||||
async def invoke_rerank(
|
||||
self,
|
||||
model: requester.RuntimeRerankModel,
|
||||
query: str,
|
||||
documents: typing.List[str],
|
||||
extra_args: dict[str, typing.Any] = {},
|
||||
) -> typing.List[dict]:
|
||||
"""Invoke rerank and return relevance scores."""
|
||||
model_name = self._build_litellm_model_name(model.model_entity.name)
|
||||
api_key = model.provider.token_mgr.get_token()
|
||||
|
||||
args = {
|
||||
'model': model_name,
|
||||
'query': query,
|
||||
'documents': documents,
|
||||
'api_key': api_key,
|
||||
'top_n': min(len(documents), 64),
|
||||
}
|
||||
self._build_common_args(args, include_retry_params=False)
|
||||
|
||||
if model.model_entity.extra_args:
|
||||
args.update(model.model_entity.extra_args)
|
||||
|
||||
args.update(extra_args)
|
||||
|
||||
try:
|
||||
response = await arerank(**args)
|
||||
|
||||
results = []
|
||||
for r in response.results:
|
||||
results.append(
|
||||
{
|
||||
'index': r.get('index', 0),
|
||||
'relevance_score': r.get('relevance_score', 0.0),
|
||||
}
|
||||
)
|
||||
|
||||
if results:
|
||||
scores = [r['relevance_score'] for r in results]
|
||||
min_score = min(scores)
|
||||
max_score = max(scores)
|
||||
if max_score - min_score > 1e-6:
|
||||
for r in results:
|
||||
r['relevance_score'] = (r['relevance_score'] - min_score) / (max_score - min_score)
|
||||
|
||||
return results
|
||||
|
||||
except Exception as e:
|
||||
self._handle_litellm_error(e)
|
||||
|
||||
async def scan_models(self, api_key: str | None = None) -> dict[str, typing.Any]:
|
||||
"""Scan models supported by the provider."""
|
||||
import httpx
|
||||
|
||||
base_url = self.requester_cfg.get('base_url', '').rstrip('/')
|
||||
timeout = self.requester_cfg.get('timeout', 120)
|
||||
|
||||
if not base_url:
|
||||
raise errors.RequesterError('Base URL required for model scanning')
|
||||
|
||||
headers = {}
|
||||
if api_key:
|
||||
headers['Authorization'] = f'Bearer {api_key}'
|
||||
|
||||
models_url = f'{base_url}/models'
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(trust_env=True, timeout=timeout) as client:
|
||||
response = await client.get(models_url, headers=headers)
|
||||
response.raise_for_status()
|
||||
payload = response.json()
|
||||
|
||||
models = []
|
||||
for item in payload.get('data', []):
|
||||
model_id = item.get('id')
|
||||
if not model_id:
|
||||
continue
|
||||
|
||||
# Infer model type
|
||||
normalized_id = (model_id or '').lower()
|
||||
embedding_keywords = ('embedding', 'embed', 'bge-', 'e5-', 'm3e', 'gte-', 'text-embedding')
|
||||
model_type = 'embedding' if any(kw in normalized_id for kw in embedding_keywords) else 'llm'
|
||||
|
||||
models.append(
|
||||
{
|
||||
'id': model_id,
|
||||
'name': model_id,
|
||||
'type': model_type,
|
||||
}
|
||||
)
|
||||
|
||||
models.sort(key=lambda x: (x['type'] != 'llm', x['name'].lower()))
|
||||
|
||||
return {'models': models}
|
||||
|
||||
except httpx.HTTPStatusError as e:
|
||||
raise errors.RequesterError(f'Model scan failed: {e.response.status_code}')
|
||||
except httpx.TimeoutException:
|
||||
raise errors.RequesterError('Model scan timeout')
|
||||
except Exception as e:
|
||||
raise errors.RequesterError(f'Model scan error: {str(e)}')
|
||||
@@ -0,0 +1,64 @@
|
||||
apiVersion: v1
|
||||
kind: LLMAPIRequester
|
||||
metadata:
|
||||
name: litellm-chat
|
||||
label:
|
||||
en_US: LiteLLM (Unified)
|
||||
zh_Hans: LiteLLM (统一请求器)
|
||||
icon: litellm.svg
|
||||
spec:
|
||||
config:
|
||||
- name: base_url
|
||||
label:
|
||||
en_US: Base URL
|
||||
zh_Hans: 基础 URL
|
||||
type: string
|
||||
required: false
|
||||
default: ''
|
||||
- name: timeout
|
||||
label:
|
||||
en_US: Timeout
|
||||
zh_Hans: 超时时间
|
||||
type: integer
|
||||
required: true
|
||||
default: 120
|
||||
- name: custom_llm_provider
|
||||
label:
|
||||
en_US: Custom Provider
|
||||
zh_Hans: 自定义 Provider
|
||||
type: string
|
||||
required: false
|
||||
default: ''
|
||||
description:
|
||||
en_US: Force provider type (e.g., anthropic, openai, gemini)
|
||||
zh_Hans: 强制指定 provider 类型(如 anthropic, openai, gemini)
|
||||
- name: drop_params
|
||||
label:
|
||||
en_US: Drop Unsupported Params
|
||||
zh_Hans: 丢弃不支持参数
|
||||
type: boolean
|
||||
required: false
|
||||
default: false
|
||||
- name: num_retries
|
||||
label:
|
||||
en_US: Number of Retries
|
||||
zh_Hans: 重试次数
|
||||
type: integer
|
||||
required: false
|
||||
default: 0
|
||||
- name: api_version
|
||||
label:
|
||||
en_US: API Version
|
||||
zh_Hans: API 版本
|
||||
type: string
|
||||
required: false
|
||||
default: ''
|
||||
support_type:
|
||||
- llm
|
||||
- text-embedding
|
||||
- rerank
|
||||
provider_category: unified
|
||||
execution:
|
||||
python:
|
||||
path: ./litellmchat.py
|
||||
attr: LiteLLMRequester
|
||||
@@ -1 +0,0 @@
|
||||
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Qiniu</title><path d="M23.111 4.6a.914.914 0 00-.861.161A13.443 13.443 0 017.947 8.897L7.38 6.831a1.076 1.076 0 00-1.211-.698l.27 2.18c-1.816-.827-2.313-.946-3.587-2.45C2.674 5.729 1.263 4.472.89 4.6a11.906 11.906 0 005.892 6.497l.738 5.97s.33 2.286 2.473 2.286h4.586c2.144 0 2.474-2.286 2.474-2.286l.518-4.28c-1.393-.11-2.268.857-2.546 1.814-.465 1.614-.465 1.716-.557 1.998-.188.575-.806.644-.806.644h-2.753s-.617-.07-.806-.644c-.12-.371-.727-2.54-1.335-4.74A11.877 11.877 0 0023.11 4.599V4.6z" fill="#06AEEF"></path></svg>
|
||||
|
Before Width: | Height: | Size: 649 B |
@@ -1,45 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import typing
|
||||
|
||||
import openai
|
||||
|
||||
from . import chatcmpl
|
||||
|
||||
|
||||
class QiniuChatCompletions(chatcmpl.OpenAIChatCompletions):
|
||||
"""七牛云 ChatCompletion API 请求器"""
|
||||
|
||||
client: openai.AsyncClient
|
||||
|
||||
default_config: dict[str, typing.Any] = {
|
||||
'base_url': 'https://api.qnaigc.com/v1',
|
||||
'timeout': 120,
|
||||
}
|
||||
|
||||
async def scan_models(self, api_key: str | None = None) -> dict[str, typing.Any]:
|
||||
try:
|
||||
result = await super().scan_models(api_key)
|
||||
except Exception:
|
||||
return self._qiniu_fallback_scan_result()
|
||||
models = result.get('models') or []
|
||||
if not models:
|
||||
return self._qiniu_fallback_scan_result()
|
||||
return result
|
||||
|
||||
def _qiniu_fallback_scan_result(self) -> dict[str, typing.Any]:
|
||||
mid = 'deepseek-v3'
|
||||
return {
|
||||
'models': [
|
||||
{
|
||||
'id': mid,
|
||||
'name': mid,
|
||||
'type': 'llm',
|
||||
'abilities': [],
|
||||
}
|
||||
],
|
||||
'debug': {
|
||||
'request': {'method': 'GET', 'url': '', 'headers': {}},
|
||||
'response': {},
|
||||
},
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
apiVersion: v1
|
||||
kind: LLMAPIRequester
|
||||
metadata:
|
||||
name: qiniu-chat-completions
|
||||
label:
|
||||
en_US: Qiniu
|
||||
zh_Hans: 七牛云
|
||||
icon: qiniu.svg
|
||||
spec:
|
||||
config:
|
||||
- name: base_url
|
||||
label:
|
||||
en_US: Base URL
|
||||
zh_Hans: 基础 URL
|
||||
type: string
|
||||
required: true
|
||||
default: https://api.qnaigc.com/v1
|
||||
- name: timeout
|
||||
label:
|
||||
en_US: Timeout
|
||||
zh_Hans: 超时时间
|
||||
type: integer
|
||||
required: true
|
||||
default: 120
|
||||
support_type:
|
||||
- llm
|
||||
provider_category: maas
|
||||
execution:
|
||||
python:
|
||||
path: ./qiniuchatcmpl.py
|
||||
attr: QiniuChatCompletions
|
||||
@@ -187,12 +187,6 @@ class N8nServiceAPIRunner(runner.RequestRunner):
|
||||
if not query.session.using_conversation.uuid:
|
||||
query.session.using_conversation.uuid = str(uuid.uuid4())
|
||||
|
||||
# Keep query variables in sync with the generated/new conversation id.
|
||||
# query.variables is later merged into payload and would otherwise
|
||||
# overwrite the generated conversation_id with the stale preprocessor
|
||||
# value (usually None for a new conversation).
|
||||
query.variables['conversation_id'] = query.session.using_conversation.uuid
|
||||
|
||||
# 预处理用户消息
|
||||
plain_text = await self._preprocess_user_message(query)
|
||||
|
||||
|
||||
@@ -57,41 +57,6 @@ class ToolManager:
|
||||
|
||||
return tools
|
||||
|
||||
async def generate_tools_for_anthropic(self, use_funcs: list[resource_tool.LLMTool]) -> list:
|
||||
"""为anthropic生成函数列表
|
||||
|
||||
e.g.
|
||||
|
||||
[
|
||||
{
|
||||
"name": "get_stock_price",
|
||||
"description": "Get the current stock price for a given ticker symbol.",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"ticker": {
|
||||
"type": "string",
|
||||
"description": "The stock ticker symbol, e.g. AAPL for Apple Inc."
|
||||
}
|
||||
},
|
||||
"required": ["ticker"]
|
||||
}
|
||||
}
|
||||
]
|
||||
"""
|
||||
|
||||
tools = []
|
||||
|
||||
for function in use_funcs:
|
||||
function_schema = {
|
||||
'name': function.name,
|
||||
'description': function.description,
|
||||
'input_schema': function.parameters,
|
||||
}
|
||||
tools.append(function_schema)
|
||||
|
||||
return tools
|
||||
|
||||
async def execute_func_call(self, name: str, parameters: dict, query: pipeline_query.Query) -> typing.Any:
|
||||
"""执行函数调用"""
|
||||
|
||||
|
||||
@@ -148,60 +148,52 @@ class RuntimeKnowledgeBase(KnowledgeBaseInterface):
|
||||
supported_extensions = {'txt', 'pdf', 'docx', 'md', 'html'}
|
||||
stored_file_tasks = []
|
||||
|
||||
try:
|
||||
# use utf-8 encoding
|
||||
with zipfile.ZipFile(io.BytesIO(zip_bytes), 'r', metadata_encoding='utf-8') as zip_ref:
|
||||
for file_info in zip_ref.filelist:
|
||||
# skip directories and hidden files
|
||||
if file_info.is_dir() or file_info.filename.startswith('.'):
|
||||
# use utf-8 encoding
|
||||
with zipfile.ZipFile(io.BytesIO(zip_bytes), 'r', metadata_encoding='utf-8') as zip_ref:
|
||||
for file_info in zip_ref.filelist:
|
||||
# skip directories and hidden files
|
||||
if file_info.is_dir() or file_info.filename.startswith('.'):
|
||||
continue
|
||||
|
||||
_, file_ext = os.path.splitext(file_info.filename)
|
||||
file_extension = file_ext.lstrip('.').lower()
|
||||
if file_extension not in supported_extensions:
|
||||
self.ap.logger.debug(f'Skipping unsupported file in ZIP: {file_info.filename}')
|
||||
continue
|
||||
|
||||
try:
|
||||
file_content = zip_ref.read(file_info.filename)
|
||||
|
||||
base_name = file_info.filename.replace('/', '_').replace('\\', '_')
|
||||
file_stem, file_ext = os.path.splitext(base_name)
|
||||
extension = file_ext.lstrip('.')
|
||||
|
||||
if file_stem.startswith('__MACOSX'):
|
||||
continue
|
||||
|
||||
_, file_ext = os.path.splitext(file_info.filename)
|
||||
file_extension = file_ext.lstrip('.').lower()
|
||||
if file_extension not in supported_extensions:
|
||||
self.ap.logger.debug(f'Skipping unsupported file in ZIP: {file_info.filename}')
|
||||
continue
|
||||
extracted_file_id = file_stem + '_' + str(uuid.uuid4())[:8] + '.' + extension
|
||||
# save file to storage
|
||||
|
||||
try:
|
||||
file_content = zip_ref.read(file_info.filename)
|
||||
await self.ap.storage_mgr.storage_provider.save(extracted_file_id, file_content)
|
||||
|
||||
base_name = file_info.filename.replace('/', '_').replace('\\', '_')
|
||||
file_stem, file_ext = os.path.splitext(base_name)
|
||||
extension = file_ext.lstrip('.')
|
||||
task_id = await self.store_file(extracted_file_id, parser_plugin_id=parser_plugin_id)
|
||||
stored_file_tasks.append(task_id)
|
||||
|
||||
if file_stem.startswith('__MACOSX'):
|
||||
continue
|
||||
self.ap.logger.info(
|
||||
f'Extracted and stored file from ZIP: {file_info.filename} -> {extracted_file_id}'
|
||||
)
|
||||
|
||||
extracted_file_id = file_stem + '_' + str(uuid.uuid4())[:8] + '.' + extension
|
||||
# save file to storage
|
||||
except Exception as e:
|
||||
self.ap.logger.warning(f'Failed to extract file {file_info.filename} from ZIP: {e}')
|
||||
continue
|
||||
|
||||
await self.ap.storage_mgr.storage_provider.save(extracted_file_id, file_content)
|
||||
if not stored_file_tasks:
|
||||
raise Exception('No supported files found in ZIP archive')
|
||||
|
||||
task_id = await self.store_file(extracted_file_id, parser_plugin_id=parser_plugin_id)
|
||||
stored_file_tasks.append(task_id)
|
||||
self.ap.logger.info(f'Successfully processed ZIP file {zip_file_id}, extracted {len(stored_file_tasks)} files')
|
||||
await self.ap.storage_mgr.storage_provider.delete(zip_file_id)
|
||||
|
||||
self.ap.logger.info(
|
||||
f'Extracted and stored file from ZIP: {file_info.filename} -> {extracted_file_id}'
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
self.ap.logger.warning(f'Failed to extract file {file_info.filename} from ZIP: {e}')
|
||||
continue
|
||||
|
||||
if not stored_file_tasks:
|
||||
raise Exception('No supported files found in ZIP archive')
|
||||
|
||||
self.ap.logger.info(
|
||||
f'Successfully processed ZIP file {zip_file_id}, extracted {len(stored_file_tasks)} files'
|
||||
)
|
||||
return stored_file_tasks[0] if stored_file_tasks else ''
|
||||
finally:
|
||||
try:
|
||||
await self.ap.storage_mgr.storage_provider.delete(zip_file_id)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
except Exception as e:
|
||||
self.ap.logger.warning(f'Failed to cleanup ZIP file {zip_file_id}: {e}')
|
||||
return stored_file_tasks[0] if stored_file_tasks else ''
|
||||
|
||||
async def retrieve(self, query: str, settings: dict | None = None) -> list[rag_context.RetrievalResultEntry]:
|
||||
# Merge stored retrieval_settings with per-request overrides
|
||||
|
||||
@@ -12,23 +12,6 @@ from .. import provider
|
||||
LOCAL_STORAGE_PATH = os.path.join('data', 'storage')
|
||||
|
||||
|
||||
def _safe_resolve(base: str, key: str) -> str:
|
||||
"""Resolve *key* under *base* and ensure the result stays inside *base*.
|
||||
|
||||
Raises ``ValueError`` if the resolved path escapes the storage root
|
||||
(e.g. via absolute paths, ``..`` components, or symlinks).
|
||||
"""
|
||||
# os.path.realpath resolves symlinks and normalises the path.
|
||||
resolved = os.path.realpath(os.path.join(base, key))
|
||||
canonical_base = os.path.realpath(base)
|
||||
# The resolved path must be *strictly* inside the base directory (or equal
|
||||
# to it only for directory operations). We append os.sep so that a base of
|
||||
# "/data/storage" does not match "/data/storage_evil".
|
||||
if not (resolved == canonical_base or resolved.startswith(canonical_base + os.sep)):
|
||||
raise ValueError(f'Path traversal detected: key {key!r} resolves outside storage root')
|
||||
return resolved
|
||||
|
||||
|
||||
class LocalStorageProvider(provider.StorageProvider):
|
||||
def __init__(self, ap: app.Application):
|
||||
super().__init__(ap)
|
||||
@@ -40,47 +23,40 @@ class LocalStorageProvider(provider.StorageProvider):
|
||||
key: str,
|
||||
value: bytes,
|
||||
):
|
||||
resolved = _safe_resolve(LOCAL_STORAGE_PATH, key)
|
||||
parent = os.path.dirname(resolved)
|
||||
if not os.path.exists(parent):
|
||||
os.makedirs(parent)
|
||||
async with aiofiles.open(resolved, 'wb') as f:
|
||||
if not os.path.exists(os.path.join(LOCAL_STORAGE_PATH, os.path.dirname(key))):
|
||||
os.makedirs(os.path.join(LOCAL_STORAGE_PATH, os.path.dirname(key)))
|
||||
async with aiofiles.open(os.path.join(LOCAL_STORAGE_PATH, f'{key}'), 'wb') as f:
|
||||
await f.write(value)
|
||||
|
||||
async def load(
|
||||
self,
|
||||
key: str,
|
||||
) -> bytes:
|
||||
resolved = _safe_resolve(LOCAL_STORAGE_PATH, key)
|
||||
async with aiofiles.open(resolved, 'rb') as f:
|
||||
async with aiofiles.open(os.path.join(LOCAL_STORAGE_PATH, f'{key}'), 'rb') as f:
|
||||
return await f.read()
|
||||
|
||||
async def exists(
|
||||
self,
|
||||
key: str,
|
||||
) -> bool:
|
||||
resolved = _safe_resolve(LOCAL_STORAGE_PATH, key)
|
||||
return os.path.exists(resolved)
|
||||
return os.path.exists(os.path.join(LOCAL_STORAGE_PATH, f'{key}'))
|
||||
|
||||
async def delete(
|
||||
self,
|
||||
key: str,
|
||||
):
|
||||
resolved = _safe_resolve(LOCAL_STORAGE_PATH, key)
|
||||
os.remove(resolved)
|
||||
os.remove(os.path.join(LOCAL_STORAGE_PATH, f'{key}'))
|
||||
|
||||
async def size(
|
||||
self,
|
||||
key: str,
|
||||
) -> int:
|
||||
resolved = _safe_resolve(LOCAL_STORAGE_PATH, key)
|
||||
return os.path.getsize(resolved)
|
||||
return os.path.getsize(os.path.join(LOCAL_STORAGE_PATH, f'{key}'))
|
||||
|
||||
async def delete_dir_recursive(
|
||||
self,
|
||||
dir_path: str,
|
||||
):
|
||||
resolved = _safe_resolve(LOCAL_STORAGE_PATH, dir_path)
|
||||
# 直接删除整个目录
|
||||
if os.path.exists(resolved):
|
||||
shutil.rmtree(resolved)
|
||||
if os.path.exists(os.path.join(LOCAL_STORAGE_PATH, dir_path)):
|
||||
shutil.rmtree(os.path.join(LOCAL_STORAGE_PATH, dir_path))
|
||||
|
||||
@@ -25,9 +25,6 @@ system:
|
||||
max_bots: -1
|
||||
max_pipelines: -1
|
||||
max_extensions: -1
|
||||
task_retention:
|
||||
# Keep at most this many completed async task records in memory
|
||||
completed_limit: 200
|
||||
jwt:
|
||||
expire: 604800
|
||||
secret: ''
|
||||
@@ -71,15 +68,6 @@ vdb:
|
||||
password: 'postgres'
|
||||
storage:
|
||||
use: local
|
||||
cleanup:
|
||||
# Enable periodic cleanup of local/S3 uploaded files and old log files
|
||||
enabled: true
|
||||
# Cleanup check interval in hours
|
||||
check_interval_hours: 1
|
||||
# Root-level uploaded files older than this will be deleted
|
||||
uploaded_file_retention_days: 7
|
||||
# LangBot log files older than this many days will be deleted
|
||||
log_retention_days: 3
|
||||
s3:
|
||||
endpoint_url: ''
|
||||
access_key_id: ''
|
||||
@@ -91,9 +79,6 @@ plugin:
|
||||
runtime_ws_url: 'ws://langbot_plugin_runtime:5400/control/ws'
|
||||
enable_marketplace: true
|
||||
display_plugin_debug_url: 'ws://localhost:5401/plugin/debug/ws'
|
||||
binary_storage:
|
||||
# Max bytes for a single plugin binary storage value
|
||||
max_value_bytes: 10485760
|
||||
monitoring:
|
||||
auto_cleanup:
|
||||
# Enable automatic cleanup of expired monitoring records
|
||||
@@ -102,8 +87,6 @@ monitoring:
|
||||
retention_days: 30
|
||||
# Cleanup check interval in hours
|
||||
check_interval_hours: 1
|
||||
# Number of expired rows to delete per table batch
|
||||
delete_batch_size: 1000
|
||||
space:
|
||||
# Space service URL for OAuth and API
|
||||
url: 'https://space.langbot.app'
|
||||
|
||||
@@ -38,8 +38,7 @@
|
||||
},
|
||||
"ai": {
|
||||
"runner": {
|
||||
"runner": "local-agent",
|
||||
"expire-time": 0
|
||||
"runner": "local-agent"
|
||||
},
|
||||
"local-agent": {
|
||||
"model": {
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 14 KiB |
File diff suppressed because it is too large
Load Diff
@@ -47,26 +47,6 @@ stages:
|
||||
label:
|
||||
en_US: Langflow API
|
||||
zh_Hans: Langflow API
|
||||
- name: expire-time
|
||||
label:
|
||||
en_US: Conversation expire time (seconds)
|
||||
zh_Hans: 单个对话过期时间(秒)
|
||||
description:
|
||||
en_US: >-
|
||||
Maximum idle time of a single conversation, measured from the last
|
||||
message/preprocess update time. When a new message arrives and the
|
||||
current conversation has been idle for longer than this value, the
|
||||
existing external conversation id is discarded and a new one is
|
||||
started automatically — the user does not need to trigger a reset
|
||||
manually. Set to 0 to disable expiry (conversations live until the
|
||||
user or another mechanism resets them).
|
||||
zh_Hans: >-
|
||||
单个对话的最长空闲时间,从最后一条消息/preprocess 更新时间开始计算。
|
||||
当新消息到达且当前对话空闲时间已超过该值时,旧的外部对话 ID 会被自动丢弃并开启新对话,
|
||||
用户无需手动重置。设置为 0 表示不限制过期时间(对话会一直保留,直到用户或其他机制主动重置)。
|
||||
type: integer
|
||||
required: true
|
||||
default: 0
|
||||
- name: local-agent
|
||||
label:
|
||||
en_US: Local Agent
|
||||
@@ -559,4 +539,4 @@ stages:
|
||||
zh_Hans: 可选的流程调整参数
|
||||
type: json
|
||||
required: false
|
||||
default: '{}'
|
||||
default: '{}'
|
||||
@@ -72,4 +72,4 @@ stages:
|
||||
- name: wait
|
||||
label:
|
||||
en_US: Wait
|
||||
zh_Hans: 等待
|
||||
zh_Hans: 等待
|
||||
@@ -1,106 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from importlib import import_module
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
|
||||
|
||||
def _preproc_module():
|
||||
# Import pipelinemgr first so pipeline stages are registered without tripping
|
||||
# the stage <-> core.app circular import during isolated test collection.
|
||||
import_module('langbot.pkg.pipeline.pipelinemgr')
|
||||
return import_module('langbot.pkg.pipeline.preproc.preproc')
|
||||
|
||||
|
||||
def _entities_module():
|
||||
return import_module('langbot.pkg.pipeline.entities')
|
||||
|
||||
|
||||
def _conversation(created_at: datetime, updated_at: datetime | None = None):
|
||||
prompt = Mock()
|
||||
prompt.messages = []
|
||||
prompt.copy = Mock(return_value=Mock(messages=[]))
|
||||
|
||||
return SimpleNamespace(
|
||||
uuid='existing-conversation-uuid',
|
||||
create_time=created_at,
|
||||
update_time=updated_at,
|
||||
prompt=prompt,
|
||||
messages=[],
|
||||
)
|
||||
|
||||
|
||||
def _prompt_preprocessing_context(default_prompt=None, prompt=None):
|
||||
ctx = Mock()
|
||||
ctx.event.default_prompt = default_prompt or []
|
||||
ctx.event.prompt = prompt or []
|
||||
return ctx
|
||||
|
||||
|
||||
async def _run_preprocessor(mock_app, sample_query, conversation):
|
||||
session = SimpleNamespace(launcher_type=sample_query.launcher_type, launcher_id=sample_query.launcher_id)
|
||||
mock_app.sess_mgr.get_session = AsyncMock(return_value=session)
|
||||
mock_app.sess_mgr.get_conversation = AsyncMock(return_value=conversation)
|
||||
mock_app.plugin_connector.emit_event = AsyncMock(return_value=_prompt_preprocessing_context())
|
||||
|
||||
sample_query.pipeline_config = {
|
||||
'ai': {
|
||||
'runner': {'runner': 'local-agent', 'expire-time': 60},
|
||||
'local-agent': {'model': {'primary': '', 'fallbacks': []}, 'prompt': []},
|
||||
},
|
||||
'trigger': {'misc': {'combine-quote-message': False}},
|
||||
'output': {'misc': {'exception-handling': 'show-hint'}},
|
||||
}
|
||||
|
||||
return await _preproc_module().PreProcessor(mock_app).process(sample_query, 'PreProcessor')
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_preprocessor_expires_conversation_from_last_update_time(mock_app, sample_query):
|
||||
conversation = _conversation(
|
||||
created_at=datetime.now() - timedelta(seconds=10),
|
||||
updated_at=datetime.now() - timedelta(seconds=120),
|
||||
)
|
||||
|
||||
result = await _run_preprocessor(mock_app, sample_query, conversation)
|
||||
|
||||
assert result.result_type == _entities_module().ResultType.CONTINUE
|
||||
assert conversation.uuid is None
|
||||
assert conversation.update_time > datetime.now() - timedelta(seconds=5)
|
||||
assert result.new_query.variables['conversation_id'] is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_preprocessor_keeps_conversation_when_last_update_is_not_expired(mock_app, sample_query):
|
||||
conversation = _conversation(
|
||||
created_at=datetime.now() - timedelta(seconds=120),
|
||||
updated_at=datetime.now() - timedelta(seconds=30),
|
||||
)
|
||||
|
||||
result = await _run_preprocessor(mock_app, sample_query, conversation)
|
||||
|
||||
assert result.result_type == _entities_module().ResultType.CONTINUE
|
||||
assert conversation.uuid == 'existing-conversation-uuid'
|
||||
assert conversation.update_time > datetime.now() - timedelta(seconds=5)
|
||||
assert result.new_query.variables['conversation_id'] == 'existing-conversation-uuid'
|
||||
|
||||
|
||||
def test_expire_time_metadata_lives_under_ai_runner_not_safety():
|
||||
metadata_dir = Path('src/langbot/templates/metadata/pipeline')
|
||||
|
||||
ai_meta = yaml.safe_load((metadata_dir / 'ai.yaml').read_text())
|
||||
safety_meta = yaml.safe_load((metadata_dir / 'safety.yaml').read_text())
|
||||
|
||||
ai_stage_names = [stage['name'] for stage in ai_meta['stages']]
|
||||
assert 'session-limit' not in ai_stage_names
|
||||
assert 'session-limit' not in [stage['name'] for stage in safety_meta['stages']]
|
||||
|
||||
runner_stage = next(stage for stage in ai_meta['stages'] if stage['name'] == 'runner')
|
||||
expire_time = next(item for item in runner_stage['config'] if item['name'] == 'expire-time')
|
||||
assert 'Conversation expire time' in expire_time['label']['en_US']
|
||||
assert 'Session validity' not in expire_time['label']['en_US']
|
||||
@@ -1 +1 @@
|
||||
|
||||
"""Provider requester tests"""
|
||||
633
tests/unit_tests/provider/test_litellmchat.py
Normal file
633
tests/unit_tests/provider/test_litellmchat.py
Normal file
@@ -0,0 +1,633 @@
|
||||
"""
|
||||
Tests for LiteLLMRequester - unified requester for chat, embedding, and rerank.
|
||||
|
||||
These tests verify:
|
||||
- Parameter building and LiteLLM API calls
|
||||
- Response processing and usage extraction
|
||||
- Error handling and exception translation
|
||||
- Model name building with provider prefix
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import Mock, AsyncMock, patch
|
||||
|
||||
import litellm
|
||||
|
||||
from langbot.pkg.provider.modelmgr.requesters import litellmchat
|
||||
from langbot.pkg.provider.modelmgr import errors
|
||||
|
||||
|
||||
class MockRuntimeModel:
|
||||
"""Mock RuntimeLLMModel for testing"""
|
||||
|
||||
def __init__(self, model_name: str = 'gpt-4o', api_key: str = 'test-key'):
|
||||
self.model_entity = Mock()
|
||||
self.model_entity.name = model_name
|
||||
self.model_entity.extra_args = {}
|
||||
self.provider = Mock()
|
||||
self.provider.token_mgr = Mock()
|
||||
self.provider.token_mgr.get_token = Mock(return_value=api_key)
|
||||
|
||||
|
||||
class MockRuntimeEmbeddingModel:
|
||||
"""Mock RuntimeEmbeddingModel for testing"""
|
||||
|
||||
def __init__(self, model_name: str = 'text-embedding-3-small', api_key: str = 'test-key'):
|
||||
self.model_entity = Mock()
|
||||
self.model_entity.name = model_name
|
||||
self.model_entity.extra_args = {}
|
||||
self.provider = Mock()
|
||||
self.provider.token_mgr = Mock()
|
||||
self.provider.token_mgr.get_token = Mock(return_value=api_key)
|
||||
|
||||
|
||||
class MockRuntimeRerankModel:
|
||||
"""Mock RuntimeRerankModel for testing"""
|
||||
|
||||
def __init__(self, model_name: str = 'cohere/rerank-english-v3.0', api_key: str = 'test-key'):
|
||||
self.model_entity = Mock()
|
||||
self.model_entity.name = model_name
|
||||
self.model_entity.extra_args = {}
|
||||
self.provider = Mock()
|
||||
self.provider.token_mgr = Mock()
|
||||
self.provider.token_mgr.get_token = Mock(return_value=api_key)
|
||||
|
||||
|
||||
class TestBuildLiteLLMModelName:
|
||||
"""Test _build_litellm_model_name method"""
|
||||
|
||||
def test_no_provider_prefix(self):
|
||||
"""Test model name without provider prefix"""
|
||||
requester = litellmchat.LiteLLMRequester(ap=Mock(), config={'custom_llm_provider': ''})
|
||||
result = requester._build_litellm_model_name('gpt-4o')
|
||||
assert result == 'gpt-4o'
|
||||
|
||||
def test_with_provider_prefix(self):
|
||||
"""Test model name with provider prefix"""
|
||||
requester = litellmchat.LiteLLMRequester(ap=Mock(), config={'custom_llm_provider': 'openai'})
|
||||
result = requester._build_litellm_model_name('gpt-4o')
|
||||
assert result == 'openai/gpt-4o'
|
||||
|
||||
def test_override_provider(self):
|
||||
"""Test override provider via parameter"""
|
||||
requester = litellmchat.LiteLLMRequester(ap=Mock(), config={'custom_llm_provider': 'openai'})
|
||||
result = requester._build_litellm_model_name('claude-3', custom_llm_provider='anthropic')
|
||||
assert result == 'anthropic/claude-3'
|
||||
|
||||
|
||||
class TestExtractUsage:
|
||||
"""Test _extract_usage method"""
|
||||
|
||||
def test_extract_usage_with_data(self):
|
||||
"""Test extraction with valid usage data"""
|
||||
requester = litellmchat.LiteLLMRequester(ap=Mock(), config={})
|
||||
|
||||
response = Mock()
|
||||
response.usage = Mock()
|
||||
response.usage.prompt_tokens = 100
|
||||
response.usage.completion_tokens = 50
|
||||
response.usage.total_tokens = 150
|
||||
|
||||
result = requester._extract_usage(response)
|
||||
|
||||
assert result['prompt_tokens'] == 100
|
||||
assert result['completion_tokens'] == 50
|
||||
assert result['total_tokens'] == 150
|
||||
|
||||
def test_extract_usage_with_zero_values(self):
|
||||
"""Test extraction when values are 0"""
|
||||
requester = litellmchat.LiteLLMRequester(ap=Mock(), config={})
|
||||
|
||||
response = Mock()
|
||||
response.usage = Mock()
|
||||
response.usage.prompt_tokens = 0
|
||||
response.usage.completion_tokens = 0
|
||||
response.usage.total_tokens = 0
|
||||
|
||||
result = requester._extract_usage(response)
|
||||
|
||||
assert result['prompt_tokens'] == 0
|
||||
assert result['completion_tokens'] == 0
|
||||
|
||||
|
||||
class TestProcessThinkingContent:
|
||||
"""Test _process_thinking_content method"""
|
||||
|
||||
def test_no_thinking_markers(self):
|
||||
"""Test content without thinking markers"""
|
||||
requester = litellmchat.LiteLLMRequester(ap=Mock(), config={})
|
||||
|
||||
result = requester._process_thinking_content('Hello world', None, remove_think=True)
|
||||
assert result == 'Hello world'
|
||||
|
||||
def test_remove_thinking_markers(self):
|
||||
"""Test removing thinking markers when remove_think=True"""
|
||||
requester = litellmchat.LiteLLMRequester(ap=Mock(), config={})
|
||||
|
||||
content = 'CRETIRE_REASONING_BEGINkLet me think...CRETIRE_REASONING_ENDk The answer is 42.'
|
||||
result = requester._process_thinking_content(content, None, remove_think=True)
|
||||
assert result == 'The answer is 42.'
|
||||
|
||||
def test_preserve_thinking_markers(self):
|
||||
"""Test preserving thinking markers when remove_think=False"""
|
||||
requester = litellmchat.LiteLLMRequester(ap=Mock(), config={})
|
||||
|
||||
content = 'CRETIRE_REASONING_BEGINkLet me think...CRETIRE_REASONING_ENDk The answer is 42.'
|
||||
result = requester._process_thinking_content(content, None, remove_think=False)
|
||||
assert 'CRETIRE_REASONING_BEGINk' in result
|
||||
assert 'The answer is 42.' in result
|
||||
|
||||
def test_empty_content(self):
|
||||
"""Test empty content"""
|
||||
requester = litellmchat.LiteLLMRequester(ap=Mock(), config={})
|
||||
|
||||
result = requester._process_thinking_content('', None, remove_think=True)
|
||||
assert result == ''
|
||||
|
||||
|
||||
class TestBuildCommonArgs:
|
||||
"""Test _build_common_args method"""
|
||||
|
||||
def test_build_args_with_all_params(self):
|
||||
"""Test building args with all config params"""
|
||||
requester = litellmchat.LiteLLMRequester(
|
||||
ap=Mock(),
|
||||
config={
|
||||
'base_url': 'https://api.openai.com/v1',
|
||||
'timeout': 60,
|
||||
'drop_params': True,
|
||||
'num_retries': 3,
|
||||
'api_version': '2024-01-01',
|
||||
},
|
||||
)
|
||||
|
||||
args = {}
|
||||
requester._build_common_args(args)
|
||||
|
||||
assert args['api_base'] == 'https://api.openai.com/v1'
|
||||
assert args['timeout'] == 60
|
||||
assert args['drop_params'] == True
|
||||
assert args['num_retries'] == 3
|
||||
assert args['api_version'] == '2024-01-01'
|
||||
|
||||
def test_build_args_without_retry_params(self):
|
||||
"""Test building args without retry params for embedding/rerank"""
|
||||
requester = litellmchat.LiteLLMRequester(
|
||||
ap=Mock(),
|
||||
config={
|
||||
'base_url': 'https://api.openai.com/v1',
|
||||
'timeout': 60,
|
||||
'num_retries': 3,
|
||||
},
|
||||
)
|
||||
|
||||
args = {}
|
||||
requester._build_common_args(args, include_retry_params=False)
|
||||
|
||||
assert args['api_base'] == 'https://api.openai.com/v1'
|
||||
assert args['timeout'] == 60
|
||||
assert 'num_retries' not in args
|
||||
|
||||
|
||||
class TestHandleLiteLLMError:
|
||||
"""Test _handle_litellm_error method"""
|
||||
|
||||
def test_bad_request_error(self):
|
||||
"""Test BadRequestError translation"""
|
||||
requester = litellmchat.LiteLLMRequester(ap=Mock(), config={})
|
||||
|
||||
# Create proper LiteLLM exception with required args
|
||||
error = litellm.BadRequestError(message='test error', model='gpt-4o', llm_provider='openai')
|
||||
|
||||
with pytest.raises(errors.RequesterError) as exc_info:
|
||||
requester._handle_litellm_error(error)
|
||||
|
||||
assert '请求参数错误' in str(exc_info.value)
|
||||
|
||||
def test_authentication_error(self):
|
||||
"""Test AuthenticationError translation"""
|
||||
requester = litellmchat.LiteLLMRequester(ap=Mock(), config={})
|
||||
|
||||
error = litellm.AuthenticationError(message='invalid key', model='gpt-4o', llm_provider='openai')
|
||||
|
||||
with pytest.raises(errors.RequesterError) as exc_info:
|
||||
requester._handle_litellm_error(error)
|
||||
|
||||
assert 'API key 无效' in str(exc_info.value)
|
||||
|
||||
def test_rate_limit_error(self):
|
||||
"""Test RateLimitError translation"""
|
||||
requester = litellmchat.LiteLLMRequester(ap=Mock(), config={})
|
||||
|
||||
error = litellm.RateLimitError(message='rate limited', model='gpt-4o', llm_provider='openai')
|
||||
|
||||
with pytest.raises(errors.RequesterError) as exc_info:
|
||||
requester._handle_litellm_error(error)
|
||||
|
||||
assert '请求过于频繁' in str(exc_info.value)
|
||||
|
||||
def test_timeout_error(self):
|
||||
"""Test Timeout translation"""
|
||||
requester = litellmchat.LiteLLMRequester(ap=Mock(), config={})
|
||||
|
||||
error = litellm.Timeout(message='timeout', model='gpt-4o', llm_provider='openai')
|
||||
|
||||
with pytest.raises(errors.RequesterError) as exc_info:
|
||||
requester._handle_litellm_error(error)
|
||||
|
||||
assert '请求超时' in str(exc_info.value)
|
||||
|
||||
def test_context_window_error(self):
|
||||
"""Test ContextWindowExceededError translation"""
|
||||
requester = litellmchat.LiteLLMRequester(ap=Mock(), config={})
|
||||
|
||||
error = litellm.ContextWindowExceededError(message='context too long', model='gpt-4o', llm_provider='openai')
|
||||
|
||||
with pytest.raises(errors.RequesterError) as exc_info:
|
||||
requester._handle_litellm_error(error)
|
||||
|
||||
assert '上下文长度超限' in str(exc_info.value)
|
||||
|
||||
def test_unknown_error(self):
|
||||
"""Test unknown error translation"""
|
||||
requester = litellmchat.LiteLLMRequester(ap=Mock(), config={})
|
||||
|
||||
with pytest.raises(errors.RequesterError) as exc_info:
|
||||
requester._handle_litellm_error(Exception('unknown'))
|
||||
|
||||
assert '未知错误' in str(exc_info.value)
|
||||
|
||||
|
||||
class TestInvokeLLM:
|
||||
"""Test invoke_llm method"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invoke_llm_basic(self):
|
||||
"""Test basic LLM invocation"""
|
||||
mock_ap = Mock()
|
||||
mock_ap.tool_mgr = Mock()
|
||||
mock_ap.tool_mgr.generate_tools_for_openai = AsyncMock(return_value=None)
|
||||
|
||||
requester = litellmchat.LiteLLMRequester(
|
||||
ap=mock_ap,
|
||||
config={
|
||||
'base_url': 'https://api.openai.com/v1',
|
||||
'timeout': 60,
|
||||
},
|
||||
)
|
||||
|
||||
model = MockRuntimeModel('gpt-4o', 'test-api-key')
|
||||
|
||||
# Mock LiteLLM response
|
||||
mock_response = Mock()
|
||||
mock_response.choices = [Mock()]
|
||||
mock_response.choices[0].message = Mock()
|
||||
mock_response.choices[0].message.model_dump = Mock(
|
||||
return_value={
|
||||
'role': 'assistant',
|
||||
'content': 'Hello! How can I help you?',
|
||||
}
|
||||
)
|
||||
mock_response.usage = Mock()
|
||||
mock_response.usage.prompt_tokens = 10
|
||||
mock_response.usage.completion_tokens = 20
|
||||
mock_response.usage.total_tokens = 30
|
||||
|
||||
import langbot_plugin.api.entities.builtin.provider.message as provider_message
|
||||
|
||||
messages = [provider_message.Message(role='user', content='Hello')]
|
||||
|
||||
# Patch acompletion at the import location
|
||||
with patch.object(litellmchat, 'acompletion', new_callable=AsyncMock, return_value=mock_response):
|
||||
result_msg, usage = await requester.invoke_llm(
|
||||
query=None,
|
||||
model=model,
|
||||
messages=messages,
|
||||
)
|
||||
|
||||
assert result_msg.role == 'assistant'
|
||||
assert result_msg.content == 'Hello! How can I help you?'
|
||||
assert usage['prompt_tokens'] == 10
|
||||
assert usage['completion_tokens'] == 20
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invoke_llm_with_tools(self):
|
||||
"""Test LLM invocation with function calling"""
|
||||
mock_ap = Mock()
|
||||
mock_ap.tool_mgr = Mock()
|
||||
mock_ap.tool_mgr.generate_tools_for_openai = AsyncMock(
|
||||
return_value=[{'type': 'function', 'function': {'name': 'get_weather'}}]
|
||||
)
|
||||
|
||||
requester = litellmchat.LiteLLMRequester(ap=mock_ap, config={})
|
||||
|
||||
model = MockRuntimeModel('gpt-4o', 'test-api-key')
|
||||
|
||||
mock_response = Mock()
|
||||
mock_response.choices = [Mock()]
|
||||
mock_response.choices[0].message = Mock()
|
||||
mock_response.choices[0].message.model_dump = Mock(
|
||||
return_value={
|
||||
'role': 'assistant',
|
||||
'content': None,
|
||||
'tool_calls': [
|
||||
{'id': 'call_123', 'type': 'function', 'function': {'name': 'get_weather', 'arguments': '{}'}}
|
||||
],
|
||||
}
|
||||
)
|
||||
mock_response.usage = Mock()
|
||||
mock_response.usage.prompt_tokens = 15
|
||||
mock_response.usage.completion_tokens = 10
|
||||
mock_response.usage.total_tokens = 25
|
||||
|
||||
import langbot_plugin.api.entities.builtin.resource.tool as resource_tool
|
||||
import langbot_plugin.api.entities.builtin.provider.message as provider_message
|
||||
|
||||
messages = [provider_message.Message(role='user', content='What is the weather?')]
|
||||
# Create proper LLMTool with all required fields
|
||||
funcs = [Mock(spec=resource_tool.LLMTool)]
|
||||
funcs[0].name = 'get_weather'
|
||||
funcs[0].description = 'Get weather'
|
||||
|
||||
with patch.object(litellmchat, 'acompletion', new_callable=AsyncMock, return_value=mock_response):
|
||||
result_msg, usage = await requester.invoke_llm(
|
||||
query=None,
|
||||
model=model,
|
||||
messages=messages,
|
||||
funcs=funcs,
|
||||
)
|
||||
|
||||
assert result_msg.tool_calls is not None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invoke_llm_error_handling(self):
|
||||
"""Test LLM invocation error handling"""
|
||||
mock_ap = Mock()
|
||||
mock_ap.tool_mgr = Mock()
|
||||
mock_ap.tool_mgr.generate_tools_for_openai = AsyncMock(return_value=None)
|
||||
|
||||
requester = litellmchat.LiteLLMRequester(ap=mock_ap, config={})
|
||||
|
||||
model = MockRuntimeModel('gpt-4o', 'test-api-key')
|
||||
|
||||
import langbot_plugin.api.entities.builtin.provider.message as provider_message
|
||||
|
||||
messages = [provider_message.Message(role='user', content='Hello')]
|
||||
|
||||
error = litellm.AuthenticationError(message='invalid key', model='gpt-4o', llm_provider='openai')
|
||||
|
||||
with patch.object(litellmchat, 'acompletion', new_callable=AsyncMock, side_effect=error):
|
||||
with pytest.raises(errors.RequesterError) as exc_info:
|
||||
await requester.invoke_llm(
|
||||
query=None,
|
||||
model=model,
|
||||
messages=messages,
|
||||
)
|
||||
|
||||
assert 'API key 无效' in str(exc_info.value)
|
||||
|
||||
|
||||
class TestInvokeEmbedding:
|
||||
"""Test invoke_embedding method"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invoke_embedding_basic(self):
|
||||
"""Test basic embedding invocation"""
|
||||
requester = litellmchat.LiteLLMRequester(
|
||||
ap=Mock(),
|
||||
config={
|
||||
'base_url': 'https://api.openai.com/v1',
|
||||
},
|
||||
)
|
||||
|
||||
model = MockRuntimeEmbeddingModel('text-embedding-3-small', 'test-api-key')
|
||||
|
||||
# Mock LiteLLM embedding response
|
||||
mock_response = Mock()
|
||||
mock_response.data = [
|
||||
Mock(embedding=[0.1, 0.2, 0.3]),
|
||||
Mock(embedding=[0.4, 0.5, 0.6]),
|
||||
]
|
||||
mock_response.usage = Mock()
|
||||
mock_response.usage.prompt_tokens = 20
|
||||
mock_response.usage.completion_tokens = 0
|
||||
mock_response.usage.total_tokens = 20
|
||||
|
||||
with patch.object(litellmchat, 'aembedding', new_callable=AsyncMock, return_value=mock_response):
|
||||
embeddings, usage = await requester.invoke_embedding(
|
||||
model=model,
|
||||
input_text=['Hello', 'World'],
|
||||
)
|
||||
|
||||
assert len(embeddings) == 2
|
||||
assert embeddings[0] == [0.1, 0.2, 0.3]
|
||||
assert embeddings[1] == [0.4, 0.5, 0.6]
|
||||
assert usage['prompt_tokens'] == 20
|
||||
|
||||
|
||||
class TestInvokeRerank:
|
||||
"""Test invoke_rerank method"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invoke_rerank_basic(self):
|
||||
"""Test basic rerank invocation"""
|
||||
requester = litellmchat.LiteLLMRequester(
|
||||
ap=Mock(),
|
||||
config={
|
||||
'base_url': 'https://api.cohere.ai',
|
||||
},
|
||||
)
|
||||
|
||||
model = MockRuntimeRerankModel('rerank-english-v3.0', 'test-api-key')
|
||||
|
||||
# Mock LiteLLM rerank response
|
||||
mock_response = Mock()
|
||||
mock_response.results = [
|
||||
{'index': 0, 'relevance_score': 0.95},
|
||||
{'index': 1, 'relevance_score': 0.3},
|
||||
{'index': 2, 'relevance_score': 0.8},
|
||||
]
|
||||
|
||||
with patch.object(litellmchat, 'arerank', new_callable=AsyncMock, return_value=mock_response):
|
||||
results = await requester.invoke_rerank(
|
||||
model=model,
|
||||
query='What is the capital of France?',
|
||||
documents=['Paris is the capital.', 'London is a city.', 'France is in Europe.'],
|
||||
)
|
||||
|
||||
assert len(results) == 3
|
||||
# Scores should be normalized
|
||||
assert results[0]['index'] == 0
|
||||
assert results[0]['relevance_score'] >= 0 and results[0]['relevance_score'] <= 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invoke_rerank_normalization(self):
|
||||
"""Test rerank score normalization"""
|
||||
requester = litellmchat.LiteLLMRequester(ap=Mock(), config={})
|
||||
|
||||
model = MockRuntimeRerankModel('rerank-english-v3.0', 'test-api-key')
|
||||
|
||||
# Mock response with varying scores
|
||||
mock_response = Mock()
|
||||
mock_response.results = [
|
||||
{'index': 0, 'relevance_score': 0.9},
|
||||
{'index': 1, 'relevance_score': 0.1},
|
||||
]
|
||||
|
||||
with patch.object(litellmchat, 'arerank', new_callable=AsyncMock, return_value=mock_response):
|
||||
results = await requester.invoke_rerank(
|
||||
model=model,
|
||||
query='test query',
|
||||
documents=['doc1', 'doc2'],
|
||||
)
|
||||
|
||||
# After normalization: 0.9 -> 1.0, 0.1 -> 0.0
|
||||
assert results[0]['relevance_score'] == 1.0
|
||||
assert results[1]['relevance_score'] == 0.0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_invoke_rerank_single_document(self):
|
||||
"""Test rerank with single document (no normalization needed)"""
|
||||
requester = litellmchat.LiteLLMRequester(ap=Mock(), config={})
|
||||
|
||||
model = MockRuntimeRerankModel('rerank-english-v3.0', 'test-api-key')
|
||||
|
||||
mock_response = Mock()
|
||||
mock_response.results = [
|
||||
{'index': 0, 'relevance_score': 0.5},
|
||||
]
|
||||
|
||||
with patch.object(litellmchat, 'arerank', new_callable=AsyncMock, return_value=mock_response):
|
||||
results = await requester.invoke_rerank(
|
||||
model=model,
|
||||
query='test query',
|
||||
documents=['doc1'],
|
||||
)
|
||||
|
||||
assert len(results) == 1
|
||||
# Single score stays as is (min==max, no normalization)
|
||||
assert results[0]['relevance_score'] == 0.5
|
||||
|
||||
|
||||
class TestConvertMessages:
|
||||
"""Test _convert_messages method"""
|
||||
|
||||
def test_convert_simple_message(self):
|
||||
"""Test converting simple text message"""
|
||||
requester = litellmchat.LiteLLMRequester(ap=Mock(), config={})
|
||||
|
||||
import langbot_plugin.api.entities.builtin.provider.message as provider_message
|
||||
|
||||
messages = [provider_message.Message(role='user', content='Hello')]
|
||||
result = requester._convert_messages(messages)
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0]['role'] == 'user'
|
||||
assert result[0]['content'] == 'Hello'
|
||||
|
||||
def test_convert_message_with_image_base64(self):
|
||||
"""Test converting message with image_base64 content"""
|
||||
requester = litellmchat.LiteLLMRequester(ap=Mock(), config={})
|
||||
|
||||
import langbot_plugin.api.entities.builtin.provider.message as provider_message
|
||||
|
||||
messages = [
|
||||
provider_message.Message(
|
||||
role='user',
|
||||
content=[
|
||||
{'type': 'text', 'text': 'What is in this image?'},
|
||||
{'type': 'image_base64', 'image_base64': 'data:image/png;base64,abc123'},
|
||||
],
|
||||
)
|
||||
]
|
||||
result = requester._convert_messages(messages)
|
||||
|
||||
assert len(result) == 1
|
||||
content = result[0]['content']
|
||||
assert isinstance(content, list)
|
||||
# Check image_base64 converted to image_url
|
||||
image_part = [p for p in content if p.get('type') == 'image_url'][0]
|
||||
assert 'image_url' in image_part
|
||||
assert image_part['image_url']['url'] == 'data:image/png;base64,abc123'
|
||||
|
||||
def test_convert_message_with_multiple_text_parts(self):
|
||||
"""Test converting message with multiple text parts (LiteLLM handles this)"""
|
||||
requester = litellmchat.LiteLLMRequester(ap=Mock(), config={})
|
||||
|
||||
import langbot_plugin.api.entities.builtin.provider.message as provider_message
|
||||
|
||||
messages = [
|
||||
provider_message.Message(
|
||||
role='user',
|
||||
content=[
|
||||
{'type': 'text', 'text': 'Hello'},
|
||||
{'type': 'text', 'text': 'World'},
|
||||
],
|
||||
)
|
||||
]
|
||||
result = requester._convert_messages(messages)
|
||||
|
||||
assert len(result) == 1
|
||||
# LiteLLM handles multiple text parts, we pass them through
|
||||
assert isinstance(result[0]['content'], list)
|
||||
|
||||
|
||||
class TestScanModels:
|
||||
"""Test scan_models method"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_scan_models_basic(self):
|
||||
"""Test basic model scanning"""
|
||||
requester = litellmchat.LiteLLMRequester(
|
||||
ap=Mock(),
|
||||
config={
|
||||
'base_url': 'https://api.openai.com/v1',
|
||||
'timeout': 60,
|
||||
},
|
||||
)
|
||||
|
||||
# Mock httpx response
|
||||
mock_response = Mock()
|
||||
mock_response.json = Mock(
|
||||
return_value={
|
||||
'data': [
|
||||
{'id': 'gpt-4o'},
|
||||
{'id': 'text-embedding-3-small'},
|
||||
{'id': 'gpt-3.5-turbo'},
|
||||
]
|
||||
}
|
||||
)
|
||||
mock_response.raise_for_status = Mock()
|
||||
|
||||
with patch('httpx.AsyncClient') as mock_client:
|
||||
mock_client.return_value.__aenter__ = AsyncMock(return_value=Mock())
|
||||
mock_client.return_value.__aenter__.return_value.get = AsyncMock(return_value=mock_response)
|
||||
|
||||
result = await requester.scan_models(api_key='test-key')
|
||||
|
||||
assert 'models' in result
|
||||
assert len(result['models']) == 3
|
||||
# Check LLM models are first
|
||||
assert result['models'][0]['type'] == 'llm'
|
||||
# Check embedding model is detected
|
||||
embedding_models = [m for m in result['models'] if m['type'] == 'embedding']
|
||||
assert len(embedding_models) == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_scan_models_no_base_url(self):
|
||||
"""Test scan_models without base_url raises error"""
|
||||
requester = litellmchat.LiteLLMRequester(
|
||||
ap=Mock(),
|
||||
config={
|
||||
'base_url': '',
|
||||
},
|
||||
)
|
||||
|
||||
with pytest.raises(errors.RequesterError) as exc_info:
|
||||
await requester.scan_models()
|
||||
|
||||
assert 'Base URL required' in str(exc_info.value)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
pytest.main([__file__, '-v'])
|
||||
@@ -1,173 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
import pytest
|
||||
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
|
||||
import langbot_plugin.api.entities.builtin.platform.entities as platform_entities
|
||||
import langbot_plugin.api.entities.builtin.platform.events as platform_events
|
||||
import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
||||
import langbot_plugin.api.entities.builtin.provider.session as provider_session
|
||||
|
||||
from langbot.pkg.api.http.service.model import _runtime_model_data
|
||||
from langbot.pkg.entity.persistence import model as persistence_model
|
||||
from langbot.pkg.pipeline.preproc.preproc import PreProcessor
|
||||
from langbot.pkg.provider.modelmgr import requester
|
||||
from langbot.pkg.provider.modelmgr.modelmgr import ModelManager
|
||||
from langbot.pkg.provider.runners.localagent import LocalAgentRunner
|
||||
|
||||
|
||||
def test_runtime_llm_model_data_preserves_uuid_after_update_payload_uuid_removed():
|
||||
update_payload = {
|
||||
'name': 'Qwen3.5-27B',
|
||||
'provider_uuid': 'provider-uuid',
|
||||
'abilities': [],
|
||||
'extra_args': {},
|
||||
}
|
||||
|
||||
runtime_entity = persistence_model.LLMModel(**_runtime_model_data('model-uuid', update_payload))
|
||||
|
||||
assert runtime_entity.uuid == 'model-uuid'
|
||||
assert runtime_entity.name == 'Qwen3.5-27B'
|
||||
|
||||
|
||||
def test_runtime_embedding_model_data_preserves_uuid_after_update_payload_uuid_removed():
|
||||
update_payload = {
|
||||
'name': 'embedding-model',
|
||||
'provider_uuid': 'provider-uuid',
|
||||
'extra_args': {},
|
||||
}
|
||||
|
||||
runtime_entity = persistence_model.EmbeddingModel(**_runtime_model_data('embedding-uuid', update_payload))
|
||||
|
||||
assert runtime_entity.uuid == 'embedding-uuid'
|
||||
assert runtime_entity.name == 'embedding-model'
|
||||
|
||||
|
||||
def test_runtime_rerank_model_data_preserves_uuid_after_update_payload_uuid_removed():
|
||||
update_payload = {
|
||||
'name': 'rerank-model',
|
||||
'provider_uuid': 'provider-uuid',
|
||||
'extra_args': {},
|
||||
}
|
||||
|
||||
runtime_entity = persistence_model.RerankModel(**_runtime_model_data('rerank-uuid', update_payload))
|
||||
|
||||
assert runtime_entity.uuid == 'rerank-uuid'
|
||||
assert runtime_entity.name == 'rerank-model'
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_updated_llm_model_is_immediately_usable_by_local_agent_pipeline():
|
||||
from langbot.pkg.api.http.service.model import LLMModelsService
|
||||
|
||||
model_uuid = 'qwen-model-uuid'
|
||||
provider_uuid = 'ollama-provider-uuid'
|
||||
|
||||
ap = SimpleNamespace()
|
||||
ap.logger = Mock()
|
||||
ap.persistence_mgr = SimpleNamespace(execute_async=AsyncMock())
|
||||
ap.tool_mgr = SimpleNamespace(get_all_tools=AsyncMock(return_value=[]))
|
||||
ap.plugin_connector = SimpleNamespace(
|
||||
emit_event=AsyncMock(return_value=SimpleNamespace(event=SimpleNamespace(default_prompt=[], prompt=[])))
|
||||
)
|
||||
|
||||
ap.model_mgr = ModelManager(ap)
|
||||
runtime_provider = Mock()
|
||||
ap.model_mgr.provider_dict = {provider_uuid: runtime_provider}
|
||||
ap.model_mgr.llm_models = [
|
||||
requester.RuntimeLLMModel(
|
||||
model_entity=persistence_model.LLMModel(
|
||||
uuid=model_uuid,
|
||||
name='old-qwen-name',
|
||||
provider_uuid=provider_uuid,
|
||||
abilities=[],
|
||||
extra_args={},
|
||||
),
|
||||
provider=runtime_provider,
|
||||
)
|
||||
]
|
||||
|
||||
await LLMModelsService(ap).update_llm_model(
|
||||
model_uuid,
|
||||
{
|
||||
'name': 'Qwen3.5-27B',
|
||||
'provider_uuid': provider_uuid,
|
||||
'abilities': [],
|
||||
'extra_args': {},
|
||||
},
|
||||
)
|
||||
|
||||
runtime_model = await ap.model_mgr.get_model_by_uuid(model_uuid)
|
||||
assert runtime_model.model_entity.uuid == model_uuid
|
||||
assert runtime_model.model_entity.name == 'Qwen3.5-27B'
|
||||
|
||||
session = SimpleNamespace(
|
||||
launcher_type=provider_session.LauncherTypes.PERSON,
|
||||
launcher_id=12345,
|
||||
)
|
||||
conversation = SimpleNamespace(
|
||||
uuid='conversation-uuid',
|
||||
create_time=None,
|
||||
update_time=None,
|
||||
prompt=SimpleNamespace(messages=[], copy=Mock(return_value=SimpleNamespace(messages=[]))),
|
||||
messages=[],
|
||||
)
|
||||
ap.sess_mgr = SimpleNamespace(
|
||||
get_session=AsyncMock(return_value=session),
|
||||
get_conversation=AsyncMock(return_value=conversation),
|
||||
)
|
||||
|
||||
message_chain = platform_message.MessageChain([platform_message.Plain(text='hello')])
|
||||
sender = platform_entities.Friend(id=12345, nickname='Tester', remark=None)
|
||||
message_event = platform_events.FriendMessage(
|
||||
type='FriendMessage',
|
||||
sender=sender,
|
||||
message_chain=message_chain,
|
||||
time=1710000000,
|
||||
)
|
||||
pipeline_config = {
|
||||
'ai': {
|
||||
'runner': {'runner': 'local-agent'},
|
||||
'local-agent': {
|
||||
'model': {'primary': model_uuid, 'fallbacks': []},
|
||||
'prompt': [],
|
||||
'knowledge-bases': [],
|
||||
},
|
||||
},
|
||||
'trigger': {'misc': {'combine-quote-message': False}},
|
||||
'output': {'misc': {'remove-think': False}},
|
||||
}
|
||||
query = pipeline_query.Query.model_construct(
|
||||
query_id='query-id',
|
||||
launcher_type=provider_session.LauncherTypes.PERSON,
|
||||
launcher_id=12345,
|
||||
sender_id=12345,
|
||||
message_chain=message_chain,
|
||||
message_event=message_event,
|
||||
adapter=AsyncMock(),
|
||||
pipeline_uuid='pipeline-uuid',
|
||||
bot_uuid='bot-uuid',
|
||||
pipeline_config=pipeline_config,
|
||||
session=None,
|
||||
prompt=None,
|
||||
messages=[],
|
||||
user_message=None,
|
||||
use_funcs=[],
|
||||
use_llm_model_uuid=None,
|
||||
variables={},
|
||||
resp_messages=[],
|
||||
resp_message_chain=None,
|
||||
current_stage_name=None,
|
||||
)
|
||||
|
||||
result = await PreProcessor(ap).process(query, 'PreProcessor')
|
||||
processed_query = result.new_query
|
||||
|
||||
assert processed_query.use_llm_model_uuid == model_uuid
|
||||
|
||||
runner = SimpleNamespace(ap=ap, pipeline_config=pipeline_config)
|
||||
candidates = await LocalAgentRunner._get_model_candidates(runner, processed_query)
|
||||
|
||||
assert [model.model_entity.uuid for model in candidates] == [model_uuid]
|
||||
@@ -1,181 +0,0 @@
|
||||
"""
|
||||
PoC test for CWE-22 path traversal in LocalStorageProvider.
|
||||
|
||||
The LocalStorageProvider uses os.path.join(LOCAL_STORAGE_PATH, key) without
|
||||
validating that the resulting path stays inside LOCAL_STORAGE_PATH.
|
||||
|
||||
When `key` is an absolute path (e.g. '/etc/passwd'), os.path.join discards
|
||||
all previous components and returns the absolute path directly, allowing
|
||||
arbitrary file reads, writes, and deletes.
|
||||
|
||||
This test must FAIL before the fix and PASS after.
|
||||
"""
|
||||
|
||||
import os
|
||||
import pytest
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from langbot.pkg.storage.providers.localstorage import LocalStorageProvider
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def storage_provider(tmp_path):
|
||||
"""Create a LocalStorageProvider with a temporary storage path."""
|
||||
storage_path = str(tmp_path / "storage")
|
||||
with patch("langbot.pkg.storage.providers.localstorage.LOCAL_STORAGE_PATH", storage_path):
|
||||
mock_app = Mock()
|
||||
provider = LocalStorageProvider(mock_app)
|
||||
yield provider, storage_path
|
||||
|
||||
|
||||
class TestPathTraversalPrevention:
|
||||
"""Test that LocalStorageProvider rejects path traversal attempts."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_absolute_path_save_rejected(self, storage_provider, tmp_path):
|
||||
"""Saving with an absolute path key must be blocked."""
|
||||
provider, storage_path = storage_provider
|
||||
target_file = str(tmp_path / "pwned.txt")
|
||||
|
||||
with patch("langbot.pkg.storage.providers.localstorage.LOCAL_STORAGE_PATH", storage_path):
|
||||
with pytest.raises((ValueError, PermissionError)):
|
||||
await provider.save(target_file, b"malicious content")
|
||||
|
||||
# The file must NOT exist outside the storage directory
|
||||
assert not os.path.exists(target_file), (
|
||||
f"Path traversal succeeded: file was written outside storage to {target_file}"
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_absolute_path_load_rejected(self, storage_provider, tmp_path):
|
||||
"""Loading with an absolute path key must be blocked."""
|
||||
provider, storage_path = storage_provider
|
||||
|
||||
# Create a file outside the storage directory
|
||||
target_file = str(tmp_path / "secret.txt")
|
||||
with open(target_file, "wb") as f:
|
||||
f.write(b"secret data")
|
||||
|
||||
with patch("langbot.pkg.storage.providers.localstorage.LOCAL_STORAGE_PATH", storage_path):
|
||||
with pytest.raises((ValueError, PermissionError, FileNotFoundError)):
|
||||
data = await provider.load(target_file)
|
||||
assert data != b"secret data", (
|
||||
"Path traversal succeeded: read file outside storage"
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_absolute_path_exists_rejected(self, storage_provider, tmp_path):
|
||||
"""Exists check with an absolute path key must be blocked or return False."""
|
||||
provider, storage_path = storage_provider
|
||||
|
||||
target_file = str(tmp_path / "check_me.txt")
|
||||
with open(target_file, "wb") as f:
|
||||
f.write(b"data")
|
||||
|
||||
with patch("langbot.pkg.storage.providers.localstorage.LOCAL_STORAGE_PATH", storage_path):
|
||||
try:
|
||||
result = await provider.exists(target_file)
|
||||
assert result is False, (
|
||||
"Path traversal succeeded: exists() returned True for file outside storage"
|
||||
)
|
||||
except (ValueError, PermissionError):
|
||||
pass # Expected
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_absolute_path_delete_rejected(self, storage_provider, tmp_path):
|
||||
"""Deleting with an absolute path key must be blocked."""
|
||||
provider, storage_path = storage_provider
|
||||
|
||||
target_file = str(tmp_path / "do_not_delete.txt")
|
||||
with open(target_file, "wb") as f:
|
||||
f.write(b"important data")
|
||||
|
||||
with patch("langbot.pkg.storage.providers.localstorage.LOCAL_STORAGE_PATH", storage_path):
|
||||
with pytest.raises((ValueError, PermissionError, FileNotFoundError)):
|
||||
await provider.delete(target_file)
|
||||
|
||||
assert os.path.exists(target_file), (
|
||||
"Path traversal succeeded: file outside storage was deleted"
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_absolute_path_size_rejected(self, storage_provider, tmp_path):
|
||||
"""Size check with an absolute path key must be blocked."""
|
||||
provider, storage_path = storage_provider
|
||||
|
||||
target_file = str(tmp_path / "measure_me.txt")
|
||||
with open(target_file, "wb") as f:
|
||||
f.write(b"some data")
|
||||
|
||||
with patch("langbot.pkg.storage.providers.localstorage.LOCAL_STORAGE_PATH", storage_path):
|
||||
with pytest.raises((ValueError, PermissionError, FileNotFoundError)):
|
||||
await provider.size(target_file)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_dot_dot_path_traversal_rejected(self, storage_provider, tmp_path):
|
||||
"""Relative path traversal with '..' must be blocked."""
|
||||
provider, storage_path = storage_provider
|
||||
|
||||
target_file = str(tmp_path / "above_storage.txt")
|
||||
with open(target_file, "wb") as f:
|
||||
f.write(b"above storage secret")
|
||||
|
||||
with patch("langbot.pkg.storage.providers.localstorage.LOCAL_STORAGE_PATH", storage_path):
|
||||
relative_key = os.path.join("..", "above_storage.txt")
|
||||
with pytest.raises((ValueError, PermissionError, FileNotFoundError)):
|
||||
data = await provider.load(relative_key)
|
||||
assert data != b"above storage secret"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_dir_recursive_traversal_rejected(self, storage_provider, tmp_path):
|
||||
"""delete_dir_recursive with traversal path must be blocked."""
|
||||
provider, storage_path = storage_provider
|
||||
|
||||
outside_dir = tmp_path / "outside_dir"
|
||||
outside_dir.mkdir()
|
||||
(outside_dir / "file.txt").write_text("important")
|
||||
|
||||
with patch("langbot.pkg.storage.providers.localstorage.LOCAL_STORAGE_PATH", storage_path):
|
||||
with pytest.raises((ValueError, PermissionError)):
|
||||
await provider.delete_dir_recursive(str(outside_dir))
|
||||
|
||||
assert outside_dir.exists(), (
|
||||
"Path traversal succeeded: directory outside storage was deleted"
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_legitimate_key_works(self, storage_provider):
|
||||
"""Normal keys without traversal must still work."""
|
||||
provider, storage_path = storage_provider
|
||||
|
||||
with patch("langbot.pkg.storage.providers.localstorage.LOCAL_STORAGE_PATH", storage_path):
|
||||
key = "test_image_abc123.png"
|
||||
content = b"PNG image data"
|
||||
|
||||
await provider.save(key, content)
|
||||
assert await provider.exists(key) is True
|
||||
loaded = await provider.load(key)
|
||||
assert loaded == content
|
||||
size = await provider.size(key)
|
||||
assert size == len(content)
|
||||
await provider.delete(key)
|
||||
assert await provider.exists(key) is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_legitimate_subdirectory_key_works(self, storage_provider):
|
||||
"""Keys with legitimate subdirectories must still work."""
|
||||
provider, storage_path = storage_provider
|
||||
|
||||
with patch("langbot.pkg.storage.providers.localstorage.LOCAL_STORAGE_PATH", storage_path):
|
||||
key = "bot_log_images/img_001.png"
|
||||
content = b"PNG image data"
|
||||
|
||||
await provider.save(key, content)
|
||||
assert await provider.exists(key) is True
|
||||
loaded = await provider.load(key)
|
||||
assert loaded == content
|
||||
await provider.delete(key)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
@@ -174,14 +174,11 @@ export default function BotDetailContent({ id }: { id: string }) {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
type="submit"
|
||||
form="bot-form"
|
||||
disabled={!formDirty}
|
||||
className={activeTab !== 'config' ? 'invisible' : ''}
|
||||
>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
{activeTab === 'config' && (
|
||||
<Button type="submit" form="bot-form" disabled={!formDirty}>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Horizontal Tabs */}
|
||||
|
||||
@@ -618,8 +618,6 @@ export default function BotForm({
|
||||
systemContext={{
|
||||
webhook_url: webhookUrl,
|
||||
extra_webhook_url: extraWebhookUrl,
|
||||
bot_uuid: initBotId || '',
|
||||
adapter_config: form.getValues('adapter_config') || {},
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -6,7 +6,6 @@ import { useTranslation } from 'react-i18next';
|
||||
import { Check, ChevronDown, ChevronRight, Copy } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { copyToClipboard } from '@/app/utils/clipboard';
|
||||
|
||||
const LEVEL_STYLES: Record<string, string> = {
|
||||
error: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400',
|
||||
@@ -32,19 +31,36 @@ export function BotLogCard({
|
||||
|
||||
function copySessionId() {
|
||||
const text = botLog.message_session_id;
|
||||
copyToClipboard(text)
|
||||
.then((ok) => {
|
||||
if (ok) {
|
||||
if (navigator.clipboard?.writeText) {
|
||||
navigator.clipboard
|
||||
.writeText(text)
|
||||
.then(() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
toast.success(t('common.copySuccess'));
|
||||
} else {
|
||||
toast.error(t('common.copyFailed'));
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
toast.error(t('common.copyFailed'));
|
||||
});
|
||||
})
|
||||
.catch(() => fallbackCopy(text));
|
||||
} else {
|
||||
fallbackCopy(text);
|
||||
}
|
||||
}
|
||||
|
||||
function fallbackCopy(text: string) {
|
||||
const ta = document.createElement('textarea');
|
||||
ta.value = text;
|
||||
ta.style.cssText = 'position:fixed;left:-9999px;top:-9999px';
|
||||
document.body.appendChild(ta);
|
||||
ta.focus();
|
||||
ta.select();
|
||||
try {
|
||||
document.execCommand('copy');
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
toast.success(t('common.copySuccess'));
|
||||
} catch {
|
||||
toast.error(t('common.copyFailed'));
|
||||
}
|
||||
document.body.removeChild(ta);
|
||||
}
|
||||
|
||||
function formatTime(timestamp: number) {
|
||||
|
||||
@@ -19,7 +19,6 @@ import {
|
||||
ThumbsUp,
|
||||
ThumbsDown,
|
||||
} from 'lucide-react';
|
||||
import { copyToClipboard } from '@/app/utils/clipboard';
|
||||
import {
|
||||
MessageChainComponent,
|
||||
Plain,
|
||||
@@ -109,9 +108,10 @@ const BotSessionMonitor = forwardRef<
|
||||
};
|
||||
|
||||
const copyUserId = (userId: string) => {
|
||||
copyToClipboard(userId).catch(() => {});
|
||||
setCopiedUserId(true);
|
||||
setTimeout(() => setCopiedUserId(false), 2000);
|
||||
navigator.clipboard.writeText(userId).then(() => {
|
||||
setCopiedUserId(true);
|
||||
setTimeout(() => setCopiedUserId(false), 2000);
|
||||
});
|
||||
};
|
||||
|
||||
const loadSessions = useCallback(async () => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import * as React from 'react';
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { toast } from 'sonner';
|
||||
import { Copy, Check, Trash2, Plus } from 'lucide-react';
|
||||
@@ -86,9 +86,6 @@ export default function ApiIntegrationDialog({
|
||||
const [newWebhookDescription, setNewWebhookDescription] = useState('');
|
||||
const [newWebhookEnabled, setNewWebhookEnabled] = useState(true);
|
||||
const [deleteWebhookId, setDeleteWebhookId] = useState<number | null>(null);
|
||||
const copiedTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(
|
||||
undefined,
|
||||
);
|
||||
const [copiedKey, setCopiedKey] = useState<string | null>(null);
|
||||
|
||||
// Sync URL with dialog state
|
||||
@@ -185,29 +182,10 @@ export default function ApiIntegrationDialog({
|
||||
}
|
||||
};
|
||||
|
||||
const copyToClipboard = (text: string) => {
|
||||
const el = document.createElement('span');
|
||||
el.textContent = text;
|
||||
el.style.cssText =
|
||||
'position:fixed;opacity:0;pointer-events:none;white-space:pre;';
|
||||
document.body.appendChild(el);
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(el);
|
||||
const sel = window.getSelection();
|
||||
sel?.removeAllRanges();
|
||||
sel?.addRange(range);
|
||||
document.execCommand('copy');
|
||||
sel?.removeAllRanges();
|
||||
document.body.removeChild(el);
|
||||
};
|
||||
|
||||
const handleCopyKey = (key: string) => {
|
||||
try {
|
||||
copyToClipboard(key);
|
||||
} catch {}
|
||||
clearTimeout(copiedTimerRef.current);
|
||||
navigator.clipboard.writeText(key);
|
||||
setCopiedKey(key);
|
||||
copiedTimerRef.current = setTimeout(() => setCopiedKey(null), 2000);
|
||||
setTimeout(() => setCopiedKey(null), 2000);
|
||||
};
|
||||
|
||||
const maskApiKey = (key: string) => {
|
||||
@@ -352,21 +330,21 @@ export default function ApiIntegrationDialog({
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{apiKeys.map((item) => (
|
||||
<TableRow key={item.id}>
|
||||
{apiKeys.map((key) => (
|
||||
<TableRow key={key.id}>
|
||||
<TableCell>
|
||||
<div>
|
||||
<div className="font-medium">{item.name}</div>
|
||||
{item.description && (
|
||||
<div className="font-medium">{key.name}</div>
|
||||
{key.description && (
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{item.description}
|
||||
{key.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<code className="text-sm bg-muted px-2 py-1 rounded">
|
||||
{maskApiKey(item.key)}
|
||||
{maskApiKey(key.key)}
|
||||
</code>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
@@ -374,11 +352,10 @@ export default function ApiIntegrationDialog({
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
type="button"
|
||||
onClick={() => handleCopyKey(item.key)}
|
||||
onClick={() => handleCopyKey(key.key)}
|
||||
title={t('common.copyApiKey')}
|
||||
>
|
||||
{copiedKey === item.key ? (
|
||||
{copiedKey === key.key ? (
|
||||
<Check className="h-4 w-4 text-green-600" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4" />
|
||||
@@ -387,7 +364,7 @@ export default function ApiIntegrationDialog({
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setDeleteKeyId(item.id)}
|
||||
onClick={() => setDeleteKeyId(key.id)}
|
||||
title={t('common.delete')}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
@@ -653,6 +630,44 @@ export default function ApiIntegrationDialog({
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Delete API Key Confirmation Dialog */}
|
||||
<Dialog open={!!createdKey} onOpenChange={() => setCreatedKey(null)}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('common.apiKeyCreated')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('common.apiKeyCreatedMessage')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium">
|
||||
{t('common.apiKeyValue')}
|
||||
</label>
|
||||
<div className="flex gap-2 mt-1">
|
||||
<Input value={createdKey?.key || ''} readOnly />
|
||||
<Button
|
||||
onClick={() => createdKey && handleCopyKey(createdKey.key)}
|
||||
variant="outline"
|
||||
size="icon"
|
||||
>
|
||||
{copiedKey === createdKey?.key ? (
|
||||
<Check className="h-4 w-4 text-green-600" />
|
||||
) : (
|
||||
<Copy className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button onClick={() => setCreatedKey(null)}>
|
||||
{t('common.close')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<AlertDialog open={!!deleteKeyId}>
|
||||
<AlertDialogPortal>
|
||||
|
||||
@@ -18,7 +18,6 @@ import { cn } from '@/lib/utils';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Copy, Check, Globe } from 'lucide-react';
|
||||
import { copyToClipboard } from '@/app/utils/clipboard';
|
||||
import { systemInfo } from '@/app/infra/http';
|
||||
|
||||
/**
|
||||
@@ -45,54 +44,6 @@ function resolveShowIfValue(
|
||||
return externalDependentValues?.[field];
|
||||
}
|
||||
|
||||
/**
|
||||
* Display-only component for embed code fields with copy animation.
|
||||
*/
|
||||
function EmbedCodeField({
|
||||
label,
|
||||
description,
|
||||
snippet,
|
||||
}: {
|
||||
label: string;
|
||||
description?: string;
|
||||
snippet: string;
|
||||
}) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const handleCopy = () => {
|
||||
copyToClipboard(snippet).catch(() => {});
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium leading-none">{label}</label>
|
||||
{description && (
|
||||
<p className="text-sm text-muted-foreground">{description}</p>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<pre className="flex-1 overflow-x-auto rounded-md bg-muted p-3 text-sm font-mono select-all">
|
||||
<code>{snippet}</code>
|
||||
</pre>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
className="shrink-0"
|
||||
onClick={handleCopy}
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="h-4 w-4 text-green-600" />
|
||||
) : (
|
||||
<Copy className="size-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Display-only component for webhook URL fields.
|
||||
* Rendered outside of react-hook-form binding since the value is
|
||||
@@ -114,9 +65,15 @@ function WebhookUrlField({
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleCopy = (text: string, setter: (v: boolean) => void) => {
|
||||
copyToClipboard(text).catch(() => {});
|
||||
setter(true);
|
||||
setTimeout(() => setter(false), 2000);
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
navigator.clipboard
|
||||
.writeText(text)
|
||||
.then(() => {
|
||||
setter(true);
|
||||
setTimeout(() => setter(false), 2000);
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
@@ -246,13 +203,10 @@ export default function DynamicFormComponent({
|
||||
return value;
|
||||
};
|
||||
|
||||
// Filter out display-only field types (e.g. webhook-url, embed-code) that should not
|
||||
// Filter out display-only field types (e.g. webhook-url) that should not
|
||||
// participate in form state, validation, or value emission.
|
||||
const editableItems = useMemo(
|
||||
() =>
|
||||
itemConfigList.filter(
|
||||
(item) => item.type !== 'webhook-url' && item.type !== 'embed-code',
|
||||
),
|
||||
() => itemConfigList.filter((item) => item.type !== 'webhook-url'),
|
||||
[itemConfigList],
|
||||
);
|
||||
|
||||
@@ -493,36 +447,6 @@ export default function DynamicFormComponent({
|
||||
);
|
||||
}
|
||||
|
||||
if (config.type === 'embed-code') {
|
||||
const botUuid = (systemContext?.bot_uuid as string) || '';
|
||||
if (!botUuid) return null;
|
||||
|
||||
const baseUrl =
|
||||
import.meta.env.VITE_API_BASE_URL || window.location.origin;
|
||||
const widgetTitle =
|
||||
((systemContext?.adapter_config as Record<string, unknown>)
|
||||
?.title as string) || 'LangBot';
|
||||
const safeTitle = widgetTitle
|
||||
.replace(/&/g, '&')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
const embedSnippet = `<script data-title="${safeTitle}" src="${baseUrl}/api/v1/embed/${botUuid}/widget.js"><\/script>`;
|
||||
|
||||
return (
|
||||
<EmbedCodeField
|
||||
key={config.id}
|
||||
label={extractI18nObject(config.label)}
|
||||
description={
|
||||
config.description
|
||||
? extractI18nObject(config.description)
|
||||
: undefined
|
||||
}
|
||||
snippet={embedSnippet}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Boolean fields use a special inline layout
|
||||
if (config.type === 'boolean') {
|
||||
return (
|
||||
|
||||
@@ -26,7 +26,6 @@ import {
|
||||
Store,
|
||||
Github,
|
||||
Zap,
|
||||
HardDrive,
|
||||
} from 'lucide-react';
|
||||
import { useTheme } from '@/components/providers/theme-provider';
|
||||
|
||||
@@ -56,7 +55,6 @@ import AccountSettingsDialog from '@/app/home/components/account-settings-dialog
|
||||
import ApiIntegrationDialog from '@/app/home/components/api-integration-dialog/ApiIntegrationDialog';
|
||||
import NewVersionDialog from '@/app/home/components/new-version-dialog/NewVersionDialog';
|
||||
import ModelsDialog from '@/app/home/components/models-dialog/ModelsDialog';
|
||||
import StorageAnalysisDialog from '@/app/home/components/storage-analysis-dialog/StorageAnalysisDialog';
|
||||
import { GitHubRelease } from '@/app/infra/http/CloudServiceClient';
|
||||
import { useAsyncTask, AsyncTaskStatus } from '@/hooks/useAsyncTask';
|
||||
import { toast } from 'sonner';
|
||||
@@ -701,103 +699,87 @@ function NavItems({
|
||||
>
|
||||
<SidebarMenuItem>
|
||||
<SidebarMenuButton
|
||||
asChild
|
||||
isActive={false}
|
||||
onClick={() => {
|
||||
if (isCollapseOnly) {
|
||||
onSectionToggle(config.id, !isOpen);
|
||||
} else {
|
||||
onChildClick(config);
|
||||
}
|
||||
}}
|
||||
tooltip={config.name}
|
||||
className="group/category-header"
|
||||
>
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={() => {
|
||||
if (isCollapseOnly) {
|
||||
onSectionToggle(config.id, !isOpen);
|
||||
} else {
|
||||
onChildClick(config);
|
||||
}
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
if (isCollapseOnly) {
|
||||
onSectionToggle(config.id, !isOpen);
|
||||
} else {
|
||||
onChildClick(config);
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
{config.icon}
|
||||
<span>{config.name}</span>
|
||||
<div className="ml-auto flex items-center gap-0.5 -mr-1">
|
||||
{canCreate &&
|
||||
(isPlugin ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="p-1 rounded-sm text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground [@media(hover:hover)]:opacity-0 group-hover/category-header:opacity-100 transition-all"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{systemInfo.enable_marketplace && (
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigate('/home/market');
|
||||
}}
|
||||
>
|
||||
<Store className="size-4" />
|
||||
{t('plugins.goToMarketplace')}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{config.icon}
|
||||
<span>{config.name}</span>
|
||||
<div className="ml-auto flex items-center gap-0.5 -mr-1">
|
||||
{canCreate &&
|
||||
(isPlugin ? (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="p-1 rounded-sm text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground [@media(hover:hover)]:opacity-0 group-hover/category-header:opacity-100 transition-all"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
{systemInfo.enable_marketplace && (
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setPendingPluginInstallAction('local');
|
||||
navigate('/home/plugins');
|
||||
navigate('/home/market');
|
||||
}}
|
||||
>
|
||||
<Upload className="size-4" />
|
||||
{t('plugins.uploadLocal')}
|
||||
<Store className="size-4" />
|
||||
{t('plugins.goToMarketplace')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setPendingPluginInstallAction('github');
|
||||
navigate('/home/plugins');
|
||||
}}
|
||||
>
|
||||
<Github className="size-4" />
|
||||
{t('plugins.installFromGithub')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="p-1 rounded-sm text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground [@media(hover:hover)]:opacity-0 group-hover/category-header:opacity-100 transition-all"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigate(`${routePrefix}?id=new`);
|
||||
}}
|
||||
>
|
||||
<Plus className="size-3.5" />
|
||||
</button>
|
||||
))}
|
||||
<CollapsibleTrigger asChild>
|
||||
)}
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setPendingPluginInstallAction('local');
|
||||
navigate('/home/plugins');
|
||||
}}
|
||||
>
|
||||
<Upload className="size-4" />
|
||||
{t('plugins.uploadLocal')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setPendingPluginInstallAction('github');
|
||||
navigate('/home/plugins');
|
||||
}}
|
||||
>
|
||||
<Github className="size-4" />
|
||||
{t('plugins.installFromGithub')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="p-1 rounded-sm hover:bg-sidebar-accent"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="p-1 rounded-sm text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground [@media(hover:hover)]:opacity-0 group-hover/category-header:opacity-100 transition-all"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigate(`${routePrefix}?id=new`);
|
||||
}}
|
||||
>
|
||||
<ChevronRight className="size-4 transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90" />
|
||||
<Plus className="size-3.5" />
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
</div>
|
||||
))}
|
||||
<CollapsibleTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="p-1 rounded-sm hover:bg-sidebar-accent"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<ChevronRight className="size-4 transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90" />
|
||||
</button>
|
||||
</CollapsibleTrigger>
|
||||
</div>
|
||||
</SidebarMenuButton>
|
||||
<CollapsibleContent>
|
||||
@@ -1041,127 +1023,6 @@ function PluginItemMenu({
|
||||
);
|
||||
}
|
||||
|
||||
// Plugin pages navigation section — grouped by plugin
|
||||
function PluginPagesNav() {
|
||||
const { pluginPages } = useSidebarData();
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const [searchParams] = useSearchParams();
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (pluginPages.length === 0) return null;
|
||||
|
||||
const pathname = location.pathname;
|
||||
const currentId =
|
||||
pathname === '/home/plugin-pages' ? searchParams.get('id') : null;
|
||||
|
||||
// Group pages by plugin (author/name)
|
||||
const grouped = new Map<
|
||||
string,
|
||||
{ label: string; iconURL: string; pages: typeof pluginPages }
|
||||
>();
|
||||
for (const page of pluginPages) {
|
||||
const key = `${page.pluginAuthor}/${page.pluginName}`;
|
||||
if (!grouped.has(key)) {
|
||||
grouped.set(key, {
|
||||
label: page.pluginLabel,
|
||||
iconURL: page.pluginIconURL,
|
||||
pages: [],
|
||||
});
|
||||
}
|
||||
grouped.get(key)!.pages.push(page);
|
||||
}
|
||||
|
||||
return (
|
||||
<SidebarGroup>
|
||||
<SidebarGroupLabel title={t('sidebar.pluginPagesTooltip')}>
|
||||
{t('sidebar.pluginPages')}
|
||||
</SidebarGroupLabel>
|
||||
<SidebarGroupContent>
|
||||
<SidebarMenu>
|
||||
{Array.from(grouped.entries()).map(
|
||||
([pluginKey, { label, iconURL, pages }]) => {
|
||||
const hasActivePage = pages.some((p) => p.id === currentId);
|
||||
|
||||
const pluginIcon = (
|
||||
<img
|
||||
src={iconURL}
|
||||
alt=""
|
||||
className="size-4 rounded-sm object-cover shrink-0"
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
// Single page — render directly without nesting
|
||||
if (pages.length === 1) {
|
||||
const page = pages[0];
|
||||
const isActive = currentId === page.id;
|
||||
const route = `/home/plugin-pages?id=${encodeURIComponent(page.id)}`;
|
||||
return (
|
||||
<SidebarMenuItem key={page.id}>
|
||||
<SidebarMenuButton
|
||||
isActive={isActive}
|
||||
tooltip={page.name}
|
||||
onClick={() => navigate(route)}
|
||||
className="select-none"
|
||||
>
|
||||
{pluginIcon}
|
||||
<span>{page.name}</span>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
);
|
||||
}
|
||||
|
||||
// Multiple pages — collapsible group
|
||||
return (
|
||||
<Collapsible
|
||||
key={pluginKey}
|
||||
defaultOpen={hasActivePage}
|
||||
className="group/collapsible"
|
||||
>
|
||||
<SidebarMenuItem>
|
||||
<CollapsibleTrigger asChild>
|
||||
<SidebarMenuButton
|
||||
tooltip={label}
|
||||
className="select-none"
|
||||
>
|
||||
{pluginIcon}
|
||||
<span>{label}</span>
|
||||
<ChevronRight className="ml-auto size-4 transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90" />
|
||||
</SidebarMenuButton>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent>
|
||||
<SidebarMenuSub>
|
||||
{pages.map((page) => {
|
||||
const isActive = currentId === page.id;
|
||||
const route = `/home/plugin-pages?id=${encodeURIComponent(page.id)}`;
|
||||
return (
|
||||
<SidebarMenuSubItem key={page.id}>
|
||||
<SidebarMenuSubButton
|
||||
isActive={isActive}
|
||||
onClick={() => navigate(route)}
|
||||
className="select-none"
|
||||
>
|
||||
<span>{page.name}</span>
|
||||
</SidebarMenuSubButton>
|
||||
</SidebarMenuSubItem>
|
||||
);
|
||||
})}
|
||||
</SidebarMenuSub>
|
||||
</CollapsibleContent>
|
||||
</SidebarMenuItem>
|
||||
</Collapsible>
|
||||
);
|
||||
},
|
||||
)}
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
);
|
||||
}
|
||||
|
||||
export default function HomeSidebar({
|
||||
onSelectedChangeAction,
|
||||
}: {
|
||||
@@ -1187,9 +1048,6 @@ export default function HomeSidebar({
|
||||
if (searchParams.get('action') === 'showApiIntegrationSettings') {
|
||||
setApiKeyDialogOpen(true);
|
||||
}
|
||||
if (searchParams.get('action') === 'showStorageAnalysis') {
|
||||
setStorageAnalysisOpen(true);
|
||||
}
|
||||
}, [searchParams]);
|
||||
|
||||
const [selectedChild, setSelectedChild] = useState<SidebarChildVO>();
|
||||
@@ -1205,7 +1063,6 @@ export default function HomeSidebar({
|
||||
const [hasNewVersion, setHasNewVersion] = useState(false);
|
||||
const [versionDialogOpen, setVersionDialogOpen] = useState(false);
|
||||
const [modelsDialogOpen, setModelsDialogOpen] = useState(false);
|
||||
const [storageAnalysisOpen, setStorageAnalysisOpen] = useState(false);
|
||||
const [userEmail, setUserEmail] = useState<string>('');
|
||||
const [starCount, setStarCount] = useState<number | null>(null);
|
||||
const [userMenuOpen, setUserMenuOpen] = useState(false);
|
||||
@@ -1245,24 +1102,6 @@ export default function HomeSidebar({
|
||||
}
|
||||
}
|
||||
|
||||
function handleStorageAnalysisChange(open: boolean) {
|
||||
setStorageAnalysisOpen(open);
|
||||
if (open) {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.set('action', 'showStorageAnalysis');
|
||||
navigate(`${pathname}?${params.toString()}`, {
|
||||
preventScrollReset: true,
|
||||
});
|
||||
} else {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
params.delete('action');
|
||||
const newUrl = params.toString()
|
||||
? `${pathname}?${params.toString()}`
|
||||
: pathname;
|
||||
navigate(newUrl, { preventScrollReset: true });
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
initSelect();
|
||||
if (!localStorage.getItem('token')) {
|
||||
@@ -1446,7 +1285,6 @@ export default function HomeSidebar({
|
||||
</SidebarMenu>
|
||||
</SidebarGroupContent>
|
||||
</SidebarGroup>
|
||||
<PluginPagesNav />
|
||||
</SidebarContent>
|
||||
|
||||
{/* Footer */}
|
||||
@@ -1569,15 +1407,6 @@ export default function HomeSidebar({
|
||||
<Settings />
|
||||
{t('account.settings')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setUserMenuOpen(false);
|
||||
handleStorageAnalysisChange(true);
|
||||
}}
|
||||
>
|
||||
<HardDrive />
|
||||
{t('storageAnalysis.title')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setUserMenuOpen(false);
|
||||
@@ -1678,10 +1507,6 @@ export default function HomeSidebar({
|
||||
open={modelsDialogOpen}
|
||||
onOpenChange={handleModelsDialogChange}
|
||||
/>
|
||||
<StorageAnalysisDialog
|
||||
open={storageAnalysisOpen}
|
||||
onOpenChange={handleStorageAnalysisChange}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -31,18 +31,6 @@ export interface SidebarEntityItem {
|
||||
// Install action types that can be triggered from sidebar
|
||||
export type PluginInstallAction = 'local' | 'github' | null;
|
||||
|
||||
// Plugin page registered by a plugin
|
||||
export interface PluginPageItem {
|
||||
id: string; // "author/name/pageId"
|
||||
name: string; // display label
|
||||
pluginAuthor: string;
|
||||
pluginName: string;
|
||||
pluginLabel: string; // human-readable plugin display name
|
||||
pluginIconURL: string; // plugin icon URL
|
||||
pageId: string;
|
||||
path: string; // asset path (HTML file)
|
||||
}
|
||||
|
||||
// Entity lists and refresh functions exposed via context
|
||||
export interface SidebarDataContextValue {
|
||||
bots: SidebarEntityItem[];
|
||||
@@ -50,7 +38,6 @@ export interface SidebarDataContextValue {
|
||||
knowledgeBases: SidebarEntityItem[];
|
||||
plugins: SidebarEntityItem[];
|
||||
mcpServers: SidebarEntityItem[];
|
||||
pluginPages: PluginPageItem[];
|
||||
refreshBots: () => Promise<void>;
|
||||
refreshPipelines: () => Promise<void>;
|
||||
refreshKnowledgeBases: () => Promise<void>;
|
||||
@@ -77,7 +64,6 @@ export function SidebarDataProvider({
|
||||
const [knowledgeBases, setKnowledgeBases] = useState<SidebarEntityItem[]>([]);
|
||||
const [plugins, setPlugins] = useState<SidebarEntityItem[]>([]);
|
||||
const [mcpServers, setMCPServers] = useState<SidebarEntityItem[]>([]);
|
||||
const [pluginPages, setPluginPages] = useState<PluginPageItem[]>([]);
|
||||
const [detailEntityName, setDetailEntityName] = useState<string | null>(null);
|
||||
const [pendingPluginInstallAction, setPendingPluginInstallAction] =
|
||||
useState<PluginInstallAction>(null);
|
||||
@@ -151,69 +137,33 @@ export function SidebarDataProvider({
|
||||
}
|
||||
}
|
||||
|
||||
// Deduplicate plugins by composite key (prefer debug over installed)
|
||||
const pluginMap = new Map<string, SidebarEntityItem>();
|
||||
for (const plugin of pluginsResp.plugins) {
|
||||
const meta = plugin.manifest.manifest.metadata;
|
||||
const author = meta.author ?? '';
|
||||
const name = meta.name;
|
||||
const compositeKey = `${author}/${name}`;
|
||||
const installedVersion = meta.version ?? '';
|
||||
setPlugins(
|
||||
pluginsResp.plugins.map((plugin) => {
|
||||
const meta = plugin.manifest.manifest.metadata;
|
||||
const author = meta.author ?? '';
|
||||
const name = meta.name;
|
||||
const compositeKey = `${author}/${name}`;
|
||||
const installedVersion = meta.version ?? '';
|
||||
|
||||
let hasUpdate = false;
|
||||
if (plugin.install_source === 'marketplace') {
|
||||
const latestVersion = marketplaceVersions.get(compositeKey);
|
||||
if (latestVersion) {
|
||||
hasUpdate = isNewerVersion(latestVersion, installedVersion);
|
||||
}
|
||||
}
|
||||
|
||||
const item: SidebarEntityItem = {
|
||||
id: compositeKey,
|
||||
name: extractI18nObject(meta.label),
|
||||
iconURL: httpClient.getPluginIconURL(author, name),
|
||||
installSource: plugin.install_source,
|
||||
installInfo: plugin.install_info,
|
||||
hasUpdate,
|
||||
debug: plugin.debug,
|
||||
};
|
||||
|
||||
// If duplicate, prefer debug version
|
||||
if (!pluginMap.has(compositeKey) || plugin.debug) {
|
||||
pluginMap.set(compositeKey, item);
|
||||
}
|
||||
}
|
||||
setPlugins(Array.from(pluginMap.values()));
|
||||
|
||||
// Extract plugin pages from spec.pages (deduplicate by id)
|
||||
const pages: PluginPageItem[] = [];
|
||||
const seenPageIds = new Set<string>();
|
||||
for (const plugin of pluginsResp.plugins) {
|
||||
const meta = plugin.manifest.manifest.metadata;
|
||||
const author = meta.author ?? '';
|
||||
const name = meta.name;
|
||||
const label = meta.label ? extractI18nObject(meta.label) : name;
|
||||
const spec = plugin.manifest.manifest.spec;
|
||||
if (spec?.pages && Array.isArray(spec.pages)) {
|
||||
for (const page of spec.pages) {
|
||||
const pageId = `${author}/${name}/${page.id}`;
|
||||
if (page.id && page.path && !seenPageIds.has(pageId)) {
|
||||
seenPageIds.add(pageId);
|
||||
pages.push({
|
||||
id: pageId,
|
||||
name: page.label ? extractI18nObject(page.label) : page.id,
|
||||
pluginAuthor: author,
|
||||
pluginName: name,
|
||||
pluginLabel: label,
|
||||
pluginIconURL: httpClient.getPluginIconURL(author, name),
|
||||
pageId: page.id,
|
||||
path: page.path,
|
||||
});
|
||||
let hasUpdate = false;
|
||||
if (plugin.install_source === 'marketplace') {
|
||||
const latestVersion = marketplaceVersions.get(compositeKey);
|
||||
if (latestVersion) {
|
||||
hasUpdate = isNewerVersion(latestVersion, installedVersion);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
setPluginPages(pages);
|
||||
|
||||
return {
|
||||
id: compositeKey,
|
||||
name: extractI18nObject(meta.label),
|
||||
iconURL: httpClient.getPluginIconURL(author, name),
|
||||
installSource: plugin.install_source,
|
||||
installInfo: plugin.install_info,
|
||||
hasUpdate,
|
||||
debug: plugin.debug,
|
||||
};
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch plugins for sidebar:', error);
|
||||
}
|
||||
@@ -264,7 +214,6 @@ export function SidebarDataProvider({
|
||||
knowledgeBases,
|
||||
plugins,
|
||||
mcpServers,
|
||||
pluginPages,
|
||||
refreshBots,
|
||||
refreshPipelines,
|
||||
refreshKnowledgeBases,
|
||||
|
||||
@@ -29,36 +29,15 @@ interface ModelsDialogProps {
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
type ExtraArgValue = string | number | boolean | Record<string, unknown>;
|
||||
|
||||
function convertExtraArgsToObject(
|
||||
args: ExtraArg[],
|
||||
): Record<string, ExtraArgValue> {
|
||||
const obj: Record<string, ExtraArgValue> = {};
|
||||
): Record<string, string | number | boolean> {
|
||||
const obj: Record<string, string | number | boolean> = {};
|
||||
args.forEach((arg) => {
|
||||
if (!arg.key.trim()) return;
|
||||
if (arg.type === 'number') {
|
||||
obj[arg.key] = Number(arg.value);
|
||||
} else if (arg.type === 'boolean') {
|
||||
obj[arg.key] = arg.value === 'true';
|
||||
} else if (arg.type === 'object') {
|
||||
const raw = arg.value.trim() || '{}';
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(raw);
|
||||
} catch {
|
||||
throw new Error(`Invalid JSON for extra parameter "${arg.key}"`);
|
||||
}
|
||||
if (
|
||||
parsed === null ||
|
||||
typeof parsed !== 'object' ||
|
||||
Array.isArray(parsed)
|
||||
) {
|
||||
throw new Error(`Extra parameter "${arg.key}" must be a JSON object`);
|
||||
}
|
||||
obj[arg.key] = parsed as Record<string, unknown>;
|
||||
} else {
|
||||
obj[arg.key] = arg.value;
|
||||
if (arg.key.trim()) {
|
||||
if (arg.type === 'number') obj[arg.key] = Number(arg.value);
|
||||
else if (arg.type === 'boolean') obj[arg.key] = arg.value === 'true';
|
||||
else obj[arg.key] = arg.value;
|
||||
}
|
||||
});
|
||||
return obj;
|
||||
|
||||
@@ -258,16 +258,11 @@ export default function AddModelPopover({
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-[min(24rem,calc(100vw-2rem))] max-h-[70vh] overflow-y-auto overscroll-none focus:outline-none focus-visible:outline-none focus-visible:ring-0"
|
||||
style={{
|
||||
maxHeight: 'min(70vh, var(--radix-popover-content-available-height))',
|
||||
}}
|
||||
className="w-[min(24rem,calc(100vw-2rem))] max-h-[calc(100vh-8rem)] overflow-y-auto"
|
||||
align="end"
|
||||
side="left"
|
||||
sideOffset={8}
|
||||
collisionPadding={16}
|
||||
onWheel={(e) => e.stopPropagation()}
|
||||
onTouchMove={(e) => e.stopPropagation()}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Tabs value={tab} onValueChange={(v) => setTab(v as ModelType)}>
|
||||
@@ -442,7 +437,7 @@ export default function AddModelPopover({
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="h-64 overflow-y-auto overscroll-none rounded-md border"
|
||||
className="h-64 overflow-y-auto overscroll-contain rounded-md border"
|
||||
onWheel={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="p-3 space-y-2">
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Plus, X, HelpCircle } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
@@ -48,30 +47,9 @@ export default function ExtraArgsEditor({
|
||||
) => {
|
||||
const newArgs = [...args];
|
||||
newArgs[index] = { ...newArgs[index], [field]: value };
|
||||
// When switching to object type, seed an empty JSON object so the textarea
|
||||
// doesn't start with an unparseable empty string.
|
||||
if (
|
||||
field === 'type' &&
|
||||
value === 'object' &&
|
||||
!newArgs[index].value.trim()
|
||||
) {
|
||||
newArgs[index].value = '{}';
|
||||
}
|
||||
onChange(newArgs);
|
||||
};
|
||||
|
||||
const isInvalidJson = (raw: string) => {
|
||||
if (!raw.trim()) return false;
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
return (
|
||||
parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)
|
||||
);
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
@@ -112,79 +90,49 @@ export default function ExtraArgsEditor({
|
||||
{args.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground">{t('common.none')}</p>
|
||||
) : (
|
||||
args.map((arg, index) => {
|
||||
const isObject = arg.type === 'object';
|
||||
const jsonError = isObject && isInvalidJson(arg.value);
|
||||
return (
|
||||
<div key={index} className="space-y-1">
|
||||
<div className="flex gap-2 items-start">
|
||||
<Input
|
||||
placeholder={t('models.keyName')}
|
||||
value={arg.key}
|
||||
className={isObject ? 'flex-[2]' : 'flex-1'}
|
||||
disabled={disabled}
|
||||
onChange={(e) => handleUpdate(index, 'key', e.target.value)}
|
||||
/>
|
||||
<Select
|
||||
value={arg.type}
|
||||
disabled={disabled}
|
||||
onValueChange={(value) => handleUpdate(index, 'type', value)}
|
||||
>
|
||||
<SelectTrigger className="w-24">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="string">{t('models.string')}</SelectItem>
|
||||
<SelectItem value="number">{t('models.number')}</SelectItem>
|
||||
<SelectItem value="boolean">
|
||||
{t('models.boolean')}
|
||||
</SelectItem>
|
||||
<SelectItem value="object">{t('models.object')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{!isObject && (
|
||||
<Input
|
||||
placeholder={t('models.value')}
|
||||
value={arg.value}
|
||||
className="flex-1"
|
||||
disabled={disabled}
|
||||
onChange={(e) =>
|
||||
handleUpdate(index, 'value', e.target.value)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{!disabled && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 flex-shrink-0"
|
||||
onClick={() => handleRemove(index)}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{isObject && (
|
||||
<Textarea
|
||||
placeholder={t('models.objectJsonPlaceholder')}
|
||||
value={arg.value}
|
||||
className={`w-full font-mono text-xs min-h-[96px] resize-y ${
|
||||
jsonError ? 'border-destructive' : ''
|
||||
}`}
|
||||
disabled={disabled}
|
||||
spellCheck={false}
|
||||
onChange={(e) => handleUpdate(index, 'value', e.target.value)}
|
||||
/>
|
||||
)}
|
||||
{jsonError && (
|
||||
<p className="text-xs text-destructive pl-1">
|
||||
{t('models.invalidJsonObject')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
args.map((arg, index) => (
|
||||
<div key={index} className="flex gap-2 items-center">
|
||||
<Input
|
||||
placeholder={t('models.keyName')}
|
||||
value={arg.key}
|
||||
className="flex-1"
|
||||
disabled={disabled}
|
||||
onChange={(e) => handleUpdate(index, 'key', e.target.value)}
|
||||
/>
|
||||
<Select
|
||||
value={arg.type}
|
||||
disabled={disabled}
|
||||
onValueChange={(value) => handleUpdate(index, 'type', value)}
|
||||
>
|
||||
<SelectTrigger className="w-24">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="string">{t('models.string')}</SelectItem>
|
||||
<SelectItem value="number">{t('models.number')}</SelectItem>
|
||||
<SelectItem value="boolean">{t('models.boolean')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Input
|
||||
placeholder={t('models.value')}
|
||||
value={arg.value}
|
||||
className="flex-1"
|
||||
disabled={disabled}
|
||||
onChange={(e) => handleUpdate(index, 'value', e.target.value)}
|
||||
/>
|
||||
{!disabled && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 flex-shrink-0"
|
||||
onClick={() => handleRemove(index)}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -46,25 +46,10 @@ interface ModelItemProps {
|
||||
function convertExtraArgsToArray(extraArgs?: object): ExtraArg[] {
|
||||
if (!extraArgs) return [];
|
||||
return Object.entries(extraArgs).map(([key, value]) => {
|
||||
let type: ExtraArg['type'] = 'string';
|
||||
let stringValue: string;
|
||||
if (typeof value === 'number') {
|
||||
type = 'number';
|
||||
stringValue = String(value);
|
||||
} else if (typeof value === 'boolean') {
|
||||
type = 'boolean';
|
||||
stringValue = String(value);
|
||||
} else if (
|
||||
value !== null &&
|
||||
typeof value === 'object' &&
|
||||
!Array.isArray(value)
|
||||
) {
|
||||
type = 'object';
|
||||
stringValue = JSON.stringify(value, null, 2);
|
||||
} else {
|
||||
stringValue = String(value);
|
||||
}
|
||||
return { key, type, value: stringValue };
|
||||
let type: 'string' | 'number' | 'boolean' = 'string';
|
||||
if (typeof value === 'number') type = 'number';
|
||||
else if (typeof value === 'boolean') type = 'boolean';
|
||||
return { key, type, value: String(value) };
|
||||
});
|
||||
}
|
||||
|
||||
@@ -224,16 +209,7 @@ export default function ModelItem({
|
||||
)}
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-80 max-h-[70vh] overflow-y-auto overscroll-none focus:outline-none focus-visible:outline-none focus-visible:ring-0"
|
||||
align="start"
|
||||
collisionPadding={16}
|
||||
style={{
|
||||
maxHeight: 'min(70vh, var(--radix-popover-content-available-height))',
|
||||
}}
|
||||
onWheel={(e) => e.stopPropagation()}
|
||||
onTouchMove={(e) => e.stopPropagation()}
|
||||
>
|
||||
<PopoverContent className="w-80" align="start">
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
<Label>{t('models.modelName')}</Label>
|
||||
|
||||
@@ -9,8 +9,7 @@ import {
|
||||
|
||||
export type ExtraArg = {
|
||||
key: string;
|
||||
type: 'string' | 'number' | 'boolean' | 'object';
|
||||
// For 'object' type, value holds a JSON string that will be parsed on save.
|
||||
type: 'string' | 'number' | 'boolean';
|
||||
value: string;
|
||||
};
|
||||
|
||||
|
||||
@@ -1,410 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
AlertCircle,
|
||||
Archive,
|
||||
Clock,
|
||||
Database,
|
||||
FileWarning,
|
||||
HardDrive,
|
||||
RefreshCw,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { backendClient } from '@/app/infra/http';
|
||||
|
||||
interface StorageSection {
|
||||
key: string;
|
||||
path: string;
|
||||
exists: boolean;
|
||||
size_bytes: number;
|
||||
file_count: number;
|
||||
}
|
||||
|
||||
interface CleanupCandidate {
|
||||
key?: string;
|
||||
name?: string;
|
||||
size_bytes: number;
|
||||
modified_at?: string;
|
||||
date?: string;
|
||||
}
|
||||
|
||||
interface StorageAnalysis {
|
||||
generated_at: string;
|
||||
cleanup_policy: {
|
||||
uploaded_file_retention_days: number;
|
||||
log_retention_days: number;
|
||||
};
|
||||
sections: StorageSection[];
|
||||
database: {
|
||||
type: string;
|
||||
monitoring_counts: Record<string, number>;
|
||||
binary_storage: {
|
||||
count: number;
|
||||
size_bytes: number | null;
|
||||
};
|
||||
};
|
||||
cleanup_candidates: {
|
||||
uploaded_files: CleanupCandidate[];
|
||||
log_files: CleanupCandidate[];
|
||||
};
|
||||
tasks: Record<string, number | undefined>;
|
||||
}
|
||||
|
||||
interface StorageAnalysisDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
function formatBytes(bytes: number | null | undefined): string {
|
||||
if (bytes === null || bytes === undefined) {
|
||||
return '-';
|
||||
}
|
||||
if (bytes < 1024) {
|
||||
return `${bytes} B`;
|
||||
}
|
||||
const units = ['KB', 'MB', 'GB', 'TB'];
|
||||
let value = bytes / 1024;
|
||||
let unitIndex = 0;
|
||||
while (value >= 1024 && unitIndex < units.length - 1) {
|
||||
value /= 1024;
|
||||
unitIndex += 1;
|
||||
}
|
||||
return `${value.toFixed(value >= 10 ? 1 : 2)} ${units[unitIndex]}`;
|
||||
}
|
||||
|
||||
export default function StorageAnalysisDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: StorageAnalysisDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [analysis, setAnalysis] = useState<StorageAnalysis | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const loadAnalysis = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const result = await backendClient.get<StorageAnalysis>(
|
||||
'/api/v1/system/storage-analysis',
|
||||
);
|
||||
setAnalysis(result);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
loadAnalysis();
|
||||
}
|
||||
}, [loadAnalysis, open]);
|
||||
|
||||
const totalBytes = useMemo(() => {
|
||||
return (
|
||||
analysis?.sections.reduce((sum, item) => sum + item.size_bytes, 0) ?? 0
|
||||
);
|
||||
}, [analysis]);
|
||||
|
||||
const uploadedCandidateBytes = useMemo(() => {
|
||||
return (
|
||||
analysis?.cleanup_candidates.uploaded_files.reduce(
|
||||
(sum, item) => sum + item.size_bytes,
|
||||
0,
|
||||
) ?? 0
|
||||
);
|
||||
}, [analysis]);
|
||||
|
||||
const logCandidateBytes = useMemo(() => {
|
||||
return (
|
||||
analysis?.cleanup_candidates.log_files.reduce(
|
||||
(sum, item) => sum + item.size_bytes,
|
||||
0,
|
||||
) ?? 0
|
||||
);
|
||||
}, [analysis]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="!flex h-[86vh] max-h-[86vh] max-w-5xl flex-col gap-0 p-0">
|
||||
<DialogHeader className="shrink-0 px-6 pt-6">
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<HardDrive className="size-5 text-blue-500" />
|
||||
{t('storageAnalysis.dialogTitle')}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('storageAnalysis.description')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex shrink-0 items-center justify-between gap-3 border-b px-6 pb-4">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{analysis
|
||||
? t('storageAnalysis.generatedAt', {
|
||||
time: new Date(analysis.generated_at).toLocaleString(),
|
||||
})
|
||||
: t('storageAnalysis.loading')}
|
||||
</div>
|
||||
<Button
|
||||
onClick={loadAnalysis}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={loading}
|
||||
>
|
||||
<RefreshCw
|
||||
className={`mr-2 size-4 ${loading ? 'animate-spin' : ''}`}
|
||||
/>
|
||||
{t('storageAnalysis.refresh')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<ScrollArea className="min-h-0 flex-1 overflow-hidden">
|
||||
<div className="space-y-5 px-6 py-5">
|
||||
{error && (
|
||||
<div className="flex items-start gap-2 rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
|
||||
<AlertCircle className="mt-0.5 size-4 shrink-0" />
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{analysis && (
|
||||
<>
|
||||
<div className="grid grid-cols-1 gap-3 md:grid-cols-4">
|
||||
<SummaryItem
|
||||
label={t('storageAnalysis.totalSize')}
|
||||
value={formatBytes(totalBytes)}
|
||||
icon={<HardDrive className="size-4" />}
|
||||
/>
|
||||
<SummaryItem
|
||||
label={t('storageAnalysis.binaryStorage')}
|
||||
value={formatBytes(
|
||||
analysis.database.binary_storage.size_bytes,
|
||||
)}
|
||||
meta={`${analysis.database.binary_storage.count}`}
|
||||
icon={<Database className="size-4" />}
|
||||
/>
|
||||
<SummaryItem
|
||||
label={t('storageAnalysis.uploadCleanup')}
|
||||
value={formatBytes(uploadedCandidateBytes)}
|
||||
meta={`${analysis.cleanup_candidates.uploaded_files.length}`}
|
||||
icon={<FileWarning className="size-4" />}
|
||||
/>
|
||||
<SummaryItem
|
||||
label={t('storageAnalysis.logCleanup')}
|
||||
value={formatBytes(logCandidateBytes)}
|
||||
meta={`${analysis.cleanup_candidates.log_files.length}`}
|
||||
icon={<FileWarning className="size-4" />}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<section className="rounded-md border px-3 py-3">
|
||||
<h2 className="mb-3 flex items-center gap-2 text-sm font-medium">
|
||||
<Clock className="size-4 text-muted-foreground" />
|
||||
{t('storageAnalysis.cleanupPolicy')}
|
||||
</h2>
|
||||
<div className="grid grid-cols-1 gap-2 text-sm md:grid-cols-3">
|
||||
<PolicyItem
|
||||
label={t('storageAnalysis.uploadRetention')}
|
||||
value={`${analysis.cleanup_policy.uploaded_file_retention_days} ${t('storageAnalysis.days')}`}
|
||||
/>
|
||||
<PolicyItem
|
||||
label={t('storageAnalysis.logRetention')}
|
||||
value={`${analysis.cleanup_policy.log_retention_days} ${t('storageAnalysis.days')}`}
|
||||
/>
|
||||
<PolicyItem
|
||||
label={t('storageAnalysis.databaseType')}
|
||||
value={analysis.database.type}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 className="mb-2 text-sm font-medium">
|
||||
{t('storageAnalysis.sections')}
|
||||
</h2>
|
||||
<div className="overflow-hidden rounded-md border">
|
||||
{analysis.sections.map((section) => (
|
||||
<div
|
||||
key={section.key}
|
||||
className="grid grid-cols-[1fr_auto_auto_auto] gap-3 border-b px-3 py-2 text-sm last:border-b-0"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium">
|
||||
{t(`storageAnalysis.sectionNames.${section.key}`)}
|
||||
</div>
|
||||
<div className="break-all text-xs text-muted-foreground">
|
||||
{section.path || '-'}
|
||||
</div>
|
||||
</div>
|
||||
{section.exists ? (
|
||||
<span />
|
||||
) : (
|
||||
<Badge variant="outline" className="self-center">
|
||||
{t('storageAnalysis.missing')}
|
||||
</Badge>
|
||||
)}
|
||||
<div className="self-center tabular-nums">
|
||||
{formatBytes(section.size_bytes)}
|
||||
</div>
|
||||
<div className="self-center text-muted-foreground tabular-nums">
|
||||
{section.file_count}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<MetricPanel
|
||||
title={t('storageAnalysis.monitoringTables')}
|
||||
values={analysis.database.monitoring_counts}
|
||||
/>
|
||||
<MetricPanel
|
||||
title={t('storageAnalysis.runtimeTasks')}
|
||||
values={analysis.tasks}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<CandidatePanel
|
||||
title={t('storageAnalysis.expiredUploads')}
|
||||
emptyText={t('storageAnalysis.noExpiredUploads')}
|
||||
candidates={analysis.cleanup_candidates.uploaded_files}
|
||||
/>
|
||||
<CandidatePanel
|
||||
title={t('storageAnalysis.expiredLogs')}
|
||||
emptyText={t('storageAnalysis.noExpiredLogs')}
|
||||
candidates={analysis.cleanup_candidates.log_files}
|
||||
/>
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
function SummaryItem({
|
||||
label,
|
||||
value,
|
||||
icon,
|
||||
meta,
|
||||
}: {
|
||||
label: string;
|
||||
value: string;
|
||||
icon: ReactNode;
|
||||
meta?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="rounded-md border px-3 py-3">
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
{icon}
|
||||
{label}
|
||||
</div>
|
||||
<div className="mt-2 flex items-end justify-between gap-2">
|
||||
<span className="text-xl font-semibold tabular-nums">{value}</span>
|
||||
{meta && <span className="text-xs text-muted-foreground">{meta}</span>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PolicyItem({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="rounded-md bg-muted/40 px-3 py-2">
|
||||
<div className="text-xs text-muted-foreground">{label}</div>
|
||||
<div className="mt-1 font-medium">{value}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MetricPanel({
|
||||
title,
|
||||
values,
|
||||
}: {
|
||||
title: string;
|
||||
values: Record<string, number | undefined>;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<h2 className="mb-2 text-sm font-medium">{title}</h2>
|
||||
<div className="rounded-md border">
|
||||
{Object.entries(values).map(([key, value]) => (
|
||||
<div
|
||||
key={key}
|
||||
className="flex items-center justify-between border-b px-3 py-2 text-sm last:border-b-0"
|
||||
>
|
||||
<span className="text-muted-foreground">{key}</span>
|
||||
<span className="font-medium tabular-nums">{value ?? '-'}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CandidatePanel({
|
||||
title,
|
||||
emptyText,
|
||||
candidates,
|
||||
}: {
|
||||
title: string;
|
||||
emptyText: string;
|
||||
candidates: CleanupCandidate[];
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
<h2 className="mb-2 flex items-center gap-2 text-sm font-medium">
|
||||
<Archive className="size-4 text-muted-foreground" />
|
||||
{title}
|
||||
</h2>
|
||||
<div className="rounded-md border">
|
||||
{candidates.length === 0 ? (
|
||||
<div className="px-3 py-6 text-center text-sm text-muted-foreground">
|
||||
{emptyText}
|
||||
</div>
|
||||
) : (
|
||||
candidates.slice(0, 8).map((candidate, index) => (
|
||||
<div
|
||||
key={`${candidate.key ?? candidate.name}-${index}`}
|
||||
className="grid grid-cols-[1fr_auto] gap-3 border-b px-3 py-2 text-sm last:border-b-0"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-medium">
|
||||
{candidate.key ?? candidate.name}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{candidate.modified_at ?? candidate.date ?? '-'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="self-center tabular-nums">
|
||||
{formatBytes(candidate.size_bytes)}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -146,12 +146,7 @@ export default function KBDetailContent({ id }: { id: string }) {
|
||||
<h1 className="text-xl font-semibold">
|
||||
{t('knowledge.editKnowledgeBase')}
|
||||
</h1>
|
||||
<Button
|
||||
type="submit"
|
||||
form="kb-form"
|
||||
disabled={!formDirty}
|
||||
className={activeTab !== 'metadata' ? 'invisible' : ''}
|
||||
>
|
||||
<Button type="submit" form="kb-form" disabled={!formDirty}>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -44,12 +44,7 @@ import {
|
||||
} from '@/app/home/plugins/components/plugin-install-task';
|
||||
|
||||
// Routes that belong to the "Extensions" section
|
||||
const EXTENSIONS_ROUTES = [
|
||||
'/home/plugins',
|
||||
'/home/market',
|
||||
'/home/mcp',
|
||||
'/home/plugin-pages',
|
||||
];
|
||||
const EXTENSIONS_ROUTES = ['/home/plugins', '/home/market', '/home/mcp'];
|
||||
|
||||
function isExtensionsRoute(pathname: string): boolean {
|
||||
return EXTENSIONS_ROUTES.some(
|
||||
|
||||
@@ -80,12 +80,7 @@ export default function PipelineDetailContent({ id }: { id: string }) {
|
||||
{/* Sticky Header: title + save button */}
|
||||
<div className="flex items-center justify-between pb-4 shrink-0">
|
||||
<h1 className="text-xl font-semibold">{t('pipelines.editPipeline')}</h1>
|
||||
<Button
|
||||
type="submit"
|
||||
form="pipeline-form"
|
||||
disabled={!formDirty}
|
||||
className={activeTab !== 'config' ? 'invisible' : ''}
|
||||
>
|
||||
<Button type="submit" form="pipeline-form" disabled={!formDirty}>
|
||||
{t('common.save')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -1,192 +0,0 @@
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { httpClient } from '@/app/infra/http/HttpClient';
|
||||
import { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useTheme } from '@/components/providers/theme-provider';
|
||||
|
||||
/**
|
||||
* Plugin page that renders a plugin-provided HTML page in an iframe.
|
||||
* URL format: /home/plugin-pages?id=author/name/pageId
|
||||
*
|
||||
* The iframe communicates with the parent via postMessage:
|
||||
*
|
||||
* Parent → iframe:
|
||||
* { type: 'langbot:context', theme: 'light'|'dark', language: 'zh-Hans'|'en-US' }
|
||||
*
|
||||
* iframe → Parent:
|
||||
* { type: 'langbot:api', requestId: string, endpoint: string, method: string, body?: any }
|
||||
*
|
||||
* Parent → iframe (response):
|
||||
* { type: 'langbot:api:response', requestId: string, data?: any, error?: string }
|
||||
*/
|
||||
export default function PluginPagesPage() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const id = searchParams.get('id');
|
||||
const { t } = useTranslation();
|
||||
const { setDetailEntityName, pluginPages } = useSidebarData();
|
||||
|
||||
// Find the matching page for breadcrumb
|
||||
const page = pluginPages.find((p) => p.id === id);
|
||||
|
||||
useEffect(() => {
|
||||
setDetailEntityName(page?.name ?? id ?? '');
|
||||
return () => setDetailEntityName(null);
|
||||
}, [page, id, setDetailEntityName]);
|
||||
|
||||
if (!id) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full text-muted-foreground">
|
||||
{t('pluginPages.selectFromSidebar')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Parse "author/name/pageId"
|
||||
const parts = id.split('/');
|
||||
if (parts.length < 3) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full text-muted-foreground">
|
||||
{t('pluginPages.invalidPage')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const author = parts[0];
|
||||
const pluginName = parts[1];
|
||||
// Use the asset path from the page manifest, not the page ID
|
||||
const assetPath = page?.path ?? parts.slice(2).join('/');
|
||||
const pageId = parts.slice(2).join('/');
|
||||
|
||||
return (
|
||||
<PluginPageIframe
|
||||
author={author}
|
||||
pluginName={pluginName}
|
||||
pagePath={assetPath}
|
||||
pageId={pageId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function PluginPageIframe({
|
||||
author,
|
||||
pluginName,
|
||||
pagePath,
|
||||
pageId,
|
||||
}: {
|
||||
author: string;
|
||||
pluginName: string;
|
||||
pagePath: string;
|
||||
pageId: string;
|
||||
}) {
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const { resolvedTheme } = useTheme();
|
||||
const { i18n } = useTranslation();
|
||||
|
||||
const assetUrl = httpClient.getPluginAssetURL(author, pluginName, pagePath);
|
||||
|
||||
// Send context (theme + language) to iframe
|
||||
// Use '*' as targetOrigin because sandboxed iframe has opaque (null) origin
|
||||
const sendContext = useCallback(() => {
|
||||
const iframe = iframeRef.current;
|
||||
if (iframe?.contentWindow) {
|
||||
iframe.contentWindow.postMessage(
|
||||
{
|
||||
type: 'langbot:context',
|
||||
theme: resolvedTheme,
|
||||
language: i18n.language,
|
||||
},
|
||||
'*',
|
||||
);
|
||||
}
|
||||
}, [resolvedTheme, i18n.language]);
|
||||
|
||||
// Re-send context when theme or language changes
|
||||
useEffect(() => {
|
||||
if (!loading) {
|
||||
sendContext();
|
||||
}
|
||||
}, [resolvedTheme, i18n.language, loading, sendContext]);
|
||||
|
||||
// Handle messages from iframe (API calls)
|
||||
useEffect(() => {
|
||||
const handleMessage = async (event: MessageEvent) => {
|
||||
// Validate source — only accept messages from our specific iframe window
|
||||
// This is more secure than origin checking: works with sandboxed (null-origin) iframes
|
||||
// and prevents spoofing from other windows/iframes
|
||||
if (event.source !== iframeRef.current?.contentWindow) return;
|
||||
|
||||
const data = event.data;
|
||||
if (!data || typeof data !== 'object') return;
|
||||
|
||||
// Validate requestId format to prevent injection
|
||||
if (data.type === 'langbot:api') {
|
||||
const { requestId, endpoint, method, body } = data;
|
||||
if (typeof requestId !== 'string' || typeof endpoint !== 'string')
|
||||
return;
|
||||
// Sanitize endpoint — must start with / and not contain ..
|
||||
if (!endpoint.startsWith('/') || endpoint.includes('..')) return;
|
||||
const normalizedMethod =
|
||||
typeof method === 'string' ? method.toUpperCase() : 'POST';
|
||||
if (
|
||||
!['GET', 'POST', 'PUT', 'PATCH', 'DELETE'].includes(normalizedMethod)
|
||||
)
|
||||
return;
|
||||
try {
|
||||
const result = await httpClient.pluginPageApi(
|
||||
author,
|
||||
pluginName,
|
||||
pageId,
|
||||
endpoint,
|
||||
normalizedMethod,
|
||||
body,
|
||||
);
|
||||
iframeRef.current?.contentWindow?.postMessage(
|
||||
{
|
||||
type: 'langbot:api:response',
|
||||
requestId,
|
||||
data: result,
|
||||
},
|
||||
'*',
|
||||
);
|
||||
} catch (err: unknown) {
|
||||
const errorMsg = err instanceof Error ? err.message : String(err);
|
||||
iframeRef.current?.contentWindow?.postMessage(
|
||||
{
|
||||
type: 'langbot:api:response',
|
||||
requestId,
|
||||
error: errorMsg,
|
||||
},
|
||||
'*',
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('message', handleMessage);
|
||||
return () => window.removeEventListener('message', handleMessage);
|
||||
}, [author, pluginName, pageId]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full w-full">
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center h-full text-muted-foreground">
|
||||
Loading...
|
||||
</div>
|
||||
)}
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
src={assetUrl}
|
||||
className="flex-1 w-full border-0 rounded-md"
|
||||
style={{ display: loading ? 'none' : 'block' }}
|
||||
onLoad={() => {
|
||||
setLoading(false);
|
||||
sendContext();
|
||||
}}
|
||||
sandbox="allow-scripts allow-forms"
|
||||
title={`${author}/${pluginName} - ${pagePath}`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -85,7 +85,7 @@ function StageRow({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-2 sm:gap-3 px-2 sm:px-3 py-2 sm:py-2.5 rounded-lg transition-all duration-300',
|
||||
'flex items-center gap-3 px-3 py-2.5 rounded-lg transition-all duration-300',
|
||||
isActive &&
|
||||
!isError &&
|
||||
'bg-blue-50 dark:bg-blue-950/30 border border-blue-200 dark:border-blue-800',
|
||||
@@ -154,7 +154,7 @@ function StageRow({
|
||||
{detail && (
|
||||
<div
|
||||
className={cn(
|
||||
'text-xs mt-0.5 break-words',
|
||||
'text-xs mt-0.5',
|
||||
isCompleted
|
||||
? 'text-green-600/70 dark:text-green-400/70'
|
||||
: 'text-blue-600/70 dark:text-blue-400/70',
|
||||
@@ -256,7 +256,7 @@ function TaskProgressContent({ task }: { task: PluginInstallTask }) {
|
||||
<>
|
||||
<span>{parts.join(' · ')}</span>
|
||||
<br />
|
||||
<span className="opacity-70 break-words">{currentDep}</span>
|
||||
<span className="opacity-70">{currentDep}</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -277,10 +277,10 @@ function TaskProgressContent({ task }: { task: PluginInstallTask }) {
|
||||
<div className="space-y-4">
|
||||
{/* Overall progress bar — always blue */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span
|
||||
className={cn(
|
||||
'text-sm font-medium shrink-0',
|
||||
'text-sm font-medium',
|
||||
isDone
|
||||
? 'text-green-700 dark:text-green-300'
|
||||
: 'text-blue-700 dark:text-blue-300',
|
||||
@@ -360,8 +360,8 @@ function TaskProgressContent({ task }: { task: PluginInstallTask }) {
|
||||
{/* Done banner */}
|
||||
{isDone && (
|
||||
<div className="flex items-center gap-2 px-3 py-2.5 rounded-lg bg-green-50 dark:bg-green-950/30 border border-green-200 dark:border-green-900">
|
||||
<CheckCircle2 className="w-5 h-5 shrink-0 text-green-600 dark:text-green-400" />
|
||||
<span className="text-sm text-green-700 dark:text-green-300 font-medium break-words">
|
||||
<CheckCircle2 className="w-5 h-5 text-green-600 dark:text-green-400" />
|
||||
<span className="text-sm text-green-700 dark:text-green-300 font-medium">
|
||||
{t('plugins.installProgress.installComplete')}
|
||||
</span>
|
||||
</div>
|
||||
@@ -406,13 +406,13 @@ export default function PluginInstallProgressDialog() {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(o) => !o && handleClose()}>
|
||||
<DialogContent
|
||||
className="sm:max-w-lg w-[90vw] max-h-[80vh] p-4 sm:p-6 bg-white dark:bg-[#1a1a1e] overflow-y-auto overflow-x-hidden"
|
||||
className="w-[460px] max-h-[80vh] p-6 bg-white dark:bg-[#1a1a1e] overflow-y-auto"
|
||||
hideCloseButton
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-start gap-3">
|
||||
<Download className="size-5 shrink-0 mt-0.5" />
|
||||
<span className="break-words">
|
||||
<DialogTitle className="flex items-center gap-3">
|
||||
<Download className="size-5" />
|
||||
<span className="truncate">
|
||||
{selectedTask
|
||||
? t('plugins.installProgress.title', {
|
||||
name: selectedTask.pluginName,
|
||||
|
||||
@@ -1,12 +1,5 @@
|
||||
import { TFunction } from 'i18next';
|
||||
import {
|
||||
Wrench,
|
||||
AudioWaveform,
|
||||
Hash,
|
||||
Book,
|
||||
FileText,
|
||||
PanelTop,
|
||||
} from 'lucide-react';
|
||||
import { Wrench, AudioWaveform, Hash, Book, FileText } from 'lucide-react';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
|
||||
export default function PluginComponentList({
|
||||
@@ -30,7 +23,6 @@ export default function PluginComponentList({
|
||||
Command: <Hash className="w-5 h-5" />,
|
||||
KnowledgeEngine: <Book className="w-5 h-5" />,
|
||||
Parser: <FileText className="w-5 h-5" />,
|
||||
Page: <PanelTop className="w-5 h-5" />,
|
||||
};
|
||||
|
||||
const componentKindList = Object.keys(components || {});
|
||||
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
Hash,
|
||||
Book,
|
||||
FileText,
|
||||
PanelTop,
|
||||
} from 'lucide-react';
|
||||
import PluginMarketCardComponent from './plugin-market-card/PluginMarketCardComponent';
|
||||
import { PluginMarketCardVO } from './plugin-market-card/PluginMarketCardVO';
|
||||
@@ -54,7 +53,6 @@ function MarketPageContent({
|
||||
'EventListener',
|
||||
'KnowledgeEngine',
|
||||
'Parser',
|
||||
'Page',
|
||||
];
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
@@ -532,14 +530,6 @@ function MarketPageContent({
|
||||
<FileText className="h-4 w-4 mr-1" />
|
||||
{t('plugins.componentName.Parser')}
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem
|
||||
value="Page"
|
||||
aria-label="Page"
|
||||
className="text-xs sm:text-sm cursor-pointer"
|
||||
>
|
||||
<PanelTop className="h-4 w-4 mr-1" />
|
||||
{t('plugins.componentName.Page')}
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -18,7 +18,6 @@ import {
|
||||
Check,
|
||||
Bug,
|
||||
} from 'lucide-react';
|
||||
import { copyToClipboard } from '@/app/utils/clipboard';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
@@ -467,13 +466,33 @@ function PluginListView() {
|
||||
};
|
||||
|
||||
const handleCopyDebugInfo = (text: string, type: 'url' | 'key') => {
|
||||
copyToClipboard(text).catch(() => {});
|
||||
if (type === 'url') {
|
||||
setCopiedDebugUrl(true);
|
||||
setTimeout(() => setCopiedDebugUrl(false), 2000);
|
||||
} else {
|
||||
setCopiedDebugKey(true);
|
||||
setTimeout(() => setCopiedDebugKey(false), 2000);
|
||||
try {
|
||||
navigator.clipboard.writeText(text);
|
||||
if (type === 'url') {
|
||||
setCopiedDebugUrl(true);
|
||||
setTimeout(() => setCopiedDebugUrl(false), 2000);
|
||||
} else {
|
||||
setCopiedDebugKey(true);
|
||||
setTimeout(() => setCopiedDebugKey(false), 2000);
|
||||
}
|
||||
} catch {
|
||||
const textArea = document.createElement('textarea');
|
||||
textArea.value = text;
|
||||
textArea.style.position = 'fixed';
|
||||
textArea.style.left = '-999999px';
|
||||
textArea.style.top = '-999999px';
|
||||
document.body.appendChild(textArea);
|
||||
textArea.select();
|
||||
textArea.setSelectionRange(0, 99999);
|
||||
const success = document.execCommand('copy');
|
||||
document.body.removeChild(textArea);
|
||||
if (success) {
|
||||
setCopiedDebugUrl(true);
|
||||
setTimeout(() => setCopiedDebugUrl(false), 2000);
|
||||
} else {
|
||||
setCopiedDebugKey(true);
|
||||
setTimeout(() => setCopiedDebugKey(false), 2000);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -45,7 +45,6 @@ export enum DynamicFormItemType {
|
||||
BOT_SELECTOR = 'bot-selector',
|
||||
TOOLS_SELECTOR = 'tools-selector',
|
||||
WEBHOOK_URL = 'webhook-url',
|
||||
EMBED_CODE = 'embed-code',
|
||||
}
|
||||
|
||||
export interface IFileConfig {
|
||||
|
||||
@@ -112,7 +112,7 @@ export class BackendClient extends BaseHttpClient {
|
||||
|
||||
public scanProviderModels(
|
||||
uuid: string,
|
||||
modelType?: 'llm' | 'embedding' | 'rerank',
|
||||
modelType?: 'llm' | 'embedding',
|
||||
): Promise<ApiRespScannedProviderModels> {
|
||||
const params = modelType ? { type: modelType } : {};
|
||||
return this.get(`/api/v1/provider/providers/${uuid}/scan-models`, params);
|
||||
@@ -596,27 +596,6 @@ export class BackendClient extends BaseHttpClient {
|
||||
);
|
||||
}
|
||||
|
||||
public async pluginPageApi(
|
||||
author: string,
|
||||
name: string,
|
||||
pageId: string,
|
||||
endpoint: string,
|
||||
method: string = 'POST',
|
||||
body?: unknown,
|
||||
): Promise<unknown> {
|
||||
const resp = await this.instance.request({
|
||||
url: `/api/v1/plugins/${author}/${name}/page-api`,
|
||||
method: 'POST',
|
||||
data: {
|
||||
page_id: pageId,
|
||||
endpoint,
|
||||
method,
|
||||
body,
|
||||
},
|
||||
});
|
||||
return resp.data?.data;
|
||||
}
|
||||
|
||||
public getPluginIconURL(author: string, name: string): string {
|
||||
if (this.instance.defaults.baseURL === '/') {
|
||||
const url = window.location.href;
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
/**
|
||||
* Copy text to clipboard with fallback support
|
||||
* Tries to use modern Clipboard API first, falls back to execCommand if not available
|
||||
*
|
||||
* @param text - The text to copy to clipboard
|
||||
* @returns Promise<boolean> - true if successful, false otherwise
|
||||
*/
|
||||
export async function copyToClipboard(text: string): Promise<boolean> {
|
||||
// Try modern Clipboard API first
|
||||
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||
try {
|
||||
await navigator.clipboard.writeText(text);
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error('[Clipboard] Modern API failed, trying fallback:', err);
|
||||
// Fall through to legacy method
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to legacy execCommand method
|
||||
try {
|
||||
const textArea = document.createElement('textarea');
|
||||
textArea.value = text;
|
||||
textArea.style.position = 'fixed';
|
||||
textArea.style.left = '-999999px';
|
||||
textArea.style.top = '-999999px';
|
||||
document.body.appendChild(textArea);
|
||||
textArea.focus();
|
||||
textArea.select();
|
||||
|
||||
const successful = document.execCommand('copy');
|
||||
document.body.removeChild(textArea);
|
||||
|
||||
return successful;
|
||||
} catch (err) {
|
||||
console.error('[Clipboard] Fallback method failed:', err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -572,9 +572,9 @@ export default function WizardPage() {
|
||||
className={cn(
|
||||
'w-6 h-6 sm:w-7 sm:h-7 rounded-full flex items-center justify-center text-xs font-medium transition-colors',
|
||||
idx < currentStep
|
||||
? 'bg-blue-600 text-white'
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: idx === currentStep
|
||||
? 'bg-blue-600 text-white'
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-muted text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
@@ -588,7 +588,7 @@ export default function WizardPage() {
|
||||
className={cn(
|
||||
'text-sm hidden sm:inline',
|
||||
idx === currentStep
|
||||
? 'font-medium text-blue-600'
|
||||
? 'font-medium text-foreground'
|
||||
: 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
@@ -599,7 +599,7 @@ export default function WizardPage() {
|
||||
<div
|
||||
className={cn(
|
||||
'w-4 sm:w-8 h-px',
|
||||
idx < currentStep ? 'bg-blue-600' : 'bg-border',
|
||||
idx < currentStep ? 'bg-primary' : 'bg-border',
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -5,8 +5,6 @@ const enUS = {
|
||||
installedPlugins: 'Installed Plugins',
|
||||
pluginMarket: 'Marketplace',
|
||||
mcpServers: 'MCP Servers',
|
||||
pluginPages: 'Plugin Pages',
|
||||
pluginPagesTooltip: 'Visual pages provided by installed plugins',
|
||||
quickStart: 'Quick Start',
|
||||
},
|
||||
common: {
|
||||
@@ -200,9 +198,6 @@ const enUS = {
|
||||
string: 'String',
|
||||
number: 'Number',
|
||||
boolean: 'Boolean',
|
||||
object: 'Object',
|
||||
objectJsonPlaceholder: '{ "type": "disabled" }',
|
||||
invalidJsonObject: 'Value must be a valid JSON object',
|
||||
selectModelProvider: 'Select Model Provider',
|
||||
modelProviderDescription:
|
||||
'Please fill in the model name provided by the provider',
|
||||
@@ -493,7 +488,6 @@ const enUS = {
|
||||
Command: 'Command',
|
||||
KnowledgeEngine: 'Knowledge Engine',
|
||||
Parser: 'Parser',
|
||||
Page: 'Page',
|
||||
},
|
||||
uploadLocal: 'Upload Local',
|
||||
debugging: 'Debugging',
|
||||
@@ -1225,41 +1219,6 @@ const enUS = {
|
||||
feedback: 'User Feedback',
|
||||
},
|
||||
},
|
||||
storageAnalysis: {
|
||||
title: 'Storage Analysis',
|
||||
description: 'Inspect storage usage and cleanup candidates',
|
||||
openDialog: 'View Analysis',
|
||||
dialogTitle: 'Storage Analysis',
|
||||
generatedAt: 'Generated at {{time}}',
|
||||
loading: 'Loading...',
|
||||
refresh: 'Refresh',
|
||||
totalSize: 'Total size',
|
||||
binaryStorage: 'Binary storage',
|
||||
uploadCleanup: 'Expired uploads',
|
||||
logCleanup: 'Expired logs',
|
||||
sections: 'Storage sections',
|
||||
monitoringTables: 'Monitoring tables',
|
||||
runtimeTasks: 'Runtime tasks',
|
||||
cleanupPolicy: 'Cleanup policy',
|
||||
uploadRetention: 'Upload retention',
|
||||
logRetention: 'Log retention',
|
||||
databaseType: 'Database type',
|
||||
days: 'days',
|
||||
missing: 'Missing',
|
||||
expiredUploads: 'Expired uploads',
|
||||
expiredLogs: 'Expired logs',
|
||||
noExpiredUploads: 'No expired uploaded files',
|
||||
noExpiredLogs: 'No expired log files',
|
||||
sectionNames: {
|
||||
database: 'Database',
|
||||
logs: 'Logs',
|
||||
storage: 'Uploaded files',
|
||||
vector_store: 'Vector store',
|
||||
plugins: 'Plugins',
|
||||
mcp: 'MCP',
|
||||
temp: 'Temporary files',
|
||||
},
|
||||
},
|
||||
limitation: {
|
||||
maxBotsReached:
|
||||
'Maximum number of bots ({{max}}) reached. Please remove an existing bot before creating a new one.',
|
||||
@@ -1332,10 +1291,6 @@ const enUS = {
|
||||
backToWorkbench: 'Back to Workbench',
|
||||
},
|
||||
},
|
||||
pluginPages: {
|
||||
selectFromSidebar: 'Select a plugin page from the sidebar',
|
||||
invalidPage: 'Invalid plugin page',
|
||||
},
|
||||
};
|
||||
|
||||
export default enUS;
|
||||
|
||||
@@ -5,9 +5,6 @@ const esES = {
|
||||
installedPlugins: 'Plugins instalados',
|
||||
pluginMarket: 'Tienda',
|
||||
mcpServers: 'Servidores MCP',
|
||||
pluginPages: 'Páginas de plugins',
|
||||
pluginPagesTooltip:
|
||||
'Páginas visuales proporcionadas por los plugins instalados',
|
||||
quickStart: 'Inicio rápido',
|
||||
},
|
||||
common: {
|
||||
@@ -205,9 +202,6 @@ const esES = {
|
||||
string: 'Cadena',
|
||||
number: 'Número',
|
||||
boolean: 'Booleano',
|
||||
object: 'Objeto',
|
||||
objectJsonPlaceholder: '{ "type": "disabled" }',
|
||||
invalidJsonObject: 'El valor debe ser un objeto JSON válido',
|
||||
selectModelProvider: 'Seleccionar proveedor del modelo',
|
||||
modelProviderDescription:
|
||||
'Por favor, introduce el nombre del modelo proporcionado por el proveedor',
|
||||
@@ -506,7 +500,6 @@ const esES = {
|
||||
Command: 'Comando',
|
||||
KnowledgeEngine: 'Motor de conocimiento',
|
||||
Parser: 'Analizador',
|
||||
Page: 'Página',
|
||||
},
|
||||
uploadLocal: 'Subir local',
|
||||
debugging: 'Depuración',
|
||||
@@ -1259,42 +1252,6 @@ const esES = {
|
||||
feedback: 'Comentarios de usuarios',
|
||||
},
|
||||
},
|
||||
storageAnalysis: {
|
||||
title: 'Análisis de almacenamiento',
|
||||
description:
|
||||
'Inspecciona el uso de almacenamiento y los candidatos de limpieza',
|
||||
openDialog: 'Ver análisis',
|
||||
dialogTitle: 'Análisis de almacenamiento',
|
||||
generatedAt: 'Generado el {{time}}',
|
||||
loading: 'Cargando...',
|
||||
refresh: 'Actualizar',
|
||||
totalSize: 'Tamaño total',
|
||||
binaryStorage: 'Almacenamiento binario de plugins',
|
||||
uploadCleanup: 'Subidas caducadas',
|
||||
logCleanup: 'Registros caducados',
|
||||
sections: 'Secciones de almacenamiento',
|
||||
monitoringTables: 'Tablas de monitoreo',
|
||||
runtimeTasks: 'Tareas en ejecución',
|
||||
cleanupPolicy: 'Política de limpieza',
|
||||
uploadRetention: 'Retención de subidas',
|
||||
logRetention: 'Retención de registros',
|
||||
databaseType: 'Tipo de base de datos',
|
||||
days: 'días',
|
||||
missing: 'Falta',
|
||||
expiredUploads: 'Subidas caducadas',
|
||||
expiredLogs: 'Registros caducados',
|
||||
noExpiredUploads: 'No hay archivos subidos caducados',
|
||||
noExpiredLogs: 'No hay registros caducados',
|
||||
sectionNames: {
|
||||
database: 'Base de datos',
|
||||
logs: 'Registros',
|
||||
storage: 'Archivos subidos',
|
||||
vector_store: 'Almacén vectorial',
|
||||
plugins: 'Plugins',
|
||||
mcp: 'MCP',
|
||||
temp: 'Archivos temporales',
|
||||
},
|
||||
},
|
||||
limitation: {
|
||||
maxBotsReached:
|
||||
'Se ha alcanzado el número máximo de Bots ({{max}}). Por favor, elimina un Bot existente antes de crear uno nuevo.',
|
||||
@@ -1371,10 +1328,6 @@ const esES = {
|
||||
backToWorkbench: 'Volver al panel de trabajo',
|
||||
},
|
||||
},
|
||||
pluginPages: {
|
||||
selectFromSidebar: 'Selecciona una página de plugin en la barra lateral',
|
||||
invalidPage: 'Página de plugin no válida',
|
||||
},
|
||||
};
|
||||
|
||||
export default esES;
|
||||
|
||||
@@ -5,8 +5,6 @@
|
||||
installedPlugins: 'インストール済みプラグイン',
|
||||
pluginMarket: 'プラグインマーケット',
|
||||
mcpServers: 'MCPサーバー',
|
||||
pluginPages: 'プラグインページ',
|
||||
pluginPagesTooltip: 'インストール済みプラグインが提供するビジュアルページ',
|
||||
quickStart: 'クイックスタート',
|
||||
},
|
||||
common: {
|
||||
@@ -203,9 +201,6 @@
|
||||
string: '文字列',
|
||||
number: '数値',
|
||||
boolean: 'ブール値',
|
||||
object: 'オブジェクト',
|
||||
objectJsonPlaceholder: '{ "type": "disabled" }',
|
||||
invalidJsonObject: '値は有効なJSONオブジェクトである必要があります',
|
||||
selectModelProvider: 'モデルプロバイダーを選択',
|
||||
modelProviderDescription: 'プロバイダーが提供するモデル名をご入力ください',
|
||||
modelManufacturer: 'モデルメーカー',
|
||||
@@ -497,7 +492,6 @@
|
||||
Command: 'コマンド',
|
||||
KnowledgeEngine: '知識エンジン',
|
||||
Parser: 'パーサー',
|
||||
Page: 'ページ',
|
||||
},
|
||||
uploadLocal: 'ローカルアップロード',
|
||||
debugging: 'デバッグ中',
|
||||
@@ -1230,41 +1224,6 @@
|
||||
feedback: 'ユーザーフィードバック',
|
||||
},
|
||||
},
|
||||
storageAnalysis: {
|
||||
title: 'ストレージ分析',
|
||||
description: 'ストレージ使用量とクリーンアップ候補を確認します',
|
||||
openDialog: '分析を表示',
|
||||
dialogTitle: 'ストレージ分析',
|
||||
generatedAt: '生成日時 {{time}}',
|
||||
loading: '読み込み中...',
|
||||
refresh: '更新',
|
||||
totalSize: '合計サイズ',
|
||||
binaryStorage: 'プラグインバイナリストレージ',
|
||||
uploadCleanup: '期限切れアップロード',
|
||||
logCleanup: '期限切れログ',
|
||||
sections: 'ストレージセクション',
|
||||
monitoringTables: '監視テーブル',
|
||||
runtimeTasks: '実行タスク',
|
||||
cleanupPolicy: 'クリーンアップポリシー',
|
||||
uploadRetention: 'アップロード保持期間',
|
||||
logRetention: 'ログ保持期間',
|
||||
databaseType: 'データベース種別',
|
||||
days: '日',
|
||||
missing: 'なし',
|
||||
expiredUploads: '期限切れアップロード',
|
||||
expiredLogs: '期限切れログ',
|
||||
noExpiredUploads: '期限切れのアップロードファイルはありません',
|
||||
noExpiredLogs: '期限切れのログファイルはありません',
|
||||
sectionNames: {
|
||||
database: 'データベース',
|
||||
logs: 'ログ',
|
||||
storage: 'アップロードファイル',
|
||||
vector_store: 'ベクターストア',
|
||||
plugins: 'プラグイン',
|
||||
mcp: 'MCP',
|
||||
temp: '一時ファイル',
|
||||
},
|
||||
},
|
||||
limitation: {
|
||||
maxBotsReached:
|
||||
'ボット数が上限({{max}}個)に達しました。新しいボットを作成するには、既存のボットを削除してください。',
|
||||
@@ -1339,10 +1298,6 @@
|
||||
backToWorkbench: 'ワークベンチに戻る',
|
||||
},
|
||||
},
|
||||
pluginPages: {
|
||||
selectFromSidebar: 'サイドバーからプラグインページを選択してください',
|
||||
invalidPage: '無効なプラグインページ',
|
||||
},
|
||||
};
|
||||
|
||||
export default jaJP;
|
||||
|
||||
@@ -5,9 +5,6 @@ const ruRU = {
|
||||
installedPlugins: 'Установленные плагины',
|
||||
pluginMarket: 'Маркетплейс',
|
||||
mcpServers: 'MCP-серверы',
|
||||
pluginPages: 'Страницы плагинов',
|
||||
pluginPagesTooltip:
|
||||
'Визуальные страницы, предоставляемые установленными плагинами',
|
||||
quickStart: 'Быстрый старт',
|
||||
},
|
||||
common: {
|
||||
@@ -202,9 +199,6 @@ const ruRU = {
|
||||
string: 'Строка',
|
||||
number: 'Число',
|
||||
boolean: 'Логический',
|
||||
object: 'Объект',
|
||||
objectJsonPlaceholder: '{ "type": "disabled" }',
|
||||
invalidJsonObject: 'Значение должно быть допустимым объектом JSON',
|
||||
selectModelProvider: 'Выберите провайдера модели',
|
||||
modelProviderDescription:
|
||||
'Пожалуйста, введите название модели, предоставленное провайдером',
|
||||
@@ -503,7 +497,6 @@ const ruRU = {
|
||||
Command: 'Команда',
|
||||
KnowledgeEngine: 'Движок знаний',
|
||||
Parser: 'Парсер',
|
||||
Page: 'Страница',
|
||||
},
|
||||
uploadLocal: 'Загрузить локально',
|
||||
debugging: 'Отладка',
|
||||
@@ -1234,41 +1227,6 @@ const ruRU = {
|
||||
feedback: 'Отзывы пользователей',
|
||||
},
|
||||
},
|
||||
storageAnalysis: {
|
||||
title: 'Анализ хранилища',
|
||||
description: 'Проверьте использование хранилища и кандидатов на очистку',
|
||||
openDialog: 'Открыть анализ',
|
||||
dialogTitle: 'Анализ хранилища',
|
||||
generatedAt: 'Создано {{time}}',
|
||||
loading: 'Загрузка...',
|
||||
refresh: 'Обновить',
|
||||
totalSize: 'Общий размер',
|
||||
binaryStorage: 'Бинарное хранилище плагинов',
|
||||
uploadCleanup: 'Просроченные загрузки',
|
||||
logCleanup: 'Просроченные журналы',
|
||||
sections: 'Разделы хранилища',
|
||||
monitoringTables: 'Таблицы мониторинга',
|
||||
runtimeTasks: 'Задачи runtime',
|
||||
cleanupPolicy: 'Политика очистки',
|
||||
uploadRetention: 'Хранение загрузок',
|
||||
logRetention: 'Хранение журналов',
|
||||
databaseType: 'Тип базы данных',
|
||||
days: 'дн.',
|
||||
missing: 'Нет',
|
||||
expiredUploads: 'Просроченные загрузки',
|
||||
expiredLogs: 'Просроченные журналы',
|
||||
noExpiredUploads: 'Нет просроченных загруженных файлов',
|
||||
noExpiredLogs: 'Нет просроченных журналов',
|
||||
sectionNames: {
|
||||
database: 'База данных',
|
||||
logs: 'Журналы',
|
||||
storage: 'Загруженные файлы',
|
||||
vector_store: 'Векторное хранилище',
|
||||
plugins: 'Плагины',
|
||||
mcp: 'MCP',
|
||||
temp: 'Временные файлы',
|
||||
},
|
||||
},
|
||||
limitation: {
|
||||
maxBotsReached:
|
||||
'Достигнуто максимальное количество ботов ({{max}}). Удалите существующего бота перед созданием нового.',
|
||||
@@ -1342,10 +1300,6 @@ const ruRU = {
|
||||
backToWorkbench: 'Вернуться к рабочей панели',
|
||||
},
|
||||
},
|
||||
pluginPages: {
|
||||
selectFromSidebar: 'Выберите страницу плагина на боковой панели',
|
||||
invalidPage: 'Недопустимая страница плагина',
|
||||
},
|
||||
};
|
||||
|
||||
export default ruRU;
|
||||
|
||||
@@ -5,8 +5,6 @@ const thTH = {
|
||||
installedPlugins: 'ปลั๊กอินที่ติดตั้ง',
|
||||
pluginMarket: 'ตลาดปลั๊กอิน',
|
||||
mcpServers: 'เซิร์ฟเวอร์ MCP',
|
||||
pluginPages: 'หน้าปลั๊กอิน',
|
||||
pluginPagesTooltip: 'หน้าเว็บที่จัดทำโดยปลั๊กอินที่ติดตั้ง',
|
||||
quickStart: 'เริ่มต้นอย่างรวดเร็ว',
|
||||
},
|
||||
common: {
|
||||
@@ -198,9 +196,6 @@ const thTH = {
|
||||
string: 'สตริง',
|
||||
number: 'ตัวเลข',
|
||||
boolean: 'บูลีน',
|
||||
object: 'อ็อบเจกต์',
|
||||
objectJsonPlaceholder: '{ "type": "disabled" }',
|
||||
invalidJsonObject: 'ค่าต้องเป็นอ็อบเจกต์ JSON ที่ถูกต้อง',
|
||||
selectModelProvider: 'เลือกผู้ให้บริการโมเดล',
|
||||
modelProviderDescription: 'กรุณากรอกชื่อโมเดลที่ผู้ให้บริการจัดเตรียมไว้',
|
||||
modelManufacturer: 'ผู้ผลิตโมเดล',
|
||||
@@ -487,7 +482,6 @@ const thTH = {
|
||||
Command: 'คำสั่ง',
|
||||
KnowledgeEngine: 'เครื่องมือความรู้',
|
||||
Parser: 'ตัวแยกวิเคราะห์',
|
||||
Page: 'หน้า',
|
||||
},
|
||||
uploadLocal: 'อัปโหลดจากเครื่อง',
|
||||
debugging: 'ดีบัก',
|
||||
@@ -1205,41 +1199,6 @@ const thTH = {
|
||||
feedback: 'ความคิดเห็นผู้ใช้',
|
||||
},
|
||||
},
|
||||
storageAnalysis: {
|
||||
title: 'วิเคราะห์พื้นที่จัดเก็บ',
|
||||
description: 'ตรวจสอบการใช้พื้นที่จัดเก็บและรายการที่สามารถล้างได้',
|
||||
openDialog: 'ดูการวิเคราะห์',
|
||||
dialogTitle: 'วิเคราะห์พื้นที่จัดเก็บ',
|
||||
generatedAt: 'สร้างเมื่อ {{time}}',
|
||||
loading: 'กำลังโหลด...',
|
||||
refresh: 'รีเฟรช',
|
||||
totalSize: 'ขนาดรวม',
|
||||
binaryStorage: 'พื้นที่จัดเก็บไบนารีของปลั๊กอิน',
|
||||
uploadCleanup: 'ไฟล์อัปโหลดที่หมดอายุ',
|
||||
logCleanup: 'บันทึกที่หมดอายุ',
|
||||
sections: 'ส่วนพื้นที่จัดเก็บ',
|
||||
monitoringTables: 'ตารางการตรวจสอบ',
|
||||
runtimeTasks: 'งาน runtime',
|
||||
cleanupPolicy: 'นโยบายการล้างข้อมูล',
|
||||
uploadRetention: 'ระยะเวลาเก็บไฟล์อัปโหลด',
|
||||
logRetention: 'ระยะเวลาเก็บบันทึก',
|
||||
databaseType: 'ชนิดฐานข้อมูล',
|
||||
days: 'วัน',
|
||||
missing: 'ไม่มี',
|
||||
expiredUploads: 'ไฟล์อัปโหลดที่หมดอายุ',
|
||||
expiredLogs: 'บันทึกที่หมดอายุ',
|
||||
noExpiredUploads: 'ไม่มีไฟล์อัปโหลดที่หมดอายุ',
|
||||
noExpiredLogs: 'ไม่มีบันทึกที่หมดอายุ',
|
||||
sectionNames: {
|
||||
database: 'ฐานข้อมูล',
|
||||
logs: 'บันทึก',
|
||||
storage: 'ไฟล์อัปโหลด',
|
||||
vector_store: 'คลังเวกเตอร์',
|
||||
plugins: 'ปลั๊กอิน',
|
||||
mcp: 'MCP',
|
||||
temp: 'ไฟล์ชั่วคราว',
|
||||
},
|
||||
},
|
||||
limitation: {
|
||||
maxBotsReached:
|
||||
'จำนวน Bot สูงสุด ({{max}}) ถึงขีดจำกัดแล้ว กรุณาลบ Bot ที่มีอยู่ก่อนสร้างใหม่',
|
||||
@@ -1311,10 +1270,6 @@ const thTH = {
|
||||
backToWorkbench: 'กลับไปหน้าทำงาน',
|
||||
},
|
||||
},
|
||||
pluginPages: {
|
||||
selectFromSidebar: 'เลือกหน้าปลั๊กอินจากแถบด้านข้าง',
|
||||
invalidPage: 'หน้าปลั๊กอินไม่ถูกต้อง',
|
||||
},
|
||||
};
|
||||
|
||||
export default thTH;
|
||||
|
||||
@@ -5,9 +5,6 @@ const viVN = {
|
||||
installedPlugins: 'Plugin đã cài đặt',
|
||||
pluginMarket: 'Chợ ứng dụng',
|
||||
mcpServers: 'Máy chủ MCP',
|
||||
pluginPages: 'Trang plugin',
|
||||
pluginPagesTooltip:
|
||||
'Các trang trực quan được cung cấp bởi plugin đã cài đặt',
|
||||
quickStart: 'Bắt đầu nhanh',
|
||||
},
|
||||
common: {
|
||||
@@ -202,9 +199,6 @@ const viVN = {
|
||||
string: 'Chuỗi',
|
||||
number: 'Số',
|
||||
boolean: 'Boolean',
|
||||
object: 'Đối tượng',
|
||||
objectJsonPlaceholder: '{ "type": "disabled" }',
|
||||
invalidJsonObject: 'Giá trị phải là một đối tượng JSON hợp lệ',
|
||||
selectModelProvider: 'Chọn nhà cung cấp mô hình',
|
||||
modelProviderDescription:
|
||||
'Vui lòng điền tên mô hình do nhà cung cấp cung cấp',
|
||||
@@ -498,7 +492,6 @@ const viVN = {
|
||||
Command: 'Lệnh',
|
||||
KnowledgeEngine: 'Công cụ tri thức',
|
||||
Parser: 'Trình phân tích',
|
||||
Page: 'Trang',
|
||||
},
|
||||
uploadLocal: 'Tải lên cục bộ',
|
||||
debugging: 'Gỡ lỗi',
|
||||
@@ -1227,41 +1220,6 @@ const viVN = {
|
||||
feedback: 'Phản hồi người dùng',
|
||||
},
|
||||
},
|
||||
storageAnalysis: {
|
||||
title: 'Phân tích lưu trữ',
|
||||
description: 'Kiểm tra dung lượng lưu trữ và các mục có thể dọn dẹp',
|
||||
openDialog: 'Xem phân tích',
|
||||
dialogTitle: 'Phân tích lưu trữ',
|
||||
generatedAt: 'Tạo lúc {{time}}',
|
||||
loading: 'Đang tải...',
|
||||
refresh: 'Làm mới',
|
||||
totalSize: 'Tổng dung lượng',
|
||||
binaryStorage: 'Lưu trữ nhị phân plugin',
|
||||
uploadCleanup: 'Tệp tải lên hết hạn',
|
||||
logCleanup: 'Nhật ký hết hạn',
|
||||
sections: 'Khu vực lưu trữ',
|
||||
monitoringTables: 'Bảng giám sát',
|
||||
runtimeTasks: 'Tác vụ runtime',
|
||||
cleanupPolicy: 'Chính sách dọn dẹp',
|
||||
uploadRetention: 'Thời gian giữ tệp tải lên',
|
||||
logRetention: 'Thời gian giữ nhật ký',
|
||||
databaseType: 'Loại cơ sở dữ liệu',
|
||||
days: 'ngày',
|
||||
missing: 'Thiếu',
|
||||
expiredUploads: 'Tệp tải lên hết hạn',
|
||||
expiredLogs: 'Nhật ký hết hạn',
|
||||
noExpiredUploads: 'Không có tệp tải lên hết hạn',
|
||||
noExpiredLogs: 'Không có nhật ký hết hạn',
|
||||
sectionNames: {
|
||||
database: 'Cơ sở dữ liệu',
|
||||
logs: 'Nhật ký',
|
||||
storage: 'Tệp tải lên',
|
||||
vector_store: 'Kho vector',
|
||||
plugins: 'Plugin',
|
||||
mcp: 'MCP',
|
||||
temp: 'Tệp tạm',
|
||||
},
|
||||
},
|
||||
limitation: {
|
||||
maxBotsReached:
|
||||
'Đã đạt số lượng Bot tối đa ({{max}}). Vui lòng xóa một Bot hiện có trước khi tạo mới.',
|
||||
@@ -1333,10 +1291,6 @@ const viVN = {
|
||||
backToWorkbench: 'Quay lại bàn làm việc',
|
||||
},
|
||||
},
|
||||
pluginPages: {
|
||||
selectFromSidebar: 'Chọn một trang plugin từ thanh bên',
|
||||
invalidPage: 'Trang plugin không hợp lệ',
|
||||
},
|
||||
};
|
||||
|
||||
export default viVN;
|
||||
|
||||
@@ -5,8 +5,6 @@ const zhHans = {
|
||||
installedPlugins: '已安装插件',
|
||||
pluginMarket: '插件市场',
|
||||
mcpServers: 'MCP 服务器',
|
||||
pluginPages: '插件页面',
|
||||
pluginPagesTooltip: '由已安装的插件提供的可视化页面',
|
||||
quickStart: '快速开始向导',
|
||||
},
|
||||
common: {
|
||||
@@ -192,9 +190,6 @@ const zhHans = {
|
||||
string: '字符串',
|
||||
number: '数字',
|
||||
boolean: '布尔值',
|
||||
object: '对象',
|
||||
objectJsonPlaceholder: '{ "type": "disabled" }',
|
||||
invalidJsonObject: '值必须是有效的 JSON 对象',
|
||||
selectModelProvider: '选择模型供应商',
|
||||
modelProviderDescription: '请填写供应商向您提供的模型名称',
|
||||
modelManufacturer: '模型厂商',
|
||||
@@ -470,7 +465,6 @@ const zhHans = {
|
||||
Command: '命令',
|
||||
KnowledgeEngine: '知识引擎',
|
||||
Parser: '解析器',
|
||||
Page: '页面',
|
||||
},
|
||||
uploadLocal: '本地上传',
|
||||
debugging: '调试中',
|
||||
@@ -1171,41 +1165,6 @@ const zhHans = {
|
||||
feedback: '用户反馈',
|
||||
},
|
||||
},
|
||||
storageAnalysis: {
|
||||
title: '存储分析',
|
||||
description: '查看存储占用和可清理文件',
|
||||
openDialog: '查看分析',
|
||||
dialogTitle: '存储分析',
|
||||
generatedAt: '生成时间 {{time}}',
|
||||
loading: '加载中...',
|
||||
refresh: '刷新',
|
||||
totalSize: '总占用',
|
||||
binaryStorage: '插件二进制存储',
|
||||
uploadCleanup: '过期上传文件',
|
||||
logCleanup: '过期日志',
|
||||
sections: '存储分区',
|
||||
monitoringTables: '监控表',
|
||||
runtimeTasks: '运行任务',
|
||||
cleanupPolicy: '清理策略',
|
||||
uploadRetention: '上传文件保留',
|
||||
logRetention: '日志保留',
|
||||
databaseType: '数据库类型',
|
||||
days: '天',
|
||||
missing: '不存在',
|
||||
expiredUploads: '过期上传文件',
|
||||
expiredLogs: '过期日志',
|
||||
noExpiredUploads: '暂无过期上传文件',
|
||||
noExpiredLogs: '暂无过期日志',
|
||||
sectionNames: {
|
||||
database: '数据库',
|
||||
logs: '日志',
|
||||
storage: '上传文件',
|
||||
vector_store: '向量库',
|
||||
plugins: '插件',
|
||||
mcp: 'MCP',
|
||||
temp: '临时文件',
|
||||
},
|
||||
},
|
||||
limitation: {
|
||||
maxBotsReached:
|
||||
'已达到机器人数量上限({{max}}个)。请先删除已有机器人后再创建新的。',
|
||||
@@ -1274,10 +1233,6 @@ const zhHans = {
|
||||
backToWorkbench: '返回工作台',
|
||||
},
|
||||
},
|
||||
pluginPages: {
|
||||
selectFromSidebar: '从侧边栏选择一个插件页面',
|
||||
invalidPage: '无效的插件页面',
|
||||
},
|
||||
};
|
||||
|
||||
export default zhHans;
|
||||
|
||||
@@ -5,8 +5,6 @@ const zhHant = {
|
||||
installedPlugins: '已安裝外掛',
|
||||
pluginMarket: '外掛市場',
|
||||
mcpServers: 'MCP 伺服器',
|
||||
pluginPages: '插件頁面',
|
||||
pluginPagesTooltip: '由已安裝的插件提供的視覺化頁面',
|
||||
quickStart: '快速開始',
|
||||
},
|
||||
common: {
|
||||
@@ -192,9 +190,6 @@ const zhHant = {
|
||||
string: '字串',
|
||||
number: '數字',
|
||||
boolean: '布林值',
|
||||
object: '物件',
|
||||
objectJsonPlaceholder: '{ "type": "disabled" }',
|
||||
invalidJsonObject: '值必須是有效的 JSON 物件',
|
||||
selectModelProvider: '選擇模型供應商',
|
||||
modelProviderDescription: '請填寫供應商向您提供的模型名稱',
|
||||
modelManufacturer: '模型廠商',
|
||||
@@ -471,7 +466,6 @@ const zhHant = {
|
||||
Command: '命令',
|
||||
KnowledgeEngine: '知識引擎',
|
||||
Parser: '解析器',
|
||||
Page: '擴展頁',
|
||||
},
|
||||
uploadLocal: '本地上傳',
|
||||
debugging: '調試中',
|
||||
@@ -1171,41 +1165,6 @@ const zhHant = {
|
||||
feedback: '使用者回饋',
|
||||
},
|
||||
},
|
||||
storageAnalysis: {
|
||||
title: '儲存分析',
|
||||
description: '查看儲存占用和可清理檔案',
|
||||
openDialog: '查看分析',
|
||||
dialogTitle: '儲存分析',
|
||||
generatedAt: '生成時間 {{time}}',
|
||||
loading: '載入中...',
|
||||
refresh: '重新整理',
|
||||
totalSize: '總占用',
|
||||
binaryStorage: '插件二進位儲存',
|
||||
uploadCleanup: '過期上傳檔案',
|
||||
logCleanup: '過期日誌',
|
||||
sections: '儲存分區',
|
||||
monitoringTables: '監控表',
|
||||
runtimeTasks: '執行任務',
|
||||
cleanupPolicy: '清理策略',
|
||||
uploadRetention: '上傳檔案保留',
|
||||
logRetention: '日誌保留',
|
||||
databaseType: '資料庫類型',
|
||||
days: '天',
|
||||
missing: '不存在',
|
||||
expiredUploads: '過期上傳檔案',
|
||||
expiredLogs: '過期日誌',
|
||||
noExpiredUploads: '暫無過期上傳檔案',
|
||||
noExpiredLogs: '暫無過期日誌',
|
||||
sectionNames: {
|
||||
database: '資料庫',
|
||||
logs: '日誌',
|
||||
storage: '上傳檔案',
|
||||
vector_store: '向量庫',
|
||||
plugins: '插件',
|
||||
mcp: 'MCP',
|
||||
temp: '暫存檔案',
|
||||
},
|
||||
},
|
||||
limitation: {
|
||||
maxBotsReached:
|
||||
'已達到機器人數量上限({{max}}個)。請先刪除已有機器人後再建立新的。',
|
||||
@@ -1274,10 +1233,6 @@ const zhHant = {
|
||||
backToWorkbench: '返回工作台',
|
||||
},
|
||||
},
|
||||
pluginPages: {
|
||||
selectFromSidebar: '從側邊欄選擇一個插件頁面',
|
||||
invalidPage: '無效的插件頁面',
|
||||
},
|
||||
};
|
||||
|
||||
export default zhHant;
|
||||
|
||||
@@ -21,7 +21,6 @@ import PluginsPage from '@/app/home/plugins/page';
|
||||
import MarketPage from '@/app/home/market/page';
|
||||
import MCPPage from '@/app/home/mcp/page';
|
||||
import KnowledgePage from '@/app/home/knowledge/page';
|
||||
import PluginPagesPage from '@/app/home/plugin-pages/page';
|
||||
|
||||
const Loading = () => <div>Loading...</div>;
|
||||
|
||||
@@ -142,14 +141,4 @@ export const router = createBrowserRouter([
|
||||
</Suspense>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: '/home/plugin-pages',
|
||||
element: (
|
||||
<Suspense fallback={<Loading />}>
|
||||
<HomeLayout>
|
||||
<PluginPagesPage />
|
||||
</HomeLayout>
|
||||
</Suspense>
|
||||
),
|
||||
},
|
||||
]);
|
||||
|
||||
Reference in New Issue
Block a user