Compare commits
2 Commits
fix/utils-
...
copilot/bu
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eefb0ddde1 | ||
|
|
4c7ee6a9c9 |
1
.gitignore
vendored
@@ -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
|
||||||
|
|||||||
26
README.md
@@ -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.*
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
18
README_CN.md
@@ -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)
|
||||||
|
|
||||||
|
|||||||
19
README_ES.md
@@ -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)
|
||||||
|
|
||||||
|
|||||||
19
README_FR.md
@@ -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)
|
||||||
|
|
||||||
|
|||||||
21
README_JP.md
@@ -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)
|
||||||
|
|
||||||
|
|||||||
19
README_KO.md
@@ -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)
|
||||||
|
|
||||||
|
|||||||
19
README_RU.md
@@ -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)
|
||||||
|
|
||||||
|
|||||||
19
README_TW.md
@@ -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(語音合成)
|
||||||
|
|
||||||
|
|||||||
19
README_VI.md
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
|
||||||
|
|||||||
@@ -1,384 +0,0 @@
|
|||||||
"""Embed widget routes - serve embeddable chat widget for external websites.
|
|
||||||
|
|
||||||
All user-facing URLs are keyed by **bot_uuid** (not pipeline_uuid) so that
|
|
||||||
internal pipeline identifiers are never exposed to end-users. Each handler
|
|
||||||
resolves the bot_uuid to the owning ``web_page_bot`` RuntimeBot and extracts
|
|
||||||
the bound pipeline_uuid for internal routing.
|
|
||||||
"""
|
|
||||||
|
|
||||||
import asyncio
|
|
||||||
import datetime
|
|
||||||
import json
|
|
||||||
import logging
|
|
||||||
import uuid
|
|
||||||
import hmac
|
|
||||||
import hashlib
|
|
||||||
import time
|
|
||||||
import re
|
|
||||||
import httpx
|
|
||||||
|
|
||||||
import quart
|
|
||||||
|
|
||||||
from ... import group
|
|
||||||
from ......utils import paths
|
|
||||||
from ......platform.sources.websocket_manager import ws_connection_manager
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
# Cache the widget template content
|
|
||||||
_widget_template_cache: str | None = None
|
|
||||||
_logo_bytes_cache: bytes | None = None
|
|
||||||
|
|
||||||
|
|
||||||
def _is_valid_uuid(s: str) -> bool:
|
|
||||||
return bool(re.match(r'^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$', s))
|
|
||||||
|
|
||||||
|
|
||||||
def _get_widget_template() -> str:
|
|
||||||
"""Load and cache the widget JS template."""
|
|
||||||
global _widget_template_cache
|
|
||||||
if _widget_template_cache is None:
|
|
||||||
template_path = paths.get_resource_path('templates/embed/widget.js')
|
|
||||||
with open(template_path, 'r', encoding='utf-8') as f:
|
|
||||||
_widget_template_cache = f.read()
|
|
||||||
return _widget_template_cache
|
|
||||||
|
|
||||||
|
|
||||||
def _get_logo_bytes() -> bytes:
|
|
||||||
"""Load and cache the logo image."""
|
|
||||||
global _logo_bytes_cache
|
|
||||||
if _logo_bytes_cache is None:
|
|
||||||
logo_path = paths.get_resource_path('templates/embed/logo.webp')
|
|
||||||
with open(logo_path, 'rb') as f:
|
|
||||||
_logo_bytes_cache = f.read()
|
|
||||||
return _logo_bytes_cache
|
|
||||||
|
|
||||||
|
|
||||||
@group.group_class('embed', '/api/v1/embed')
|
|
||||||
class EmbedRouterGroup(group.RouterGroup):
|
|
||||||
# -- helpers -------------------------------------------------------------
|
|
||||||
|
|
||||||
def _resolve_bot(self, bot_uuid: str):
|
|
||||||
"""Resolve *bot_uuid* to ``(runtime_bot, pipeline_uuid)``.
|
|
||||||
|
|
||||||
Returns ``(None, None)`` when the bot does not exist, is not a
|
|
||||||
``web_page_bot``, is disabled, or has no pipeline bound.
|
|
||||||
"""
|
|
||||||
for bot in self.ap.platform_mgr.bots:
|
|
||||||
if (
|
|
||||||
bot.bot_entity.uuid == bot_uuid
|
|
||||||
and bot.bot_entity.adapter == 'web_page_bot'
|
|
||||||
and bot.bot_entity.enable
|
|
||||||
and bot.bot_entity.use_pipeline_uuid
|
|
||||||
):
|
|
||||||
return bot, bot.bot_entity.use_pipeline_uuid
|
|
||||||
return None, None
|
|
||||||
|
|
||||||
def _get_bot_config(self, bot_uuid: str) -> dict:
|
|
||||||
for bot in self.ap.platform_mgr.bots:
|
|
||||||
if bot.bot_entity.uuid == bot_uuid and bot.bot_entity.adapter == 'web_page_bot':
|
|
||||||
return bot.bot_entity.adapter_config
|
|
||||||
return {}
|
|
||||||
|
|
||||||
async def _verify_session_token(self, request, bot_uuid: str) -> bool:
|
|
||||||
config = self._get_bot_config(bot_uuid)
|
|
||||||
secret = config.get('turnstile_secret_key', '')
|
|
||||||
if not secret:
|
|
||||||
return True
|
|
||||||
auth_header = request.headers.get('Authorization', '')
|
|
||||||
if not auth_header.startswith('Bearer '):
|
|
||||||
return False
|
|
||||||
token = auth_header[7:]
|
|
||||||
try:
|
|
||||||
ts_str, mac = token.split('.', 1)
|
|
||||||
ts = float(ts_str)
|
|
||||||
if time.time() - ts > 86400:
|
|
||||||
return False
|
|
||||||
expected_mac = hmac.new(secret.encode(), f'{ts_str}'.encode(), hashlib.sha256).hexdigest()
|
|
||||||
return hmac.compare_digest(mac, expected_mac)
|
|
||||||
except Exception:
|
|
||||||
return False
|
|
||||||
|
|
||||||
# -- routes --------------------------------------------------------------
|
|
||||||
|
|
||||||
async def initialize(self) -> None:
|
|
||||||
@self.route('/<bot_uuid>/turnstile/verify', methods=['POST'], auth_type=group.AuthType.NONE)
|
|
||||||
async def verify_turnstile(bot_uuid: str) -> str:
|
|
||||||
if not _is_valid_uuid(bot_uuid):
|
|
||||||
return self.http_status(400, -1, 'Invalid bot_uuid format')
|
|
||||||
runtime_bot, pipeline_uuid = self._resolve_bot(bot_uuid)
|
|
||||||
if runtime_bot is None:
|
|
||||||
return self.http_status(404, -1, 'Bot not found or not available')
|
|
||||||
try:
|
|
||||||
data = await quart.request.get_json()
|
|
||||||
token = data.get('token')
|
|
||||||
if not token:
|
|
||||||
return self.http_status(400, -1, 'Token is required')
|
|
||||||
|
|
||||||
config = self._get_bot_config(bot_uuid)
|
|
||||||
secret = config.get('turnstile_secret_key', '')
|
|
||||||
if not secret:
|
|
||||||
ts = time.time()
|
|
||||||
return self.success(data={'token': f'{ts}.dummy'})
|
|
||||||
|
|
||||||
async with httpx.AsyncClient() as client:
|
|
||||||
resp = await client.post(
|
|
||||||
'https://challenges.cloudflare.com/turnstile/v0/siteverify',
|
|
||||||
data={'secret': secret, 'response': token},
|
|
||||||
)
|
|
||||||
result = resp.json()
|
|
||||||
|
|
||||||
if not result.get('success'):
|
|
||||||
return self.http_status(403, -1, 'Turnstile verification failed')
|
|
||||||
|
|
||||||
ts = time.time()
|
|
||||||
mac = hmac.new(secret.encode(), f'{ts}'.encode(), hashlib.sha256).hexdigest()
|
|
||||||
session_token = f'{ts}.{mac}'
|
|
||||||
|
|
||||||
return self.success(data={'token': session_token})
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f'Turnstile verify failed: {e}', exc_info=True)
|
|
||||||
return self.http_status(500, -1, 'Internal server error')
|
|
||||||
|
|
||||||
@self.route('/<bot_uuid>/widget.js', methods=['GET'], auth_type=group.AuthType.NONE)
|
|
||||||
async def serve_widget(bot_uuid: str) -> quart.Response:
|
|
||||||
"""Serve the embed widget JavaScript with injected configuration."""
|
|
||||||
if not _is_valid_uuid(bot_uuid):
|
|
||||||
return self.http_status(400, -1, 'Invalid bot_uuid format')
|
|
||||||
runtime_bot, pipeline_uuid = self._resolve_bot(bot_uuid)
|
|
||||||
if runtime_bot is None:
|
|
||||||
return quart.Response(
|
|
||||||
'// Bot not found or not available', status=404, content_type='application/javascript'
|
|
||||||
)
|
|
||||||
try:
|
|
||||||
template = _get_widget_template()
|
|
||||||
except FileNotFoundError:
|
|
||||||
return quart.Response('// Widget template not found', status=404, content_type='application/javascript')
|
|
||||||
|
|
||||||
base_url = quart.request.host_url.rstrip('/')
|
|
||||||
webhook_prefix = self.ap.instance_config.data.get('api', {}).get('webhook_prefix', '')
|
|
||||||
if webhook_prefix:
|
|
||||||
base_url = webhook_prefix.rstrip('/')
|
|
||||||
|
|
||||||
if not re.match(r'^https?://[a-zA-Z0-9._:/-]+$', base_url):
|
|
||||||
base_url = quart.request.host_url.rstrip('/')
|
|
||||||
|
|
||||||
config = self._get_bot_config(bot_uuid)
|
|
||||||
site_key = config.get('turnstile_site_key', '')
|
|
||||||
locale = config.get('language', 'en_US') or 'en_US'
|
|
||||||
bubble_icon = config.get('bubble_icon', 'logo') or 'logo'
|
|
||||||
widget_js = template.replace('__LANGBOT_TURNSTILE_SITE_KEY__', site_key)
|
|
||||||
widget_js = widget_js.replace('__LANGBOT_BOT_UUID__', bot_uuid)
|
|
||||||
widget_js = widget_js.replace('__LANGBOT_BASE_URL__', base_url)
|
|
||||||
widget_js = widget_js.replace('__LANGBOT_LOCALE__', locale)
|
|
||||||
widget_js = widget_js.replace('__LANGBOT_BUBBLE_ICON__', bubble_icon)
|
|
||||||
|
|
||||||
response = quart.Response(widget_js, content_type='application/javascript; charset=utf-8')
|
|
||||||
response.headers['Cache-Control'] = 'public, max-age=300'
|
|
||||||
return response
|
|
||||||
|
|
||||||
@self.route('/logo', methods=['GET'], auth_type=group.AuthType.NONE)
|
|
||||||
async def serve_logo() -> quart.Response:
|
|
||||||
"""Serve the LangBot logo for the embed widget."""
|
|
||||||
try:
|
|
||||||
logo_data = _get_logo_bytes()
|
|
||||||
except FileNotFoundError:
|
|
||||||
return quart.Response('', status=404)
|
|
||||||
|
|
||||||
response = quart.Response(logo_data, content_type='image/webp')
|
|
||||||
response.headers['Cache-Control'] = 'public, max-age=86400'
|
|
||||||
return response
|
|
||||||
|
|
||||||
@self.route('/<bot_uuid>/messages/<session_type>', methods=['GET'], auth_type=group.AuthType.NONE)
|
|
||||||
async def get_embed_messages(bot_uuid: str, session_type: str) -> str:
|
|
||||||
if not _is_valid_uuid(bot_uuid):
|
|
||||||
return self.http_status(400, -1, 'Invalid bot_uuid format')
|
|
||||||
runtime_bot, pipeline_uuid = self._resolve_bot(bot_uuid)
|
|
||||||
if runtime_bot is None:
|
|
||||||
return self.http_status(404, -1, 'Bot not found or not available')
|
|
||||||
if not await self._verify_session_token(quart.request, bot_uuid):
|
|
||||||
return self.http_status(403, -1, 'Unauthorized or session expired')
|
|
||||||
try:
|
|
||||||
if session_type not in ['person', 'group']:
|
|
||||||
return self.http_status(400, -1, 'session_type must be person or group')
|
|
||||||
|
|
||||||
websocket_adapter = self.ap.platform_mgr.websocket_proxy_bot.adapter
|
|
||||||
if not websocket_adapter:
|
|
||||||
return self.http_status(404, -1, 'WebSocket adapter not found')
|
|
||||||
|
|
||||||
messages = websocket_adapter.get_websocket_messages(pipeline_uuid, session_type)
|
|
||||||
return self.success(data={'messages': messages})
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f'Failed to get embed messages: {e}', exc_info=True)
|
|
||||||
return self.http_status(500, -1, 'Internal server error')
|
|
||||||
|
|
||||||
@self.route('/<bot_uuid>/reset/<session_type>', methods=['POST'], auth_type=group.AuthType.NONE)
|
|
||||||
async def reset_embed_session(bot_uuid: str, session_type: str) -> str:
|
|
||||||
if not _is_valid_uuid(bot_uuid):
|
|
||||||
return self.http_status(400, -1, 'Invalid bot_uuid format')
|
|
||||||
runtime_bot, pipeline_uuid = self._resolve_bot(bot_uuid)
|
|
||||||
if runtime_bot is None:
|
|
||||||
return self.http_status(404, -1, 'Bot not found or not available')
|
|
||||||
if not await self._verify_session_token(quart.request, bot_uuid):
|
|
||||||
return self.http_status(403, -1, 'Unauthorized or session expired')
|
|
||||||
try:
|
|
||||||
if session_type not in ['person', 'group']:
|
|
||||||
return self.http_status(400, -1, 'session_type must be person or group')
|
|
||||||
|
|
||||||
websocket_adapter = self.ap.platform_mgr.websocket_proxy_bot.adapter
|
|
||||||
if not websocket_adapter:
|
|
||||||
return self.http_status(404, -1, 'WebSocket adapter not found')
|
|
||||||
|
|
||||||
websocket_adapter.reset_session(pipeline_uuid, session_type)
|
|
||||||
return self.success(data={'message': 'Session reset successfully'})
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f'Failed to reset embed session: {e}', exc_info=True)
|
|
||||||
return self.http_status(500, -1, 'Internal server error')
|
|
||||||
|
|
||||||
@self.route('/<bot_uuid>/feedback', methods=['POST'], auth_type=group.AuthType.NONE)
|
|
||||||
async def submit_feedback(bot_uuid: str) -> str:
|
|
||||||
if not _is_valid_uuid(bot_uuid):
|
|
||||||
return self.http_status(400, -1, 'Invalid bot_uuid format')
|
|
||||||
runtime_bot, pipeline_uuid = self._resolve_bot(bot_uuid)
|
|
||||||
if runtime_bot is None:
|
|
||||||
return self.http_status(404, -1, 'Bot not found or not available')
|
|
||||||
if not await self._verify_session_token(quart.request, bot_uuid):
|
|
||||||
return self.http_status(403, -1, 'Unauthorized or session expired')
|
|
||||||
try:
|
|
||||||
data = await quart.request.get_json()
|
|
||||||
message_id = data.get('message_id', '')
|
|
||||||
feedback_type = data.get('feedback_type')
|
|
||||||
|
|
||||||
if feedback_type not in (1, 2, 3):
|
|
||||||
return self.http_status(400, -1, 'feedback_type must be 1 (like), 2 (dislike), or 3 (cancel)')
|
|
||||||
|
|
||||||
feedback_id = f'embed_{uuid.uuid4().hex[:12]}'
|
|
||||||
|
|
||||||
await self.ap.monitoring_service.record_feedback(
|
|
||||||
feedback_id=feedback_id,
|
|
||||||
feedback_type=feedback_type,
|
|
||||||
bot_id=runtime_bot.bot_entity.uuid,
|
|
||||||
bot_name=runtime_bot.bot_entity.name or bot_uuid,
|
|
||||||
pipeline_id=pipeline_uuid,
|
|
||||||
message_id=str(message_id),
|
|
||||||
platform='web_page_bot',
|
|
||||||
)
|
|
||||||
|
|
||||||
return self.success(data={'feedback_id': feedback_id})
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f'Failed to record feedback: {e}', exc_info=True)
|
|
||||||
return self.http_status(500, -1, 'Internal server error')
|
|
||||||
|
|
||||||
# -- Embed WebSocket endpoint ----------------------------------------
|
|
||||||
|
|
||||||
@self.quart_app.websocket(self.path + '/<bot_uuid>/ws/connect')
|
|
||||||
async def embed_websocket_connect(bot_uuid: str):
|
|
||||||
"""WebSocket connection for embed widget, keyed by bot_uuid."""
|
|
||||||
if not _is_valid_uuid(bot_uuid):
|
|
||||||
await quart.websocket.send(json.dumps({'type': 'error', 'message': 'Invalid bot_uuid format'}))
|
|
||||||
return
|
|
||||||
|
|
||||||
runtime_bot, pipeline_uuid = self._resolve_bot(bot_uuid)
|
|
||||||
if runtime_bot is None:
|
|
||||||
await quart.websocket.send(json.dumps({'type': 'error', 'message': 'Bot not found or not available'}))
|
|
||||||
return
|
|
||||||
|
|
||||||
session_type = quart.websocket.args.get('session_type', 'person')
|
|
||||||
if session_type not in ['person', 'group']:
|
|
||||||
await quart.websocket.send(
|
|
||||||
json.dumps({'type': 'error', 'message': 'session_type must be person or group'})
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
websocket_adapter = self.ap.platform_mgr.websocket_proxy_bot.adapter
|
|
||||||
if not websocket_adapter:
|
|
||||||
await quart.websocket.send(json.dumps({'type': 'error', 'message': 'WebSocket adapter not found'}))
|
|
||||||
return
|
|
||||||
|
|
||||||
try:
|
|
||||||
connection = await ws_connection_manager.add_connection(
|
|
||||||
websocket=quart.websocket._get_current_object(),
|
|
||||||
pipeline_uuid=pipeline_uuid,
|
|
||||||
session_type=session_type,
|
|
||||||
metadata={'user_agent': quart.websocket.headers.get('User-Agent', '')},
|
|
||||||
)
|
|
||||||
|
|
||||||
await quart.websocket.send(
|
|
||||||
json.dumps(
|
|
||||||
{
|
|
||||||
'type': 'connected',
|
|
||||||
'connection_id': connection.connection_id,
|
|
||||||
'bot_uuid': bot_uuid,
|
|
||||||
'session_type': session_type,
|
|
||||||
'timestamp': connection.created_at.isoformat(),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
logger.debug(
|
|
||||||
f'Embed WebSocket connected: {connection.connection_id} '
|
|
||||||
f'(bot={bot_uuid}, pipeline={pipeline_uuid}, session_type={session_type})'
|
|
||||||
)
|
|
||||||
|
|
||||||
receive_task = asyncio.create_task(self._handle_receive(connection, websocket_adapter, runtime_bot))
|
|
||||||
send_task = asyncio.create_task(self._handle_send(connection))
|
|
||||||
|
|
||||||
try:
|
|
||||||
await asyncio.gather(receive_task, send_task)
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f'Embed WebSocket task error: {e}')
|
|
||||||
finally:
|
|
||||||
await ws_connection_manager.remove_connection(connection.connection_id)
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f'Embed WebSocket connection error: {e}', exc_info=True)
|
|
||||||
try:
|
|
||||||
await quart.websocket.send(json.dumps({'type': 'error', 'message': 'Internal server error'}))
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# -- WebSocket receive/send helpers --------------------------------------
|
|
||||||
|
|
||||||
async def _handle_receive(self, connection, websocket_adapter, owner_bot):
|
|
||||||
try:
|
|
||||||
while connection.is_active:
|
|
||||||
message = await quart.websocket.receive()
|
|
||||||
await ws_connection_manager.update_activity(connection.connection_id)
|
|
||||||
|
|
||||||
try:
|
|
||||||
data = json.loads(message)
|
|
||||||
message_type = data.get('type', 'message')
|
|
||||||
|
|
||||||
if message_type == 'ping':
|
|
||||||
await connection.send_queue.put(
|
|
||||||
{'type': 'pong', 'timestamp': datetime.datetime.now().isoformat()}
|
|
||||||
)
|
|
||||||
elif message_type == 'message':
|
|
||||||
await websocket_adapter.handle_websocket_message(connection, data, owner_bot=owner_bot)
|
|
||||||
elif message_type == 'disconnect':
|
|
||||||
break
|
|
||||||
|
|
||||||
except json.JSONDecodeError:
|
|
||||||
await connection.send_queue.put({'type': 'error', 'message': 'Invalid JSON format'})
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f'Embed receive error: {e}', exc_info=True)
|
|
||||||
finally:
|
|
||||||
connection.is_active = False
|
|
||||||
|
|
||||||
async def _handle_send(self, connection):
|
|
||||||
try:
|
|
||||||
while connection.is_active:
|
|
||||||
try:
|
|
||||||
message = await asyncio.wait_for(connection.send_queue.get(), timeout=1.0)
|
|
||||||
await quart.websocket.send(json.dumps(message))
|
|
||||||
except asyncio.TimeoutError:
|
|
||||||
continue
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f'Embed send error: {e}', exc_info=True)
|
|
||||||
finally:
|
|
||||||
connection.is_active = False
|
|
||||||
@@ -43,9 +43,6 @@ class WebSocketChatRouterGroup(group.RouterGroup):
|
|||||||
await quart.websocket.send(json.dumps({'type': 'error', 'message': 'WebSocket adapter not found'}))
|
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':
|
||||||
# 客户端主动断开
|
# 客户端主动断开
|
||||||
|
|||||||
@@ -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={})
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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()
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -1,309 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import datetime
|
|
||||||
import os
|
|
||||||
import re
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
import sqlalchemy
|
|
||||||
|
|
||||||
from ....core import app
|
|
||||||
from ....entity.persistence import bstorage as persistence_bstorage
|
|
||||||
from ....entity.persistence import monitoring as persistence_monitoring
|
|
||||||
|
|
||||||
|
|
||||||
LOG_FILE_PATTERN = re.compile(r'^langbot-(\d{4}-\d{2}-\d{2})\.log(?:\.\d+)?$')
|
|
||||||
DEFAULT_UPLOAD_FILE_RETENTION_DAYS = 7
|
|
||||||
DEFAULT_LOG_RETENTION_DAYS = 3
|
|
||||||
|
|
||||||
|
|
||||||
class MaintenanceService:
|
|
||||||
"""Storage maintenance and diagnostics."""
|
|
||||||
|
|
||||||
ap: app.Application
|
|
||||||
|
|
||||||
def __init__(self, ap: app.Application) -> None:
|
|
||||||
self.ap = ap
|
|
||||||
|
|
||||||
async def cleanup_expired_files(self) -> dict[str, int]:
|
|
||||||
cleanup_cfg = self.ap.instance_config.data.get('storage', {}).get('cleanup', {})
|
|
||||||
upload_retention_days = self._positive_int(
|
|
||||||
cleanup_cfg.get('uploaded_file_retention_days'),
|
|
||||||
DEFAULT_UPLOAD_FILE_RETENTION_DAYS,
|
|
||||||
'storage.cleanup.uploaded_file_retention_days',
|
|
||||||
)
|
|
||||||
log_retention_days = self._positive_int(
|
|
||||||
cleanup_cfg.get('log_retention_days'),
|
|
||||||
DEFAULT_LOG_RETENTION_DAYS,
|
|
||||||
'storage.cleanup.log_retention_days',
|
|
||||||
)
|
|
||||||
|
|
||||||
return {
|
|
||||||
'uploaded_files': await self._cleanup_expired_uploaded_files(upload_retention_days),
|
|
||||||
'log_files': self._cleanup_expired_log_files(log_retention_days),
|
|
||||||
}
|
|
||||||
|
|
||||||
async def get_storage_analysis(self) -> dict[str, Any]:
|
|
||||||
cleanup_cfg = self.ap.instance_config.data.get('storage', {}).get('cleanup', {})
|
|
||||||
upload_retention_days = self._positive_int(
|
|
||||||
cleanup_cfg.get('uploaded_file_retention_days'),
|
|
||||||
DEFAULT_UPLOAD_FILE_RETENTION_DAYS,
|
|
||||||
'storage.cleanup.uploaded_file_retention_days',
|
|
||||||
)
|
|
||||||
log_retention_days = self._positive_int(
|
|
||||||
cleanup_cfg.get('log_retention_days'),
|
|
||||||
DEFAULT_LOG_RETENTION_DAYS,
|
|
||||||
'storage.cleanup.log_retention_days',
|
|
||||||
)
|
|
||||||
|
|
||||||
database_cfg = self.ap.instance_config.data.get('database', {})
|
|
||||||
database_type = database_cfg.get('use', 'sqlite')
|
|
||||||
database_path = (
|
|
||||||
Path(database_cfg.get('sqlite', {}).get('path', 'data/langbot.db')) if database_type == 'sqlite' else None
|
|
||||||
)
|
|
||||||
roots: list[tuple[str, Path | None]] = [
|
|
||||||
('database', database_path),
|
|
||||||
('logs', Path('data/logs')),
|
|
||||||
('storage', Path('data/storage')),
|
|
||||||
('vector_store', Path('data/chroma')),
|
|
||||||
('plugins', Path('data/plugins')),
|
|
||||||
('mcp', Path('data/mcp')),
|
|
||||||
('temp', Path('data/temp')),
|
|
||||||
]
|
|
||||||
|
|
||||||
sections = []
|
|
||||||
for key, path in roots:
|
|
||||||
sections.append(
|
|
||||||
{
|
|
||||||
'key': key,
|
|
||||||
'path': str(path) if path else '',
|
|
||||||
'exists': path.exists() if path else False,
|
|
||||||
'size_bytes': self._path_size(path) if path else 0,
|
|
||||||
'file_count': self._file_count(path) if path else 0,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
monitoring_counts = await self._monitoring_counts()
|
|
||||||
binary_storage = await self._binary_storage_stats()
|
|
||||||
upload_candidates = await self._expired_uploaded_candidates(upload_retention_days)
|
|
||||||
log_candidates = self._expired_log_candidates(log_retention_days)
|
|
||||||
|
|
||||||
return {
|
|
||||||
'generated_at': datetime.datetime.now(datetime.timezone.utc).isoformat(),
|
|
||||||
'cleanup_policy': {
|
|
||||||
'uploaded_file_retention_days': upload_retention_days,
|
|
||||||
'log_retention_days': log_retention_days,
|
|
||||||
},
|
|
||||||
'sections': sections,
|
|
||||||
'database': {
|
|
||||||
'type': database_type,
|
|
||||||
'monitoring_counts': monitoring_counts,
|
|
||||||
'binary_storage': binary_storage,
|
|
||||||
},
|
|
||||||
'cleanup_candidates': {
|
|
||||||
'uploaded_files': upload_candidates,
|
|
||||||
'log_files': log_candidates,
|
|
||||||
},
|
|
||||||
'tasks': self.ap.task_mgr.get_stats() if self.ap.task_mgr else {},
|
|
||||||
}
|
|
||||||
|
|
||||||
async def _cleanup_expired_uploaded_files(self, retention_days: int) -> int:
|
|
||||||
provider = self.ap.storage_mgr.storage_provider
|
|
||||||
provider_name = provider.__class__.__name__
|
|
||||||
if provider_name == 'LocalStorageProvider':
|
|
||||||
candidates = self._expired_local_upload_candidates(retention_days, include_paths=True)
|
|
||||||
deleted = 0
|
|
||||||
for item in candidates:
|
|
||||||
try:
|
|
||||||
os.remove(item['path'])
|
|
||||||
deleted += 1
|
|
||||||
except FileNotFoundError:
|
|
||||||
pass
|
|
||||||
except Exception as e:
|
|
||||||
self.ap.logger.warning(f'Failed to delete expired uploaded file {item["key"]}: {e}')
|
|
||||||
return deleted
|
|
||||||
|
|
||||||
if provider_name == 'S3StorageProvider':
|
|
||||||
return await self._cleanup_expired_s3_uploaded_files(retention_days)
|
|
||||||
|
|
||||||
return 0
|
|
||||||
|
|
||||||
async def _expired_uploaded_candidates(self, retention_days: int) -> list[dict[str, Any]]:
|
|
||||||
provider_name = self.ap.storage_mgr.storage_provider.__class__.__name__
|
|
||||||
if provider_name == 'LocalStorageProvider':
|
|
||||||
return self._expired_local_upload_candidates(retention_days)
|
|
||||||
if provider_name == 'S3StorageProvider':
|
|
||||||
return await self._expired_s3_upload_candidates(retention_days)
|
|
||||||
return []
|
|
||||||
|
|
||||||
async def _cleanup_expired_s3_uploaded_files(self, retention_days: int) -> int:
|
|
||||||
provider = self.ap.storage_mgr.storage_provider
|
|
||||||
candidates = await self._expired_s3_upload_candidates(retention_days)
|
|
||||||
deleted = 0
|
|
||||||
for item in candidates:
|
|
||||||
await provider.delete(item['key'])
|
|
||||||
deleted += 1
|
|
||||||
return deleted
|
|
||||||
|
|
||||||
async def _expired_s3_upload_candidates(self, retention_days: int) -> list[dict[str, Any]]:
|
|
||||||
provider = self.ap.storage_mgr.storage_provider
|
|
||||||
cutoff = datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(days=retention_days)
|
|
||||||
candidates = []
|
|
||||||
paginator = provider.s3_client.get_paginator('list_objects_v2')
|
|
||||||
|
|
||||||
for page in paginator.paginate(Bucket=provider.bucket_name):
|
|
||||||
for obj in page.get('Contents', []):
|
|
||||||
key = obj.get('Key', '')
|
|
||||||
last_modified = obj.get('LastModified')
|
|
||||||
if not self._is_uploaded_file_key(key):
|
|
||||||
continue
|
|
||||||
if last_modified and last_modified < cutoff:
|
|
||||||
candidates.append(
|
|
||||||
{
|
|
||||||
'key': key,
|
|
||||||
'size_bytes': obj.get('Size', 0),
|
|
||||||
'modified_at': last_modified.isoformat(),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
return candidates
|
|
||||||
|
|
||||||
def _cleanup_expired_log_files(self, retention_days: int) -> int:
|
|
||||||
deleted = 0
|
|
||||||
for item in self._expired_log_candidates(retention_days, include_paths=True):
|
|
||||||
try:
|
|
||||||
os.remove(item['path'])
|
|
||||||
deleted += 1
|
|
||||||
except FileNotFoundError:
|
|
||||||
pass
|
|
||||||
except Exception as e:
|
|
||||||
self.ap.logger.warning(f'Failed to delete expired log file {item["name"]}: {e}')
|
|
||||||
return deleted
|
|
||||||
|
|
||||||
def _expired_local_upload_candidates(
|
|
||||||
self, retention_days: int, include_paths: bool = False
|
|
||||||
) -> list[dict[str, Any]]:
|
|
||||||
storage_root = Path('data/storage')
|
|
||||||
if not storage_root.exists():
|
|
||||||
return []
|
|
||||||
|
|
||||||
cutoff = datetime.datetime.now().timestamp() - retention_days * 86400
|
|
||||||
candidates = []
|
|
||||||
for entry in storage_root.iterdir():
|
|
||||||
if not entry.is_file() or not self._is_uploaded_file_key(entry.name):
|
|
||||||
continue
|
|
||||||
stat = entry.stat()
|
|
||||||
if stat.st_mtime >= cutoff:
|
|
||||||
continue
|
|
||||||
item = {
|
|
||||||
'key': entry.name,
|
|
||||||
'size_bytes': stat.st_size,
|
|
||||||
'modified_at': datetime.datetime.fromtimestamp(stat.st_mtime, datetime.timezone.utc).isoformat(),
|
|
||||||
}
|
|
||||||
if include_paths:
|
|
||||||
item['path'] = str(entry)
|
|
||||||
candidates.append(item)
|
|
||||||
return candidates
|
|
||||||
|
|
||||||
def _expired_log_candidates(self, retention_days: int, include_paths: bool = False) -> list[dict[str, Any]]:
|
|
||||||
log_root = Path('data/logs')
|
|
||||||
if not log_root.exists():
|
|
||||||
return []
|
|
||||||
|
|
||||||
cutoff_date = datetime.date.today() - datetime.timedelta(days=retention_days - 1)
|
|
||||||
candidates = []
|
|
||||||
for entry in log_root.iterdir():
|
|
||||||
if not entry.is_file():
|
|
||||||
continue
|
|
||||||
match = LOG_FILE_PATTERN.match(entry.name)
|
|
||||||
if not match:
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
file_date = datetime.date.fromisoformat(match.group(1))
|
|
||||||
except ValueError:
|
|
||||||
continue
|
|
||||||
if file_date >= cutoff_date:
|
|
||||||
continue
|
|
||||||
stat = entry.stat()
|
|
||||||
item = {
|
|
||||||
'name': entry.name,
|
|
||||||
'date': file_date.isoformat(),
|
|
||||||
'size_bytes': stat.st_size,
|
|
||||||
}
|
|
||||||
if include_paths:
|
|
||||||
item['path'] = str(entry)
|
|
||||||
candidates.append(item)
|
|
||||||
return candidates
|
|
||||||
|
|
||||||
def _is_uploaded_file_key(self, key: str) -> bool:
|
|
||||||
return '/' not in key and not key.startswith('plugin_config_')
|
|
||||||
|
|
||||||
async def _monitoring_counts(self) -> dict[str, int]:
|
|
||||||
tables = {
|
|
||||||
'messages': persistence_monitoring.MonitoringMessage.id,
|
|
||||||
'llm_calls': persistence_monitoring.MonitoringLLMCall.id,
|
|
||||||
'embedding_calls': persistence_monitoring.MonitoringEmbeddingCall.id,
|
|
||||||
'errors': persistence_monitoring.MonitoringError.id,
|
|
||||||
'sessions': persistence_monitoring.MonitoringSession.session_id,
|
|
||||||
'feedback': persistence_monitoring.MonitoringFeedback.id,
|
|
||||||
}
|
|
||||||
counts: dict[str, int] = {}
|
|
||||||
for key, column in tables.items():
|
|
||||||
result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(sqlalchemy.func.count(column)))
|
|
||||||
counts[key] = result.scalar() or 0
|
|
||||||
return counts
|
|
||||||
|
|
||||||
async def _binary_storage_stats(self) -> dict[str, Any]:
|
|
||||||
count_result = await self.ap.persistence_mgr.execute_async(
|
|
||||||
sqlalchemy.select(sqlalchemy.func.count(persistence_bstorage.BinaryStorage.unique_key))
|
|
||||||
)
|
|
||||||
size_bytes = None
|
|
||||||
try:
|
|
||||||
size_result = await self.ap.persistence_mgr.execute_async(
|
|
||||||
sqlalchemy.select(sqlalchemy.func.sum(sqlalchemy.func.length(persistence_bstorage.BinaryStorage.value)))
|
|
||||||
)
|
|
||||||
size_bytes = size_result.scalar() or 0
|
|
||||||
except Exception as e:
|
|
||||||
self.ap.logger.warning(f'Failed to estimate binary storage size: {e}')
|
|
||||||
|
|
||||||
return {
|
|
||||||
'count': count_result.scalar() or 0,
|
|
||||||
'size_bytes': size_bytes,
|
|
||||||
}
|
|
||||||
|
|
||||||
def _path_size(self, path: Path) -> int:
|
|
||||||
if not path.exists():
|
|
||||||
return 0
|
|
||||||
if path.is_file():
|
|
||||||
return path.stat().st_size
|
|
||||||
total = 0
|
|
||||||
for root, _, files in os.walk(path):
|
|
||||||
for file_name in files:
|
|
||||||
file_path = Path(root) / file_name
|
|
||||||
try:
|
|
||||||
total += file_path.stat().st_size
|
|
||||||
except FileNotFoundError:
|
|
||||||
pass
|
|
||||||
return total
|
|
||||||
|
|
||||||
def _file_count(self, path: Path) -> int:
|
|
||||||
if not path.exists():
|
|
||||||
return 0
|
|
||||||
if path.is_file():
|
|
||||||
return 1
|
|
||||||
count = 0
|
|
||||||
for _, _, files in os.walk(path):
|
|
||||||
count += len(files)
|
|
||||||
return count
|
|
||||||
|
|
||||||
def _positive_int(self, value: Any, default: int, name: str) -> int:
|
|
||||||
try:
|
|
||||||
parsed = int(value)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
self.ap.logger.warning(f'Invalid {name}: {value!r}, using {default}')
|
|
||||||
return default
|
|
||||||
if parsed < 1:
|
|
||||||
self.ap.logger.warning(f'Invalid {name}: {value!r}, using {default}')
|
|
||||||
return default
|
|
||||||
return parsed
|
|
||||||
@@ -23,17 +23,6 @@ def _parse_provider_api_keys(provider_dict: dict) -> dict:
|
|||||||
return provider_dict
|
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.',
|
|
||||||
],
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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]
|
|
||||||
|
|||||||
@@ -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(),
|
|
||||||
)
|
|
||||||
|
|||||||
@@ -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')
|
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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: ""
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 3.4 KiB |
@@ -1,693 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import typing
|
|
||||||
import asyncio
|
|
||||||
import traceback
|
|
||||||
import base64
|
|
||||||
import json
|
|
||||||
|
|
||||||
import nio
|
|
||||||
|
|
||||||
from langbot.pkg.utils import httpclient
|
|
||||||
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
|
|
||||||
import langbot_plugin.api.entities.builtin.platform.message as platform_message
|
|
||||||
import langbot_plugin.api.entities.builtin.platform.events as platform_events
|
|
||||||
import langbot_plugin.api.entities.builtin.platform.entities as platform_entities
|
|
||||||
import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_platform_logger
|
|
||||||
|
|
||||||
|
|
||||||
class MatrixMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
|
|
||||||
@staticmethod
|
|
||||||
async def yiri2target(message_chain: platform_message.MessageChain, client: nio.AsyncClient) -> list[dict]:
|
|
||||||
components = []
|
|
||||||
for component in message_chain:
|
|
||||||
if isinstance(component, platform_message.Plain):
|
|
||||||
components.append({'type': 'text', 'text': component.text})
|
|
||||||
elif isinstance(component, platform_message.Image):
|
|
||||||
image_bytes = None
|
|
||||||
if component.base64:
|
|
||||||
b64_data = component.base64
|
|
||||||
if ';base64,' in b64_data:
|
|
||||||
b64_data = b64_data.split(';base64,', 1)[1]
|
|
||||||
image_bytes = base64.b64decode(b64_data)
|
|
||||||
elif component.url:
|
|
||||||
session = httpclient.get_session()
|
|
||||||
async with session.get(component.url) as response:
|
|
||||||
image_bytes = await response.read()
|
|
||||||
elif component.path:
|
|
||||||
with open(component.path, 'rb') as f:
|
|
||||||
image_bytes = f.read()
|
|
||||||
if image_bytes:
|
|
||||||
resp = await client.upload(image_bytes, content_type='image/png')
|
|
||||||
if isinstance(resp, nio.UploadResponse):
|
|
||||||
components.append({'type': 'image', 'mxc_url': resp.content_uri})
|
|
||||||
elif isinstance(component, platform_message.File):
|
|
||||||
file_bytes = None
|
|
||||||
if component.base64:
|
|
||||||
b64_data = component.base64
|
|
||||||
if ';base64,' in b64_data:
|
|
||||||
b64_data = b64_data.split(';base64,', 1)[1]
|
|
||||||
file_bytes = base64.b64decode(b64_data)
|
|
||||||
elif component.url:
|
|
||||||
session = httpclient.get_session()
|
|
||||||
async with session.get(component.url) as response:
|
|
||||||
file_bytes = await response.read()
|
|
||||||
elif component.path:
|
|
||||||
with open(component.path, 'rb') as f:
|
|
||||||
file_bytes = f.read()
|
|
||||||
if file_bytes:
|
|
||||||
file_name = getattr(component, 'name', None) or 'file'
|
|
||||||
resp = await client.upload(file_bytes, content_type='application/octet-stream', filename=file_name)
|
|
||||||
if isinstance(resp, nio.UploadResponse):
|
|
||||||
components.append(
|
|
||||||
{
|
|
||||||
'type': 'file',
|
|
||||||
'mxc_url': resp.content_uri,
|
|
||||||
'filename': file_name,
|
|
||||||
'size': len(file_bytes),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
elif isinstance(component, platform_message.Forward):
|
|
||||||
for node in component.node_list:
|
|
||||||
components.extend(await MatrixMessageConverter.yiri2target(node.message_chain, client))
|
|
||||||
return components
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
async def target2yiri(event: nio.RoomMessageText | nio.RoomMessageImage, client: nio.AsyncClient, bot_user_id: str):
|
|
||||||
message_components = []
|
|
||||||
|
|
||||||
if isinstance(event, nio.RoomMessageText):
|
|
||||||
text = event.body
|
|
||||||
if bot_user_id and bot_user_id in text:
|
|
||||||
message_components.append(platform_message.At(target=bot_user_id))
|
|
||||||
text = text.replace(bot_user_id, '').strip()
|
|
||||||
message_components.append(platform_message.Plain(text=text))
|
|
||||||
|
|
||||||
elif isinstance(event, nio.RoomMessageImage):
|
|
||||||
mxc_url = event.url
|
|
||||||
if mxc_url:
|
|
||||||
resp = await client.download(mxc_url)
|
|
||||||
if isinstance(resp, nio.DownloadResponse):
|
|
||||||
b64 = base64.b64encode(resp.body).decode('utf-8')
|
|
||||||
content_type = resp.content_type or 'image/png'
|
|
||||||
message_components.append(platform_message.Image(base64=f'data:{content_type};base64,{b64}'))
|
|
||||||
if event.body:
|
|
||||||
message_components.append(platform_message.Plain(text=event.body))
|
|
||||||
|
|
||||||
return platform_message.MessageChain(message_components)
|
|
||||||
|
|
||||||
|
|
||||||
class MatrixEventConverter(abstract_platform_adapter.AbstractEventConverter):
|
|
||||||
@staticmethod
|
|
||||||
async def yiri2target(event: platform_events.MessageEvent):
|
|
||||||
return event.source_platform_object
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
async def target2yiri(
|
|
||||||
event: nio.RoomMessageText | nio.RoomMessageImage,
|
|
||||||
room: nio.MatrixRoom,
|
|
||||||
client: nio.AsyncClient,
|
|
||||||
bot_user_id: str,
|
|
||||||
bridge_user_ids: list[str] | None = None,
|
|
||||||
):
|
|
||||||
lb_message = await MatrixMessageConverter.target2yiri(event, client, bot_user_id)
|
|
||||||
|
|
||||||
# Determine if this is a direct/private chat or a group chat.
|
|
||||||
# Exclude bot itself and bridge bots, count remaining real users.
|
|
||||||
exclude_ids = {bot_user_id}
|
|
||||||
if bridge_user_ids:
|
|
||||||
exclude_ids.update(bridge_user_ids)
|
|
||||||
real_users = [uid for uid in room.users if uid not in exclude_ids]
|
|
||||||
is_direct = len(real_users) <= 1
|
|
||||||
|
|
||||||
if is_direct:
|
|
||||||
return platform_events.FriendMessage(
|
|
||||||
sender=platform_entities.Friend(
|
|
||||||
id=event.sender,
|
|
||||||
nickname=room.user_name(event.sender) or event.sender,
|
|
||||||
remark='',
|
|
||||||
),
|
|
||||||
message_chain=lb_message,
|
|
||||||
time=event.server_timestamp / 1000.0,
|
|
||||||
source_platform_object={'event': event, 'room': room},
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
return platform_events.GroupMessage(
|
|
||||||
sender=platform_entities.GroupMember(
|
|
||||||
id=event.sender,
|
|
||||||
member_name=room.user_name(event.sender) or event.sender,
|
|
||||||
permission=platform_entities.Permission.Member,
|
|
||||||
group=platform_entities.Group(
|
|
||||||
id=room.room_id,
|
|
||||||
name=room.display_name or room.room_id,
|
|
||||||
permission=platform_entities.Permission.Member,
|
|
||||||
),
|
|
||||||
special_title='',
|
|
||||||
),
|
|
||||||
message_chain=lb_message,
|
|
||||||
time=event.server_timestamp / 1000.0,
|
|
||||||
source_platform_object={'event': event, 'room': room},
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class BridgeState:
|
|
||||||
"""Per-bridge runtime state."""
|
|
||||||
|
|
||||||
def __init__(self, user_id: str, login_command: str, logout_command: str, success_keyword: str, check_command: str):
|
|
||||||
self.user_id = user_id
|
|
||||||
self.login_command = login_command
|
|
||||||
self.logout_command = logout_command
|
|
||||||
self.success_keyword = success_keyword
|
|
||||||
self.check_command = check_command or login_command
|
|
||||||
self.logged_in = False
|
|
||||||
self.dm_room_id: str | None = None
|
|
||||||
self.login_task: asyncio.Task | None = None
|
|
||||||
self.check_task: asyncio.Task | None = None
|
|
||||||
self.check_responded = False
|
|
||||||
|
|
||||||
|
|
||||||
class MatrixAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
|
||||||
client: typing.Any = None
|
|
||||||
message_converter: MatrixMessageConverter = MatrixMessageConverter()
|
|
||||||
event_converter: MatrixEventConverter = MatrixEventConverter()
|
|
||||||
config: dict
|
|
||||||
listeners: typing.Dict[typing.Type[platform_events.Event], typing.Callable] = {}
|
|
||||||
_running: bool = False
|
|
||||||
_initial_sync_done: bool = False
|
|
||||||
_bridges: list = []
|
|
||||||
|
|
||||||
def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger):
|
|
||||||
homeserver_url = config.get('homeserver_url', '')
|
|
||||||
access_token = config.get('access_token', '')
|
|
||||||
user_id = config.get('user_id', '')
|
|
||||||
|
|
||||||
if not homeserver_url or not access_token or not user_id:
|
|
||||||
raise ValueError('Matrix 机器人缺少必要配置项 (homeserver_url, user_id, access_token)')
|
|
||||||
|
|
||||||
client = nio.AsyncClient(homeserver_url, user_id)
|
|
||||||
client.access_token = access_token
|
|
||||||
client.user_id = user_id
|
|
||||||
|
|
||||||
super().__init__(
|
|
||||||
config=config,
|
|
||||||
logger=logger,
|
|
||||||
bot_account_id=user_id,
|
|
||||||
client=client,
|
|
||||||
listeners={},
|
|
||||||
)
|
|
||||||
|
|
||||||
# Parse bridges config AFTER super().__init__() to avoid Pydantic resetting _bridges
|
|
||||||
self._bridges = []
|
|
||||||
bridges_raw = config.get('bridges', '')
|
|
||||||
if bridges_raw:
|
|
||||||
if isinstance(bridges_raw, str):
|
|
||||||
try:
|
|
||||||
bridges_list = json.loads(bridges_raw)
|
|
||||||
except (json.JSONDecodeError, TypeError) as e:
|
|
||||||
raise ValueError(f'bridges 配置 JSON 解析失败: {e}\n原始值: {bridges_raw}')
|
|
||||||
else:
|
|
||||||
bridges_list = bridges_raw
|
|
||||||
for b in bridges_list:
|
|
||||||
if isinstance(b, dict) and b.get('user_id', '').strip():
|
|
||||||
self._bridges.append(
|
|
||||||
BridgeState(
|
|
||||||
user_id=b['user_id'].strip(),
|
|
||||||
login_command=b.get('login_command', '').strip(),
|
|
||||||
logout_command=b.get('logout_command', '').strip(),
|
|
||||||
success_keyword=b.get('success_keyword', 'Successfully logged in').strip(),
|
|
||||||
check_command=b.get('check_command', '').strip(),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
# Backward compatibility: old single-bridge config
|
|
||||||
if not self._bridges:
|
|
||||||
old_user_id = config.get('bridge_user_id', '').strip()
|
|
||||||
old_command = config.get('bridge_login_command', '').strip()
|
|
||||||
old_keyword = config.get('bridge_login_success_keyword', 'Successfully logged in').strip()
|
|
||||||
old_check = config.get('bridge_check_command', '').strip()
|
|
||||||
old_logout = config.get('bridge_logout_command', '').strip()
|
|
||||||
if old_user_id:
|
|
||||||
self._bridges.append(
|
|
||||||
BridgeState(
|
|
||||||
user_id=old_user_id,
|
|
||||||
login_command=old_command,
|
|
||||||
logout_command=old_logout,
|
|
||||||
success_keyword=old_keyword,
|
|
||||||
check_command=old_check,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain):
|
|
||||||
components = await self.message_converter.yiri2target(message, self.client)
|
|
||||||
for component in components:
|
|
||||||
await self._send_component(target_id, component)
|
|
||||||
|
|
||||||
async def reply_message(
|
|
||||||
self,
|
|
||||||
message_source: platform_events.MessageEvent,
|
|
||||||
message: platform_message.MessageChain,
|
|
||||||
quote_origin: bool = False,
|
|
||||||
):
|
|
||||||
source_obj = message_source.source_platform_object
|
|
||||||
room_id = source_obj['room'].room_id
|
|
||||||
components = await self.message_converter.yiri2target(message, self.client)
|
|
||||||
|
|
||||||
for component in components:
|
|
||||||
if quote_origin:
|
|
||||||
original_event = source_obj['event']
|
|
||||||
await self._send_component(room_id, component, reply_to=original_event.event_id)
|
|
||||||
else:
|
|
||||||
await self._send_component(room_id, component)
|
|
||||||
|
|
||||||
async def _send_component(self, room_id: str, component: dict, reply_to: str | None = None):
|
|
||||||
content = {}
|
|
||||||
if component['type'] == 'text':
|
|
||||||
content = {
|
|
||||||
'msgtype': 'm.text',
|
|
||||||
'body': component['text'],
|
|
||||||
}
|
|
||||||
elif component['type'] == 'image':
|
|
||||||
content = {
|
|
||||||
'msgtype': 'm.image',
|
|
||||||
'body': 'image.png',
|
|
||||||
'url': component['mxc_url'],
|
|
||||||
}
|
|
||||||
elif component['type'] == 'file':
|
|
||||||
content = {
|
|
||||||
'msgtype': 'm.file',
|
|
||||||
'body': component.get('filename', 'file'),
|
|
||||||
'url': component['mxc_url'],
|
|
||||||
'info': {'size': component.get('size', 0)},
|
|
||||||
}
|
|
||||||
|
|
||||||
if reply_to and content:
|
|
||||||
content['m.relates_to'] = {
|
|
||||||
'm.in_reply_to': {'event_id': reply_to},
|
|
||||||
}
|
|
||||||
|
|
||||||
if content:
|
|
||||||
await self.client.room_send(
|
|
||||||
room_id=room_id,
|
|
||||||
message_type='m.room.message',
|
|
||||||
content=content,
|
|
||||||
)
|
|
||||||
|
|
||||||
def register_listener(
|
|
||||||
self,
|
|
||||||
event_type: typing.Type[platform_events.Event],
|
|
||||||
callback: typing.Callable[
|
|
||||||
[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None
|
|
||||||
],
|
|
||||||
):
|
|
||||||
self.listeners[event_type] = callback
|
|
||||||
|
|
||||||
async def run_async(self):
|
|
||||||
self._running = True
|
|
||||||
await self.logger.info('Matrix adapter starting...')
|
|
||||||
|
|
||||||
# Debug: log bridge parsing result
|
|
||||||
bridges_raw = self.config.get('bridges', '')
|
|
||||||
await self.logger.debug(f'bridges config raw: type={type(bridges_raw).__name__}, repr={repr(bridges_raw)}')
|
|
||||||
await self.logger.debug(
|
|
||||||
f'parsed _bridges count: {len(self._bridges)}, ids: {[b.user_id for b in self._bridges]}'
|
|
||||||
)
|
|
||||||
|
|
||||||
# Collect all bridge bot user IDs for filtering
|
|
||||||
_bridge_user_ids = [b.user_id for b in self._bridges]
|
|
||||||
_bridge_user_id_set = set(_bridge_user_ids)
|
|
||||||
|
|
||||||
# Auto-join invited rooms
|
|
||||||
async def on_invite(room: nio.MatrixRoom, event: nio.InviteMemberEvent):
|
|
||||||
if event.membership == 'invite' and event.state_key == self.client.user_id:
|
|
||||||
await self.client.join(room.room_id)
|
|
||||||
await self.logger.debug(f'Auto-joined room: {room.display_name or room.room_id}')
|
|
||||||
|
|
||||||
self.client.add_event_callback(on_invite, nio.InviteMemberEvent)
|
|
||||||
|
|
||||||
# Handle text messages
|
|
||||||
async def on_message(room: nio.MatrixRoom, event: nio.RoomMessageText):
|
|
||||||
if not self._initial_sync_done:
|
|
||||||
return
|
|
||||||
if event.sender == self.client.user_id:
|
|
||||||
return
|
|
||||||
|
|
||||||
# Admin commands (from any non-bridge user)
|
|
||||||
if event.sender not in _bridge_user_id_set:
|
|
||||||
body = (event.body or '').strip()
|
|
||||||
if body == '!relogin':
|
|
||||||
await self._handle_relogin_command(room.room_id)
|
|
||||||
return
|
|
||||||
if body == '!status':
|
|
||||||
await self._handle_status_command(room.room_id)
|
|
||||||
return
|
|
||||||
|
|
||||||
if event.sender in _bridge_user_id_set:
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
lb_event = await self.event_converter.target2yiri(
|
|
||||||
event, room, self.client, self.bot_account_id, _bridge_user_ids
|
|
||||||
)
|
|
||||||
if type(lb_event) in self.listeners:
|
|
||||||
result = self.listeners[type(lb_event)](lb_event, self)
|
|
||||||
if asyncio.iscoroutine(result):
|
|
||||||
await result
|
|
||||||
except Exception:
|
|
||||||
await self.logger.error(f'Error handling Matrix message: {traceback.format_exc()}')
|
|
||||||
|
|
||||||
self.client.add_event_callback(on_message, nio.RoomMessageText)
|
|
||||||
|
|
||||||
# Handle image messages
|
|
||||||
async def on_image(room: nio.MatrixRoom, event: nio.RoomMessageImage):
|
|
||||||
if not self._initial_sync_done:
|
|
||||||
return
|
|
||||||
if event.sender == self.client.user_id:
|
|
||||||
return
|
|
||||||
if event.sender in _bridge_user_id_set:
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
lb_event = await self.event_converter.target2yiri(
|
|
||||||
event, room, self.client, self.bot_account_id, _bridge_user_ids
|
|
||||||
)
|
|
||||||
if type(lb_event) in self.listeners:
|
|
||||||
result = self.listeners[type(lb_event)](lb_event, self)
|
|
||||||
if asyncio.iscoroutine(result):
|
|
||||||
await result
|
|
||||||
except Exception:
|
|
||||||
await self.logger.error(f'Error handling Matrix image: {traceback.format_exc()}')
|
|
||||||
|
|
||||||
self.client.add_event_callback(on_image, nio.RoomMessageImage)
|
|
||||||
|
|
||||||
# Set up bridge-specific callbacks for each bridge
|
|
||||||
_disconnect_keywords = ['disconnected', 'logged out', 'connection lost', 'session expired', 'token expired']
|
|
||||||
|
|
||||||
for bridge in self._bridges:
|
|
||||||
# Login success detection (notice)
|
|
||||||
async def on_bridge_notice(room: nio.MatrixRoom, event: nio.RoomMessageNotice, _b=bridge):
|
|
||||||
if not self._initial_sync_done:
|
|
||||||
return
|
|
||||||
if event.sender != _b.user_id:
|
|
||||||
return
|
|
||||||
_b.check_responded = True
|
|
||||||
if _b.success_keyword in (event.body or ''):
|
|
||||||
_b.logged_in = True
|
|
||||||
await self.logger.info(f'[{_b.user_id}] Bridge login succeeded.')
|
|
||||||
# Disconnect detection
|
|
||||||
body_lower = (event.body or '').lower()
|
|
||||||
for kw in _disconnect_keywords:
|
|
||||||
if kw in body_lower and _b.logged_in:
|
|
||||||
_b.logged_in = False
|
|
||||||
await self.logger.info(f'[{_b.user_id}] Bridge 账号掉线 (检测到: "{kw}"), 将自动重新登录...')
|
|
||||||
self._restart_bridge_login(_b)
|
|
||||||
break
|
|
||||||
|
|
||||||
self.client.add_event_callback(on_bridge_notice, nio.RoomMessageNotice)
|
|
||||||
|
|
||||||
# Login success + disconnect detection (text)
|
|
||||||
async def on_bridge_text(room: nio.MatrixRoom, event: nio.RoomMessageText, _b=bridge):
|
|
||||||
if not self._initial_sync_done:
|
|
||||||
return
|
|
||||||
if event.sender != _b.user_id:
|
|
||||||
return
|
|
||||||
_b.check_responded = True
|
|
||||||
if _b.success_keyword in (event.body or ''):
|
|
||||||
_b.logged_in = True
|
|
||||||
await self.logger.info(f'[{_b.user_id}] Bridge login succeeded.')
|
|
||||||
body_lower = (event.body or '').lower()
|
|
||||||
for kw in _disconnect_keywords:
|
|
||||||
if kw in body_lower and _b.logged_in:
|
|
||||||
_b.logged_in = False
|
|
||||||
await self.logger.info(f'[{_b.user_id}] Bridge 账号掉线 (检测到: "{kw}"), 将自动重新登录...')
|
|
||||||
self._restart_bridge_login(_b)
|
|
||||||
break
|
|
||||||
|
|
||||||
self.client.add_event_callback(on_bridge_text, nio.RoomMessageText)
|
|
||||||
|
|
||||||
# QR code image forwarding
|
|
||||||
async def on_bridge_image(room: nio.MatrixRoom, event: nio.RoomMessageImage, _b=bridge):
|
|
||||||
if not self._initial_sync_done:
|
|
||||||
return
|
|
||||||
if event.sender != _b.user_id:
|
|
||||||
return
|
|
||||||
mxc_url = event.url
|
|
||||||
if not mxc_url:
|
|
||||||
return
|
|
||||||
try:
|
|
||||||
resp = await self.client.download(mxc_url)
|
|
||||||
if isinstance(resp, nio.DownloadResponse):
|
|
||||||
b64 = base64.b64encode(resp.body).decode('utf-8')
|
|
||||||
content_type = resp.content_type or 'image/png'
|
|
||||||
await self.logger.info(
|
|
||||||
f'[{_b.user_id}] Bridge 发送了二维码,请扫码登录:',
|
|
||||||
images=[platform_message.Image(base64=f'data:{content_type};base64,{b64}')],
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
await self.logger.error(
|
|
||||||
f'[{_b.user_id}] Failed to download bridge QR image: {traceback.format_exc()}'
|
|
||||||
)
|
|
||||||
|
|
||||||
self.client.add_event_callback(on_bridge_image, nio.RoomMessageImage)
|
|
||||||
|
|
||||||
await self.logger.debug('Matrix adapter running, starting sync...')
|
|
||||||
|
|
||||||
# Initial sync to skip old messages
|
|
||||||
resp = await self.client.sync(timeout=10000)
|
|
||||||
if isinstance(resp, nio.SyncResponse):
|
|
||||||
await self.logger.debug(f'Matrix initial sync done, next_batch: {resp.next_batch}')
|
|
||||||
self._initial_sync_done = True
|
|
||||||
|
|
||||||
# Display account info
|
|
||||||
display_name = self.client.user_id
|
|
||||||
try:
|
|
||||||
profile_resp = await self.client.get_displayname(self.client.user_id)
|
|
||||||
if isinstance(profile_resp, nio.ProfileGetDisplayNameResponse) and profile_resp.displayname:
|
|
||||||
display_name = profile_resp.displayname
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
joined_rooms = len(self.client.rooms)
|
|
||||||
homeserver = self.config.get('homeserver_url', '')
|
|
||||||
bridge_info = ''
|
|
||||||
if self._bridges:
|
|
||||||
bridge_names = ', '.join(b.user_id for b in self._bridges)
|
|
||||||
bridge_info = f' | 桥接: [{bridge_names}]'
|
|
||||||
await self.logger.info(
|
|
||||||
f'Matrix 账号: {display_name} ({self.client.user_id}) | '
|
|
||||||
f'服务器: {homeserver} | 已加入 {joined_rooms} 个房间{bridge_info}'
|
|
||||||
)
|
|
||||||
|
|
||||||
# Start bridge login and status check tasks for each bridge
|
|
||||||
for bridge in self._bridges:
|
|
||||||
if bridge.login_command:
|
|
||||||
await self.logger.info(
|
|
||||||
f'[{bridge.user_id}] Bridge login enabled (命令: "{bridge.login_command}", '
|
|
||||||
f'关键词: "{bridge.success_keyword}")'
|
|
||||||
)
|
|
||||||
bridge.login_task = asyncio.create_task(self._periodic_bridge_login(bridge))
|
|
||||||
bridge.check_task = asyncio.create_task(self._periodic_bridge_check(bridge))
|
|
||||||
else:
|
|
||||||
await self.logger.debug(f'[{bridge.user_id}] Bridge login not configured (no login_command)')
|
|
||||||
|
|
||||||
# Main sync loop
|
|
||||||
while self._running:
|
|
||||||
try:
|
|
||||||
await self.client.sync(timeout=30000)
|
|
||||||
except Exception:
|
|
||||||
await self.logger.error(f'Matrix sync error: {traceback.format_exc()}')
|
|
||||||
await asyncio.sleep(5)
|
|
||||||
|
|
||||||
async def _periodic_bridge_login(self, bridge: BridgeState):
|
|
||||||
"""Periodically send login command to a bridge bot until login succeeds."""
|
|
||||||
try:
|
|
||||||
await self.logger.info(f'[{bridge.user_id}] Bridge login task started, looking for DM room...')
|
|
||||||
dm_room_id = None
|
|
||||||
for room_id, room in self.client.rooms.items():
|
|
||||||
if room.member_count == 2 and bridge.user_id in [m for m in room.users]:
|
|
||||||
dm_room_id = room_id
|
|
||||||
break
|
|
||||||
|
|
||||||
if not dm_room_id:
|
|
||||||
resp = await self.client.room_create(
|
|
||||||
is_direct=True,
|
|
||||||
invite=[bridge.user_id],
|
|
||||||
)
|
|
||||||
if isinstance(resp, nio.RoomCreateResponse):
|
|
||||||
dm_room_id = resp.room_id
|
|
||||||
await self.logger.debug(f'[{bridge.user_id}] Created DM room: {dm_room_id}')
|
|
||||||
else:
|
|
||||||
await self.logger.error(f'[{bridge.user_id}] Failed to create DM room: {resp}')
|
|
||||||
return
|
|
||||||
|
|
||||||
bridge.dm_room_id = dm_room_id
|
|
||||||
|
|
||||||
# Force logout first on every adapter start
|
|
||||||
logout_cmd = bridge.logout_command or bridge.login_command.replace('login', 'logout')
|
|
||||||
await self.logger.info(f'[{bridge.user_id}] 强制登出: "{logout_cmd}"')
|
|
||||||
await self.client.room_send(
|
|
||||||
room_id=dm_room_id,
|
|
||||||
message_type='m.room.message',
|
|
||||||
content={'msgtype': 'm.text', 'body': logout_cmd},
|
|
||||||
)
|
|
||||||
await asyncio.sleep(3)
|
|
||||||
|
|
||||||
while self._running and not bridge.logged_in:
|
|
||||||
await self.logger.debug(f'[{bridge.user_id}] Sending "{bridge.login_command}" in room {dm_room_id}')
|
|
||||||
await self.client.room_send(
|
|
||||||
room_id=dm_room_id,
|
|
||||||
message_type='m.room.message',
|
|
||||||
content={'msgtype': 'm.text', 'body': bridge.login_command},
|
|
||||||
)
|
|
||||||
for _ in range(60):
|
|
||||||
if not self._running or bridge.logged_in:
|
|
||||||
break
|
|
||||||
await asyncio.sleep(1)
|
|
||||||
|
|
||||||
if bridge.logged_in:
|
|
||||||
await self.logger.debug(f'[{bridge.user_id}] Bridge login confirmed, periodic login stopped.')
|
|
||||||
except asyncio.CancelledError:
|
|
||||||
pass
|
|
||||||
except Exception:
|
|
||||||
await self.logger.error(f'[{bridge.user_id}] Bridge periodic login error: {traceback.format_exc()}')
|
|
||||||
|
|
||||||
def _restart_bridge_login(self, bridge: BridgeState):
|
|
||||||
"""Cancel existing login task and start a new one."""
|
|
||||||
if bridge.login_task and not bridge.login_task.done():
|
|
||||||
bridge.login_task.cancel()
|
|
||||||
bridge.login_task = asyncio.create_task(self._periodic_bridge_login(bridge))
|
|
||||||
|
|
||||||
async def _periodic_bridge_check(self, bridge: BridgeState):
|
|
||||||
"""Periodically check a bridge's login status."""
|
|
||||||
try:
|
|
||||||
while self._running and not bridge.logged_in:
|
|
||||||
await asyncio.sleep(5)
|
|
||||||
|
|
||||||
check_interval = 300 # 5 minutes
|
|
||||||
response_timeout = 30
|
|
||||||
await self.logger.debug(f'[{bridge.user_id}] Bridge status check started (interval: {check_interval}s)')
|
|
||||||
|
|
||||||
while self._running:
|
|
||||||
for _ in range(check_interval):
|
|
||||||
if not self._running:
|
|
||||||
return
|
|
||||||
await asyncio.sleep(1)
|
|
||||||
|
|
||||||
if not bridge.logged_in or not bridge.dm_room_id:
|
|
||||||
continue
|
|
||||||
|
|
||||||
try:
|
|
||||||
bridge.check_responded = False
|
|
||||||
await self.client.room_send(
|
|
||||||
room_id=bridge.dm_room_id,
|
|
||||||
message_type='m.room.message',
|
|
||||||
content={'msgtype': 'm.text', 'body': bridge.check_command},
|
|
||||||
)
|
|
||||||
await self.logger.debug(f'[{bridge.user_id}] Bridge status check: sent "{bridge.check_command}"')
|
|
||||||
|
|
||||||
for _ in range(response_timeout):
|
|
||||||
if bridge.check_responded or not self._running:
|
|
||||||
break
|
|
||||||
await asyncio.sleep(1)
|
|
||||||
|
|
||||||
if bridge.check_responded:
|
|
||||||
await self.logger.debug(f'[{bridge.user_id}] Bridge status check: OK')
|
|
||||||
else:
|
|
||||||
await self.logger.info(
|
|
||||||
f'[{bridge.user_id}] Bridge status check: 无响应, 可能已掉线, 尝试重新登录...'
|
|
||||||
)
|
|
||||||
bridge.logged_in = False
|
|
||||||
self._restart_bridge_login(bridge)
|
|
||||||
except Exception:
|
|
||||||
await self.logger.error(f'[{bridge.user_id}] Bridge status check error: {traceback.format_exc()}')
|
|
||||||
except asyncio.CancelledError:
|
|
||||||
pass
|
|
||||||
except Exception:
|
|
||||||
await self.logger.error(f'[{bridge.user_id}] Bridge status check fatal error: {traceback.format_exc()}')
|
|
||||||
|
|
||||||
async def _handle_relogin_command(self, room_id: str):
|
|
||||||
"""Handle !relogin command: logout then re-login all bridges."""
|
|
||||||
if not self._bridges:
|
|
||||||
await self.client.room_send(
|
|
||||||
room_id=room_id,
|
|
||||||
message_type='m.room.message',
|
|
||||||
content={'msgtype': 'm.text', 'body': '没有配置任何桥。'},
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
lines = ['开始重新登录所有桥...']
|
|
||||||
for bridge in self._bridges:
|
|
||||||
if not bridge.login_command or not bridge.dm_room_id:
|
|
||||||
lines.append(f'[{bridge.user_id}] 跳过(未配置登录命令或无DM房间)')
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Use configured logout command, fallback to deriving from login command
|
|
||||||
logout_cmd = bridge.logout_command or bridge.login_command.replace('login', 'logout')
|
|
||||||
lines.append(f'[{bridge.user_id}] 发送 "{logout_cmd}"...')
|
|
||||||
|
|
||||||
# Cancel existing tasks
|
|
||||||
if bridge.login_task and not bridge.login_task.done():
|
|
||||||
bridge.login_task.cancel()
|
|
||||||
if bridge.check_task and not bridge.check_task.done():
|
|
||||||
bridge.check_task.cancel()
|
|
||||||
|
|
||||||
# Send logout
|
|
||||||
try:
|
|
||||||
await self.client.room_send(
|
|
||||||
room_id=bridge.dm_room_id,
|
|
||||||
message_type='m.room.message',
|
|
||||||
content={'msgtype': 'm.text', 'body': logout_cmd},
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
lines.append(f'[{bridge.user_id}] logout 发送失败: {e}')
|
|
||||||
|
|
||||||
await asyncio.sleep(2)
|
|
||||||
|
|
||||||
# Reset state and restart login
|
|
||||||
bridge.logged_in = False
|
|
||||||
self._restart_bridge_login(bridge)
|
|
||||||
lines.append(f'[{bridge.user_id}] 已触发重新登录')
|
|
||||||
|
|
||||||
await self.client.room_send(
|
|
||||||
room_id=room_id,
|
|
||||||
message_type='m.room.message',
|
|
||||||
content={'msgtype': 'm.text', 'body': '\n'.join(lines)},
|
|
||||||
)
|
|
||||||
|
|
||||||
async def _handle_status_command(self, room_id: str):
|
|
||||||
"""Handle !status command: show bridge states."""
|
|
||||||
if not self._bridges:
|
|
||||||
await self.client.room_send(
|
|
||||||
room_id=room_id,
|
|
||||||
message_type='m.room.message',
|
|
||||||
content={'msgtype': 'm.text', 'body': '没有配置任何桥。'},
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
lines = ['桥状态:']
|
|
||||||
for bridge in self._bridges:
|
|
||||||
status = '已登录 ✓' if bridge.logged_in else '未登录 ✗'
|
|
||||||
dm = bridge.dm_room_id or '无'
|
|
||||||
lines.append(f'• {bridge.user_id}: {status} (DM: {dm})')
|
|
||||||
await self.client.room_send(
|
|
||||||
room_id=room_id,
|
|
||||||
message_type='m.room.message',
|
|
||||||
content={'msgtype': 'm.text', 'body': '\n'.join(lines)},
|
|
||||||
)
|
|
||||||
|
|
||||||
async def kill(self) -> bool:
|
|
||||||
self._running = False
|
|
||||||
for bridge in self._bridges:
|
|
||||||
if bridge.login_task and not bridge.login_task.done():
|
|
||||||
bridge.login_task.cancel()
|
|
||||||
if bridge.check_task and not bridge.check_task.done():
|
|
||||||
bridge.check_task.cancel()
|
|
||||||
if self.client:
|
|
||||||
await self.client.close()
|
|
||||||
await self.logger.debug('Matrix adapter stopped')
|
|
||||||
return True
|
|
||||||
|
|
||||||
async def unregister_listener(
|
|
||||||
self,
|
|
||||||
event_type: typing.Type[platform_events.Event],
|
|
||||||
callback: typing.Callable[
|
|
||||||
[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None
|
|
||||||
],
|
|
||||||
):
|
|
||||||
if event_type in self.listeners:
|
|
||||||
del self.listeners[event_type]
|
|
||||||
@@ -1,123 +0,0 @@
|
|||||||
apiVersion: v1
|
|
||||||
kind: MessagePlatformAdapter
|
|
||||||
metadata:
|
|
||||||
name: matrix
|
|
||||||
label:
|
|
||||||
en_US: Matrix
|
|
||||||
zh_Hans: Matrix
|
|
||||||
zh_Hant: Matrix
|
|
||||||
ja_JP: Matrix
|
|
||||||
th_TH: Matrix
|
|
||||||
vi_VN: Matrix
|
|
||||||
es_ES: Matrix
|
|
||||||
description:
|
|
||||||
en_US: Matrix protocol adapter, supports self-hosted Synapse servers and any Matrix-compatible homeserver
|
|
||||||
zh_Hans: Matrix 协议适配器,支持自建 Synapse 服务器及任何 Matrix 兼容的 Homeserver
|
|
||||||
zh_Hant: Matrix 協議適配器,支持自建 Synapse 伺服器及任何 Matrix 相容的 Homeserver
|
|
||||||
ja_JP: Matrix プロトコルアダプター、セルフホストの Synapse サーバーおよび Matrix 互換のホームサーバーをサポート
|
|
||||||
th_TH: อะแดปเตอร์โปรโตคอล Matrix รองรับเซิร์ฟเวอร์ Synapse ที่โฮสต์เองและ Homeserver ที่เข้ากันได้กับ Matrix
|
|
||||||
vi_VN: Bộ điều hợp giao thức Matrix, hỗ trợ máy chủ Synapse tự lưu trữ và bất kỳ Homeserver tương thích Matrix nào
|
|
||||||
es_ES: Adaptador del protocolo Matrix, compatible con servidores Synapse autoalojados y cualquier Homeserver compatible con Matrix
|
|
||||||
icon: matrix.png
|
|
||||||
spec:
|
|
||||||
categories:
|
|
||||||
- global
|
|
||||||
- protocol
|
|
||||||
config:
|
|
||||||
- name: homeserver_url
|
|
||||||
label:
|
|
||||||
en_US: Homeserver URL
|
|
||||||
zh_Hans: Homeserver 地址
|
|
||||||
zh_Hant: Homeserver 地址
|
|
||||||
ja_JP: Homeserver URL
|
|
||||||
th_TH: URL ของ Homeserver
|
|
||||||
vi_VN: URL Homeserver
|
|
||||||
es_ES: URL del Homeserver
|
|
||||||
description:
|
|
||||||
en_US: "The URL of the Matrix homeserver, e.g. http://localhost:8008"
|
|
||||||
zh_Hans: "Matrix Homeserver 的地址,例如 http://localhost:8008"
|
|
||||||
type: string
|
|
||||||
required: true
|
|
||||||
default: "http://localhost:8008"
|
|
||||||
- name: user_id
|
|
||||||
label:
|
|
||||||
en_US: Bot User ID
|
|
||||||
zh_Hans: 机器人用户 ID
|
|
||||||
zh_Hant: 機器人用戶 ID
|
|
||||||
ja_JP: ボットユーザー ID
|
|
||||||
th_TH: ID ผู้ใช้บอท
|
|
||||||
vi_VN: ID người dùng bot
|
|
||||||
es_ES: ID de usuario del bot
|
|
||||||
description:
|
|
||||||
en_US: "The full Matrix user ID, e.g. @bot:localhost"
|
|
||||||
zh_Hans: "完整的 Matrix 用户 ID,例如 @bot:localhost"
|
|
||||||
type: string
|
|
||||||
required: true
|
|
||||||
default: "@langbot:localhost"
|
|
||||||
- name: access_token
|
|
||||||
label:
|
|
||||||
en_US: Access Token
|
|
||||||
zh_Hans: 访问令牌
|
|
||||||
zh_Hant: 訪問令牌
|
|
||||||
ja_JP: アクセストークン
|
|
||||||
th_TH: โทเค็นการเข้าถึง
|
|
||||||
vi_VN: Mã truy cập
|
|
||||||
es_ES: Token de acceso
|
|
||||||
description:
|
|
||||||
en_US: "Access token obtained by logging in via the Matrix client API"
|
|
||||||
zh_Hans: "通过 Matrix Client API 登录获取的访问令牌"
|
|
||||||
type: string
|
|
||||||
required: true
|
|
||||||
default: ""
|
|
||||||
- name: bridge_user_id
|
|
||||||
label:
|
|
||||||
en_US: Bridge Bot User ID (single bridge, legacy)
|
|
||||||
zh_Hans: 桥机器人用户 ID(单桥兼容)
|
|
||||||
description:
|
|
||||||
en_US: "Single bridge bot user ID (legacy). Prefer 'bridges' for multi-bridge. e.g. @discordbot:localhost"
|
|
||||||
zh_Hans: "单桥机器人用户 ID(旧格式兼容)。推荐使用 bridges 配置多桥。例如 @discordbot:localhost"
|
|
||||||
type: string
|
|
||||||
required: false
|
|
||||||
default: ""
|
|
||||||
- name: bridge_login_command
|
|
||||||
label:
|
|
||||||
en_US: Bridge Login Command (single bridge, legacy)
|
|
||||||
zh_Hans: 桥登录命令(单桥兼容)
|
|
||||||
description:
|
|
||||||
en_US: "Login command for single bridge (legacy). e.g. !discord login"
|
|
||||||
zh_Hans: "单桥登录命令(旧格式兼容)。例如 !discord login"
|
|
||||||
type: string
|
|
||||||
required: false
|
|
||||||
default: ""
|
|
||||||
- name: bridge_login_success_keyword
|
|
||||||
label:
|
|
||||||
en_US: Bridge Login Success Keyword (single bridge, legacy)
|
|
||||||
zh_Hans: 桥登录成功关键词(单桥兼容)
|
|
||||||
description:
|
|
||||||
en_US: "Success keyword for single bridge (legacy). e.g. Successfully logged in"
|
|
||||||
zh_Hans: "单桥登录成功关键词(旧格式兼容)。例如 Successfully logged in"
|
|
||||||
type: string
|
|
||||||
required: false
|
|
||||||
default: "Successfully logged in"
|
|
||||||
- name: bridges
|
|
||||||
label:
|
|
||||||
en_US: Bridges Config (Multi-bridge)
|
|
||||||
zh_Hans: 桥配置(多桥)
|
|
||||||
description:
|
|
||||||
en_US: >
|
|
||||||
JSON array of bridge configs. Each bridge: {"user_id": "@bot:host", "login_command": "!xx login",
|
|
||||||
"success_keyword": "logged in", "check_command": "!xx ping"}.
|
|
||||||
Example: [{"user_id":"@discordbot:localhost","login_command":"!discord login","success_keyword":"logged in"},
|
|
||||||
{"user_id":"@telegrambot:localhost","login_command":"!tg login","success_keyword":"logged in"}]
|
|
||||||
zh_Hans: >
|
|
||||||
JSON 数组格式的多桥配置。每个桥: {"user_id": "@bot:host", "login_command": "!xx login",
|
|
||||||
"success_keyword": "logged in", "check_command": "!xx ping"}。
|
|
||||||
示例: [{"user_id":"@discordbot:localhost","login_command":"!discord login","success_keyword":"logged in"},
|
|
||||||
{"user_id":"@telegrambot:localhost","login_command":"!tg login","success_keyword":"logged in"}]
|
|
||||||
type: string
|
|
||||||
required: false
|
|
||||||
default: ""
|
|
||||||
execution:
|
|
||||||
python:
|
|
||||||
path: ./matrix.py
|
|
||||||
attr: MatrixAdapter
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -1,177 +0,0 @@
|
|||||||
apiVersion: v1
|
|
||||||
kind: MessagePlatformAdapter
|
|
||||||
metadata:
|
|
||||||
name: web_page_bot
|
|
||||||
label:
|
|
||||||
en_US: "Page Bot"
|
|
||||||
zh_Hans: "页面机器人"
|
|
||||||
zh_Hant: "頁面機器人"
|
|
||||||
ja_JP: "ページボット"
|
|
||||||
th_TH: "บอทหน้าเว็บ"
|
|
||||||
vi_VN: "Bot trang web"
|
|
||||||
es_ES: "Bot de página"
|
|
||||||
description:
|
|
||||||
en_US: "Embed a chat widget on any website with a simple script tag"
|
|
||||||
zh_Hans: "通过一行脚本标签将聊天组件嵌入到任何网站"
|
|
||||||
zh_Hant: "透過一行腳本標籤將聊天元件嵌入到任何網站"
|
|
||||||
ja_JP: "シンプルなスクリプトタグで任意のウェブサイトにチャットウィジェットを埋め込みます"
|
|
||||||
th_TH: "ฝังวิดเจ็ตแชทในเว็บไซต์ใดก็ได้ด้วยแท็กสคริปต์"
|
|
||||||
vi_VN: "Nhúng widget trò chuyện vào bất kỳ trang web nào bằng thẻ script"
|
|
||||||
es_ES: "Incrusta un widget de chat en cualquier sitio web con una etiqueta de script"
|
|
||||||
icon: "webpage.webp"
|
|
||||||
spec:
|
|
||||||
categories:
|
|
||||||
- popular
|
|
||||||
config:
|
|
||||||
- name: title
|
|
||||||
label:
|
|
||||||
en_US: Widget Title
|
|
||||||
zh_Hans: 组件标题
|
|
||||||
zh_Hant: 元件標題
|
|
||||||
ja_JP: ウィジェットタイトル
|
|
||||||
th_TH: ชื่อวิดเจ็ต
|
|
||||||
vi_VN: Tiêu đề widget
|
|
||||||
es_ES: Título del widget
|
|
||||||
description:
|
|
||||||
en_US: The title displayed in the chat widget header
|
|
||||||
zh_Hans: 显示在聊天组件顶部的标题
|
|
||||||
zh_Hant: 顯示在聊天元件頂部的標題
|
|
||||||
ja_JP: チャットウィジェットのヘッダーに表示されるタイトル
|
|
||||||
th_TH: ชื่อที่แสดงในส่วนหัวของวิดเจ็ตแชท
|
|
||||||
vi_VN: Tiêu đề hiển thị trong đầu widget trò chuyện
|
|
||||||
es_ES: El título que se muestra en el encabezado del widget de chat
|
|
||||||
type: string
|
|
||||||
required: false
|
|
||||||
default: "LangBot"
|
|
||||||
- name: bubble_icon
|
|
||||||
label:
|
|
||||||
en_US: Bubble Icon
|
|
||||||
zh_Hans: 气泡图标
|
|
||||||
zh_Hant: 氣泡圖示
|
|
||||||
ja_JP: バブルアイコン
|
|
||||||
th_TH: ไอคอนบับเบิล
|
|
||||||
vi_VN: Biểu tượng bong bóng
|
|
||||||
es_ES: Icono de burbuja
|
|
||||||
ru_RU: Иконка пузырька
|
|
||||||
description:
|
|
||||||
en_US: "Icon displayed on the floating chat bubble"
|
|
||||||
zh_Hans: "浮动聊天气泡上显示的图标"
|
|
||||||
type: select
|
|
||||||
required: false
|
|
||||||
default: "logo"
|
|
||||||
options:
|
|
||||||
- name: "logo"
|
|
||||||
label:
|
|
||||||
en_US: "LangBot Logo"
|
|
||||||
zh_Hans: "LangBot 图标"
|
|
||||||
- name: "chat"
|
|
||||||
label:
|
|
||||||
en_US: "Chat Bubble"
|
|
||||||
zh_Hans: "聊天气泡"
|
|
||||||
- name: "robot"
|
|
||||||
label:
|
|
||||||
en_US: "Robot"
|
|
||||||
zh_Hans: "机器人"
|
|
||||||
- name: "headset"
|
|
||||||
label:
|
|
||||||
en_US: "Headset"
|
|
||||||
zh_Hans: "客服耳机"
|
|
||||||
- name: "sparkle"
|
|
||||||
label:
|
|
||||||
en_US: "Sparkle"
|
|
||||||
zh_Hans: "星光"
|
|
||||||
- name: "message"
|
|
||||||
label:
|
|
||||||
en_US: "Message"
|
|
||||||
zh_Hans: "消息"
|
|
||||||
- name: language
|
|
||||||
label:
|
|
||||||
en_US: Widget Language
|
|
||||||
zh_Hans: 组件语言
|
|
||||||
zh_Hant: 元件語言
|
|
||||||
ja_JP: ウィジェット言語
|
|
||||||
th_TH: ภาษาวิดเจ็ต
|
|
||||||
vi_VN: Ngôn ngữ widget
|
|
||||||
es_ES: Idioma del widget
|
|
||||||
ru_RU: Язык виджета
|
|
||||||
description:
|
|
||||||
en_US: "Display language of the chat widget"
|
|
||||||
zh_Hans: "聊天组件的显示语言"
|
|
||||||
zh_Hant: "聊天元件的顯示語言"
|
|
||||||
ja_JP: "チャットウィジェットの表示言語"
|
|
||||||
th_TH: "ภาษาแสดงผลของวิดเจ็ตแชท"
|
|
||||||
vi_VN: "Ngôn ngữ hiển thị của widget trò chuyện"
|
|
||||||
es_ES: "Idioma de visualización del widget de chat"
|
|
||||||
ru_RU: "Язык отображения виджета чата"
|
|
||||||
type: select
|
|
||||||
required: false
|
|
||||||
default: "en_US"
|
|
||||||
options:
|
|
||||||
- name: "en_US"
|
|
||||||
label:
|
|
||||||
en_US: "English"
|
|
||||||
- name: "zh_Hans"
|
|
||||||
label:
|
|
||||||
en_US: "简体中文"
|
|
||||||
- name: "zh_Hant"
|
|
||||||
label:
|
|
||||||
en_US: "繁體中文"
|
|
||||||
- name: "ja_JP"
|
|
||||||
label:
|
|
||||||
en_US: "日本語"
|
|
||||||
- name: "es_ES"
|
|
||||||
label:
|
|
||||||
en_US: "Español"
|
|
||||||
- name: "ru_RU"
|
|
||||||
label:
|
|
||||||
en_US: "Русский"
|
|
||||||
- name: "th_TH"
|
|
||||||
label:
|
|
||||||
en_US: "ไทย"
|
|
||||||
- name: "vi_VN"
|
|
||||||
label:
|
|
||||||
en_US: "Tiếng Việt"
|
|
||||||
- name: embed_code
|
|
||||||
label:
|
|
||||||
en_US: Embed Code
|
|
||||||
zh_Hans: 嵌入代码
|
|
||||||
zh_Hant: 嵌入代碼
|
|
||||||
ja_JP: 埋め込みコード
|
|
||||||
th_TH: โค้ดฝังตัว
|
|
||||||
vi_VN: Mã nhúng
|
|
||||||
es_ES: Código de incrustación
|
|
||||||
description:
|
|
||||||
en_US: "Copy this code and paste it into your website HTML. The code will be generated after saving."
|
|
||||||
zh_Hans: "复制此代码并粘贴到你的网站 HTML 中。保存后将自动生成。"
|
|
||||||
zh_Hant: "複製此代碼並貼到你的網站 HTML 中。儲存後將自動生成。"
|
|
||||||
ja_JP: "このコードをコピーしてウェブサイトのHTMLに貼り付けてください。保存後に自動生成されます。"
|
|
||||||
th_TH: "คัดลอกโค้ดนี้และวางในHTML ของเว็บไซต์ของคุณ จะสร้างอัตโนมัติหลังจากบันทึก"
|
|
||||||
vi_VN: "Sao chép mã này và dán vào HTML trang web của bạn. Mã sẽ được tạo tự động sau khi lưu."
|
|
||||||
es_ES: "Copia este código y pégalo en el HTML de tu sitio web. El código se generará después de guardar."
|
|
||||||
type: embed-code
|
|
||||||
required: false
|
|
||||||
default: ""
|
|
||||||
- name: turnstile_site_key
|
|
||||||
label:
|
|
||||||
en_US: Turnstile Site Key
|
|
||||||
zh_Hans: Turnstile 站点密钥
|
|
||||||
description:
|
|
||||||
en_US: "Cloudflare Turnstile site key for bot protection. Get it from the Cloudflare dashboard (Turnstile > Add Site). Leave empty to disable."
|
|
||||||
zh_Hans: "Cloudflare Turnstile 站点密钥,用于防止机器人滥用。在 Cloudflare 控制台(Turnstile > 添加站点)中获取。留空则不启用。"
|
|
||||||
type: string
|
|
||||||
required: false
|
|
||||||
default: ""
|
|
||||||
- name: turnstile_secret_key
|
|
||||||
label:
|
|
||||||
en_US: Turnstile Secret Key
|
|
||||||
zh_Hans: Turnstile 服务端密钥
|
|
||||||
description:
|
|
||||||
en_US: "Cloudflare Turnstile secret key for server-side token verification. Found alongside the site key in the Cloudflare dashboard. Required if site key is set."
|
|
||||||
zh_Hans: "Cloudflare Turnstile 服务端密钥,用于服务端验证令牌。与站点密钥一起在 Cloudflare 控制台中获取。设置了站点密钥时必填。"
|
|
||||||
type: string
|
|
||||||
required: false
|
|
||||||
default: ""
|
|
||||||
execution:
|
|
||||||
python:
|
|
||||||
path: "web_page_bot_adapter.py"
|
|
||||||
attr: "WebPageBotAdapter"
|
|
||||||
@@ -1,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
|
|
||||||
|
Before Width: | Height: | Size: 14 KiB |
@@ -312,7 +312,7 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
|
|||||||
|
|
||||||
async def _process_image_components(self, message_chain_obj: list):
|
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]:
|
||||||
"""获取消息历史"""
|
"""获取消息历史"""
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 != '':
|
||||||
|
|||||||
@@ -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')
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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 []
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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 |
@@ -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 |
@@ -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
|
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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 |
@@ -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
|
|
||||||
@@ -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']),
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Qiniu</title><path d="M23.111 4.6a.914.914 0 00-.861.161A13.443 13.443 0 017.947 8.897L7.38 6.831a1.076 1.076 0 00-1.211-.698l.27 2.18c-1.816-.827-2.313-.946-3.587-2.45C2.674 5.729 1.263 4.472.89 4.6a11.906 11.906 0 005.892 6.497l.738 5.97s.33 2.286 2.473 2.286h4.586c2.144 0 2.474-2.286 2.474-2.286l.518-4.28c-1.393-.11-2.268.857-2.546 1.814-.465 1.614-.465 1.716-.557 1.998-.188.575-.806.644-.806.644h-2.753s-.617-.07-.806-.644c-.12-.371-.727-2.54-1.335-4.74A11.877 11.877 0 0023.11 4.599V4.6z" fill="#06AEEF"></path></svg>
|
|
||||||
|
Before Width: | Height: | Size: 649 B |
@@ -1,45 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import typing
|
|
||||||
|
|
||||||
import openai
|
|
||||||
|
|
||||||
from . import chatcmpl
|
|
||||||
|
|
||||||
|
|
||||||
class QiniuChatCompletions(chatcmpl.OpenAIChatCompletions):
|
|
||||||
"""七牛云 ChatCompletion API 请求器"""
|
|
||||||
|
|
||||||
client: openai.AsyncClient
|
|
||||||
|
|
||||||
default_config: dict[str, typing.Any] = {
|
|
||||||
'base_url': 'https://api.qnaigc.com/v1',
|
|
||||||
'timeout': 120,
|
|
||||||
}
|
|
||||||
|
|
||||||
async def scan_models(self, api_key: str | None = None) -> dict[str, typing.Any]:
|
|
||||||
try:
|
|
||||||
result = await super().scan_models(api_key)
|
|
||||||
except Exception:
|
|
||||||
return self._qiniu_fallback_scan_result()
|
|
||||||
models = result.get('models') or []
|
|
||||||
if not models:
|
|
||||||
return self._qiniu_fallback_scan_result()
|
|
||||||
return result
|
|
||||||
|
|
||||||
def _qiniu_fallback_scan_result(self) -> dict[str, typing.Any]:
|
|
||||||
mid = 'deepseek-v3'
|
|
||||||
return {
|
|
||||||
'models': [
|
|
||||||
{
|
|
||||||
'id': mid,
|
|
||||||
'name': mid,
|
|
||||||
'type': 'llm',
|
|
||||||
'abilities': [],
|
|
||||||
}
|
|
||||||
],
|
|
||||||
'debug': {
|
|
||||||
'request': {'method': 'GET', 'url': '', 'headers': {}},
|
|
||||||
'response': {},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
@@ -1,31 +0,0 @@
|
|||||||
apiVersion: v1
|
|
||||||
kind: LLMAPIRequester
|
|
||||||
metadata:
|
|
||||||
name: qiniu-chat-completions
|
|
||||||
label:
|
|
||||||
en_US: Qiniu
|
|
||||||
zh_Hans: 七牛云
|
|
||||||
icon: qiniu.svg
|
|
||||||
spec:
|
|
||||||
config:
|
|
||||||
- name: base_url
|
|
||||||
label:
|
|
||||||
en_US: Base URL
|
|
||||||
zh_Hans: 基础 URL
|
|
||||||
type: string
|
|
||||||
required: true
|
|
||||||
default: https://api.qnaigc.com/v1
|
|
||||||
- name: timeout
|
|
||||||
label:
|
|
||||||
en_US: Timeout
|
|
||||||
zh_Hans: 超时时间
|
|
||||||
type: integer
|
|
||||||
required: true
|
|
||||||
default: 120
|
|
||||||
support_type:
|
|
||||||
- llm
|
|
||||||
provider_category: maas
|
|
||||||
execution:
|
|
||||||
python:
|
|
||||||
path: ./qiniuchatcmpl.py
|
|
||||||
attr: QiniuChatCompletions
|
|
||||||
@@ -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 |
@@ -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:
|
||||||
|
|||||||
@@ -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 |
@@ -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
|
|
||||||
@@ -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:
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 14 KiB |
@@ -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
|
||||||
|
|||||||
@@ -1,106 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import datetime, timedelta
|
|
||||||
from importlib import import_module
|
|
||||||
from pathlib import Path
|
|
||||||
from types import SimpleNamespace
|
|
||||||
from unittest.mock import AsyncMock, Mock
|
|
||||||
|
|
||||||
import pytest
|
|
||||||
import yaml
|
|
||||||
|
|
||||||
|
|
||||||
def _preproc_module():
|
|
||||||
# Import pipelinemgr first so pipeline stages are registered without tripping
|
|
||||||
# the stage <-> core.app circular import during isolated test collection.
|
|
||||||
import_module('langbot.pkg.pipeline.pipelinemgr')
|
|
||||||
return import_module('langbot.pkg.pipeline.preproc.preproc')
|
|
||||||
|
|
||||||
|
|
||||||
def _entities_module():
|
|
||||||
return import_module('langbot.pkg.pipeline.entities')
|
|
||||||
|
|
||||||
|
|
||||||
def _conversation(created_at: datetime, updated_at: datetime | None = None):
|
|
||||||
prompt = Mock()
|
|
||||||
prompt.messages = []
|
|
||||||
prompt.copy = Mock(return_value=Mock(messages=[]))
|
|
||||||
|
|
||||||
return SimpleNamespace(
|
|
||||||
uuid='existing-conversation-uuid',
|
|
||||||
create_time=created_at,
|
|
||||||
update_time=updated_at,
|
|
||||||
prompt=prompt,
|
|
||||||
messages=[],
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _prompt_preprocessing_context(default_prompt=None, prompt=None):
|
|
||||||
ctx = Mock()
|
|
||||||
ctx.event.default_prompt = default_prompt or []
|
|
||||||
ctx.event.prompt = prompt or []
|
|
||||||
return ctx
|
|
||||||
|
|
||||||
|
|
||||||
async def _run_preprocessor(mock_app, sample_query, conversation):
|
|
||||||
session = SimpleNamespace(launcher_type=sample_query.launcher_type, launcher_id=sample_query.launcher_id)
|
|
||||||
mock_app.sess_mgr.get_session = AsyncMock(return_value=session)
|
|
||||||
mock_app.sess_mgr.get_conversation = AsyncMock(return_value=conversation)
|
|
||||||
mock_app.plugin_connector.emit_event = AsyncMock(return_value=_prompt_preprocessing_context())
|
|
||||||
|
|
||||||
sample_query.pipeline_config = {
|
|
||||||
'ai': {
|
|
||||||
'runner': {'runner': 'local-agent', 'expire-time': 60},
|
|
||||||
'local-agent': {'model': {'primary': '', 'fallbacks': []}, 'prompt': []},
|
|
||||||
},
|
|
||||||
'trigger': {'misc': {'combine-quote-message': False}},
|
|
||||||
'output': {'misc': {'exception-handling': 'show-hint'}},
|
|
||||||
}
|
|
||||||
|
|
||||||
return await _preproc_module().PreProcessor(mock_app).process(sample_query, 'PreProcessor')
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_preprocessor_expires_conversation_from_last_update_time(mock_app, sample_query):
|
|
||||||
conversation = _conversation(
|
|
||||||
created_at=datetime.now() - timedelta(seconds=10),
|
|
||||||
updated_at=datetime.now() - timedelta(seconds=120),
|
|
||||||
)
|
|
||||||
|
|
||||||
result = await _run_preprocessor(mock_app, sample_query, conversation)
|
|
||||||
|
|
||||||
assert result.result_type == _entities_module().ResultType.CONTINUE
|
|
||||||
assert conversation.uuid is None
|
|
||||||
assert conversation.update_time > datetime.now() - timedelta(seconds=5)
|
|
||||||
assert result.new_query.variables['conversation_id'] is None
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
|
||||||
async def test_preprocessor_keeps_conversation_when_last_update_is_not_expired(mock_app, sample_query):
|
|
||||||
conversation = _conversation(
|
|
||||||
created_at=datetime.now() - timedelta(seconds=120),
|
|
||||||
updated_at=datetime.now() - timedelta(seconds=30),
|
|
||||||
)
|
|
||||||
|
|
||||||
result = await _run_preprocessor(mock_app, sample_query, conversation)
|
|
||||||
|
|
||||||
assert result.result_type == _entities_module().ResultType.CONTINUE
|
|
||||||
assert conversation.uuid == 'existing-conversation-uuid'
|
|
||||||
assert conversation.update_time > datetime.now() - timedelta(seconds=5)
|
|
||||||
assert result.new_query.variables['conversation_id'] == 'existing-conversation-uuid'
|
|
||||||
|
|
||||||
|
|
||||||
def test_expire_time_metadata_lives_under_ai_runner_not_safety():
|
|
||||||
metadata_dir = Path('src/langbot/templates/metadata/pipeline')
|
|
||||||
|
|
||||||
ai_meta = yaml.safe_load((metadata_dir / 'ai.yaml').read_text())
|
|
||||||
safety_meta = yaml.safe_load((metadata_dir / 'safety.yaml').read_text())
|
|
||||||
|
|
||||||
ai_stage_names = [stage['name'] for stage in ai_meta['stages']]
|
|
||||||
assert 'session-limit' not in ai_stage_names
|
|
||||||
assert 'session-limit' not in [stage['name'] for stage in safety_meta['stages']]
|
|
||||||
|
|
||||||
runner_stage = next(stage for stage in ai_meta['stages'] if stage['name'] == 'runner')
|
|
||||||
expire_time = next(item for item in runner_stage['config'] if item['name'] == 'expire-time')
|
|
||||||
assert 'Conversation expire time' in expire_time['label']['en_US']
|
|
||||||
assert 'Session validity' not in expire_time['label']['en_US']
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
|
|
||||||
@@ -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]
|
|
||||||
@@ -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"])
|
|
||||||
@@ -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
@@ -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]]
|
||||||
|
|||||||
@@ -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 = () => {
|
||||||
|
|||||||
@@ -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 */}
|
||||||
|
|||||||
@@ -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') || {},
|
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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 () => {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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, '&')
|
|
||||||
.replace(/"/g, '"')
|
|
||||||
.replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>');
|
|
||||||
const embedSnippet = `<script data-title="${safeTitle}" src="${baseUrl}/api/v1/embed/${botUuid}/widget.js"><\/script>`;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<EmbedCodeField
|
|
||||||
key={config.id}
|
|
||||||
label={extractI18nObject(config.label)}
|
|
||||||
description={
|
|
||||||
config.description
|
|
||||||
? extractI18nObject(config.description)
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
snippet={embedSnippet}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 (
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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}
|
|
||||||
/>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||