Compare commits

..

2 Commits

Author SHA1 Message Date
copilot-swe-agent[bot]
eefb0ddde1 fix(model): preserve UUID when updating LLM and embedding models in runtime
Agent-Logs-Url: https://github.com/langbot-app/LangBot/sessions/ffaa444c-501a-41ef-bf8b-d2cdef3b7625

Co-authored-by: RockChinQ <45992437+RockChinQ@users.noreply.github.com>
2026-04-19 14:34:44 +00:00
copilot-swe-agent[bot]
4c7ee6a9c9 Initial plan 2026-04-19 14:27:38 +00:00
136 changed files with 771 additions and 10646 deletions

1
.gitignore vendored
View File

@@ -47,7 +47,6 @@ plugins.bak
coverage.xml coverage.xml
.coverage .coverage
src/langbot/web/ src/langbot/web/
testsdk/
# Build artifacts # Build artifacts
/dist /dist

View File

@@ -84,26 +84,24 @@ docker compose up -d
| Platform | Status | Notes | | Platform | Status | Notes |
|----------|--------|-------| |----------|--------|-------|
| Discord | ✅ | Official | | Discord | ✅ | |
| Telegram | ✅ | Official | | Telegram | ✅ | |
| Slack | ✅ | Official | | Slack | ✅ | |
| LINE | ✅ | Official | | LINE | ✅ | |
| QQ | ✅ | Personal & Official API (Channel, DM, Group) | | QQ | ✅ | Personal & Official API |
| WeCom | ✅ | Enterprise WeChat, External CS, AI Bot | | WeCom | ✅ | Enterprise WeChat, External CS, AI Bot |
| WeChat | ✅ | Personal & Official Account | | WeChat | ✅ | Personal & Official Account |
| Lark | ✅ | Official | | Lark | ✅ | |
| DingTalk | ✅ | Official | | DingTalk | ✅ | |
| KOOK | ✅ | Official | | KOOK | ✅ | |
| Satori | ✅ | | | 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 ## Supported LLMs & Integrations
| Provider | Type | Status | | Provider | Type | Status |
| ----------------------------------------------------------------------------------------------------------------- | ------------ | ------ | |----------|------|--------|
| [OpenAI](https://platform.openai.com/) | LLM | ✅ | | [OpenAI](https://platform.openai.com/) | LLM | ✅ |
| [Anthropic](https://www.anthropic.com/) | LLM | ✅ | | [Anthropic](https://www.anthropic.com/) | LLM | ✅ |
| [DeepSeek](https://www.deepseek.com/) | LLM | ✅ | | [DeepSeek](https://www.deepseek.com/) | LLM | ✅ |
@@ -125,7 +123,6 @@ docker compose up -d
| [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | GPU Platform | ✅ | | [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | GPU Platform | ✅ |
| [接口 AI](https://jiekou.ai/) | Gateway | ✅ | | [接口 AI](https://jiekou.ai/) | Gateway | ✅ |
| [302.AI](https://share.302.ai/SuTG99) | Gateway | ✅ | | [302.AI](https://share.302.ai/SuTG99) | Gateway | ✅ |
| [Qiniu](https://www.qiniu.com/ai/agent) | Gateway | ✅ |
[→ View all integrations](https://link.langbot.app/en/docs/features) [→ View all integrations](https://link.langbot.app/en/docs/features)
@@ -134,7 +131,7 @@ docker compose up -d
## Why LangBot? ## Why LangBot?
| Use Case | How LangBot Helps | | Use Case | How LangBot Helps |
| --------------------------- | ------------------------------------------------------------------------------------------ | |----------|-------------------|
| **Customer Support** | Deploy AI agents to Slack/Discord/Telegram that answer questions using your knowledge base | | **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 | | **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 | | **Community Management** | Moderate QQ/Discord groups with AI-powered content filtering and interaction |
@@ -145,11 +142,10 @@ docker compose up -d
## Live Demo ## Live Demo
**Try it now:** https://demo.langbot.dev/ **Try it now:** https://demo.langbot.dev/
- Email: `demo@langbot.app` - Email: `demo@langbot.app`
- Password: `langbot123456` - Password: `langbot123456`
_Note: Public demo environment. Do not enter sensitive information._ *Note: Public demo environment. Do not enter sensitive information.*
--- ---

View File

@@ -87,16 +87,13 @@ docker compose up -d
| QQ | ✅ | 个人号、官方机器人(频道、私聊、群聊) | | QQ | ✅ | 个人号、官方机器人(频道、私聊、群聊) |
| 微信 | ✅ | 个人微信、微信公众号 | | 微信 | ✅ | 个人微信、微信公众号 |
| 企业微信 | ✅ | 应用消息、对外客服、智能机器人 | | 企业微信 | ✅ | 应用消息、对外客服、智能机器人 |
| 飞书 | ✅ | 官方 | | 飞书 | ✅ | |
| 钉钉 | ✅ | 官方 | | 钉钉 | ✅ | |
| Satori | ✅ | | | Discord | ✅ | |
| Discord | ✅ | 官方 | | Telegram | ✅ | |
| Telegram | ✅ | 官方 | | Slack | ✅ | |
| Slack | ✅ | 官方 | | LINE | ✅ | |
| LINE | ✅ | 官方 | | KOOK | ✅ | |
| KOOK | ✅ | 官方 |
| Email | ✅ | 只 Matrix、Satori |
| Matrix | ✅ | 支持多种桥接平台,如 Signal、WhatsApp、Messenger、iMessage、Mattermost、Google Chat、IRC、XMPP、Zulip 等 |
--- ---
@@ -127,7 +124,6 @@ docker compose up -d
| [302.AI](https://share.302.ai/SuTG99) | 聚合平台 | ✅ | | [302.AI](https://share.302.ai/SuTG99) | 聚合平台 | ✅ |
| [小马算力](https://www.tokenpony.cn/453z1) | 聚合平台 | ✅ | | [小马算力](https://www.tokenpony.cn/453z1) | 聚合平台 | ✅ |
| [百宝箱Tbox](https://www.tbox.cn/open) | 智能体平台 | ✅ | | [百宝箱Tbox](https://www.tbox.cn/open) | 智能体平台 | ✅ |
| [七牛云Qiniu](https://www.qiniu.com/ai/agent) | 聚合平台 | ✅ |
[→ 查看完整集成列表](https://link.langbot.app/zh/docs/features) [→ 查看完整集成列表](https://link.langbot.app/zh/docs/features)

View File

@@ -83,19 +83,17 @@ docker compose up -d
| Plataforma | Estado | Notas | | Plataforma | Estado | Notas |
|----------|--------|-------| |----------|--------|-------|
| Discord | ✅ | Oficial | | Discord | ✅ | |
| Telegram | ✅ | Oficial | | Telegram | ✅ | |
| Slack | ✅ | Oficial | | Slack | ✅ | |
| LINE | ✅ | Oficial | | LINE | ✅ | |
| QQ | ✅ | Personal y API Oficial (Canal, DM, Grupo) | | QQ | ✅ | Personal y API Oficial |
| WeCom | ✅ | WeChat Empresarial, CS Externo, AI Bot | | WeCom | ✅ | WeChat Empresarial, CS Externo, AI Bot |
| WeChat | ✅ | Personal y Cuenta Oficial | | WeChat | ✅ | Personal y Cuenta Oficial |
| Lark | ✅ | Oficial | | Lark | ✅ | |
| DingTalk | ✅ | Oficial | | DingTalk | ✅ | |
| KOOK | ✅ | Oficial | | KOOK | ✅ | |
| Satori | ✅ | | | 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 | ✅ | | [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | Plataforma GPU | ✅ |
| [接口 AI](https://jiekou.ai/) | Pasarela | ✅ | | [接口 AI](https://jiekou.ai/) | Pasarela | ✅ |
| [302.AI](https://share.302.ai/SuTG99) | Pasarela | ✅ | | [302.AI](https://share.302.ai/SuTG99) | Pasarela | ✅ |
| [Qiniu](https://www.qiniu.com/ai/agent) | Pasarela | ✅ |
[→ Ver todas las integraciones](https://link.langbot.app/en/docs/features) [→ Ver todas las integraciones](https://link.langbot.app/en/docs/features)

View File

@@ -83,19 +83,17 @@ docker compose up -d
| Plateforme | Statut | Notes | | Plateforme | Statut | Notes |
|----------|--------|-------| |----------|--------|-------|
| Discord | ✅ | Officiel | | Discord | ✅ | |
| Telegram | ✅ | Officiel | | Telegram | ✅ | |
| Slack | ✅ | Officiel | | Slack | ✅ | |
| LINE | ✅ | Officiel | | LINE | ✅ | |
| QQ | ✅ | Personnel & API Officielle (Canal, DM, Groupe) | | QQ | ✅ | Personnel & API Officielle |
| WeCom | ✅ | WeChat Entreprise, CS Externe, AI Bot | | WeCom | ✅ | WeChat Entreprise, CS Externe, AI Bot |
| WeChat | ✅ | Personnel & Compte Officiel | | WeChat | ✅ | Personnel & Compte Officiel |
| Lark | ✅ | Officiel | | Lark | ✅ | |
| DingTalk | ✅ | Officiel | | DingTalk | ✅ | |
| KOOK | ✅ | Officiel | | KOOK | ✅ | |
| Satori | ✅ | | | 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 | ✅ | | [CompShare](https://www.compshare.cn/?ytag=GPU_YY-gh_langbot) | Plateforme GPU | ✅ |
| [PPIO](https://ppinfra.com/user/register?invited_by=QJKFYD&utm_source=github_langbot) | Plateforme GPU | ✅ | | [PPIO](https://ppinfra.com/user/register?invited_by=QJKFYD&utm_source=github_langbot) | Plateforme GPU | ✅ |
| [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | Plateforme GPU | ✅ | | [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | Plateforme GPU | ✅ |
| [Qiniu](https://www.qiniu.com/ai/agent) | Passerelle | ✅ |
[→ Voir toutes les intégrations](https://link.langbot.app/en/docs/features) [→ Voir toutes les intégrations](https://link.langbot.app/en/docs/features)

View File

@@ -83,19 +83,17 @@ docker compose up -d
| プラットフォーム | ステータス | 備考 | | プラットフォーム | ステータス | 備考 |
|----------|--------|-------| |----------|--------|-------|
| Discord | ✅ | 公式 | | Discord | ✅ | |
| Telegram | ✅ | 公式 | | Telegram | ✅ | |
| Slack | ✅ | 公式 | | Slack | ✅ | |
| LINE | ✅ | 公式 | | LINE | ✅ | |
| QQ | ✅ | 個人公式APIチャンネル・DM・グループ | | QQ | ✅ | 個人 & 公式API |
| WeCom | ✅ | 企業WeChat、外部CS、AIボット | | WeCom | ✅ | 企業WeChat、外部CS、AIボット |
| WeChat | ✅ | 個人公式アカウント | | WeChat | ✅ | 個人 & 公式アカウント |
| Lark | ✅ | 公式 | | Lark | ✅ | |
| DingTalk | ✅ | 公式 | | DingTalk | ✅ | |
| KOOK | ✅ | 公式 | | KOOK | ✅ | |
| Satori | ✅ | | | 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プラットフォーム | ✅ | | [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | GPUプラットフォーム | ✅ |
| [接口 AI](https://jiekou.ai/) | ゲートウェイ | ✅ | | [接口 AI](https://jiekou.ai/) | ゲートウェイ | ✅ |
| [302.AI](https://share.302.ai/SuTG99) | ゲートウェイ | ✅ | | [302.AI](https://share.302.ai/SuTG99) | ゲートウェイ | ✅ |
| [Qiniu](https://www.qiniu.com/ai/agent) | ゲートウェイ | ✅ |
[→ すべての統合を表示](https://link.langbot.app/en/docs/features) [→ すべての統合を表示](https://link.langbot.app/en/docs/features)

View File

@@ -83,19 +83,17 @@ docker compose up -d
| 플랫폼 | 상태 | 비고 | | 플랫폼 | 상태 | 비고 |
|--------|------|------| |--------|------|------|
| Discord | ✅ | 공식 | | Discord | ✅ | |
| Telegram | ✅ | 공식 | | Telegram | ✅ | |
| Slack | ✅ | 공식 | | Slack | ✅ | |
| LINE | ✅ | 공식 | | LINE | ✅ | |
| QQ | ✅ | 개인 및 공식 API (채널, DM, 그룹) | | QQ | ✅ | 개인 및 공식 API |
| WeCom | ✅ | 기업 WeChat, 외부 CS, AI Bot | | WeCom | ✅ | 기업 WeChat, 외부 CS, AI Bot |
| WeChat | ✅ | 개인 및 공식 계정 | | WeChat | ✅ | 개인 및 공식 계정 |
| Lark | ✅ | 공식 | | Lark | ✅ | |
| DingTalk | ✅ | 공식 | | DingTalk | ✅ | |
| KOOK | ✅ | 공식 | | KOOK | ✅ | |
| Satori | ✅ | | | 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 플랫폼 | ✅ | | [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | GPU 플랫폼 | ✅ |
| [接口 AI](https://jiekou.ai/) | 게이트웨이 | ✅ | | [接口 AI](https://jiekou.ai/) | 게이트웨이 | ✅ |
| [302.AI](https://share.302.ai/SuTG99) | 게이트웨이 | ✅ | | [302.AI](https://share.302.ai/SuTG99) | 게이트웨이 | ✅ |
| [Qiniu](https://www.qiniu.com/ai/agent) | 게이트웨이 | ✅ |
[→ 모든 통합 보기](https://link.langbot.app/en/docs/features) [→ 모든 통합 보기](https://link.langbot.app/en/docs/features)

View File

@@ -83,19 +83,17 @@ docker compose up -d
| Платформа | Статус | Примечания | | Платформа | Статус | Примечания |
|-----------|--------|------------| |-----------|--------|------------|
| Discord | ✅ | Официальный | | Discord | ✅ | |
| Telegram | ✅ | Официальный | | Telegram | ✅ | |
| Slack | ✅ | Официальный | | Slack | ✅ | |
| LINE | ✅ | Официальный | | LINE | ✅ | |
| QQ | ✅ | Личный и официальный API (Канал, ЛС, Группа) | | QQ | ✅ | Личный и официальный API |
| WeCom | ✅ | Корпоративный WeChat, внешний CS, AI-бот | | WeCom | ✅ | Корпоративный WeChat, внешний CS, AI-бот |
| WeChat | ✅ | Личный и официальный аккаунт | | WeChat | ✅ | Личный и официальный аккаунт |
| Lark | ✅ | Официальный | | Lark | ✅ | |
| DingTalk | ✅ | Официальный | | DingTalk | ✅ | |
| KOOK | ✅ | Официальный | | KOOK | ✅ | |
| Satori | ✅ | | | 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 | ✅ | | [CompShare](https://www.compshare.cn/?ytag=GPU_YY-gh_langbot) | Платформа GPU | ✅ |
| [PPIO](https://ppinfra.com/user/register?invited_by=QJKFYD&utm_source=github_langbot) | Платформа GPU | ✅ | | [PPIO](https://ppinfra.com/user/register?invited_by=QJKFYD&utm_source=github_langbot) | Платформа GPU | ✅ |
| [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | Платформа GPU | ✅ | | [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | Платформа GPU | ✅ |
| [Qiniu](https://www.qiniu.com/ai/agent) | Шлюз | ✅ |
[→ Смотреть все интеграции](https://link.langbot.app/en/docs/features) [→ Смотреть все интеграции](https://link.langbot.app/en/docs/features)

View File

@@ -85,19 +85,17 @@ docker compose up -d
| 平台 | 狀態 | 備註 | | 平台 | 狀態 | 備註 |
|------|------|------| |------|------|------|
| Discord | ✅ | 官方 |
| Telegram | ✅ | 官方 |
| Slack | ✅ | 官方 |
| LINE | ✅ | 官方 |
| QQ | ✅ | 個人號、官方機器人(頻道、私聊、群聊) | | QQ | ✅ | 個人號、官方機器人(頻道、私聊、群聊) |
| 企業微信 | ✅ | 應用訊息、對外客服、智能機器人 |
| 微信 | ✅ | 個人微信、微信公眾號 | | 微信 | ✅ | 個人微信、微信公眾號 |
| 飛書 | ✅ | 官方 | | 企業微信 | ✅ | 應用訊息、對外客服、智能機器人 |
| 釘釘 | ✅ | 官方 | | 飛書 | ✅ | |
| KOOK | ✅ | 官方 | | 釘釘 | ✅ | |
| Discord | ✅ | |
| Telegram | ✅ | |
| Slack | ✅ | |
| LINE | ✅ | |
| KOOK | ✅ | |
| Satori | ✅ | | | 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 平台 | ✅ | | [PPIO](https://ppinfra.com/user/register?invited_by=QJKFYD&utm_source=github_langbot) | GPU 平台 | ✅ |
| [接口 AI](https://jiekou.ai/) | 聚合平台 | ✅ | | [接口 AI](https://jiekou.ai/) | 聚合平台 | ✅ |
| [302.AI](https://share.302.ai/SuTG99) | 聚合平台 | ✅ | | [302.AI](https://share.302.ai/SuTG99) | 聚合平台 | ✅ |
| [Qiniu](https://www.qiniu.com/ai/agent) | 聚合平台 | ✅ |
### TTS語音合成 ### TTS語音合成

View File

@@ -83,19 +83,17 @@ docker compose up -d
| Nền tảng | Trạng thái | Ghi chú | | Nền tảng | Trạng thái | Ghi chú |
|----------|--------|-------| |----------|--------|-------|
| Discord | ✅ | Chính thức | | Discord | ✅ | |
| Telegram | ✅ | Chính thức | | Telegram | ✅ | |
| Slack | ✅ | Chính thức | | Slack | ✅ | |
| LINE | ✅ | Chính thức | | LINE | ✅ | |
| QQ | ✅ | Cá nhân & API chính thức (Kênh, DM, Nhóm) | | QQ | ✅ | Cá nhân & API chính thức |
| WeCom | ✅ | WeChat doanh nghiệp, CS bên ngoài, AI Bot | | WeCom | ✅ | WeChat doanh nghiệp, CS bên ngoài, AI Bot |
| WeChat | ✅ | Cá nhân & Tài khoản công khai | | WeChat | ✅ | Cá nhân & Tài khoản công khai |
| Lark | ✅ | Chính thức | | Lark | ✅ | |
| DingTalk | ✅ | Chính thức | | DingTalk | ✅ | |
| KOOK | ✅ | Chính thức | | KOOK | ✅ | |
| Satori | ✅ | | | 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 | ✅ | | [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | Nền tảng GPU | ✅ |
| [接口 AI](https://jiekou.ai/) | Cổng | ✅ | | [接口 AI](https://jiekou.ai/) | Cổng | ✅ |
| [302.AI](https://share.302.ai/SuTG99) | Cổng | ✅ | | [302.AI](https://share.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) [→ Xem tất cả tích hợp](https://link.langbot.app/en/docs/features)

View File

@@ -1,6 +1,6 @@
[project] [project]
name = "langbot" name = "langbot"
version = "4.9.7" version = "4.9.6"
description = "Production-grade platform for building agentic IM bots" description = "Production-grade platform for building agentic IM bots"
readme = "README.md" readme = "README.md"
license-files = ["LICENSE"] license-files = ["LICENSE"]
@@ -22,7 +22,7 @@ dependencies = [
"discord-py>=2.5.2", "discord-py>=2.5.2",
"pynacl>=1.5.0", # Required for Discord voice support "pynacl>=1.5.0", # Required for Discord voice support
"gewechat-client>=0.1.5", "gewechat-client>=0.1.5",
"lark-oapi>=1.5.5", "lark-oapi>=1.4.15",
"mcp>=1.25.0", "mcp>=1.25.0",
"nakuru-project-idk>=0.0.2.1", "nakuru-project-idk>=0.0.2.1",
"ollama>=0.4.8", "ollama>=0.4.8",
@@ -35,7 +35,6 @@ dependencies = [
"python-telegram-bot>=22.0", "python-telegram-bot>=22.0",
"pyyaml>=6.0.2", "pyyaml>=6.0.2",
"qq-botpy-rc>=1.2.1.6", "qq-botpy-rc>=1.2.1.6",
"qrcode>=7.4",
"quart>=0.20.0", "quart>=0.20.0",
"quart-cors>=0.8.0", "quart-cors>=0.8.0",
"requests>=2.32.3", "requests>=2.32.3",
@@ -70,10 +69,9 @@ dependencies = [
"chromadb>=1.0.0,<2.0.0", "chromadb>=1.0.0,<2.0.0",
"qdrant-client (>=1.15.1,<2.0.0)", "qdrant-client (>=1.15.1,<2.0.0)",
"pyseekdb==1.1.0.post3", "pyseekdb==1.1.0.post3",
"langbot-plugin==0.3.11", "langbot-plugin==0.3.8",
"asyncpg>=0.30.0", "asyncpg>=0.30.0",
"line-bot-sdk>=3.19.0", "line-bot-sdk>=3.19.0",
"matrix-nio>=0.25.2",
"tboxsdk>=0.0.10", "tboxsdk>=0.0.10",
"boto3>=1.35.0", "boto3>=1.35.0",
"pymilvus>=2.6.4", "pymilvus>=2.6.4",

View File

@@ -1,3 +1,3 @@
"""LangBot - Production-grade platform for building agentic IM bots""" """LangBot - Production-grade platform for building agentic IM bots"""
__version__ = '4.9.7' __version__ = '4.9.6'

View File

@@ -481,12 +481,6 @@ class DingTalkClient:
card_data['config'] = json.dumps({'autoLayout': card_auto_layout}) card_data['config'] = json.dumps({'autoLayout': card_auto_layout})
card_data['content'] = '' 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) card_instance = dingtalk_stream.AICardReplier(self.client, incoming_message)
# print(card_instance) # print(card_instance)
# 先投放卡片: https://open.dingtalk.com/document/orgapp/create-and-deliver-cards # 先投放卡片: https://open.dingtalk.com/document/orgapp/create-and-deliver-cards

View File

@@ -1,10 +1,8 @@
import re
import time import time
import asyncio
from quart import request from quart import request
import httpx import httpx
from quart import Quart 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 import langbot_plugin.api.entities.builtin.platform.events as platform_events
from .qqofficialevent import QQOfficialEvent from .qqofficialevent import QQOfficialEvent
import json import json
@@ -34,8 +32,6 @@ class QQOfficialClient:
self.access_token = '' self.access_token = ''
self.access_token_expiry_time = None self.access_token_expiry_time = None
self.logger = logger self.logger = logger
self._msg_seq_counter = 0
self._token_refresh_task: Optional[asyncio.Task] = None
async def check_access_token(self): async def check_access_token(self):
"""检查access_token是否存在""" """检查access_token是否存在"""
@@ -54,18 +50,18 @@ class QQOfficialClient:
headers = { headers = {
'content-type': 'application/json', 'content-type': 'application/json',
} }
try:
response = await client.post(url, json=params, headers=headers) response = await client.post(url, json=params, headers=headers)
if response.status_code != 200: if response.status_code == 200:
raise Exception(f'Failed to get access_token: HTTP {response.status_code} {response.text}')
response_data = response.json() response_data = response.json()
access_token = response_data.get('access_token') access_token = response_data.get('access_token')
expires_in = int(response_data.get('expires_in', 7200)) expires_in = int(response_data.get('expires_in', 7200))
self.access_token_expiry_time = time.time() + expires_in - 60 self.access_token_expiry_time = time.time() + expires_in - 60
if access_token: if access_token:
self.access_token = access_token self.access_token = access_token
await self.logger.info(f'access_token obtained, expires_in={expires_in}s') except Exception as e:
else: await self.logger.error(f'获取access_token失败: {response_data}')
raise Exception('Failed to get access_token: no access_token in response') raise Exception(f'获取access_token失败: {e}')
async def handle_callback_request(self): async def handle_callback_request(self):
"""处理回调请求(独立端口模式,使用全局 request""" """处理回调请求(独立端口模式,使用全局 request"""
@@ -91,10 +87,10 @@ class QQOfficialClient:
try: try:
body = await req.get_data() 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: 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 return {'code': 0, 'message': 'ok'}, 200
payload = json.loads(body) payload = json.loads(body)
@@ -115,6 +111,7 @@ class QQOfficialClient:
return {'code': 0, 'message': 'success'} return {'code': 0, 'message': 'success'}
except Exception as e: 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()}') await self.logger.error(f'Error in handle_callback_request: {traceback.format_exc()}')
return {'error': str(e)}, 400 return {'error': str(e)}, 400
@@ -142,24 +139,21 @@ class QQOfficialClient:
async def get_message(self, msg: dict) -> Dict[str, Any]: async def get_message(self, msg: dict) -> Dict[str, Any]:
"""获取消息""" """获取消息"""
d = msg.get('d', {})
if not isinstance(d, dict):
return {}
message_data = { message_data = {
't': msg.get('t', {}), 't': msg.get('t', {}),
'user_openid': d.get('author', {}).get('user_openid', {}), 'user_openid': msg.get('d', {}).get('author', {}).get('user_openid', {}),
'timestamp': d.get('timestamp', {}), 'timestamp': msg.get('d', {}).get('timestamp', {}),
'd_author_id': d.get('author', {}).get('id', {}), 'd_author_id': msg.get('d', {}).get('author', {}).get('id', {}),
'content': d.get('content', {}), 'content': msg.get('d', {}).get('content', {}),
'd_id': d.get('id', {}), 'd_id': msg.get('d', {}).get('id', {}),
'id': msg.get('id', {}), 'id': msg.get('id', {}),
'channel_id': d.get('channel_id', {}), 'channel_id': msg.get('d', {}).get('channel_id', {}),
'username': d.get('author', {}).get('username', {}), 'username': msg.get('d', {}).get('author', {}).get('username', {}),
'guild_id': d.get('guild_id', {}), 'guild_id': msg.get('d', {}).get('guild_id', {}),
'member_openid': d.get('author', {}).get('openid', {}), 'member_openid': msg.get('d', {}).get('author', {}).get('openid', {}),
'group_openid': d.get('group_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 = [attachment['url'] for attachment in attachments if await self.is_image(attachment)]
image_attachments_type = [ image_attachments_type = [
attachment['content_type'] for attachment in attachments if await self.is_image(attachment) attachment['content_type'] for attachment in attachments if await self.is_image(attachment)
@@ -198,7 +192,7 @@ class QQOfficialClient:
if response.status_code == 200: if response.status_code == 200:
return return
else: else:
await self.logger.error(f'Failed to send private message: {response_data}') await self.logger.error(f'发送私聊消息失败: {response_data}')
raise ValueError(response) raise ValueError(response)
async def send_group_text_msg(self, group_openid: str, content: str, msg_id: str): 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: if response.status_code == 200:
return return
else: 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()) raise Exception(response.read().decode())
async def send_channle_group_text_msg(self, channel_id: str, content: str, msg_id: str): 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: if response.status_code == 200:
return True return True
else: else:
await self.logger.error(f'Failed to send channel group message: {response.json()}') await self.logger.error(f'发送频道群聊消息失败: {response.json()}')
raise Exception(response) raise Exception(response)
async def send_channle_private_text_msg(self, guild_id: str, content: str, msg_id: str): 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: if response.status_code == 200:
return True return True
else: else:
await self.logger.error(f'Failed to send channel private message: {response.json()}') await self.logger.error(f'发送频道私聊消息失败: {response.json()}')
raise Exception(response) 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): async def is_token_expired(self):
"""检查token是否过期""" """检查token是否过期"""
if self.access_token_expiry_time is None: if self.access_token_expiry_time is None:
@@ -513,325 +292,3 @@ class QQOfficialClient:
'signature': signature, 'signature': signature,
} }
return response 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)

View File

@@ -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

View File

@@ -43,9 +43,6 @@ class WebSocketChatRouterGroup(group.RouterGroup):
await quart.websocket.send(json.dumps({'type': 'error', 'message': 'WebSocket adapter not found'})) await quart.websocket.send(json.dumps({'type': 'error', 'message': 'WebSocket adapter not found'}))
return 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( connection = await ws_connection_manager.add_connection(
websocket=quart.websocket._get_current_object(), 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)) send_task = asyncio.create_task(self._handle_send(connection))
# 等待任务完成 # 等待任务完成
@@ -181,14 +178,7 @@ class WebSocketChatRouterGroup(group.RouterGroup):
except Exception as e: except Exception as e:
return self.http_status(500, -1, f'Internal server error: {str(e)}') return self.http_status(500, -1, f'Internal server error: {str(e)}')
def _find_owner_bot(self, pipeline_uuid: str): async def _handle_receive(self, connection, websocket_adapter):
"""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):
"""处理接收消息的任务""" """处理接收消息的任务"""
try: try:
while connection.is_active: while connection.is_active:
@@ -213,7 +203,7 @@ class WebSocketChatRouterGroup(group.RouterGroup):
logger.debug(f'收到消息: {data} from {connection.connection_id}') logger.debug(f'收到消息: {data} from {connection.connection_id}')
# 处理消息不等待响应响应会通过broadcast异步发送 # 处理消息不等待响应响应会通过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': elif message_type == 'disconnect':
# 客户端主动断开 # 客户端主动断开

View File

@@ -1,6 +1,5 @@
import quart import quart
import mimetypes import mimetypes
import asyncio
from ... import group from ... import group
from langbot.pkg.utils import importutil from langbot.pkg.utils import importutil
@@ -36,640 +35,3 @@ class AdaptersRouterGroup(group.RouterGroup):
return quart.Response( return quart.Response(
importutil.read_resource_file_bytes(icon_path), mimetype=mimetypes.guess_type(icon_path)[0] importutil.read_resource_file_bytes(icon_path), mimetype=mimetypes.guess_type(icon_path)[0]
) )
# In-memory session store for active registrations
_create_app_sessions: dict = {}
_SESSION_TTL = 900 # 15 minutes
def _cleanup_expired_sessions():
"""Remove sessions that have exceeded their TTL."""
import time
now = time.time()
expired = [sid for sid, s in _create_app_sessions.items() if now - s.get('created_at', 0) > _SESSION_TTL]
for sid in expired:
session = _create_app_sessions.pop(sid, None)
if session and session.get('task') and not session['task'].done():
session['task'].cancel()
@self.route('/lark/create-app', methods=['POST'])
async def _() -> str:
"""Start Feishu one-click app registration. Returns session_id + QR code URL."""
import uuid
import time
import lark_oapi as lark
from lark_oapi.scene.registration.errors import AppAccessDeniedError, AppExpiredError
_cleanup_expired_sessions()
session_id = str(uuid.uuid4())
loop = asyncio.get_running_loop()
session = {
'status': 'pending',
'qr_url': None,
'expire_at': None,
'app_id': None,
'app_secret': None,
'error': None,
'created_at': time.time(),
}
_create_app_sessions[session_id] = session
def on_qr_code(info):
# May be called from a background thread by the SDK;
# use call_soon_threadsafe to safely update session state.
def _update():
session['qr_url'] = info['url']
session['expire_at'] = time.time() + 600 # 10 minutes
session['status'] = 'waiting'
loop.call_soon_threadsafe(_update)
async def run_registration():
try:
result = await lark.aregister_app(
on_qr_code=on_qr_code,
source='langbot',
)
session['status'] = 'success'
session['app_id'] = result['client_id']
session['app_secret'] = result['client_secret']
except AppAccessDeniedError:
session['status'] = 'error'
session['error'] = 'User denied authorization'
except AppExpiredError:
session['status'] = 'error'
session['error'] = 'QR code expired'
except Exception as e:
session['status'] = 'error'
session['error'] = str(e)
task = asyncio.create_task(run_registration())
session['task'] = task
# Wait for QR code to be ready (max 10 seconds)
for _ in range(20):
if session['qr_url']:
break
await asyncio.sleep(0.5)
if not session['qr_url']:
task.cancel()
session['status'] = 'error'
session['error'] = 'Timeout waiting for QR code'
return self.http_status(504, -1, 'Timeout waiting for QR code')
return self.success(
data={
'session_id': session_id,
'qr_url': session['qr_url'],
'expire_at': session['expire_at'],
}
)
@self.route('/lark/create-app/status/<session_id>', methods=['GET'])
async def _(session_id: str) -> str:
"""Poll registration status."""
session = _create_app_sessions.get(session_id)
if not session:
return self.http_status(404, -1, 'Session not found')
data = {'status': session['status']}
if session['status'] == 'success':
data['app_id'] = session['app_id']
data['app_secret'] = session['app_secret']
_create_app_sessions.pop(session_id, None)
elif session['status'] == 'error':
data['error'] = session['error']
_create_app_sessions.pop(session_id, None)
return self.success(data=data)
@self.route('/lark/create-app/<session_id>', methods=['DELETE'])
async def _(session_id: str) -> str:
"""Cancel and clean up a registration session."""
session = _create_app_sessions.pop(session_id, None)
if session and session.get('task') and not session['task'].done():
session['task'].cancel()
return self.success(data={})
# -----------------------------------------------------------------------
# WeChat QR Code Login
# -----------------------------------------------------------------------
_weixin_login_sessions: dict = {}
_WEIXIN_SESSION_TTL = 600 # 10 minutes (3 retries × 3 min QR validity)
def _cleanup_expired_weixin_sessions():
import time
now = time.time()
expired = [
sid for sid, s in _weixin_login_sessions.items() if now - s.get('created_at', 0) > _WEIXIN_SESSION_TTL
]
for sid in expired:
session = _weixin_login_sessions.pop(sid, None)
if session and session.get('task') and not session['task'].done():
session['task'].cancel()
@self.route('/weixin/login', methods=['POST'])
async def _() -> str:
"""Start WeChat QR code login. Returns session_id + QR code data URL."""
import uuid
import time
import io
import base64
from langbot.libs.openclaw_weixin_api.client import OpenClawWeixinClient, DEFAULT_BASE_URL
_cleanup_expired_weixin_sessions()
session_id = str(uuid.uuid4())
loop = asyncio.get_running_loop()
session = {
'status': 'pending',
'qr_data_url': None,
'expire_at': None,
'token': None,
'base_url': None,
'account_id': None,
'error': None,
'created_at': time.time(),
}
_weixin_login_sessions[session_id] = session
client = OpenClawWeixinClient(
base_url=DEFAULT_BASE_URL,
token='',
)
async def run_login():
try:
import qrcode as qr_lib
for _attempt in range(3):
qr_resp = await client.fetch_qrcode()
if not qr_resp.qrcode or not qr_resp.qrcode_img_content:
raise Exception('Failed to get QR code from server')
# Generate QR code image locally
qr = qr_lib.QRCode(error_correction=qr_lib.constants.ERROR_CORRECT_L)
qr.add_data(qr_resp.qrcode_img_content)
qr.make(fit=True)
img = qr.make_image(fill_color='black', back_color='white')
buf = io.BytesIO()
img.save(buf, format='PNG')
b64 = base64.b64encode(buf.getvalue()).decode('utf-8')
data_url = f'data:image/png;base64,{b64}'
def _update_qr():
session['qr_data_url'] = data_url
session['expire_at'] = time.time() + 480 # 8 minutes
session['status'] = 'waiting'
loop.call_soon_threadsafe(_update_qr)
# Poll for scan status
deadline = loop.time() + 180
while loop.time() < deadline:
try:
status_resp = await client.poll_qrcode_status(qr_resp.qrcode)
except Exception:
await asyncio.sleep(2)
continue
if status_resp.status == 'confirmed' and status_resp.bot_token:
session['status'] = 'success'
session['token'] = status_resp.bot_token
session['base_url'] = status_resp.baseurl or client.base_url
session['account_id'] = status_resp.ilink_bot_id or ''
return
if status_resp.status == 'expired':
break # retry with new QR code
await asyncio.sleep(1)
else:
pass # timeout, retry
# All retries exhausted
session['status'] = 'error'
session['error'] = 'QR code login failed: max retries exceeded'
except Exception as e:
session['status'] = 'error'
session['error'] = str(e)
finally:
await client.close()
task = asyncio.create_task(run_login())
session['task'] = task
# Wait for QR code to be ready (max 10 seconds)
for _ in range(20):
if session['qr_data_url']:
break
await asyncio.sleep(0.5)
if not session['qr_data_url']:
task.cancel()
session['status'] = 'error'
session['error'] = 'Timeout waiting for QR code'
return self.http_status(504, -1, 'Timeout waiting for QR code')
return self.success(
data={
'session_id': session_id,
'qr_data_url': session['qr_data_url'],
'expire_at': session['expire_at'],
}
)
@self.route('/weixin/login/status/<session_id>', methods=['GET'])
async def _(session_id: str) -> str:
"""Poll WeChat login status."""
session = _weixin_login_sessions.get(session_id)
if not session:
return self.http_status(404, -1, 'Session not found')
data = {'status': session['status']}
if session['status'] == 'success':
data['token'] = session['token']
data['base_url'] = session['base_url']
data['account_id'] = session['account_id']
_weixin_login_sessions.pop(session_id, None)
elif session['status'] == 'error':
data['error'] = session['error']
_weixin_login_sessions.pop(session_id, None)
return self.success(data=data)
@self.route('/weixin/login/<session_id>', methods=['DELETE'])
async def _(session_id: str) -> str:
"""Cancel and clean up a WeChat login session."""
session = _weixin_login_sessions.pop(session_id, None)
if session and session.get('task') and not session['task'].done():
session['task'].cancel()
return self.success(data={})
# -----------------------------------------------------------------------
# DingTalk Device Flow QR Code Login
# -----------------------------------------------------------------------
_dingtalk_sessions: dict = {}
_DINGTALK_SESSION_TTL = 600 # 10 minutes (QR code validity window)
def _cleanup_expired_dingtalk_sessions():
import time
now = time.time()
expired = [
sid for sid, s in _dingtalk_sessions.items() if now - s.get('created_at', 0) > _DINGTALK_SESSION_TTL
]
for sid in expired:
session = _dingtalk_sessions.pop(sid, None)
if session and session.get('task') and not session['task'].done():
session['task'].cancel()
@self.route('/dingtalk/create-app', methods=['POST'])
async def _() -> str:
"""Start DingTalk one-click app creation via Device Flow. Returns session_id + QR code URL."""
import uuid
import time
import aiohttp
DINGTALK_BASE_URL = 'https://oapi.dingtalk.com'
_cleanup_expired_dingtalk_sessions()
session_id = str(uuid.uuid4())
session = {
'status': 'pending',
'qr_url': None,
'expire_at': None,
'client_id': None,
'client_secret': None,
'error': None,
'created_at': time.time(),
'device_code': None,
'interval': 5,
}
_dingtalk_sessions[session_id] = session
async def run_device_flow():
try:
timeout = aiohttp.ClientTimeout(total=10)
async with aiohttp.ClientSession(timeout=timeout) as http:
# Step 1: Init — get nonce
async with http.post(
f'{DINGTALK_BASE_URL}/app/registration/init',
json={'source': 'langbot'},
) as resp:
try:
data = await resp.json()
except (aiohttp.ContentTypeError, ValueError):
session['status'] = 'error'
session['error'] = 'Invalid response from DingTalk service'
return
if data.get('errcode', -1) != 0:
session['status'] = 'error'
session['error'] = data.get('errmsg', 'Failed to init')
return
nonce = data['nonce']
# Step 2: Begin — get device_code + QR URL
async with http.post(
f'{DINGTALK_BASE_URL}/app/registration/begin',
json={'nonce': nonce},
) as resp:
try:
data = await resp.json()
except (aiohttp.ContentTypeError, ValueError):
session['status'] = 'error'
session['error'] = 'Invalid response from DingTalk service'
return
if data.get('errcode', -1) != 0:
session['status'] = 'error'
session['error'] = data.get('errmsg', 'Failed to begin authorization')
return
device_code = data['device_code']
verification_uri_complete = data.get('verification_uri_complete', '')
expires_in = data.get('expires_in', 7200)
interval = data.get('interval', 5)
session['device_code'] = device_code
session['interval'] = interval
session['qr_url'] = verification_uri_complete
session['expire_at'] = time.time() + 600 # QR code valid for ~10 min
session['status'] = 'waiting'
# Step 3: Poll for authorization result
deadline = time.time() + expires_in
while time.time() < deadline:
await asyncio.sleep(interval)
async with http.post(
f'{DINGTALK_BASE_URL}/app/registration/poll',
json={'device_code': device_code},
) as poll_resp:
try:
poll_data = await poll_resp.json()
except (aiohttp.ContentTypeError, ValueError):
continue
if poll_data.get('errcode', -1) != 0:
session['status'] = 'error'
session['error'] = poll_data.get('errmsg', 'Poll failed')
return
status = poll_data.get('status', '')
if status == 'SUCCESS':
session['status'] = 'success'
session['client_id'] = poll_data.get('client_id', '')
session['client_secret'] = poll_data.get('client_secret', '')
return
elif status == 'FAIL':
session['status'] = 'error'
session['error'] = poll_data.get('fail_reason', 'Authorization failed')
return
elif status == 'EXPIRED':
session['status'] = 'error'
session['error'] = 'QR code expired'
return
# status == 'WAITING': continue polling
# Timeout
session['status'] = 'error'
session['error'] = 'QR code expired'
except asyncio.CancelledError:
return
except Exception as e:
session['status'] = 'error'
session['error'] = str(e)
task = asyncio.create_task(run_device_flow())
session['task'] = task
# Wait for QR code to be ready (max 10 seconds)
for _ in range(20):
if session['qr_url'] or session['error']:
break
await asyncio.sleep(0.5)
if session['error']:
task.cancel()
return self.http_status(502, -1, session['error'])
if not session['qr_url']:
task.cancel()
session['status'] = 'error'
session['error'] = 'Timeout waiting for QR code'
return self.http_status(504, -1, 'Timeout waiting for QR code')
return self.success(
data={
'session_id': session_id,
'qr_url': session['qr_url'],
'expire_at': session['expire_at'],
}
)
@self.route('/dingtalk/create-app/status/<session_id>', methods=['GET'])
async def _(session_id: str) -> str:
"""Poll DingTalk Device Flow status."""
_cleanup_expired_dingtalk_sessions()
session = _dingtalk_sessions.get(session_id)
if not session:
return self.http_status(404, -1, 'Session not found')
data = {'status': session['status']}
if session['status'] == 'success':
data['client_id'] = session['client_id']
data['client_secret'] = session['client_secret']
_dingtalk_sessions.pop(session_id, None)
elif session['status'] == 'error':
data['error'] = session['error']
_dingtalk_sessions.pop(session_id, None)
return self.success(data=data)
@self.route('/dingtalk/create-app/<session_id>', methods=['DELETE'])
async def _(session_id: str) -> str:
"""Cancel and clean up a DingTalk Device Flow session."""
session = _dingtalk_sessions.pop(session_id, None)
if session and session.get('task') and not session['task'].done():
session['task'].cancel()
return self.success(data={})
# -----------------------------------------------------------------------
# WeComBot QR Code One-Click Create
# -----------------------------------------------------------------------
_wecombot_sessions: dict = {}
_WECOMBOT_SESSION_TTL = 300 # 5 minutes (WeCom QR validity window)
def _cleanup_expired_wecombot_sessions():
import time
now = time.time()
expired = [
sid for sid, s in _wecombot_sessions.items() if now - s.get('created_at', 0) > _WECOMBOT_SESSION_TTL
]
for sid in expired:
session = _wecombot_sessions.pop(sid, None)
if session and session.get('task') and not session['task'].done():
session['task'].cancel()
@self.route('/wecombot/create-bot', methods=['POST'])
async def _() -> str:
"""Start WeComBot one-click creation via QR code. Returns session_id + QR code URL."""
import uuid
import time
import aiohttp
WECOM_QC_GENERATE_URL = 'https://work.weixin.qq.com/ai/qc/generate'
WECOM_QC_QUERY_URL = 'https://work.weixin.qq.com/ai/qc/query_result'
_cleanup_expired_wecombot_sessions()
session_id = str(uuid.uuid4())
session = {
'status': 'pending',
'qr_url': None,
'expire_at': None,
'botid': None,
'secret': None,
'error': None,
'created_at': time.time(),
'scode': None,
'task': None,
}
_wecombot_sessions[session_id] = session
async def run_qr_flow():
try:
timeout = aiohttp.ClientTimeout(total=10)
async with aiohttp.ClientSession(timeout=timeout) as http:
# Step 1: Generate QR code
async with http.get(
f'{WECOM_QC_GENERATE_URL}?source=langbot&plat=0',
) as resp:
try:
data = await resp.json()
except (aiohttp.ContentTypeError, ValueError):
session['status'] = 'error'
session['error'] = 'Invalid response from WeCom service'
return
if not data.get('data', {}).get('scode') or not data.get('data', {}).get('auth_url'):
session['status'] = 'error'
session['error'] = data.get('errmsg', 'Failed to generate QR code')
return
scode = data['data']['scode']
auth_url = data['data']['auth_url']
session['scode'] = scode
session['qr_url'] = auth_url
session['expire_at'] = time.time() + _WECOMBOT_SESSION_TTL
session['status'] = 'waiting'
# Step 2: Poll for scan result
deadline = time.time() + _WECOMBOT_SESSION_TTL
while time.time() < deadline:
await asyncio.sleep(3)
async with http.get(
f'{WECOM_QC_QUERY_URL}?scode={scode}',
) as poll_resp:
try:
poll_data = await poll_resp.json()
except (aiohttp.ContentTypeError, ValueError):
continue
status = poll_data.get('data', {}).get('status', '')
if status == 'success':
bot_info = poll_data.get('data', {}).get('bot_info', {})
if bot_info.get('botid') and bot_info.get('secret'):
session['status'] = 'success'
session['botid'] = bot_info['botid']
session['secret'] = bot_info['secret']
return
else:
session['status'] = 'error'
session['error'] = 'Scan succeeded but bot info is incomplete'
return
# Timeout
session['status'] = 'error'
session['error'] = 'QR code expired'
except asyncio.CancelledError:
return
except Exception as e:
session['status'] = 'error'
session['error'] = str(e)
task = asyncio.create_task(run_qr_flow())
session['task'] = task
# Wait for QR code to be ready (max 10 seconds)
for _ in range(20):
if session['qr_url'] or session['error']:
break
await asyncio.sleep(0.5)
if session['error']:
task.cancel()
return self.http_status(502, -1, session['error'])
if not session['qr_url']:
task.cancel()
session['status'] = 'error'
session['error'] = 'Timeout waiting for QR code'
return self.http_status(504, -1, 'Timeout waiting for QR code')
return self.success(
data={
'session_id': session_id,
'qr_url': session['qr_url'],
'expire_at': session['expire_at'],
}
)
@self.route('/wecombot/create-bot/status/<session_id>', methods=['GET'])
async def _(session_id: str) -> str:
"""Poll WeComBot creation status."""
_cleanup_expired_wecombot_sessions()
session = _wecombot_sessions.get(session_id)
if not session:
return self.http_status(404, -1, 'Session not found')
data = {'status': session['status']}
if session['status'] == 'success':
data['botid'] = session['botid']
data['secret'] = session['secret']
_wecombot_sessions.pop(session_id, None)
elif session['status'] == 'error':
data['error'] = session['error']
_wecombot_sessions.pop(session_id, None)
return self.success(data=data)
@self.route('/wecombot/create-bot/<session_id>', methods=['DELETE'])
async def _(session_id: str) -> str:
"""Cancel and clean up a WeComBot creation session."""
session = _wecombot_sessions.pop(session_id, None)
if session and session.get('task') and not session['task'].done():
session['task'].cancel()
return self.success(data={})

View File

@@ -6,48 +6,11 @@ import re
import httpx import httpx
import uuid import uuid
import os import os
import posixpath
from .....core import taskmgr from .....core import taskmgr
from .. import group from .. import group
from langbot_plugin.runtime.plugin.mgr import PluginInstallSource 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}'
def _get_request_origin() -> str:
"""Return the public request origin, respecting reverse-proxy headers."""
forwarded_proto = quart.request.headers.get('X-Forwarded-Proto', '').split(',')[0].strip()
forwarded_host = quart.request.headers.get('X-Forwarded-Host', '').split(',')[0].strip()
scheme = forwarded_proto or quart.request.scheme
host = forwarded_host or quart.request.host
return f'{scheme}://{host}'
@group.group_class('plugins', '/api/v1/plugins') @group.group_class('plugins', '/api/v1/plugins')
class PluginsRouterGroup(group.RouterGroup): class PluginsRouterGroup(group.RouterGroup):
@@ -64,15 +27,6 @@ class PluginsRouterGroup(group.RouterGroup):
return None return None
async def initialize(self) -> 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) @self.route('', methods=['GET'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
async def _() -> str: async def _() -> str:
plugins = await self.ap.plugin_connector.list_plugins() plugins = await self.ap.plugin_connector.list_plugins()
@@ -181,62 +135,15 @@ class PluginsRouterGroup(group.RouterGroup):
return quart.Response(icon_data, mimetype=mime_type) return quart.Response(icon_data, mimetype=mime_type)
@self.route( @self.route(
'/<author>/<plugin_name>/assets/<path:filepath>', '/<author>/<plugin_name>/assets/<filepath>',
methods=['GET'], methods=['GET'],
auth_type=group.AuthType.NONE, auth_type=group.AuthType.NONE,
) )
async def _(author: str, plugin_name: str, filepath: str) -> quart.Response: async def _(author: str, plugin_name: str, filepath: str) -> quart.Response:
asset_path = _normalize_plugin_asset_path(filepath) asset_data = await self.ap.plugin_connector.get_plugin_assets(author, plugin_name, 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_bytes = base64.b64decode(asset_data['asset_base64']) asset_bytes = base64.b64decode(asset_data['asset_base64'])
mime_type = asset_data['mime_type'] mime_type = asset_data['mime_type']
resp = quart.Response(asset_bytes, mimetype=mime_type) return 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 = _get_request_origin()
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'))
@self.route('/github/releases', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY) @self.route('/github/releases', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
async def _() -> str: async def _() -> str:

View File

@@ -97,51 +97,3 @@ class EmbeddingModelsRouterGroup(group.RouterGroup):
await self.ap.embedding_models_service.test_embedding_model(model_uuid, json_data) await self.ap.embedding_models_service.test_embedding_model(model_uuid, json_data)
return self.success() return self.success()
@group.group_class('models/rerank', '/api/v1/provider/models/rerank')
class RerankModelsRouterGroup(group.RouterGroup):
async def initialize(self) -> None:
@self.route('', methods=['GET', 'POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
async def _() -> str:
if quart.request.method == 'GET':
provider_uuid = quart.request.args.get('provider_uuid')
if provider_uuid:
return self.success(
data={
'models': await self.ap.rerank_models_service.get_rerank_models_by_provider(provider_uuid)
}
)
return self.success(data={'models': await self.ap.rerank_models_service.get_rerank_models()})
elif quart.request.method == 'POST':
json_data = await quart.request.json
model_uuid = await self.ap.rerank_models_service.create_rerank_model(json_data)
return self.success(data={'uuid': model_uuid})
@self.route('/<model_uuid>', methods=['GET', 'PUT', 'DELETE'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
async def _(model_uuid: str) -> str:
if quart.request.method == 'GET':
model = await self.ap.rerank_models_service.get_rerank_model(model_uuid)
if model is None:
return self.http_status(404, -1, 'model not found')
return self.success(data={'model': model})
elif quart.request.method == 'PUT':
json_data = await quart.request.json
await self.ap.rerank_models_service.update_rerank_model(model_uuid, json_data)
return self.success()
elif quart.request.method == 'DELETE':
await self.ap.rerank_models_service.delete_rerank_model(model_uuid)
return self.success()
@self.route('/<model_uuid>/test', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
async def _(model_uuid: str) -> str:
json_data = await quart.request.json
await self.ap.rerank_models_service.test_rerank_model(model_uuid, json_data)
return self.success()

View File

@@ -15,7 +15,6 @@ class ModelProvidersRouterGroup(group.RouterGroup):
counts = await self.ap.provider_service.get_provider_model_counts(provider['uuid']) counts = await self.ap.provider_service.get_provider_model_counts(provider['uuid'])
provider['llm_count'] = counts['llm_count'] provider['llm_count'] = counts['llm_count']
provider['embedding_count'] = counts['embedding_count'] provider['embedding_count'] = counts['embedding_count']
provider['rerank_count'] = counts['rerank_count']
return self.success(data={'providers': providers}) return self.success(data={'providers': providers})
elif quart.request.method == 'POST': elif quart.request.method == 'POST':
json_data = await quart.request.json json_data = await quart.request.json
@@ -33,7 +32,6 @@ class ModelProvidersRouterGroup(group.RouterGroup):
counts = await self.ap.provider_service.get_provider_model_counts(provider_uuid) counts = await self.ap.provider_service.get_provider_model_counts(provider_uuid)
provider['llm_count'] = counts['llm_count'] provider['llm_count'] = counts['llm_count']
provider['embedding_count'] = counts['embedding_count'] provider['embedding_count'] = counts['embedding_count']
provider['rerank_count'] = counts['rerank_count']
return self.success(data={'provider': provider}) return self.success(data={'provider': provider})
elif quart.request.method == 'PUT': elif quart.request.method == 'PUT':
json_data = await quart.request.json json_data = await quart.request.json

View File

@@ -136,10 +136,6 @@ class SystemRouterGroup(group.RouterGroup):
return self.success(data=task.to_dict()) 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) @self.route('/debug/exec', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
async def _() -> str: async def _() -> str:
if not constants.debug_mode: if not constants.debug_mode:

View File

@@ -146,7 +146,6 @@ class UserRouterGroup(group.RouterGroup):
return self.fail(3, str(e)) return self.fail(3, str(e))
except ValueError as e: except ValueError as e:
traceback.print_exc() traceback.print_exc()
self.ap.logger.warning(f'Space OAuth callback failed: {e}')
return self.fail(1, str(e)) return self.fail(1, str(e))
except Exception as e: except Exception as e:
traceback.print_exc() traceback.print_exc()

View File

@@ -99,11 +99,11 @@ class BotService:
# TODO: 检查配置信息格式 # TODO: 检查配置信息格式
bot_data['uuid'] = str(uuid.uuid4()) bot_data['uuid'] = str(uuid.uuid4())
# bind the most recently updated pipeline if any exist # checkout the default pipeline
result = await self.ap.persistence_mgr.execute_async( result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(persistence_pipeline.LegacyPipeline) sqlalchemy.select(persistence_pipeline.LegacyPipeline).where(
.order_by(persistence_pipeline.LegacyPipeline.updated_at.desc()) persistence_pipeline.LegacyPipeline.is_default == True
.limit(1) )
) )
pipeline = result.first() pipeline = result.first()
if pipeline is not None: if pipeline is not None:

View File

@@ -31,126 +31,15 @@ class KnowledgeService:
if not knowledge_engine_plugin_id: if not knowledge_engine_plugin_id:
raise ValueError('knowledge_engine_plugin_id is required') raise ValueError('knowledge_engine_plugin_id is required')
creation_settings = kb_data.get('creation_settings', {})
retrieval_settings = kb_data.get('retrieval_settings', {})
# Validate required fields based on plugin's creation_schema and retrieval_schema
await self._validate_schema_required_fields(
knowledge_engine_plugin_id,
creation_settings,
retrieval_settings,
)
kb = await self.ap.rag_mgr.create_knowledge_base( kb = await self.ap.rag_mgr.create_knowledge_base(
name=kb_data.get('name', 'Untitled'), name=kb_data.get('name', 'Untitled'),
knowledge_engine_plugin_id=knowledge_engine_plugin_id, knowledge_engine_plugin_id=knowledge_engine_plugin_id,
creation_settings=creation_settings, creation_settings=kb_data.get('creation_settings', {}),
retrieval_settings=retrieval_settings, retrieval_settings=kb_data.get('retrieval_settings', {}),
description=kb_data.get('description', ''), description=kb_data.get('description', ''),
) )
return kb.uuid return kb.uuid
async def _validate_schema_required_fields(
self,
plugin_id: str,
creation_settings: dict,
retrieval_settings: dict,
) -> None:
"""Validate required fields based on plugin's creation_schema and retrieval_schema.
This is a business-agnostic validation that checks all fields marked as
required in the plugin's schema, regardless of field type.
Args:
plugin_id: Knowledge Engine plugin ID.
creation_settings: User-provided creation settings.
retrieval_settings: User-provided retrieval settings.
Raises:
ValueError: If any required field is missing or empty.
"""
# Validate creation_schema
try:
creation_schema = await self.ap.plugin_connector.get_rag_creation_schema(plugin_id)
self._check_required_fields(creation_schema, creation_settings, 'creation_settings')
except ValueError:
raise
except Exception as e:
self.ap.logger.warning(f'Failed to get creation_schema for validation: {e}')
# Validate retrieval_schema
try:
retrieval_schema = await self.ap.plugin_connector.get_rag_retrieval_schema(plugin_id)
self._check_required_fields(retrieval_schema, retrieval_settings, 'retrieval_settings')
except ValueError:
raise
except Exception as e:
self.ap.logger.warning(f'Failed to get retrieval_schema for validation: {e}')
def _check_required_fields(
self,
schema: dict | list,
settings: dict,
context: str,
) -> None:
"""Check required fields in schema against provided settings.
Args:
schema: Plugin-defined schema (can be list or dict with 'schema' key).
settings: User-provided settings values.
context: Context name for error messages (e.g., 'creation_settings').
Raises:
ValueError: If a required field is missing or empty.
"""
if not schema:
return
# schema can be a list directly, or a dict with 'schema' key
items = schema if isinstance(schema, list) else schema.get('schema', [])
if not items:
return
for item in items:
field_name = item.get('name')
if not field_name:
continue
is_required = item.get('required', False)
if not is_required:
continue
# Check show_if condition - if field is conditionally shown, only validate when condition is met
show_if = item.get('show_if')
if show_if:
depend_field = show_if.get('field')
operator = show_if.get('operator')
expected_value = show_if.get('value')
if depend_field and operator:
depend_value = settings.get(depend_field)
# If show_if condition is not met, skip validation for this field
if operator == 'eq' and depend_value != expected_value:
continue
if operator == 'neq' and depend_value == expected_value:
continue
if operator == 'in' and isinstance(expected_value, list) and depend_value not in expected_value:
continue
value = settings.get(field_name)
# Validate required field has a non-empty value
if value is None or (isinstance(value, str) and value.strip() == ''):
# Get field label for friendly error message
label = item.get('label', {})
field_label = (
label.get('en_US', field_name)
or label.get('zh_Hans', field_name)
or label.get('zh_Hant', field_name)
or field_name
)
raise ValueError(f'{field_label} is required ({context}.{field_name})')
async def update_knowledge_base(self, kb_uuid: str, kb_data: dict) -> None: async def update_knowledge_base(self, kb_uuid: str, kb_data: dict) -> None:
"""更新知识库""" """更新知识库"""
# Filter to only mutable fields # Filter to only mutable fields

View File

@@ -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

View File

@@ -23,17 +23,6 @@ def _parse_provider_api_keys(provider_dict: dict) -> dict:
return provider_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: class LLMModelsService:
ap: app.Application ap: app.Application
@@ -184,7 +173,7 @@ class LLMModelsService:
raise Exception('provider not found') raise Exception('provider not found')
runtime_llm_model = await self.ap.model_mgr.load_llm_model_with_provider( 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(uuid=model_uuid, **model_data),
runtime_provider, runtime_provider,
) )
self.ap.model_mgr.llm_models.append(runtime_llm_model) self.ap.model_mgr.llm_models.append(runtime_llm_model)
@@ -345,7 +334,7 @@ class EmbeddingModelsService:
raise Exception('provider not found') raise Exception('provider not found')
runtime_embedding_model = await self.ap.model_mgr.load_embedding_model_with_provider( 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(uuid=model_uuid, **model_data),
runtime_provider, runtime_provider,
) )
self.ap.model_mgr.embedding_models.append(runtime_embedding_model) self.ap.model_mgr.embedding_models.append(runtime_embedding_model)
@@ -378,162 +367,3 @@ class EmbeddingModelsService:
input_text=['Hello, world!'], input_text=['Hello, world!'],
extra_args={}, extra_args={},
) )
class RerankModelsService:
ap: app.Application
def __init__(self, ap: app.Application) -> None:
self.ap = ap
async def get_rerank_models(self) -> list[dict]:
"""Get all rerank models with provider info"""
result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_model.RerankModel))
models = result.all()
providers_result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(persistence_model.ModelProvider)
)
providers = {p.uuid: p for p in providers_result.all()}
models_list = []
for model in models:
model_dict = self.ap.persistence_mgr.serialize_model(persistence_model.RerankModel, model)
provider = providers.get(model.provider_uuid)
if provider:
provider_dict = self.ap.persistence_mgr.serialize_model(persistence_model.ModelProvider, provider)
model_dict['provider'] = _parse_provider_api_keys(provider_dict)
models_list.append(model_dict)
return models_list
async def get_rerank_models_by_provider(self, provider_uuid: str) -> list[dict]:
"""Get rerank models by provider UUID"""
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(persistence_model.RerankModel).where(
persistence_model.RerankModel.provider_uuid == provider_uuid
)
)
models = result.all()
return [self.ap.persistence_mgr.serialize_model(persistence_model.RerankModel, m) for m in models]
async def create_rerank_model(self, model_data: dict, preserve_uuid: bool = False) -> str:
"""Create a new rerank model"""
if not preserve_uuid:
model_data['uuid'] = str(uuid.uuid4())
if 'provider' in model_data:
provider_data = model_data.pop('provider')
if provider_data.get('uuid'):
model_data['provider_uuid'] = provider_data['uuid']
else:
provider_uuid = await self.ap.provider_service.find_or_create_provider(
requester=provider_data.get('requester', ''),
base_url=provider_data.get('base_url', ''),
api_keys=provider_data.get('api_keys', []),
)
model_data['provider_uuid'] = provider_uuid
await self.ap.persistence_mgr.execute_async(
sqlalchemy.insert(persistence_model.RerankModel).values(**model_data)
)
runtime_provider = self.ap.model_mgr.provider_dict.get(model_data['provider_uuid'])
if runtime_provider is None:
raise Exception('provider not found')
runtime_rerank_model = await self.ap.model_mgr.load_rerank_model_with_provider(
persistence_model.RerankModel(**model_data),
runtime_provider,
)
self.ap.model_mgr.rerank_models.append(runtime_rerank_model)
return model_data['uuid']
async def get_rerank_model(self, model_uuid: str) -> dict | None:
"""Get a single rerank model with provider info"""
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(persistence_model.RerankModel).where(persistence_model.RerankModel.uuid == model_uuid)
)
model = result.first()
if model is None:
return None
model_dict = self.ap.persistence_mgr.serialize_model(persistence_model.RerankModel, model)
provider_result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(persistence_model.ModelProvider).where(
persistence_model.ModelProvider.uuid == model.provider_uuid
)
)
provider = provider_result.first()
if provider:
provider_dict = self.ap.persistence_mgr.serialize_model(persistence_model.ModelProvider, provider)
model_dict['provider'] = _parse_provider_api_keys(provider_dict)
return model_dict
async def update_rerank_model(self, model_uuid: str, model_data: dict) -> None:
"""Update an existing rerank model"""
if 'uuid' in model_data:
del model_data['uuid']
if 'provider' in model_data:
provider_data = model_data.pop('provider')
if provider_data.get('uuid'):
model_data['provider_uuid'] = provider_data['uuid']
else:
provider_uuid = await self.ap.provider_service.find_or_create_provider(
requester=provider_data.get('requester', ''),
base_url=provider_data.get('base_url', ''),
api_keys=provider_data.get('api_keys', []),
)
model_data['provider_uuid'] = provider_uuid
await self.ap.persistence_mgr.execute_async(
sqlalchemy.update(persistence_model.RerankModel)
.where(persistence_model.RerankModel.uuid == model_uuid)
.values(**model_data)
)
await self.ap.model_mgr.remove_rerank_model(model_uuid)
runtime_provider = self.ap.model_mgr.provider_dict.get(model_data['provider_uuid'])
if runtime_provider is None:
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)),
runtime_provider,
)
self.ap.model_mgr.rerank_models.append(runtime_rerank_model)
async def delete_rerank_model(self, model_uuid: str) -> None:
"""Delete a rerank model"""
await self.ap.persistence_mgr.execute_async(
sqlalchemy.delete(persistence_model.RerankModel).where(persistence_model.RerankModel.uuid == model_uuid)
)
await self.ap.model_mgr.remove_rerank_model(model_uuid)
async def test_rerank_model(self, model_uuid: str, model_data: dict) -> None:
"""Test a rerank model"""
runtime_rerank_model: model_requester.RuntimeRerankModel | None = None
if model_uuid != '_':
for model in self.ap.model_mgr.rerank_models:
if model.model_entity.uuid == model_uuid:
runtime_rerank_model = model
break
if runtime_rerank_model is None:
raise Exception('model not found')
else:
runtime_rerank_model = await self.ap.model_mgr.init_temporary_runtime_rerank_model(model_data)
await runtime_rerank_model.provider.invoke_rerank(
model=runtime_rerank_model,
query='What is artificial intelligence?',
documents=[
'Artificial intelligence is a branch of computer science.',
'The weather is nice today.',
],
)

View File

@@ -18,119 +18,55 @@ class MonitoringService:
# ========== Cleanup Methods ========== # ========== 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. """Delete monitoring records older than the specified retention period.
Args: Args:
retention_days: Number of days to retain records. retention_days: Number of days to retain records.
batch_size: Maximum rows to delete per table batch.
Returns: Returns:
A dict mapping table name to the number of deleted rows. 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( cutoff = datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None) - datetime.timedelta(
days=retention_days 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', 'monitoring_messages',
persistence_monitoring.MonitoringMessage, persistence_monitoring.MonitoringMessage,
persistence_monitoring.MonitoringMessage.timestamp, persistence_monitoring.MonitoringMessage.timestamp,
persistence_monitoring.MonitoringMessage.id,
), ),
( (
'monitoring_llm_calls', 'monitoring_llm_calls',
persistence_monitoring.MonitoringLLMCall, persistence_monitoring.MonitoringLLMCall,
persistence_monitoring.MonitoringLLMCall.timestamp, persistence_monitoring.MonitoringLLMCall.timestamp,
persistence_monitoring.MonitoringLLMCall.id,
), ),
( (
'monitoring_embedding_calls', 'monitoring_embedding_calls',
persistence_monitoring.MonitoringEmbeddingCall, persistence_monitoring.MonitoringEmbeddingCall,
persistence_monitoring.MonitoringEmbeddingCall.timestamp, persistence_monitoring.MonitoringEmbeddingCall.timestamp,
persistence_monitoring.MonitoringEmbeddingCall.id,
), ),
( (
'monitoring_errors', 'monitoring_errors',
persistence_monitoring.MonitoringError, persistence_monitoring.MonitoringError,
persistence_monitoring.MonitoringError.timestamp, persistence_monitoring.MonitoringError.timestamp,
persistence_monitoring.MonitoringError.id,
), ),
( (
'monitoring_sessions', 'monitoring_sessions',
persistence_monitoring.MonitoringSession, persistence_monitoring.MonitoringSession,
persistence_monitoring.MonitoringSession.last_activity, 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] = {} deleted_counts: dict[str, int] = {}
for table_name, model_cls, ts_column, pk_column in tables_and_columns: for table_name, model_cls, ts_column in tables_and_columns:
deleted_counts[table_name] = await self._delete_expired_in_batches( result = await self.ap.persistence_mgr.execute_async(sqlalchemy.delete(model_cls).where(ts_column < cutoff))
model_cls=model_cls, deleted_counts[table_name] = result.rowcount
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()
return deleted_counts 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 ========== # ========== Recording Methods ==========
async def record_message( async def record_message(

View File

@@ -17,24 +17,6 @@ class ModelProviderService:
def __init__(self, ap: app.Application) -> None: def __init__(self, ap: app.Application) -> None:
self.ap = ap self.ap = ap
@staticmethod
def _normalize_api_keys(api_keys: str | list[str] | tuple[str, ...] | None) -> list[str]:
if api_keys is None:
return []
raw_keys = [api_keys] if isinstance(api_keys, str) else list(api_keys)
normalized_keys = []
seen_keys = set()
for raw_key in raw_keys:
normalized_key = raw_key.strip() if isinstance(raw_key, str) else ''
if not normalized_key or normalized_key in seen_keys:
continue
normalized_keys.append(normalized_key)
seen_keys.add(normalized_key)
return normalized_keys
async def get_providers(self) -> list[dict]: async def get_providers(self) -> list[dict]:
"""Get all providers""" """Get all providers"""
result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_model.ModelProvider)) result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_model.ModelProvider))
@@ -77,7 +59,6 @@ class ModelProviderService:
async def create_provider(self, provider_data: dict) -> str: async def create_provider(self, provider_data: dict) -> str:
"""Create a new provider""" """Create a new provider"""
provider_data['uuid'] = str(uuid.uuid4()) provider_data['uuid'] = str(uuid.uuid4())
provider_data['api_keys'] = self._normalize_api_keys(provider_data.get('api_keys'))
await self.ap.persistence_mgr.execute_async( await self.ap.persistence_mgr.execute_async(
sqlalchemy.insert(persistence_model.ModelProvider).values(**provider_data) sqlalchemy.insert(persistence_model.ModelProvider).values(**provider_data)
) )
@@ -91,8 +72,6 @@ class ModelProviderService:
"""Update an existing provider""" """Update an existing provider"""
if 'uuid' in provider_data: if 'uuid' in provider_data:
del provider_data['uuid'] del provider_data['uuid']
if 'api_keys' in provider_data:
provider_data['api_keys'] = self._normalize_api_keys(provider_data.get('api_keys'))
await self.ap.persistence_mgr.execute_async( await self.ap.persistence_mgr.execute_async(
sqlalchemy.update(persistence_model.ModelProvider) sqlalchemy.update(persistence_model.ModelProvider)
.where(persistence_model.ModelProvider.uuid == provider_uuid) .where(persistence_model.ModelProvider.uuid == provider_uuid)
@@ -119,14 +98,6 @@ class ModelProviderService:
if embedding_result.first() is not None: if embedding_result.first() is not None:
raise ValueError('Cannot delete provider: Embedding models still reference it') raise ValueError('Cannot delete provider: Embedding models still reference it')
rerank_result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(persistence_model.RerankModel).where(
persistence_model.RerankModel.provider_uuid == provider_uuid
)
)
if rerank_result.first() is not None:
raise ValueError('Cannot delete provider: Rerank models still reference it')
await self.ap.persistence_mgr.execute_async( await self.ap.persistence_mgr.execute_async(
sqlalchemy.delete(persistence_model.ModelProvider).where( sqlalchemy.delete(persistence_model.ModelProvider).where(
persistence_model.ModelProvider.uuid == provider_uuid persistence_model.ModelProvider.uuid == provider_uuid
@@ -151,19 +122,10 @@ class ModelProviderService:
) )
embedding_count = embedding_result.scalar() or 0 embedding_count = embedding_result.scalar() or 0
rerank_result = await self.ap.persistence_mgr.execute_async( return {'llm_count': llm_count, 'embedding_count': embedding_count}
sqlalchemy.select(sqlalchemy.func.count())
.select_from(persistence_model.RerankModel)
.where(persistence_model.RerankModel.provider_uuid == provider_uuid)
)
rerank_count = rerank_result.scalar() or 0
return {'llm_count': llm_count, 'embedding_count': embedding_count, 'rerank_count': rerank_count}
async def find_or_create_provider(self, requester: str, base_url: str, api_keys: list) -> str: async def find_or_create_provider(self, requester: str, base_url: str, api_keys: list) -> str:
"""Find existing provider or create new one""" """Find existing provider or create new one"""
api_keys = self._normalize_api_keys(api_keys)
# Try to find existing provider with same config # Try to find existing provider with same config
result = await self.ap.persistence_mgr.execute_async( result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(persistence_model.ModelProvider).where( sqlalchemy.select(persistence_model.ModelProvider).where(
@@ -191,7 +153,7 @@ class ModelProviderService:
'name': provider_name, 'name': provider_name,
'requester': requester, 'requester': requester,
'base_url': base_url, 'base_url': base_url,
'api_keys': api_keys, 'api_keys': api_keys or [],
} }
) )
@@ -200,7 +162,7 @@ class ModelProviderService:
await self.ap.persistence_mgr.execute_async( await self.ap.persistence_mgr.execute_async(
sqlalchemy.update(persistence_model.ModelProvider) sqlalchemy.update(persistence_model.ModelProvider)
.where(persistence_model.ModelProvider.uuid == '00000000-0000-0000-0000-000000000000') .where(persistence_model.ModelProvider.uuid == '00000000-0000-0000-0000-000000000000')
.values(api_keys=self._normalize_api_keys(api_key)) .values(api_keys=[api_key])
) )
await self.ap.model_mgr.reload_provider('00000000-0000-0000-0000-000000000000') await self.ap.model_mgr.reload_provider('00000000-0000-0000-0000-000000000000')

View File

@@ -179,7 +179,7 @@ class SpaceService:
space_url = space_config['url'] space_url = space_config['url']
session = httpclient.get_session() session = httpclient.get_session()
async with session.get(f'{space_url}/api/v1/models', params={'page_size': 100}) as response: async with session.get(f'{space_url}/api/v1/models') as response:
if response.status != 200: if response.status != 200:
raise ValueError(f'Failed to get models: {await response.text()}') raise ValueError(f'Failed to get models: {await response.text()}')
data = await response.json() data = await response.json()

View File

@@ -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 apikey as apikey_service
from ..api.http.service import webhook as webhook_service from ..api.http.service import webhook as webhook_service
from ..api.http.service import monitoring as monitoring_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 ..discover import engine as discover_engine
from ..storage import mgr as storagemgr from ..storage import mgr as storagemgr
@@ -134,8 +133,6 @@ class Application:
embedding_models_service: model_service.EmbeddingModelsService = None embedding_models_service: model_service.EmbeddingModelsService = None
rerank_models_service: model_service.RerankModelsService = None
provider_service: provider_service.ModelProviderService = None provider_service: provider_service.ModelProviderService = None
pipeline_service: pipeline_service.PipelineService = None pipeline_service: pipeline_service.PipelineService = None
@@ -156,8 +153,6 @@ class Application:
monitoring_service: monitoring_service.MonitoringService = None monitoring_service: monitoring_service.MonitoringService = None
maintenance_service: maintenance_service.MaintenanceService = None
def __init__(self): def __init__(self):
pass pass
@@ -197,30 +192,14 @@ class Application:
monitoring_cfg = self.instance_config.data.get('monitoring', {}) monitoring_cfg = self.instance_config.data.get('monitoring', {})
auto_cleanup_cfg = monitoring_cfg.get('auto_cleanup', {}) auto_cleanup_cfg = monitoring_cfg.get('auto_cleanup', {})
if auto_cleanup_cfg.get('enabled', True): if auto_cleanup_cfg.get('enabled', True):
retention_days = self._get_positive_int_config( retention_days = auto_cleanup_cfg.get('retention_days', 30)
auto_cleanup_cfg.get('retention_days', 30), check_interval_hours = auto_cleanup_cfg.get('check_interval_hours', 1)
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',
)
async def monitoring_cleanup_loop(): async def monitoring_cleanup_loop():
check_interval_seconds = check_interval_hours * 3600 check_interval_seconds = check_interval_hours * 3600
while True: while True:
try: try:
deleted = await self.monitoring_service.cleanup_expired_records( deleted = await self.monitoring_service.cleanup_expired_records(retention_days)
retention_days,
batch_size=delete_batch_size,
)
total_deleted = sum(deleted.values()) total_deleted = sum(deleted.values())
if total_deleted > 0: if total_deleted > 0:
self.logger.info( self.logger.info(
@@ -237,33 +216,6 @@ class Application:
scopes=[core_entities.LifecycleControlScope.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( self.task_mgr.create_task(
never_ending(), never_ending(),
name='never-ending-task', name='never-ending-task',
@@ -278,28 +230,6 @@ class Application:
self.logger.error(f'Application runtime fatal exception: {e}') self.logger.error(f'Application runtime fatal exception: {e}')
self.logger.debug(f'Traceback: {traceback.format_exc()}') 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): def dispose(self):
self.plugin_connector.dispose() self.plugin_connector.dispose()

View File

@@ -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 apikey as apikey_service
from ...api.http.service import webhook as webhook_service from ...api.http.service import webhook as webhook_service
from ...api.http.service import monitoring as monitoring_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 ...discover import engine as discover_engine
from ...storage import mgr as storagemgr from ...storage import mgr as storagemgr
from ...utils import logcache from ...utils import logcache
@@ -62,9 +61,6 @@ class BuildAppStage(stage.BootingStage):
embedding_models_service_inst = model_service.EmbeddingModelsService(ap) embedding_models_service_inst = model_service.EmbeddingModelsService(ap)
ap.embedding_models_service = embedding_models_service_inst ap.embedding_models_service = embedding_models_service_inst
rerank_models_service_inst = model_service.RerankModelsService(ap)
ap.rerank_models_service = rerank_models_service_inst
provider_service_inst = provider_service.ModelProviderService(ap) provider_service_inst = provider_service.ModelProviderService(ap)
ap.provider_service = provider_service_inst ap.provider_service = provider_service_inst
@@ -168,9 +164,6 @@ class BuildAppStage(stage.BootingStage):
monitoring_service_inst = monitoring_service.MonitoringService(ap) monitoring_service_inst = monitoring_service.MonitoringService(ap)
ap.monitoring_service = monitoring_service_inst 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: async def runtime_disconnect_callback(connector: plugin_connector.PluginRuntimeConnector) -> None:
await asyncio.sleep(3) await asyncio.sleep(3)
await plugin_connector_inst.initialize() await plugin_connector_inst.initialize()

View File

@@ -3,7 +3,6 @@ from __future__ import annotations
import asyncio import asyncio
import typing import typing
import datetime import datetime
import time
from . import app from . import app
from . import entities as core_entities from . import entities as core_entities
@@ -120,7 +119,6 @@ class TaskWrapper:
self.label = label if label != '' else name self.label = label if label != '' else name
self.task.set_name(name) self.task.set_name(name)
self.scopes = scopes self.scopes = scopes
self.created_at = time.time()
def assume_exception(self): def assume_exception(self):
try: try:
@@ -156,7 +154,6 @@ class TaskWrapper:
'name': self.name, 'name': self.name,
'label': self.label, 'label': self.label,
'scopes': [scope.value for scope in self.scopes], 'scopes': [scope.value for scope in self.scopes],
'created_at': self.created_at,
'task_context': self.task_context.to_dict(), 'task_context': self.task_context.to_dict(),
'runtime': { 'runtime': {
'done': self.task.done(), 'done': self.task.done(),
@@ -196,8 +193,6 @@ class AsyncTaskManager:
) -> TaskWrapper: ) -> TaskWrapper:
wrapper = TaskWrapper(self.ap, coro, task_type, kind, name, label, context, scopes) wrapper = TaskWrapper(self.ap, coro, task_type, kind, name, label, context, scopes)
self.tasks.append(wrapper) self.tasks.append(wrapper)
wrapper.task.add_done_callback(lambda _: self._prune_completed_tasks())
self._prune_completed_tasks()
return wrapper return wrapper
def create_user_task( def create_user_task(
@@ -231,15 +226,6 @@ class AsyncTaskManager:
'id_index': TaskWrapper._id_index, '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: def get_task_by_id(self, id: int) -> TaskWrapper | None:
for t in self.tasks: for t in self.tasks:
if t.id == id: if t.id == id:
@@ -257,27 +243,3 @@ class AsyncTaskManager:
if not wrapper.task.done(): if not wrapper.task.done():
wrapper.task.cancel() wrapper.task.cancel()
return 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]

View File

@@ -59,22 +59,3 @@ class EmbeddingModel(Base):
server_default=sqlalchemy.func.now(), server_default=sqlalchemy.func.now(),
onupdate=sqlalchemy.func.now(), onupdate=sqlalchemy.func.now(),
) )
class RerankModel(Base):
"""Rerank model"""
__tablename__ = 'rerank_models'
uuid = sqlalchemy.Column(sqlalchemy.String(255), primary_key=True, unique=True)
name = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
provider_uuid = sqlalchemy.Column(sqlalchemy.String(255), nullable=False)
extra_args = sqlalchemy.Column(sqlalchemy.JSON, nullable=False, default={})
prefered_ranking = sqlalchemy.Column(sqlalchemy.Integer, nullable=False, default=0)
created_at = sqlalchemy.Column(sqlalchemy.DateTime, nullable=False, server_default=sqlalchemy.func.now())
updated_at = sqlalchemy.Column(
sqlalchemy.DateTime,
nullable=False,
server_default=sqlalchemy.func.now(),
onupdate=sqlalchemy.func.now(),
)

View File

@@ -1,35 +0,0 @@
"""add rerank_models table
Revision ID: 0003_add_rerank_models
Revises: 0002_sample
Create Date: 2026-04-19
"""
import sqlalchemy as sa
from alembic import op
revision = '0003_add_rerank_models'
down_revision = '0002_sample'
branch_labels = None
depends_on = None
def upgrade() -> None:
# Check if table already exists (may have been created by create_all())
conn = op.get_bind()
inspector = sa.inspect(conn)
if 'rerank_models' not in inspector.get_table_names():
op.create_table(
'rerank_models',
sa.Column('uuid', sa.String(255), primary_key=True, unique=True),
sa.Column('name', sa.String(255), nullable=False),
sa.Column('provider_uuid', sa.String(255), nullable=False),
sa.Column('extra_args', sa.JSON, nullable=False, server_default='{}'),
sa.Column('prefered_ranking', sa.Integer, nullable=False, server_default='0'),
sa.Column('created_at', sa.DateTime, nullable=False, server_default=sa.func.now()),
sa.Column('updated_at', sa.DateTime, nullable=False, server_default=sa.func.now()),
)
def downgrade() -> None:
op.drop_table('rerank_models')

View File

@@ -75,27 +75,6 @@ class PreProcessor(stage.PipelineStage):
query.bot_uuid, 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
query.session = session query.session = session
query.prompt = conversation.prompt.copy() query.prompt = conversation.prompt.copy()
@@ -181,9 +160,6 @@ class PreProcessor(stage.PipelineStage):
elif me.url: elif me.url:
content_list.append(provider_message.ContentElement.from_file_url(me.url, 'voice')) content_list.append(provider_message.ContentElement.from_file_url(me.url, 'voice'))
elif isinstance(me, platform_message.File): 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: elif isinstance(me, platform_message.Quote) and quote_msg:
for msg in me.origin: for msg in me.origin:
@@ -196,9 +172,6 @@ class PreProcessor(stage.PipelineStage):
if msg.base64 is not None: if msg.base64 is not None:
content_list.append(provider_message.ContentElement.from_image_base64(msg.base64)) content_list.append(provider_message.ContentElement.from_image_base64(msg.base64))
elif isinstance(msg, platform_message.File): 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): elif isinstance(msg, platform_message.Voice):
if msg.base64: if msg.base64:

View File

@@ -523,7 +523,7 @@ class PlatformManager:
return None return None
async def remove_bot(self, bot_uuid: str): 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.bot_entity.uuid == bot_uuid:
if bot.enable: if bot.enable:
await bot.shutdown() await bot.shutdown()

View File

@@ -19,18 +19,6 @@ spec:
en: https://link.langbot.app/en/platforms/dingtalk en: https://link.langbot.app/en/platforms/dingtalk
ja: https://link.langbot.app/ja/platforms/dingtalk ja: https://link.langbot.app/ja/platforms/dingtalk
config: config:
- name: one-click-create
label:
en_US: One-Click Create App
zh_Hans: 一键创建应用
zh_Hant: 一鍵建立應用
description:
en_US: "Scan QR code with DingTalk to automatically create an app and fill in credentials. Note: Robot Code cannot be obtained automatically, you need to copy it from the DingTalk Developer Backend manually."
zh_Hans: "使用钉钉扫码自动创建应用并填写凭据。注意:机器人代码无法自动获取,需前往钉钉开发者后台手动复制。"
zh_Hant: "使用釘釘掃碼自動建立應用並填寫憑證。注意:機器人代碼無法自動取得,需前往釘釘開發者後台手動複製。"
type: qr-code-login
login_platform: dingtalk
required: false
- name: client_id - name: client_id
label: label:
en_US: Client ID en_US: Client ID
@@ -52,10 +40,6 @@ spec:
en_US: Robot Code en_US: Robot Code
zh_Hans: 机器人代码 zh_Hans: 机器人代码
zh_Hant: 機器人代碼 zh_Hant: 機器人代碼
description:
en_US: "Required for image recognition, file upload and other features. Get it from DingTalk Developer Backend > Robot Configuration."
zh_Hans: "识图、上传文件等功能必填。请前往钉钉开发者后台 > 机器人配置中获取。"
zh_Hant: "識圖、上傳檔案等功能必填。請前往釘釘開發者後台 > 機器人設定中取得。"
type: string type: string
required: true required: true
default: "" default: ""

View File

@@ -1025,90 +1025,7 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
return api_client return api_client
async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain): async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain):
text_elements, media_items = await self.message_converter.yiri2target(message, self.api_client) pass
# Map standard target_type to Feishu receive_id_type
if target_type == 'person':
receive_id_type = 'open_id'
elif target_type == 'group':
receive_id_type = 'chat_id'
else:
receive_id_type = target_type
# Send text message if there are text elements
if text_elements:
needs_post = any(ele['tag'] == 'at' for paragraph in text_elements for ele in paragraph)
if needs_post:
msg_type = 'post'
final_content = json.dumps(
{
'zh_Hans': {
'title': '',
'content': text_elements,
},
}
)
else:
msg_type = 'text'
parts = []
for paragraph in text_elements:
para_text = ''.join(ele.get('text', '') for ele in paragraph)
if para_text:
parts.append(para_text)
final_content = json.dumps({'text': '\n\n'.join(parts)})
request: CreateMessageRequest = (
CreateMessageRequest.builder()
.receive_id_type(receive_id_type)
.request_body(
CreateMessageRequestBody.builder()
.receive_id(target_id)
.content(final_content)
.msg_type(msg_type)
.uuid(str(uuid.uuid4()))
.build()
)
.build()
)
app_access_token = self.get_app_access_token()
req_opt: RequestOption = (
RequestOption.builder().app_ticket(self.app_ticket).app_access_token(app_access_token).build()
)
response: CreateMessageResponse = self.api_client.im.v1.message.create(request, req_opt)
if not response.success():
raise Exception(
f'client.im.v1.message.create failed, code: {response.code}, msg: {response.msg}, log_id: {response.get_log_id()}, resp: \n{json.dumps(json.loads(response.raw.content), indent=4, ensure_ascii=False)}'
)
# Send media messages separately (image, audio, file, etc.)
for media in media_items:
request: CreateMessageRequest = (
CreateMessageRequest.builder()
.receive_id_type(receive_id_type)
.request_body(
CreateMessageRequestBody.builder()
.receive_id(target_id)
.content(json.dumps(media['content']))
.msg_type(media['msg_type'])
.uuid(str(uuid.uuid4()))
.build()
)
.build()
)
app_access_token = self.get_app_access_token()
req_opt: RequestOption = (
RequestOption.builder().app_ticket(self.app_ticket).app_access_token(app_access_token).build()
)
response: CreateMessageResponse = self.api_client.im.v1.message.create(request, req_opt)
if not response.success():
raise Exception(
f'client.im.v1.message.create ({media["msg_type"]}) failed, code: {response.code}, msg: {response.msg}, log_id: {response.get_log_id()}, resp: \n{json.dumps(json.loads(response.raw.content), indent=4, ensure_ascii=False)}'
)
async def is_stream_output_supported(self) -> bool: async def is_stream_output_supported(self) -> bool:
is_stream = False is_stream = False

View File

@@ -23,20 +23,6 @@ spec:
en: https://link.langbot.app/en/platforms/lark en: https://link.langbot.app/en/platforms/lark
ja: https://link.langbot.app/ja/platforms/lark ja: https://link.langbot.app/ja/platforms/lark
config: config:
- name: one-click-create
label:
en_US: One-Click Create App
zh_Hans: 一键创建应用
zh_Hant: 一鍵建立應用
ja_JP: ワンクリックでアプリ作成
description:
en_US: Scan QR code to automatically create a Feishu app and fill in credentials
zh_Hans: 扫码自动创建飞书应用并填写凭据
zh_Hant: 掃碼自動建立飛書應用並填寫憑證
ja_JP: QRコードをスキャンしてFeishuアプリを自動作成し、認証情報を入力
type: qr-code-login
login_platform: feishu
required: false
- name: app_id - name: app_id
label: label:
en_US: App ID en_US: App ID

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

View File

@@ -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]

View File

@@ -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

View File

@@ -32,20 +32,6 @@ spec:
type: string type: string
required: true required: true
default: "https://ilinkai.weixin.qq.com" default: "https://ilinkai.weixin.qq.com"
- name: qr-login
label:
en_US: Scan QR Login
zh_Hans: 扫码登录
zh_Hant: 掃碼登入
ja_JP: QRコードでログイン
description:
en_US: Scan QR code with WeChat to authorize and automatically fill in the token
zh_Hans: 使用微信扫码授权,自动填写令牌
zh_Hant: 使用微信掃碼授權,自動填寫令牌
ja_JP: WeChatでQRコードをスキャンし、トークンを自動入力
type: qr-code-login
login_platform: weixin
required: false
- name: token - name: token
label: label:
en_US: Token en_US: Token

View File

@@ -1,11 +1,9 @@
from __future__ import annotations from __future__ import annotations
import typing import typing
import re
import asyncio import asyncio
import traceback import traceback
import datetime import datetime
import time
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter 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.message as platform_message
@@ -17,25 +15,11 @@ from ...utils import image
from ..logger import EventLogger 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): class QQOfficialMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
@staticmethod @staticmethod
async def yiri2target(message_chain: platform_message.MessageChain): async def yiri2target(message_chain: platform_message.MessageChain):
"""将 LangBot 消息链转换为 QQ Official 消息格式列表。"""
content_list = [] content_list = []
# 只实现了发文字
for msg in message_chain: for msg in message_chain:
if type(msg) is platform_message.Plain: if type(msg) is platform_message.Plain:
content_list.append( content_list.append(
@@ -44,49 +28,6 @@ class QQOfficialMessageConverter(abstract_platform_adapter.AbstractMessageConver
'content': msg.text, '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 return content_list
@@ -188,19 +129,12 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
config: dict config: dict
bot_account_id: str bot_account_id: str
bot_uuid: str = None bot_uuid: str = None
enable_webhook: bool = False
message_converter: QQOfficialMessageConverter = QQOfficialMessageConverter() message_converter: QQOfficialMessageConverter = QQOfficialMessageConverter()
event_converter: QQOfficialEventConverter = QQOfficialEventConverter() event_converter: QQOfficialEventConverter = QQOfficialEventConverter()
def __init__(self, config: dict, logger: EventLogger): def __init__(self, config: dict, logger: EventLogger):
enable_webhook = config.get('enable-webhook', False)
bot = QQOfficialClient( bot = QQOfficialClient(
app_id=config['appid'], app_id=config['appid'], secret=config['secret'], token=config['token'], logger=logger, unified_mode=True
secret=config['secret'],
token=config['token'],
logger=logger,
unified_mode=enable_webhook,
) )
super().__init__( super().__init__(
@@ -210,13 +144,6 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
bot_account_id=config['appid'], 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( async def reply_message(
self, self,
message_source: platform_events.MessageEvent, message_source: platform_events.MessageEvent,
@@ -229,18 +156,28 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
content_list = await QQOfficialMessageConverter.yiri2target(message) content_list = await QQOfficialMessageConverter.yiri2target(message)
# 确定 target_type 和 target_id # 私聊消息
target_type = None
target_id = None
if qq_official_event.t == 'C2C_MESSAGE_CREATE': if qq_official_event.t == 'C2C_MESSAGE_CREATE':
target_type = 'c2c' for content in content_list:
target_id = qq_official_event.user_openid if content['type'] == 'text':
elif qq_official_event.t == 'GROUP_AT_MESSAGE_CREATE': await self.bot.send_private_text_msg(
target_type = 'group' qq_official_event.user_openid,
target_id = qq_official_event.group_openid content['content'],
elif qq_official_event.t == 'AT_MESSAGE_CREATE': qq_official_event.d_id,
# 频道群聊使用频道 API暂不支持富媒体 )
# 群聊消息
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: for content in content_list:
if content['type'] == 'text': if content['type'] == 'text':
await self.bot.send_channle_group_text_msg( await self.bot.send_channle_group_text_msg(
@@ -248,9 +185,9 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
content['content'], content['content'],
qq_official_event.d_id, 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: for content in content_list:
if content['type'] == 'text': if content['type'] == 'text':
await self.bot.send_channle_private_text_msg( await self.bot.send_channle_private_text_msg(
@@ -258,63 +195,6 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
content['content'], content['content'],
qq_official_event.d_id, 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): async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain):
pass pass
@@ -358,197 +238,18 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
return await self.bot.handle_unified_webhook(request) return await self.bot.handle_unified_webhook(request)
async def run_async(self): async def run_async(self):
if not self.enable_webhook:
await self._run_websocket()
else:
# 统一 webhook 模式下,不启动独立的 Quart 应用 # 统一 webhook 模式下,不启动独立的 Quart 应用
# 保持运行但不启动独立端口
async def keep_alive(): async def keep_alive():
while True: while True:
await asyncio.sleep(1) await asyncio.sleep(1)
await keep_alive() await keep_alive()
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
async def kill(self) -> bool: 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 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)
def unregister_listener( def unregister_listener(
self, self,
event_type: type, event_type: type,

View File

@@ -7,9 +7,9 @@ metadata:
zh_Hans: QQ 官方 API zh_Hans: QQ 官方 API
zh_Hant: QQ 官方 API zh_Hant: QQ 官方 API
description: description:
en_US: QQ Official API (Webhook / WebSocket) en_US: QQ Official API (Webhook)
zh_Hans: QQ 官方 API,支持 Webhook 和 WebSocket 两种连接模 zh_Hans: QQ 官方 API (Webhook),需要公网地址以接收消息推送,请查看文档了解使用方
zh_Hant: QQ 官方 API,支援 Webhook 和 WebSocket 兩種連線模 zh_Hant: QQ 官方 API (Webhook),需要公網地址以接收訊息推送,請查看文件了解使用方
icon: qqofficial.svg icon: qqofficial.svg
spec: spec:
categories: categories:
@@ -19,6 +19,18 @@ spec:
en: https://link.langbot.app/en/platforms/qqofficial en: https://link.langbot.app/en/platforms/qqofficial
ja: https://link.langbot.app/ja/platforms/qqofficial ja: https://link.langbot.app/ja/platforms/qqofficial
config: 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 - name: appid
label: label:
en_US: App ID en_US: App ID
@@ -43,46 +55,6 @@ spec:
type: string type: string
required: true required: true
default: "" 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: execution:
python: python:
path: ./qqofficial.py path: ./qqofficial.py

View File

@@ -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"

View File

@@ -1,94 +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
model_config = pydantic.ConfigDict(arbitrary_types_allowed=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

View File

@@ -312,7 +312,7 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
async def _process_image_components(self, message_chain_obj: list): async def _process_image_components(self, message_chain_obj: list):
""" """
处理消息链中的图片和文件组件将path转换为base64 处理消息链中的图片组件将path转换为base64
Args: Args:
message_chain_obj: 消息链对象列表 message_chain_obj: 消息链对象列表
@@ -322,18 +322,16 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
storage_mgr = self.ap.storage_mgr storage_mgr = self.ap.storage_mgr
for component in message_chain_obj: for component in message_chain_obj:
comp_type = component.get('type', '') if component.get('type') == 'Image' and component.get('path'):
comp_path = component.get('path', '')
if not comp_path:
continue
if comp_type == 'Image':
try: 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') 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')): if file_key.lower().endswith(('.jpg', '.jpeg')):
mime_type = 'image/jpeg' mime_type = 'image/jpeg'
elif file_key.lower().endswith('.png'): elif file_key.lower().endswith('.png'):
@@ -343,19 +341,19 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
elif file_key.lower().endswith('.webp'): elif file_key.lower().endswith('.webp'):
mime_type = 'image/webp' mime_type = 'image/webp'
else: else:
mime_type = 'image/png' mime_type = 'image/png' # 默认
component['base64'] = f'data:{mime_type};base64,{base64_str}' 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'] = '' component['path'] = ''
# 保留path字段用于后端处理前端使用base64显示
except Exception as e: 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( async def handle_websocket_message(
self, self,
connection: WebSocketConnection, connection: WebSocketConnection,
message_data: dict, message_data: dict,
owner_bot=None,
): ):
""" """
处理从WebSocket接收的消息 处理从WebSocket接收的消息
@@ -368,8 +366,6 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
message_data: 消息数据,包含: message_data: 消息数据,包含:
- message: 消息链 - message: 消息链
- stream: 是否启用流式输出 (可选默认True) - 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 pipeline_uuid = connection.pipeline_uuid
session_type = connection.session_type 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() 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 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 if event.__class__ in self.listeners:
listeners = ( asyncio.create_task(self.listeners[event.__class__](event, self))
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))
def get_websocket_messages(self, pipeline_uuid: str, session_type: str) -> list[dict]: def get_websocket_messages(self, pipeline_uuid: str, session_type: str) -> list[dict]:
"""获取消息历史""" """获取消息历史"""

View File

@@ -19,18 +19,6 @@ spec:
en: https://link.langbot.app/en/platforms/wecombot en: https://link.langbot.app/en/platforms/wecombot
ja: https://link.langbot.app/ja/platforms/wecombot ja: https://link.langbot.app/ja/platforms/wecombot
config: config:
- name: one-click-create
label:
en_US: One-Click Create Bot
zh_Hans: 一键创建机器人
zh_Hant: 一鍵建立機器人
description:
en_US: "Scan QR code with WeCom to automatically create a bot and fill in BotId and Secret. Note: Robot Name needs to be filled in manually."
zh_Hans: "使用企业微信扫码自动创建机器人并填写 BotId 和 Secret。注意机器人名称需手动填写。"
zh_Hant: "使用企業微信掃碼自動建立機器人並填寫 BotId 和 Secret。注意機器人名稱需手動填寫。"
type: qr-code-login
login_platform: wecombot
required: false
- name: BotId - name: BotId
label: label:
en_US: BotId en_US: BotId

View File

@@ -11,7 +11,6 @@ import os
import sys import sys
import httpx import httpx
import sqlalchemy import sqlalchemy
import yaml
from async_lru import alru_cache from async_lru import alru_cache
from langbot_plugin.api.entities.builtin.pipeline.query import provider_session from langbot_plugin.api.entities.builtin.pipeline.query import provider_session
@@ -196,26 +195,16 @@ class PluginRuntimeConnector:
return await self.handler.ping() return await self.handler.ping()
def _inspect_plugin_package( def _extract_deps_metadata(
self, self,
file_bytes: bytes, file_bytes: bytes,
task_context: taskmgr.TaskContext | None, task_context: taskmgr.TaskContext | None,
) -> tuple[str | None, str | None]: ):
"""Extract plugin identity and dependency metadata from a plugin package.""" """Extract dependency count from requirements.txt inside plugin zip."""
plugin_author = None if task_context is None:
plugin_name = None return
try: try:
with zipfile.ZipFile(io.BytesIO(file_bytes)) as zf: with zipfile.ZipFile(io.BytesIO(file_bytes)) as zf:
try:
manifest = yaml.safe_load(zf.read('manifest.yaml').decode('utf-8', errors='ignore')) or {}
metadata = manifest.get('metadata', {})
plugin_author = metadata.get('author')
plugin_name = metadata.get('name')
except Exception:
pass
if task_context is not None:
for name in zf.namelist(): for name in zf.namelist():
if name.endswith('requirements.txt'): if name.endswith('requirements.txt'):
content = zf.read(name).decode('utf-8', errors='ignore') content = zf.read(name).decode('utf-8', errors='ignore')
@@ -230,76 +219,16 @@ class PluginRuntimeConnector:
except Exception: except Exception:
pass pass
return plugin_author, plugin_name
def _build_plugin_startup_failure_message(
self,
plugin_author: str,
plugin_name: str,
task_context: taskmgr.TaskContext | None,
) -> str:
dep_hint = ''
if task_context is not None:
current_dep = task_context.metadata.get('current_dep')
if current_dep:
dep_hint = f' Last dependency: {current_dep}.'
return (
f'Plugin {plugin_author}/{plugin_name} failed to start after installation. '
f'Dependency installation or plugin initialization may have failed.{dep_hint} '
f'Please check the plugin requirements and runtime logs.'
)
async def _wait_for_installed_plugin_ready(
self,
plugin_author: str | None,
plugin_name: str | None,
task_context: taskmgr.TaskContext | None,
timeout: float = 30,
):
"""Wait until the installed plugin is registered by the runtime.
The plugin runtime launches plugins asynchronously. If dependency installation
fails, the plugin process exits before registration; without this check the
install task can incorrectly finish successfully.
"""
if not plugin_author or not plugin_name:
return
deadline = time.time() + timeout
last_error: Exception | None = None
while time.time() < deadline:
try:
plugin = await self.get_plugin_info(plugin_author, plugin_name)
if plugin is not None:
status = plugin.get('status')
if status == 'initialized':
return
except Exception as e:
last_error = e
await asyncio.sleep(0.5)
message = self._build_plugin_startup_failure_message(plugin_author, plugin_name, task_context)
if last_error is not None:
message = f'{message} Last runtime error: {last_error}'
raise RuntimeError(message)
async def install_plugin( async def install_plugin(
self, self,
install_source: PluginInstallSource, install_source: PluginInstallSource,
install_info: dict[str, Any], install_info: dict[str, Any],
task_context: taskmgr.TaskContext | None = None, task_context: taskmgr.TaskContext | None = None,
): ):
plugin_author = install_info.get('plugin_author')
plugin_name = install_info.get('plugin_name')
if install_source == PluginInstallSource.LOCAL: if install_source == PluginInstallSource.LOCAL:
# transfer file before install # transfer file before install
file_bytes = install_info['plugin_file'] file_bytes = install_info['plugin_file']
plugin_author, plugin_name = self._inspect_plugin_package(file_bytes, task_context) self._extract_deps_metadata(file_bytes, task_context)
if task_context is not None and plugin_author and plugin_name:
task_context.metadata['plugin_name'] = f'{plugin_author}/{plugin_name}'
file_key = await self.handler.send_file(file_bytes, 'lbpkg') file_key = await self.handler.send_file(file_bytes, 'lbpkg')
install_info['plugin_file_key'] = file_key install_info['plugin_file_key'] = file_key
del install_info['plugin_file'] del install_info['plugin_file']
@@ -336,9 +265,7 @@ class PluginRuntimeConnector:
task_context.metadata['download_speed'] = downloaded / elapsed if elapsed > 0 else 0 task_context.metadata['download_speed'] = downloaded / elapsed if elapsed > 0 else 0
file_bytes = b''.join(chunks) file_bytes = b''.join(chunks)
plugin_author, plugin_name = self._inspect_plugin_package(file_bytes, task_context) self._extract_deps_metadata(file_bytes, task_context)
if task_context is not None and plugin_author and plugin_name:
task_context.metadata['plugin_name'] = f'{plugin_author}/{plugin_name}'
file_key = await self.handler.send_file(file_bytes, 'lbpkg') file_key = await self.handler.send_file(file_bytes, 'lbpkg')
install_info['plugin_file_key'] = file_key install_info['plugin_file_key'] = file_key
self.ap.logger.info(f'Transfered file {file_key} to plugin runtime') self.ap.logger.info(f'Transfered file {file_key} to plugin runtime')
@@ -362,8 +289,6 @@ class PluginRuntimeConnector:
if metadata is not None and task_context is not None: if metadata is not None and task_context is not None:
task_context.metadata.update(metadata) task_context.metadata.update(metadata)
await self._wait_for_installed_plugin_ready(plugin_author, plugin_name, task_context)
async def upgrade_plugin( async def upgrade_plugin(
self, self,
plugin_author: str, plugin_author: str,
@@ -506,17 +431,6 @@ class PluginRuntimeConnector:
async def get_plugin_assets(self, plugin_author: str, plugin_name: str, filepath: str) -> dict[str, Any]: 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) 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]: async def get_debug_info(self) -> dict[str, Any]:
"""Get debug information including debug key and WS URL""" """Get debug information including debug key and WS URL"""
if not self.is_enable_plugin: if not self.is_enable_plugin:

View File

@@ -367,22 +367,6 @@ class RuntimeConnectionHandler(handler.Handler):
owner_type = data['owner_type'] owner_type = data['owner_type']
owner = data['owner'] owner = data['owner']
value = base64.b64decode(data['value_base64']) 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( result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(persistence_bstorage.BinaryStorage) sqlalchemy.select(persistence_bstorage.BinaryStorage)
@@ -955,11 +939,6 @@ class RuntimeConnectionHandler(handler.Handler):
timeout=20, timeout=20,
) )
asset_file_key = result['file_file_key'] asset_file_key = result['file_file_key']
if not asset_file_key:
return {
'asset_base64': '',
'mime_type': '',
}
mime_type = result['mime_type'] mime_type = result['mime_type']
asset_bytes = await self.read_local_file(asset_file_key) asset_bytes = await self.read_local_file(asset_file_key)
await self.delete_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, '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: async def cleanup_plugin_data(self, plugin_author: str, plugin_name: str) -> None:
"""Cleanup plugin settings and binary storage""" """Cleanup plugin settings and binary storage"""
# Delete plugin settings # Delete plugin settings

View File

@@ -9,6 +9,7 @@ from ...discover import engine
from . import token from . import token
from ...entity.persistence import model as persistence_model from ...entity.persistence import model as persistence_model
from ...entity.errors import provider as provider_errors from ...entity.errors import provider as provider_errors
from async_lru import alru_cache
class ModelManager: class ModelManager:
@@ -23,8 +24,6 @@ class ModelManager:
embedding_models: list[requester.RuntimeEmbeddingModel] embedding_models: list[requester.RuntimeEmbeddingModel]
rerank_models: list[requester.RuntimeRerankModel]
requester_components: list[engine.Component] requester_components: list[engine.Component]
requester_dict: dict[str, type[requester.ProviderAPIRequester]] requester_dict: dict[str, type[requester.ProviderAPIRequester]]
@@ -33,7 +32,6 @@ class ModelManager:
self.ap = ap self.ap = ap
self.llm_models = [] self.llm_models = []
self.embedding_models = [] self.embedding_models = []
self.rerank_models = []
self.requester_components = [] self.requester_components = []
self.requester_dict = {} self.requester_dict = {}
@@ -66,7 +64,8 @@ class ModelManager:
self.llm_models = [] self.llm_models = []
self.embedding_models = [] self.embedding_models = []
self.rerank_models = []
# Load all providers first
self.provider_dict = {} self.provider_dict = {}
providers_result = await self.ap.persistence_mgr.execute_async( providers_result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(persistence_model.ModelProvider) sqlalchemy.select(persistence_model.ModelProvider)
@@ -111,22 +110,6 @@ class ModelManager:
except Exception as e: except Exception as e:
self.ap.logger.error(f'Failed to load model {embedding_model.uuid}: {e}\n{traceback.format_exc()}') self.ap.logger.error(f'Failed to load model {embedding_model.uuid}: {e}\n{traceback.format_exc()}')
# Load rerank models
result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(persistence_model.RerankModel))
rerank_models = result.all()
for rerank_model in rerank_models:
try:
provider = self.provider_dict.get(rerank_model.provider_uuid)
if provider is None:
self.ap.logger.warning(
f'Provider {rerank_model.provider_uuid} not found for model {rerank_model.uuid}'
)
continue
runtime_rerank_model = await self.load_rerank_model_with_provider(rerank_model, provider)
self.rerank_models.append(runtime_rerank_model)
except Exception as e:
self.ap.logger.error(f'Failed to load model {rerank_model.uuid}: {e}\n{traceback.format_exc()}')
async def sync_new_models_from_space(self): async def sync_new_models_from_space(self):
"""Sync models from Space""" """Sync models from Space"""
space_model_provider = await self.ap.persistence_mgr.execute_async( space_model_provider = await self.ap.persistence_mgr.execute_async(
@@ -229,26 +212,6 @@ class ModelManager:
return runtime_embedding_model return runtime_embedding_model
async def init_temporary_runtime_rerank_model(
self,
model_info: dict,
) -> requester.RuntimeRerankModel:
"""Initialize runtime rerank model from dict (for testing)"""
provider_info = model_info.get('provider', {})
runtime_provider = await self.load_provider(provider_info)
runtime_rerank_model = requester.RuntimeRerankModel(
model_entity=persistence_model.RerankModel(
uuid=model_info.get('uuid', ''),
name=model_info.get('name', ''),
provider_uuid='',
extra_args=model_info.get('extra_args', {}),
),
provider=runtime_provider,
)
return runtime_rerank_model
async def load_provider( async def load_provider(
self, provider_info: persistence_model.ModelProvider | sqlalchemy.Row | dict self, provider_info: persistence_model.ModelProvider | sqlalchemy.Row | dict
) -> requester.RuntimeProvider: ) -> requester.RuntimeProvider:
@@ -306,9 +269,6 @@ class ModelManager:
for model in self.embedding_models: for model in self.embedding_models:
if model.provider.provider_entity.uuid == provider_uuid: if model.provider.provider_entity.uuid == provider_uuid:
model.provider = new_runtime_provider model.provider = new_runtime_provider
for model in self.rerank_models:
if model.provider.provider_entity.uuid == provider_uuid:
model.provider = new_runtime_provider
# update ref in provider dict # update ref in provider dict
self.provider_dict[provider_uuid] = new_runtime_provider self.provider_dict[provider_uuid] = new_runtime_provider
@@ -345,22 +305,6 @@ class ModelManager:
return runtime_embedding_model return runtime_embedding_model
async def load_rerank_model_with_provider(
self,
model_info: persistence_model.RerankModel | sqlalchemy.Row,
provider: requester.RuntimeProvider,
) -> requester.RuntimeRerankModel:
"""Load rerank model with provider info"""
if isinstance(model_info, sqlalchemy.Row):
model_info = persistence_model.RerankModel(**model_info._mapping)
runtime_rerank_model = requester.RuntimeRerankModel(
model_entity=model_info,
provider=provider,
)
return runtime_rerank_model
async def load_llm_model(self, model_info: dict): async def load_llm_model(self, model_info: dict):
"""Load LLM model from dict (with provider info)""" """Load LLM model from dict (with provider info)"""
provider_info = model_info.get('provider', {}) provider_info = model_info.get('provider', {})
@@ -408,6 +352,7 @@ class ModelManager:
await self.load_embedding_model_with_provider(model_entity, provider_entity) await self.load_embedding_model_with_provider(model_entity, provider_entity)
@alru_cache(ttl=60 * 5)
async def get_model_by_uuid(self, uuid: str) -> requester.RuntimeLLMModel: async def get_model_by_uuid(self, uuid: str) -> requester.RuntimeLLMModel:
"""Get LLM model by uuid""" """Get LLM model by uuid"""
for model in self.llm_models: for model in self.llm_models:
@@ -415,6 +360,7 @@ class ModelManager:
return model return model
raise ValueError(f'LLM model {uuid} not found') raise ValueError(f'LLM model {uuid} not found')
@alru_cache(ttl=60 * 5)
async def get_embedding_model_by_uuid(self, uuid: str) -> requester.RuntimeEmbeddingModel: async def get_embedding_model_by_uuid(self, uuid: str) -> requester.RuntimeEmbeddingModel:
"""Get embedding model by uuid""" """Get embedding model by uuid"""
for model in self.embedding_models: for model in self.embedding_models:
@@ -422,13 +368,6 @@ class ModelManager:
return model return model
raise ValueError(f'Embedding model {uuid} not found') raise ValueError(f'Embedding model {uuid} not found')
async def get_rerank_model_by_uuid(self, uuid: str) -> requester.RuntimeRerankModel:
"""Get rerank model by uuid"""
for model in self.rerank_models:
if model.model_entity.uuid == uuid:
return model
raise ValueError(f'Rerank model {uuid} not found')
async def remove_llm_model(self, model_uuid: str): async def remove_llm_model(self, model_uuid: str):
"""Remove LLM model""" """Remove LLM model"""
for model in self.llm_models: for model in self.llm_models:
@@ -443,13 +382,6 @@ class ModelManager:
self.embedding_models.remove(model) self.embedding_models.remove(model)
return return
async def remove_rerank_model(self, model_uuid: str):
"""Remove rerank model"""
for model in self.rerank_models:
if model.model_entity.uuid == model_uuid:
self.rerank_models.remove(model)
return
def get_available_requesters_info(self, model_type: str) -> list[dict]: def get_available_requesters_info(self, model_type: str) -> list[dict]:
"""Get all available requesters""" """Get all available requesters"""
if model_type != '': if model_type != '':

View File

@@ -247,40 +247,6 @@ class RuntimeProvider:
except Exception as monitor_err: except Exception as monitor_err:
self.requester.ap.logger.error(f'[Monitoring] Failed to record embedding call: {monitor_err}') self.requester.ap.logger.error(f'[Monitoring] Failed to record embedding call: {monitor_err}')
async def invoke_rerank(
self,
model: RuntimeRerankModel,
query: str,
documents: typing.List[str],
extra_args: dict[str, typing.Any] = {},
) -> typing.List[dict]:
"""Bridge method for invoking rerank with monitoring"""
start_time = time.time()
status = 'success'
try:
result = await self.requester.invoke_rerank(
model=model,
query=query,
documents=documents,
extra_args=extra_args,
)
return result
except Exception:
status = 'error'
raise
finally:
duration_ms = int((time.time() - start_time) * 1000)
try:
self.requester.ap.logger.debug(
f'[Rerank] model={model.model_entity.name} docs={len(documents)} '
f'duration={duration_ms}ms status={status}'
)
except Exception as monitor_err:
self.requester.ap.logger.error(f'[Monitoring] Failed to record rerank call: {monitor_err}')
class RuntimeLLMModel: class RuntimeLLMModel:
"""运行时模型""" """运行时模型"""
@@ -318,29 +284,10 @@ class RuntimeEmbeddingModel:
self.provider = provider self.provider = provider
class RuntimeRerankModel:
"""运行时 Rerank 模型"""
model_entity: persistence_model.RerankModel
"""模型数据"""
provider: RuntimeProvider
"""提供商实例"""
def __init__(
self,
model_entity: persistence_model.RerankModel,
provider: RuntimeProvider,
):
self.model_entity = model_entity
self.provider = provider
class ProviderAPIRequester(metaclass=abc.ABCMeta): class ProviderAPIRequester(metaclass=abc.ABCMeta):
"""Provider API请求器""" """Provider API请求器"""
name: str = None name: str = None
init_api_key: str = 'langbot-init-placeholder'
ap: app.Application ap: app.Application
@@ -429,23 +376,3 @@ class ProviderAPIRequester(metaclass=abc.ABCMeta):
或者 tuple[typing.List[typing.List[float]], dict]: 返回 (embedding 向量, usage_info) 或者 tuple[typing.List[typing.List[float]], dict]: 返回 (embedding 向量, usage_info)
""" """
pass pass
async def invoke_rerank(
self,
model: RuntimeRerankModel,
query: str,
documents: typing.List[str],
extra_args: dict[str, typing.Any] = {},
) -> typing.List[dict]:
"""调用 Rerank API
Args:
model (RuntimeRerankModel): 使用的模型信息
query (str): 查询文本
documents (typing.List[str]): 待重排序的文档列表
extra_args (dict[str, typing.Any], optional): 额外的参数. Defaults to {}.
Returns:
typing.List[dict]: [{"index": int, "relevance_score": float}, ...]
"""
raise NotImplementedError('This requester does not support rerank')

View File

@@ -25,7 +25,6 @@ spec:
support_type: support_type:
- llm - llm
- text-embedding - text-embedding
- rerank
provider_category: maas provider_category: maas
execution: execution:
python: python:

View File

@@ -24,7 +24,6 @@ spec:
default: 120 default: 120
support_type: support_type:
- llm - llm
- rerank
provider_category: maas provider_category: maas
execution: execution:
python: python:

View File

@@ -25,7 +25,7 @@ class OpenAIChatCompletions(requester.ProviderAPIRequester):
async def initialize(self): async def initialize(self):
self.client = openai.AsyncClient( self.client = openai.AsyncClient(
api_key=self.init_api_key, api_key='',
base_url=self.requester_cfg['base_url'].replace(' ', ''), base_url=self.requester_cfg['base_url'].replace(' ', ''),
timeout=self.requester_cfg['timeout'], timeout=self.requester_cfg['timeout'],
http_client=httpx.AsyncClient(trust_env=True, timeout=self.requester_cfg['timeout']), http_client=httpx.AsyncClient(trust_env=True, timeout=self.requester_cfg['timeout']),
@@ -615,88 +615,3 @@ class OpenAIChatCompletions(requester.ProviderAPIRequester):
raise errors.RequesterError(f'请求过于频繁或余额不足: {e.message}') raise errors.RequesterError(f'请求过于频繁或余额不足: {e.message}')
except openai.APIError as e: except openai.APIError as e:
raise errors.RequesterError(f'请求错误: {e.message}') raise errors.RequesterError(f'请求错误: {e.message}')
async def invoke_rerank(
self,
model: requester.RuntimeRerankModel,
query: str,
documents: typing.List[str],
extra_args: dict[str, typing.Any] = {},
) -> typing.List[dict]:
"""Standard /rerank endpoint (Jina/Cohere/SiliconFlow/Voyage/DashScope compatible)
Supports extra_args from model.extra_args:
- rerank_url: full URL override (e.g. "https://dashscope.aliyuncs.com/compatible-api/v1/reranks")
- rerank_path: path override appended to base_url (e.g. "reranks" instead of default "rerank")
- Any other fields are merged into the request payload.
"""
api_key = model.provider.token_mgr.get_token()
base_url = self.requester_cfg.get('base_url', '').rstrip('/')
timeout = self.requester_cfg.get('timeout', 120)
merged_args = {}
if model.model_entity.extra_args:
merged_args.update(model.model_entity.extra_args)
if extra_args:
merged_args.update(extra_args)
rerank_url = merged_args.pop('rerank_url', None)
rerank_path = merged_args.pop('rerank_path', 'rerank')
if not rerank_url:
rerank_url = f'{base_url}/{rerank_path}'
headers = {
'Content-Type': 'application/json',
'Authorization': f'Bearer {api_key}',
}
payload = {
'model': model.model_entity.name,
'query': query,
'documents': documents[:64],
'top_n': min(len(documents), 64),
}
if merged_args:
payload.update(merged_args)
try:
async with httpx.AsyncClient(trust_env=True, timeout=timeout) as client:
resp = await client.post(rerank_url, headers=headers, json=payload)
resp.raise_for_status()
data = resp.json()
results = self._parse_rerank_response(data)
if results:
scores = [r.get('relevance_score', 0.0) 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 httpx.HTTPStatusError as e:
raise errors.RequesterError(f'Rerank request failed: {e.response.status_code} - {e.response.text}')
except httpx.TimeoutException:
raise errors.RequesterError('Rerank request timed out')
except Exception as e:
raise errors.RequesterError(f'Rerank request error: {str(e)}')
@staticmethod
def _parse_rerank_response(data: dict) -> typing.List[dict]:
"""Parse rerank response from various providers.
Handles:
- Jina/Cohere/SiliconFlow: {"results": [{"index", "relevance_score"}]}
- Voyage AI: {"data": [{"index", "relevance_score"}]}
- DashScope: {"output": {"results": [{"index", "relevance_score"}]}}
"""
if 'results' in data:
return data['results']
if 'data' in data:
return data['data']
if 'output' in data and isinstance(data['output'], dict):
return data['output'].get('results', [])
return []

View File

@@ -25,7 +25,6 @@ spec:
support_type: support_type:
- llm - llm
- text-embedding - text-embedding
- rerank
provider_category: manufacturer provider_category: manufacturer
execution: execution:
python: python:

View File

@@ -1,8 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 128 128" id="Chroma--Streamline-Svg-Logos" height="128" width="128"> <svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<desc> <rect width="24" height="24" rx="5" fill="#7B68EE"/>
Chroma Streamline Icon: https://streamlinehq.com <circle cx="12" cy="12" r="6" fill="#FF6B35"/>
</desc> <circle cx="12" cy="12" r="3" fill="#7B68EE"/>
<path fill="#ffde2d" d="M84.88839999999999 104.10666666666665c23.0732 0 41.77773333333333 -17.956266666666664 41.77773333333333 -40.10653333333333 0 -22.150266666666667 -18.70453333333333 -40.10653333333333 -41.77773333333333 -40.10653333333333 -23.0732 0 -41.77773333333333 17.956266666666664 -41.77773333333333 40.10653333333333 0 22.150266666666667 18.70453333333333 40.10653333333333 41.77773333333333 40.10653333333333Z" stroke-width="1.3333"></path> <path d="M12 6V18" stroke="#FFF" stroke-width="1.5" stroke-linecap="round"/>
<path fill="#327eff" d="M43.111066666666666 104.10666666666665c23.0732 0 41.77773333333333 -17.956266666666664 41.77773333333333 -40.10653333333333 0 -22.150266666666667 -18.70453333333333 -40.10653333333333 -41.77773333333333 -40.10653333333333C20.037866666666666 23.8936 1.3333333333333333 41.849866666666664 1.3333333333333333 64.00013333333334 1.3333333333333333 86.15039999999999 20.037866666666666 104.10666666666665 43.111066666666666 104.10666666666665Z" stroke-width="1.3333"></path> <path d="M6 12H18" stroke="#FFF" stroke-width="1.5" stroke-linecap="round"/>
<path fill="#ff6446" d="M84.88866666666667 64.00013333333334c0 22.150399999999998 -18.704666666666665 40.10626666666666 -41.778 40.10626666666666V64.00013333333334h41.778Zm-41.778 0c0 -22.150266666666667 18.70453333333333 -40.10653333333333 41.778 -40.10653333333333v40.10653333333333H43.11066666666666Z" stroke-width="1.3333"></path>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 1.5 KiB

After

Width:  |  Height:  |  Size: 413 B

View File

@@ -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>Cohere</title><path clip-rule="evenodd" d="M8.128 14.099c.592 0 1.77-.033 3.398-.703 1.897-.781 5.672-2.2 8.395-3.656 1.905-1.018 2.74-2.366 2.74-4.18A4.56 4.56 0 0018.1 1H7.549A6.55 6.55 0 001 7.55c0 3.617 2.745 6.549 7.128 6.549z" fill="#39594D" fill-rule="evenodd"></path><path clip-rule="evenodd" d="M9.912 18.61a4.387 4.387 0 012.705-4.052l3.323-1.38c3.361-1.394 7.06 1.076 7.06 4.715a5.104 5.104 0 01-5.105 5.104l-3.597-.001a4.386 4.386 0 01-4.386-4.387z" fill="#D18EE2" fill-rule="evenodd"></path><path d="M4.776 14.962A3.775 3.775 0 001 18.738v.489a3.776 3.776 0 007.551 0v-.49a3.775 3.775 0 00-3.775-3.775z" fill="#FF7759"></path></svg>

Before

Width:  |  Height:  |  Size: 769 B

View File

@@ -1,31 +0,0 @@
apiVersion: v1
kind: LLMAPIRequester
metadata:
name: cohere-rerank
label:
en_US: Cohere
zh_Hans: Cohere
icon: cohere.svg
spec:
config:
- name: base_url
label:
en_US: Base URL
zh_Hans: 基础 URL
type: string
required: true
default: https://api.cohere.com/v2
- name: timeout
label:
en_US: Timeout
zh_Hans: 超时时间
type: integer
required: true
default: 120
support_type:
- rerank
provider_category: manufacturer
execution:
python:
path: ./chatcmpl.py
attr: OpenAIChatCompletions

View File

@@ -25,7 +25,6 @@ spec:
support_type: support_type:
- llm - llm
- text-embedding - text-embedding
- rerank
provider_category: maas provider_category: maas
execution: execution:
python: python:

View File

@@ -1 +0,0 @@
<svg fill="currentColor" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Jina</title><path d="M6.608 21.416a4.608 4.608 0 100-9.217 4.608 4.608 0 000 9.217zM20.894 2.015c.614 0 1.106.492 1.106 1.106v9.002c0 5.13-4.148 9.309-9.217 9.37v-9.355l-.03-9.032c0-.614.491-1.106 1.106-1.106h7.158l-.123.015z"></path></svg>

Before

Width:  |  Height:  |  Size: 404 B

View File

@@ -1,31 +0,0 @@
apiVersion: v1
kind: LLMAPIRequester
metadata:
name: jina-rerank
label:
en_US: Jina
zh_Hans: Jina
icon: jina.svg
spec:
config:
- name: base_url
label:
en_US: Base URL
zh_Hans: 基础 URL
type: string
required: true
default: https://api.jina.ai/v1
- name: timeout
label:
en_US: Timeout
zh_Hans: 超时时间
type: integer
required: true
default: 120
support_type:
- rerank
provider_category: manufacturer
execution:
python:
path: ./chatcmpl.py
attr: OpenAIChatCompletions

View File

@@ -25,7 +25,7 @@ class ModelScopeChatCompletions(requester.ProviderAPIRequester):
async def initialize(self): async def initialize(self):
self.client = openai.AsyncClient( self.client = openai.AsyncClient(
api_key=self.init_api_key, api_key='',
base_url=self.requester_cfg['base_url'], base_url=self.requester_cfg['base_url'],
timeout=self.requester_cfg['timeout'], timeout=self.requester_cfg['timeout'],
http_client=httpx.AsyncClient(trust_env=True, timeout=self.requester_cfg['timeout']), http_client=httpx.AsyncClient(trust_env=True, timeout=self.requester_cfg['timeout']),

View File

@@ -25,7 +25,6 @@ spec:
support_type: support_type:
- llm - llm
- text-embedding - text-embedding
- rerank
provider_category: maas provider_category: maas
execution: execution:
python: python:

View File

@@ -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

View File

@@ -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': {},
},
}

View File

@@ -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

View File

@@ -1,17 +1,8 @@
<svg id="_图层_1" data-name="图层 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 334.84 76.22"> <svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs> <rect width="24" height="24" rx="5" fill="#1E3A5F"/>
<style> <path d="M6 12C6 8.68629 8.68629 6 12 6C15.3137 6 18 8.68629 18 12" stroke="#4FC3F7" stroke-width="2" stroke-linecap="round"/>
.cls-1 { <path d="M18 12C18 15.3137 15.3137 18 12 18C8.68629 18 6 15.3137 6 12" stroke="#81D4FA" stroke-width="2" stroke-linecap="round"/>
fill: currentColor; <circle cx="12" cy="12" r="2" fill="#4FC3F7"/>
} <circle cx="6" cy="12" r="1.5" fill="#81D4FA"/>
</style> <circle cx="18" cy="12" r="1.5" fill="#4FC3F7"/>
</defs>
<path class="cls-1" d="M308.56,23.63c-5.04,0-9.73,1.43-13.73,3.88V1.08l-12.56,4.61v70h12.56v-3.35c4,2.46,8.71,3.88,13.73,3.88,14.49,0,26.29-11.79,26.29-26.29s-11.79-26.29-26.29-26.29h0ZM308.56,63.88c-6.87,0-12.57-4.98-13.73-11.51v-4.91c1.16-6.54,6.88-11.51,13.73-11.51,7.7,0,13.96,6.26,13.96,13.96s-6.26,13.96-13.96,13.96Z"></path>
<path class="cls-1" d="M255.54,5.69v21.83c-4-2.46-8.71-3.88-13.73-3.88-14.49,0-26.29,11.79-26.29,26.29s11.79,26.29,26.29,26.29c5.04,0,9.73-1.43,13.73-3.88v3.35h12.56V1.08l-12.56,4.61ZM241.81,63.88c-7.7,0-13.96-6.26-13.96-13.96s6.26-13.96,13.96-13.96c6.87,0,12.57,4.98,13.73,11.51v4.91c-1.16,6.54-6.88,11.51-13.73,11.51Z"></path>
<polygon class="cls-1" points="195.35 52.2 186.65 61.17 200.64 75.62 209.32 75.62 218.01 75.62 195.35 52.2"></polygon>
<path class="cls-1" d="M167.14,4.59c.65,3.99.68,8.04.03,12.15-.03.17.16.3.31.21,3.82-2.21,7.82-3.69,12.01-4.33.12-.02.19-.13.17-.23-.68-4.13-.61-8.18-.03-12.16.02-.17-.16-.3-.31-.2-4.01,2.31-8.01,3.81-12.01,4.34-.12.01-.19.12-.17.23h0Z"></path>
<path class="cls-1" d="M198.75,24.09l-19.07,19.72v-25.57c-4.49.67-8.7,2.11-12.56,4.57v52.83h12.56v-13.87l3.78-3.9.02.02,8.68-8.97-.02-.02,23.98-24.8h-17.37Z"></path>
<path class="cls-1" d="M145.03,57.86c-2.56,4.45-7.17,7.2-12.13,7.2-5.96,0-11.3-3.96-13.32-9.85h38.87l.08-.42c.29-1.5.42-3.06.42-4.65,0-14.37-11.69-26.06-26.06-26.06s-26.06,11.69-26.06,26.06,11.69,26.06,26.06,26.06c9.63,0,18.43-5.28,22.98-13.77l.26-.49-11.1-4.08h-.01ZM132.88,35.19h.03c5.96,0,11.3,3.96,13.32,9.85h-26.67c2.02-5.89,7.36-9.85,13.32-9.85Z"></path>
<path class="cls-1" d="M75.92,65.07c-5.96,0-11.29-3.96-13.32-9.85h38.87l.08-.42c.29-1.5.42-3.06.42-4.65,0-14.37-11.69-26.06-26.06-26.06s-26.06,11.69-26.06,26.06,11.69,26.06,26.06,26.06c9.63,0,18.43-5.28,22.98-13.77l.26-.49h0l-11.1-4.08c-2.56,4.45-7.17,7.2-12.13,7.2h-.01ZM75.92,35.19h.03c5.96,0,11.29,3.96,13.32,9.85h-26.67c2.03-5.89,7.36-9.85,13.32-9.85Z"></path>
<path class="cls-1" d="M30.43,45.58l-10.2-1.91c-3.03-.56-4.98-2.25-4.98-4.33,0-1.5,1.61-4.35,7.68-4.35,5.53,0,9.36,3.5,10.25,6.26l10.9-4-.14-.42c-1.17-3.54-3.5-6.58-6.94-9.04-3.49-2.49-8.04-3.69-13.88-3.69s-10.98,1.5-14.78,4.34c-3.88,2.91-5.84,6.76-5.84,11.46,0,7.98,4.72,12.77,14.42,14.64l9.9,1.81c3.05.61,4.94,2.27,4.94,4.33,0,2.61-3.58,4.44-8.7,4.44-5.79,0-9.9-3.72-11.85-7.14L0,62.1l.14.39c1.3,3.8,3.89,7.07,7.7,9.71,3.78,2.6,8.65,3.95,14.51,3.98l.25.03c6.87,0,12.55-1.57,16.43-4.53,3.98-3.05,6-6.99,6-11.74,0-3.73-1.14-6.7-3.6-9.33-2.27-2.42-5.98-4.11-10.98-5.02h-.02Z"></path>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 2.7 KiB

After

Width:  |  Height:  |  Size: 569 B

View File

@@ -25,7 +25,6 @@ spec:
support_type: support_type:
- llm - llm
- text-embedding - text-embedding
- rerank
provider_category: maas provider_category: maas
execution: execution:
python: python:

View File

@@ -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>Voyage</title><path d="M5.407 0v.066a.974.974 0 00-.048.245c-.011.11-.016.208-.016.295 0 .339.043.715.128 1.13.097.405.274.912.531 1.524l7.125 16.366L20.011 3.39c.161-.404.333-.846.515-1.327.182-.48.273-.966.273-1.458a1.406 1.406 0 00-.096-.54V0H24v.066c-.204.207-.45.578-.74 1.114-.29.535-.606 1.195-.949 1.982L13.095 24h-1.287L3.075 3.965c-.204-.47-.418-.923-.644-1.36-.214-.437-.418-.83-.61-1.18-.194-.36-.365-.66-.515-.9A5.666 5.666 0 001 .064V0h4.407z" fill="#012E33"></path></svg>

Before

Width:  |  Height:  |  Size: 610 B

View File

@@ -1,31 +0,0 @@
apiVersion: v1
kind: LLMAPIRequester
metadata:
name: voyageai-rerank
label:
en_US: Voyage AI
zh_Hans: Voyage AI
icon: voyageai.svg
spec:
config:
- name: base_url
label:
en_US: Base URL
zh_Hans: 基础 URL
type: string
required: true
default: https://api.voyageai.com/v1
- name: timeout
label:
en_US: Timeout
zh_Hans: 超时时间
type: integer
required: true
default: 120
support_type:
- rerank
provider_category: manufacturer
execution:
python:
path: ./chatcmpl.py
attr: OpenAIChatCompletions

View File

@@ -14,14 +14,7 @@ class TokenManager:
def __init__(self, name: str, tokens: list[str]): def __init__(self, name: str, tokens: list[str]):
self.name = name self.name = name
self.tokens = [] self.tokens = tokens
seen_tokens = set()
for token in tokens:
normalized_token = token.strip() if isinstance(token, str) else ''
if not normalized_token or normalized_token in seen_tokens:
continue
self.tokens.append(normalized_token)
seen_tokens.add(normalized_token)
self.using_token_index = 0 self.using_token_index = 0
def get_token(self) -> str: def get_token(self) -> str:

View File

@@ -172,45 +172,6 @@ class LocalAgentRunner(runner.RequestRunner):
if result: if result:
all_results.extend(result) all_results.extend(result)
# Rerank step: re-score results using a rerank model if configured
local_agent_config = query.pipeline_config.get('ai', {}).get('local-agent', {})
rerank_model_uuid = local_agent_config.get('rerank-model', '')
if rerank_model_uuid == '__none__':
rerank_model_uuid = ''
self.ap.logger.info(
f'Rerank config: model_uuid={rerank_model_uuid!r}, '
f'results={len(all_results)}, '
f'local_agent_keys={list(local_agent_config.keys())}'
)
if all_results and rerank_model_uuid:
try:
rerank_model = await self.ap.model_mgr.get_rerank_model_by_uuid(rerank_model_uuid)
rerank_top_k = int(local_agent_config.get('rerank-top-k', 5))
doc_texts = []
for entry in all_results:
text = ' '.join(c.text for c in entry.content if c.type == 'text' and c.text)
doc_texts.append(text)
doc_texts_capped = doc_texts[:64]
scores = await rerank_model.provider.invoke_rerank(
model=rerank_model,
query=user_message_text,
documents=doc_texts_capped,
)
scored = sorted(scores, key=lambda x: x.get('relevance_score', 0), reverse=True)
top_indices = [s['index'] for s in scored[:rerank_top_k] if s['index'] < len(all_results)]
all_results = [all_results[i] for i in top_indices]
self.ap.logger.info(
f'Rerank complete: {len(doc_texts)} docs reranked -> top {len(all_results)} kept (top_k={rerank_top_k})'
)
except ValueError:
self.ap.logger.warning(f'Rerank model {rerank_model_uuid} not found, skipping rerank')
except Exception as e:
self.ap.logger.warning(f'Rerank failed, using original order: {e}')
final_user_message_text = '' final_user_message_text = ''
if all_results: if all_results:

View File

@@ -187,12 +187,6 @@ class N8nServiceAPIRunner(runner.RequestRunner):
if not query.session.using_conversation.uuid: if not query.session.using_conversation.uuid:
query.session.using_conversation.uuid = str(uuid.uuid4()) 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) plain_text = await self._preprocess_user_message(query)

View File

@@ -148,7 +148,6 @@ class RuntimeKnowledgeBase(KnowledgeBaseInterface):
supported_extensions = {'txt', 'pdf', 'docx', 'md', 'html'} supported_extensions = {'txt', 'pdf', 'docx', 'md', 'html'}
stored_file_tasks = [] stored_file_tasks = []
try:
# use utf-8 encoding # use utf-8 encoding
with zipfile.ZipFile(io.BytesIO(zip_bytes), 'r', metadata_encoding='utf-8') as zip_ref: with zipfile.ZipFile(io.BytesIO(zip_bytes), 'r', metadata_encoding='utf-8') as zip_ref:
for file_info in zip_ref.filelist: for file_info in zip_ref.filelist:
@@ -191,17 +190,10 @@ class RuntimeKnowledgeBase(KnowledgeBaseInterface):
if not stored_file_tasks: if not stored_file_tasks:
raise Exception('No supported files found in ZIP archive') raise Exception('No supported files found in ZIP archive')
self.ap.logger.info( self.ap.logger.info(f'Successfully processed ZIP file {zip_file_id}, extracted {len(stored_file_tasks)} files')
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) await self.ap.storage_mgr.storage_provider.delete(zip_file_id)
except FileNotFoundError:
pass return stored_file_tasks[0] if stored_file_tasks else ''
except Exception as e:
self.ap.logger.warning(f'Failed to cleanup ZIP file {zip_file_id}: {e}')
async def retrieve(self, query: str, settings: dict | None = None) -> list[rag_context.RetrievalResultEntry]: async def retrieve(self, query: str, settings: dict | None = None) -> list[rag_context.RetrievalResultEntry]:
# Merge stored retrieval_settings with per-request overrides # Merge stored retrieval_settings with per-request overrides

View File

@@ -12,23 +12,6 @@ from .. import provider
LOCAL_STORAGE_PATH = os.path.join('data', 'storage') 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): class LocalStorageProvider(provider.StorageProvider):
def __init__(self, ap: app.Application): def __init__(self, ap: app.Application):
super().__init__(ap) super().__init__(ap)
@@ -40,47 +23,40 @@ class LocalStorageProvider(provider.StorageProvider):
key: str, key: str,
value: bytes, value: bytes,
): ):
resolved = _safe_resolve(LOCAL_STORAGE_PATH, key) if not os.path.exists(os.path.join(LOCAL_STORAGE_PATH, os.path.dirname(key))):
parent = os.path.dirname(resolved) os.makedirs(os.path.join(LOCAL_STORAGE_PATH, os.path.dirname(key)))
if not os.path.exists(parent): async with aiofiles.open(os.path.join(LOCAL_STORAGE_PATH, f'{key}'), 'wb') as f:
os.makedirs(parent)
async with aiofiles.open(resolved, 'wb') as f:
await f.write(value) await f.write(value)
async def load( async def load(
self, self,
key: str, key: str,
) -> bytes: ) -> bytes:
resolved = _safe_resolve(LOCAL_STORAGE_PATH, key) async with aiofiles.open(os.path.join(LOCAL_STORAGE_PATH, f'{key}'), 'rb') as f:
async with aiofiles.open(resolved, 'rb') as f:
return await f.read() return await f.read()
async def exists( async def exists(
self, self,
key: str, key: str,
) -> bool: ) -> bool:
resolved = _safe_resolve(LOCAL_STORAGE_PATH, key) return os.path.exists(os.path.join(LOCAL_STORAGE_PATH, f'{key}'))
return os.path.exists(resolved)
async def delete( async def delete(
self, self,
key: str, key: str,
): ):
resolved = _safe_resolve(LOCAL_STORAGE_PATH, key) os.remove(os.path.join(LOCAL_STORAGE_PATH, f'{key}'))
os.remove(resolved)
async def size( async def size(
self, self,
key: str, key: str,
) -> int: ) -> int:
resolved = _safe_resolve(LOCAL_STORAGE_PATH, key) return os.path.getsize(os.path.join(LOCAL_STORAGE_PATH, f'{key}'))
return os.path.getsize(resolved)
async def delete_dir_recursive( async def delete_dir_recursive(
self, self,
dir_path: str, dir_path: str,
): ):
resolved = _safe_resolve(LOCAL_STORAGE_PATH, dir_path)
# 直接删除整个目录 # 直接删除整个目录
if os.path.exists(resolved): if os.path.exists(os.path.join(LOCAL_STORAGE_PATH, dir_path)):
shutil.rmtree(resolved) shutil.rmtree(os.path.join(LOCAL_STORAGE_PATH, dir_path))

View File

@@ -83,7 +83,7 @@ def get_func_schema(function: typing.Callable) -> dict:
parameters['properties'][param.name] = { parameters['properties'][param.name] = {
'type': param_type, 'type': param_type,
'description': args_doc.get(param.name, ''), 'description': args_doc[param.name],
} }
# add schema for array # add schema for array

View File

@@ -25,9 +25,6 @@ system:
max_bots: -1 max_bots: -1
max_pipelines: -1 max_pipelines: -1
max_extensions: -1 max_extensions: -1
task_retention:
# Keep at most this many completed async task records in memory
completed_limit: 200
jwt: jwt:
expire: 604800 expire: 604800
secret: '' secret: ''
@@ -71,15 +68,6 @@ vdb:
password: 'postgres' password: 'postgres'
storage: storage:
use: local 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: s3:
endpoint_url: '' endpoint_url: ''
access_key_id: '' access_key_id: ''
@@ -91,9 +79,6 @@ plugin:
runtime_ws_url: 'ws://langbot_plugin_runtime:5400/control/ws' runtime_ws_url: 'ws://langbot_plugin_runtime:5400/control/ws'
enable_marketplace: true enable_marketplace: true
display_plugin_debug_url: 'ws://localhost:5401/plugin/debug/ws' 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: monitoring:
auto_cleanup: auto_cleanup:
# Enable automatic cleanup of expired monitoring records # Enable automatic cleanup of expired monitoring records
@@ -102,8 +87,6 @@ monitoring:
retention_days: 30 retention_days: 30
# Cleanup check interval in hours # Cleanup check interval in hours
check_interval_hours: 1 check_interval_hours: 1
# Number of expired rows to delete per table batch
delete_batch_size: 1000
space: space:
# Space service URL for OAuth and API # Space service URL for OAuth and API
url: 'https://space.langbot.app' url: 'https://space.langbot.app'

View File

@@ -38,8 +38,7 @@
}, },
"ai": { "ai": {
"runner": { "runner": {
"runner": "local-agent", "runner": "local-agent"
"expire-time": 0
}, },
"local-agent": { "local-agent": {
"model": { "model": {
@@ -53,9 +52,7 @@
"content": "You are a helpful assistant." "content": "You are a helpful assistant."
} }
], ],
"knowledge-bases": [], "knowledge-bases": []
"rerank-model": "",
"rerank-top-k": 5
}, },
"dify-service-api": { "dify-service-api": {
"base-url": "https://api.dify.ai/v1", "base-url": "https://api.dify.ai/v1",

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

File diff suppressed because it is too large Load Diff

View File

@@ -47,26 +47,6 @@ stages:
label: label:
en_US: Langflow API en_US: Langflow API
zh_Hans: 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 - name: local-agent
label: label:
en_US: Local Agent en_US: Local Agent
@@ -124,34 +104,6 @@ stages:
field: __system.is_wizard field: __system.is_wizard
operator: neq operator: neq
value: true value: true
- name: rerank-model
label:
en_US: Rerank Model
zh_Hans: 重排序模型
description:
en_US: Optional rerank model to improve retrieval quality by re-scoring retrieved chunks
zh_Hans: 可选的重排序模型,通过重新评分检索结果来提升检索质量
type: rerank-model-selector
required: false
default: ''
show_if:
field: knowledge-bases
operator: neq
value: []
- name: rerank-top-k
label:
en_US: Rerank Top K
zh_Hans: 重排序保留数量
description:
en_US: Number of top results to keep after reranking
zh_Hans: 重排序后保留的最相关结果数量
type: integer
required: false
default: 5
show_if:
field: rerank-model
operator: neq
value: ''
- name: dify-service-api - name: dify-service-api
label: label:
en_US: Dify Service API en_US: Dify Service API

View File

@@ -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']

View File

@@ -1 +0,0 @@

View File

@@ -1,264 +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.api.http.service.provider import ModelProviderService
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.modelmgr.requesters.chatcmpl import OpenAIChatCompletions
from langbot.pkg.provider.modelmgr.requesters.modelscopechatcmpl import ModelScopeChatCompletions
from langbot.pkg.provider.modelmgr.token import TokenManager
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'
def test_normalize_space_provider_api_keys_filters_blank_values():
assert ModelProviderService._normalize_api_keys('space-key') == ['space-key']
assert ModelProviderService._normalize_api_keys(' trimmed-key ') == ['trimmed-key']
assert ModelProviderService._normalize_api_keys('') == []
assert ModelProviderService._normalize_api_keys(' ') == []
assert ModelProviderService._normalize_api_keys(None) == []
assert ModelProviderService._normalize_api_keys([' first-key ', '', 'first-key', 'second-key']) == [
'first-key',
'second-key',
]
def test_token_manager_filters_blank_and_duplicate_tokens():
token_mgr = TokenManager('provider-uuid', [' first-key ', '', 'first-key', 'second-key', ' '])
assert token_mgr.tokens == ['first-key', 'second-key']
assert token_mgr.get_token() == 'first-key'
@pytest.mark.asyncio
async def test_openai_requester_initialize_uses_placeholder_api_key(monkeypatch):
captured_kwargs = {}
def fake_client(**kwargs):
captured_kwargs.update(kwargs)
return SimpleNamespace(**kwargs)
monkeypatch.setattr('langbot.pkg.provider.modelmgr.requesters.chatcmpl.openai.AsyncClient', fake_client)
monkeypatch.setattr('langbot.pkg.provider.modelmgr.requesters.chatcmpl.httpx.AsyncClient', fake_client)
requester_inst = OpenAIChatCompletions(ap=SimpleNamespace(), config={})
await requester_inst.initialize()
assert captured_kwargs['api_key'] == OpenAIChatCompletions.init_api_key
@pytest.mark.asyncio
async def test_modelscope_requester_initialize_uses_placeholder_api_key(monkeypatch):
captured_kwargs = {}
def fake_client(**kwargs):
captured_kwargs.update(kwargs)
return SimpleNamespace(**kwargs)
monkeypatch.setattr('langbot.pkg.provider.modelmgr.requesters.modelscopechatcmpl.openai.AsyncClient', fake_client)
monkeypatch.setattr('langbot.pkg.provider.modelmgr.requesters.modelscopechatcmpl.httpx.AsyncClient', fake_client)
requester_inst = ModelScopeChatCompletions(ap=SimpleNamespace(), config={})
await requester_inst.initialize()
assert captured_kwargs['api_key'] == ModelScopeChatCompletions.init_api_key
@pytest.mark.asyncio
async def test_openai_embedding_call_overrides_placeholder_api_key():
captured_request = {}
async def fake_create(**kwargs):
captured_request['api_key'] = fake_client.api_key
captured_request['kwargs'] = kwargs
return SimpleNamespace(
data=[SimpleNamespace(embedding=[0.1, 0.2])],
usage=SimpleNamespace(prompt_tokens=3, total_tokens=3),
)
fake_client = SimpleNamespace(
api_key=OpenAIChatCompletions.init_api_key,
embeddings=SimpleNamespace(create=fake_create),
)
requester_inst = OpenAIChatCompletions(ap=SimpleNamespace(), config={})
requester_inst.client = fake_client
embeddings, usage_info = await requester_inst.invoke_embedding(
model=requester.RuntimeEmbeddingModel(
model_entity=SimpleNamespace(name='text-embedding-3-small', extra_args={}),
provider=SimpleNamespace(token_mgr=TokenManager('provider-uuid', [' runtime-key ', '', 'runtime-key'])),
),
input_text=['hello'],
)
assert captured_request['api_key'] == 'runtime-key'
assert captured_request['kwargs']['model'] == 'text-embedding-3-small'
assert embeddings == [[0.1, 0.2]]
assert usage_info == {'prompt_tokens': 3, 'total_tokens': 3}
@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]

View File

@@ -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"])

View File

@@ -1,15 +0,0 @@
from langbot.pkg.utils.funcschema import get_func_schema
def test_get_func_schema_uses_empty_description_for_undocumented_parameter():
def sample_function(documented: str, undocumented: int):
"""Sample function.
Args:
documented(str): documented parameter description
"""
schema = get_func_schema(sample_function)
assert schema['parameters']['properties']['documented']['description'] == 'documented parameter description'
assert schema['parameters']['properties']['undocumented']['description'] == ''

282
uv.lock generated
View File

@@ -37,11 +37,11 @@ sdist = { url = "https://files.pythonhosted.org/packages/c2/91/e4bf8584695b065e2
[[package]] [[package]]
name = "aiofiles" name = "aiofiles"
version = "24.1.0" version = "25.1.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/0b/03/a88171e277e8caa88a4c77808c20ebb04ba74cc4681bf1e9416c862de237/aiofiles-24.1.0.tar.gz", hash = "sha256:22a075c9e5a3810f0c2e48f3008c94d68c65d763b9b03857924c99e57355166c", size = 30247, upload-time = "2024-06-24T11:02:03.584Z" } sdist = { url = "https://files.pythonhosted.org/packages/41/c3/534eac40372d8ee36ef40df62ec129bee4fdb5ad9706e58a29be53b2c970/aiofiles-25.1.0.tar.gz", hash = "sha256:a8d728f0a29de45dc521f18f07297428d56992a742f0cd2701ba86e44d23d5b2", size = 46354, upload-time = "2025-10-09T20:51:04.358Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/a5/45/30bb92d442636f570cb5651bc661f52b610e2eec3f891a5dc3a4c3667db0/aiofiles-24.1.0-py3-none-any.whl", hash = "sha256:b4ec55f4195e3eb5d7abd1bf7e061763e864dd4954231fb8539a0ef8bb8260e5", size = 15896, upload-time = "2024-06-24T11:02:01.529Z" }, { url = "https://files.pythonhosted.org/packages/bc/8a/340a1555ae33d7354dbca4faa54948d76d89a27ceef032c8c3bc661d003e/aiofiles-25.1.0-py3-none-any.whl", hash = "sha256:abe311e527c862958650f9438e859c1fa7568a141b22abcd015e120e86a85695", size = 14668, upload-time = "2025-10-09T20:51:03.174Z" },
] ]
[[package]] [[package]]
@@ -155,19 +155,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/62/29/2f8418269e46454a26171bfdd6a055d74febf32234e474930f2f60a17145/aiohttp-3.13.5-cp314-cp314t-win_amd64.whl", hash = "sha256:18a2f6c1182c51baa1d28d68fea51513cb2a76612f038853c0ad3c145423d3d9", size = 505441, upload-time = "2026-03-31T22:00:12.791Z" }, { url = "https://files.pythonhosted.org/packages/62/29/2f8418269e46454a26171bfdd6a055d74febf32234e474930f2f60a17145/aiohttp-3.13.5-cp314-cp314t-win_amd64.whl", hash = "sha256:18a2f6c1182c51baa1d28d68fea51513cb2a76612f038853c0ad3c145423d3d9", size = 505441, upload-time = "2026-03-31T22:00:12.791Z" },
] ]
[[package]]
name = "aiohttp-socks"
version = "0.11.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "aiohttp" },
{ name = "python-socks" },
]
sdist = { url = "https://files.pythonhosted.org/packages/1f/cc/e5bbd54f76bd56291522251e47267b645dac76327b2657ade9545e30522c/aiohttp_socks-0.11.0.tar.gz", hash = "sha256:0afe51638527c79077e4bd6e57052c87c4824233d6e20bb061c53766421b10f0", size = 11196, upload-time = "2025-12-09T13:35:52.564Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/bf/7d/4b633d709b8901d59444d2e512b93e72fe62d2b492a040097c3f7ba017bb/aiohttp_socks-0.11.0-py3-none-any.whl", hash = "sha256:9aacce57c931b8fbf8f6d333cf3cafe4c35b971b35430309e167a35a8aab9ec1", size = 10556, upload-time = "2025-12-09T13:35:50.18Z" },
]
[[package]] [[package]]
name = "aioshutil" name = "aioshutil"
version = "1.6" version = "1.6"
@@ -934,61 +921,61 @@ toml = [
[[package]] [[package]]
name = "cryptography" name = "cryptography"
version = "47.0.0" version = "46.0.7"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, { name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/ef/b2/7ffa7fe8207a8c42147ffe70c3e360b228160c1d85dc3faff16aaa3244c0/cryptography-47.0.0.tar.gz", hash = "sha256:9f8e55fe4e63613a5e1cc5819030f27b97742d720203a087802ce4ce9ceb52bb", size = 830863, upload-time = "2026-04-24T19:54:57.056Z" } sdist = { url = "https://files.pythonhosted.org/packages/47/93/ac8f3d5ff04d54bc814e961a43ae5b0b146154c89c61b47bb07557679b18/cryptography-46.0.7.tar.gz", hash = "sha256:e4cfd68c5f3e0bfdad0d38e023239b96a2fe84146481852dffbcca442c245aa5", size = 750652, upload-time = "2026-04-08T01:57:54.692Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/a4/98/40dfe932134bdcae4f6ab5927c87488754bf9eb79297d7e0070b78dd58e9/cryptography-47.0.0-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:160ad728f128972d362e714054f6ba0067cab7fb350c5202a9ae8ae4ce3ef1a0", size = 7912214, upload-time = "2026-04-24T19:53:03.864Z" }, { url = "https://files.pythonhosted.org/packages/0b/5d/4a8f770695d73be252331e60e526291e3df0c9b27556a90a6b47bccca4c2/cryptography-46.0.7-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:ea42cbe97209df307fdc3b155f1b6fa2577c0defa8f1f7d3be7d31d189108ad4", size = 7179869, upload-time = "2026-04-08T01:56:17.157Z" },
{ url = "https://files.pythonhosted.org/packages/34/c6/2733531243fba725f58611b918056b277692f1033373dcc8bd01af1c05d4/cryptography-47.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b9a8943e359b7615db1a3ba587994618e094ff3d6fa5a390c73d079ce18b3973", size = 4644617, upload-time = "2026-04-24T19:53:06.909Z" }, { url = "https://files.pythonhosted.org/packages/5f/45/6d80dc379b0bbc1f9d1e429f42e4cb9e1d319c7a8201beffd967c516ea01/cryptography-46.0.7-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b36a4695e29fe69215d75960b22577197aca3f7a25b9cf9d165dcfe9d80bc325", size = 4275492, upload-time = "2026-04-08T01:56:19.36Z" },
{ url = "https://files.pythonhosted.org/packages/00/e3/b27be1a670a9b87f855d211cf0e1174a5d721216b7616bd52d8581d912ed/cryptography-47.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f5c15764f261394b22aef6b00252f5195f46f2ca300bec57149474e2538b31f8", size = 4668186, upload-time = "2026-04-24T19:53:09.053Z" }, { url = "https://files.pythonhosted.org/packages/4a/9a/1765afe9f572e239c3469f2cb429f3ba7b31878c893b246b4b2994ffe2fe/cryptography-46.0.7-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5ad9ef796328c5e3c4ceed237a183f5d41d21150f972455a9d926593a1dcb308", size = 4426670, upload-time = "2026-04-08T01:56:21.415Z" },
{ url = "https://files.pythonhosted.org/packages/81/b9/8443cfe5d17d482d348cee7048acf502bb89a51b6382f06240fd290d4ca3/cryptography-47.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:9c59ab0e0fa3a180a5a9c59f3a5abe3ef90d474bc56d7fadfbe80359491b615b", size = 4651244, upload-time = "2026-04-24T19:53:11.217Z" }, { url = "https://files.pythonhosted.org/packages/8f/3e/af9246aaf23cd4ee060699adab1e47ced3f5f7e7a8ffdd339f817b446462/cryptography-46.0.7-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:73510b83623e080a2c35c62c15298096e2a5dc8d51c3b4e1740211839d0dea77", size = 4280275, upload-time = "2026-04-08T01:56:23.539Z" },
{ url = "https://files.pythonhosted.org/packages/5d/5e/13ed0cdd0eb88ba159d6dd5ebfece8cb901dbcf1ae5ac4072e28b55d3153/cryptography-47.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:34b4358b925a5ea3e14384ca781a2c0ef7ac219b57bb9eacc4457078e2b19f92", size = 5252906, upload-time = "2026-04-24T19:53:13.532Z" }, { url = "https://files.pythonhosted.org/packages/0f/54/6bbbfc5efe86f9d71041827b793c24811a017c6ac0fd12883e4caa86b8ed/cryptography-46.0.7-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cbd5fb06b62bd0721e1170273d3f4d5a277044c47ca27ee257025146c34cbdd1", size = 4928402, upload-time = "2026-04-08T01:56:25.624Z" },
{ url = "https://files.pythonhosted.org/packages/64/16/ed058e1df0f33d440217cd120d41d5dda9dd215a80b8187f68483185af82/cryptography-47.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:0024b87d47ae2399165a6bfb20d24888881eeab83ae2566d62467c5ff0030ce7", size = 4701842, upload-time = "2026-04-24T19:53:15.618Z" }, { url = "https://files.pythonhosted.org/packages/2d/cf/054b9d8220f81509939599c8bdbc0c408dbd2bdd41688616a20731371fe0/cryptography-46.0.7-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:420b1e4109cc95f0e5700eed79908cef9268265c773d3a66f7af1eef53d409ef", size = 4459985, upload-time = "2026-04-08T01:56:27.309Z" },
{ url = "https://files.pythonhosted.org/packages/02/e0/3d30986b30fdbd9e969abbdf8ba00ed0618615144341faeb57f395a084fe/cryptography-47.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:1e47422b5557bb82d3fff997e8d92cff4e28b9789576984f08c248d2b3535d93", size = 4289313, upload-time = "2026-04-24T19:53:17.755Z" }, { url = "https://files.pythonhosted.org/packages/f9/46/4e4e9c6040fb01c7467d47217d2f882daddeb8828f7df800cb806d8a2288/cryptography-46.0.7-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:24402210aa54baae71d99441d15bb5a1919c195398a87b563df84468160a65de", size = 3990652, upload-time = "2026-04-08T01:56:29.095Z" },
{ url = "https://files.pythonhosted.org/packages/df/fd/32db38e3ad0cb331f0691cb4c7a8a6f176f679124dee746b3af6633db4d9/cryptography-47.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:6f29f36582e6151d9686235e586dd35bb67491f024767d10b842e520dc6a07ac", size = 4650964, upload-time = "2026-04-24T19:53:20.062Z" }, { url = "https://files.pythonhosted.org/packages/36/5f/313586c3be5a2fbe87e4c9a254207b860155a8e1f3cca99f9910008e7d08/cryptography-46.0.7-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:8a469028a86f12eb7d2fe97162d0634026d92a21f3ae0ac87ed1c4a447886c83", size = 4279805, upload-time = "2026-04-08T01:56:30.928Z" },
{ url = "https://files.pythonhosted.org/packages/86/53/5395d944dfd48cb1f67917f533c609c34347185ef15eb4308024c876f274/cryptography-47.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:a9b761f012a943b7de0e828843c5688d0de94a0578d44d6c85a1bae32f87791f", size = 5207817, upload-time = "2026-04-24T19:53:22.498Z" }, { url = "https://files.pythonhosted.org/packages/69/33/60dfc4595f334a2082749673386a4d05e4f0cf4df8248e63b2c3437585f2/cryptography-46.0.7-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:9694078c5d44c157ef3162e3bf3946510b857df5a3955458381d1c7cfc143ddb", size = 4892883, upload-time = "2026-04-08T01:56:32.614Z" },
{ url = "https://files.pythonhosted.org/packages/34/4f/e5711b28e1901f7d480a2b1b688b645aa4c77c73f10731ed17e7f7db3f0d/cryptography-47.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:4e1de79e047e25d6e9f8cea71c86b4a53aced64134f0f003bbcbf3655fd172c8", size = 4701544, upload-time = "2026-04-24T19:53:24.356Z" }, { url = "https://files.pythonhosted.org/packages/c7/0b/333ddab4270c4f5b972f980adef4faa66951a4aaf646ca067af597f15563/cryptography-46.0.7-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:42a1e5f98abb6391717978baf9f90dc28a743b7d9be7f0751a6f56a75d14065b", size = 4459756, upload-time = "2026-04-08T01:56:34.306Z" },
{ url = "https://files.pythonhosted.org/packages/22/22/c8ddc25de3010fc8da447648f5a092c40e7a8fadf01dd6d255d9c0b9373d/cryptography-47.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:ef6b3634087f18d2155b1e8ce264e5345a753da2c5fa9815e7d41315c90f8318", size = 4783536, upload-time = "2026-04-24T19:53:26.665Z" }, { url = "https://files.pythonhosted.org/packages/d2/14/633913398b43b75f1234834170947957c6b623d1701ffc7a9600da907e89/cryptography-46.0.7-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:91bbcb08347344f810cbe49065914fe048949648f6bd5c2519f34619142bbe85", size = 4410244, upload-time = "2026-04-08T01:56:35.977Z" },
{ url = "https://files.pythonhosted.org/packages/66/b6/d4a68f4ea999c6d89e8498579cba1c5fcba4276284de7773b17e4fa69293/cryptography-47.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:11dbb9f50a0f1bb9757b3d8c27c1101780efb8f0bdecfb12439c22a74d64c001", size = 4926106, upload-time = "2026-04-24T19:53:28.686Z" }, { url = "https://files.pythonhosted.org/packages/10/f2/19ceb3b3dc14009373432af0c13f46aa08e3ce334ec6eff13492e1812ccd/cryptography-46.0.7-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:5d1c02a14ceb9148cc7816249f64f623fbfee39e8c03b3650d842ad3f34d637e", size = 4674868, upload-time = "2026-04-08T01:56:38.034Z" },
{ url = "https://files.pythonhosted.org/packages/54/ed/5f524db1fade9c013aa618e1c99c6ed05e8ffc9ceee6cda22fed22dda3f4/cryptography-47.0.0-cp311-abi3-win32.whl", hash = "sha256:7fda2f02c9015db3f42bb8a22324a454516ed10a8c29ca6ece6cdbb5efe2a203", size = 3258581, upload-time = "2026-04-24T19:53:31.058Z" }, { url = "https://files.pythonhosted.org/packages/1a/bb/a5c213c19ee94b15dfccc48f363738633a493812687f5567addbcbba9f6f/cryptography-46.0.7-cp311-abi3-win32.whl", hash = "sha256:d23c8ca48e44ee015cd0a54aeccdf9f09004eba9fc96f38c911011d9ff1bd457", size = 3026504, upload-time = "2026-04-08T01:56:39.666Z" },
{ url = "https://files.pythonhosted.org/packages/b2/dc/1b901990b174786569029f67542b3edf72ac068b6c3c8683c17e6a2f5363/cryptography-47.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:f5c3296dab66202f1b18a91fa266be93d6aa0c2806ea3d67762c69f60adc71aa", size = 3775309, upload-time = "2026-04-24T19:53:33.054Z" }, { url = "https://files.pythonhosted.org/packages/2b/02/7788f9fefa1d060ca68717c3901ae7fffa21ee087a90b7f23c7a603c32ae/cryptography-46.0.7-cp311-abi3-win_amd64.whl", hash = "sha256:397655da831414d165029da9bc483bed2fe0e75dde6a1523ec2fe63f3c46046b", size = 3488363, upload-time = "2026-04-08T01:56:41.893Z" },
{ url = "https://files.pythonhosted.org/packages/14/88/7aa18ad9c11bc87689affa5ce4368d884b517502d75739d475fc6f4a03c7/cryptography-47.0.0-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:be12cb6a204f77ed968bcefe68086eb061695b540a3dd05edac507a3111b25f0", size = 7904299, upload-time = "2026-04-24T19:53:35.003Z" }, { url = "https://files.pythonhosted.org/packages/7b/56/15619b210e689c5403bb0540e4cb7dbf11a6bf42e483b7644e471a2812b3/cryptography-46.0.7-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:d151173275e1728cf7839aaa80c34fe550c04ddb27b34f48c232193df8db5842", size = 7119671, upload-time = "2026-04-08T01:56:44Z" },
{ url = "https://files.pythonhosted.org/packages/07/55/c18f75724544872f234678fdedc871391722cb34a2aee19faa9f63100bb2/cryptography-47.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2ebd84adf0728c039a3be2700289378e1c164afc6748df1a5ed456767bef9ba7", size = 4631180, upload-time = "2026-04-24T19:53:37.517Z" }, { url = "https://files.pythonhosted.org/packages/74/66/e3ce040721b0b5599e175ba91ab08884c75928fbeb74597dd10ef13505d2/cryptography-46.0.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:db0f493b9181c7820c8134437eb8b0b4792085d37dbb24da050476ccb664e59c", size = 4268551, upload-time = "2026-04-08T01:56:46.071Z" },
{ url = "https://files.pythonhosted.org/packages/ee/65/31a5cc0eaca99cec5bafffe155d407115d96136bb161e8b49e0ef73f09a7/cryptography-47.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7f68d6fbc7fbbcfb0939fea72c3b96a9f9a6edfc0e1b1d29778a2066030418b1", size = 4653529, upload-time = "2026-04-24T19:53:39.775Z" }, { url = "https://files.pythonhosted.org/packages/03/11/5e395f961d6868269835dee1bafec6a1ac176505a167f68b7d8818431068/cryptography-46.0.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ebd6daf519b9f189f85c479427bbd6e9c9037862cf8fe89ee35503bd209ed902", size = 4408887, upload-time = "2026-04-08T01:56:47.718Z" },
{ url = "https://files.pythonhosted.org/packages/e5/bc/641c0519a495f3bfd0421b48d7cd325c4336578523ccd76ea322b6c29c7a/cryptography-47.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:6651d32eff255423503aa276739da98c30f26c40cbeffcc6048e0d54ef704c0c", size = 4638570, upload-time = "2026-04-24T19:53:42.129Z" }, { url = "https://files.pythonhosted.org/packages/40/53/8ed1cf4c3b9c8e611e7122fb56f1c32d09e1fff0f1d77e78d9ff7c82653e/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:b7b412817be92117ec5ed95f880defe9cf18a832e8cafacf0a22337dc1981b4d", size = 4271354, upload-time = "2026-04-08T01:56:49.312Z" },
{ url = "https://files.pythonhosted.org/packages/2b/f2/300327b0a47f6dc94dd8b71b57052aefe178bb51745073d73d80604f11ab/cryptography-47.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:3fb8fa48075fad7193f2e5496135c6a76ac4b2aa5a38433df0a539296b377829", size = 5238019, upload-time = "2026-04-24T19:53:44.577Z" }, { url = "https://files.pythonhosted.org/packages/50/46/cf71e26025c2e767c5609162c866a78e8a2915bbcfa408b7ca495c6140c4/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:fbfd0e5f273877695cb93baf14b185f4878128b250cc9f8e617ea0c025dfb022", size = 4905845, upload-time = "2026-04-08T01:56:50.916Z" },
{ url = "https://files.pythonhosted.org/packages/e9/5a/5b5cf994391d4bf9d9c7efd4c66aabe4d95227256627f8fea6cff7dfadbd/cryptography-47.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:11438c7518132d95f354fa01a4aa2f806d172a061a7bed18cf18cbdacdb204d7", size = 4686832, upload-time = "2026-04-24T19:53:47.015Z" }, { url = "https://files.pythonhosted.org/packages/c0/ea/01276740375bac6249d0a971ebdf6b4dc9ead0ee0a34ef3b5a88c1a9b0d4/cryptography-46.0.7-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:ffca7aa1d00cf7d6469b988c581598f2259e46215e0140af408966a24cf086ce", size = 4444641, upload-time = "2026-04-08T01:56:52.882Z" },
{ url = "https://files.pythonhosted.org/packages/dc/2c/ae950e28fd6475c852fc21a44db3e6b5bcc1261d1e370f2b6e42fa800fef/cryptography-47.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:8c1a736bbb3288005796c3f7ccb9453360d7fed483b13b9f468aea5171432923", size = 4269301, upload-time = "2026-04-24T19:53:48.97Z" }, { url = "https://files.pythonhosted.org/packages/3d/4c/7d258f169ae71230f25d9f3d06caabcff8c3baf0978e2b7d65e0acac3827/cryptography-46.0.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:60627cf07e0d9274338521205899337c5d18249db56865f943cbe753aa96f40f", size = 3967749, upload-time = "2026-04-08T01:56:54.597Z" },
{ url = "https://files.pythonhosted.org/packages/67/fb/6a39782e150ffe5cc1b0018cb6ddc48bf7ca62b498d7539ffc8a758e977d/cryptography-47.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:f1557695e5c2b86e204f6ce9470497848634100787935ab7adc5397c54abd7ab", size = 4638110, upload-time = "2026-04-24T19:53:51.011Z" }, { url = "https://files.pythonhosted.org/packages/b5/2a/2ea0767cad19e71b3530e4cad9605d0b5e338b6a1e72c37c9c1ceb86c333/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:80406c3065e2c55d7f49a9550fe0c49b3f12e5bfff5dedb727e319e1afb9bf99", size = 4270942, upload-time = "2026-04-08T01:56:56.416Z" },
{ url = "https://files.pythonhosted.org/packages/8e/d7/0b3c71090a76e5c203164a47688b697635ece006dcd2499ab3a4dbd3f0bd/cryptography-47.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:f9a034b642b960767fb343766ae5ba6ad653f2e890ddd82955aef288ffea8736", size = 5194988, upload-time = "2026-04-24T19:53:52.962Z" }, { url = "https://files.pythonhosted.org/packages/41/3d/fe14df95a83319af25717677e956567a105bb6ab25641acaa093db79975d/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:c5b1ccd1239f48b7151a65bc6dd54bcfcc15e028c8ac126d3fada09db0e07ef1", size = 4871079, upload-time = "2026-04-08T01:56:58.31Z" },
{ url = "https://files.pythonhosted.org/packages/63/33/63a961498a9df51721ab578c5a2622661411fc520e00bd83b0cc64eb20c4/cryptography-47.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:b1c76fca783aa7698eb21eb14f9c4aa09452248ee54a627d125025a43f83e7a7", size = 4686563, upload-time = "2026-04-24T19:53:55.274Z" }, { url = "https://files.pythonhosted.org/packages/9c/59/4a479e0f36f8f378d397f4eab4c850b4ffb79a2f0d58704b8fa0703ddc11/cryptography-46.0.7-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:d5f7520159cd9c2154eb61eb67548ca05c5774d39e9c2c4339fd793fe7d097b2", size = 4443999, upload-time = "2026-04-08T01:57:00.508Z" },
{ url = "https://files.pythonhosted.org/packages/b7/bf/5ee5b145248f92250de86145d1c1d6edebbd57a7fe7caa4dedb5d4cf06a1/cryptography-47.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4f7722c97826770bab8ae92959a2e7b20a5e9e9bf4deae68fd86c3ca457bab52", size = 4770094, upload-time = "2026-04-24T19:53:57.753Z" }, { url = "https://files.pythonhosted.org/packages/28/17/b59a741645822ec6d04732b43c5d35e4ef58be7bfa84a81e5ae6f05a1d33/cryptography-46.0.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fcd8eac50d9138c1d7fc53a653ba60a2bee81a505f9f8850b6b2888555a45d0e", size = 4399191, upload-time = "2026-04-08T01:57:02.654Z" },
{ url = "https://files.pythonhosted.org/packages/92/43/21d220b2da5d517773894dacdcdb5c682c28d3fffce65548cb06e87d5501/cryptography-47.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:09f6d7bf6724f8db8b32f11eccf23efc8e759924bc5603800335cf8859a3ddbd", size = 4913811, upload-time = "2026-04-24T19:54:00.236Z" }, { url = "https://files.pythonhosted.org/packages/59/6a/bb2e166d6d0e0955f1e9ff70f10ec4b2824c9cfcdb4da772c7dd69cc7d80/cryptography-46.0.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:65814c60f8cc400c63131584e3e1fad01235edba2614b61fbfbfa954082db0ee", size = 4655782, upload-time = "2026-04-08T01:57:04.592Z" },
{ url = "https://files.pythonhosted.org/packages/31/98/dc4ad376ac5f1a1a7d4a83f7b0c6f2bcad36b5d2d8f30aeb482d3a7d9582/cryptography-47.0.0-cp314-cp314t-win32.whl", hash = "sha256:6eebcaf0df1d21ce1f90605c9b432dd2c4f4ab665ac29a40d5e3fc68f51b5e63", size = 3237158, upload-time = "2026-04-24T19:54:02.606Z" }, { url = "https://files.pythonhosted.org/packages/95/b6/3da51d48415bcb63b00dc17c2eff3a651b7c4fed484308d0f19b30e8cb2c/cryptography-46.0.7-cp314-cp314t-win32.whl", hash = "sha256:fdd1736fed309b4300346f88f74cd120c27c56852c3838cab416e7a166f67298", size = 3002227, upload-time = "2026-04-08T01:57:06.91Z" },
{ url = "https://files.pythonhosted.org/packages/bc/da/97f62d18306b5133468bc3f8cc73a3111e8cdc8cf8d3e69474d6e5fd2d1b/cryptography-47.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:51c9313e90bd1690ec5a75ed047c27c0b8e6c570029712943d6116ef9a90620b", size = 3758706, upload-time = "2026-04-24T19:54:04.433Z" }, { url = "https://files.pythonhosted.org/packages/32/a8/9f0e4ed57ec9cebe506e58db11ae472972ecb0c659e4d52bbaee80ca340a/cryptography-46.0.7-cp314-cp314t-win_amd64.whl", hash = "sha256:e06acf3c99be55aa3b516397fe42f5855597f430add9c17fa46bf2e0fb34c9bb", size = 3475332, upload-time = "2026-04-08T01:57:08.807Z" },
{ url = "https://files.pythonhosted.org/packages/e0/34/a4fae8ae7c3bc227460c9ae43f56abf1b911da0ec29e0ebac53bb0a4b6b7/cryptography-47.0.0-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:14432c8a9bcb37009784f9594a62fae211a2ae9543e96c92b2a8e4c3cd5cd0c4", size = 7904072, upload-time = "2026-04-24T19:54:06.411Z" }, { url = "https://files.pythonhosted.org/packages/a7/7f/cd42fc3614386bc0c12f0cb3c4ae1fc2bbca5c9662dfed031514911d513d/cryptography-46.0.7-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:462ad5cb1c148a22b2e3bcc5ad52504dff325d17daf5df8d88c17dda1f75f2a4", size = 7165618, upload-time = "2026-04-08T01:57:10.645Z" },
{ url = "https://files.pythonhosted.org/packages/01/64/d7b1e54fdb69f22d24a64bb3e88dc718b31c7fb10ef0b9691a3cf7eeea6e/cryptography-47.0.0-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:07efe86201817e7d3c18781ca9770bc0db04e1e48c994be384e4602bc38f8f27", size = 4635767, upload-time = "2026-04-24T19:54:08.519Z" }, { url = "https://files.pythonhosted.org/packages/a5/d0/36a49f0262d2319139d2829f773f1b97ef8aef7f97e6e5bd21455e5a8fb5/cryptography-46.0.7-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:84d4cced91f0f159a7ddacad249cc077e63195c36aac40b4150e7a57e84fffe7", size = 4270628, upload-time = "2026-04-08T01:57:12.885Z" },
{ url = "https://files.pythonhosted.org/packages/8b/7b/cca826391fb2a94efdcdfe4631eb69306ee1cff0b22f664a412c90713877/cryptography-47.0.0-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:2b45761c6ec22b7c726d6a829558777e32d0f1c8be7c3f3480f9c912d5ee8a10", size = 4654350, upload-time = "2026-04-24T19:54:10.795Z" }, { url = "https://files.pythonhosted.org/packages/8a/6c/1a42450f464dda6ffbe578a911f773e54dd48c10f9895a23a7e88b3e7db5/cryptography-46.0.7-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:128c5edfe5e5938b86b03941e94fac9ee793a94452ad1365c9fc3f4f62216832", size = 4415405, upload-time = "2026-04-08T01:57:14.923Z" },
{ url = "https://files.pythonhosted.org/packages/4c/65/4b57bcc823f42a991627c51c2f68c9fd6eb1393c1756aac876cba2accae2/cryptography-47.0.0-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:edd4da498015da5b9f26d38d3bfc2e90257bfa9cbed1f6767c282a0025ae649b", size = 4643394, upload-time = "2026-04-24T19:54:13.275Z" }, { url = "https://files.pythonhosted.org/packages/9a/92/4ed714dbe93a066dc1f4b4581a464d2d7dbec9046f7c8b7016f5286329e2/cryptography-46.0.7-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:5e51be372b26ef4ba3de3c167cd3d1022934bc838ae9eaad7e644986d2a3d163", size = 4272715, upload-time = "2026-04-08T01:57:16.638Z" },
{ url = "https://files.pythonhosted.org/packages/f4/c4/2c5fbeea70adbbca2bbae865e1d605d6a4a7f8dbd9d33eaf69645087f06c/cryptography-47.0.0-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:9af828c0d5a65c70ec729cd7495a4bf1a67ecb66417b8f02ff125ab8a6326a74", size = 5225777, upload-time = "2026-04-24T19:54:15.18Z" }, { url = "https://files.pythonhosted.org/packages/b7/e6/a26b84096eddd51494bba19111f8fffe976f6a09f132706f8f1bf03f51f7/cryptography-46.0.7-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:cdf1a610ef82abb396451862739e3fc93b071c844399e15b90726ef7470eeaf2", size = 4918400, upload-time = "2026-04-08T01:57:19.021Z" },
{ url = "https://files.pythonhosted.org/packages/7e/b8/ac57107ef32749d2b244e36069bb688792a363aaaa3acc9e3cf84c130315/cryptography-47.0.0-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:256d07c78a04d6b276f5df935a9923275f53bd1522f214447fdf365494e2d515", size = 4688771, upload-time = "2026-04-24T19:54:17.835Z" }, { url = "https://files.pythonhosted.org/packages/c7/08/ffd537b605568a148543ac3c2b239708ae0bd635064bab41359252ef88ed/cryptography-46.0.7-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:1d25aee46d0c6f1a501adcddb2d2fee4b979381346a78558ed13e50aa8a59067", size = 4450634, upload-time = "2026-04-08T01:57:21.185Z" },
{ url = "https://files.pythonhosted.org/packages/56/fc/9f1de22ff8be99d991f240a46863c52d475404c408886c5a38d2b5c3bb26/cryptography-47.0.0-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:5d0e362ff51041b0c0d219cc7d6924d7b8996f57ce5712bdcef71eb3c65a59cc", size = 4270753, upload-time = "2026-04-24T19:54:19.963Z" }, { url = "https://files.pythonhosted.org/packages/16/01/0cd51dd86ab5b9befe0d031e276510491976c3a80e9f6e31810cce46c4ad/cryptography-46.0.7-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:cdfbe22376065ffcf8be74dc9a909f032df19bc58a699456a21712d6e5eabfd0", size = 3985233, upload-time = "2026-04-08T01:57:22.862Z" },
{ url = "https://files.pythonhosted.org/packages/00/68/d70c852797aa68e8e48d12e5a87170c43f67bb4a59403627259dd57d15de/cryptography-47.0.0-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:1581aef4219f7ca2849d0250edaa3866212fb74bf5667284f46aa92f9e65c1ca", size = 4642911, upload-time = "2026-04-24T19:54:21.818Z" }, { url = "https://files.pythonhosted.org/packages/92/49/819d6ed3a7d9349c2939f81b500a738cb733ab62fbecdbc1e38e83d45e12/cryptography-46.0.7-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:abad9dac36cbf55de6eb49badd4016806b3165d396f64925bf2999bcb67837ba", size = 4271955, upload-time = "2026-04-08T01:57:24.814Z" },
{ url = "https://files.pythonhosted.org/packages/a5/51/661cbee74f594c5d97ff82d34f10d5551c085ca4668645f4606ebd22bd5d/cryptography-47.0.0-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:a49a3eb5341b9503fa3000a9a0db033161db90d47285291f53c2a9d2cd1b7f76", size = 5181411, upload-time = "2026-04-24T19:54:24.376Z" }, { url = "https://files.pythonhosted.org/packages/80/07/ad9b3c56ebb95ed2473d46df0847357e01583f4c52a85754d1a55e29e4d0/cryptography-46.0.7-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:935ce7e3cfdb53e3536119a542b839bb94ec1ad081013e9ab9b7cfd478b05006", size = 4879888, upload-time = "2026-04-08T01:57:26.88Z" },
{ url = "https://files.pythonhosted.org/packages/94/87/f2b6c374a82cf076cfa1416992ac8e8ec94d79facc37aec87c1a5cb72352/cryptography-47.0.0-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:2207a498b03275d0051589e326b79d4cf59985c99031b05bb292ac52631c37fe", size = 4688262, upload-time = "2026-04-24T19:54:26.946Z" }, { url = "https://files.pythonhosted.org/packages/b8/c7/201d3d58f30c4c2bdbe9b03844c291feb77c20511cc3586daf7edc12a47b/cryptography-46.0.7-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:35719dc79d4730d30f1c2b6474bd6acda36ae2dfae1e3c16f2051f215df33ce0", size = 4449961, upload-time = "2026-04-08T01:57:29.068Z" },
{ url = "https://files.pythonhosted.org/packages/14/e2/8b7462f4acf21ec509616f0245018bb197194ab0b65c2ea21a0bdd53c0eb/cryptography-47.0.0-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7a02675e2fabd0c0fc04c868b8781863cbf1967691543c22f5470500ff840b31", size = 4775506, upload-time = "2026-04-24T19:54:28.926Z" }, { url = "https://files.pythonhosted.org/packages/a5/ef/649750cbf96f3033c3c976e112265c33906f8e462291a33d77f90356548c/cryptography-46.0.7-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:7bbc6ccf49d05ac8f7d7b5e2e2c33830d4fe2061def88210a126d130d7f71a85", size = 4401696, upload-time = "2026-04-08T01:57:31.029Z" },
{ url = "https://files.pythonhosted.org/packages/70/75/158e494e4c08dc05e039da5bb48553826bd26c23930cf8d3cd5f21fa8921/cryptography-47.0.0-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:80887c5cbd1774683cb126f0ab4184567f080071d5acf62205acb354b4b753b7", size = 4912060, upload-time = "2026-04-24T19:54:30.869Z" }, { url = "https://files.pythonhosted.org/packages/41/52/a8908dcb1a389a459a29008c29966c1d552588d4ae6d43f3a1a4512e0ebe/cryptography-46.0.7-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:a1529d614f44b863a7b480c6d000fe93b59acee9c82ffa027cfadc77521a9f5e", size = 4664256, upload-time = "2026-04-08T01:57:33.144Z" },
{ url = "https://files.pythonhosted.org/packages/06/bd/0a9d3edbf5eadbac926d7b9b3cd0c4be584eeeae4a003d24d9eda4affbbd/cryptography-47.0.0-cp38-abi3-win32.whl", hash = "sha256:ed67ea4e0cfb5faa5bc7ecb6e2b8838f3807a03758eec239d6c21c8769355310", size = 3248487, upload-time = "2026-04-24T19:54:33.494Z" }, { url = "https://files.pythonhosted.org/packages/4b/fa/f0ab06238e899cc3fb332623f337a7364f36f4bb3f2534c2bb95a35b132c/cryptography-46.0.7-cp38-abi3-win32.whl", hash = "sha256:f247c8c1a1fb45e12586afbb436ef21ff1e80670b2861a90353d9b025583d246", size = 3013001, upload-time = "2026-04-08T01:57:34.933Z" },
{ url = "https://files.pythonhosted.org/packages/60/80/5681af756d0da3a599b7bdb586fac5a1540f1bcefd2717a20e611ddade45/cryptography-47.0.0-cp38-abi3-win_amd64.whl", hash = "sha256:835d2d7f47cdc53b3224e90810fb1d36ca94ea29cc1801fb4c1bc43876735769", size = 3755737, upload-time = "2026-04-24T19:54:35.408Z" }, { url = "https://files.pythonhosted.org/packages/d2/f1/00ce3bde3ca542d1acd8f8cfa38e446840945aa6363f9b74746394b14127/cryptography-46.0.7-cp38-abi3-win_amd64.whl", hash = "sha256:506c4ff91eff4f82bdac7633318a526b1d1309fc07ca76a3ad182cb5b686d6d3", size = 3472985, upload-time = "2026-04-08T01:57:36.714Z" },
{ url = "https://files.pythonhosted.org/packages/1b/a0/928c9ce0d120a40a81aa99e3ba383e87337b9ac9ef9f6db02e4d7822424d/cryptography-47.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:7f1207974a904e005f762869996cf620e9bf79ecb4622f148550bb48e0eb35a7", size = 3909893, upload-time = "2026-04-24T19:54:38.334Z" }, { url = "https://files.pythonhosted.org/packages/63/0c/dca8abb64e7ca4f6b2978769f6fea5ad06686a190cec381f0a796fdcaaba/cryptography-46.0.7-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:fc9ab8856ae6cf7c9358430e49b368f3108f050031442eaeb6b9d87e4dcf4e4f", size = 3476879, upload-time = "2026-04-08T01:57:38.664Z" },
{ url = "https://files.pythonhosted.org/packages/81/75/d691e284750df5d9569f2b1ce4a00a71e1d79566da83b2b3e5549c84917f/cryptography-47.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:1a405c08857258c11016777e11c02bacbe7ef596faf259305d282272a3a05cbe", size = 4587867, upload-time = "2026-04-24T19:54:40.619Z" }, { url = "https://files.pythonhosted.org/packages/3a/ea/075aac6a84b7c271578d81a2f9968acb6e273002408729f2ddff517fed4a/cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:d3b99c535a9de0adced13d159c5a9cf65c325601aa30f4be08afd680643e9c15", size = 4219700, upload-time = "2026-04-08T01:57:40.625Z" },
{ url = "https://files.pythonhosted.org/packages/07/d6/1b90f1a4e453009730b4545286f0b39bb348d805c11181fc31544e4f9a65/cryptography-47.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:20fdbe3e38fb67c385d233c89371fa27f9909f6ebca1cecc20c13518dae65475", size = 4627192, upload-time = "2026-04-24T19:54:42.849Z" }, { url = "https://files.pythonhosted.org/packages/6c/7b/1c55db7242b5e5612b29fc7a630e91ee7a6e3c8e7bf5406d22e206875fbd/cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:d02c738dacda7dc2a74d1b2b3177042009d5cab7c7079db74afc19e56ca1b455", size = 4385982, upload-time = "2026-04-08T01:57:42.725Z" },
{ url = "https://files.pythonhosted.org/packages/dc/53/cb358a80e9e359529f496870dd08c102aa8a4b5b9f9064f00f0d6ed5b527/cryptography-47.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:f7db373287273d8af1414cf95dc4118b13ffdc62be521997b0f2b270771fef50", size = 4587486, upload-time = "2026-04-24T19:54:44.908Z" }, { url = "https://files.pythonhosted.org/packages/cb/da/9870eec4b69c63ef5925bf7d8342b7e13bc2ee3d47791461c4e49ca212f4/cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:04959522f938493042d595a736e7dbdff6eb6cc2339c11465b3ff89343b65f65", size = 4219115, upload-time = "2026-04-08T01:57:44.939Z" },
{ url = "https://files.pythonhosted.org/packages/8b/57/aaa3d53876467a226f9a7a82fd14dd48058ad2de1948493442dfa16e2ffd/cryptography-47.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:9fe6b7c64926c765f9dff301f9c1b867febcda5768868ca084e18589113732ab", size = 4626327, upload-time = "2026-04-24T19:54:47.813Z" }, { url = "https://files.pythonhosted.org/packages/f4/72/05aa5832b82dd341969e9a734d1812a6aadb088d9eb6f0430fc337cc5a8f/cryptography-46.0.7-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:3986ac1dee6def53797289999eabe84798ad7817f3e97779b5061a95b0ee4968", size = 4385479, upload-time = "2026-04-08T01:57:46.86Z" },
{ url = "https://files.pythonhosted.org/packages/ab/9c/51f28c3550276bcf35660703ba0ab829a90b88be8cd98a71ef23c2413913/cryptography-47.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:cffbba3392df0fa8629bb7f43454ee2925059ee158e23c54620b9063912b86c8", size = 3698916, upload-time = "2026-04-24T19:54:49.782Z" }, { url = "https://files.pythonhosted.org/packages/20/2a/1b016902351a523aa2bd446b50a5bc1175d7a7d1cf90fe2ef904f9b84ebc/cryptography-46.0.7-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:258514877e15963bd43b558917bc9f54cf7cf866c38aa576ebf47a77ddbc43a4", size = 3412829, upload-time = "2026-04-08T01:57:48.874Z" },
] ]
[[package]] [[package]]
@@ -1859,7 +1846,7 @@ wheels = [
[[package]] [[package]]
name = "langbot" name = "langbot"
version = "4.9.7" version = "4.9.6"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "aiocqhttp" }, { name = "aiocqhttp" },
@@ -1895,7 +1882,6 @@ dependencies = [
{ name = "line-bot-sdk" }, { name = "line-bot-sdk" },
{ name = "mako" }, { name = "mako" },
{ name = "markdown" }, { name = "markdown" },
{ name = "matrix-nio" },
{ name = "mcp" }, { name = "mcp" },
{ name = "mypy" }, { name = "mypy" },
{ name = "nakuru-project-idk" }, { name = "nakuru-project-idk" },
@@ -1921,7 +1907,6 @@ dependencies = [
{ name = "pyyaml" }, { name = "pyyaml" },
{ name = "qdrant-client" }, { name = "qdrant-client" },
{ name = "qq-botpy-rc" }, { name = "qq-botpy-rc" },
{ name = "qrcode" },
{ name = "quart" }, { name = "quart" },
{ name = "quart-cors" }, { name = "quart-cors" },
{ name = "requests" }, { name = "requests" },
@@ -1972,16 +1957,15 @@ requires-dist = [
{ name = "ebooklib", specifier = ">=0.18" }, { name = "ebooklib", specifier = ">=0.18" },
{ name = "gewechat-client", specifier = ">=0.1.5" }, { name = "gewechat-client", specifier = ">=0.1.5" },
{ name = "html2text", specifier = ">=2024.2.26" }, { name = "html2text", specifier = ">=2024.2.26" },
{ name = "langbot-plugin", specifier = "==0.3.11" }, { name = "langbot-plugin", specifier = "==0.3.8" },
{ name = "langchain", specifier = ">=0.2.0" }, { name = "langchain", specifier = ">=0.2.0" },
{ name = "langchain-core", specifier = ">=1.2.28" }, { name = "langchain-core", specifier = ">=1.2.28" },
{ name = "langchain-text-splitters", specifier = ">=1.1.2" }, { name = "langchain-text-splitters", specifier = ">=1.1.2" },
{ name = "langsmith", specifier = ">=0.7.31" }, { name = "langsmith", specifier = ">=0.7.31" },
{ name = "lark-oapi", specifier = ">=1.5.5" }, { name = "lark-oapi", specifier = ">=1.4.15" },
{ name = "line-bot-sdk", specifier = ">=3.19.0" }, { name = "line-bot-sdk", specifier = ">=3.19.0" },
{ name = "mako", specifier = ">=1.3.11" }, { name = "mako", specifier = ">=1.3.11" },
{ name = "markdown", specifier = ">=3.6" }, { name = "markdown", specifier = ">=3.6" },
{ name = "matrix-nio", specifier = ">=0.25.2" },
{ name = "mcp", specifier = ">=1.25.0" }, { name = "mcp", specifier = ">=1.25.0" },
{ name = "mypy", specifier = ">=1.16.0" }, { name = "mypy", specifier = ">=1.16.0" },
{ name = "nakuru-project-idk", specifier = ">=0.0.2.1" }, { name = "nakuru-project-idk", specifier = ">=0.0.2.1" },
@@ -2007,7 +1991,6 @@ requires-dist = [
{ name = "pyyaml", specifier = ">=6.0.2" }, { name = "pyyaml", specifier = ">=6.0.2" },
{ name = "qdrant-client", specifier = ">=1.15.1,<2.0.0" }, { name = "qdrant-client", specifier = ">=1.15.1,<2.0.0" },
{ name = "qq-botpy-rc", specifier = ">=1.2.1.6" }, { name = "qq-botpy-rc", specifier = ">=1.2.1.6" },
{ name = "qrcode", specifier = ">=7.4" },
{ name = "quart", specifier = ">=0.20.0" }, { name = "quart", specifier = ">=0.20.0" },
{ name = "quart-cors", specifier = ">=0.8.0" }, { name = "quart-cors", specifier = ">=0.8.0" },
{ name = "requests", specifier = ">=2.32.3" }, { name = "requests", specifier = ">=2.32.3" },
@@ -2034,7 +2017,7 @@ dev = [
[[package]] [[package]]
name = "langbot-plugin" name = "langbot-plugin"
version = "0.3.11" version = "0.3.8"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "aiofiles" }, { name = "aiofiles" },
@@ -2052,9 +2035,9 @@ dependencies = [
{ name = "watchdog" }, { name = "watchdog" },
{ name = "websockets" }, { name = "websockets" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/91/83/93b86bcdbfe51d820fa59232aaa73cc802d6ce614f67d8f8b33957419538/langbot_plugin-0.3.11.tar.gz", hash = "sha256:8d10c98c771b468b2d35cc007778439c39922a88265fcc16a5881234bc7c1b19", size = 190315, upload-time = "2026-05-12T15:45:24.262Z" } sdist = { url = "https://files.pythonhosted.org/packages/b8/d8/7c8ac9516e35d69ead3e934b408e48541f5772eb88fbed19cd216af4b6c2/langbot_plugin-0.3.8.tar.gz", hash = "sha256:e8e420c3b2f167c9635e3e0af46fb452895be9d68ec05bf112ac5f221c3316f3", size = 179803, upload-time = "2026-04-10T11:05:42.791Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/8f/22/de7977a6a5cbf557b80043eb3ed39e5feff24033a5d6db4ab88d48ccb6ea/langbot_plugin-0.3.11-py3-none-any.whl", hash = "sha256:c1d2e84eda1584902d99efa316b850c08c1c04fcc199306ff4af1dca1431304a", size = 165574, upload-time = "2026-05-12T15:45:22.908Z" }, { url = "https://files.pythonhosted.org/packages/81/63/4a61b67d4886522647e0b60063da155279b943a6b2e6cd004e29aedf67d1/langbot_plugin-0.3.8-py3-none-any.whl", hash = "sha256:2246f343b4735cb4004cf44462ffb47531222c21efeef163a4acd758ebbec2cd", size = 157354, upload-time = "2026-04-10T11:05:41.525Z" },
] ]
[[package]] [[package]]
@@ -2073,11 +2056,10 @@ wheels = [
[[package]] [[package]]
name = "langchain-core" name = "langchain-core"
version = "1.3.2" version = "1.3.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "jsonpatch" }, { name = "jsonpatch" },
{ name = "langchain-protocol" },
{ name = "langsmith" }, { name = "langsmith" },
{ name = "packaging" }, { name = "packaging" },
{ name = "pydantic" }, { name = "pydantic" },
@@ -2086,21 +2068,9 @@ dependencies = [
{ name = "typing-extensions" }, { name = "typing-extensions" },
{ name = "uuid-utils" }, { name = "uuid-utils" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/a8/03/7219502e8ca728d65eb44d7a3eb60239230742a70dbfc9241b9bfd61c4ab/langchain_core-1.3.2.tar.gz", hash = "sha256:fd7a50b2f28ba561fd9d7f5d2760bc9e06cf00cdf820a3ccafe88a94ffa8d5b7", size = 911813, upload-time = "2026-04-24T15:49:23.699Z" } sdist = { url = "https://files.pythonhosted.org/packages/92/fe/20190232d9b513242899dbb0c2bb77e31b4d61e343743adbe90ebc2603d2/langchain_core-1.3.0.tar.gz", hash = "sha256:14a39f528bf459aa3aa40d0a7f7f1bae7520d435ef991ae14a4ceb74d8c49046", size = 860755, upload-time = "2026-04-17T14:51:38.298Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/7d/d5/8fa4431007cbb7cfed7590f4d6a5dea3ad724f4174d248f6642ef5ce7d05/langchain_core-1.3.2-py3-none-any.whl", hash = "sha256:d44a66127f9f8db735bdfd0ab9661bccb47a97113cfd3f2d89c74864422b7274", size = 542390, upload-time = "2026-04-24T15:49:21.991Z" }, { url = "https://files.pythonhosted.org/packages/f8/e2/dbfa347aa072a6dc4cd38d6f9ebfc730b4c14c258c47f480f4c5c546f177/langchain_core-1.3.0-py3-none-any.whl", hash = "sha256:baf16ee028475df177b9ab8869a751c79406d64a6f12125b93802991b566cced", size = 515140, upload-time = "2026-04-17T14:51:36.274Z" },
]
[[package]]
name = "langchain-protocol"
version = "0.0.12"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/5c/51/1157009b6f94e6e58be58fa8b620187d657909a8b36a6bf5b0c52a2711f6/langchain_protocol-0.0.12.tar.gz", hash = "sha256:5e14c434290a705c9510fdb1a83ecf7561a5e6e0dfd053930ade80dba069269f", size = 6408, upload-time = "2026-04-25T01:05:01.489Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/95/82/3431e3061c917439589fa88a6b23c9bc0e154cba0f05d2e895a68c76ff74/langchain_protocol-0.0.12-py3-none-any.whl", hash = "sha256:402b61f42d4139692528cf37226c367bb6efc8ff8165b29380accb0abfece7b2", size = 6639, upload-time = "2026-04-25T01:05:00.487Z" },
] ]
[[package]] [[package]]
@@ -2173,7 +2143,7 @@ wheels = [
[[package]] [[package]]
name = "langsmith" name = "langsmith"
version = "0.7.36" version = "0.7.32"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "httpx" }, { name = "httpx" },
@@ -2186,14 +2156,14 @@ dependencies = [
{ name = "xxhash" }, { name = "xxhash" },
{ name = "zstandard" }, { name = "zstandard" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/8d/4c/5f20508000ee0559bfa713b85c431b1cdc95d2913247ff9eb318e7fdff7b/langsmith-0.7.36.tar.gz", hash = "sha256:d18ef34819e0a252cf52c74ce6e9bd5de6deea4f85a3aef50abc9f48d8c5f8b8", size = 4402322, upload-time = "2026-04-24T16:58:06.681Z" } sdist = { url = "https://files.pythonhosted.org/packages/2f/b4/a0b4a501bee6b8a741ce29f8c48155b132118483cddc6f9247735ddb38fa/langsmith-0.7.32.tar.gz", hash = "sha256:b59b8e106d0e4c4842e158229296086e2aa7c561e3f602acda73d3ad0062e915", size = 1184518, upload-time = "2026-04-15T23:42:41.885Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/f3/8d/3ca31ae3a4a437191243ad6d9061ede9367440bb7dc9a0da1ecc2c2a4865/langsmith-0.7.36-py3-none-any.whl", hash = "sha256:e1657a795f3f1982bb8d34c98b143b630ca3eee9de2c10e670c9105233b54654", size = 381808, upload-time = "2026-04-24T16:58:04.572Z" }, { url = "https://files.pythonhosted.org/packages/62/bc/148f98ac7dad73ac5e1b1c985290079cfeeb9ba13d760a24f25002beb2c9/langsmith-0.7.32-py3-none-any.whl", hash = "sha256:e1fde928990c4c52f47dc5132708cec674355d9101723d564183e965f383bf5f", size = 378272, upload-time = "2026-04-15T23:42:39.905Z" },
] ]
[[package]] [[package]]
name = "lark-oapi" name = "lark-oapi"
version = "1.6.4" version = "1.5.3"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "httpx" }, { name = "httpx" },
@@ -2202,9 +2172,8 @@ dependencies = [
{ name = "requests-toolbelt" }, { name = "requests-toolbelt" },
{ name = "websockets" }, { name = "websockets" },
] ]
sdist = { url = "https://files.pythonhosted.org/packages/12/aa/db027c41fdfb4f42471634cfc2a6f69d64d68f58ee555914293d60dbaceb/lark_oapi-1.6.4.tar.gz", hash = "sha256:b2aceccd1a01e55a82927ba1ee187e2eae5392cc97bc00ce0b3f08da3fb9a4ce", size = 2078060, upload-time = "2026-05-12T11:03:07.041Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/89/9f/47ec3a6628acdd74229a91abf67a3b574002fe6e587be4265c3bd928bddf/lark_oapi-1.6.4-py3-none-any.whl", hash = "sha256:9013b2793f627612906090c5d960ca7bd1cf8896a66875528221c769e0ceecc0", size = 7142621, upload-time = "2026-05-12T11:03:03.882Z" }, { url = "https://files.pythonhosted.org/packages/bf/ff/2ece5d735ebfa2af600a53176f2636ae47af2bf934e08effab64f0d1e047/lark_oapi-1.5.3-py3-none-any.whl", hash = "sha256:fda6b32bb38d21b6bdaae94979c600b94c7c521e985adade63a54e4b3e20cc36", size = 6993016, upload-time = "2026-01-27T08:21:49.307Z" },
] ]
[[package]] [[package]]
@@ -2576,25 +2545,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" },
] ]
[[package]]
name = "matrix-nio"
version = "0.25.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "aiofiles" },
{ name = "aiohttp" },
{ name = "aiohttp-socks" },
{ name = "h11" },
{ name = "h2" },
{ name = "jsonschema" },
{ name = "pycryptodome" },
{ name = "unpaddedbase64" },
]
sdist = { url = "https://files.pythonhosted.org/packages/33/50/c20129fd6f0e1aad3510feefd3229427fc8163a111f3911ed834e414116b/matrix_nio-0.25.2.tar.gz", hash = "sha256:8ef8180c374e12368e5c83a692abfb3bab8d71efcd17c5560b5c40c9b6f2f600", size = 155480, upload-time = "2024-10-04T07:51:41.62Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7b/0f/8b958d46e23ed4f69d2cffd63b46bb097a1155524e2e7f5c4279c8691c4a/matrix_nio-0.25.2-py3-none-any.whl", hash = "sha256:9c2880004b0e475db874456c0f79b7dd2b6285073a7663bcaca29e0754a67495", size = 181982, upload-time = "2024-10-04T07:51:39.451Z" },
]
[[package]] [[package]]
name = "mcp" name = "mcp"
version = "1.26.0" version = "1.26.0"
@@ -5634,15 +5584,6 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/37/87/1f677586e8ac487e29672e4b17455758fce261de06a0d086167bb760361a/uc_micro_py-1.0.3-py3-none-any.whl", hash = "sha256:db1dffff340817673d7b466ec86114a9dc0e9d4d9b5ba229d9d60e5c12600cd5", size = 6229, upload-time = "2024-02-09T16:52:00.371Z" }, { url = "https://files.pythonhosted.org/packages/37/87/1f677586e8ac487e29672e4b17455758fce261de06a0d086167bb760361a/uc_micro_py-1.0.3-py3-none-any.whl", hash = "sha256:db1dffff340817673d7b466ec86114a9dc0e9d4d9b5ba229d9d60e5c12600cd5", size = 6229, upload-time = "2024-02-09T16:52:00.371Z" },
] ]
[[package]]
name = "unpaddedbase64"
version = "2.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/4d/f8/114266b21a7a9e3d09b352bb63c9d61d918bb7aa35d08c722793bfbfd28f/unpaddedbase64-2.1.0.tar.gz", hash = "sha256:7273c60c089de39d90f5d6d4a7883a79e319dc9d9b1c8924a7fab96178a5f005", size = 5621, upload-time = "2021-03-09T11:35:47.729Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/4c/a7/563b2d8fb7edc07320bf69ac6a7eedcd7a1a9d663a6bb90a4d9bd2eda5f7/unpaddedbase64-2.1.0-py3-none-any.whl", hash = "sha256:485eff129c30175d2cd6f0cd8d2310dff51e666f7f36175f738d75dfdbd0b1c6", size = 6083, upload-time = "2021-03-09T11:35:46.7Z" },
]
[[package]] [[package]]
name = "urllib3" name = "urllib3"
version = "2.6.3" version = "2.6.3"
@@ -5908,44 +5849,61 @@ wheels = [
[[package]] [[package]]
name = "websockets" name = "websockets"
version = "15.0.1" version = "16.0"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/21/e6/26d09fab466b7ca9c7737474c52be4f76a40301b08362eb2dbc19dcc16c1/websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee", size = 177016, upload-time = "2025-03-05T20:03:41.606Z" } sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/9f/32/18fcd5919c293a398db67443acd33fde142f283853076049824fc58e6f75/websockets-15.0.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:823c248b690b2fd9303ba00c4f66cd5e2d8c3ba4aa968b2779be9532a4dad431", size = 175423, upload-time = "2025-03-05T20:01:56.276Z" }, { url = "https://files.pythonhosted.org/packages/f2/db/de907251b4ff46ae804ad0409809504153b3f30984daf82a1d84a9875830/websockets-16.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:31a52addea25187bde0797a97d6fc3d2f92b6f72a9370792d65a6e84615ac8a8", size = 177340, upload-time = "2026-01-10T09:22:34.539Z" },
{ url = "https://files.pythonhosted.org/packages/76/70/ba1ad96b07869275ef42e2ce21f07a5b0148936688c2baf7e4a1f60d5058/websockets-15.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678999709e68425ae2593acf2e3ebcbcf2e69885a5ee78f9eb80e6e371f1bf57", size = 173082, upload-time = "2025-03-05T20:01:57.563Z" }, { url = "https://files.pythonhosted.org/packages/f3/fa/abe89019d8d8815c8781e90d697dec52523fb8ebe308bf11664e8de1877e/websockets-16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:417b28978cdccab24f46400586d128366313e8a96312e4b9362a4af504f3bbad", size = 175022, upload-time = "2026-01-10T09:22:36.332Z" },
{ url = "https://files.pythonhosted.org/packages/86/f2/10b55821dd40eb696ce4704a87d57774696f9451108cff0d2824c97e0f97/websockets-15.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d50fd1ee42388dcfb2b3676132c78116490976f1300da28eb629272d5d93e905", size = 173330, upload-time = "2025-03-05T20:01:59.063Z" }, { url = "https://files.pythonhosted.org/packages/58/5d/88ea17ed1ded2079358b40d31d48abe90a73c9e5819dbcde1606e991e2ad/websockets-16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:af80d74d4edfa3cb9ed973a0a5ba2b2a549371f8a741e0800cb07becdd20f23d", size = 175319, upload-time = "2026-01-10T09:22:37.602Z" },
{ url = "https://files.pythonhosted.org/packages/a5/90/1c37ae8b8a113d3daf1065222b6af61cc44102da95388ac0018fcb7d93d9/websockets-15.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d99e5546bf73dbad5bf3547174cd6cb8ba7273062a23808ffea025ecb1cf8562", size = 182878, upload-time = "2025-03-05T20:02:00.305Z" }, { url = "https://files.pythonhosted.org/packages/d2/ae/0ee92b33087a33632f37a635e11e1d99d429d3d323329675a6022312aac2/websockets-16.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:08d7af67b64d29823fed316505a89b86705f2b7981c07848fb5e3ea3020c1abe", size = 184631, upload-time = "2026-01-10T09:22:38.789Z" },
{ url = "https://files.pythonhosted.org/packages/8e/8d/96e8e288b2a41dffafb78e8904ea7367ee4f891dafc2ab8d87e2124cb3d3/websockets-15.0.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:66dd88c918e3287efc22409d426c8f729688d89a0c587c88971a0faa2c2f3792", size = 181883, upload-time = "2025-03-05T20:02:03.148Z" }, { url = "https://files.pythonhosted.org/packages/c8/c5/27178df583b6c5b31b29f526ba2da5e2f864ecc79c99dae630a85d68c304/websockets-16.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7be95cfb0a4dae143eaed2bcba8ac23f4892d8971311f1b06f3c6b78952ee70b", size = 185870, upload-time = "2026-01-10T09:22:39.893Z" },
{ url = "https://files.pythonhosted.org/packages/93/1f/5d6dbf551766308f6f50f8baf8e9860be6182911e8106da7a7f73785f4c4/websockets-15.0.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8dd8327c795b3e3f219760fa603dcae1dcc148172290a8ab15158cf85a953413", size = 182252, upload-time = "2025-03-05T20:02:05.29Z" }, { url = "https://files.pythonhosted.org/packages/87/05/536652aa84ddc1c018dbb7e2c4cbcd0db884580bf8e95aece7593fde526f/websockets-16.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d6297ce39ce5c2e6feb13c1a996a2ded3b6832155fcfc920265c76f24c7cceb5", size = 185361, upload-time = "2026-01-10T09:22:41.016Z" },
{ url = "https://files.pythonhosted.org/packages/d4/78/2d4fed9123e6620cbf1706c0de8a1632e1a28e7774d94346d7de1bba2ca3/websockets-15.0.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8fdc51055e6ff4adeb88d58a11042ec9a5eae317a0a53d12c062c8a8865909e8", size = 182521, upload-time = "2025-03-05T20:02:07.458Z" }, { url = "https://files.pythonhosted.org/packages/6d/e2/d5332c90da12b1e01f06fb1b85c50cfc489783076547415bf9f0a659ec19/websockets-16.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1c1b30e4f497b0b354057f3467f56244c603a79c0d1dafce1d16c283c25f6e64", size = 184615, upload-time = "2026-01-10T09:22:42.442Z" },
{ url = "https://files.pythonhosted.org/packages/e7/3b/66d4c1b444dd1a9823c4a81f50231b921bab54eee2f69e70319b4e21f1ca/websockets-15.0.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:693f0192126df6c2327cce3baa7c06f2a117575e32ab2308f7f8216c29d9e2e3", size = 181958, upload-time = "2025-03-05T20:02:09.842Z" }, { url = "https://files.pythonhosted.org/packages/77/fb/d3f9576691cae9253b51555f841bc6600bf0a983a461c79500ace5a5b364/websockets-16.0-cp311-cp311-win32.whl", hash = "sha256:5f451484aeb5cafee1ccf789b1b66f535409d038c56966d6101740c1614b86c6", size = 178246, upload-time = "2026-01-10T09:22:43.654Z" },
{ url = "https://files.pythonhosted.org/packages/08/ff/e9eed2ee5fed6f76fdd6032ca5cd38c57ca9661430bb3d5fb2872dc8703c/websockets-15.0.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:54479983bd5fb469c38f2f5c7e3a24f9a4e70594cd68cd1fa6b9340dadaff7cf", size = 181918, upload-time = "2025-03-05T20:02:11.968Z" }, { url = "https://files.pythonhosted.org/packages/54/67/eaff76b3dbaf18dcddabc3b8c1dba50b483761cccff67793897945b37408/websockets-16.0-cp311-cp311-win_amd64.whl", hash = "sha256:8d7f0659570eefb578dacde98e24fb60af35350193e4f56e11190787bee77dac", size = 178684, upload-time = "2026-01-10T09:22:44.941Z" },
{ url = "https://files.pythonhosted.org/packages/d8/75/994634a49b7e12532be6a42103597b71098fd25900f7437d6055ed39930a/websockets-15.0.1-cp311-cp311-win32.whl", hash = "sha256:16b6c1b3e57799b9d38427dda63edcbe4926352c47cf88588c0be4ace18dac85", size = 176388, upload-time = "2025-03-05T20:02:13.32Z" }, { url = "https://files.pythonhosted.org/packages/84/7b/bac442e6b96c9d25092695578dda82403c77936104b5682307bd4deb1ad4/websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00", size = 177365, upload-time = "2026-01-10T09:22:46.787Z" },
{ url = "https://files.pythonhosted.org/packages/98/93/e36c73f78400a65f5e236cd376713c34182e6663f6889cd45a4a04d8f203/websockets-15.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:27ccee0071a0e75d22cb35849b1db43f2ecd3e161041ac1ee9d2352ddf72f065", size = 176828, upload-time = "2025-03-05T20:02:14.585Z" }, { url = "https://files.pythonhosted.org/packages/b0/fe/136ccece61bd690d9c1f715baaeefd953bb2360134de73519d5df19d29ca/websockets-16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79", size = 175038, upload-time = "2026-01-10T09:22:47.999Z" },
{ url = "https://files.pythonhosted.org/packages/51/6b/4545a0d843594f5d0771e86463606a3988b5a09ca5123136f8a76580dd63/websockets-15.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3e90baa811a5d73f3ca0bcbf32064d663ed81318ab225ee4f427ad4e26e5aff3", size = 175437, upload-time = "2025-03-05T20:02:16.706Z" }, { url = "https://files.pythonhosted.org/packages/40/1e/9771421ac2286eaab95b8575b0cb701ae3663abf8b5e1f64f1fd90d0a673/websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39", size = 175328, upload-time = "2026-01-10T09:22:49.809Z" },
{ url = "https://files.pythonhosted.org/packages/f4/71/809a0f5f6a06522af902e0f2ea2757f71ead94610010cf570ab5c98e99ed/websockets-15.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:592f1a9fe869c778694f0aa806ba0374e97648ab57936f092fd9d87f8bc03665", size = 173096, upload-time = "2025-03-05T20:02:18.832Z" }, { url = "https://files.pythonhosted.org/packages/18/29/71729b4671f21e1eaa5d6573031ab810ad2936c8175f03f97f3ff164c802/websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c", size = 184915, upload-time = "2026-01-10T09:22:51.071Z" },
{ url = "https://files.pythonhosted.org/packages/3d/69/1a681dd6f02180916f116894181eab8b2e25b31e484c5d0eae637ec01f7c/websockets-15.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0701bc3cfcb9164d04a14b149fd74be7347a530ad3bbf15ab2c678a2cd3dd9a2", size = 173332, upload-time = "2025-03-05T20:02:20.187Z" }, { url = "https://files.pythonhosted.org/packages/97/bb/21c36b7dbbafc85d2d480cd65df02a1dc93bf76d97147605a8e27ff9409d/websockets-16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f", size = 186152, upload-time = "2026-01-10T09:22:52.224Z" },
{ url = "https://files.pythonhosted.org/packages/a6/02/0073b3952f5bce97eafbb35757f8d0d54812b6174ed8dd952aa08429bcc3/websockets-15.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8b56bdcdb4505c8078cb6c7157d9811a85790f2f2b3632c7d1462ab5783d215", size = 183152, upload-time = "2025-03-05T20:02:22.286Z" }, { url = "https://files.pythonhosted.org/packages/4a/34/9bf8df0c0cf88fa7bfe36678dc7b02970c9a7d5e065a3099292db87b1be2/websockets-16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1", size = 185583, upload-time = "2026-01-10T09:22:53.443Z" },
{ url = "https://files.pythonhosted.org/packages/74/45/c205c8480eafd114b428284840da0b1be9ffd0e4f87338dc95dc6ff961a1/websockets-15.0.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0af68c55afbd5f07986df82831c7bff04846928ea8d1fd7f30052638788bc9b5", size = 182096, upload-time = "2025-03-05T20:02:24.368Z" }, { url = "https://files.pythonhosted.org/packages/47/88/4dd516068e1a3d6ab3c7c183288404cd424a9a02d585efbac226cb61ff2d/websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2", size = 184880, upload-time = "2026-01-10T09:22:55.033Z" },
{ url = "https://files.pythonhosted.org/packages/14/8f/aa61f528fba38578ec553c145857a181384c72b98156f858ca5c8e82d9d3/websockets-15.0.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64dee438fed052b52e4f98f76c5790513235efaa1ef7f3f2192c392cd7c91b65", size = 182523, upload-time = "2025-03-05T20:02:25.669Z" }, { url = "https://files.pythonhosted.org/packages/91/d6/7d4553ad4bf1c0421e1ebd4b18de5d9098383b5caa1d937b63df8d04b565/websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89", size = 178261, upload-time = "2026-01-10T09:22:56.251Z" },
{ url = "https://files.pythonhosted.org/packages/ec/6d/0267396610add5bc0d0d3e77f546d4cd287200804fe02323797de77dbce9/websockets-15.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d5f6b181bb38171a8ad1d6aa58a67a6aa9d4b38d0f8c5f496b9e42561dfc62fe", size = 182790, upload-time = "2025-03-05T20:02:26.99Z" }, { url = "https://files.pythonhosted.org/packages/c3/f0/f3a17365441ed1c27f850a80b2bc680a0fa9505d733fe152fdf5e98c1c0b/websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea", size = 178693, upload-time = "2026-01-10T09:22:57.478Z" },
{ url = "https://files.pythonhosted.org/packages/02/05/c68c5adbf679cf610ae2f74a9b871ae84564462955d991178f95a1ddb7dd/websockets-15.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5d54b09eba2bada6011aea5375542a157637b91029687eb4fdb2dab11059c1b4", size = 182165, upload-time = "2025-03-05T20:02:30.291Z" }, { url = "https://files.pythonhosted.org/packages/cc/9c/baa8456050d1c1b08dd0ec7346026668cbc6f145ab4e314d707bb845bf0d/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9", size = 177364, upload-time = "2026-01-10T09:22:59.333Z" },
{ url = "https://files.pythonhosted.org/packages/29/93/bb672df7b2f5faac89761cb5fa34f5cec45a4026c383a4b5761c6cea5c16/websockets-15.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3be571a8b5afed347da347bfcf27ba12b069d9d7f42cb8c7028b5e98bbb12597", size = 182160, upload-time = "2025-03-05T20:02:31.634Z" }, { url = "https://files.pythonhosted.org/packages/7e/0c/8811fc53e9bcff68fe7de2bcbe75116a8d959ac699a3200f4847a8925210/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230", size = 175039, upload-time = "2026-01-10T09:23:01.171Z" },
{ url = "https://files.pythonhosted.org/packages/ff/83/de1f7709376dc3ca9b7eeb4b9a07b4526b14876b6d372a4dc62312bebee0/websockets-15.0.1-cp312-cp312-win32.whl", hash = "sha256:c338ffa0520bdb12fbc527265235639fb76e7bc7faafbb93f6ba80d9c06578a9", size = 176395, upload-time = "2025-03-05T20:02:33.017Z" }, { url = "https://files.pythonhosted.org/packages/aa/82/39a5f910cb99ec0b59e482971238c845af9220d3ab9fa76dd9162cda9d62/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c", size = 175323, upload-time = "2026-01-10T09:23:02.341Z" },
{ url = "https://files.pythonhosted.org/packages/7d/71/abf2ebc3bbfa40f391ce1428c7168fb20582d0ff57019b69ea20fa698043/websockets-15.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcd5cf9e305d7b8338754470cf69cf81f420459dbae8a3b40cee57417f4614a7", size = 176841, upload-time = "2025-03-05T20:02:34.498Z" }, { url = "https://files.pythonhosted.org/packages/bd/28/0a25ee5342eb5d5f297d992a77e56892ecb65e7854c7898fb7d35e9b33bd/websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5", size = 184975, upload-time = "2026-01-10T09:23:03.756Z" },
{ url = "https://files.pythonhosted.org/packages/cb/9f/51f0cf64471a9d2b4d0fc6c534f323b664e7095640c34562f5182e5a7195/websockets-15.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee443ef070bb3b6ed74514f5efaa37a252af57c90eb33b956d35c8e9c10a1931", size = 175440, upload-time = "2025-03-05T20:02:36.695Z" }, { url = "https://files.pythonhosted.org/packages/f9/66/27ea52741752f5107c2e41fda05e8395a682a1e11c4e592a809a90c6a506/websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82", size = 186203, upload-time = "2026-01-10T09:23:05.01Z" },
{ url = "https://files.pythonhosted.org/packages/8a/05/aa116ec9943c718905997412c5989f7ed671bc0188ee2ba89520e8765d7b/websockets-15.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5a939de6b7b4e18ca683218320fc67ea886038265fd1ed30173f5ce3f8e85675", size = 173098, upload-time = "2025-03-05T20:02:37.985Z" }, { url = "https://files.pythonhosted.org/packages/37/e5/8e32857371406a757816a2b471939d51c463509be73fa538216ea52b792a/websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8", size = 185653, upload-time = "2026-01-10T09:23:06.301Z" },
{ url = "https://files.pythonhosted.org/packages/ff/0b/33cef55ff24f2d92924923c99926dcce78e7bd922d649467f0eda8368923/websockets-15.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:746ee8dba912cd6fc889a8147168991d50ed70447bf18bcda7039f7d2e3d9151", size = 173329, upload-time = "2025-03-05T20:02:39.298Z" }, { url = "https://files.pythonhosted.org/packages/9b/67/f926bac29882894669368dc73f4da900fcdf47955d0a0185d60103df5737/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f", size = 184920, upload-time = "2026-01-10T09:23:07.492Z" },
{ url = "https://files.pythonhosted.org/packages/31/1d/063b25dcc01faa8fada1469bdf769de3768b7044eac9d41f734fd7b6ad6d/websockets-15.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:595b6c3969023ecf9041b2936ac3827e4623bfa3ccf007575f04c5a6aa318c22", size = 183111, upload-time = "2025-03-05T20:02:40.595Z" }, { url = "https://files.pythonhosted.org/packages/3c/a1/3d6ccdcd125b0a42a311bcd15a7f705d688f73b2a22d8cf1c0875d35d34a/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a", size = 178255, upload-time = "2026-01-10T09:23:09.245Z" },
{ url = "https://files.pythonhosted.org/packages/93/53/9a87ee494a51bf63e4ec9241c1ccc4f7c2f45fff85d5bde2ff74fcb68b9e/websockets-15.0.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c714d2fc58b5ca3e285461a4cc0c9a66bd0e24c5da9911e30158286c9b5be7f", size = 182054, upload-time = "2025-03-05T20:02:41.926Z" }, { url = "https://files.pythonhosted.org/packages/6b/ae/90366304d7c2ce80f9b826096a9e9048b4bb760e44d3b873bb272cba696b/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156", size = 178689, upload-time = "2026-01-10T09:23:10.483Z" },
{ url = "https://files.pythonhosted.org/packages/ff/b2/83a6ddf56cdcbad4e3d841fcc55d6ba7d19aeb89c50f24dd7e859ec0805f/websockets-15.0.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f3c1e2ab208db911594ae5b4f79addeb3501604a165019dd221c0bdcabe4db8", size = 182496, upload-time = "2025-03-05T20:02:43.304Z" }, { url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" },
{ url = "https://files.pythonhosted.org/packages/98/41/e7038944ed0abf34c45aa4635ba28136f06052e08fc2168520bb8b25149f/websockets-15.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:229cf1d3ca6c1804400b0a9790dc66528e08a6a1feec0d5040e8b9eb14422375", size = 182829, upload-time = "2025-03-05T20:02:48.812Z" }, { url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" },
{ url = "https://files.pythonhosted.org/packages/e0/17/de15b6158680c7623c6ef0db361da965ab25d813ae54fcfeae2e5b9ef910/websockets-15.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:756c56e867a90fb00177d530dca4b097dd753cde348448a1012ed6c5131f8b7d", size = 182217, upload-time = "2025-03-05T20:02:50.14Z" }, { url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" },
{ url = "https://files.pythonhosted.org/packages/33/2b/1f168cb6041853eef0362fb9554c3824367c5560cbdaad89ac40f8c2edfc/websockets-15.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:558d023b3df0bffe50a04e710bc87742de35060580a293c2a984299ed83bc4e4", size = 182195, upload-time = "2025-03-05T20:02:51.561Z" }, { url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" },
{ url = "https://files.pythonhosted.org/packages/86/eb/20b6cdf273913d0ad05a6a14aed4b9a85591c18a987a3d47f20fa13dcc47/websockets-15.0.1-cp313-cp313-win32.whl", hash = "sha256:ba9e56e8ceeeedb2e080147ba85ffcd5cd0711b89576b83784d8605a7df455fa", size = 176393, upload-time = "2025-03-05T20:02:53.814Z" }, { url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" },
{ url = "https://files.pythonhosted.org/packages/1b/6c/c65773d6cab416a64d191d6ee8a8b1c68a09970ea6909d16965d26bfed1e/websockets-15.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561", size = 176837, upload-time = "2025-03-05T20:02:55.237Z" }, { url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" },
{ url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" }, { url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" },
{ url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" },
{ url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" },
{ url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" },
{ url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" },
{ url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" },
{ url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" },
{ url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" },
{ url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" },
{ url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" },
{ url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" },
{ url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" },
{ url = "https://files.pythonhosted.org/packages/72/07/c98a68571dcf256e74f1f816b8cc5eae6eb2d3d5cfa44d37f801619d9166/websockets-16.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:349f83cd6c9a415428ee1005cadb5c2c56f4389bc06a9af16103c3bc3dcc8b7d", size = 174947, upload-time = "2026-01-10T09:23:36.166Z" },
{ url = "https://files.pythonhosted.org/packages/7e/52/93e166a81e0305b33fe416338be92ae863563fe7bce446b0f687b9df5aea/websockets-16.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:4a1aba3340a8dca8db6eb5a7986157f52eb9e436b74813764241981ca4888f03", size = 175260, upload-time = "2026-01-10T09:23:37.409Z" },
{ url = "https://files.pythonhosted.org/packages/56/0c/2dbf513bafd24889d33de2ff0368190a0e69f37bcfa19009ef819fe4d507/websockets-16.0-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f4a32d1bd841d4bcbffdcb3d2ce50c09c3909fbead375ab28d0181af89fd04da", size = 176071, upload-time = "2026-01-10T09:23:39.158Z" },
{ url = "https://files.pythonhosted.org/packages/a5/8f/aea9c71cc92bf9b6cc0f7f70df8f0b420636b6c96ef4feee1e16f80f75dd/websockets-16.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0298d07ee155e2e9fda5be8a9042200dd2e3bb0b8a38482156576f863a9d457c", size = 176968, upload-time = "2026-01-10T09:23:41.031Z" },
{ url = "https://files.pythonhosted.org/packages/9a/3f/f70e03f40ffc9a30d817eef7da1be72ee4956ba8d7255c399a01b135902a/websockets-16.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:a653aea902e0324b52f1613332ddf50b00c06fdaf7e92624fbf8c77c78fa5767", size = 178735, upload-time = "2026-01-10T09:23:42.259Z" },
{ url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" },
] ]
[[package]] [[package]]

View File

@@ -1,4 +1,4 @@
import { useEffect, useState, useCallback, Suspense, useRef } from 'react'; import { useEffect, useState, useCallback, Suspense } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom'; import { useNavigate, useSearchParams } from 'react-router-dom';
import { httpClient } from '@/app/infra/http/HttpClient'; import { httpClient } from '@/app/infra/http/HttpClient';
import { toast } from 'sonner'; import { toast } from 'sonner';
@@ -20,39 +20,10 @@ import { Button } from '@/components/ui/button';
import { LoadingSpinner } from '@/components/ui/loading-spinner'; import { LoadingSpinner } from '@/components/ui/loading-spinner';
import langbotIcon from '@/app/assets/langbot-logo.webp'; import langbotIcon from '@/app/assets/langbot-logo.webp';
type SpaceOAuthLoginResult = {
token: string;
user: string;
};
const pendingSpaceOAuthLogins = new Map<
string,
Promise<SpaceOAuthLoginResult>
>();
function getOrCreateSpaceOAuthLoginPromise(
authCode: string,
): Promise<SpaceOAuthLoginResult> {
const pendingRequest = pendingSpaceOAuthLogins.get(authCode);
if (pendingRequest) {
return pendingRequest;
}
const requestPromise = httpClient
.exchangeSpaceOAuthCode(authCode)
.finally(() => {
pendingSpaceOAuthLogins.delete(authCode);
});
pendingSpaceOAuthLogins.set(authCode, requestPromise);
return requestPromise;
}
function SpaceOAuthCallbackContent() { function SpaceOAuthCallbackContent() {
const navigate = useNavigate(); const navigate = useNavigate();
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const { t } = useTranslation(); const { t } = useTranslation();
const isMountedRef = useRef(true);
const [status, setStatus] = useState< const [status, setStatus] = useState<
'loading' | 'confirm' | 'success' | 'error' 'loading' | 'confirm' | 'success' | 'error'
@@ -66,11 +37,7 @@ function SpaceOAuthCallbackContent() {
const handleOAuthCallback = useCallback( const handleOAuthCallback = useCallback(
async (authCode: string) => { async (authCode: string) => {
try { try {
const response = await getOrCreateSpaceOAuthLoginPromise(authCode); const response = await httpClient.exchangeSpaceOAuthCode(authCode);
if (!isMountedRef.current) {
return;
}
localStorage.setItem('token', response.token); localStorage.setItem('token', response.token);
if (response.user) { if (response.user) {
localStorage.setItem('userEmail', response.user); localStorage.setItem('userEmail', response.user);
@@ -85,10 +52,6 @@ function SpaceOAuthCallbackContent() {
navigate(redirectTo); navigate(redirectTo);
}, 1000); }, 1000);
} catch (err) { } catch (err) {
if (!isMountedRef.current) {
return;
}
setStatus('error'); setStatus('error');
const errorObj = err as { msg?: string }; const errorObj = err as { msg?: string };
const errMsg = (errorObj?.msg || '').toLowerCase(); const errMsg = (errorObj?.msg || '').toLowerCase();
@@ -109,10 +72,6 @@ function SpaceOAuthCallbackContent() {
setIsProcessing(true); setIsProcessing(true);
try { try {
const response = await httpClient.bindSpaceAccount(authCode, state); const response = await httpClient.bindSpaceAccount(authCode, state);
if (!isMountedRef.current) {
return;
}
localStorage.setItem('token', response.token); localStorage.setItem('token', response.token);
if (response.user) { if (response.user) {
localStorage.setItem('userEmail', response.user); localStorage.setItem('userEmail', response.user);
@@ -123,10 +82,6 @@ function SpaceOAuthCallbackContent() {
navigate('/home'); navigate('/home');
}, 1000); }, 1000);
} catch (err) { } catch (err) {
if (!isMountedRef.current) {
return;
}
setStatus('error'); setStatus('error');
const errorObj = err as { msg?: string }; const errorObj = err as { msg?: string };
const errMsg = (errorObj?.msg || '').toLowerCase(); const errMsg = (errorObj?.msg || '').toLowerCase();
@@ -136,17 +91,13 @@ function SpaceOAuthCallbackContent() {
setErrorMessage(t('account.bindSpaceFailed')); setErrorMessage(t('account.bindSpaceFailed'));
} }
} finally { } finally {
if (isMountedRef.current) {
setIsProcessing(false); setIsProcessing(false);
} }
}
}, },
[navigate, t], [navigate, t],
); );
useEffect(() => { useEffect(() => {
isMountedRef.current = true;
const authCode = searchParams.get('code'); const authCode = searchParams.get('code');
const error = searchParams.get('error'); const error = searchParams.get('error');
const errorDescription = searchParams.get('error_description'); const errorDescription = searchParams.get('error_description');
@@ -184,9 +135,6 @@ function SpaceOAuthCallbackContent() {
// Normal login/register mode // Normal login/register mode
handleOAuthCallback(authCode); handleOAuthCallback(authCode);
} }
return () => {
isMountedRef.current = false;
};
}, [searchParams, handleOAuthCallback, t]); }, [searchParams, handleOAuthCallback, t]);
const handleConfirmBind = () => { const handleConfirmBind = () => {

View File

@@ -174,14 +174,11 @@ export default function BotDetailContent({ id }: { id: string }) {
</div> </div>
)} )}
</div> </div>
<Button {activeTab === 'config' && (
type="submit" <Button type="submit" form="bot-form" disabled={!formDirty}>
form="bot-form"
disabled={!formDirty}
className={activeTab !== 'config' ? 'invisible' : ''}
>
{t('common.save')} {t('common.save')}
</Button> </Button>
)}
</div> </div>
{/* Horizontal Tabs */} {/* Horizontal Tabs */}

View File

@@ -267,7 +267,6 @@ export default function BotForm({
type: parseDynamicFormItemType(item.type), type: parseDynamicFormItemType(item.type),
options: item.options, options: item.options,
show_if: item.show_if, show_if: item.show_if,
login_platform: item.login_platform,
}), }),
), ),
); );
@@ -619,8 +618,6 @@ export default function BotForm({
systemContext={{ systemContext={{
webhook_url: webhookUrl, webhook_url: webhookUrl,
extra_webhook_url: extraWebhookUrl, extra_webhook_url: extraWebhookUrl,
bot_uuid: initBotId || '',
adapter_config: form.getValues('adapter_config') || {},
}} }}
/> />
)} )}

View File

@@ -6,7 +6,6 @@ import { useTranslation } from 'react-i18next';
import { Check, ChevronDown, ChevronRight, Copy } from 'lucide-react'; import { Check, ChevronDown, ChevronRight, Copy } from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { copyToClipboard } from '@/app/utils/clipboard';
const LEVEL_STYLES: Record<string, string> = { const LEVEL_STYLES: Record<string, string> = {
error: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400', 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() { function copySessionId() {
const text = botLog.message_session_id; const text = botLog.message_session_id;
copyToClipboard(text) if (navigator.clipboard?.writeText) {
.then((ok) => { navigator.clipboard
if (ok) { .writeText(text)
.then(() => {
setCopied(true); setCopied(true);
setTimeout(() => setCopied(false), 2000); setTimeout(() => setCopied(false), 2000);
toast.success(t('common.copySuccess')); toast.success(t('common.copySuccess'));
})
.catch(() => fallbackCopy(text));
} else { } 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')); toast.error(t('common.copyFailed'));
} }
}) document.body.removeChild(ta);
.catch(() => {
toast.error(t('common.copyFailed'));
});
} }
function formatTime(timestamp: number) { function formatTime(timestamp: number) {

View File

@@ -19,7 +19,6 @@ import {
ThumbsUp, ThumbsUp,
ThumbsDown, ThumbsDown,
} from 'lucide-react'; } from 'lucide-react';
import { copyToClipboard } from '@/app/utils/clipboard';
import { import {
MessageChainComponent, MessageChainComponent,
Plain, Plain,
@@ -109,9 +108,10 @@ const BotSessionMonitor = forwardRef<
}; };
const copyUserId = (userId: string) => { const copyUserId = (userId: string) => {
copyToClipboard(userId).catch(() => {}); navigator.clipboard.writeText(userId).then(() => {
setCopiedUserId(true); setCopiedUserId(true);
setTimeout(() => setCopiedUserId(false), 2000); setTimeout(() => setCopiedUserId(false), 2000);
});
}; };
const loadSessions = useCallback(async () => { const loadSessions = useCallback(async () => {

View File

@@ -1,5 +1,5 @@
import * as React from 'react'; import * as React from 'react';
import { useState, useEffect, useRef } from 'react'; import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { Copy, Check, Trash2, Plus } from 'lucide-react'; import { Copy, Check, Trash2, Plus } from 'lucide-react';
@@ -86,9 +86,6 @@ export default function ApiIntegrationDialog({
const [newWebhookDescription, setNewWebhookDescription] = useState(''); const [newWebhookDescription, setNewWebhookDescription] = useState('');
const [newWebhookEnabled, setNewWebhookEnabled] = useState(true); const [newWebhookEnabled, setNewWebhookEnabled] = useState(true);
const [deleteWebhookId, setDeleteWebhookId] = useState<number | null>(null); const [deleteWebhookId, setDeleteWebhookId] = useState<number | null>(null);
const copiedTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(
undefined,
);
const [copiedKey, setCopiedKey] = useState<string | null>(null); const [copiedKey, setCopiedKey] = useState<string | null>(null);
// Sync URL with dialog state // 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) => { const handleCopyKey = (key: string) => {
try { navigator.clipboard.writeText(key);
copyToClipboard(key);
} catch {}
clearTimeout(copiedTimerRef.current);
setCopiedKey(key); setCopiedKey(key);
copiedTimerRef.current = setTimeout(() => setCopiedKey(null), 2000); setTimeout(() => setCopiedKey(null), 2000);
}; };
const maskApiKey = (key: string) => { const maskApiKey = (key: string) => {
@@ -352,21 +330,21 @@ export default function ApiIntegrationDialog({
</TableRow> </TableRow>
</TableHeader> </TableHeader>
<TableBody> <TableBody>
{apiKeys.map((item) => ( {apiKeys.map((key) => (
<TableRow key={item.id}> <TableRow key={key.id}>
<TableCell> <TableCell>
<div> <div>
<div className="font-medium">{item.name}</div> <div className="font-medium">{key.name}</div>
{item.description && ( {key.description && (
<div className="text-sm text-muted-foreground"> <div className="text-sm text-muted-foreground">
{item.description} {key.description}
</div> </div>
)} )}
</div> </div>
</TableCell> </TableCell>
<TableCell> <TableCell>
<code className="text-sm bg-muted px-2 py-1 rounded"> <code className="text-sm bg-muted px-2 py-1 rounded">
{maskApiKey(item.key)} {maskApiKey(key.key)}
</code> </code>
</TableCell> </TableCell>
<TableCell> <TableCell>
@@ -374,11 +352,10 @@ export default function ApiIntegrationDialog({
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
type="button" onClick={() => handleCopyKey(key.key)}
onClick={() => handleCopyKey(item.key)}
title={t('common.copyApiKey')} title={t('common.copyApiKey')}
> >
{copiedKey === item.key ? ( {copiedKey === key.key ? (
<Check className="h-4 w-4 text-green-600" /> <Check className="h-4 w-4 text-green-600" />
) : ( ) : (
<Copy className="h-4 w-4" /> <Copy className="h-4 w-4" />
@@ -387,7 +364,7 @@ export default function ApiIntegrationDialog({
<Button <Button
variant="ghost" variant="ghost"
size="sm" size="sm"
onClick={() => setDeleteKeyId(item.id)} onClick={() => setDeleteKeyId(key.id)}
title={t('common.delete')} title={t('common.delete')}
> >
<Trash2 className="h-4 w-4" /> <Trash2 className="h-4 w-4" />
@@ -653,6 +630,44 @@ export default function ApiIntegrationDialog({
</DialogContent> </DialogContent>
</Dialog> </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 */} {/* Delete Confirmation Dialog */}
<AlertDialog open={!!deleteKeyId}> <AlertDialog open={!!deleteKeyId}>
<AlertDialogPortal> <AlertDialogPortal>

View File

@@ -11,17 +11,13 @@ import {
FormMessage, FormMessage,
} from '@/components/ui/form'; } from '@/components/ui/form';
import DynamicFormItemComponent from '@/app/home/components/dynamic-form/DynamicFormItemComponent'; import DynamicFormItemComponent from '@/app/home/components/dynamic-form/DynamicFormItemComponent';
import QrCodeLoginDialog, {
QrLoginPlatform,
} from '@/app/home/components/qrcode-login/QrCodeLoginDialog';
import { useEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { extractI18nObject } from '@/i18n/I18nProvider'; import { extractI18nObject } from '@/i18n/I18nProvider';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { Input } from '@/components/ui/input'; import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { Copy, Check, Globe, QrCode } from 'lucide-react'; import { Copy, Check, Globe } from 'lucide-react';
import { copyToClipboard } from '@/app/utils/clipboard';
import { systemInfo } from '@/app/infra/http'; import { systemInfo } from '@/app/infra/http';
/** /**
@@ -48,54 +44,6 @@ function resolveShowIfValue(
return externalDependentValues?.[field]; 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. * Display-only component for webhook URL fields.
* Rendered outside of react-hook-form binding since the value is * Rendered outside of react-hook-form binding since the value is
@@ -117,9 +65,15 @@ function WebhookUrlField({
const { t } = useTranslation(); const { t } = useTranslation();
const handleCopy = (text: string, setter: (v: boolean) => void) => { const handleCopy = (text: string, setter: (v: boolean) => void) => {
copyToClipboard(text).catch(() => {}); if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard
.writeText(text)
.then(() => {
setter(true); setter(true);
setTimeout(() => setter(false), 2000); setTimeout(() => setter(false), 2000);
})
.catch(() => {});
}
}; };
return ( return (
@@ -198,7 +152,6 @@ export default function DynamicFormComponent({
isEditing, isEditing,
externalDependentValues, externalDependentValues,
systemContext, systemContext,
onValidate,
}: { }: {
itemConfigList: IDynamicFormItemSchema[]; itemConfigList: IDynamicFormItemSchema[];
onSubmit?: (val: object) => unknown; onSubmit?: (val: object) => unknown;
@@ -209,9 +162,6 @@ export default function DynamicFormComponent({
/** Extra variables accessible via the `__system.*` namespace in show_if conditions. /** Extra variables accessible via the `__system.*` namespace in show_if conditions.
* e.g. `{ is_wizard: true }` makes `show_if: { field: "__system.is_wizard", ... }` work. */ * e.g. `{ is_wizard: true }` makes `show_if: { field: "__system.is_wizard", ... }` work. */
systemContext?: Record<string, unknown>; systemContext?: Record<string, unknown>;
/** Callback to expose validation function to parent component.
* Parent can call this function to trigger validation and get validity state. */
onValidate?: (validateFn: () => Promise<boolean>) => void;
}) { }) {
const isInitialMount = useRef(true); const isInitialMount = useRef(true);
const previousInitialValues = useRef(initialValues); const previousInitialValues = useRef(initialValues);
@@ -253,16 +203,10 @@ export default function DynamicFormComponent({
return value; 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. // participate in form state, validation, or value emission.
const editableItems = useMemo( const editableItems = useMemo(
() => () => itemConfigList.filter((item) => item.type !== 'webhook-url'),
itemConfigList.filter(
(item) =>
item.type !== 'webhook-url' &&
item.type !== 'embed-code' &&
item.type !== 'qr-code-login',
),
[itemConfigList], [itemConfigList],
); );
@@ -296,9 +240,6 @@ export default function DynamicFormComponent({
case 'embedding-model-selector': case 'embedding-model-selector':
fieldSchema = z.string(); fieldSchema = z.string();
break; break;
case 'rerank-model-selector':
fieldSchema = z.string();
break;
case 'knowledge-base-selector': case 'knowledge-base-selector':
fieldSchema = z.string(); fieldSchema = z.string();
break; break;
@@ -362,17 +303,6 @@ export default function DynamicFormComponent({
}, {} as FormValues), }, {} as FormValues),
}); });
// Expose validation function to parent component
const validate = async (): Promise<boolean> => {
// Trigger validation for all fields
const result = await form.trigger();
return result;
};
useEffect(() => {
onValidate?.(validate);
}, [onValidate]);
// 当 initialValues 变化时更新表单值 // 当 initialValues 变化时更新表单值
// 但要避免因为内部表单更新触发的 onSubmit 导致的 initialValues 变化而重新设置表单 // 但要避免因为内部表单更新触发的 onSubmit 导致的 initialValues 变化而重新设置表单
useEffect(() => { useEffect(() => {
@@ -455,28 +385,9 @@ export default function DynamicFormComponent({
return () => subscription.unsubscribe(); return () => subscription.unsubscribe();
}, [form, editableItems]); }, [form, editableItems]);
// State for QR code login dialog
const [qrDialogOpen, setQrDialogOpen] = useState(false);
const [qrDialogPlatform, setQrDialogPlatform] =
useState<QrLoginPlatform>('feishu');
return ( return (
<Form {...form}> <Form {...form}>
<div className="space-y-4"> <div className="space-y-4">
{/* QR code login dialog */}
<QrCodeLoginDialog
open={qrDialogOpen}
onOpenChange={setQrDialogOpen}
platform={qrDialogPlatform}
onSuccess={(credentials) => {
for (const [key, value] of Object.entries(credentials)) {
if (value) {
form.setValue(key as keyof FormValues, value as never);
}
}
}}
/>
{itemConfigList.map((config) => { {itemConfigList.map((config) => {
if (config.show_if) { if (config.show_if) {
const dependValue = resolveShowIfValue( const dependValue = resolveShowIfValue(
@@ -533,96 +444,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, '&amp;')
.replace(/"/g, '&quot;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
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}
/>
);
}
// QR code login button (e.g. Feishu one-click create, WeChat scan login)
if (config.type === 'qr-code-login') {
return (
<FormItem key={config.id}>
<div
className="relative flex items-center gap-4 p-4 rounded-xl border-2 border-dashed cursor-pointer transition-all hover:border-solid hover:shadow-md group"
style={{
borderColor:
'color-mix(in srgb, var(--primary) 25%, transparent)',
background:
'color-mix(in srgb, var(--primary) 3%, transparent)',
}}
onClick={() => {
if (!isEditing) {
setQrDialogPlatform(
(config.login_platform as QrLoginPlatform) || 'feishu',
);
setQrDialogOpen(true);
}
}}
>
<div className="flex items-center justify-center h-12 w-12 rounded-lg bg-primary/10 shrink-0">
<QrCode className="h-6 w-6 text-primary" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-foreground">
{extractI18nObject(config.label)}
</span>
<span className="px-1.5 py-0.5 text-[10px] font-bold rounded bg-primary text-primary-foreground">
{t('common.recommend')}
</span>
</div>
{config.description && (
<p className="text-xs text-muted-foreground mt-1 leading-relaxed">
{extractI18nObject(config.description)}
</p>
)}
</div>
<Button
type="button"
size="sm"
disabled={!!isEditing}
className="shrink-0"
onClick={(e) => {
e.stopPropagation();
setQrDialogPlatform(
(config.login_platform as QrLoginPlatform) || 'feishu',
);
setQrDialogOpen(true);
}}
>
<QrCode className="h-3.5 w-3.5 mr-1" />
{t('common.start')}
</Button>
</div>
</FormItem>
);
}
// Boolean fields use a special inline layout // Boolean fields use a special inline layout
if (config.type === 'boolean') { if (config.type === 'boolean') {
return ( return (

View File

@@ -23,7 +23,6 @@ import {
Bot, Bot,
KnowledgeBase, KnowledgeBase,
EmbeddingModel, EmbeddingModel,
RerankModel,
PluginTool, PluginTool,
} from '@/app/infra/entities/api'; } from '@/app/infra/entities/api';
import { toast } from 'sonner'; import { toast } from 'sonner';
@@ -75,7 +74,6 @@ export default function DynamicFormItemComponent({
}) { }) {
const [llmModels, setLlmModels] = useState<LLMModel[]>([]); const [llmModels, setLlmModels] = useState<LLMModel[]>([]);
const [embeddingModels, setEmbeddingModels] = useState<EmbeddingModel[]>([]); const [embeddingModels, setEmbeddingModels] = useState<EmbeddingModel[]>([]);
const [rerankModels, setRerankModels] = useState<RerankModel[]>([]);
const [knowledgeBases, setKnowledgeBases] = useState<KnowledgeBase[]>([]); const [knowledgeBases, setKnowledgeBases] = useState<KnowledgeBase[]>([]);
const [bots, setBots] = useState<Bot[]>([]); const [bots, setBots] = useState<Bot[]>([]);
const [tools, setTools] = useState<PluginTool[]>([]); const [tools, setTools] = useState<PluginTool[]>([]);
@@ -182,19 +180,6 @@ export default function DynamicFormItemComponent({
} }
}, [config.type]); }, [config.type]);
useEffect(() => {
if (config.type === DynamicFormItemType.RERANK_MODEL_SELECTOR) {
httpClient
.getProviderRerankModels()
.then((resp) => {
setRerankModels(resp.models);
})
.catch((err) => {
toast.error('Failed to load rerank models: ' + err.msg);
});
}
}, [config.type]);
useEffect(() => { useEffect(() => {
if (config.type === DynamicFormItemType.MODEL_FALLBACK_SELECTOR) { if (config.type === DynamicFormItemType.MODEL_FALLBACK_SELECTOR) {
fetchLlmModels(); fetchLlmModels();
@@ -600,45 +585,6 @@ export default function DynamicFormItemComponent({
</div> </div>
); );
case DynamicFormItemType.RERANK_MODEL_SELECTOR:
const groupedRerankModels = rerankModels.reduce(
(acc, model) => {
const providerName = model.provider?.name || 'Unknown';
if (!acc[providerName]) acc[providerName] = [];
acc[providerName].push(model);
return acc;
},
{} as Record<string, RerankModel[]>,
);
return (
<div className="max-w-md">
<Select
value={field.value || '__none__'}
onValueChange={(v) => field.onChange(v === '__none__' ? '' : v)}
>
<SelectTrigger className="bg-[#ffffff] dark:bg-[#2a2a2e]">
<SelectValue placeholder={t('models.rerank')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none__">{t('common.none')}</SelectItem>
{Object.entries(groupedRerankModels).map(
([providerName, models]) => (
<SelectGroup key={providerName}>
<SelectLabel>{providerName}</SelectLabel>
{models.map((model) => (
<SelectItem key={model.uuid} value={model.uuid}>
{model.name}
</SelectItem>
))}
</SelectGroup>
),
)}
</SelectContent>
</Select>
</div>
);
case DynamicFormItemType.MODEL_FALLBACK_SELECTOR: { case DynamicFormItemType.MODEL_FALLBACK_SELECTOR: {
// Separate space models from regular models // Separate space models from regular models
const fbSpaceModels = llmModels.filter( const fbSpaceModels = llmModels.filter(

View File

@@ -16,7 +16,6 @@ export class DynamicFormItemConfig implements IDynamicFormItemSchema {
description?: I18nObject; description?: I18nObject;
options?: IDynamicFormItemOption[]; options?: IDynamicFormItemOption[];
show_if?: IShowIfCondition; show_if?: IShowIfCondition;
login_platform?: string;
constructor(params: IDynamicFormItemSchema) { constructor(params: IDynamicFormItemSchema) {
this.id = params.id; this.id = params.id;
@@ -28,7 +27,6 @@ export class DynamicFormItemConfig implements IDynamicFormItemSchema {
this.description = params.description; this.description = params.description;
this.options = params.options; this.options = params.options;
this.show_if = params.show_if; this.show_if = params.show_if;
this.login_platform = params.login_platform;
} }
} }

View File

@@ -26,7 +26,6 @@ import {
Store, Store,
Github, Github,
Zap, Zap,
HardDrive,
} from 'lucide-react'; } from 'lucide-react';
import { useTheme } from '@/components/providers/theme-provider'; 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 ApiIntegrationDialog from '@/app/home/components/api-integration-dialog/ApiIntegrationDialog';
import NewVersionDialog from '@/app/home/components/new-version-dialog/NewVersionDialog'; import NewVersionDialog from '@/app/home/components/new-version-dialog/NewVersionDialog';
import ModelsDialog from '@/app/home/components/models-dialog/ModelsDialog'; 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 { GitHubRelease } from '@/app/infra/http/CloudServiceClient';
import { useAsyncTask, AsyncTaskStatus } from '@/hooks/useAsyncTask'; import { useAsyncTask, AsyncTaskStatus } from '@/hooks/useAsyncTask';
import { toast } from 'sonner'; import { toast } from 'sonner';
@@ -701,14 +699,7 @@ function NavItems({
> >
<SidebarMenuItem> <SidebarMenuItem>
<SidebarMenuButton <SidebarMenuButton
asChild
isActive={false} isActive={false}
tooltip={config.name}
className="group/category-header"
>
<div
role="button"
tabIndex={0}
onClick={() => { onClick={() => {
if (isCollapseOnly) { if (isCollapseOnly) {
onSectionToggle(config.id, !isOpen); onSectionToggle(config.id, !isOpen);
@@ -716,16 +707,8 @@ function NavItems({
onChildClick(config); onChildClick(config);
} }
}} }}
onKeyDown={(e) => { tooltip={config.name}
if (e.key === 'Enter' || e.key === ' ') { className="group/category-header"
e.preventDefault();
if (isCollapseOnly) {
onSectionToggle(config.id, !isOpen);
} else {
onChildClick(config);
}
}
}}
> >
{config.icon} {config.icon}
<span>{config.name}</span> <span>{config.name}</span>
@@ -798,7 +781,6 @@ function NavItems({
</button> </button>
</CollapsibleTrigger> </CollapsibleTrigger>
</div> </div>
</div>
</SidebarMenuButton> </SidebarMenuButton>
<CollapsibleContent> <CollapsibleContent>
<SidebarMenuSub>{renderEntityList(false)}</SidebarMenuSub> <SidebarMenuSub>{renderEntityList(false)}</SidebarMenuSub>
@@ -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({ export default function HomeSidebar({
onSelectedChangeAction, onSelectedChangeAction,
}: { }: {
@@ -1187,9 +1048,6 @@ export default function HomeSidebar({
if (searchParams.get('action') === 'showApiIntegrationSettings') { if (searchParams.get('action') === 'showApiIntegrationSettings') {
setApiKeyDialogOpen(true); setApiKeyDialogOpen(true);
} }
if (searchParams.get('action') === 'showStorageAnalysis') {
setStorageAnalysisOpen(true);
}
}, [searchParams]); }, [searchParams]);
const [selectedChild, setSelectedChild] = useState<SidebarChildVO>(); const [selectedChild, setSelectedChild] = useState<SidebarChildVO>();
@@ -1205,7 +1063,6 @@ export default function HomeSidebar({
const [hasNewVersion, setHasNewVersion] = useState(false); const [hasNewVersion, setHasNewVersion] = useState(false);
const [versionDialogOpen, setVersionDialogOpen] = useState(false); const [versionDialogOpen, setVersionDialogOpen] = useState(false);
const [modelsDialogOpen, setModelsDialogOpen] = useState(false); const [modelsDialogOpen, setModelsDialogOpen] = useState(false);
const [storageAnalysisOpen, setStorageAnalysisOpen] = useState(false);
const [userEmail, setUserEmail] = useState<string>(''); const [userEmail, setUserEmail] = useState<string>('');
const [starCount, setStarCount] = useState<number | null>(null); const [starCount, setStarCount] = useState<number | null>(null);
const [userMenuOpen, setUserMenuOpen] = useState(false); 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(() => { useEffect(() => {
initSelect(); initSelect();
if (!localStorage.getItem('token')) { if (!localStorage.getItem('token')) {
@@ -1446,7 +1285,6 @@ export default function HomeSidebar({
</SidebarMenu> </SidebarMenu>
</SidebarGroupContent> </SidebarGroupContent>
</SidebarGroup> </SidebarGroup>
<PluginPagesNav />
</SidebarContent> </SidebarContent>
{/* Footer */} {/* Footer */}
@@ -1569,15 +1407,6 @@ export default function HomeSidebar({
<Settings /> <Settings />
{t('account.settings')} {t('account.settings')}
</DropdownMenuItem> </DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
setUserMenuOpen(false);
handleStorageAnalysisChange(true);
}}
>
<HardDrive />
{t('storageAnalysis.title')}
</DropdownMenuItem>
<DropdownMenuItem <DropdownMenuItem
onClick={() => { onClick={() => {
setUserMenuOpen(false); setUserMenuOpen(false);
@@ -1678,10 +1507,6 @@ export default function HomeSidebar({
open={modelsDialogOpen} open={modelsDialogOpen}
onOpenChange={handleModelsDialogChange} onOpenChange={handleModelsDialogChange}
/> />
<StorageAnalysisDialog
open={storageAnalysisOpen}
onOpenChange={handleStorageAnalysisChange}
/>
</> </>
); );
} }

Some files were not shown because too many files have changed in this diff Show More