Compare commits

..

3 Commits

Author SHA1 Message Date
WangCham
3b3deec080 feat: modify frontend 2026-05-04 17:50:19 +08:00
WangCham
58ec377413 feat: add filter 2026-05-02 23:02:56 +08:00
WangCham
7c50aabe65 feat: add mcp and skills 2026-05-02 17:38:18 +08:00
99 changed files with 4755 additions and 11670 deletions

1
.gitignore vendored
View File

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

View File

@@ -84,48 +84,45 @@ docker compose up -d
| Platform | Status | Notes |
|----------|--------|-------|
| Discord | ✅ | Official |
| Telegram | ✅ | Official |
| Slack | ✅ | Official |
| LINE | ✅ | Official |
| QQ | ✅ | Personal & Official API (Channel, DM, Group) |
| Discord | ✅ | |
| Telegram | ✅ | |
| Slack | ✅ | |
| LINE | ✅ | |
| QQ | ✅ | Personal & Official API |
| WeCom | ✅ | Enterprise WeChat, External CS, AI Bot |
| WeChat | ✅ | Personal & Official Account |
| Lark | ✅ | Official |
| DingTalk | ✅ | Official |
| KOOK | ✅ | Official |
| Lark | ✅ | |
| DingTalk | ✅ | |
| KOOK | ✅ | |
| Satori | ✅ | |
| Email | ✅ | Matrix, Satori |
| Matrix | ✅ | Supports multiple bridged platforms such as Signal, WhatsApp, Messenger, iMessage, Mattermost, Google Chat, IRC, XMPP, Zulip, and more |
---
## Supported LLMs & Integrations
| Provider | Type | Status |
| ----------------------------------------------------------------------------------------------------------------- | ------------ | ------ |
| [OpenAI](https://platform.openai.com/) | LLM | ✅ |
| [Anthropic](https://www.anthropic.com/) | LLM | ✅ |
| [DeepSeek](https://www.deepseek.com/) | LLM | ✅ |
| [Google Gemini](https://aistudio.google.com/prompts/new_chat) | LLM | ✅ |
| [xAI](https://x.ai/) | LLM | ✅ |
| [Moonshot](https://www.moonshot.cn/) | LLM | ✅ |
| [Zhipu AI](https://open.bigmodel.cn/) | LLM | ✅ |
| [Ollama](https://ollama.com/) | Local LLM | ✅ |
| [LM Studio](https://lmstudio.ai/) | Local LLM | ✅ |
| [Dify](https://dify.ai) | LLMOps | ✅ |
| [MCP](https://modelcontextprotocol.io/) | Protocol | ✅ |
| [SiliconFlow](https://siliconflow.cn/) | Gateway | ✅ |
| [Aliyun Bailian](https://bailian.console.aliyun.com/) | Gateway | ✅ |
| [Volc Engine Ark](https://console.volcengine.com/ark/region:ark+cn-beijing/model?vendor=Bytedance&view=LIST_VIEW) | Gateway | ✅ |
| [ModelScope](https://modelscope.cn/docs/model-service/API-Inference/intro) | Gateway | ✅ |
| [GiteeAI](https://ai.gitee.com/) | Gateway | ✅ |
| [CompShare](https://www.compshare.cn/?ytag=GPU_YY-gh_langbot) | GPU Platform | ✅ |
| [PPIO](https://ppinfra.com/user/register?invited_by=QJKFYD&utm_source=github_langbot) | GPU Platform | ✅ |
| [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | GPU Platform | ✅ |
| [接口 AI](https://jiekou.ai/) | Gateway | ✅ |
| [302.AI](https://share.302.ai/SuTG99) | Gateway | ✅ |
| [Qiniu](https://www.qiniu.com/ai/agent) | Gateway | ✅ |
| Provider | Type | Status |
|----------|------|--------|
| [OpenAI](https://platform.openai.com/) | LLM | ✅ |
| [Anthropic](https://www.anthropic.com/) | LLM | ✅ |
| [DeepSeek](https://www.deepseek.com/) | LLM | ✅ |
| [Google Gemini](https://aistudio.google.com/prompts/new_chat) | LLM | ✅ |
| [xAI](https://x.ai/) | LLM | ✅ |
| [Moonshot](https://www.moonshot.cn/) | LLM | ✅ |
| [Zhipu AI](https://open.bigmodel.cn/) | LLM | ✅ |
| [Ollama](https://ollama.com/) | Local LLM | ✅ |
| [LM Studio](https://lmstudio.ai/) | Local LLM | ✅ |
| [Dify](https://dify.ai) | LLMOps | ✅ |
| [MCP](https://modelcontextprotocol.io/) | Protocol | ✅ |
| [SiliconFlow](https://siliconflow.cn/) | Gateway | ✅ |
| [Aliyun Bailian](https://bailian.console.aliyun.com/) | Gateway | ✅ |
| [Volc Engine Ark](https://console.volcengine.com/ark/region:ark+cn-beijing/model?vendor=Bytedance&view=LIST_VIEW) | Gateway | ✅ |
| [ModelScope](https://modelscope.cn/docs/model-service/API-Inference/intro) | Gateway | ✅ |
| [GiteeAI](https://ai.gitee.com/) | Gateway | ✅ |
| [CompShare](https://www.compshare.cn/?ytag=GPU_YY-gh_langbot) | GPU Platform | ✅ |
| [PPIO](https://ppinfra.com/user/register?invited_by=QJKFYD&utm_source=github_langbot) | GPU Platform | ✅ |
| [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | GPU Platform | ✅ |
| [接口 AI](https://jiekou.ai/) | Gateway | ✅ |
| [302.AI](https://share.302.ai/SuTG99) | Gateway | ✅ |
[→ View all integrations](https://link.langbot.app/en/docs/features)
@@ -133,23 +130,22 @@ docker compose up -d
## Why LangBot?
| Use Case | How LangBot Helps |
| --------------------------- | ------------------------------------------------------------------------------------------ |
| **Customer Support** | Deploy AI agents to Slack/Discord/Telegram that answer questions using your knowledge base |
| **Internal Tools** | Connect n8n/Dify workflows to WeCom/DingTalk for automated business processes |
| **Community Management** | Moderate QQ/Discord groups with AI-powered content filtering and interaction |
| **Multi-Platform Presence** | One bot, all platforms. Manage from a single dashboard |
| Use Case | How LangBot Helps |
|----------|-------------------|
| **Customer Support** | Deploy AI agents to Slack/Discord/Telegram that answer questions using your knowledge base |
| **Internal Tools** | Connect n8n/Dify workflows to WeCom/DingTalk for automated business processes |
| **Community Management** | Moderate QQ/Discord groups with AI-powered content filtering and interaction |
| **Multi-Platform Presence** | One bot, all platforms. Manage from a single dashboard |
---
## Live Demo
**Try it now:** https://demo.langbot.dev/
- Email: `demo@langbot.app`
- Password: `langbot123456`
_Note: Public demo environment. Do not enter sensitive information._
*Note: Public demo environment. Do not enter sensitive information.*
---

View File

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

View File

@@ -83,19 +83,17 @@ docker compose up -d
| Plataforma | Estado | Notas |
|----------|--------|-------|
| Discord | ✅ | Oficial |
| Telegram | ✅ | Oficial |
| Slack | ✅ | Oficial |
| LINE | ✅ | Oficial |
| QQ | ✅ | Personal y API Oficial (Canal, DM, Grupo) |
| Discord | ✅ | |
| Telegram | ✅ | |
| Slack | ✅ | |
| LINE | ✅ | |
| QQ | ✅ | Personal y API Oficial |
| WeCom | ✅ | WeChat Empresarial, CS Externo, AI Bot |
| WeChat | ✅ | Personal y Cuenta Oficial |
| Lark | ✅ | Oficial |
| DingTalk | ✅ | Oficial |
| KOOK | ✅ | Oficial |
| Lark | ✅ | |
| DingTalk | ✅ | |
| KOOK | ✅ | |
| Satori | ✅ | |
| Email | ✅ | Matrix, Satori |
| Matrix | ✅ | Admite varias plataformas puenteadas como Signal, WhatsApp, Messenger, iMessage, Mattermost, Google Chat, IRC, XMPP, Zulip y más |
---
@@ -124,7 +122,6 @@ docker compose up -d
| [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | Plataforma GPU | ✅ |
| [接口 AI](https://jiekou.ai/) | Pasarela | ✅ |
| [302.AI](https://share.302.ai/SuTG99) | Pasarela | ✅ |
| [Qiniu](https://www.qiniu.com/ai/agent) | Pasarela | ✅ |
[→ Ver todas las integraciones](https://link.langbot.app/en/docs/features)

View File

@@ -83,19 +83,17 @@ docker compose up -d
| Plateforme | Statut | Notes |
|----------|--------|-------|
| Discord | ✅ | Officiel |
| Telegram | ✅ | Officiel |
| Slack | ✅ | Officiel |
| LINE | ✅ | Officiel |
| QQ | ✅ | Personnel & API Officielle (Canal, DM, Groupe) |
| Discord | ✅ | |
| Telegram | ✅ | |
| Slack | ✅ | |
| LINE | ✅ | |
| QQ | ✅ | Personnel & API Officielle |
| WeCom | ✅ | WeChat Entreprise, CS Externe, AI Bot |
| WeChat | ✅ | Personnel & Compte Officiel |
| Lark | ✅ | Officiel |
| DingTalk | ✅ | Officiel |
| KOOK | ✅ | Officiel |
| Lark | ✅ | |
| DingTalk | ✅ | |
| KOOK | ✅ | |
| Satori | ✅ | |
| Email | ✅ | Matrix, Satori |
| Matrix | ✅ | Prend en charge plusieurs plateformes via ponts, comme Signal, WhatsApp, Messenger, iMessage, Mattermost, Google Chat, IRC, XMPP, Zulip, etc. |
---
@@ -124,7 +122,6 @@ docker compose up -d
| [CompShare](https://www.compshare.cn/?ytag=GPU_YY-gh_langbot) | Plateforme GPU | ✅ |
| [PPIO](https://ppinfra.com/user/register?invited_by=QJKFYD&utm_source=github_langbot) | Plateforme GPU | ✅ |
| [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | Plateforme GPU | ✅ |
| [Qiniu](https://www.qiniu.com/ai/agent) | Passerelle | ✅ |
[→ Voir toutes les intégrations](https://link.langbot.app/en/docs/features)

View File

@@ -83,19 +83,17 @@ docker compose up -d
| プラットフォーム | ステータス | 備考 |
|----------|--------|-------|
| Discord | ✅ | 公式 |
| Telegram | ✅ | 公式 |
| Slack | ✅ | 公式 |
| LINE | ✅ | 公式 |
| QQ | ✅ | 個人公式APIチャンネル・DM・グループ |
| Discord | ✅ | |
| Telegram | ✅ | |
| Slack | ✅ | |
| LINE | ✅ | |
| QQ | ✅ | 個人 & 公式API |
| WeCom | ✅ | 企業WeChat、外部CS、AIボット |
| WeChat | ✅ | 個人公式アカウント |
| Lark | ✅ | 公式 |
| DingTalk | ✅ | 公式 |
| KOOK | ✅ | 公式 |
| WeChat | ✅ | 個人 & 公式アカウント |
| Lark | ✅ | |
| DingTalk | ✅ | |
| KOOK | ✅ | |
| Satori | ✅ | |
| Email | ✅ | Matrix、Satori |
| Matrix | ✅ | Signal、WhatsApp、Messenger、iMessage、Mattermost、Google Chat、IRC、XMPP、Zulip など複数のブリッジ先プラットフォームに対応 |
---
@@ -124,7 +122,6 @@ docker compose up -d
| [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | GPUプラットフォーム | ✅ |
| [接口 AI](https://jiekou.ai/) | ゲートウェイ | ✅ |
| [302.AI](https://share.302.ai/SuTG99) | ゲートウェイ | ✅ |
| [Qiniu](https://www.qiniu.com/ai/agent) | ゲートウェイ | ✅ |
[→ すべての統合を表示](https://link.langbot.app/en/docs/features)

View File

@@ -83,19 +83,17 @@ docker compose up -d
| 플랫폼 | 상태 | 비고 |
|--------|------|------|
| Discord | ✅ | 공식 |
| Telegram | ✅ | 공식 |
| Slack | ✅ | 공식 |
| LINE | ✅ | 공식 |
| QQ | ✅ | 개인 및 공식 API (채널, DM, 그룹) |
| Discord | ✅ | |
| Telegram | ✅ | |
| Slack | ✅ | |
| LINE | ✅ | |
| QQ | ✅ | 개인 및 공식 API |
| WeCom | ✅ | 기업 WeChat, 외부 CS, AI Bot |
| WeChat | ✅ | 개인 및 공식 계정 |
| Lark | ✅ | 공식 |
| DingTalk | ✅ | 공식 |
| KOOK | ✅ | 공식 |
| Lark | ✅ | |
| DingTalk | ✅ | |
| KOOK | ✅ | |
| Satori | ✅ | |
| Email | ✅ | Matrix, Satori |
| Matrix | ✅ | Signal, WhatsApp, Messenger, iMessage, Mattermost, Google Chat, IRC, XMPP, Zulip 등 여러 브리지 플랫폼 지원 |
---
@@ -124,7 +122,6 @@ docker compose up -d
| [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | GPU 플랫폼 | ✅ |
| [接口 AI](https://jiekou.ai/) | 게이트웨이 | ✅ |
| [302.AI](https://share.302.ai/SuTG99) | 게이트웨이 | ✅ |
| [Qiniu](https://www.qiniu.com/ai/agent) | 게이트웨이 | ✅ |
[→ 모든 통합 보기](https://link.langbot.app/en/docs/features)

View File

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

View File

@@ -85,19 +85,17 @@ docker compose up -d
| 平台 | 狀態 | 備註 |
|------|------|------|
| Discord | ✅ | 官方 |
| Telegram | ✅ | 官方 |
| Slack | ✅ | 官方 |
| LINE | ✅ | 官方 |
| QQ | ✅ | 個人號、官方機器人(頻道、私聊、群聊) |
| 企業微信 | ✅ | 應用訊息、對外客服、智能機器人 |
| 微信 | ✅ | 個人微信、微信公眾號 |
| 飛書 | ✅ | 官方 |
| 釘釘 | ✅ | 官方 |
| KOOK | ✅ | 官方 |
| 企業微信 | ✅ | 應用訊息、對外客服、智能機器人 |
| 飛書 | ✅ | |
| 釘釘 | ✅ | |
| Discord | ✅ | |
| Telegram | ✅ | |
| Slack | ✅ | |
| LINE | ✅ | |
| KOOK | ✅ | |
| Satori | ✅ | |
| Email | ✅ | 只 Matrix、Satori |
| Matrix | ✅ | 支援多種橋接平台,如 Signal、WhatsApp、Messenger、iMessage、Mattermost、Google Chat、IRC、XMPP、Zulip 等 |
---
@@ -126,7 +124,6 @@ docker compose up -d
| [PPIO](https://ppinfra.com/user/register?invited_by=QJKFYD&utm_source=github_langbot) | GPU 平台 | ✅ |
| [接口 AI](https://jiekou.ai/) | 聚合平台 | ✅ |
| [302.AI](https://share.302.ai/SuTG99) | 聚合平台 | ✅ |
| [Qiniu](https://www.qiniu.com/ai/agent) | 聚合平台 | ✅ |
### TTS語音合成

View File

@@ -83,19 +83,17 @@ docker compose up -d
| Nền tảng | Trạng thái | Ghi chú |
|----------|--------|-------|
| Discord | ✅ | Chính thức |
| Telegram | ✅ | Chính thức |
| Slack | ✅ | Chính thức |
| LINE | ✅ | Chính thức |
| QQ | ✅ | Cá nhân & API chính thức (Kênh, DM, Nhóm) |
| Discord | ✅ | |
| Telegram | ✅ | |
| Slack | ✅ | |
| LINE | ✅ | |
| QQ | ✅ | Cá nhân & API chính thức |
| WeCom | ✅ | WeChat doanh nghiệp, CS bên ngoài, AI Bot |
| WeChat | ✅ | Cá nhân & Tài khoản công khai |
| Lark | ✅ | Chính thức |
| DingTalk | ✅ | Chính thức |
| KOOK | ✅ | Chính thức |
| Lark | ✅ | |
| DingTalk | ✅ | |
| KOOK | ✅ | |
| Satori | ✅ | |
| Email | ✅ | Matrix, Satori |
| Matrix | ✅ | Hỗ trợ nhiều nền tảng qua bridge như Signal, WhatsApp, Messenger, iMessage, Mattermost, Google Chat, IRC, XMPP, Zulip và hơn thế nữa |
---
@@ -124,7 +122,6 @@ docker compose up -d
| [ShengSuanYun](https://www.shengsuanyun.com/?from=CH_KYIPP758) | Nền tảng GPU | ✅ |
| [接口 AI](https://jiekou.ai/) | Cổng | ✅ |
| [302.AI](https://share.302.ai/SuTG99) | Cổng | ✅ |
| [Qiniu](https://www.qiniu.com/ai/agent) | Cổng | ✅ |
[→ Xem tất cả tích hợp](https://link.langbot.app/en/docs/features)

View File

@@ -1,858 +0,0 @@
# LangBot 多租户与多用户改造方案
## 目标
本方案面向 LangBot 从“单实例单管理员”演进到 SaaS 友好的“多 workspace、多账户、多权限”架构。
核心定义:
- Account登录主体。一个自然人或服务账号可加入多个 workspace。
- Workspace租户边界。一个 workspace 内可拥有多个用户、机器人、流水线、模型、知识库、扩展、监控数据与 API Key。
- Membership账户与 workspace 的关系,承载角色与权限。
- Role/Permissionworkspace 内权限,不再用“是否是当前唯一用户”来决定访问能力。
目标体验:
- 新用户登录后可以创建 workspace、加入 workspace、切换 workspace。
- 同一个账户可加入多个 workspace每个 workspace 权限不同。
- 一个 workspace 可邀请多个用户协作,并分别设置 owner/admin/editor/viewer 等权限。
- 所有业务资源默认属于某个 workspace所有 API 默认在当前 workspace 下工作。
- Plugin SDK、MCP、知识库、模型调用、监控日志都能拿到稳定的 workspace 上下文,并且不跨租户泄露数据。
## 调研结论
### 当前 LangBot 的单用户假设
LangBot 现在已经有 `users` 表和 JWT 登录,但仍是单用户/单租户模型:
- `src/langbot/pkg/entity/persistence/user.py``User` 只保存 `user/password/account_type/space_*`没有角色、状态、workspace 关系。
- `src/langbot/pkg/api/http/service/user.py` 通过 `is_initialized()` 判断系统是否已有用户;`create_or_update_space_user()` 在系统已初始化且邮箱不匹配时拒绝新用户,这直接限制了多用户登录。
- `src/langbot/pkg/api/http/controller/group.py``AuthType.USER_TOKEN` 验证后只向 handler 注入 `user_email`JWT payload 也只有 `user`,没有 `account_id``workspace_id``role``permissions`
- `src/langbot/pkg/api/http/service/apikey.py` 的 API Key 只验证 key 是否存在,没有 owner、scope、workspace。
- `web/src/app/infra/http/BaseHttpClient.ts``localStorage.token` 读取单个 token并在所有请求上加 `Authorization`;前端没有 workspace selector也没有当前 workspace 上下文。
当前登录流程更像“初始化一个本地管理账号”,而不是 SaaS 账户体系。要支持多用户,必须把“初始化状态”和“首个 workspace 创建”拆开。
### 业务资源当前都是全局资源
主要持久化表没有租户字段:
- Bot`bots`
- Pipeline`legacy_pipelines``pipeline_run_records`
- Model`model_providers``llm_models``embedding_models``rerank_models`
- Plugin`plugin_settings`
- MCP`mcp_servers`
- RAG`knowledge_bases``knowledge_base_files``knowledge_base_chunks`
- Monitoring`monitoring_messages``monitoring_llm_calls``monitoring_sessions``monitoring_errors``monitoring_embedding_calls``monitoring_feedback`
- API Key`api_keys`
- Webhook`webhooks`
- Metadata`metadata`
- Binary storage`binary_storages`
对应服务也直接 select 全表,例如:
- `BotService.get_bots()` 返回所有 bot。
- `PipelineService.get_pipelines()` 返回所有 pipeline。
- `ModelProviderService.get_providers()` 返回所有 provider。
- `MCPService.get_mcp_servers()` 返回所有 MCP server。
- 插件和二进制存储没有 workspace 维度,插件 workspace storage 在 SDK 里还硬编码为 `default`
所以改造重点不是只给用户表加字段,而是给资源访问层统一加入 workspace scope。
### 运行时也存在全局单例假设
`src/langbot/pkg/core/stages/build_app.py` 初始化的是一个全局 `Application`,其中包含单例:
- `platform_mgr`
- `pipeline_mgr`
- `model_mgr`
- `tool_mgr`
- `plugin_connector`
- `sess_mgr`
- `rag_mgr`
- `vector_db_mgr`
当前运行时把所有 bot、pipeline、model、plugin、MCP 都加载到同一套内存管理器。多租户改造需要决定:是共享运行时并在对象上带 workspace 过滤,还是每个 workspace 拆 runtime shard。第一阶段建议共享进程、强制 workspace-aware等规模变大后再演进为按 workspace 分片。
### Plugin SDK 对 workspace 的假设
SDK 当前只认识 bot/pipeline/query/session不认识租户
- `src/langbot_plugin/api/entities/builtin/pipeline/query.py``Query``query_id/launcher_type/launcher_id/sender_id/bot_uuid/pipeline_uuid`,没有 `workspace_id/account_id`
- `src/langbot_plugin/api/entities/builtin/provider/session.py``Session` 只按 `launcher_type + launcher_id` 表达会话。
- `src/langbot_plugin/api/proxies/langbot_api.py` 暴露 `get_bots/get_llm_models/invoke_llm/list_tools/vector_*` 等 Host API都是全局语义。
- `src/langbot_plugin/runtime/io/handlers/plugin.py``set_workspace_storage/get_workspace_storage``owner_type` 设为 `workspace`,但 `owner` 固定为 `default`
- LangBot 侧 `src/langbot/pkg/plugin/handler.py` 处理插件请求时,会把 `GET_BOTS``GET_LLM_MODELS``VECTOR_*` 等转到全局服务。
这意味着多租户落地时,不能只在 Web API 层过滤;插件可以通过 Host API 访问全局资源,所以 SDK/Runtime 通信也必须传递 workspace context。
## 开源版与商业版产品边界
LangBot 是开源软件,但多 workspace 能力本质上是 SaaS 控制面能力。如果把完整多 workspace、成员协作、订阅权益和配额代码都放进开源仓库只靠本地 feature flag 或本地 license check无法有效避免第三方 fork 后自建 SaaS。因此建议采用 open-core 架构:开源版保留单 workspace 执行能力,账户、订阅、权益和多 workspace 协作能力放到 LangBot Space/Cloud Control Plane 和商业模块中。
### 版本边界
推荐拆成三层:
- `LangBot Core OSS`:开源,自托管,默认只有一个隐式 `default workspace`。它可以在数据结构上兼容 workspace但产品能力上不提供创建多个 workspace、切换 workspace、成员邀请、成员权限管理、审计和多租户配额。
- `LangBot Space / Cloud Control Plane`:托管控制面,负责 account、workspace、membership、subscription、billing、entitlement、license token、workspace quota、marketplace 权益等能力。
- `LangBot Commercial Module`:商业闭源或私有包,承载多 workspace、团队协作、RBAC、自定义角色、审计日志、SAML/SSO、企业私有化授权等能力。
企业私有化版本可以采用 `LangBot Core + Commercial Module + License Token` 的形式交付。开源 Core 仍然可独立运行,但只能作为单 workspace 自托管产品,不提供 SaaS 多租户控制面。
### OSS 中如何保留兼容但不开放多 workspace
为了让后续商业版复用同一套资源隔离模型OSS 代码里可以保留 `workspace_uuid` 相关字段和默认 workspace 迁移,但应限制为单 workspace
- 首次初始化时创建一个 `Default Workspace`
- 所有资源自动归属这个 default workspace。
- 不暴露 `POST /api/v1/workspaces`
- 不暴露 workspace switcher。
- 不暴露成员邀请和成员角色管理。
- 不支持一个 account 加入多个 workspace。
- 不支持 workspace 数量大于 1。
- 前端不展示 workspace selector。
- API 层如果收到非 default workspace 的 `X-Workspace-Id`,直接拒绝。
也就是说OSS 可以是 workspace-aware但不是 multi-workspace-enabled。这样做的价值是代码结构提前适配租户隔离未来商业版不用重写所有资源模型同时开源版用户无法直接通过 UI/API 获得 SaaS 型多租户能力。
### 账户、订阅和权益抽到 Space
账户和订阅体系建议从 LangBot Core 中抽出,交给 Space 控制面:
```text
LangBot Space
-> Account
-> Workspace
-> Membership
-> Subscription
-> Entitlement
-> License Token
LangBot Core
-> Validate entitlement / license
-> Run bots, pipelines, plugins, MCP, RAG
-> Enforce local resource scope
-> Report usage
```
这样做有几个原因:
- 账号体系如果完全在本地,第三方容易直接改库创建 workspace/membership。
- 订阅、配额和商业权益如果完全在本地,容易绕过。
- Space 可以统一处理 OAuth、组织、邀请、付款、发票、套餐、权益、Marketplace 分发权限。
- LangBot Core 只作为执行面消费 Space 下发的 entitlement减少商业规则暴露。
### Entitlement 设计
Space 向 LangBot Core 下发签名权益,可以是在线校验,也可以为企业版提供短期/长期离线 license token。
示例:
```json
{
"edition": "oss",
"workspace_limit": 1,
"member_limit": 1,
"multi_workspace": false,
"rbac": false,
"audit_log": false,
"custom_roles": false,
"sso": false,
"commercial_use": false,
"expires_at": 1893456000
}
```
OSS 默认权益:
- `workspace_limit = 1`
- `member_limit = 1`
- `multi_workspace = false`
- `rbac = false`
- `audit_log = false`
- `sso = false`
Cloud/Pro/Enterprise 权益:
- `workspace_limit > 1`
- `member_limit > 1`
- `multi_workspace = true`
- `rbac = true`
- 可按套餐打开 audit、custom roles、SSO、usage reporting、enterprise support 等能力。
Core 执行面需要在关键入口强制校验 entitlement
- 创建 workspace。
- 邀请成员。
- 修改成员角色。
- 切换 workspace。
- 创建超过 quota 的资源。
- 开启商业模块功能。
### 商业模块边界
以下能力不建议进入 OSS 仓库的完整实现:
- 多 workspace 创建和切换。
- Workspace 成员邀请。
- Workspace RBAC 和自定义角色。
- Workspace 审计日志。
- Workspace 级用量和配额管理。
- 订阅、账单、发票。
- 企业 SSO/SAML/OIDC。
- 在线/离线 license 管理。
- 多租户 SaaS 运营控制台。
OSS 仓库可以保留接口占位、默认 workspace 兼容和必要的数据隔离字段,但完整交互、管理 UI、权益校验器和多 workspace policy engine 应由 Space 或商业模块提供。
### 防自建 SaaS 的现实边界
技术上无法 100% 阻止别人 fork 开源代码后自行改造。更可靠的策略是组合:
- 不把完整商业多 workspace 实现放进 OSS。
- Space 控制面提供账号、订阅、权益、Marketplace 和官方托管能力。
- 商业模块闭源或私有分发。
- 使用商标、云服务条款和商业 license 限制“自称官方 LangBot SaaS”或未经授权商用托管。
- 如果当前开源 license 对托管商用限制不足,需要单独评估 license 策略,必要时引入 open-core license 或新增商业附加条款。具体 license 调整需要法律评审。
结论:多 workspace 的底层 schema 可以在 OSS 中以 default workspace 兼容方式铺路,但多 workspace 产品能力、账户订阅权益、协作管理和 SaaS 控制面应放到 Space/商业模块,不作为开源版可直接使用的能力。
## 推荐总体架构
采用“单实例多 workspace资源行级隔离运行时上下文隔离”的架构
```mermaid
flowchart TD
A["Account"] --> B["WorkspaceMembership"]
B --> C["Workspace"]
C --> D["Bots"]
C --> E["Pipelines"]
C --> F["Models & Providers"]
C --> G["Knowledge Bases"]
C --> H["Extensions: Plugins / MCP"]
C --> I["API Keys & Webhooks"]
C --> J["Monitoring"]
D --> K["Runtime Query"]
E --> K
K --> L["Plugin Runtime"]
K --> M["MCP Runtime"]
L --> N["Workspace-scoped Host APIs"]
```
原则:
- 账户全局唯一workspace 是所有业务资源的归属边界。
- 所有 HTTP handler 在进入业务服务前解析出 `RequestContext(account, workspace, membership, permissions)`
- 所有 service 方法显式接收 `ctx``workspace_id`,禁止在业务服务里无条件 select 全表。
- 运行时对象的 key 从 `uuid` 扩展为 `(workspace_id, uuid)` 或使用全局唯一 uuid 但必须记录 workspace_id 并校验。
- 插件/MCP/知识库/模型调用都按 query 所属 workspace 过滤可用资源。
## 数据模型设计
### Account
替代现有 `users` 的语义,建议保留表名但升级字段,避免过大迁移:
字段建议:
- `id`
- `uuid`
- `email`
- `password_hash`
- `display_name`
- `avatar_url`
- `account_type`: `local | space`
- `status`: `active | disabled | deleted`
- `space_account_uuid`
- `space_access_token`
- `space_refresh_token`
- `space_access_token_expires_at`
- `space_api_key`
- `created_at`
- `updated_at`
兼容策略:
- 旧字段 `user` 迁移为 `email`,可以短期保留 alias。
-`password` 迁移为 `password_hash`也可先保持列名不变service 层改命名。
- JWT 中不要继续只放 email应放 `sub=account_uuid`
### Workspace
新增 `workspaces`
- `uuid`
- `name`
- `slug`
- `avatar_url`
- `type`: `personal | team`
- `status`: `active | suspended | deleted`
- `default_language`
- `created_by_account_uuid`
- `created_at`
- `updated_at`
每个账户首次登录时自动创建一个 personal workspace。旧单用户实例迁移时创建一个 `Default Workspace`
### WorkspaceMembership
新增 `workspace_memberships`
- `workspace_uuid`
- `account_uuid`
- `role`: `owner | admin | developer | operator | viewer`
- `status`: `active | invited | disabled`
- `invited_by_account_uuid`
- `joined_at`
- `created_at`
- `updated_at`
唯一索引:
- `(workspace_uuid, account_uuid)`
### WorkspaceInvitation
新增 `workspace_invitations`
- `uuid`
- `workspace_uuid`
- `email`
- `role`
- `token_hash`
- `expires_at`
- `accepted_at`
- `created_by_account_uuid`
- `created_at`
用于邀请外部用户加入 workspace。Space OAuth 登录时可以根据 email 自动匹配未接受邀请。
### Role 与 Permission
先用固定角色,后续再做自定义角色。
建议权限:
- `workspace.manage`
- `member.view`
- `member.invite`
- `member.update_role`
- `member.remove`
- `bot.view`
- `bot.manage`
- `pipeline.view`
- `pipeline.manage`
- `model.view`
- `model.manage`
- `knowledge.view`
- `knowledge.manage`
- `extension.view`
- `extension.manage`
- `monitoring.view`
- `apikey.manage`
- `webhook.manage`
- `billing.view`
- `billing.manage`
角色映射:
| Role | 说明 | 权限 |
| --- | --- | --- |
| owner | workspace 拥有者 | 全部权限;可转让 owner不可被其他角色移除 |
| admin | 管理员 | 除 owner 转让和删除 workspace 外的全部权限 |
| developer | 构建者 | 管理 bot、pipeline、model、knowledge、extension、webhook可看监控 |
| operator | 运营者 | 查看和启停 bot、查看监控、查看配置不可改密钥和删除资源 |
| viewer | 只读成员 | 只读资源和监控 |
### 业务资源加 workspace_uuid
以下表需要新增 `workspace_uuid`
- `bots`
- `legacy_pipelines`
- `pipeline_run_records`
- `model_providers`
- `llm_models`
- `embedding_models`
- `rerank_models`
- `plugin_settings`
- `mcp_servers`
- `knowledge_bases`
- `knowledge_base_files`
- `knowledge_base_chunks`
- `monitoring_*`
- `api_keys`
- `webhooks`
- `binary_storages`
- `metadata`
索引建议:
- 所有资源表加 `(workspace_uuid, created_at)``(workspace_uuid, updated_at)`
- 资源唯一键从单列改为 workspace 复合唯一:
- `bots.uuid` 可保持全局唯一,但查询仍必须带 workspace。
- `plugin_settings` 主键从 `(plugin_author, plugin_name)` 改为 `(workspace_uuid, plugin_author, plugin_name)`
- `mcp_servers.name` 如果未来要求唯一,必须是 `(workspace_uuid, name)`
- `metadata.key` 改为 `(workspace_uuid, key)`,系统级 metadata 单独放 `system_metadata` 或使用 `workspace_uuid=NULL`
- `binary_storages.unique_key` 建议改为 `workspace_uuid + owner_type + owner + key` 的 hash。
### API Key
API Key 必须归属于 workspace
- `workspace_uuid`
- `created_by_account_uuid`
- `scopes`
- `expires_at`
- `last_used_at`
- `status`
验证 API Key 后生成 `RequestContext`
- `account_uuid=None` 或 service account uuid
- `workspace_uuid=key.workspace_uuid`
- `permissions=key.scopes`
这样 `/api/v1/platform/bots/<uuid>/send_message` 之类接口不会跨 workspace 操作 bot。
## 后端改造方案
### RequestContext
新增统一上下文对象,例如:
```python
class RequestContext:
account_uuid: str | None
workspace_uuid: str
role: str | None
permissions: set[str]
auth_type: Literal["user_token", "api_key"]
```
改造 `RouterGroup.route()`
- `USER_TOKEN`:验证 JWT读取 `account_uuid`,再从 header/query/cookie 中解析 current workspace。
- `API_KEY`:验证 API Key直接得到 workspace。
- `USER_TOKEN_OR_API_KEY`:两者都返回同一种 `RequestContext`
- handler 参数从可选 `user_email` 升级为可选 `ctx`;兼容期同时支持 `user_email`
当前 workspace 传递方式:
- 推荐 header`X-Workspace-Id: <workspace_uuid>`
- Web 前端同时把当前 workspace 存在 localStorage。
- 如果未传,后端用账户最近使用 workspace 或第一个 active membership。
JWT payload
```json
{
"sub": "account_uuid",
"email": "user@example.com",
"iss": "LangBot-...",
"exp": 1234567890
}
```
不要把 workspace 写死在 JWT 里,否则切换 workspace 需要刷新 token。可以额外支持短 TTL workspace token但第一阶段不必。
### 服务层改造模式
所有 service 方法增加 `ctx``workspace_uuid`
```python
async def get_bots(self, ctx: RequestContext, include_secret: bool = True):
require(ctx, "bot.view")
query = sqlalchemy.select(Bot).where(Bot.workspace_uuid == ctx.workspace_uuid)
```
需要改造的服务:
- `UserService`:拆成 AccountService + WorkspaceService 更清晰。
- `ApiKeyService`:按 workspace 管理 key。
- `BotService`:所有 bot 查询/创建/更新/删除按 workspace。
- `PipelineService`:所有 pipeline 查询/默认 pipeline 按 workspace。
- `ModelProviderService` 和 model services按 workspace 隔离 provider 和 model。
- `MCPService`:按 workspace 管理 MCP server运行时按 workspace host。
- `KnowledgeService/RAGRuntimeService`:按 workspace 管理 KB、文件、collection。
- `MonitoringService`:记录和查询都带 workspace。
- `WebhookService`:按 workspace 管理 webhook。
- `PluginRuntimeConnector`:插件安装、设置、配置按 workspace。
### HTTP API 形态
保留现有路径,靠 `X-Workspace-Id` 表示当前 workspace可减少前端和 SDK 破坏:
- `GET /api/v1/workspaces`
- `POST /api/v1/workspaces`
- `GET /api/v1/workspaces/current`
- `PUT /api/v1/workspaces/current`
- `GET /api/v1/workspaces/<workspace_uuid>/members`
- `POST /api/v1/workspaces/<workspace_uuid>/invitations`
- `PUT /api/v1/workspaces/<workspace_uuid>/members/<account_uuid>`
- `DELETE /api/v1/workspaces/<workspace_uuid>/members/<account_uuid>`
现有资源 API
- `/api/v1/platform/bots`
- `/api/v1/pipelines`
- `/api/v1/provider/*`
- `/api/v1/plugins`
- `/api/v1/mcp`
- `/api/v1/knowledge`
继续保留,但必须从 `RequestContext.workspace_uuid` 过滤。
对外 API Key 也使用相同路径,只是由 key 决定 workspace。
### 初始化流程
现有 `/api/v1/user/init` 含义改为“创建首个账号和首个 workspace”
1. 如果系统没有任何 account
- 创建 account。
- 创建 personal/team workspace。
- 创建 owner membership。
- 创建默认 pipeline。
- 标记 wizard status 到该 workspace metadata。
2. 如果系统已有 account
- 禁止无邀请注册,除非配置允许公开注册。
- Space OAuth 登录后,如果没有 membership引导创建 workspace 或接受邀请。
`/api/v1/user/account-info` 不应再只返回 first user应返回
- `initialized`
- `registration_mode`
- `space_enabled`
- `default_login_methods`
登录成功后前端调用 `/api/v1/workspaces` 选择 workspace。
### 运行时隔离
第一阶段采用共享进程 + workspace-aware runtime
- `RuntimeBot` 增加 `workspace_uuid`
- `RuntimePipeline` 增加 `workspace_uuid`
- `Query` 增加 `workspace_uuid`,从 bot/pipeline 派生。
- `SessionManager.get_session()` key 从 `(launcher_type, launcher_id)` 改为 `(workspace_uuid, bot_uuid, launcher_type, launcher_id)`
- `PipelineManager.pipeline_dict` key 可保持 pipeline uuid但所有 load/get 都校验 workspace如果 uuid 不是全局唯一则改为 `(workspace_uuid, pipeline_uuid)`
- `ModelManager` provider/model 加 workspace 过滤;`get_model_by_uuid` 必须确保 query workspace 可访问。
- `ToolManager` 中 MCP tools、plugin tools 按 query workspace 过滤。
后续规模化时可演进:
- workspace runtime shard每个 workspace 一套 plugin runtime/MCP runtime。
- 大客户独立进程或独立数据库。
## Plugin SDK 与 Runtime 改造
### Query/Event 增加 workspace context
SDK `Query` 增加:
- `workspace_uuid: str`
- `workspace_slug: str | None`
- `account_uuid: str | None`,仅 Web/API 触发时可能有,聊天平台消息通常为空。
Event 模型通过 `event.query.workspace_uuid` 可拿到租户上下文;序列化时也应包含这些字段。
向后兼容:
- 字段可选,默认 `None`
- 老插件不感知这些字段也能跑。
- 新插件可通过 `ctx.event.query.workspace_uuid` 或新增 `ctx.get_workspace()` 访问。
### Host API 默认按当前 workspace 限制
`LangBotAPIProxy` 的以下方法必须由 Host 端按 workspace 过滤:
- `get_bots`
- `get_bot_info`
- `send_message`
- `get_llm_models`
- `invoke_llm`
- `list_plugins_manifest`
- `list_commands`
- `list_tools`
- `call_tool`
- `invoke_embedding`
- `vector_*`
- `list_knowledge_bases`
- `retrieve_knowledge`
建议新增显式方法:
- `get_workspace_info()`
- `get_current_account()`
- `get_workspace_storage(...)`
但不要让插件传入任意 workspace id 来越权访问。插件请求的 workspace 应由 Runtime 根据当前 query/plugin connection 填充。
### Workspace storage 修复
当前 SDK runtime 中:
```python
data["owner_type"] = "workspace"
data["owner"] = "default"
```
必须改为:
- 如果请求来自 query/eventowner 为 `workspace_uuid`
- 如果请求来自后台插件任务owner 为 plugin 安装所属 workspace。
- Host 侧 `binary_storages``workspace_uuid`,并在 unique key 中包含 workspace。
Plugin storage 建议也同时加 workspace
- 现在 plugin storage owner 是 `author/name`,这会导致同一插件在不同 workspace 的私有数据冲突。
- 应改为 `(workspace_uuid, plugin_id, key)`
### 插件安装与配置
`plugin_settings` 从全局变为 workspace-scoped
- 同一个插件可安装到多个 workspace。
- 每个 workspace 有自己的 enabled、priority、config、install_source、install_info。
- 插件 runtime 列表需要能按 workspace 过滤。
实现路线有两种:
1. 共享插件进程,插件代码只加载一份,设置和调用时附带 workspace。
2. 每个 workspace 一个插件容器实例,隔离最彻底但资源占用更高。
推荐第一阶段采用方案 1但要求
- 所有 RuntimeToLangBot/PluginToRuntime action 都能携带 `workspace_uuid`
- 插件 config 获取时按 workspace 返回。
- 插件 page API 请求必须校验当前用户在该 workspace 有访问权限。
### MCP
MCP server 是租户资源:
- `mcp_servers.workspace_uuid`
- MCP session key 从 `server_name` 改为 `(workspace_uuid, server_name)` 或使用全局 uuid。
- Pipeline extension preferences 中绑定 MCP server uuid 时,只能绑定同 workspace 的 server。
- MCP tool 列表在 query 执行时按 query.workspace_uuid + pipeline 绑定关系过滤。
## 前端改造
### Workspace selector
Home layout 顶部或 sidebar 增加 workspace selector
- 当前 workspace 名称和头像。
- 切换 workspace 后写入 `localStorage.currentWorkspaceId`
- 所有请求自动带 `X-Workspace-Id`
- 切换后刷新 sidebar 数据和页面缓存。
`BaseHttpClient` request interceptor 增加:
```ts
const workspaceId = localStorage.getItem("currentWorkspaceId");
if (workspaceId) config.headers["X-Workspace-Id"] = workspaceId;
```
### 用户与成员管理页面
新增页面:
- `/home/workspace/settings`
- `/home/workspace/members`
- `/home/workspace/invitations`
能力:
- owner/admin 邀请成员。
- owner/admin 修改成员角色。
- owner 移除成员、转让 owner。
- 所有人可切换 workspace。
- viewer/operator 在 UI 上隐藏不可操作按钮,但后端仍做权限校验。
### 登录与注册
登录后流程:
1. `authUser` 拿 token。
2. `initializeUserInfo()` 获取 account info。
3. `GET /api/v1/workspaces`
4. 如果没有 workspace进入创建 workspace 向导。
5. 如果有多个 workspace默认进入最近使用 workspace可切换。
注册页不再表达“初始化管理员账号”,而是:
- 首次系统启动:创建首个 owner + default workspace。
- 后续:根据配置允许公开注册,或只能接受邀请。
### 旧页面影响
需要逐个检查这些页面的数据加载是否都依赖当前 workspace
- Bots
- Pipelines
- Plugins/Market/MCP
- Knowledge
- Monitoring
- Models dialog
- API integration dialog
- Wizard
## 迁移方案
### 迁移阶段 0准备
- 引入 `workspaces``workspace_memberships``workspace_invitations`
-`users` 增加 `uuid/status/display_name` 等字段。
- 创建 `RequestContext`,但先不强制所有服务改完。
### 迁移阶段 1默认 workspace
对现有实例执行迁移:
1. 创建 `Default Workspace`
2. 找到现有第一个 user设为 owner。
3. 所有已有资源写入 `workspace_uuid=default_workspace_uuid`
4. `metadata` 迁入 default workspace确实全局的配置放到 `system_metadata`
5. `binary_storages``owner_type=workspace, owner=default` 改为 owner 为 default workspace uuid。
6. 插件 `plugin_settings` 归入 default workspace。
### 迁移阶段 2服务层强制 scope
- 改所有 service 查询,必须要求 `workspace_uuid`
- API Key 迁移为 workspace key。
- 所有写操作必须检查权限。
- 监控和任务查询按 workspace 过滤。
### 迁移阶段 3运行时上下文
- `Query``Session``RuntimeBot``RuntimePipeline` 增加 workspace。
- Plugin/MCP/Model/RAG runtime 全部按 workspace 过滤。
- 修复 SDK workspace storage。
### 迁移阶段 4前端多 workspace
- 登录后 workspace 选择。
- Header/sidebar workspace switcher。
- 成员和邀请管理。
- 所有 API 请求带 `X-Workspace-Id`
### 迁移阶段 5安全收敛
- 添加跨 workspace 越权测试。
- 添加 API Key scope 测试。
- 添加插件 Host API 过滤测试。
- 添加 MCP 和 RAG 隔离测试。
## 安全边界
必须防的场景:
- 用户 A 修改 URL 中 bot uuid访问用户 B workspace 的 bot。
- API Key 来自 workspace A但调用 workspace B 的 bot。
- 插件通过 `get_bots()` 枚举所有 workspace 的 bot。
- 插件通过 `workspace_storage` 读取其它 workspace 的数据。
- MCP server 名称相同导致 session 复用。
- monitoring session_id 相同导致数据串租户。
- Space OAuth 登录时,同 email 账户被错误绑定到已有本地 account。
建议策略:
- 所有资源访问都使用 `workspace_uuid + resource_id`
- 所有 service 方法入口做权限检查。
- 插件 Host API 的 workspace 不信任插件入参,只信任 query/runtime connection 上下文。
- API Key 只授予最小 scope默认不允许成员管理。
- owner 角色不能被普通 admin 移除或降权。
## 实施优先级
### P0基础租户骨架
- Account uuid/status。
- Workspace / Membership / Invitation。
- RequestContext。
- JWT 改为 account uuid。
- 前端 current workspace header。
### P1资源行级隔离
- Bots、Pipelines、Models、MCP、Plugins、Knowledge、Monitoring、API Keys 全部加 workspace_uuid。
- service 查询统一加 workspace filter。
- 权限矩阵落地。
### P2运行时隔离
- Query、Session、RuntimeBot、RuntimePipeline 加 workspace。
- Plugin Host API 和 MCP tools 按 workspace 过滤。
- SDK workspace storage 从 `default` 改为真实 workspace。
### P3协作体验
- 邀请成员。
- 成员列表和角色管理。
- workspace switcher。
- 最近使用 workspace。
### P4SaaS 运维增强
- Workspace 级用量统计。
- Workspace 级限额max_bots/max_pipelines/max_extensions/tokens/storage。
- 审计日志。
- workspace suspend/delete。
- 可选自定义角色。
## 测试计划
后端测试:
- 账户可加入多个 workspace。
- 同账户在不同 workspace 权限不同。
- viewer 不能创建/修改资源。
- API Key 只能访问所属 workspace。
- 所有资源 list/get/update/delete 都不能跨 workspace。
- 默认 workspace 迁移后旧数据可用。
运行时测试:
- 两个 workspace 使用相同 `launcher_id` 不共享 session。
- 两个 workspace 使用相同 MCP server name 不共享 MCP session。
- 插件 `get_bots()` 只能看到当前 workspace bot。
- 插件 `workspace_storage` 在不同 workspace 读写隔离。
- Pipeline 只调用当前 workspace 绑定的插件和 MCP tools。
前端测试:
- 登录后自动进入最近 workspace。
- 切换 workspace 后列表数据变化。
- 无权限按钮隐藏,直接调用 API 也被后端拒绝。
- 邀请成员流程完整。
迁移测试:
- SQLite 老实例迁移。
- PostgreSQL 老实例迁移。
- 已有 local account 迁移为 default workspace owner。
- 已有 Space account token 和 Space model provider API key 不丢失。
## 关键实现注意事项
- 不建议在第一版做数据库 schema-per-tenant。LangBot 当前 ORM 和运行时均以单库单表为主,先做 shared schema + workspace_uuid 成本更低。
- 不建议每个 workspace 立即启动独立 plugin runtime。先共享 runtime强制 action 带 workspace大客户隔离可作为后续部署形态。
- 不要只在前端过滤 workspace。插件、API Key、MCP、RAG 都能绕过前端,必须在后端和运行时层过滤。
- `metadata` 要拆清楚wizard status 属于 workspace系统版本/迁移信息属于 system。
- `users.user` 用 email 当主键语义不稳,应尽快引入 `account_uuid` 并让 JWT 以 uuid 为准。
- `plugin_settings` 当前主键没有 workspace改造时要先改主键/唯一约束,否则同插件无法在多个 workspace 配不同配置。
## 建议落地顺序
1. 新增 workspace/account/membership 表和 RequestContext。
2. 迁移旧数据到 default workspace。
3. 改 auth 和前端请求头,让每个请求都有 current workspace。
4. 从最核心资源开始逐个加 scopebot -> pipeline -> provider/model -> plugin/MCP -> knowledge -> monitoring。
5. 改 SDK Query/Event 和 runtime storage。
6. 上成员管理 UI 和邀请。
7. 做越权测试和迁移测试。
这个顺序的好处是可以较早让主 UI 在一个 workspace 下继续工作,同时把最危险的跨租户泄露面逐步收紧。

View File

@@ -69,10 +69,9 @@ dependencies = [
"chromadb>=1.0.0,<2.0.0",
"qdrant-client (>=1.15.1,<2.0.0)",
"pyseekdb==1.1.0.post3",
"langbot-plugin==0.3.10",
"langbot-plugin==0.3.8",
"asyncpg>=0.30.0",
"line-bot-sdk>=3.19.0",
"matrix-nio>=0.25.2",
"tboxsdk>=0.0.10",
"boto3>=1.35.0",
"pymilvus>=2.6.4",

View File

@@ -481,12 +481,6 @@ class DingTalkClient:
card_data['config'] = json.dumps({'autoLayout': card_auto_layout})
card_data['content'] = ''
# 将用户的消息内容作为卡片的查询参数,方便后续处理
if incoming_message.message_type == 'text':
card_data['query'] = incoming_message.get_text_list()[0]
else:
card_data['query'] = '...'
card_instance = dingtalk_stream.AICardReplier(self.client, incoming_message)
# print(card_instance)
# 先投放卡片: https://open.dingtalk.com/document/orgapp/create-and-deliver-cards

View File

@@ -1,10 +1,8 @@
import re
import time
import asyncio
from quart import request
import httpx
from quart import Quart
from typing import Callable, Dict, Any, Optional
from typing import Callable, Dict, Any
import langbot_plugin.api.entities.builtin.platform.events as platform_events
from .qqofficialevent import QQOfficialEvent
import json
@@ -34,8 +32,6 @@ class QQOfficialClient:
self.access_token = ''
self.access_token_expiry_time = None
self.logger = logger
self._msg_seq_counter = 0
self._token_refresh_task: Optional[asyncio.Task] = None
async def check_access_token(self):
"""检查access_token是否存在"""
@@ -54,18 +50,18 @@ class QQOfficialClient:
headers = {
'content-type': 'application/json',
}
response = await client.post(url, json=params, headers=headers)
if response.status_code != 200:
raise Exception(f'Failed to get access_token: HTTP {response.status_code} {response.text}')
response_data = response.json()
access_token = response_data.get('access_token')
expires_in = int(response_data.get('expires_in', 7200))
self.access_token_expiry_time = time.time() + expires_in - 60
if access_token:
self.access_token = access_token
await self.logger.info(f'access_token obtained, expires_in={expires_in}s')
else:
raise Exception('Failed to get access_token: no access_token in response')
try:
response = await client.post(url, json=params, headers=headers)
if response.status_code == 200:
response_data = response.json()
access_token = response_data.get('access_token')
expires_in = int(response_data.get('expires_in', 7200))
self.access_token_expiry_time = time.time() + expires_in - 60
if access_token:
self.access_token = access_token
except Exception as e:
await self.logger.error(f'获取access_token失败: {response_data}')
raise Exception(f'获取access_token失败: {e}')
async def handle_callback_request(self):
"""处理回调请求(独立端口模式,使用全局 request"""
@@ -91,10 +87,10 @@ class QQOfficialClient:
try:
body = await req.get_data()
await self.logger.info(f'Received request, body length: {len(body)}')
print(f'[QQ Official] Received request, body length: {len(body)}')
if not body or len(body) == 0:
await self.logger.info('Received empty body, might be health check or GET request')
print('[QQ Official] Received empty body, might be health check or GET request')
return {'code': 0, 'message': 'ok'}, 200
payload = json.loads(body)
@@ -115,6 +111,7 @@ class QQOfficialClient:
return {'code': 0, 'message': 'success'}
except Exception as e:
print(f'[QQ Official] ERROR: {traceback.format_exc()}')
await self.logger.error(f'Error in handle_callback_request: {traceback.format_exc()}')
return {'error': str(e)}, 400
@@ -142,24 +139,21 @@ class QQOfficialClient:
async def get_message(self, msg: dict) -> Dict[str, Any]:
"""获取消息"""
d = msg.get('d', {})
if not isinstance(d, dict):
return {}
message_data = {
't': msg.get('t', {}),
'user_openid': d.get('author', {}).get('user_openid', {}),
'timestamp': d.get('timestamp', {}),
'd_author_id': d.get('author', {}).get('id', {}),
'content': d.get('content', {}),
'd_id': d.get('id', {}),
'user_openid': msg.get('d', {}).get('author', {}).get('user_openid', {}),
'timestamp': msg.get('d', {}).get('timestamp', {}),
'd_author_id': msg.get('d', {}).get('author', {}).get('id', {}),
'content': msg.get('d', {}).get('content', {}),
'd_id': msg.get('d', {}).get('id', {}),
'id': msg.get('id', {}),
'channel_id': d.get('channel_id', {}),
'username': d.get('author', {}).get('username', {}),
'guild_id': d.get('guild_id', {}),
'member_openid': d.get('author', {}).get('openid', {}),
'group_openid': d.get('group_openid', {}),
'channel_id': msg.get('d', {}).get('channel_id', {}),
'username': msg.get('d', {}).get('author', {}).get('username', {}),
'guild_id': msg.get('d', {}).get('guild_id', {}),
'member_openid': msg.get('d', {}).get('author', {}).get('openid', {}),
'group_openid': msg.get('d', {}).get('group_openid', {}),
}
attachments = d.get('attachments', [])
attachments = msg.get('d', {}).get('attachments', [])
image_attachments = [attachment['url'] for attachment in attachments if await self.is_image(attachment)]
image_attachments_type = [
attachment['content_type'] for attachment in attachments if await self.is_image(attachment)
@@ -198,7 +192,7 @@ class QQOfficialClient:
if response.status_code == 200:
return
else:
await self.logger.error(f'Failed to send private message: {response_data}')
await self.logger.error(f'发送私聊消息失败: {response_data}')
raise ValueError(response)
async def send_group_text_msg(self, group_openid: str, content: str, msg_id: str):
@@ -221,7 +215,7 @@ class QQOfficialClient:
if response.status_code == 200:
return
else:
await self.logger.error(f'Failed to send group message: {response.json()}')
await self.logger.error(f'发送群聊消息失败:{response.json()}')
raise Exception(response.read().decode())
async def send_channle_group_text_msg(self, channel_id: str, content: str, msg_id: str):
@@ -244,7 +238,7 @@ class QQOfficialClient:
if response.status_code == 200:
return True
else:
await self.logger.error(f'Failed to send channel group message: {response.json()}')
await self.logger.error(f'发送频道群聊消息失败: {response.json()}')
raise Exception(response)
async def send_channle_private_text_msg(self, guild_id: str, content: str, msg_id: str):
@@ -267,224 +261,9 @@ class QQOfficialClient:
if response.status_code == 200:
return True
else:
await self.logger.error(f'Failed to send channel private message: {response.json()}')
await self.logger.error(f'发送频道私聊消息失败: {response.json()}')
raise Exception(response)
# ---- 富媒体消息 ----
# 媒体文件类型
MEDIA_TYPE_IMAGE = 1
MEDIA_TYPE_VIDEO = 2
MEDIA_TYPE_VOICE = 3
MEDIA_TYPE_FILE = 4
async def upload_media(
self,
target_type: str,
target_id: str,
file_type: int,
file_url: str = None,
file_data: str = None,
file_name: str = None,
) -> str:
"""上传媒体文件,返回 file_info。
Args:
target_type: 'c2c' | 'group'
target_id: 用户 openid 或群 openid
file_type: 1=图片, 2=视频, 3=语音, 4=文件
file_url: 在线 URL与 file_data 二选一)
file_data: base64 编码的文件数据或 data URL与 file_url 二选一)
file_name: 文件名file_type=4 时必填)
"""
if not await self.check_access_token():
await self.get_access_token()
if target_type == 'c2c':
url = f'{self.base_url}/v2/users/{target_id}/files'
elif target_type == 'group':
url = f'{self.base_url}/v2/groups/{target_id}/files'
else:
raise ValueError(f'Unsupported target_type: {target_type}')
body = {
'file_type': file_type,
'srv_send_msg': False,
}
if file_url:
body['url'] = file_url
elif file_data:
# 处理 data URL 格式: data:image/png;base64,xxxxx
if file_data.startswith('data:'):
match = re.match(r'^data:[^;]+;base64,(.+)$', file_data, re.DOTALL)
if match:
body['file_data'] = match.group(1)
else:
body['file_data'] = file_data
else:
body['file_data'] = file_data
else:
raise ValueError('file_url or file_data is required')
if file_type == self.MEDIA_TYPE_FILE and file_name:
body['file_name'] = file_name
async with httpx.AsyncClient(timeout=120) as client:
headers = {
'Authorization': f'QQBot {self.access_token}',
'Content-Type': 'application/json',
}
response = await client.post(url, headers=headers, json=body)
if response.status_code == 200:
data = response.json()
file_info = data.get('file_info', '')
preview = file_info[:80] + '...' if len(file_info) > 80 else file_info
await self.logger.info(f'Upload media success, file_info={preview}')
return file_info
else:
raise Exception(f'Failed to upload media: HTTP {response.status_code} {response.text}')
async def _send_media_msg(
self,
target_type: str,
target_id: str,
file_info: str,
msg_id: str = None,
content: str = None,
):
"""发送富媒体消息msg_type=7"""
if not await self.check_access_token():
await self.get_access_token()
if target_type == 'c2c':
url = f'{self.base_url}/v2/users/{target_id}/messages'
elif target_type == 'group':
url = f'{self.base_url}/v2/groups/{target_id}/messages'
else:
raise ValueError(f'Unsupported target_type: {target_type}')
self._msg_seq_counter += 1
msg_seq = self._msg_seq_counter
body = {
'msg_type': 7,
'media': {'file_info': file_info},
'msg_seq': msg_seq,
}
if content:
body['content'] = content
if msg_id:
body['msg_id'] = msg_id
async with httpx.AsyncClient(timeout=120) as client:
headers = {
'Authorization': f'QQBot {self.access_token}',
'Content-Type': 'application/json',
}
await self.logger.info(f'Sending rich media: {json.dumps(body, ensure_ascii=False)[:200]}')
response = await client.post(url, headers=headers, json=body)
if response.status_code != 200:
raise Exception(f'Failed to send rich media message: HTTP {response.status_code} {response.text}')
async def send_image_msg(
self,
target_type: str,
target_id: str,
file_url: str = None,
file_data: str = None,
msg_id: str = None,
content: str = None,
):
"""发送图片消息"""
file_info = await self.upload_media(
target_type,
target_id,
self.MEDIA_TYPE_IMAGE,
file_url=file_url,
file_data=file_data,
)
await self._send_media_msg(target_type, target_id, file_info, msg_id, content)
async def send_voice_msg(
self,
target_type: str,
target_id: str,
file_url: str = None,
file_data: str = None,
msg_id: str = None,
):
"""发送语音消息"""
file_info = await self.upload_media(
target_type,
target_id,
self.MEDIA_TYPE_VOICE,
file_url=file_url,
file_data=file_data,
)
await self._send_media_msg(target_type, target_id, file_info, msg_id)
async def send_file_msg(
self,
target_type: str,
target_id: str,
file_url: str = None,
file_data: str = None,
file_name: str = None,
msg_id: str = None,
):
"""发送文件消息(含视频)"""
file_info = await self.upload_media(
target_type,
target_id,
self.MEDIA_TYPE_FILE,
file_url=file_url,
file_data=file_data,
file_name=file_name,
)
await self._send_media_msg(target_type, target_id, file_info, msg_id)
async def send_stream_msg(
self,
user_openid: str,
content: str,
event_id: str,
msg_id: str,
msg_seq: int = 1,
index: int = 0,
stream_msg_id: str = None,
input_state: int = 1,
):
"""发送流式消息C2C 私聊)。
Args:
input_state: 1=生成中, 10=生成结束
"""
if not await self.check_access_token():
await self.get_access_token()
url = f'{self.base_url}/v2/users/{user_openid}/stream_messages'
body = {
'input_mode': 'replace',
'input_state': input_state,
'content_type': 'markdown',
'content_raw': content,
'event_id': event_id,
'msg_id': msg_id,
'msg_seq': msg_seq,
'index': index,
}
if stream_msg_id:
body['stream_msg_id'] = stream_msg_id
async with httpx.AsyncClient(timeout=120) as client:
headers = {
'Authorization': f'QQBot {self.access_token}',
'Content-Type': 'application/json',
}
response = await client.post(url, headers=headers, json=body)
if response.status_code != 200:
raise Exception(f'Failed to send stream message: HTTP {response.status_code} {response.text}')
return response.json()
async def is_token_expired(self):
"""检查token是否过期"""
if self.access_token_expiry_time is None:
@@ -513,325 +292,3 @@ class QQOfficialClient:
'signature': signature,
}
return response
# ---- WebSocket Gateway ----
# Reference: https://bot.q.qq.com/wiki/develop/api-v2/dev-prepare/interface-framework/event-emit.html
INTENT_GUILDS = 1 << 0
INTENT_GUILD_MEMBERS = 1 << 1
INTENT_PUBLIC_GUILD_MESSAGES = 1 << 30
INTENT_DIRECT_MESSAGE = 1 << 12
INTENT_GROUP_AND_C2C = 1 << 25
INTENT_INTERACTION = 1 << 26
FULL_INTENTS = (
INTENT_GUILDS
| INTENT_GUILD_MEMBERS
| INTENT_PUBLIC_GUILD_MESSAGES
| INTENT_DIRECT_MESSAGE
| INTENT_GROUP_AND_C2C
| INTENT_INTERACTION
)
async def get_gateway_url(self) -> str:
"""获取 WebSocket 网关地址"""
if not await self.check_access_token():
await self.get_access_token()
url = f'{self.base_url}/gateway'
async with httpx.AsyncClient() as client:
headers = {
'Authorization': f'QQBot {self.access_token}',
}
response = await client.get(url, headers=headers)
if response.status_code == 200:
data = response.json()
ws_url = data.get('url', '')
if not ws_url:
raise Exception('Gateway URL is empty')
return ws_url
else:
raise Exception(f'Failed to get Gateway URL: HTTP {response.status_code} {response.text}')
async def _background_token_refresh(self):
"""在 token 到期前主动刷新"""
try:
while True:
if self.access_token_expiry_time:
remain = self.access_token_expiry_time - time.time()
if remain > 120:
await asyncio.sleep(remain - 60)
continue
self.access_token = ''
self.access_token_expiry_time = None
if await self.check_access_token():
await asyncio.sleep(60)
else:
await self.get_access_token()
await asyncio.sleep(60)
except asyncio.CancelledError:
pass
async def connect_gateway(
self,
on_event: Callable[[str, dict], Any],
on_ready: Optional[Callable[[], Any]] = None,
on_error: Optional[Callable[[Exception], Any]] = None,
):
"""WebSocket 网关连接,含重连逻辑。持续重连直到达到最大次数或被取消。
Args:
on_event: 收到 op=0 Dispatch 事件时的回调,参数为 (event_type, event_data)
on_ready: 连接就绪 (收到 READY) 时的回调
on_error: 发生错误时的回调
"""
import websockets
session_id = ''
last_seq = 0
reconnect_attempts = 0
max_reconnect_attempts = 100
backoff_delays = [1, 2, 5, 10, 30, 60]
rate_limit_delay = 60
# Cancel previous token refresh task if any
if self._token_refresh_task and not self._token_refresh_task.done():
self._token_refresh_task.cancel()
try:
await self._token_refresh_task
except asyncio.CancelledError:
pass
self._token_refresh_task = None
while reconnect_attempts <= max_reconnect_attempts:
heartbeat_interval = 45000
should_refresh_token = False
ws = None
heartbeat_task = None
# Refresh token if needed
if should_refresh_token:
self.access_token = ''
self.access_token_expiry_time = None
try:
ws_url = await self.get_gateway_url()
await self.logger.info(f'Gateway URL obtained: {ws_url[:60]}...')
except Exception as e:
error_msg = str(e)
await self.logger.error(f'Failed to get gateway URL: {e}')
reconnect_attempts += 1
if '100017' in error_msg or '频率' in error_msg or 'Too many' in error_msg:
delay = rate_limit_delay
else:
delay = backoff_delays[min(reconnect_attempts - 1, len(backoff_delays) - 1)]
await self.logger.info(f'Reconnecting in {delay}s (attempt {reconnect_attempts})')
await asyncio.sleep(delay)
continue
try:
await self.logger.info('Connecting to WebSocket gateway...')
ws = await websockets.connect(ws_url)
await self.logger.info('WebSocket connected')
except Exception as e:
await self.logger.error(f'WebSocket connection failed: {e}')
reconnect_attempts += 1
delay = backoff_delays[min(reconnect_attempts - 1, len(backoff_delays) - 1)]
await self.logger.info(f'Reconnecting in {delay}s (attempt {reconnect_attempts})')
await asyncio.sleep(delay)
continue
try:
async for raw_msg in ws:
try:
payload = json.loads(raw_msg)
except json.JSONDecodeError:
await self.logger.error(f'Failed to parse message: {raw_msg}')
continue
op = payload.get('op')
d = payload.get('d', {})
s = payload.get('s')
t = payload.get('t')
if not isinstance(d, dict):
d = {}
if op == 10: # Hello
heartbeat_interval = d.get('heartbeat_interval', 45000)
await self.logger.info(f'Received Hello, heartbeat_interval={heartbeat_interval}ms')
# Send Identify or Resume
if session_id and last_seq > 0:
resume_payload = {
'op': 6,
'd': {
'token': f'QQBot {self.access_token}',
'session_id': session_id,
'seq': last_seq,
},
}
await ws.send(json.dumps(resume_payload))
await self.logger.info(f'Sent Resume, session_id={session_id}, seq={last_seq}')
else:
identify_payload = {
'op': 2,
'd': {
'token': f'QQBot {self.access_token}',
'intents': self.FULL_INTENTS,
'shard': [0, 1],
},
}
await ws.send(json.dumps(identify_payload))
await self.logger.info(f'Sent Identify, intents={self.FULL_INTENTS}')
# Start heartbeat
async def _heartbeat_loop(conn, interval_ms):
interval_sec = interval_ms / 1000.0
try:
while True:
await asyncio.sleep(interval_sec)
try:
hb_payload = {'op': 1, 'd': last_seq}
await conn.send(json.dumps(hb_payload))
except Exception:
break
except asyncio.CancelledError:
pass
heartbeat_task = asyncio.create_task(_heartbeat_loop(ws, heartbeat_interval))
elif op == 0: # Dispatch
if s is not None:
last_seq = s
if t == 'READY':
session_id = d.get('session_id', '')
reconnect_attempts = 0
await self.logger.info(f'READY, session_id={session_id}')
if on_ready:
try:
result = on_ready()
if asyncio.iscoroutine(result):
await result
except Exception:
pass
# Track token refresh task to avoid leaks
if self._token_refresh_task and not self._token_refresh_task.done():
self._token_refresh_task.cancel()
try:
await self._token_refresh_task
except asyncio.CancelledError:
pass
self._token_refresh_task = asyncio.create_task(self._background_token_refresh())
elif t == 'RESUMED':
reconnect_attempts = 0
await self.logger.info('RESUMED')
else:
await self.logger.debug(f'Received event: {t}, seq={s}')
if on_event:
try:
result = on_event(t, d)
if asyncio.iscoroutine(result):
await result
except Exception:
await self.logger.error(f'Error handling event {t}: {traceback.format_exc()}')
elif op == 11: # Heartbeat ACK
pass
elif op == 7: # Reconnect
await self.logger.info('Received Reconnect directive')
break
elif op == 9: # Invalid Session
can_resume = d.get('can_resume', False)
await self.logger.warning(f'Invalid Session, can_resume={can_resume}')
if not can_resume:
session_id = ''
last_seq = 0
should_refresh_token = True
break
# Connection closed normally (end of async for)
try:
close_code = ws.close_code
close_reason = ws.close_reason or ''
except Exception:
close_code = None
close_reason = ''
await self.logger.info(f'Connection closed, code={close_code}, reason={close_reason}')
if close_code == 4004:
should_refresh_token = True
elif close_code in (4006, 4007, 4009):
session_id = ''
last_seq = 0
should_refresh_token = True
elif close_code == 4008:
reconnect_attempts += 1
delay = rate_limit_delay
await self.logger.info(
f'Rate limited, waiting {delay}s before reconnect (attempt {reconnect_attempts})'
)
await asyncio.sleep(delay)
continue
elif close_code in (4914, 4915):
err = Exception(f'Bot disconnected/banned (close_code={close_code})')
if on_error:
await self._safe_callback(on_error, err)
return
elif close_code in (4900, 4901, 4902, 4903, 4904, 4905, 4906, 4907, 4908, 4909, 4910, 4911, 4912, 4913):
session_id = ''
last_seq = 0
if close_code == 1000:
return
except asyncio.CancelledError:
raise
except Exception:
await self.logger.error(f'Unexpected error in WebSocket loop: {traceback.format_exc()}')
finally:
if heartbeat_task:
heartbeat_task.cancel()
try:
await heartbeat_task
except asyncio.CancelledError:
pass
if ws:
try:
await ws.close()
except Exception:
pass
# If we reach here, we need to reconnect
reconnect_attempts += 1
if reconnect_attempts > max_reconnect_attempts:
await self.logger.error(f'Max reconnect attempts ({max_reconnect_attempts}) reached, stopping')
if on_error:
await self._safe_callback(on_error, Exception('Max reconnect attempts reached'))
return
delay = backoff_delays[min(reconnect_attempts - 1, len(backoff_delays) - 1)]
await self.logger.info(f'Reconnecting in {delay}s (attempt {reconnect_attempts})')
await asyncio.sleep(delay)
async def _safe_callback(self, callback, *args):
"""Safely invoke a callback, handling both sync and async functions."""
try:
result = callback(*args)
if asyncio.iscoroutine(result):
await result
except Exception:
pass
async def connect_gateway_loop(
self,
on_event: Callable[[str, dict], Any],
on_ready: Optional[Callable[[], Any]] = None,
on_error: Optional[Callable[[Exception], Any]] = None,
):
"""持续重连的网关循环。"""
await self.connect_gateway(on_event, on_ready, on_error)

View File

@@ -1,384 +0,0 @@
"""Embed widget routes - serve embeddable chat widget for external websites.
All user-facing URLs are keyed by **bot_uuid** (not pipeline_uuid) so that
internal pipeline identifiers are never exposed to end-users. Each handler
resolves the bot_uuid to the owning ``web_page_bot`` RuntimeBot and extracts
the bound pipeline_uuid for internal routing.
"""
import asyncio
import datetime
import json
import logging
import uuid
import hmac
import hashlib
import time
import re
import httpx
import quart
from ... import group
from ......utils import paths
from ......platform.sources.websocket_manager import ws_connection_manager
logger = logging.getLogger(__name__)
# Cache the widget template content
_widget_template_cache: str | None = None
_logo_bytes_cache: bytes | None = None
def _is_valid_uuid(s: str) -> bool:
return bool(re.match(r'^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$', s))
def _get_widget_template() -> str:
"""Load and cache the widget JS template."""
global _widget_template_cache
if _widget_template_cache is None:
template_path = paths.get_resource_path('templates/embed/widget.js')
with open(template_path, 'r', encoding='utf-8') as f:
_widget_template_cache = f.read()
return _widget_template_cache
def _get_logo_bytes() -> bytes:
"""Load and cache the logo image."""
global _logo_bytes_cache
if _logo_bytes_cache is None:
logo_path = paths.get_resource_path('templates/embed/logo.webp')
with open(logo_path, 'rb') as f:
_logo_bytes_cache = f.read()
return _logo_bytes_cache
@group.group_class('embed', '/api/v1/embed')
class EmbedRouterGroup(group.RouterGroup):
# -- helpers -------------------------------------------------------------
def _resolve_bot(self, bot_uuid: str):
"""Resolve *bot_uuid* to ``(runtime_bot, pipeline_uuid)``.
Returns ``(None, None)`` when the bot does not exist, is not a
``web_page_bot``, is disabled, or has no pipeline bound.
"""
for bot in self.ap.platform_mgr.bots:
if (
bot.bot_entity.uuid == bot_uuid
and bot.bot_entity.adapter == 'web_page_bot'
and bot.bot_entity.enable
and bot.bot_entity.use_pipeline_uuid
):
return bot, bot.bot_entity.use_pipeline_uuid
return None, None
def _get_bot_config(self, bot_uuid: str) -> dict:
for bot in self.ap.platform_mgr.bots:
if bot.bot_entity.uuid == bot_uuid and bot.bot_entity.adapter == 'web_page_bot':
return bot.bot_entity.adapter_config
return {}
async def _verify_session_token(self, request, bot_uuid: str) -> bool:
config = self._get_bot_config(bot_uuid)
secret = config.get('turnstile_secret_key', '')
if not secret:
return True
auth_header = request.headers.get('Authorization', '')
if not auth_header.startswith('Bearer '):
return False
token = auth_header[7:]
try:
ts_str, mac = token.split('.', 1)
ts = float(ts_str)
if time.time() - ts > 86400:
return False
expected_mac = hmac.new(secret.encode(), f'{ts_str}'.encode(), hashlib.sha256).hexdigest()
return hmac.compare_digest(mac, expected_mac)
except Exception:
return False
# -- routes --------------------------------------------------------------
async def initialize(self) -> None:
@self.route('/<bot_uuid>/turnstile/verify', methods=['POST'], auth_type=group.AuthType.NONE)
async def verify_turnstile(bot_uuid: str) -> str:
if not _is_valid_uuid(bot_uuid):
return self.http_status(400, -1, 'Invalid bot_uuid format')
runtime_bot, pipeline_uuid = self._resolve_bot(bot_uuid)
if runtime_bot is None:
return self.http_status(404, -1, 'Bot not found or not available')
try:
data = await quart.request.get_json()
token = data.get('token')
if not token:
return self.http_status(400, -1, 'Token is required')
config = self._get_bot_config(bot_uuid)
secret = config.get('turnstile_secret_key', '')
if not secret:
ts = time.time()
return self.success(data={'token': f'{ts}.dummy'})
async with httpx.AsyncClient() as client:
resp = await client.post(
'https://challenges.cloudflare.com/turnstile/v0/siteverify',
data={'secret': secret, 'response': token},
)
result = resp.json()
if not result.get('success'):
return self.http_status(403, -1, 'Turnstile verification failed')
ts = time.time()
mac = hmac.new(secret.encode(), f'{ts}'.encode(), hashlib.sha256).hexdigest()
session_token = f'{ts}.{mac}'
return self.success(data={'token': session_token})
except Exception as e:
logger.error(f'Turnstile verify failed: {e}', exc_info=True)
return self.http_status(500, -1, 'Internal server error')
@self.route('/<bot_uuid>/widget.js', methods=['GET'], auth_type=group.AuthType.NONE)
async def serve_widget(bot_uuid: str) -> quart.Response:
"""Serve the embed widget JavaScript with injected configuration."""
if not _is_valid_uuid(bot_uuid):
return self.http_status(400, -1, 'Invalid bot_uuid format')
runtime_bot, pipeline_uuid = self._resolve_bot(bot_uuid)
if runtime_bot is None:
return quart.Response(
'// Bot not found or not available', status=404, content_type='application/javascript'
)
try:
template = _get_widget_template()
except FileNotFoundError:
return quart.Response('// Widget template not found', status=404, content_type='application/javascript')
base_url = quart.request.host_url.rstrip('/')
webhook_prefix = self.ap.instance_config.data.get('api', {}).get('webhook_prefix', '')
if webhook_prefix:
base_url = webhook_prefix.rstrip('/')
if not re.match(r'^https?://[a-zA-Z0-9._:/-]+$', base_url):
base_url = quart.request.host_url.rstrip('/')
config = self._get_bot_config(bot_uuid)
site_key = config.get('turnstile_site_key', '')
locale = config.get('language', 'en_US') or 'en_US'
bubble_icon = config.get('bubble_icon', 'logo') or 'logo'
widget_js = template.replace('__LANGBOT_TURNSTILE_SITE_KEY__', site_key)
widget_js = widget_js.replace('__LANGBOT_BOT_UUID__', bot_uuid)
widget_js = widget_js.replace('__LANGBOT_BASE_URL__', base_url)
widget_js = widget_js.replace('__LANGBOT_LOCALE__', locale)
widget_js = widget_js.replace('__LANGBOT_BUBBLE_ICON__', bubble_icon)
response = quart.Response(widget_js, content_type='application/javascript; charset=utf-8')
response.headers['Cache-Control'] = 'public, max-age=300'
return response
@self.route('/logo', methods=['GET'], auth_type=group.AuthType.NONE)
async def serve_logo() -> quart.Response:
"""Serve the LangBot logo for the embed widget."""
try:
logo_data = _get_logo_bytes()
except FileNotFoundError:
return quart.Response('', status=404)
response = quart.Response(logo_data, content_type='image/webp')
response.headers['Cache-Control'] = 'public, max-age=86400'
return response
@self.route('/<bot_uuid>/messages/<session_type>', methods=['GET'], auth_type=group.AuthType.NONE)
async def get_embed_messages(bot_uuid: str, session_type: str) -> str:
if not _is_valid_uuid(bot_uuid):
return self.http_status(400, -1, 'Invalid bot_uuid format')
runtime_bot, pipeline_uuid = self._resolve_bot(bot_uuid)
if runtime_bot is None:
return self.http_status(404, -1, 'Bot not found or not available')
if not await self._verify_session_token(quart.request, bot_uuid):
return self.http_status(403, -1, 'Unauthorized or session expired')
try:
if session_type not in ['person', 'group']:
return self.http_status(400, -1, 'session_type must be person or group')
websocket_adapter = self.ap.platform_mgr.websocket_proxy_bot.adapter
if not websocket_adapter:
return self.http_status(404, -1, 'WebSocket adapter not found')
messages = websocket_adapter.get_websocket_messages(pipeline_uuid, session_type)
return self.success(data={'messages': messages})
except Exception as e:
logger.error(f'Failed to get embed messages: {e}', exc_info=True)
return self.http_status(500, -1, 'Internal server error')
@self.route('/<bot_uuid>/reset/<session_type>', methods=['POST'], auth_type=group.AuthType.NONE)
async def reset_embed_session(bot_uuid: str, session_type: str) -> str:
if not _is_valid_uuid(bot_uuid):
return self.http_status(400, -1, 'Invalid bot_uuid format')
runtime_bot, pipeline_uuid = self._resolve_bot(bot_uuid)
if runtime_bot is None:
return self.http_status(404, -1, 'Bot not found or not available')
if not await self._verify_session_token(quart.request, bot_uuid):
return self.http_status(403, -1, 'Unauthorized or session expired')
try:
if session_type not in ['person', 'group']:
return self.http_status(400, -1, 'session_type must be person or group')
websocket_adapter = self.ap.platform_mgr.websocket_proxy_bot.adapter
if not websocket_adapter:
return self.http_status(404, -1, 'WebSocket adapter not found')
websocket_adapter.reset_session(pipeline_uuid, session_type)
return self.success(data={'message': 'Session reset successfully'})
except Exception as e:
logger.error(f'Failed to reset embed session: {e}', exc_info=True)
return self.http_status(500, -1, 'Internal server error')
@self.route('/<bot_uuid>/feedback', methods=['POST'], auth_type=group.AuthType.NONE)
async def submit_feedback(bot_uuid: str) -> str:
if not _is_valid_uuid(bot_uuid):
return self.http_status(400, -1, 'Invalid bot_uuid format')
runtime_bot, pipeline_uuid = self._resolve_bot(bot_uuid)
if runtime_bot is None:
return self.http_status(404, -1, 'Bot not found or not available')
if not await self._verify_session_token(quart.request, bot_uuid):
return self.http_status(403, -1, 'Unauthorized or session expired')
try:
data = await quart.request.get_json()
message_id = data.get('message_id', '')
feedback_type = data.get('feedback_type')
if feedback_type not in (1, 2, 3):
return self.http_status(400, -1, 'feedback_type must be 1 (like), 2 (dislike), or 3 (cancel)')
feedback_id = f'embed_{uuid.uuid4().hex[:12]}'
await self.ap.monitoring_service.record_feedback(
feedback_id=feedback_id,
feedback_type=feedback_type,
bot_id=runtime_bot.bot_entity.uuid,
bot_name=runtime_bot.bot_entity.name or bot_uuid,
pipeline_id=pipeline_uuid,
message_id=str(message_id),
platform='web_page_bot',
)
return self.success(data={'feedback_id': feedback_id})
except Exception as e:
logger.error(f'Failed to record feedback: {e}', exc_info=True)
return self.http_status(500, -1, 'Internal server error')
# -- Embed WebSocket endpoint ----------------------------------------
@self.quart_app.websocket(self.path + '/<bot_uuid>/ws/connect')
async def embed_websocket_connect(bot_uuid: str):
"""WebSocket connection for embed widget, keyed by bot_uuid."""
if not _is_valid_uuid(bot_uuid):
await quart.websocket.send(json.dumps({'type': 'error', 'message': 'Invalid bot_uuid format'}))
return
runtime_bot, pipeline_uuid = self._resolve_bot(bot_uuid)
if runtime_bot is None:
await quart.websocket.send(json.dumps({'type': 'error', 'message': 'Bot not found or not available'}))
return
session_type = quart.websocket.args.get('session_type', 'person')
if session_type not in ['person', 'group']:
await quart.websocket.send(
json.dumps({'type': 'error', 'message': 'session_type must be person or group'})
)
return
websocket_adapter = self.ap.platform_mgr.websocket_proxy_bot.adapter
if not websocket_adapter:
await quart.websocket.send(json.dumps({'type': 'error', 'message': 'WebSocket adapter not found'}))
return
try:
connection = await ws_connection_manager.add_connection(
websocket=quart.websocket._get_current_object(),
pipeline_uuid=pipeline_uuid,
session_type=session_type,
metadata={'user_agent': quart.websocket.headers.get('User-Agent', '')},
)
await quart.websocket.send(
json.dumps(
{
'type': 'connected',
'connection_id': connection.connection_id,
'bot_uuid': bot_uuid,
'session_type': session_type,
'timestamp': connection.created_at.isoformat(),
}
)
)
logger.debug(
f'Embed WebSocket connected: {connection.connection_id} '
f'(bot={bot_uuid}, pipeline={pipeline_uuid}, session_type={session_type})'
)
receive_task = asyncio.create_task(self._handle_receive(connection, websocket_adapter, runtime_bot))
send_task = asyncio.create_task(self._handle_send(connection))
try:
await asyncio.gather(receive_task, send_task)
except Exception as e:
logger.error(f'Embed WebSocket task error: {e}')
finally:
await ws_connection_manager.remove_connection(connection.connection_id)
except Exception as e:
logger.error(f'Embed WebSocket connection error: {e}', exc_info=True)
try:
await quart.websocket.send(json.dumps({'type': 'error', 'message': 'Internal server error'}))
except Exception:
pass
# -- WebSocket receive/send helpers --------------------------------------
async def _handle_receive(self, connection, websocket_adapter, owner_bot):
try:
while connection.is_active:
message = await quart.websocket.receive()
await ws_connection_manager.update_activity(connection.connection_id)
try:
data = json.loads(message)
message_type = data.get('type', 'message')
if message_type == 'ping':
await connection.send_queue.put(
{'type': 'pong', 'timestamp': datetime.datetime.now().isoformat()}
)
elif message_type == 'message':
await websocket_adapter.handle_websocket_message(connection, data, owner_bot=owner_bot)
elif message_type == 'disconnect':
break
except json.JSONDecodeError:
await connection.send_queue.put({'type': 'error', 'message': 'Invalid JSON format'})
except Exception as e:
logger.error(f'Embed receive error: {e}', exc_info=True)
finally:
connection.is_active = False
async def _handle_send(self, connection):
try:
while connection.is_active:
try:
message = await asyncio.wait_for(connection.send_queue.get(), timeout=1.0)
await quart.websocket.send(json.dumps(message))
except asyncio.TimeoutError:
continue
except Exception as e:
logger.error(f'Embed send error: {e}', exc_info=True)
finally:
connection.is_active = False

View File

@@ -43,9 +43,6 @@ class WebSocketChatRouterGroup(group.RouterGroup):
await quart.websocket.send(json.dumps({'type': 'error', 'message': 'WebSocket adapter not found'}))
return
# Find the owning bot for this pipeline (e.g. a web_page_bot)
owner_bot = self._find_owner_bot(pipeline_uuid)
# 注册连接
connection = await ws_connection_manager.add_connection(
websocket=quart.websocket._get_current_object(),
@@ -73,7 +70,7 @@ class WebSocketChatRouterGroup(group.RouterGroup):
)
# 创建接收和发送任务
receive_task = asyncio.create_task(self._handle_receive(connection, websocket_adapter, owner_bot))
receive_task = asyncio.create_task(self._handle_receive(connection, websocket_adapter))
send_task = asyncio.create_task(self._handle_send(connection))
# 等待任务完成
@@ -181,14 +178,7 @@ class WebSocketChatRouterGroup(group.RouterGroup):
except Exception as e:
return self.http_status(500, -1, f'Internal server error: {str(e)}')
def _find_owner_bot(self, pipeline_uuid: str):
"""Find a user-created bot (e.g. web_page_bot) that owns this pipeline."""
for bot in self.ap.platform_mgr.bots:
if bot.bot_entity.adapter == 'web_page_bot' and bot.bot_entity.use_pipeline_uuid == pipeline_uuid:
return bot
return None
async def _handle_receive(self, connection, websocket_adapter, owner_bot=None):
async def _handle_receive(self, connection, websocket_adapter):
"""处理接收消息的任务"""
try:
while connection.is_active:
@@ -213,7 +203,7 @@ class WebSocketChatRouterGroup(group.RouterGroup):
logger.debug(f'收到消息: {data} from {connection.connection_id}')
# 处理消息不等待响应响应会通过broadcast异步发送
await websocket_adapter.handle_websocket_message(connection, data, owner_bot=owner_bot)
await websocket_adapter.handle_websocket_message(connection, data)
elif message_type == 'disconnect':
# 客户端主动断开

View File

@@ -6,38 +6,11 @@ import re
import httpx
import uuid
import os
import posixpath
from .....core import taskmgr
from .. import group
from langbot_plugin.runtime.plugin.mgr import PluginInstallSource
# Resolve the built-in page SDK JS from the langbot_plugin package
_PAGE_SDK_PATH = None
try:
import langbot_plugin.assets as _assets_pkg
_candidate = os.path.join(os.path.dirname(_assets_pkg.__file__), 'langbot-page-sdk.js')
if os.path.exists(_candidate):
_PAGE_SDK_PATH = _candidate
except Exception:
pass
def _normalize_plugin_asset_path(filepath: str) -> str | None:
filepath = filepath.replace('\\', '/')
if filepath.startswith('/'):
return None
normalized = posixpath.normpath(filepath)
if normalized == '.' or normalized.startswith('../') or normalized == '..':
return None
if normalized.startswith('components/pages/'):
return normalized
return f'assets/{normalized}'
@group.group_class('plugins', '/api/v1/plugins')
class PluginsRouterGroup(group.RouterGroup):
@@ -54,15 +27,6 @@ class PluginsRouterGroup(group.RouterGroup):
return None
async def initialize(self) -> None:
@self.route('/_sdk/page-sdk.js', methods=['GET'], auth_type=group.AuthType.NONE)
async def _() -> quart.Response:
"""Serve the built-in LangBot page SDK JavaScript."""
if _PAGE_SDK_PATH and os.path.exists(_PAGE_SDK_PATH):
with open(_PAGE_SDK_PATH, 'r') as f:
content = f.read()
return quart.Response(content, mimetype='application/javascript')
return quart.Response('// SDK not found', status=404, mimetype='application/javascript')
@self.route('', methods=['GET'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
async def _() -> str:
plugins = await self.ap.plugin_connector.list_plugins()
@@ -171,62 +135,15 @@ class PluginsRouterGroup(group.RouterGroup):
return quart.Response(icon_data, mimetype=mime_type)
@self.route(
'/<author>/<plugin_name>/assets/<path:filepath>',
'/<author>/<plugin_name>/assets/<filepath>',
methods=['GET'],
auth_type=group.AuthType.NONE,
)
async def _(author: str, plugin_name: str, filepath: str) -> quart.Response:
asset_path = _normalize_plugin_asset_path(filepath)
if asset_path is None:
return quart.Response('Asset not found', status=404)
asset_data = await self.ap.plugin_connector.get_plugin_assets(author, plugin_name, asset_path)
if not asset_data.get('asset_base64'):
return quart.Response('Asset not found', status=404)
asset_data = await self.ap.plugin_connector.get_plugin_assets(author, plugin_name, filepath)
asset_bytes = base64.b64decode(asset_data['asset_base64'])
mime_type = asset_data['mime_type']
resp = quart.Response(asset_bytes, mimetype=mime_type)
# CSP for HTML pages served to sandboxed iframes (opaque origin).
# 'self' doesn't work in sandboxed iframes — use actual server origin.
if mime_type and mime_type.startswith('text/html'):
origin = f'{quart.request.scheme}://{quart.request.host}'
resp.headers['Content-Security-Policy'] = (
f'default-src {origin}; '
f"script-src {origin} 'unsafe-inline'; "
f"style-src {origin} 'unsafe-inline'; "
f'img-src {origin} data:; '
f'connect-src {origin}; '
"frame-src 'none'; "
"object-src 'none'"
)
return resp
@self.route(
'/<author>/<plugin_name>/page-api',
methods=['POST'],
auth_type=group.AuthType.USER_TOKEN_OR_API_KEY,
)
async def _(author: str, plugin_name: str) -> str:
"""Forward a page API request to the plugin."""
data = await quart.request.json
if not isinstance(data, dict):
return self.http_status(400, -1, 'invalid request body')
page_id = data.get('page_id', '')
endpoint = data.get('endpoint', '')
method = data.get('method', 'POST')
body = data.get('body')
if not isinstance(page_id, str) or not isinstance(endpoint, str) or not isinstance(method, str):
return self.http_status(400, -1, 'invalid page api request')
if not endpoint.startswith('/') or '..' in endpoint:
return self.http_status(400, -1, 'invalid endpoint')
result = await self.ap.plugin_connector.handle_page_api(
author, plugin_name, page_id, endpoint, method.upper(), body
)
if result.get('error'):
return self.http_status(400, -1, result['error'])
return self.success(data=result.get('data'))
return quart.Response(asset_bytes, mimetype=mime_type)
@self.route('/github/releases', methods=['POST'], auth_type=group.AuthType.USER_TOKEN_OR_API_KEY)
async def _() -> str:

View File

@@ -136,10 +136,6 @@ class SystemRouterGroup(group.RouterGroup):
return self.success(data=task.to_dict())
@self.route('/storage-analysis', methods=['GET'], auth_type=group.AuthType.USER_TOKEN)
async def _() -> str:
return self.success(data=await self.ap.maintenance_service.get_storage_analysis())
@self.route('/debug/exec', methods=['POST'], auth_type=group.AuthType.USER_TOKEN)
async def _() -> str:
if not constants.debug_mode:

View File

@@ -1,309 +0,0 @@
from __future__ import annotations
import datetime
import os
import re
from pathlib import Path
from typing import Any
import sqlalchemy
from ....core import app
from ....entity.persistence import bstorage as persistence_bstorage
from ....entity.persistence import monitoring as persistence_monitoring
LOG_FILE_PATTERN = re.compile(r'^langbot-(\d{4}-\d{2}-\d{2})\.log(?:\.\d+)?$')
DEFAULT_UPLOAD_FILE_RETENTION_DAYS = 7
DEFAULT_LOG_RETENTION_DAYS = 3
class MaintenanceService:
"""Storage maintenance and diagnostics."""
ap: app.Application
def __init__(self, ap: app.Application) -> None:
self.ap = ap
async def cleanup_expired_files(self) -> dict[str, int]:
cleanup_cfg = self.ap.instance_config.data.get('storage', {}).get('cleanup', {})
upload_retention_days = self._positive_int(
cleanup_cfg.get('uploaded_file_retention_days'),
DEFAULT_UPLOAD_FILE_RETENTION_DAYS,
'storage.cleanup.uploaded_file_retention_days',
)
log_retention_days = self._positive_int(
cleanup_cfg.get('log_retention_days'),
DEFAULT_LOG_RETENTION_DAYS,
'storage.cleanup.log_retention_days',
)
return {
'uploaded_files': await self._cleanup_expired_uploaded_files(upload_retention_days),
'log_files': self._cleanup_expired_log_files(log_retention_days),
}
async def get_storage_analysis(self) -> dict[str, Any]:
cleanup_cfg = self.ap.instance_config.data.get('storage', {}).get('cleanup', {})
upload_retention_days = self._positive_int(
cleanup_cfg.get('uploaded_file_retention_days'),
DEFAULT_UPLOAD_FILE_RETENTION_DAYS,
'storage.cleanup.uploaded_file_retention_days',
)
log_retention_days = self._positive_int(
cleanup_cfg.get('log_retention_days'),
DEFAULT_LOG_RETENTION_DAYS,
'storage.cleanup.log_retention_days',
)
database_cfg = self.ap.instance_config.data.get('database', {})
database_type = database_cfg.get('use', 'sqlite')
database_path = (
Path(database_cfg.get('sqlite', {}).get('path', 'data/langbot.db')) if database_type == 'sqlite' else None
)
roots: list[tuple[str, Path | None]] = [
('database', database_path),
('logs', Path('data/logs')),
('storage', Path('data/storage')),
('vector_store', Path('data/chroma')),
('plugins', Path('data/plugins')),
('mcp', Path('data/mcp')),
('temp', Path('data/temp')),
]
sections = []
for key, path in roots:
sections.append(
{
'key': key,
'path': str(path) if path else '',
'exists': path.exists() if path else False,
'size_bytes': self._path_size(path) if path else 0,
'file_count': self._file_count(path) if path else 0,
}
)
monitoring_counts = await self._monitoring_counts()
binary_storage = await self._binary_storage_stats()
upload_candidates = await self._expired_uploaded_candidates(upload_retention_days)
log_candidates = self._expired_log_candidates(log_retention_days)
return {
'generated_at': datetime.datetime.now(datetime.timezone.utc).isoformat(),
'cleanup_policy': {
'uploaded_file_retention_days': upload_retention_days,
'log_retention_days': log_retention_days,
},
'sections': sections,
'database': {
'type': database_type,
'monitoring_counts': monitoring_counts,
'binary_storage': binary_storage,
},
'cleanup_candidates': {
'uploaded_files': upload_candidates,
'log_files': log_candidates,
},
'tasks': self.ap.task_mgr.get_stats() if self.ap.task_mgr else {},
}
async def _cleanup_expired_uploaded_files(self, retention_days: int) -> int:
provider = self.ap.storage_mgr.storage_provider
provider_name = provider.__class__.__name__
if provider_name == 'LocalStorageProvider':
candidates = self._expired_local_upload_candidates(retention_days, include_paths=True)
deleted = 0
for item in candidates:
try:
os.remove(item['path'])
deleted += 1
except FileNotFoundError:
pass
except Exception as e:
self.ap.logger.warning(f'Failed to delete expired uploaded file {item["key"]}: {e}')
return deleted
if provider_name == 'S3StorageProvider':
return await self._cleanup_expired_s3_uploaded_files(retention_days)
return 0
async def _expired_uploaded_candidates(self, retention_days: int) -> list[dict[str, Any]]:
provider_name = self.ap.storage_mgr.storage_provider.__class__.__name__
if provider_name == 'LocalStorageProvider':
return self._expired_local_upload_candidates(retention_days)
if provider_name == 'S3StorageProvider':
return await self._expired_s3_upload_candidates(retention_days)
return []
async def _cleanup_expired_s3_uploaded_files(self, retention_days: int) -> int:
provider = self.ap.storage_mgr.storage_provider
candidates = await self._expired_s3_upload_candidates(retention_days)
deleted = 0
for item in candidates:
await provider.delete(item['key'])
deleted += 1
return deleted
async def _expired_s3_upload_candidates(self, retention_days: int) -> list[dict[str, Any]]:
provider = self.ap.storage_mgr.storage_provider
cutoff = datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(days=retention_days)
candidates = []
paginator = provider.s3_client.get_paginator('list_objects_v2')
for page in paginator.paginate(Bucket=provider.bucket_name):
for obj in page.get('Contents', []):
key = obj.get('Key', '')
last_modified = obj.get('LastModified')
if not self._is_uploaded_file_key(key):
continue
if last_modified and last_modified < cutoff:
candidates.append(
{
'key': key,
'size_bytes': obj.get('Size', 0),
'modified_at': last_modified.isoformat(),
}
)
return candidates
def _cleanup_expired_log_files(self, retention_days: int) -> int:
deleted = 0
for item in self._expired_log_candidates(retention_days, include_paths=True):
try:
os.remove(item['path'])
deleted += 1
except FileNotFoundError:
pass
except Exception as e:
self.ap.logger.warning(f'Failed to delete expired log file {item["name"]}: {e}')
return deleted
def _expired_local_upload_candidates(
self, retention_days: int, include_paths: bool = False
) -> list[dict[str, Any]]:
storage_root = Path('data/storage')
if not storage_root.exists():
return []
cutoff = datetime.datetime.now().timestamp() - retention_days * 86400
candidates = []
for entry in storage_root.iterdir():
if not entry.is_file() or not self._is_uploaded_file_key(entry.name):
continue
stat = entry.stat()
if stat.st_mtime >= cutoff:
continue
item = {
'key': entry.name,
'size_bytes': stat.st_size,
'modified_at': datetime.datetime.fromtimestamp(stat.st_mtime, datetime.timezone.utc).isoformat(),
}
if include_paths:
item['path'] = str(entry)
candidates.append(item)
return candidates
def _expired_log_candidates(self, retention_days: int, include_paths: bool = False) -> list[dict[str, Any]]:
log_root = Path('data/logs')
if not log_root.exists():
return []
cutoff_date = datetime.date.today() - datetime.timedelta(days=retention_days - 1)
candidates = []
for entry in log_root.iterdir():
if not entry.is_file():
continue
match = LOG_FILE_PATTERN.match(entry.name)
if not match:
continue
try:
file_date = datetime.date.fromisoformat(match.group(1))
except ValueError:
continue
if file_date >= cutoff_date:
continue
stat = entry.stat()
item = {
'name': entry.name,
'date': file_date.isoformat(),
'size_bytes': stat.st_size,
}
if include_paths:
item['path'] = str(entry)
candidates.append(item)
return candidates
def _is_uploaded_file_key(self, key: str) -> bool:
return '/' not in key and not key.startswith('plugin_config_')
async def _monitoring_counts(self) -> dict[str, int]:
tables = {
'messages': persistence_monitoring.MonitoringMessage.id,
'llm_calls': persistence_monitoring.MonitoringLLMCall.id,
'embedding_calls': persistence_monitoring.MonitoringEmbeddingCall.id,
'errors': persistence_monitoring.MonitoringError.id,
'sessions': persistence_monitoring.MonitoringSession.session_id,
'feedback': persistence_monitoring.MonitoringFeedback.id,
}
counts: dict[str, int] = {}
for key, column in tables.items():
result = await self.ap.persistence_mgr.execute_async(sqlalchemy.select(sqlalchemy.func.count(column)))
counts[key] = result.scalar() or 0
return counts
async def _binary_storage_stats(self) -> dict[str, Any]:
count_result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(sqlalchemy.func.count(persistence_bstorage.BinaryStorage.unique_key))
)
size_bytes = None
try:
size_result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(sqlalchemy.func.sum(sqlalchemy.func.length(persistence_bstorage.BinaryStorage.value)))
)
size_bytes = size_result.scalar() or 0
except Exception as e:
self.ap.logger.warning(f'Failed to estimate binary storage size: {e}')
return {
'count': count_result.scalar() or 0,
'size_bytes': size_bytes,
}
def _path_size(self, path: Path) -> int:
if not path.exists():
return 0
if path.is_file():
return path.stat().st_size
total = 0
for root, _, files in os.walk(path):
for file_name in files:
file_path = Path(root) / file_name
try:
total += file_path.stat().st_size
except FileNotFoundError:
pass
return total
def _file_count(self, path: Path) -> int:
if not path.exists():
return 0
if path.is_file():
return 1
count = 0
for _, _, files in os.walk(path):
count += len(files)
return count
def _positive_int(self, value: Any, default: int, name: str) -> int:
try:
parsed = int(value)
except (TypeError, ValueError):
self.ap.logger.warning(f'Invalid {name}: {value!r}, using {default}')
return default
if parsed < 1:
self.ap.logger.warning(f'Invalid {name}: {value!r}, using {default}')
return default
return parsed

View File

@@ -23,17 +23,6 @@ def _parse_provider_api_keys(provider_dict: dict) -> dict:
return provider_dict
def _runtime_model_data(model_uuid: str, model_data: dict) -> dict:
"""Return model data for rebuilding runtime models after an update.
Update payloads intentionally omit uuid before writing to the database.
Runtime model entities still need the stable uuid so pipeline configs can
resolve the in-memory model immediately after an edit, without requiring a
process restart.
"""
return {**model_data, 'uuid': model_uuid}
class LLMModelsService:
ap: app.Application
@@ -184,7 +173,7 @@ class LLMModelsService:
raise Exception('provider not found')
runtime_llm_model = await self.ap.model_mgr.load_llm_model_with_provider(
persistence_model.LLMModel(**_runtime_model_data(model_uuid, model_data)),
persistence_model.LLMModel(**model_data),
runtime_provider,
)
self.ap.model_mgr.llm_models.append(runtime_llm_model)
@@ -345,7 +334,7 @@ class EmbeddingModelsService:
raise Exception('provider not found')
runtime_embedding_model = await self.ap.model_mgr.load_embedding_model_with_provider(
persistence_model.EmbeddingModel(**_runtime_model_data(model_uuid, model_data)),
persistence_model.EmbeddingModel(**model_data),
runtime_provider,
)
self.ap.model_mgr.embedding_models.append(runtime_embedding_model)
@@ -503,7 +492,7 @@ class RerankModelsService:
raise Exception('provider not found')
runtime_rerank_model = await self.ap.model_mgr.load_rerank_model_with_provider(
persistence_model.RerankModel(**_runtime_model_data(model_uuid, model_data)),
persistence_model.RerankModel(**model_data),
runtime_provider,
)
self.ap.model_mgr.rerank_models.append(runtime_rerank_model)

View File

@@ -18,119 +18,55 @@ class MonitoringService:
# ========== Cleanup Methods ==========
async def cleanup_expired_records(self, retention_days: int, batch_size: int = 1000) -> dict[str, int]:
async def cleanup_expired_records(self, retention_days: int) -> dict[str, int]:
"""Delete monitoring records older than the specified retention period.
Args:
retention_days: Number of days to retain records.
batch_size: Maximum rows to delete per table batch.
Returns:
A dict mapping table name to the number of deleted rows.
"""
if retention_days < 1:
raise ValueError('retention_days must be >= 1')
if batch_size < 1:
raise ValueError('batch_size must be >= 1')
cutoff = datetime.datetime.now(datetime.timezone.utc).replace(tzinfo=None) - datetime.timedelta(
days=retention_days
)
tables_and_columns: list[tuple[str, type, sqlalchemy.Column, sqlalchemy.Column]] = [
tables_and_columns: list[tuple[str, type, sqlalchemy.Column]] = [
(
'monitoring_messages',
persistence_monitoring.MonitoringMessage,
persistence_monitoring.MonitoringMessage.timestamp,
persistence_monitoring.MonitoringMessage.id,
),
(
'monitoring_llm_calls',
persistence_monitoring.MonitoringLLMCall,
persistence_monitoring.MonitoringLLMCall.timestamp,
persistence_monitoring.MonitoringLLMCall.id,
),
(
'monitoring_embedding_calls',
persistence_monitoring.MonitoringEmbeddingCall,
persistence_monitoring.MonitoringEmbeddingCall.timestamp,
persistence_monitoring.MonitoringEmbeddingCall.id,
),
(
'monitoring_errors',
persistence_monitoring.MonitoringError,
persistence_monitoring.MonitoringError.timestamp,
persistence_monitoring.MonitoringError.id,
),
(
'monitoring_sessions',
persistence_monitoring.MonitoringSession,
persistence_monitoring.MonitoringSession.last_activity,
persistence_monitoring.MonitoringSession.session_id,
),
(
'monitoring_feedback',
persistence_monitoring.MonitoringFeedback,
persistence_monitoring.MonitoringFeedback.timestamp,
persistence_monitoring.MonitoringFeedback.id,
),
]
deleted_counts: dict[str, int] = {}
for table_name, model_cls, ts_column, pk_column in tables_and_columns:
deleted_counts[table_name] = await self._delete_expired_in_batches(
model_cls=model_cls,
ts_column=ts_column,
pk_column=pk_column,
cutoff=cutoff,
batch_size=batch_size,
)
if sum(deleted_counts.values()) > 0:
await self._release_sqlite_space()
for table_name, model_cls, ts_column in tables_and_columns:
result = await self.ap.persistence_mgr.execute_async(sqlalchemy.delete(model_cls).where(ts_column < cutoff))
deleted_counts[table_name] = result.rowcount
return deleted_counts
async def _delete_expired_in_batches(
self,
model_cls: type,
ts_column: sqlalchemy.Column,
pk_column: sqlalchemy.Column,
cutoff: datetime.datetime,
batch_size: int,
) -> int:
deleted_total = 0
while True:
select_result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(pk_column).where(ts_column < cutoff).limit(batch_size)
)
pk_values = list(select_result.scalars().all())
if not pk_values:
break
delete_result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.delete(model_cls).where(pk_column.in_(pk_values))
)
deleted = delete_result.rowcount or 0
deleted_total += deleted
if len(pk_values) < batch_size:
break
return deleted_total
async def _release_sqlite_space(self) -> None:
database_type = self.ap.instance_config.data.get('database', {}).get('use', 'sqlite')
if database_type != 'sqlite':
return
async with self.ap.persistence_mgr.get_db_engine().connect() as conn:
autocommit_conn = await conn.execution_options(isolation_level='AUTOCOMMIT')
await autocommit_conn.execute(sqlalchemy.text('PRAGMA wal_checkpoint(TRUNCATE)'))
await autocommit_conn.execute(sqlalchemy.text('VACUUM'))
# ========== Recording Methods ==========
async def record_message(

View File

@@ -179,7 +179,7 @@ class SpaceService:
space_url = space_config['url']
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:
raise ValueError(f'Failed to get models: {await response.text()}')
data = await response.json()

View File

@@ -31,7 +31,6 @@ from ..api.http.service import mcp as mcp_service
from ..api.http.service import apikey as apikey_service
from ..api.http.service import webhook as webhook_service
from ..api.http.service import monitoring as monitoring_service
from ..api.http.service import maintenance as maintenance_service
from ..discover import engine as discover_engine
from ..storage import mgr as storagemgr
@@ -156,8 +155,6 @@ class Application:
monitoring_service: monitoring_service.MonitoringService = None
maintenance_service: maintenance_service.MaintenanceService = None
def __init__(self):
pass
@@ -197,30 +194,14 @@ class Application:
monitoring_cfg = self.instance_config.data.get('monitoring', {})
auto_cleanup_cfg = monitoring_cfg.get('auto_cleanup', {})
if auto_cleanup_cfg.get('enabled', True):
retention_days = self._get_positive_int_config(
auto_cleanup_cfg.get('retention_days', 30),
default=30,
name='monitoring.auto_cleanup.retention_days',
)
delete_batch_size = self._get_positive_int_config(
auto_cleanup_cfg.get('delete_batch_size', 1000),
default=1000,
name='monitoring.auto_cleanup.delete_batch_size',
)
check_interval_hours = self._get_positive_float_config(
auto_cleanup_cfg.get('check_interval_hours', 1),
default=1,
name='monitoring.auto_cleanup.check_interval_hours',
)
retention_days = auto_cleanup_cfg.get('retention_days', 30)
check_interval_hours = auto_cleanup_cfg.get('check_interval_hours', 1)
async def monitoring_cleanup_loop():
check_interval_seconds = check_interval_hours * 3600
while True:
try:
deleted = await self.monitoring_service.cleanup_expired_records(
retention_days,
batch_size=delete_batch_size,
)
deleted = await self.monitoring_service.cleanup_expired_records(retention_days)
total_deleted = sum(deleted.values())
if total_deleted > 0:
self.logger.info(
@@ -237,33 +218,6 @@ class Application:
scopes=[core_entities.LifecycleControlScope.APPLICATION],
)
# Start storage/log maintenance task if enabled
storage_cleanup_cfg = self.instance_config.data.get('storage', {}).get('cleanup', {})
if storage_cleanup_cfg.get('enabled', True) and self.maintenance_service is not None:
check_interval_hours = self._get_positive_float_config(
storage_cleanup_cfg.get('check_interval_hours', 1),
default=1,
name='storage.cleanup.check_interval_hours',
)
async def storage_cleanup_loop():
check_interval_seconds = check_interval_hours * 3600
while True:
try:
deleted = await self.maintenance_service.cleanup_expired_files()
total_deleted = sum(deleted.values())
if total_deleted > 0:
self.logger.info(f'Storage maintenance: deleted expired files: {deleted}')
except Exception as e:
self.logger.warning(f'Storage maintenance error: {e}')
await asyncio.sleep(check_interval_seconds)
self.task_mgr.create_task(
storage_cleanup_loop(),
name='storage-maintenance',
scopes=[core_entities.LifecycleControlScope.APPLICATION],
)
self.task_mgr.create_task(
never_ending(),
name='never-ending-task',
@@ -278,28 +232,6 @@ class Application:
self.logger.error(f'Application runtime fatal exception: {e}')
self.logger.debug(f'Traceback: {traceback.format_exc()}')
def _get_positive_int_config(self, value, default: int, name: str) -> int:
try:
parsed = int(value)
except (TypeError, ValueError):
self.logger.warning(f'Invalid {name}: {value!r}, using {default}')
return default
if parsed < 1:
self.logger.warning(f'Invalid {name}: {value!r}, using {default}')
return default
return parsed
def _get_positive_float_config(self, value, default: float, name: str) -> float:
try:
parsed = float(value)
except (TypeError, ValueError):
self.logger.warning(f'Invalid {name}: {value!r}, using {default}')
return default
if parsed <= 0:
self.logger.warning(f'Invalid {name}: {value!r}, using {default}')
return default
return parsed
def dispose(self):
self.plugin_connector.dispose()

View File

@@ -28,7 +28,6 @@ from ...api.http.service import mcp as mcp_service
from ...api.http.service import apikey as apikey_service
from ...api.http.service import webhook as webhook_service
from ...api.http.service import monitoring as monitoring_service
from ...api.http.service import maintenance as maintenance_service
from ...discover import engine as discover_engine
from ...storage import mgr as storagemgr
from ...utils import logcache
@@ -168,9 +167,6 @@ class BuildAppStage(stage.BootingStage):
monitoring_service_inst = monitoring_service.MonitoringService(ap)
ap.monitoring_service = monitoring_service_inst
maintenance_service_inst = maintenance_service.MaintenanceService(ap)
ap.maintenance_service = maintenance_service_inst
async def runtime_disconnect_callback(connector: plugin_connector.PluginRuntimeConnector) -> None:
await asyncio.sleep(3)
await plugin_connector_inst.initialize()

View File

@@ -3,7 +3,6 @@ from __future__ import annotations
import asyncio
import typing
import datetime
import time
from . import app
from . import entities as core_entities
@@ -120,7 +119,6 @@ class TaskWrapper:
self.label = label if label != '' else name
self.task.set_name(name)
self.scopes = scopes
self.created_at = time.time()
def assume_exception(self):
try:
@@ -156,7 +154,6 @@ class TaskWrapper:
'name': self.name,
'label': self.label,
'scopes': [scope.value for scope in self.scopes],
'created_at': self.created_at,
'task_context': self.task_context.to_dict(),
'runtime': {
'done': self.task.done(),
@@ -196,8 +193,6 @@ class AsyncTaskManager:
) -> TaskWrapper:
wrapper = TaskWrapper(self.ap, coro, task_type, kind, name, label, context, scopes)
self.tasks.append(wrapper)
wrapper.task.add_done_callback(lambda _: self._prune_completed_tasks())
self._prune_completed_tasks()
return wrapper
def create_user_task(
@@ -231,15 +226,6 @@ class AsyncTaskManager:
'id_index': TaskWrapper._id_index,
}
def get_stats(self) -> dict:
completed = sum(1 for t in self.tasks if t.task.done())
return {
'total': len(self.tasks),
'running': len(self.tasks) - completed,
'completed': completed,
'id_index': TaskWrapper._id_index,
}
def get_task_by_id(self, id: int) -> TaskWrapper | None:
for t in self.tasks:
if t.id == id:
@@ -257,27 +243,3 @@ class AsyncTaskManager:
if not wrapper.task.done():
wrapper.task.cancel()
return
def _prune_completed_tasks(self):
completed_limit = (
self.ap.instance_config.data.get('system', {})
.get('task_retention', {})
.get(
'completed_limit',
200,
)
)
try:
completed_limit = int(completed_limit)
except (TypeError, ValueError):
completed_limit = 200
if completed_limit < 1:
completed_limit = 1
completed_tasks = [wrapper for wrapper in self.tasks if wrapper.task.done()]
overflow = len(completed_tasks) - completed_limit
if overflow <= 0:
return
remove_ids = {wrapper.id for wrapper in completed_tasks[:overflow]}
self.tasks = [wrapper for wrapper in self.tasks if wrapper.id not in remove_ids]

View File

@@ -75,27 +75,6 @@ class PreProcessor(stage.PipelineStage):
query.bot_uuid,
)
# Expire externally managed conversation ids after the conversation has
# been idle for longer than the configured conversation expire time.
# The idle window is measured from the last preprocess/update time, not
# from the conversation creation time.
conversation_expire_time = query.pipeline_config.get('ai', {}).get('runner', {}).get('expire-time', None)
now = datetime.datetime.now()
if conversation_expire_time is not None and conversation_expire_time > 0:
last_update_time = getattr(conversation, 'update_time', None) or getattr(conversation, 'create_time', None)
if last_update_time is not None:
conversation_idle_time = now.timestamp() - last_update_time.timestamp()
if conversation_idle_time > conversation_expire_time:
self.ap.logger.info(
f'Conversation({query.query_id}) is expired (idle: {conversation_idle_time}s), create new conversation'
)
conversation.uuid = None
# Treat every preprocess pass as a conversation activity update. This
# makes future expiry checks use the latest incoming message/preprocess
# time instead of the first message/creation time.
conversation.update_time = now
# 设置query
query.session = session
query.prompt = conversation.prompt.copy()
@@ -181,10 +160,7 @@ class PreProcessor(stage.PipelineStage):
elif me.url:
content_list.append(provider_message.ContentElement.from_file_url(me.url, 'voice'))
elif isinstance(me, platform_message.File):
if me.base64:
content_list.append(provider_message.ContentElement.from_file_base64(me.base64, me.name))
elif me.url:
content_list.append(provider_message.ContentElement.from_file_url(me.url, me.name))
content_list.append(provider_message.ContentElement.from_file_url(me.url, me.name))
elif isinstance(me, platform_message.Quote) and quote_msg:
for msg in me.origin:
if isinstance(msg, platform_message.Plain):
@@ -196,10 +172,7 @@ class PreProcessor(stage.PipelineStage):
if msg.base64 is not None:
content_list.append(provider_message.ContentElement.from_image_base64(msg.base64))
elif isinstance(msg, platform_message.File):
if msg.base64:
content_list.append(provider_message.ContentElement.from_file_base64(msg.base64, msg.name))
elif msg.url:
content_list.append(provider_message.ContentElement.from_file_url(msg.url, msg.name))
content_list.append(provider_message.ContentElement.from_file_url(msg.url, msg.name))
elif isinstance(msg, platform_message.Voice):
if msg.base64:
content_list.append(

View File

@@ -523,7 +523,7 @@ class PlatformManager:
return None
async def remove_bot(self, bot_uuid: str):
for bot in self.bots[:]:
for bot in self.bots:
if bot.bot_entity.uuid == bot_uuid:
if bot.enable:
await bot.shutdown()

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.4 KiB

View File

@@ -1,693 +0,0 @@
from __future__ import annotations
import typing
import asyncio
import traceback
import base64
import json
import nio
from langbot.pkg.utils import httpclient
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
import langbot_plugin.api.entities.builtin.platform.message as platform_message
import langbot_plugin.api.entities.builtin.platform.events as platform_events
import langbot_plugin.api.entities.builtin.platform.entities as platform_entities
import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_platform_logger
class MatrixMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
@staticmethod
async def yiri2target(message_chain: platform_message.MessageChain, client: nio.AsyncClient) -> list[dict]:
components = []
for component in message_chain:
if isinstance(component, platform_message.Plain):
components.append({'type': 'text', 'text': component.text})
elif isinstance(component, platform_message.Image):
image_bytes = None
if component.base64:
b64_data = component.base64
if ';base64,' in b64_data:
b64_data = b64_data.split(';base64,', 1)[1]
image_bytes = base64.b64decode(b64_data)
elif component.url:
session = httpclient.get_session()
async with session.get(component.url) as response:
image_bytes = await response.read()
elif component.path:
with open(component.path, 'rb') as f:
image_bytes = f.read()
if image_bytes:
resp = await client.upload(image_bytes, content_type='image/png')
if isinstance(resp, nio.UploadResponse):
components.append({'type': 'image', 'mxc_url': resp.content_uri})
elif isinstance(component, platform_message.File):
file_bytes = None
if component.base64:
b64_data = component.base64
if ';base64,' in b64_data:
b64_data = b64_data.split(';base64,', 1)[1]
file_bytes = base64.b64decode(b64_data)
elif component.url:
session = httpclient.get_session()
async with session.get(component.url) as response:
file_bytes = await response.read()
elif component.path:
with open(component.path, 'rb') as f:
file_bytes = f.read()
if file_bytes:
file_name = getattr(component, 'name', None) or 'file'
resp = await client.upload(file_bytes, content_type='application/octet-stream', filename=file_name)
if isinstance(resp, nio.UploadResponse):
components.append(
{
'type': 'file',
'mxc_url': resp.content_uri,
'filename': file_name,
'size': len(file_bytes),
}
)
elif isinstance(component, platform_message.Forward):
for node in component.node_list:
components.extend(await MatrixMessageConverter.yiri2target(node.message_chain, client))
return components
@staticmethod
async def target2yiri(event: nio.RoomMessageText | nio.RoomMessageImage, client: nio.AsyncClient, bot_user_id: str):
message_components = []
if isinstance(event, nio.RoomMessageText):
text = event.body
if bot_user_id and bot_user_id in text:
message_components.append(platform_message.At(target=bot_user_id))
text = text.replace(bot_user_id, '').strip()
message_components.append(platform_message.Plain(text=text))
elif isinstance(event, nio.RoomMessageImage):
mxc_url = event.url
if mxc_url:
resp = await client.download(mxc_url)
if isinstance(resp, nio.DownloadResponse):
b64 = base64.b64encode(resp.body).decode('utf-8')
content_type = resp.content_type or 'image/png'
message_components.append(platform_message.Image(base64=f'data:{content_type};base64,{b64}'))
if event.body:
message_components.append(platform_message.Plain(text=event.body))
return platform_message.MessageChain(message_components)
class MatrixEventConverter(abstract_platform_adapter.AbstractEventConverter):
@staticmethod
async def yiri2target(event: platform_events.MessageEvent):
return event.source_platform_object
@staticmethod
async def target2yiri(
event: nio.RoomMessageText | nio.RoomMessageImage,
room: nio.MatrixRoom,
client: nio.AsyncClient,
bot_user_id: str,
bridge_user_ids: list[str] | None = None,
):
lb_message = await MatrixMessageConverter.target2yiri(event, client, bot_user_id)
# Determine if this is a direct/private chat or a group chat.
# Exclude bot itself and bridge bots, count remaining real users.
exclude_ids = {bot_user_id}
if bridge_user_ids:
exclude_ids.update(bridge_user_ids)
real_users = [uid for uid in room.users if uid not in exclude_ids]
is_direct = len(real_users) <= 1
if is_direct:
return platform_events.FriendMessage(
sender=platform_entities.Friend(
id=event.sender,
nickname=room.user_name(event.sender) or event.sender,
remark='',
),
message_chain=lb_message,
time=event.server_timestamp / 1000.0,
source_platform_object={'event': event, 'room': room},
)
else:
return platform_events.GroupMessage(
sender=platform_entities.GroupMember(
id=event.sender,
member_name=room.user_name(event.sender) or event.sender,
permission=platform_entities.Permission.Member,
group=platform_entities.Group(
id=room.room_id,
name=room.display_name or room.room_id,
permission=platform_entities.Permission.Member,
),
special_title='',
),
message_chain=lb_message,
time=event.server_timestamp / 1000.0,
source_platform_object={'event': event, 'room': room},
)
class BridgeState:
"""Per-bridge runtime state."""
def __init__(self, user_id: str, login_command: str, logout_command: str, success_keyword: str, check_command: str):
self.user_id = user_id
self.login_command = login_command
self.logout_command = logout_command
self.success_keyword = success_keyword
self.check_command = check_command or login_command
self.logged_in = False
self.dm_room_id: str | None = None
self.login_task: asyncio.Task | None = None
self.check_task: asyncio.Task | None = None
self.check_responded = False
class MatrixAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
client: typing.Any = None
message_converter: MatrixMessageConverter = MatrixMessageConverter()
event_converter: MatrixEventConverter = MatrixEventConverter()
config: dict
listeners: typing.Dict[typing.Type[platform_events.Event], typing.Callable] = {}
_running: bool = False
_initial_sync_done: bool = False
_bridges: list = []
def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger):
homeserver_url = config.get('homeserver_url', '')
access_token = config.get('access_token', '')
user_id = config.get('user_id', '')
if not homeserver_url or not access_token or not user_id:
raise ValueError('Matrix 机器人缺少必要配置项 (homeserver_url, user_id, access_token)')
client = nio.AsyncClient(homeserver_url, user_id)
client.access_token = access_token
client.user_id = user_id
super().__init__(
config=config,
logger=logger,
bot_account_id=user_id,
client=client,
listeners={},
)
# Parse bridges config AFTER super().__init__() to avoid Pydantic resetting _bridges
self._bridges = []
bridges_raw = config.get('bridges', '')
if bridges_raw:
if isinstance(bridges_raw, str):
try:
bridges_list = json.loads(bridges_raw)
except (json.JSONDecodeError, TypeError) as e:
raise ValueError(f'bridges 配置 JSON 解析失败: {e}\n原始值: {bridges_raw}')
else:
bridges_list = bridges_raw
for b in bridges_list:
if isinstance(b, dict) and b.get('user_id', '').strip():
self._bridges.append(
BridgeState(
user_id=b['user_id'].strip(),
login_command=b.get('login_command', '').strip(),
logout_command=b.get('logout_command', '').strip(),
success_keyword=b.get('success_keyword', 'Successfully logged in').strip(),
check_command=b.get('check_command', '').strip(),
)
)
# Backward compatibility: old single-bridge config
if not self._bridges:
old_user_id = config.get('bridge_user_id', '').strip()
old_command = config.get('bridge_login_command', '').strip()
old_keyword = config.get('bridge_login_success_keyword', 'Successfully logged in').strip()
old_check = config.get('bridge_check_command', '').strip()
old_logout = config.get('bridge_logout_command', '').strip()
if old_user_id:
self._bridges.append(
BridgeState(
user_id=old_user_id,
login_command=old_command,
logout_command=old_logout,
success_keyword=old_keyword,
check_command=old_check,
)
)
async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain):
components = await self.message_converter.yiri2target(message, self.client)
for component in components:
await self._send_component(target_id, component)
async def reply_message(
self,
message_source: platform_events.MessageEvent,
message: platform_message.MessageChain,
quote_origin: bool = False,
):
source_obj = message_source.source_platform_object
room_id = source_obj['room'].room_id
components = await self.message_converter.yiri2target(message, self.client)
for component in components:
if quote_origin:
original_event = source_obj['event']
await self._send_component(room_id, component, reply_to=original_event.event_id)
else:
await self._send_component(room_id, component)
async def _send_component(self, room_id: str, component: dict, reply_to: str | None = None):
content = {}
if component['type'] == 'text':
content = {
'msgtype': 'm.text',
'body': component['text'],
}
elif component['type'] == 'image':
content = {
'msgtype': 'm.image',
'body': 'image.png',
'url': component['mxc_url'],
}
elif component['type'] == 'file':
content = {
'msgtype': 'm.file',
'body': component.get('filename', 'file'),
'url': component['mxc_url'],
'info': {'size': component.get('size', 0)},
}
if reply_to and content:
content['m.relates_to'] = {
'm.in_reply_to': {'event_id': reply_to},
}
if content:
await self.client.room_send(
room_id=room_id,
message_type='m.room.message',
content=content,
)
def register_listener(
self,
event_type: typing.Type[platform_events.Event],
callback: typing.Callable[
[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None
],
):
self.listeners[event_type] = callback
async def run_async(self):
self._running = True
await self.logger.info('Matrix adapter starting...')
# Debug: log bridge parsing result
bridges_raw = self.config.get('bridges', '')
await self.logger.debug(f'bridges config raw: type={type(bridges_raw).__name__}, repr={repr(bridges_raw)}')
await self.logger.debug(
f'parsed _bridges count: {len(self._bridges)}, ids: {[b.user_id for b in self._bridges]}'
)
# Collect all bridge bot user IDs for filtering
_bridge_user_ids = [b.user_id for b in self._bridges]
_bridge_user_id_set = set(_bridge_user_ids)
# Auto-join invited rooms
async def on_invite(room: nio.MatrixRoom, event: nio.InviteMemberEvent):
if event.membership == 'invite' and event.state_key == self.client.user_id:
await self.client.join(room.room_id)
await self.logger.debug(f'Auto-joined room: {room.display_name or room.room_id}')
self.client.add_event_callback(on_invite, nio.InviteMemberEvent)
# Handle text messages
async def on_message(room: nio.MatrixRoom, event: nio.RoomMessageText):
if not self._initial_sync_done:
return
if event.sender == self.client.user_id:
return
# Admin commands (from any non-bridge user)
if event.sender not in _bridge_user_id_set:
body = (event.body or '').strip()
if body == '!relogin':
await self._handle_relogin_command(room.room_id)
return
if body == '!status':
await self._handle_status_command(room.room_id)
return
if event.sender in _bridge_user_id_set:
return
try:
lb_event = await self.event_converter.target2yiri(
event, room, self.client, self.bot_account_id, _bridge_user_ids
)
if type(lb_event) in self.listeners:
result = self.listeners[type(lb_event)](lb_event, self)
if asyncio.iscoroutine(result):
await result
except Exception:
await self.logger.error(f'Error handling Matrix message: {traceback.format_exc()}')
self.client.add_event_callback(on_message, nio.RoomMessageText)
# Handle image messages
async def on_image(room: nio.MatrixRoom, event: nio.RoomMessageImage):
if not self._initial_sync_done:
return
if event.sender == self.client.user_id:
return
if event.sender in _bridge_user_id_set:
return
try:
lb_event = await self.event_converter.target2yiri(
event, room, self.client, self.bot_account_id, _bridge_user_ids
)
if type(lb_event) in self.listeners:
result = self.listeners[type(lb_event)](lb_event, self)
if asyncio.iscoroutine(result):
await result
except Exception:
await self.logger.error(f'Error handling Matrix image: {traceback.format_exc()}')
self.client.add_event_callback(on_image, nio.RoomMessageImage)
# Set up bridge-specific callbacks for each bridge
_disconnect_keywords = ['disconnected', 'logged out', 'connection lost', 'session expired', 'token expired']
for bridge in self._bridges:
# Login success detection (notice)
async def on_bridge_notice(room: nio.MatrixRoom, event: nio.RoomMessageNotice, _b=bridge):
if not self._initial_sync_done:
return
if event.sender != _b.user_id:
return
_b.check_responded = True
if _b.success_keyword in (event.body or ''):
_b.logged_in = True
await self.logger.info(f'[{_b.user_id}] Bridge login succeeded.')
# Disconnect detection
body_lower = (event.body or '').lower()
for kw in _disconnect_keywords:
if kw in body_lower and _b.logged_in:
_b.logged_in = False
await self.logger.info(f'[{_b.user_id}] Bridge 账号掉线 (检测到: "{kw}"), 将自动重新登录...')
self._restart_bridge_login(_b)
break
self.client.add_event_callback(on_bridge_notice, nio.RoomMessageNotice)
# Login success + disconnect detection (text)
async def on_bridge_text(room: nio.MatrixRoom, event: nio.RoomMessageText, _b=bridge):
if not self._initial_sync_done:
return
if event.sender != _b.user_id:
return
_b.check_responded = True
if _b.success_keyword in (event.body or ''):
_b.logged_in = True
await self.logger.info(f'[{_b.user_id}] Bridge login succeeded.')
body_lower = (event.body or '').lower()
for kw in _disconnect_keywords:
if kw in body_lower and _b.logged_in:
_b.logged_in = False
await self.logger.info(f'[{_b.user_id}] Bridge 账号掉线 (检测到: "{kw}"), 将自动重新登录...')
self._restart_bridge_login(_b)
break
self.client.add_event_callback(on_bridge_text, nio.RoomMessageText)
# QR code image forwarding
async def on_bridge_image(room: nio.MatrixRoom, event: nio.RoomMessageImage, _b=bridge):
if not self._initial_sync_done:
return
if event.sender != _b.user_id:
return
mxc_url = event.url
if not mxc_url:
return
try:
resp = await self.client.download(mxc_url)
if isinstance(resp, nio.DownloadResponse):
b64 = base64.b64encode(resp.body).decode('utf-8')
content_type = resp.content_type or 'image/png'
await self.logger.info(
f'[{_b.user_id}] Bridge 发送了二维码,请扫码登录:',
images=[platform_message.Image(base64=f'data:{content_type};base64,{b64}')],
)
except Exception:
await self.logger.error(
f'[{_b.user_id}] Failed to download bridge QR image: {traceback.format_exc()}'
)
self.client.add_event_callback(on_bridge_image, nio.RoomMessageImage)
await self.logger.debug('Matrix adapter running, starting sync...')
# Initial sync to skip old messages
resp = await self.client.sync(timeout=10000)
if isinstance(resp, nio.SyncResponse):
await self.logger.debug(f'Matrix initial sync done, next_batch: {resp.next_batch}')
self._initial_sync_done = True
# Display account info
display_name = self.client.user_id
try:
profile_resp = await self.client.get_displayname(self.client.user_id)
if isinstance(profile_resp, nio.ProfileGetDisplayNameResponse) and profile_resp.displayname:
display_name = profile_resp.displayname
except Exception:
pass
joined_rooms = len(self.client.rooms)
homeserver = self.config.get('homeserver_url', '')
bridge_info = ''
if self._bridges:
bridge_names = ', '.join(b.user_id for b in self._bridges)
bridge_info = f' | 桥接: [{bridge_names}]'
await self.logger.info(
f'Matrix 账号: {display_name} ({self.client.user_id}) | '
f'服务器: {homeserver} | 已加入 {joined_rooms} 个房间{bridge_info}'
)
# Start bridge login and status check tasks for each bridge
for bridge in self._bridges:
if bridge.login_command:
await self.logger.info(
f'[{bridge.user_id}] Bridge login enabled (命令: "{bridge.login_command}", '
f'关键词: "{bridge.success_keyword}")'
)
bridge.login_task = asyncio.create_task(self._periodic_bridge_login(bridge))
bridge.check_task = asyncio.create_task(self._periodic_bridge_check(bridge))
else:
await self.logger.debug(f'[{bridge.user_id}] Bridge login not configured (no login_command)')
# Main sync loop
while self._running:
try:
await self.client.sync(timeout=30000)
except Exception:
await self.logger.error(f'Matrix sync error: {traceback.format_exc()}')
await asyncio.sleep(5)
async def _periodic_bridge_login(self, bridge: BridgeState):
"""Periodically send login command to a bridge bot until login succeeds."""
try:
await self.logger.info(f'[{bridge.user_id}] Bridge login task started, looking for DM room...')
dm_room_id = None
for room_id, room in self.client.rooms.items():
if room.member_count == 2 and bridge.user_id in [m for m in room.users]:
dm_room_id = room_id
break
if not dm_room_id:
resp = await self.client.room_create(
is_direct=True,
invite=[bridge.user_id],
)
if isinstance(resp, nio.RoomCreateResponse):
dm_room_id = resp.room_id
await self.logger.debug(f'[{bridge.user_id}] Created DM room: {dm_room_id}')
else:
await self.logger.error(f'[{bridge.user_id}] Failed to create DM room: {resp}')
return
bridge.dm_room_id = dm_room_id
# Force logout first on every adapter start
logout_cmd = bridge.logout_command or bridge.login_command.replace('login', 'logout')
await self.logger.info(f'[{bridge.user_id}] 强制登出: "{logout_cmd}"')
await self.client.room_send(
room_id=dm_room_id,
message_type='m.room.message',
content={'msgtype': 'm.text', 'body': logout_cmd},
)
await asyncio.sleep(3)
while self._running and not bridge.logged_in:
await self.logger.debug(f'[{bridge.user_id}] Sending "{bridge.login_command}" in room {dm_room_id}')
await self.client.room_send(
room_id=dm_room_id,
message_type='m.room.message',
content={'msgtype': 'm.text', 'body': bridge.login_command},
)
for _ in range(60):
if not self._running or bridge.logged_in:
break
await asyncio.sleep(1)
if bridge.logged_in:
await self.logger.debug(f'[{bridge.user_id}] Bridge login confirmed, periodic login stopped.')
except asyncio.CancelledError:
pass
except Exception:
await self.logger.error(f'[{bridge.user_id}] Bridge periodic login error: {traceback.format_exc()}')
def _restart_bridge_login(self, bridge: BridgeState):
"""Cancel existing login task and start a new one."""
if bridge.login_task and not bridge.login_task.done():
bridge.login_task.cancel()
bridge.login_task = asyncio.create_task(self._periodic_bridge_login(bridge))
async def _periodic_bridge_check(self, bridge: BridgeState):
"""Periodically check a bridge's login status."""
try:
while self._running and not bridge.logged_in:
await asyncio.sleep(5)
check_interval = 300 # 5 minutes
response_timeout = 30
await self.logger.debug(f'[{bridge.user_id}] Bridge status check started (interval: {check_interval}s)')
while self._running:
for _ in range(check_interval):
if not self._running:
return
await asyncio.sleep(1)
if not bridge.logged_in or not bridge.dm_room_id:
continue
try:
bridge.check_responded = False
await self.client.room_send(
room_id=bridge.dm_room_id,
message_type='m.room.message',
content={'msgtype': 'm.text', 'body': bridge.check_command},
)
await self.logger.debug(f'[{bridge.user_id}] Bridge status check: sent "{bridge.check_command}"')
for _ in range(response_timeout):
if bridge.check_responded or not self._running:
break
await asyncio.sleep(1)
if bridge.check_responded:
await self.logger.debug(f'[{bridge.user_id}] Bridge status check: OK')
else:
await self.logger.info(
f'[{bridge.user_id}] Bridge status check: 无响应, 可能已掉线, 尝试重新登录...'
)
bridge.logged_in = False
self._restart_bridge_login(bridge)
except Exception:
await self.logger.error(f'[{bridge.user_id}] Bridge status check error: {traceback.format_exc()}')
except asyncio.CancelledError:
pass
except Exception:
await self.logger.error(f'[{bridge.user_id}] Bridge status check fatal error: {traceback.format_exc()}')
async def _handle_relogin_command(self, room_id: str):
"""Handle !relogin command: logout then re-login all bridges."""
if not self._bridges:
await self.client.room_send(
room_id=room_id,
message_type='m.room.message',
content={'msgtype': 'm.text', 'body': '没有配置任何桥。'},
)
return
lines = ['开始重新登录所有桥...']
for bridge in self._bridges:
if not bridge.login_command or not bridge.dm_room_id:
lines.append(f'[{bridge.user_id}] 跳过未配置登录命令或无DM房间')
continue
# Use configured logout command, fallback to deriving from login command
logout_cmd = bridge.logout_command or bridge.login_command.replace('login', 'logout')
lines.append(f'[{bridge.user_id}] 发送 "{logout_cmd}"...')
# Cancel existing tasks
if bridge.login_task and not bridge.login_task.done():
bridge.login_task.cancel()
if bridge.check_task and not bridge.check_task.done():
bridge.check_task.cancel()
# Send logout
try:
await self.client.room_send(
room_id=bridge.dm_room_id,
message_type='m.room.message',
content={'msgtype': 'm.text', 'body': logout_cmd},
)
except Exception as e:
lines.append(f'[{bridge.user_id}] logout 发送失败: {e}')
await asyncio.sleep(2)
# Reset state and restart login
bridge.logged_in = False
self._restart_bridge_login(bridge)
lines.append(f'[{bridge.user_id}] 已触发重新登录')
await self.client.room_send(
room_id=room_id,
message_type='m.room.message',
content={'msgtype': 'm.text', 'body': '\n'.join(lines)},
)
async def _handle_status_command(self, room_id: str):
"""Handle !status command: show bridge states."""
if not self._bridges:
await self.client.room_send(
room_id=room_id,
message_type='m.room.message',
content={'msgtype': 'm.text', 'body': '没有配置任何桥。'},
)
return
lines = ['桥状态:']
for bridge in self._bridges:
status = '已登录 ✓' if bridge.logged_in else '未登录 ✗'
dm = bridge.dm_room_id or ''
lines.append(f'{bridge.user_id}: {status} (DM: {dm})')
await self.client.room_send(
room_id=room_id,
message_type='m.room.message',
content={'msgtype': 'm.text', 'body': '\n'.join(lines)},
)
async def kill(self) -> bool:
self._running = False
for bridge in self._bridges:
if bridge.login_task and not bridge.login_task.done():
bridge.login_task.cancel()
if bridge.check_task and not bridge.check_task.done():
bridge.check_task.cancel()
if self.client:
await self.client.close()
await self.logger.debug('Matrix adapter stopped')
return True
async def unregister_listener(
self,
event_type: typing.Type[platform_events.Event],
callback: typing.Callable[
[platform_events.Event, abstract_platform_adapter.AbstractMessagePlatformAdapter], None
],
):
if event_type in self.listeners:
del self.listeners[event_type]

View File

@@ -1,123 +0,0 @@
apiVersion: v1
kind: MessagePlatformAdapter
metadata:
name: matrix
label:
en_US: Matrix
zh_Hans: Matrix
zh_Hant: Matrix
ja_JP: Matrix
th_TH: Matrix
vi_VN: Matrix
es_ES: Matrix
description:
en_US: Matrix protocol adapter, supports self-hosted Synapse servers and any Matrix-compatible homeserver
zh_Hans: Matrix 协议适配器,支持自建 Synapse 服务器及任何 Matrix 兼容的 Homeserver
zh_Hant: Matrix 協議適配器,支持自建 Synapse 伺服器及任何 Matrix 相容的 Homeserver
ja_JP: Matrix プロトコルアダプター、セルフホストの Synapse サーバーおよび Matrix 互換のホームサーバーをサポート
th_TH: อะแดปเตอร์โปรโตคอล Matrix รองรับเซิร์ฟเวอร์ Synapse ที่โฮสต์เองและ Homeserver ที่เข้ากันได้กับ Matrix
vi_VN: Bộ điều hợp giao thức Matrix, hỗ trợ máy chủ Synapse tự lưu trữ và bất kỳ Homeserver tương thích Matrix nào
es_ES: Adaptador del protocolo Matrix, compatible con servidores Synapse autoalojados y cualquier Homeserver compatible con Matrix
icon: matrix.png
spec:
categories:
- global
- protocol
config:
- name: homeserver_url
label:
en_US: Homeserver URL
zh_Hans: Homeserver 地址
zh_Hant: Homeserver 地址
ja_JP: Homeserver URL
th_TH: URL ของ Homeserver
vi_VN: URL Homeserver
es_ES: URL del Homeserver
description:
en_US: "The URL of the Matrix homeserver, e.g. http://localhost:8008"
zh_Hans: "Matrix Homeserver 的地址,例如 http://localhost:8008"
type: string
required: true
default: "http://localhost:8008"
- name: user_id
label:
en_US: Bot User ID
zh_Hans: 机器人用户 ID
zh_Hant: 機器人用戶 ID
ja_JP: ボットユーザー ID
th_TH: ID ผู้ใช้บอท
vi_VN: ID người dùng bot
es_ES: ID de usuario del bot
description:
en_US: "The full Matrix user ID, e.g. @bot:localhost"
zh_Hans: "完整的 Matrix 用户 ID例如 @bot:localhost"
type: string
required: true
default: "@langbot:localhost"
- name: access_token
label:
en_US: Access Token
zh_Hans: 访问令牌
zh_Hant: 訪問令牌
ja_JP: アクセストークン
th_TH: โทเค็นการเข้าถึง
vi_VN: Mã truy cập
es_ES: Token de acceso
description:
en_US: "Access token obtained by logging in via the Matrix client API"
zh_Hans: "通过 Matrix Client API 登录获取的访问令牌"
type: string
required: true
default: ""
- name: bridge_user_id
label:
en_US: Bridge Bot User ID (single bridge, legacy)
zh_Hans: 桥机器人用户 ID单桥兼容
description:
en_US: "Single bridge bot user ID (legacy). Prefer 'bridges' for multi-bridge. e.g. @discordbot:localhost"
zh_Hans: "单桥机器人用户 ID旧格式兼容。推荐使用 bridges 配置多桥。例如 @discordbot:localhost"
type: string
required: false
default: ""
- name: bridge_login_command
label:
en_US: Bridge Login Command (single bridge, legacy)
zh_Hans: 桥登录命令(单桥兼容)
description:
en_US: "Login command for single bridge (legacy). e.g. !discord login"
zh_Hans: "单桥登录命令(旧格式兼容)。例如 !discord login"
type: string
required: false
default: ""
- name: bridge_login_success_keyword
label:
en_US: Bridge Login Success Keyword (single bridge, legacy)
zh_Hans: 桥登录成功关键词(单桥兼容)
description:
en_US: "Success keyword for single bridge (legacy). e.g. Successfully logged in"
zh_Hans: "单桥登录成功关键词(旧格式兼容)。例如 Successfully logged in"
type: string
required: false
default: "Successfully logged in"
- name: bridges
label:
en_US: Bridges Config (Multi-bridge)
zh_Hans: 桥配置(多桥)
description:
en_US: >
JSON array of bridge configs. Each bridge: {"user_id": "@bot:host", "login_command": "!xx login",
"success_keyword": "logged in", "check_command": "!xx ping"}.
Example: [{"user_id":"@discordbot:localhost","login_command":"!discord login","success_keyword":"logged in"},
{"user_id":"@telegrambot:localhost","login_command":"!tg login","success_keyword":"logged in"}]
zh_Hans: >
JSON 数组格式的多桥配置。每个桥: {"user_id": "@bot:host", "login_command": "!xx login",
"success_keyword": "logged in", "check_command": "!xx ping"}。
示例: [{"user_id":"@discordbot:localhost","login_command":"!discord login","success_keyword":"logged in"},
{"user_id":"@telegrambot:localhost","login_command":"!tg login","success_keyword":"logged in"}]
type: string
required: false
default: ""
execution:
python:
path: ./matrix.py
attr: MatrixAdapter

View File

@@ -1,11 +1,9 @@
from __future__ import annotations
import typing
import re
import asyncio
import traceback
import datetime
import time
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
import langbot_plugin.api.entities.builtin.platform.message as platform_message
@@ -17,25 +15,11 @@ from ...utils import image
from ..logger import EventLogger
def _is_base64_data(value: str) -> bool:
"""Check if a string contains base64-encoded data rather than a URL."""
if not value:
return False
# data: URI scheme (e.g. data:image/png;base64,xxx)
if value.startswith('data:'):
return True
# Only treat as base64 if it doesn't look like a URL/path and has valid base64 chars
if value.startswith(('http://', 'https://', '/', './', '../')):
return False
# Check if it looks like base64 (only valid chars, reasonable length)
return bool(re.fullmatch(r'[A-Za-z0-9+/=\s]{20,}', value))
class QQOfficialMessageConverter(abstract_platform_adapter.AbstractMessageConverter):
@staticmethod
async def yiri2target(message_chain: platform_message.MessageChain):
"""将 LangBot 消息链转换为 QQ Official 消息格式列表。"""
content_list = []
# 只实现了发文字
for msg in message_chain:
if type(msg) is platform_message.Plain:
content_list.append(
@@ -44,49 +28,6 @@ class QQOfficialMessageConverter(abstract_platform_adapter.AbstractMessageConver
'content': msg.text,
}
)
elif type(msg) is platform_message.Image:
url = msg.url if hasattr(msg, 'url') and msg.url else None
b64 = msg.base64 if hasattr(msg, 'base64') and msg.base64 else None
# Some plugins (e.g. MimoTTS) store base64 data in the url field
if url and not b64 and _is_base64_data(url):
b64 = url
url = None
content_list.append(
{
'type': 'image',
'url': url,
'base64': b64,
}
)
elif type(msg) is platform_message.Voice:
url = msg.url if hasattr(msg, 'url') and msg.url else None
b64 = msg.base64 if hasattr(msg, 'base64') and msg.base64 else None
# Some plugins (e.g. MimoTTS) store base64 data in the url field
if url and not b64 and _is_base64_data(url):
b64 = url
url = None
content_list.append(
{
'type': 'voice',
'url': url,
'base64': b64,
}
)
elif type(msg) is platform_message.File:
url = msg.url if hasattr(msg, 'url') and msg.url else None
b64 = msg.base64 if hasattr(msg, 'base64') and msg.base64 else None
# Some plugins store base64 data in the url field
if url and not b64 and _is_base64_data(url):
b64 = url
url = None
content_list.append(
{
'type': 'file',
'url': url,
'base64': b64,
'name': msg.name if hasattr(msg, 'name') else 'file',
}
)
return content_list
@@ -188,19 +129,12 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
config: dict
bot_account_id: str
bot_uuid: str = None
enable_webhook: bool = False
message_converter: QQOfficialMessageConverter = QQOfficialMessageConverter()
event_converter: QQOfficialEventConverter = QQOfficialEventConverter()
def __init__(self, config: dict, logger: EventLogger):
enable_webhook = config.get('enable-webhook', False)
bot = QQOfficialClient(
app_id=config['appid'],
secret=config['secret'],
token=config['token'],
logger=logger,
unified_mode=enable_webhook,
app_id=config['appid'], secret=config['secret'], token=config['token'], logger=logger, unified_mode=True
)
super().__init__(
@@ -210,13 +144,6 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
bot_account_id=config['appid'],
)
self.enable_webhook = enable_webhook
self._ws_task: asyncio.Task = None
self._stream_ctx: dict = {}
self._stream_ctx_ts: dict[str, float] = {}
self._fallback_text: dict[str, str] = {}
self._fallback_text_ts: dict[str, float] = {}
async def reply_message(
self,
message_source: platform_events.MessageEvent,
@@ -229,18 +156,28 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
content_list = await QQOfficialMessageConverter.yiri2target(message)
# 确定 target_type 和 target_id
target_type = None
target_id = None
# 私聊消息
if qq_official_event.t == 'C2C_MESSAGE_CREATE':
target_type = 'c2c'
target_id = qq_official_event.user_openid
elif qq_official_event.t == 'GROUP_AT_MESSAGE_CREATE':
target_type = 'group'
target_id = qq_official_event.group_openid
elif qq_official_event.t == 'AT_MESSAGE_CREATE':
# 频道群聊使用频道 API暂不支持富媒体
for content in content_list:
if content['type'] == 'text':
await self.bot.send_private_text_msg(
qq_official_event.user_openid,
content['content'],
qq_official_event.d_id,
)
# 群聊消息
if qq_official_event.t == 'GROUP_AT_MESSAGE_CREATE':
for content in content_list:
if content['type'] == 'text':
await self.bot.send_group_text_msg(
qq_official_event.group_openid,
content['content'],
qq_official_event.d_id,
)
# 频道群聊
if qq_official_event.t == 'AT_MESSAGE_CREATE':
for content in content_list:
if content['type'] == 'text':
await self.bot.send_channle_group_text_msg(
@@ -248,9 +185,9 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
content['content'],
qq_official_event.d_id,
)
return
elif qq_official_event.t == 'DIRECT_MESSAGE_CREATE':
# 频道私聊使用频道 API暂不支持富媒体
# 频道私聊
if qq_official_event.t == 'DIRECT_MESSAGE_CREATE':
for content in content_list:
if content['type'] == 'text':
await self.bot.send_channle_private_text_msg(
@@ -258,63 +195,6 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
content['content'],
qq_official_event.d_id,
)
return
# C2C 和群聊:支持文字 + 富媒体
for content in content_list:
content_type = content.get('type', 'text')
if content_type == 'text':
if target_type == 'c2c':
await self.bot.send_private_text_msg(
target_id,
content['content'],
qq_official_event.d_id,
)
elif target_type == 'group':
await self.bot.send_group_text_msg(
target_id,
content['content'],
qq_official_event.d_id,
)
elif content_type == 'image':
file_url = content.get('url')
file_data = content.get('base64')
if file_url or file_data:
await self.bot.send_image_msg(
target_type,
target_id,
file_url=file_url,
file_data=file_data,
msg_id=qq_official_event.d_id,
)
elif content_type == 'voice':
file_url = content.get('url')
file_data = content.get('base64')
if file_url or file_data:
await self.bot.send_voice_msg(
target_type,
target_id,
file_url=file_url,
file_data=file_data,
msg_id=qq_official_event.d_id,
)
elif content_type == 'file':
file_url = content.get('url')
file_data = content.get('base64')
file_name = content.get('name', 'file')
if file_url or file_data:
await self.bot.send_file_msg(
target_type,
target_id,
file_url=file_url,
file_data=file_data,
file_name=file_name,
msg_id=qq_official_event.d_id,
)
async def send_message(self, target_type: str, target_id: str, message: platform_message.MessageChain):
pass
@@ -358,196 +238,17 @@ class QQOfficialAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter
return await self.bot.handle_unified_webhook(request)
async def run_async(self):
if not self.enable_webhook:
await self._run_websocket()
else:
# 统一 webhook 模式下,不启动独立的 Quart 应用
async def keep_alive():
while True:
await asyncio.sleep(1)
# 统一 webhook 模式下,不启动独立的 Quart 应用
# 保持运行但不启动独立端口
await keep_alive()
async def keep_alive():
while True:
await asyncio.sleep(1)
async def _run_websocket(self):
"""以 WebSocket 模式运行网关连接"""
await self.logger.info('QQ Official adapter starting in WebSocket mode')
async def on_ready():
await self.logger.info('QQ Official WebSocket connected and ready')
async def on_event(event_type: str, event_data: dict):
# 只处理消息事件,忽略 READY/RESUMED 等系统事件
message_event_types = {
'C2C_MESSAGE_CREATE',
'DIRECT_MESSAGE_CREATE',
'GROUP_AT_MESSAGE_CREATE',
'AT_MESSAGE_CREATE',
}
if event_type not in message_event_types:
return
if not isinstance(event_data, dict):
await self.logger.warning(f'Event data is not dict, skipping: {event_type} -> {type(event_data)}')
return
await self.logger.info(f'Processing message event: {event_type}')
# 构造与 webhook 模式相同的 payload 结构
payload = {'t': event_type, 'd': event_data}
message_data = await self.bot.get_message(payload)
if message_data:
event = QQOfficialEvent.from_payload(message_data)
await self.bot._handle_message(event)
async def on_error(error: Exception):
await self.logger.error(f'WebSocket error: {error}')
await self.logger.error(f'QQ Official WebSocket error: {error}')
self._ws_task = asyncio.create_task(self.bot.connect_gateway_loop(on_event, on_ready, on_error))
try:
await self._ws_task
except asyncio.CancelledError:
pass
await keep_alive()
async def kill(self) -> bool:
if self._ws_task:
self._ws_task.cancel()
try:
await self._ws_task
except asyncio.CancelledError:
pass
self._ws_task = None
return True
# --------------- 流式输出 ---------------
_STREAM_CTX_TTL = 300 # seconds
async def _cleanup_stale_streams(self):
"""Remove stream contexts that have not been updated for more than _STREAM_CTX_TTL seconds."""
now = time.time()
stale_ids = [mid for mid, ts in self._stream_ctx_ts.items() if now - ts > self._STREAM_CTX_TTL]
for mid in stale_ids:
self._stream_ctx.pop(mid, None)
self._stream_ctx_ts.pop(mid, None)
stale_fb = [mid for mid, ts in self._fallback_text_ts.items() if now - ts > self._STREAM_CTX_TTL]
for mid in stale_fb:
self._fallback_text.pop(mid, None)
self._fallback_text_ts.pop(mid, None)
if stale_ids or stale_fb:
await self.logger.debug(f'Cleaned up {len(stale_ids)} stream contexts, {len(stale_fb)} fallback texts')
async def is_stream_output_supported(self) -> bool:
return self.config.get('enable-stream-reply', False)
async def create_message_card(self, message_id: str, event: platform_events.MessageEvent) -> bool:
source = event.source_platform_object
# Streaming API only supports C2C private chat
if source.t != 'C2C_MESSAGE_CREATE':
return False
ctx = {
'user_openid': source.user_openid,
'msg_id': source.d_id,
'stream_msg_id': None,
'msg_seq': 1,
'index': 0,
'last_update_ts': 0,
'accumulated_text': '',
'sent_length': 0,
'session_started': False,
}
self._stream_ctx[message_id] = ctx
self._stream_ctx_ts[message_id] = time.time()
return True
async def reply_message_chunk(
self,
message_source: platform_events.MessageEvent,
bot_message: dict,
message: platform_message.MessageChain,
quote_origin: bool = False,
is_final: bool = False,
):
# Periodically clean up stale stream contexts
await self._cleanup_stale_streams()
# 提取纯文本内容(当前 chunk 的文本)
text_parts = []
for msg in message:
if type(msg) is platform_message.Plain:
text_parts.append(msg.text)
chunk_text = '\n\n'.join(text_parts)
message_id = (
bot_message.get('resp_message_id')
if isinstance(bot_message, dict)
else getattr(bot_message, 'resp_message_id', None)
)
if not message_id or message_id not in self._stream_ctx:
# 非流式场景(如群聊不支持流式),累积文本后一次性回复
if chunk_text:
self._fallback_text[message_id] = self._fallback_text.get(message_id, '') + chunk_text
self._fallback_text_ts[message_id] = time.time()
if is_final:
full_text = self._fallback_text.pop(message_id, '')
if full_text:
fallback_msg = platform_message.MessageChain([platform_message.Plain(text=full_text)])
await self.reply_message(message_source, fallback_msg, quote_origin)
return
ctx = self._stream_ctx[message_id]
# 累积文本
if chunk_text:
ctx['accumulated_text'] += chunk_text
# 未启动会话时,等第一个有内容的 chunk 来建立会话
if not ctx['session_started']:
if not ctx['accumulated_text']:
return
# 用第一个 chunk 的文本建立会话(不发 "..." 避免污染前缀)
ctx['session_started'] = True
# 发送内容 = 全量累积文本
# QQ API 的 replace 模式不允许修改已下发前缀,所以:
# - 首次:发送全部文本,建立会话
# - 后续只能发送新增部分append 行为)
content_to_send = ctx['accumulated_text'][ctx['sent_length'] :]
if not content_to_send and not is_final:
return
input_state = 10 if is_final else 1
# Rate limiting: skip non-final updates if last update was <0.5s ago
now = time.time()
if not is_final and (now - ctx['last_update_ts']) < 0.5:
return
ctx['last_update_ts'] = now
try:
resp = await self.bot.send_stream_msg(
user_openid=ctx['user_openid'],
content=content_to_send,
event_id=ctx['msg_id'],
msg_id=ctx['msg_id'],
msg_seq=ctx['msg_seq'],
index=ctx['index'],
stream_msg_id=ctx['stream_msg_id'],
input_state=input_state,
)
if resp and isinstance(resp, dict):
new_stream_id = resp.get('id')
if new_stream_id:
ctx['stream_msg_id'] = new_stream_id
ctx['sent_length'] = len(ctx['accumulated_text'])
ctx['index'] += 1
await self.logger.debug(
f'[QQ Official] 流式 chunk 已发送, index={ctx["index"]}, '
f'sent_len={ctx["sent_length"]}, is_final={is_final}'
)
except Exception as e:
await self.logger.error(f'Failed to send stream message: {e}')
if is_final:
self._stream_ctx.pop(message_id, None)
return False
def unregister_listener(
self,

View File

@@ -7,9 +7,9 @@ metadata:
zh_Hans: QQ 官方 API
zh_Hant: QQ 官方 API
description:
en_US: QQ Official API (Webhook / WebSocket)
zh_Hans: QQ 官方 API,支持 Webhook 和 WebSocket 两种连接模
zh_Hant: QQ 官方 API,支援 Webhook 和 WebSocket 兩種連線模
en_US: QQ Official API (Webhook)
zh_Hans: QQ 官方 API (Webhook),需要公网地址以接收消息推送,请查看文档了解使用方
zh_Hant: QQ 官方 API (Webhook),需要公網地址以接收訊息推送,請查看文件了解使用方
icon: qqofficial.svg
spec:
categories:
@@ -19,6 +19,18 @@ spec:
en: https://link.langbot.app/en/platforms/qqofficial
ja: https://link.langbot.app/ja/platforms/qqofficial
config:
- name: webhook_url
label:
en_US: Webhook Callback URL
zh_Hans: Webhook 回调地址
zh_Hant: Webhook 回調地址
description:
en_US: Copy this URL and paste it into your QQ Official API webhook configuration
zh_Hans: 复制此地址并粘贴到 QQ 官方 API 的 Webhook 配置中
zh_Hant: 複製此地址並貼到 QQ 官方 API 的 Webhook 設定中
type: webhook-url
required: false
default: ""
- name: appid
label:
en_US: App ID
@@ -43,46 +55,6 @@ spec:
type: string
required: true
default: ""
- name: enable-webhook
label:
en_US: Enable Webhook Mode
zh_Hans: 启用Webhook模式
zh_Hant: 啟用 Webhook 模式
description:
en_US: If enabled, the bot will use webhook mode to receive messages. Otherwise, it will use WebSocket mode
zh_Hans: 如果启用,机器人将使用 Webhook 模式接收消息。否则,将使用 WebSocket 模式
zh_Hant: 如果啟用,機器人將使用 Webhook 模式接收訊息。否則,將使用 WebSocket 模式
type: boolean
required: true
default: false
- name: enable-stream-reply
label:
en_US: Enable Stream Reply Mode
zh_Hans: 启用流式回复模式
zh_Hant: 啟用串流回覆模式
description:
en_US: If enabled, the bot will use streaming mode to reply messages (C2C only)
zh_Hans: 如果启用,机器人将使用流式方式回复消息(仅私聊)
zh_Hant: 如果啟用,機器人將使用串流方式回覆訊息(僅私聊)
type: boolean
required: true
default: false
- name: webhook_url
label:
en_US: Webhook Callback URL
zh_Hans: Webhook 回调地址
zh_Hant: Webhook 回調地址
description:
en_US: Copy this URL and paste it into your QQ Official API webhook configuration
zh_Hans: 复制此地址并粘贴到 QQ 官方 API 的 Webhook 配置中
zh_Hant: 複製此地址並貼到 QQ 官方 API 的 Webhook 設定中
type: webhook-url
required: false
default: ""
show_if:
field: enable-webhook
operator: eq
value: true
execution:
python:
path: ./qqofficial.py

View File

@@ -1,177 +0,0 @@
apiVersion: v1
kind: MessagePlatformAdapter
metadata:
name: web_page_bot
label:
en_US: "Page Bot"
zh_Hans: "页面机器人"
zh_Hant: "頁面機器人"
ja_JP: "ページボット"
th_TH: "บอทหน้าเว็บ"
vi_VN: "Bot trang web"
es_ES: "Bot de página"
description:
en_US: "Embed a chat widget on any website with a simple script tag"
zh_Hans: "通过一行脚本标签将聊天组件嵌入到任何网站"
zh_Hant: "透過一行腳本標籤將聊天元件嵌入到任何網站"
ja_JP: "シンプルなスクリプトタグで任意のウェブサイトにチャットウィジェットを埋め込みます"
th_TH: "ฝังวิดเจ็ตแชทในเว็บไซต์ใดก็ได้ด้วยแท็กสคริปต์"
vi_VN: "Nhúng widget trò chuyện vào bất kỳ trang web nào bằng thẻ script"
es_ES: "Incrusta un widget de chat en cualquier sitio web con una etiqueta de script"
icon: "webpage.webp"
spec:
categories:
- popular
config:
- name: title
label:
en_US: Widget Title
zh_Hans: 组件标题
zh_Hant: 元件標題
ja_JP: ウィジェットタイトル
th_TH: ชื่อวิดเจ็ต
vi_VN: Tiêu đề widget
es_ES: Título del widget
description:
en_US: The title displayed in the chat widget header
zh_Hans: 显示在聊天组件顶部的标题
zh_Hant: 顯示在聊天元件頂部的標題
ja_JP: チャットウィジェットのヘッダーに表示されるタイトル
th_TH: ชื่อที่แสดงในส่วนหัวของวิดเจ็ตแชท
vi_VN: Tiêu đề hiển thị trong đầu widget trò chuyện
es_ES: El título que se muestra en el encabezado del widget de chat
type: string
required: false
default: "LangBot"
- name: bubble_icon
label:
en_US: Bubble Icon
zh_Hans: 气泡图标
zh_Hant: 氣泡圖示
ja_JP: バブルアイコン
th_TH: ไอคอนบับเบิล
vi_VN: Biểu tượng bong bóng
es_ES: Icono de burbuja
ru_RU: Иконка пузырька
description:
en_US: "Icon displayed on the floating chat bubble"
zh_Hans: "浮动聊天气泡上显示的图标"
type: select
required: false
default: "logo"
options:
- name: "logo"
label:
en_US: "LangBot Logo"
zh_Hans: "LangBot 图标"
- name: "chat"
label:
en_US: "Chat Bubble"
zh_Hans: "聊天气泡"
- name: "robot"
label:
en_US: "Robot"
zh_Hans: "机器人"
- name: "headset"
label:
en_US: "Headset"
zh_Hans: "客服耳机"
- name: "sparkle"
label:
en_US: "Sparkle"
zh_Hans: "星光"
- name: "message"
label:
en_US: "Message"
zh_Hans: "消息"
- name: language
label:
en_US: Widget Language
zh_Hans: 组件语言
zh_Hant: 元件語言
ja_JP: ウィジェット言語
th_TH: ภาษาวิดเจ็ต
vi_VN: Ngôn ngữ widget
es_ES: Idioma del widget
ru_RU: Язык виджета
description:
en_US: "Display language of the chat widget"
zh_Hans: "聊天组件的显示语言"
zh_Hant: "聊天元件的顯示語言"
ja_JP: "チャットウィジェットの表示言語"
th_TH: "ภาษาแสดงผลของวิดเจ็ตแชท"
vi_VN: "Ngôn ngữ hiển thị của widget trò chuyện"
es_ES: "Idioma de visualización del widget de chat"
ru_RU: "Язык отображения виджета чата"
type: select
required: false
default: "en_US"
options:
- name: "en_US"
label:
en_US: "English"
- name: "zh_Hans"
label:
en_US: "简体中文"
- name: "zh_Hant"
label:
en_US: "繁體中文"
- name: "ja_JP"
label:
en_US: "日本語"
- name: "es_ES"
label:
en_US: "Español"
- name: "ru_RU"
label:
en_US: "Русский"
- name: "th_TH"
label:
en_US: "ไทย"
- name: "vi_VN"
label:
en_US: "Tiếng Việt"
- name: embed_code
label:
en_US: Embed Code
zh_Hans: 嵌入代码
zh_Hant: 嵌入代碼
ja_JP: 埋め込みコード
th_TH: โค้ดฝังตัว
vi_VN: Mã nhúng
es_ES: Código de incrustación
description:
en_US: "Copy this code and paste it into your website HTML. The code will be generated after saving."
zh_Hans: "复制此代码并粘贴到你的网站 HTML 中。保存后将自动生成。"
zh_Hant: "複製此代碼並貼到你的網站 HTML 中。儲存後將自動生成。"
ja_JP: "このコードをコピーしてウェブサイトのHTMLに貼り付けてください。保存後に自動生成されます。"
th_TH: "คัดลอกโค้ดนี้และวางในHTML ของเว็บไซต์ของคุณ จะสร้างอัตโนมัติหลังจากบันทึก"
vi_VN: "Sao chép mã này và dán vào HTML trang web của bạn. Mã sẽ được tạo tự động sau khi lưu."
es_ES: "Copia este código y pégalo en el HTML de tu sitio web. El código se generará después de guardar."
type: embed-code
required: false
default: ""
- name: turnstile_site_key
label:
en_US: Turnstile Site Key
zh_Hans: Turnstile 站点密钥
description:
en_US: "Cloudflare Turnstile site key for bot protection. Get it from the Cloudflare dashboard (Turnstile > Add Site). Leave empty to disable."
zh_Hans: "Cloudflare Turnstile 站点密钥,用于防止机器人滥用。在 Cloudflare 控制台Turnstile > 添加站点)中获取。留空则不启用。"
type: string
required: false
default: ""
- name: turnstile_secret_key
label:
en_US: Turnstile Secret Key
zh_Hans: Turnstile 服务端密钥
description:
en_US: "Cloudflare Turnstile secret key for server-side token verification. Found alongside the site key in the Cloudflare dashboard. Required if site key is set."
zh_Hans: "Cloudflare Turnstile 服务端密钥,用于服务端验证令牌。与站点密钥一起在 Cloudflare 控制台中获取。设置了站点密钥时必填。"
type: string
required: false
default: ""
execution:
python:
path: "web_page_bot_adapter.py"
attr: "WebPageBotAdapter"

View File

@@ -1,97 +0,0 @@
"""Web Page Bot adapter - lightweight adapter for embeddable chat widget"""
import typing
import pydantic
import langbot_plugin.api.definition.abstract.platform.adapter as abstract_platform_adapter
import langbot_plugin.api.entities.builtin.platform.message as platform_message
import langbot_plugin.api.entities.builtin.platform.events as platform_events
import langbot_plugin.api.definition.abstract.platform.event_logger as abstract_platform_logger
class WebPageBotAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
"""Lightweight adapter for the embeddable page bot.
This adapter does not handle messages itself. The actual WebSocket
communication is handled by the singleton websocket_proxy_bot.
This adapter stores event listeners so that RuntimeBot can register
its handlers, which are then called by the websocket adapter when
a message arrives for this bot's pipeline.
Message sending/replying is delegated to the websocket_proxy_bot's
adapter so that replies are actually delivered over the WebSocket
connection while the dashboard correctly shows this adapter's name.
"""
listeners: dict = pydantic.Field(default_factory=dict, exclude=True)
_ws_adapter: typing.Any = None
class Config:
arbitrary_types_allowed = True
# Allow private attributes
underscore_attrs_are_private = True
def __init__(self, config: dict, logger: abstract_platform_logger.AbstractEventLogger, **kwargs):
super().__init__(config=config, logger=logger, **kwargs)
def set_ws_adapter(self, ws_adapter) -> None:
"""Set the underlying WebSocket adapter used for actual message delivery."""
object.__setattr__(self, '_ws_adapter', ws_adapter)
async def send_message(
self,
target_type: str,
target_id: str,
message: platform_message.MessageChain,
) -> dict:
if self._ws_adapter is not None:
return await self._ws_adapter.send_message(target_type, target_id, message)
return {}
async def reply_message(
self,
message_source: platform_events.MessageEvent,
message: platform_message.MessageChain,
quote_origin: bool = False,
) -> dict:
if self._ws_adapter is not None:
return await self._ws_adapter.reply_message(message_source, message, quote_origin)
return {}
async def reply_message_chunk(
self,
message_source: platform_events.MessageEvent,
bot_message,
message: platform_message.MessageChain,
quote_origin: bool = False,
is_final: bool = False,
) -> dict:
if self._ws_adapter is not None:
return await self._ws_adapter.reply_message_chunk(
message_source, bot_message, message, quote_origin, is_final
)
return {}
def register_listener(
self,
event_type: typing.Type[platform_events.Event],
func: typing.Callable,
):
self.listeners[event_type] = func
def unregister_listener(
self,
event_type: typing.Type[platform_events.Event],
func: typing.Callable,
):
self.listeners.pop(event_type, None)
async def is_muted(self, group_id: int) -> bool:
return False
async def run_async(self):
pass
async def kill(self):
pass

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

View File

@@ -312,7 +312,7 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
async def _process_image_components(self, message_chain_obj: list):
"""
处理消息链中的图片和文件组件将path转换为base64
处理消息链中的图片组件将path转换为base64
Args:
message_chain_obj: 消息链对象列表
@@ -322,18 +322,16 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
storage_mgr = self.ap.storage_mgr
for component in message_chain_obj:
comp_type = component.get('type', '')
comp_path = component.get('path', '')
if not comp_path:
continue
if comp_type == 'Image':
if component.get('type') == 'Image' and component.get('path'):
try:
file_content = await storage_mgr.storage_provider.load(comp_path)
# 从storage读取文件
file_content = await storage_mgr.storage_provider.load(component['path'])
# 转换为base64
base64_str = base64.b64encode(file_content).decode('utf-8')
file_key = comp_path
# 添加data URI前缀根据文件扩展名判断MIME类型
file_key = component['path']
if file_key.lower().endswith(('.jpg', '.jpeg')):
mime_type = 'image/jpeg'
elif file_key.lower().endswith('.png'):
@@ -343,19 +341,19 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
elif file_key.lower().endswith('.webp'):
mime_type = 'image/webp'
else:
mime_type = 'image/png'
mime_type = 'image/png' # 默认
component['base64'] = f'data:{mime_type};base64,{base64_str}'
await storage_mgr.storage_provider.delete(comp_path)
await storage_mgr.storage_provider.delete(component['path'])
component['path'] = ''
# 保留path字段用于后端处理前端使用base64显示
except Exception as e:
await self.logger.error(f'Failed to load image file {comp_path}: {e}')
await self.logger.error(f'加载图片文件失败 {component["path"]}: {e}')
async def handle_websocket_message(
self,
connection: WebSocketConnection,
message_data: dict,
owner_bot=None,
):
"""
处理从WebSocket接收的消息
@@ -368,8 +366,6 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
message_data: 消息数据,包含:
- message: 消息链
- stream: 是否启用流式输出 (可选默认True)
owner_bot: Optional RuntimeBot that owns this pipeline (e.g. a web_page_bot).
When provided, its identity is used for logging and session tracking.
"""
pipeline_uuid = connection.pipeline_uuid
session_type = connection.session_type
@@ -439,26 +435,12 @@ class WebSocketAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter)
sender=sender, message_chain=message_chain, time=datetime.now().timestamp()
)
# 设置流水线UUID (proxy bot always needs it for reply_message routing)
# 设置流水线UUID
self.ap.platform_mgr.websocket_proxy_bot.bot_entity.use_pipeline_uuid = pipeline_uuid
if owner_bot is not None:
owner_bot.bot_entity.use_pipeline_uuid = pipeline_uuid
# 异步触发事件处理
# Use owner_bot's listeners if available, otherwise fall back to proxy bot
listeners = (
owner_bot.adapter.listeners
if (owner_bot and hasattr(owner_bot.adapter, 'listeners') and owner_bot.adapter.listeners)
else self.listeners
)
# Pass owner_bot's adapter so that downstream logging / dashboard
# attributes the message to the correct bot adapter name.
# Wire the ws adapter into the owner so replies are actually delivered.
if owner_bot and hasattr(owner_bot.adapter, 'set_ws_adapter'):
owner_bot.adapter.set_ws_adapter(self)
callback_adapter = owner_bot.adapter if (owner_bot and hasattr(owner_bot, 'adapter')) else self
if event.__class__ in listeners:
asyncio.create_task(listeners[event.__class__](event, callback_adapter))
# 异步触发事件处理(不等待结果)
if event.__class__ in self.listeners:
asyncio.create_task(self.listeners[event.__class__](event, self))
def get_websocket_messages(self, pipeline_uuid: str, session_type: str) -> list[dict]:
"""获取消息历史"""

View File

@@ -431,17 +431,6 @@ class PluginRuntimeConnector:
async def get_plugin_assets(self, plugin_author: str, plugin_name: str, filepath: str) -> dict[str, Any]:
return await self.handler.get_plugin_assets(plugin_author, plugin_name, filepath)
async def handle_page_api(
self,
plugin_author: str,
plugin_name: str,
page_id: str,
endpoint: str,
method: str,
body: Any = None,
) -> dict[str, Any]:
return await self.handler.handle_page_api(plugin_author, plugin_name, page_id, endpoint, method, body)
async def get_debug_info(self) -> dict[str, Any]:
"""Get debug information including debug key and WS URL"""
if not self.is_enable_plugin:

View File

@@ -367,22 +367,6 @@ class RuntimeConnectionHandler(handler.Handler):
owner_type = data['owner_type']
owner = data['owner']
value = base64.b64decode(data['value_base64'])
max_value_bytes = (
self.ap.instance_config.data.get('plugin', {})
.get('binary_storage', {})
.get(
'max_value_bytes',
10 * 1024 * 1024,
)
)
try:
max_value_bytes = int(max_value_bytes)
except (TypeError, ValueError):
max_value_bytes = 10 * 1024 * 1024
if max_value_bytes >= 0 and len(value) > max_value_bytes:
return handler.ActionResponse.error(
message=f'Binary storage value exceeds limit ({len(value)} > {max_value_bytes} bytes)',
)
result = await self.ap.persistence_mgr.execute_async(
sqlalchemy.select(persistence_bstorage.BinaryStorage)
@@ -955,11 +939,6 @@ class RuntimeConnectionHandler(handler.Handler):
timeout=20,
)
asset_file_key = result['file_file_key']
if not asset_file_key:
return {
'asset_base64': '',
'mime_type': '',
}
mime_type = result['mime_type']
asset_bytes = await self.read_local_file(asset_file_key)
await self.delete_local_file(asset_file_key)
@@ -968,30 +947,6 @@ class RuntimeConnectionHandler(handler.Handler):
'mime_type': mime_type,
}
async def handle_page_api(
self,
plugin_author: str,
plugin_name: str,
page_id: str,
endpoint: str,
method: str,
body: Any = None,
) -> dict[str, Any]:
"""Forward a page API call to the plugin via runtime."""
result = await self.call_action(
LangBotToRuntimeAction.PAGE_API,
{
'plugin_author': plugin_author,
'plugin_name': plugin_name,
'page_id': page_id,
'endpoint': endpoint,
'method': method,
'body': body,
},
timeout=30,
)
return result
async def cleanup_plugin_data(self, plugin_author: str, plugin_name: str) -> None:
"""Cleanup plugin settings and binary storage"""
# Delete plugin settings

View File

@@ -1 +0,0 @@
<svg height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Qiniu</title><path d="M23.111 4.6a.914.914 0 00-.861.161A13.443 13.443 0 017.947 8.897L7.38 6.831a1.076 1.076 0 00-1.211-.698l.27 2.18c-1.816-.827-2.313-.946-3.587-2.45C2.674 5.729 1.263 4.472.89 4.6a11.906 11.906 0 005.892 6.497l.738 5.97s.33 2.286 2.473 2.286h4.586c2.144 0 2.474-2.286 2.474-2.286l.518-4.28c-1.393-.11-2.268.857-2.546 1.814-.465 1.614-.465 1.716-.557 1.998-.188.575-.806.644-.806.644h-2.753s-.617-.07-.806-.644c-.12-.371-.727-2.54-1.335-4.74A11.877 11.877 0 0023.11 4.599V4.6z" fill="#06AEEF"></path></svg>

Before

Width:  |  Height:  |  Size: 649 B

View File

@@ -1,45 +0,0 @@
from __future__ import annotations
import typing
import openai
from . import chatcmpl
class QiniuChatCompletions(chatcmpl.OpenAIChatCompletions):
"""七牛云 ChatCompletion API 请求器"""
client: openai.AsyncClient
default_config: dict[str, typing.Any] = {
'base_url': 'https://api.qnaigc.com/v1',
'timeout': 120,
}
async def scan_models(self, api_key: str | None = None) -> dict[str, typing.Any]:
try:
result = await super().scan_models(api_key)
except Exception:
return self._qiniu_fallback_scan_result()
models = result.get('models') or []
if not models:
return self._qiniu_fallback_scan_result()
return result
def _qiniu_fallback_scan_result(self) -> dict[str, typing.Any]:
mid = 'deepseek-v3'
return {
'models': [
{
'id': mid,
'name': mid,
'type': 'llm',
'abilities': [],
}
],
'debug': {
'request': {'method': 'GET', 'url': '', 'headers': {}},
'response': {},
},
}

View File

@@ -1,31 +0,0 @@
apiVersion: v1
kind: LLMAPIRequester
metadata:
name: qiniu-chat-completions
label:
en_US: Qiniu
zh_Hans: 七牛云
icon: qiniu.svg
spec:
config:
- name: base_url
label:
en_US: Base URL
zh_Hans: 基础 URL
type: string
required: true
default: https://api.qnaigc.com/v1
- name: timeout
label:
en_US: Timeout
zh_Hans: 超时时间
type: integer
required: true
default: 120
support_type:
- llm
provider_category: maas
execution:
python:
path: ./qiniuchatcmpl.py
attr: QiniuChatCompletions

View File

@@ -187,12 +187,6 @@ class N8nServiceAPIRunner(runner.RequestRunner):
if not query.session.using_conversation.uuid:
query.session.using_conversation.uuid = str(uuid.uuid4())
# Keep query variables in sync with the generated/new conversation id.
# query.variables is later merged into payload and would otherwise
# overwrite the generated conversation_id with the stale preprocessor
# value (usually None for a new conversation).
query.variables['conversation_id'] = query.session.using_conversation.uuid
# 预处理用户消息
plain_text = await self._preprocess_user_message(query)

View File

@@ -148,60 +148,52 @@ class RuntimeKnowledgeBase(KnowledgeBaseInterface):
supported_extensions = {'txt', 'pdf', 'docx', 'md', 'html'}
stored_file_tasks = []
try:
# use utf-8 encoding
with zipfile.ZipFile(io.BytesIO(zip_bytes), 'r', metadata_encoding='utf-8') as zip_ref:
for file_info in zip_ref.filelist:
# skip directories and hidden files
if file_info.is_dir() or file_info.filename.startswith('.'):
# use utf-8 encoding
with zipfile.ZipFile(io.BytesIO(zip_bytes), 'r', metadata_encoding='utf-8') as zip_ref:
for file_info in zip_ref.filelist:
# skip directories and hidden files
if file_info.is_dir() or file_info.filename.startswith('.'):
continue
_, file_ext = os.path.splitext(file_info.filename)
file_extension = file_ext.lstrip('.').lower()
if file_extension not in supported_extensions:
self.ap.logger.debug(f'Skipping unsupported file in ZIP: {file_info.filename}')
continue
try:
file_content = zip_ref.read(file_info.filename)
base_name = file_info.filename.replace('/', '_').replace('\\', '_')
file_stem, file_ext = os.path.splitext(base_name)
extension = file_ext.lstrip('.')
if file_stem.startswith('__MACOSX'):
continue
_, file_ext = os.path.splitext(file_info.filename)
file_extension = file_ext.lstrip('.').lower()
if file_extension not in supported_extensions:
self.ap.logger.debug(f'Skipping unsupported file in ZIP: {file_info.filename}')
continue
extracted_file_id = file_stem + '_' + str(uuid.uuid4())[:8] + '.' + extension
# save file to storage
try:
file_content = zip_ref.read(file_info.filename)
await self.ap.storage_mgr.storage_provider.save(extracted_file_id, file_content)
base_name = file_info.filename.replace('/', '_').replace('\\', '_')
file_stem, file_ext = os.path.splitext(base_name)
extension = file_ext.lstrip('.')
task_id = await self.store_file(extracted_file_id, parser_plugin_id=parser_plugin_id)
stored_file_tasks.append(task_id)
if file_stem.startswith('__MACOSX'):
continue
self.ap.logger.info(
f'Extracted and stored file from ZIP: {file_info.filename} -> {extracted_file_id}'
)
extracted_file_id = file_stem + '_' + str(uuid.uuid4())[:8] + '.' + extension
# save file to storage
except Exception as e:
self.ap.logger.warning(f'Failed to extract file {file_info.filename} from ZIP: {e}')
continue
await self.ap.storage_mgr.storage_provider.save(extracted_file_id, file_content)
if not stored_file_tasks:
raise Exception('No supported files found in ZIP archive')
task_id = await self.store_file(extracted_file_id, parser_plugin_id=parser_plugin_id)
stored_file_tasks.append(task_id)
self.ap.logger.info(f'Successfully processed ZIP file {zip_file_id}, extracted {len(stored_file_tasks)} files')
await self.ap.storage_mgr.storage_provider.delete(zip_file_id)
self.ap.logger.info(
f'Extracted and stored file from ZIP: {file_info.filename} -> {extracted_file_id}'
)
except Exception as e:
self.ap.logger.warning(f'Failed to extract file {file_info.filename} from ZIP: {e}')
continue
if not stored_file_tasks:
raise Exception('No supported files found in ZIP archive')
self.ap.logger.info(
f'Successfully processed ZIP file {zip_file_id}, extracted {len(stored_file_tasks)} files'
)
return stored_file_tasks[0] if stored_file_tasks else ''
finally:
try:
await self.ap.storage_mgr.storage_provider.delete(zip_file_id)
except FileNotFoundError:
pass
except Exception as e:
self.ap.logger.warning(f'Failed to cleanup ZIP file {zip_file_id}: {e}')
return stored_file_tasks[0] if stored_file_tasks else ''
async def retrieve(self, query: str, settings: dict | None = None) -> list[rag_context.RetrievalResultEntry]:
# Merge stored retrieval_settings with per-request overrides

View File

@@ -12,23 +12,6 @@ from .. import provider
LOCAL_STORAGE_PATH = os.path.join('data', 'storage')
def _safe_resolve(base: str, key: str) -> str:
"""Resolve *key* under *base* and ensure the result stays inside *base*.
Raises ``ValueError`` if the resolved path escapes the storage root
(e.g. via absolute paths, ``..`` components, or symlinks).
"""
# os.path.realpath resolves symlinks and normalises the path.
resolved = os.path.realpath(os.path.join(base, key))
canonical_base = os.path.realpath(base)
# The resolved path must be *strictly* inside the base directory (or equal
# to it only for directory operations). We append os.sep so that a base of
# "/data/storage" does not match "/data/storage_evil".
if not (resolved == canonical_base or resolved.startswith(canonical_base + os.sep)):
raise ValueError(f'Path traversal detected: key {key!r} resolves outside storage root')
return resolved
class LocalStorageProvider(provider.StorageProvider):
def __init__(self, ap: app.Application):
super().__init__(ap)
@@ -40,47 +23,40 @@ class LocalStorageProvider(provider.StorageProvider):
key: str,
value: bytes,
):
resolved = _safe_resolve(LOCAL_STORAGE_PATH, key)
parent = os.path.dirname(resolved)
if not os.path.exists(parent):
os.makedirs(parent)
async with aiofiles.open(resolved, 'wb') as f:
if not os.path.exists(os.path.join(LOCAL_STORAGE_PATH, os.path.dirname(key))):
os.makedirs(os.path.join(LOCAL_STORAGE_PATH, os.path.dirname(key)))
async with aiofiles.open(os.path.join(LOCAL_STORAGE_PATH, f'{key}'), 'wb') as f:
await f.write(value)
async def load(
self,
key: str,
) -> bytes:
resolved = _safe_resolve(LOCAL_STORAGE_PATH, key)
async with aiofiles.open(resolved, 'rb') as f:
async with aiofiles.open(os.path.join(LOCAL_STORAGE_PATH, f'{key}'), 'rb') as f:
return await f.read()
async def exists(
self,
key: str,
) -> bool:
resolved = _safe_resolve(LOCAL_STORAGE_PATH, key)
return os.path.exists(resolved)
return os.path.exists(os.path.join(LOCAL_STORAGE_PATH, f'{key}'))
async def delete(
self,
key: str,
):
resolved = _safe_resolve(LOCAL_STORAGE_PATH, key)
os.remove(resolved)
os.remove(os.path.join(LOCAL_STORAGE_PATH, f'{key}'))
async def size(
self,
key: str,
) -> int:
resolved = _safe_resolve(LOCAL_STORAGE_PATH, key)
return os.path.getsize(resolved)
return os.path.getsize(os.path.join(LOCAL_STORAGE_PATH, f'{key}'))
async def delete_dir_recursive(
self,
dir_path: str,
):
resolved = _safe_resolve(LOCAL_STORAGE_PATH, dir_path)
# 直接删除整个目录
if os.path.exists(resolved):
shutil.rmtree(resolved)
if os.path.exists(os.path.join(LOCAL_STORAGE_PATH, dir_path)):
shutil.rmtree(os.path.join(LOCAL_STORAGE_PATH, dir_path))

View File

@@ -25,9 +25,6 @@ system:
max_bots: -1
max_pipelines: -1
max_extensions: -1
task_retention:
# Keep at most this many completed async task records in memory
completed_limit: 200
jwt:
expire: 604800
secret: ''
@@ -71,15 +68,6 @@ vdb:
password: 'postgres'
storage:
use: local
cleanup:
# Enable periodic cleanup of local/S3 uploaded files and old log files
enabled: true
# Cleanup check interval in hours
check_interval_hours: 1
# Root-level uploaded files older than this will be deleted
uploaded_file_retention_days: 7
# LangBot log files older than this many days will be deleted
log_retention_days: 3
s3:
endpoint_url: ''
access_key_id: ''
@@ -91,9 +79,6 @@ plugin:
runtime_ws_url: 'ws://langbot_plugin_runtime:5400/control/ws'
enable_marketplace: true
display_plugin_debug_url: 'ws://localhost:5401/plugin/debug/ws'
binary_storage:
# Max bytes for a single plugin binary storage value
max_value_bytes: 10485760
monitoring:
auto_cleanup:
# Enable automatic cleanup of expired monitoring records
@@ -102,8 +87,6 @@ monitoring:
retention_days: 30
# Cleanup check interval in hours
check_interval_hours: 1
# Number of expired rows to delete per table batch
delete_batch_size: 1000
space:
# Space service URL for OAuth and API
url: 'https://space.langbot.app'

View File

@@ -38,8 +38,7 @@
},
"ai": {
"runner": {
"runner": "local-agent",
"expire-time": 0
"runner": "local-agent"
},
"local-agent": {
"model": {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

File diff suppressed because it is too large Load Diff

View File

@@ -47,26 +47,6 @@ stages:
label:
en_US: Langflow API
zh_Hans: Langflow API
- name: expire-time
label:
en_US: Conversation expire time (seconds)
zh_Hans: 单个对话过期时间(秒)
description:
en_US: >-
Maximum idle time of a single conversation, measured from the last
message/preprocess update time. When a new message arrives and the
current conversation has been idle for longer than this value, the
existing external conversation id is discarded and a new one is
started automatically — the user does not need to trigger a reset
manually. Set to 0 to disable expiry (conversations live until the
user or another mechanism resets them).
zh_Hans: >-
单个对话的最长空闲时间,从最后一条消息/preprocess 更新时间开始计算。
当新消息到达且当前对话空闲时间已超过该值时,旧的外部对话 ID 会被自动丢弃并开启新对话,
用户无需手动重置。设置为 0 表示不限制过期时间(对话会一直保留,直到用户或其他机制主动重置)。
type: integer
required: true
default: 0
- name: local-agent
label:
en_US: Local Agent
@@ -559,4 +539,4 @@ stages:
zh_Hans: 可选的流程调整参数
type: json
required: false
default: '{}'
default: '{}'

View File

@@ -72,4 +72,4 @@ stages:
- name: wait
label:
en_US: Wait
zh_Hans: 等待
zh_Hans: 等待

View File

@@ -1,106 +0,0 @@
from __future__ import annotations
from datetime import datetime, timedelta
from importlib import import_module
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock
import pytest
import yaml
def _preproc_module():
# Import pipelinemgr first so pipeline stages are registered without tripping
# the stage <-> core.app circular import during isolated test collection.
import_module('langbot.pkg.pipeline.pipelinemgr')
return import_module('langbot.pkg.pipeline.preproc.preproc')
def _entities_module():
return import_module('langbot.pkg.pipeline.entities')
def _conversation(created_at: datetime, updated_at: datetime | None = None):
prompt = Mock()
prompt.messages = []
prompt.copy = Mock(return_value=Mock(messages=[]))
return SimpleNamespace(
uuid='existing-conversation-uuid',
create_time=created_at,
update_time=updated_at,
prompt=prompt,
messages=[],
)
def _prompt_preprocessing_context(default_prompt=None, prompt=None):
ctx = Mock()
ctx.event.default_prompt = default_prompt or []
ctx.event.prompt = prompt or []
return ctx
async def _run_preprocessor(mock_app, sample_query, conversation):
session = SimpleNamespace(launcher_type=sample_query.launcher_type, launcher_id=sample_query.launcher_id)
mock_app.sess_mgr.get_session = AsyncMock(return_value=session)
mock_app.sess_mgr.get_conversation = AsyncMock(return_value=conversation)
mock_app.plugin_connector.emit_event = AsyncMock(return_value=_prompt_preprocessing_context())
sample_query.pipeline_config = {
'ai': {
'runner': {'runner': 'local-agent', 'expire-time': 60},
'local-agent': {'model': {'primary': '', 'fallbacks': []}, 'prompt': []},
},
'trigger': {'misc': {'combine-quote-message': False}},
'output': {'misc': {'exception-handling': 'show-hint'}},
}
return await _preproc_module().PreProcessor(mock_app).process(sample_query, 'PreProcessor')
@pytest.mark.asyncio
async def test_preprocessor_expires_conversation_from_last_update_time(mock_app, sample_query):
conversation = _conversation(
created_at=datetime.now() - timedelta(seconds=10),
updated_at=datetime.now() - timedelta(seconds=120),
)
result = await _run_preprocessor(mock_app, sample_query, conversation)
assert result.result_type == _entities_module().ResultType.CONTINUE
assert conversation.uuid is None
assert conversation.update_time > datetime.now() - timedelta(seconds=5)
assert result.new_query.variables['conversation_id'] is None
@pytest.mark.asyncio
async def test_preprocessor_keeps_conversation_when_last_update_is_not_expired(mock_app, sample_query):
conversation = _conversation(
created_at=datetime.now() - timedelta(seconds=120),
updated_at=datetime.now() - timedelta(seconds=30),
)
result = await _run_preprocessor(mock_app, sample_query, conversation)
assert result.result_type == _entities_module().ResultType.CONTINUE
assert conversation.uuid == 'existing-conversation-uuid'
assert conversation.update_time > datetime.now() - timedelta(seconds=5)
assert result.new_query.variables['conversation_id'] == 'existing-conversation-uuid'
def test_expire_time_metadata_lives_under_ai_runner_not_safety():
metadata_dir = Path('src/langbot/templates/metadata/pipeline')
ai_meta = yaml.safe_load((metadata_dir / 'ai.yaml').read_text())
safety_meta = yaml.safe_load((metadata_dir / 'safety.yaml').read_text())
ai_stage_names = [stage['name'] for stage in ai_meta['stages']]
assert 'session-limit' not in ai_stage_names
assert 'session-limit' not in [stage['name'] for stage in safety_meta['stages']]
runner_stage = next(stage for stage in ai_meta['stages'] if stage['name'] == 'runner')
expire_time = next(item for item in runner_stage['config'] if item['name'] == 'expire-time')
assert 'Conversation expire time' in expire_time['label']['en_US']
assert 'Session validity' not in expire_time['label']['en_US']

View File

@@ -1 +0,0 @@

View File

@@ -1,173 +0,0 @@
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import AsyncMock, Mock
import pytest
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
import langbot_plugin.api.entities.builtin.platform.entities as platform_entities
import langbot_plugin.api.entities.builtin.platform.events as platform_events
import langbot_plugin.api.entities.builtin.platform.message as platform_message
import langbot_plugin.api.entities.builtin.provider.session as provider_session
from langbot.pkg.api.http.service.model import _runtime_model_data
from langbot.pkg.entity.persistence import model as persistence_model
from langbot.pkg.pipeline.preproc.preproc import PreProcessor
from langbot.pkg.provider.modelmgr import requester
from langbot.pkg.provider.modelmgr.modelmgr import ModelManager
from langbot.pkg.provider.runners.localagent import LocalAgentRunner
def test_runtime_llm_model_data_preserves_uuid_after_update_payload_uuid_removed():
update_payload = {
'name': 'Qwen3.5-27B',
'provider_uuid': 'provider-uuid',
'abilities': [],
'extra_args': {},
}
runtime_entity = persistence_model.LLMModel(**_runtime_model_data('model-uuid', update_payload))
assert runtime_entity.uuid == 'model-uuid'
assert runtime_entity.name == 'Qwen3.5-27B'
def test_runtime_embedding_model_data_preserves_uuid_after_update_payload_uuid_removed():
update_payload = {
'name': 'embedding-model',
'provider_uuid': 'provider-uuid',
'extra_args': {},
}
runtime_entity = persistence_model.EmbeddingModel(**_runtime_model_data('embedding-uuid', update_payload))
assert runtime_entity.uuid == 'embedding-uuid'
assert runtime_entity.name == 'embedding-model'
def test_runtime_rerank_model_data_preserves_uuid_after_update_payload_uuid_removed():
update_payload = {
'name': 'rerank-model',
'provider_uuid': 'provider-uuid',
'extra_args': {},
}
runtime_entity = persistence_model.RerankModel(**_runtime_model_data('rerank-uuid', update_payload))
assert runtime_entity.uuid == 'rerank-uuid'
assert runtime_entity.name == 'rerank-model'
@pytest.mark.asyncio
async def test_updated_llm_model_is_immediately_usable_by_local_agent_pipeline():
from langbot.pkg.api.http.service.model import LLMModelsService
model_uuid = 'qwen-model-uuid'
provider_uuid = 'ollama-provider-uuid'
ap = SimpleNamespace()
ap.logger = Mock()
ap.persistence_mgr = SimpleNamespace(execute_async=AsyncMock())
ap.tool_mgr = SimpleNamespace(get_all_tools=AsyncMock(return_value=[]))
ap.plugin_connector = SimpleNamespace(
emit_event=AsyncMock(return_value=SimpleNamespace(event=SimpleNamespace(default_prompt=[], prompt=[])))
)
ap.model_mgr = ModelManager(ap)
runtime_provider = Mock()
ap.model_mgr.provider_dict = {provider_uuid: runtime_provider}
ap.model_mgr.llm_models = [
requester.RuntimeLLMModel(
model_entity=persistence_model.LLMModel(
uuid=model_uuid,
name='old-qwen-name',
provider_uuid=provider_uuid,
abilities=[],
extra_args={},
),
provider=runtime_provider,
)
]
await LLMModelsService(ap).update_llm_model(
model_uuid,
{
'name': 'Qwen3.5-27B',
'provider_uuid': provider_uuid,
'abilities': [],
'extra_args': {},
},
)
runtime_model = await ap.model_mgr.get_model_by_uuid(model_uuid)
assert runtime_model.model_entity.uuid == model_uuid
assert runtime_model.model_entity.name == 'Qwen3.5-27B'
session = SimpleNamespace(
launcher_type=provider_session.LauncherTypes.PERSON,
launcher_id=12345,
)
conversation = SimpleNamespace(
uuid='conversation-uuid',
create_time=None,
update_time=None,
prompt=SimpleNamespace(messages=[], copy=Mock(return_value=SimpleNamespace(messages=[]))),
messages=[],
)
ap.sess_mgr = SimpleNamespace(
get_session=AsyncMock(return_value=session),
get_conversation=AsyncMock(return_value=conversation),
)
message_chain = platform_message.MessageChain([platform_message.Plain(text='hello')])
sender = platform_entities.Friend(id=12345, nickname='Tester', remark=None)
message_event = platform_events.FriendMessage(
type='FriendMessage',
sender=sender,
message_chain=message_chain,
time=1710000000,
)
pipeline_config = {
'ai': {
'runner': {'runner': 'local-agent'},
'local-agent': {
'model': {'primary': model_uuid, 'fallbacks': []},
'prompt': [],
'knowledge-bases': [],
},
},
'trigger': {'misc': {'combine-quote-message': False}},
'output': {'misc': {'remove-think': False}},
}
query = pipeline_query.Query.model_construct(
query_id='query-id',
launcher_type=provider_session.LauncherTypes.PERSON,
launcher_id=12345,
sender_id=12345,
message_chain=message_chain,
message_event=message_event,
adapter=AsyncMock(),
pipeline_uuid='pipeline-uuid',
bot_uuid='bot-uuid',
pipeline_config=pipeline_config,
session=None,
prompt=None,
messages=[],
user_message=None,
use_funcs=[],
use_llm_model_uuid=None,
variables={},
resp_messages=[],
resp_message_chain=None,
current_stage_name=None,
)
result = await PreProcessor(ap).process(query, 'PreProcessor')
processed_query = result.new_query
assert processed_query.use_llm_model_uuid == model_uuid
runner = SimpleNamespace(ap=ap, pipeline_config=pipeline_config)
candidates = await LocalAgentRunner._get_model_candidates(runner, processed_query)
assert [model.model_entity.uuid for model in candidates] == [model_uuid]

View File

@@ -1,181 +0,0 @@
"""
PoC test for CWE-22 path traversal in LocalStorageProvider.
The LocalStorageProvider uses os.path.join(LOCAL_STORAGE_PATH, key) without
validating that the resulting path stays inside LOCAL_STORAGE_PATH.
When `key` is an absolute path (e.g. '/etc/passwd'), os.path.join discards
all previous components and returns the absolute path directly, allowing
arbitrary file reads, writes, and deletes.
This test must FAIL before the fix and PASS after.
"""
import os
import pytest
from unittest.mock import Mock, patch
from langbot.pkg.storage.providers.localstorage import LocalStorageProvider
@pytest.fixture
def storage_provider(tmp_path):
"""Create a LocalStorageProvider with a temporary storage path."""
storage_path = str(tmp_path / "storage")
with patch("langbot.pkg.storage.providers.localstorage.LOCAL_STORAGE_PATH", storage_path):
mock_app = Mock()
provider = LocalStorageProvider(mock_app)
yield provider, storage_path
class TestPathTraversalPrevention:
"""Test that LocalStorageProvider rejects path traversal attempts."""
@pytest.mark.asyncio
async def test_absolute_path_save_rejected(self, storage_provider, tmp_path):
"""Saving with an absolute path key must be blocked."""
provider, storage_path = storage_provider
target_file = str(tmp_path / "pwned.txt")
with patch("langbot.pkg.storage.providers.localstorage.LOCAL_STORAGE_PATH", storage_path):
with pytest.raises((ValueError, PermissionError)):
await provider.save(target_file, b"malicious content")
# The file must NOT exist outside the storage directory
assert not os.path.exists(target_file), (
f"Path traversal succeeded: file was written outside storage to {target_file}"
)
@pytest.mark.asyncio
async def test_absolute_path_load_rejected(self, storage_provider, tmp_path):
"""Loading with an absolute path key must be blocked."""
provider, storage_path = storage_provider
# Create a file outside the storage directory
target_file = str(tmp_path / "secret.txt")
with open(target_file, "wb") as f:
f.write(b"secret data")
with patch("langbot.pkg.storage.providers.localstorage.LOCAL_STORAGE_PATH", storage_path):
with pytest.raises((ValueError, PermissionError, FileNotFoundError)):
data = await provider.load(target_file)
assert data != b"secret data", (
"Path traversal succeeded: read file outside storage"
)
@pytest.mark.asyncio
async def test_absolute_path_exists_rejected(self, storage_provider, tmp_path):
"""Exists check with an absolute path key must be blocked or return False."""
provider, storage_path = storage_provider
target_file = str(tmp_path / "check_me.txt")
with open(target_file, "wb") as f:
f.write(b"data")
with patch("langbot.pkg.storage.providers.localstorage.LOCAL_STORAGE_PATH", storage_path):
try:
result = await provider.exists(target_file)
assert result is False, (
"Path traversal succeeded: exists() returned True for file outside storage"
)
except (ValueError, PermissionError):
pass # Expected
@pytest.mark.asyncio
async def test_absolute_path_delete_rejected(self, storage_provider, tmp_path):
"""Deleting with an absolute path key must be blocked."""
provider, storage_path = storage_provider
target_file = str(tmp_path / "do_not_delete.txt")
with open(target_file, "wb") as f:
f.write(b"important data")
with patch("langbot.pkg.storage.providers.localstorage.LOCAL_STORAGE_PATH", storage_path):
with pytest.raises((ValueError, PermissionError, FileNotFoundError)):
await provider.delete(target_file)
assert os.path.exists(target_file), (
"Path traversal succeeded: file outside storage was deleted"
)
@pytest.mark.asyncio
async def test_absolute_path_size_rejected(self, storage_provider, tmp_path):
"""Size check with an absolute path key must be blocked."""
provider, storage_path = storage_provider
target_file = str(tmp_path / "measure_me.txt")
with open(target_file, "wb") as f:
f.write(b"some data")
with patch("langbot.pkg.storage.providers.localstorage.LOCAL_STORAGE_PATH", storage_path):
with pytest.raises((ValueError, PermissionError, FileNotFoundError)):
await provider.size(target_file)
@pytest.mark.asyncio
async def test_dot_dot_path_traversal_rejected(self, storage_provider, tmp_path):
"""Relative path traversal with '..' must be blocked."""
provider, storage_path = storage_provider
target_file = str(tmp_path / "above_storage.txt")
with open(target_file, "wb") as f:
f.write(b"above storage secret")
with patch("langbot.pkg.storage.providers.localstorage.LOCAL_STORAGE_PATH", storage_path):
relative_key = os.path.join("..", "above_storage.txt")
with pytest.raises((ValueError, PermissionError, FileNotFoundError)):
data = await provider.load(relative_key)
assert data != b"above storage secret"
@pytest.mark.asyncio
async def test_delete_dir_recursive_traversal_rejected(self, storage_provider, tmp_path):
"""delete_dir_recursive with traversal path must be blocked."""
provider, storage_path = storage_provider
outside_dir = tmp_path / "outside_dir"
outside_dir.mkdir()
(outside_dir / "file.txt").write_text("important")
with patch("langbot.pkg.storage.providers.localstorage.LOCAL_STORAGE_PATH", storage_path):
with pytest.raises((ValueError, PermissionError)):
await provider.delete_dir_recursive(str(outside_dir))
assert outside_dir.exists(), (
"Path traversal succeeded: directory outside storage was deleted"
)
@pytest.mark.asyncio
async def test_legitimate_key_works(self, storage_provider):
"""Normal keys without traversal must still work."""
provider, storage_path = storage_provider
with patch("langbot.pkg.storage.providers.localstorage.LOCAL_STORAGE_PATH", storage_path):
key = "test_image_abc123.png"
content = b"PNG image data"
await provider.save(key, content)
assert await provider.exists(key) is True
loaded = await provider.load(key)
assert loaded == content
size = await provider.size(key)
assert size == len(content)
await provider.delete(key)
assert await provider.exists(key) is False
@pytest.mark.asyncio
async def test_legitimate_subdirectory_key_works(self, storage_provider):
"""Keys with legitimate subdirectories must still work."""
provider, storage_path = storage_provider
with patch("langbot.pkg.storage.providers.localstorage.LOCAL_STORAGE_PATH", storage_path):
key = "bot_log_images/img_001.png"
content = b"PNG image data"
await provider.save(key, content)
assert await provider.exists(key) is True
loaded = await provider.load(key)
assert loaded == content
await provider.delete(key)
if __name__ == "__main__":
pytest.main([__file__, "-v"])

7171
uv.lock generated

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -618,8 +618,6 @@ export default function BotForm({
systemContext={{
webhook_url: webhookUrl,
extra_webhook_url: extraWebhookUrl,
bot_uuid: initBotId || '',
adapter_config: form.getValues('adapter_config') || {},
}}
/>
)}

View File

@@ -6,7 +6,6 @@ import { useTranslation } from 'react-i18next';
import { Check, ChevronDown, ChevronRight, Copy } from 'lucide-react';
import { toast } from 'sonner';
import { cn } from '@/lib/utils';
import { copyToClipboard } from '@/app/utils/clipboard';
const LEVEL_STYLES: Record<string, string> = {
error: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400',
@@ -32,19 +31,36 @@ export function BotLogCard({
function copySessionId() {
const text = botLog.message_session_id;
copyToClipboard(text)
.then((ok) => {
if (ok) {
if (navigator.clipboard?.writeText) {
navigator.clipboard
.writeText(text)
.then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
toast.success(t('common.copySuccess'));
} else {
toast.error(t('common.copyFailed'));
}
})
.catch(() => {
toast.error(t('common.copyFailed'));
});
})
.catch(() => fallbackCopy(text));
} else {
fallbackCopy(text);
}
}
function fallbackCopy(text: string) {
const ta = document.createElement('textarea');
ta.value = text;
ta.style.cssText = 'position:fixed;left:-9999px;top:-9999px';
document.body.appendChild(ta);
ta.focus();
ta.select();
try {
document.execCommand('copy');
setCopied(true);
setTimeout(() => setCopied(false), 2000);
toast.success(t('common.copySuccess'));
} catch {
toast.error(t('common.copyFailed'));
}
document.body.removeChild(ta);
}
function formatTime(timestamp: number) {

View File

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

View File

@@ -1,5 +1,5 @@
import * as React from 'react';
import { useState, useEffect, useRef } from 'react';
import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { toast } from 'sonner';
import { Copy, Check, Trash2, Plus } from 'lucide-react';
@@ -86,9 +86,6 @@ export default function ApiIntegrationDialog({
const [newWebhookDescription, setNewWebhookDescription] = useState('');
const [newWebhookEnabled, setNewWebhookEnabled] = useState(true);
const [deleteWebhookId, setDeleteWebhookId] = useState<number | null>(null);
const copiedTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(
undefined,
);
const [copiedKey, setCopiedKey] = useState<string | null>(null);
// Sync URL with dialog state
@@ -185,29 +182,10 @@ export default function ApiIntegrationDialog({
}
};
const copyToClipboard = (text: string) => {
const el = document.createElement('span');
el.textContent = text;
el.style.cssText =
'position:fixed;opacity:0;pointer-events:none;white-space:pre;';
document.body.appendChild(el);
const range = document.createRange();
range.selectNodeContents(el);
const sel = window.getSelection();
sel?.removeAllRanges();
sel?.addRange(range);
document.execCommand('copy');
sel?.removeAllRanges();
document.body.removeChild(el);
};
const handleCopyKey = (key: string) => {
try {
copyToClipboard(key);
} catch {}
clearTimeout(copiedTimerRef.current);
navigator.clipboard.writeText(key);
setCopiedKey(key);
copiedTimerRef.current = setTimeout(() => setCopiedKey(null), 2000);
setTimeout(() => setCopiedKey(null), 2000);
};
const maskApiKey = (key: string) => {
@@ -352,21 +330,21 @@ export default function ApiIntegrationDialog({
</TableRow>
</TableHeader>
<TableBody>
{apiKeys.map((item) => (
<TableRow key={item.id}>
{apiKeys.map((key) => (
<TableRow key={key.id}>
<TableCell>
<div>
<div className="font-medium">{item.name}</div>
{item.description && (
<div className="font-medium">{key.name}</div>
{key.description && (
<div className="text-sm text-muted-foreground">
{item.description}
{key.description}
</div>
)}
</div>
</TableCell>
<TableCell>
<code className="text-sm bg-muted px-2 py-1 rounded">
{maskApiKey(item.key)}
{maskApiKey(key.key)}
</code>
</TableCell>
<TableCell>
@@ -374,11 +352,10 @@ export default function ApiIntegrationDialog({
<Button
variant="ghost"
size="sm"
type="button"
onClick={() => handleCopyKey(item.key)}
onClick={() => handleCopyKey(key.key)}
title={t('common.copyApiKey')}
>
{copiedKey === item.key ? (
{copiedKey === key.key ? (
<Check className="h-4 w-4 text-green-600" />
) : (
<Copy className="h-4 w-4" />
@@ -387,7 +364,7 @@ export default function ApiIntegrationDialog({
<Button
variant="ghost"
size="sm"
onClick={() => setDeleteKeyId(item.id)}
onClick={() => setDeleteKeyId(key.id)}
title={t('common.delete')}
>
<Trash2 className="h-4 w-4" />
@@ -653,6 +630,44 @@ export default function ApiIntegrationDialog({
</DialogContent>
</Dialog>
{/* Delete API Key Confirmation Dialog */}
<Dialog open={!!createdKey} onOpenChange={() => setCreatedKey(null)}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{t('common.apiKeyCreated')}</DialogTitle>
<DialogDescription>
{t('common.apiKeyCreatedMessage')}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div>
<label className="text-sm font-medium">
{t('common.apiKeyValue')}
</label>
<div className="flex gap-2 mt-1">
<Input value={createdKey?.key || ''} readOnly />
<Button
onClick={() => createdKey && handleCopyKey(createdKey.key)}
variant="outline"
size="icon"
>
{copiedKey === createdKey?.key ? (
<Check className="h-4 w-4 text-green-600" />
) : (
<Copy className="h-4 w-4" />
)}
</Button>
</div>
</div>
</div>
<DialogFooter>
<Button onClick={() => setCreatedKey(null)}>
{t('common.close')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Delete Confirmation Dialog */}
<AlertDialog open={!!deleteKeyId}>
<AlertDialogPortal>

View File

@@ -18,7 +18,6 @@ import { cn } from '@/lib/utils';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Copy, Check, Globe } from 'lucide-react';
import { copyToClipboard } from '@/app/utils/clipboard';
import { systemInfo } from '@/app/infra/http';
/**
@@ -45,54 +44,6 @@ function resolveShowIfValue(
return externalDependentValues?.[field];
}
/**
* Display-only component for embed code fields with copy animation.
*/
function EmbedCodeField({
label,
description,
snippet,
}: {
label: string;
description?: string;
snippet: string;
}) {
const [copied, setCopied] = useState(false);
const handleCopy = () => {
copyToClipboard(snippet).catch(() => {});
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
return (
<div className="space-y-2">
<label className="text-sm font-medium leading-none">{label}</label>
{description && (
<p className="text-sm text-muted-foreground">{description}</p>
)}
<div className="flex items-center gap-2">
<pre className="flex-1 overflow-x-auto rounded-md bg-muted p-3 text-sm font-mono select-all">
<code>{snippet}</code>
</pre>
<Button
type="button"
variant="outline"
size="icon"
className="shrink-0"
onClick={handleCopy}
>
{copied ? (
<Check className="h-4 w-4 text-green-600" />
) : (
<Copy className="size-4" />
)}
</Button>
</div>
</div>
);
}
/**
* Display-only component for webhook URL fields.
* Rendered outside of react-hook-form binding since the value is
@@ -114,9 +65,15 @@ function WebhookUrlField({
const { t } = useTranslation();
const handleCopy = (text: string, setter: (v: boolean) => void) => {
copyToClipboard(text).catch(() => {});
setter(true);
setTimeout(() => setter(false), 2000);
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard
.writeText(text)
.then(() => {
setter(true);
setTimeout(() => setter(false), 2000);
})
.catch(() => {});
}
};
return (
@@ -246,13 +203,10 @@ export default function DynamicFormComponent({
return value;
};
// Filter out display-only field types (e.g. webhook-url, embed-code) that should not
// Filter out display-only field types (e.g. webhook-url) that should not
// participate in form state, validation, or value emission.
const editableItems = useMemo(
() =>
itemConfigList.filter(
(item) => item.type !== 'webhook-url' && item.type !== 'embed-code',
),
() => itemConfigList.filter((item) => item.type !== 'webhook-url'),
[itemConfigList],
);
@@ -493,36 +447,6 @@ export default function DynamicFormComponent({
);
}
if (config.type === 'embed-code') {
const botUuid = (systemContext?.bot_uuid as string) || '';
if (!botUuid) return null;
const baseUrl =
import.meta.env.VITE_API_BASE_URL || window.location.origin;
const widgetTitle =
((systemContext?.adapter_config as Record<string, unknown>)
?.title as string) || 'LangBot';
const safeTitle = widgetTitle
.replace(/&/g, '&amp;')
.replace(/"/g, '&quot;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
const embedSnippet = `<script data-title="${safeTitle}" src="${baseUrl}/api/v1/embed/${botUuid}/widget.js"><\/script>`;
return (
<EmbedCodeField
key={config.id}
label={extractI18nObject(config.label)}
description={
config.description
? extractI18nObject(config.description)
: undefined
}
snippet={embedSnippet}
/>
);
}
// Boolean fields use a special inline layout
if (config.type === 'boolean') {
return (

View File

@@ -26,7 +26,6 @@ import {
Store,
Github,
Zap,
HardDrive,
} from 'lucide-react';
import { useTheme } from '@/components/providers/theme-provider';
@@ -56,7 +55,6 @@ import AccountSettingsDialog from '@/app/home/components/account-settings-dialog
import ApiIntegrationDialog from '@/app/home/components/api-integration-dialog/ApiIntegrationDialog';
import NewVersionDialog from '@/app/home/components/new-version-dialog/NewVersionDialog';
import ModelsDialog from '@/app/home/components/models-dialog/ModelsDialog';
import StorageAnalysisDialog from '@/app/home/components/storage-analysis-dialog/StorageAnalysisDialog';
import { GitHubRelease } from '@/app/infra/http/CloudServiceClient';
import { useAsyncTask, AsyncTaskStatus } from '@/hooks/useAsyncTask';
import { toast } from 'sonner';
@@ -701,103 +699,87 @@ function NavItems({
>
<SidebarMenuItem>
<SidebarMenuButton
asChild
isActive={false}
onClick={() => {
if (isCollapseOnly) {
onSectionToggle(config.id, !isOpen);
} else {
onChildClick(config);
}
}}
tooltip={config.name}
className="group/category-header"
>
<div
role="button"
tabIndex={0}
onClick={() => {
if (isCollapseOnly) {
onSectionToggle(config.id, !isOpen);
} else {
onChildClick(config);
}
}}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
if (isCollapseOnly) {
onSectionToggle(config.id, !isOpen);
} else {
onChildClick(config);
}
}
}}
>
{config.icon}
<span>{config.name}</span>
<div className="ml-auto flex items-center gap-0.5 -mr-1">
{canCreate &&
(isPlugin ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="p-1 rounded-sm text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground [@media(hover:hover)]:opacity-0 group-hover/category-header:opacity-100 transition-all"
onClick={(e) => e.stopPropagation()}
>
<Plus className="size-3.5" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{systemInfo.enable_marketplace && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
navigate('/home/market');
}}
>
<Store className="size-4" />
{t('plugins.goToMarketplace')}
</DropdownMenuItem>
)}
{config.icon}
<span>{config.name}</span>
<div className="ml-auto flex items-center gap-0.5 -mr-1">
{canCreate &&
(isPlugin ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className="p-1 rounded-sm text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground [@media(hover:hover)]:opacity-0 group-hover/category-header:opacity-100 transition-all"
onClick={(e) => e.stopPropagation()}
>
<Plus className="size-3.5" />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{systemInfo.enable_marketplace && (
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
setPendingPluginInstallAction('local');
navigate('/home/plugins');
navigate('/home/market');
}}
>
<Upload className="size-4" />
{t('plugins.uploadLocal')}
<Store className="size-4" />
{t('plugins.goToMarketplace')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
setPendingPluginInstallAction('github');
navigate('/home/plugins');
}}
>
<Github className="size-4" />
{t('plugins.installFromGithub')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : (
<button
type="button"
className="p-1 rounded-sm text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground [@media(hover:hover)]:opacity-0 group-hover/category-header:opacity-100 transition-all"
onClick={(e) => {
e.stopPropagation();
navigate(`${routePrefix}?id=new`);
}}
>
<Plus className="size-3.5" />
</button>
))}
<CollapsibleTrigger asChild>
)}
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
setPendingPluginInstallAction('local');
navigate('/home/plugins');
}}
>
<Upload className="size-4" />
{t('plugins.uploadLocal')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={(e) => {
e.stopPropagation();
setPendingPluginInstallAction('github');
navigate('/home/plugins');
}}
>
<Github className="size-4" />
{t('plugins.installFromGithub')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
) : (
<button
type="button"
className="p-1 rounded-sm hover:bg-sidebar-accent"
onClick={(e) => e.stopPropagation()}
className="p-1 rounded-sm text-sidebar-foreground/70 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground [@media(hover:hover)]:opacity-0 group-hover/category-header:opacity-100 transition-all"
onClick={(e) => {
e.stopPropagation();
navigate(`${routePrefix}?id=new`);
}}
>
<ChevronRight className="size-4 transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90" />
<Plus className="size-3.5" />
</button>
</CollapsibleTrigger>
</div>
))}
<CollapsibleTrigger asChild>
<button
type="button"
className="p-1 rounded-sm hover:bg-sidebar-accent"
onClick={(e) => e.stopPropagation()}
>
<ChevronRight className="size-4 transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90" />
</button>
</CollapsibleTrigger>
</div>
</SidebarMenuButton>
<CollapsibleContent>
@@ -1041,127 +1023,6 @@ function PluginItemMenu({
);
}
// Plugin pages navigation section — grouped by plugin
function PluginPagesNav() {
const { pluginPages } = useSidebarData();
const navigate = useNavigate();
const location = useLocation();
const [searchParams] = useSearchParams();
const { t } = useTranslation();
if (pluginPages.length === 0) return null;
const pathname = location.pathname;
const currentId =
pathname === '/home/plugin-pages' ? searchParams.get('id') : null;
// Group pages by plugin (author/name)
const grouped = new Map<
string,
{ label: string; iconURL: string; pages: typeof pluginPages }
>();
for (const page of pluginPages) {
const key = `${page.pluginAuthor}/${page.pluginName}`;
if (!grouped.has(key)) {
grouped.set(key, {
label: page.pluginLabel,
iconURL: page.pluginIconURL,
pages: [],
});
}
grouped.get(key)!.pages.push(page);
}
return (
<SidebarGroup>
<SidebarGroupLabel title={t('sidebar.pluginPagesTooltip')}>
{t('sidebar.pluginPages')}
</SidebarGroupLabel>
<SidebarGroupContent>
<SidebarMenu>
{Array.from(grouped.entries()).map(
([pluginKey, { label, iconURL, pages }]) => {
const hasActivePage = pages.some((p) => p.id === currentId);
const pluginIcon = (
<img
src={iconURL}
alt=""
className="size-4 rounded-sm object-cover shrink-0"
onError={(e) => {
(e.target as HTMLImageElement).style.display = 'none';
}}
/>
);
// Single page — render directly without nesting
if (pages.length === 1) {
const page = pages[0];
const isActive = currentId === page.id;
const route = `/home/plugin-pages?id=${encodeURIComponent(page.id)}`;
return (
<SidebarMenuItem key={page.id}>
<SidebarMenuButton
isActive={isActive}
tooltip={page.name}
onClick={() => navigate(route)}
className="select-none"
>
{pluginIcon}
<span>{page.name}</span>
</SidebarMenuButton>
</SidebarMenuItem>
);
}
// Multiple pages — collapsible group
return (
<Collapsible
key={pluginKey}
defaultOpen={hasActivePage}
className="group/collapsible"
>
<SidebarMenuItem>
<CollapsibleTrigger asChild>
<SidebarMenuButton
tooltip={label}
className="select-none"
>
{pluginIcon}
<span>{label}</span>
<ChevronRight className="ml-auto size-4 transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90" />
</SidebarMenuButton>
</CollapsibleTrigger>
<CollapsibleContent>
<SidebarMenuSub>
{pages.map((page) => {
const isActive = currentId === page.id;
const route = `/home/plugin-pages?id=${encodeURIComponent(page.id)}`;
return (
<SidebarMenuSubItem key={page.id}>
<SidebarMenuSubButton
isActive={isActive}
onClick={() => navigate(route)}
className="select-none"
>
<span>{page.name}</span>
</SidebarMenuSubButton>
</SidebarMenuSubItem>
);
})}
</SidebarMenuSub>
</CollapsibleContent>
</SidebarMenuItem>
</Collapsible>
);
},
)}
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
);
}
export default function HomeSidebar({
onSelectedChangeAction,
}: {
@@ -1187,9 +1048,6 @@ export default function HomeSidebar({
if (searchParams.get('action') === 'showApiIntegrationSettings') {
setApiKeyDialogOpen(true);
}
if (searchParams.get('action') === 'showStorageAnalysis') {
setStorageAnalysisOpen(true);
}
}, [searchParams]);
const [selectedChild, setSelectedChild] = useState<SidebarChildVO>();
@@ -1205,7 +1063,6 @@ export default function HomeSidebar({
const [hasNewVersion, setHasNewVersion] = useState(false);
const [versionDialogOpen, setVersionDialogOpen] = useState(false);
const [modelsDialogOpen, setModelsDialogOpen] = useState(false);
const [storageAnalysisOpen, setStorageAnalysisOpen] = useState(false);
const [userEmail, setUserEmail] = useState<string>('');
const [starCount, setStarCount] = useState<number | null>(null);
const [userMenuOpen, setUserMenuOpen] = useState(false);
@@ -1245,24 +1102,6 @@ export default function HomeSidebar({
}
}
function handleStorageAnalysisChange(open: boolean) {
setStorageAnalysisOpen(open);
if (open) {
const params = new URLSearchParams(searchParams.toString());
params.set('action', 'showStorageAnalysis');
navigate(`${pathname}?${params.toString()}`, {
preventScrollReset: true,
});
} else {
const params = new URLSearchParams(searchParams.toString());
params.delete('action');
const newUrl = params.toString()
? `${pathname}?${params.toString()}`
: pathname;
navigate(newUrl, { preventScrollReset: true });
}
}
useEffect(() => {
initSelect();
if (!localStorage.getItem('token')) {
@@ -1446,7 +1285,6 @@ export default function HomeSidebar({
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>
<PluginPagesNav />
</SidebarContent>
{/* Footer */}
@@ -1569,15 +1407,6 @@ export default function HomeSidebar({
<Settings />
{t('account.settings')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
setUserMenuOpen(false);
handleStorageAnalysisChange(true);
}}
>
<HardDrive />
{t('storageAnalysis.title')}
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => {
setUserMenuOpen(false);
@@ -1678,10 +1507,6 @@ export default function HomeSidebar({
open={modelsDialogOpen}
onOpenChange={handleModelsDialogChange}
/>
<StorageAnalysisDialog
open={storageAnalysisOpen}
onOpenChange={handleStorageAnalysisChange}
/>
</>
);
}

View File

@@ -31,18 +31,6 @@ export interface SidebarEntityItem {
// Install action types that can be triggered from sidebar
export type PluginInstallAction = 'local' | 'github' | null;
// Plugin page registered by a plugin
export interface PluginPageItem {
id: string; // "author/name/pageId"
name: string; // display label
pluginAuthor: string;
pluginName: string;
pluginLabel: string; // human-readable plugin display name
pluginIconURL: string; // plugin icon URL
pageId: string;
path: string; // asset path (HTML file)
}
// Entity lists and refresh functions exposed via context
export interface SidebarDataContextValue {
bots: SidebarEntityItem[];
@@ -50,7 +38,6 @@ export interface SidebarDataContextValue {
knowledgeBases: SidebarEntityItem[];
plugins: SidebarEntityItem[];
mcpServers: SidebarEntityItem[];
pluginPages: PluginPageItem[];
refreshBots: () => Promise<void>;
refreshPipelines: () => Promise<void>;
refreshKnowledgeBases: () => Promise<void>;
@@ -77,7 +64,6 @@ export function SidebarDataProvider({
const [knowledgeBases, setKnowledgeBases] = useState<SidebarEntityItem[]>([]);
const [plugins, setPlugins] = useState<SidebarEntityItem[]>([]);
const [mcpServers, setMCPServers] = useState<SidebarEntityItem[]>([]);
const [pluginPages, setPluginPages] = useState<PluginPageItem[]>([]);
const [detailEntityName, setDetailEntityName] = useState<string | null>(null);
const [pendingPluginInstallAction, setPendingPluginInstallAction] =
useState<PluginInstallAction>(null);
@@ -151,69 +137,33 @@ export function SidebarDataProvider({
}
}
// Deduplicate plugins by composite key (prefer debug over installed)
const pluginMap = new Map<string, SidebarEntityItem>();
for (const plugin of pluginsResp.plugins) {
const meta = plugin.manifest.manifest.metadata;
const author = meta.author ?? '';
const name = meta.name;
const compositeKey = `${author}/${name}`;
const installedVersion = meta.version ?? '';
setPlugins(
pluginsResp.plugins.map((plugin) => {
const meta = plugin.manifest.manifest.metadata;
const author = meta.author ?? '';
const name = meta.name;
const compositeKey = `${author}/${name}`;
const installedVersion = meta.version ?? '';
let hasUpdate = false;
if (plugin.install_source === 'marketplace') {
const latestVersion = marketplaceVersions.get(compositeKey);
if (latestVersion) {
hasUpdate = isNewerVersion(latestVersion, installedVersion);
}
}
const item: SidebarEntityItem = {
id: compositeKey,
name: extractI18nObject(meta.label),
iconURL: httpClient.getPluginIconURL(author, name),
installSource: plugin.install_source,
installInfo: plugin.install_info,
hasUpdate,
debug: plugin.debug,
};
// If duplicate, prefer debug version
if (!pluginMap.has(compositeKey) || plugin.debug) {
pluginMap.set(compositeKey, item);
}
}
setPlugins(Array.from(pluginMap.values()));
// Extract plugin pages from spec.pages (deduplicate by id)
const pages: PluginPageItem[] = [];
const seenPageIds = new Set<string>();
for (const plugin of pluginsResp.plugins) {
const meta = plugin.manifest.manifest.metadata;
const author = meta.author ?? '';
const name = meta.name;
const label = meta.label ? extractI18nObject(meta.label) : name;
const spec = plugin.manifest.manifest.spec;
if (spec?.pages && Array.isArray(spec.pages)) {
for (const page of spec.pages) {
const pageId = `${author}/${name}/${page.id}`;
if (page.id && page.path && !seenPageIds.has(pageId)) {
seenPageIds.add(pageId);
pages.push({
id: pageId,
name: page.label ? extractI18nObject(page.label) : page.id,
pluginAuthor: author,
pluginName: name,
pluginLabel: label,
pluginIconURL: httpClient.getPluginIconURL(author, name),
pageId: page.id,
path: page.path,
});
let hasUpdate = false;
if (plugin.install_source === 'marketplace') {
const latestVersion = marketplaceVersions.get(compositeKey);
if (latestVersion) {
hasUpdate = isNewerVersion(latestVersion, installedVersion);
}
}
}
}
setPluginPages(pages);
return {
id: compositeKey,
name: extractI18nObject(meta.label),
iconURL: httpClient.getPluginIconURL(author, name),
installSource: plugin.install_source,
installInfo: plugin.install_info,
hasUpdate,
debug: plugin.debug,
};
}),
);
} catch (error) {
console.error('Failed to fetch plugins for sidebar:', error);
}
@@ -264,7 +214,6 @@ export function SidebarDataProvider({
knowledgeBases,
plugins,
mcpServers,
pluginPages,
refreshBots,
refreshPipelines,
refreshKnowledgeBases,

View File

@@ -29,36 +29,15 @@ interface ModelsDialogProps {
onOpenChange: (open: boolean) => void;
}
type ExtraArgValue = string | number | boolean | Record<string, unknown>;
function convertExtraArgsToObject(
args: ExtraArg[],
): Record<string, ExtraArgValue> {
const obj: Record<string, ExtraArgValue> = {};
): Record<string, string | number | boolean> {
const obj: Record<string, string | number | boolean> = {};
args.forEach((arg) => {
if (!arg.key.trim()) return;
if (arg.type === 'number') {
obj[arg.key] = Number(arg.value);
} else if (arg.type === 'boolean') {
obj[arg.key] = arg.value === 'true';
} else if (arg.type === 'object') {
const raw = arg.value.trim() || '{}';
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
throw new Error(`Invalid JSON for extra parameter "${arg.key}"`);
}
if (
parsed === null ||
typeof parsed !== 'object' ||
Array.isArray(parsed)
) {
throw new Error(`Extra parameter "${arg.key}" must be a JSON object`);
}
obj[arg.key] = parsed as Record<string, unknown>;
} else {
obj[arg.key] = arg.value;
if (arg.key.trim()) {
if (arg.type === 'number') obj[arg.key] = Number(arg.value);
else if (arg.type === 'boolean') obj[arg.key] = arg.value === 'true';
else obj[arg.key] = arg.value;
}
});
return obj;

View File

@@ -258,16 +258,11 @@ export default function AddModelPopover({
</Button>
</PopoverTrigger>
<PopoverContent
className="w-[min(24rem,calc(100vw-2rem))] max-h-[70vh] overflow-y-auto overscroll-none focus:outline-none focus-visible:outline-none focus-visible:ring-0"
style={{
maxHeight: 'min(70vh, var(--radix-popover-content-available-height))',
}}
className="w-[min(24rem,calc(100vw-2rem))] max-h-[calc(100vh-8rem)] overflow-y-auto"
align="end"
side="left"
sideOffset={8}
collisionPadding={16}
onWheel={(e) => e.stopPropagation()}
onTouchMove={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
>
<Tabs value={tab} onValueChange={(v) => setTab(v as ModelType)}>
@@ -442,7 +437,7 @@ export default function AddModelPopover({
</div>
<div
className="h-64 overflow-y-auto overscroll-none rounded-md border"
className="h-64 overflow-y-auto overscroll-contain rounded-md border"
onWheel={(e) => e.stopPropagation()}
>
<div className="p-3 space-y-2">

View File

@@ -1,7 +1,6 @@
import { Plus, X, HelpCircle } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label';
import {
Select,
@@ -48,30 +47,9 @@ export default function ExtraArgsEditor({
) => {
const newArgs = [...args];
newArgs[index] = { ...newArgs[index], [field]: value };
// When switching to object type, seed an empty JSON object so the textarea
// doesn't start with an unparseable empty string.
if (
field === 'type' &&
value === 'object' &&
!newArgs[index].value.trim()
) {
newArgs[index].value = '{}';
}
onChange(newArgs);
};
const isInvalidJson = (raw: string) => {
if (!raw.trim()) return false;
try {
const parsed = JSON.parse(raw);
return (
parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)
);
} catch {
return true;
}
};
return (
<div className="space-y-2">
<div className="flex items-center justify-between">
@@ -112,79 +90,49 @@ export default function ExtraArgsEditor({
{args.length === 0 ? (
<p className="text-sm text-muted-foreground">{t('common.none')}</p>
) : (
args.map((arg, index) => {
const isObject = arg.type === 'object';
const jsonError = isObject && isInvalidJson(arg.value);
return (
<div key={index} className="space-y-1">
<div className="flex gap-2 items-start">
<Input
placeholder={t('models.keyName')}
value={arg.key}
className={isObject ? 'flex-[2]' : 'flex-1'}
disabled={disabled}
onChange={(e) => handleUpdate(index, 'key', e.target.value)}
/>
<Select
value={arg.type}
disabled={disabled}
onValueChange={(value) => handleUpdate(index, 'type', value)}
>
<SelectTrigger className="w-24">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="string">{t('models.string')}</SelectItem>
<SelectItem value="number">{t('models.number')}</SelectItem>
<SelectItem value="boolean">
{t('models.boolean')}
</SelectItem>
<SelectItem value="object">{t('models.object')}</SelectItem>
</SelectContent>
</Select>
{!isObject && (
<Input
placeholder={t('models.value')}
value={arg.value}
className="flex-1"
disabled={disabled}
onChange={(e) =>
handleUpdate(index, 'value', e.target.value)
}
/>
)}
{!disabled && (
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 flex-shrink-0"
onClick={() => handleRemove(index)}
>
<X className="h-4 w-4" />
</Button>
)}
</div>
{isObject && (
<Textarea
placeholder={t('models.objectJsonPlaceholder')}
value={arg.value}
className={`w-full font-mono text-xs min-h-[96px] resize-y ${
jsonError ? 'border-destructive' : ''
}`}
disabled={disabled}
spellCheck={false}
onChange={(e) => handleUpdate(index, 'value', e.target.value)}
/>
)}
{jsonError && (
<p className="text-xs text-destructive pl-1">
{t('models.invalidJsonObject')}
</p>
)}
</div>
);
})
args.map((arg, index) => (
<div key={index} className="flex gap-2 items-center">
<Input
placeholder={t('models.keyName')}
value={arg.key}
className="flex-1"
disabled={disabled}
onChange={(e) => handleUpdate(index, 'key', e.target.value)}
/>
<Select
value={arg.type}
disabled={disabled}
onValueChange={(value) => handleUpdate(index, 'type', value)}
>
<SelectTrigger className="w-24">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="string">{t('models.string')}</SelectItem>
<SelectItem value="number">{t('models.number')}</SelectItem>
<SelectItem value="boolean">{t('models.boolean')}</SelectItem>
</SelectContent>
</Select>
<Input
placeholder={t('models.value')}
value={arg.value}
className="flex-1"
disabled={disabled}
onChange={(e) => handleUpdate(index, 'value', e.target.value)}
/>
{!disabled && (
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 flex-shrink-0"
onClick={() => handleRemove(index)}
>
<X className="h-4 w-4" />
</Button>
)}
</div>
))
)}
</div>
);

View File

@@ -46,25 +46,10 @@ interface ModelItemProps {
function convertExtraArgsToArray(extraArgs?: object): ExtraArg[] {
if (!extraArgs) return [];
return Object.entries(extraArgs).map(([key, value]) => {
let type: ExtraArg['type'] = 'string';
let stringValue: string;
if (typeof value === 'number') {
type = 'number';
stringValue = String(value);
} else if (typeof value === 'boolean') {
type = 'boolean';
stringValue = String(value);
} else if (
value !== null &&
typeof value === 'object' &&
!Array.isArray(value)
) {
type = 'object';
stringValue = JSON.stringify(value, null, 2);
} else {
stringValue = String(value);
}
return { key, type, value: stringValue };
let type: 'string' | 'number' | 'boolean' = 'string';
if (typeof value === 'number') type = 'number';
else if (typeof value === 'boolean') type = 'boolean';
return { key, type, value: String(value) };
});
}
@@ -224,16 +209,7 @@ export default function ModelItem({
)}
</div>
</PopoverTrigger>
<PopoverContent
className="w-80 max-h-[70vh] overflow-y-auto overscroll-none focus:outline-none focus-visible:outline-none focus-visible:ring-0"
align="start"
collisionPadding={16}
style={{
maxHeight: 'min(70vh, var(--radix-popover-content-available-height))',
}}
onWheel={(e) => e.stopPropagation()}
onTouchMove={(e) => e.stopPropagation()}
>
<PopoverContent className="w-80" align="start">
<div className="space-y-3">
<div className="space-y-2">
<Label>{t('models.modelName')}</Label>

View File

@@ -9,8 +9,7 @@ import {
export type ExtraArg = {
key: string;
type: 'string' | 'number' | 'boolean' | 'object';
// For 'object' type, value holds a JSON string that will be parsed on save.
type: 'string' | 'number' | 'boolean';
value: string;
};

View File

@@ -1,410 +0,0 @@
'use client';
import {
type ReactNode,
useCallback,
useEffect,
useMemo,
useState,
} from 'react';
import { useTranslation } from 'react-i18next';
import {
AlertCircle,
Archive,
Clock,
Database,
FileWarning,
HardDrive,
RefreshCw,
} from 'lucide-react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { ScrollArea } from '@/components/ui/scroll-area';
import { backendClient } from '@/app/infra/http';
interface StorageSection {
key: string;
path: string;
exists: boolean;
size_bytes: number;
file_count: number;
}
interface CleanupCandidate {
key?: string;
name?: string;
size_bytes: number;
modified_at?: string;
date?: string;
}
interface StorageAnalysis {
generated_at: string;
cleanup_policy: {
uploaded_file_retention_days: number;
log_retention_days: number;
};
sections: StorageSection[];
database: {
type: string;
monitoring_counts: Record<string, number>;
binary_storage: {
count: number;
size_bytes: number | null;
};
};
cleanup_candidates: {
uploaded_files: CleanupCandidate[];
log_files: CleanupCandidate[];
};
tasks: Record<string, number | undefined>;
}
interface StorageAnalysisDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
function formatBytes(bytes: number | null | undefined): string {
if (bytes === null || bytes === undefined) {
return '-';
}
if (bytes < 1024) {
return `${bytes} B`;
}
const units = ['KB', 'MB', 'GB', 'TB'];
let value = bytes / 1024;
let unitIndex = 0;
while (value >= 1024 && unitIndex < units.length - 1) {
value /= 1024;
unitIndex += 1;
}
return `${value.toFixed(value >= 10 ? 1 : 2)} ${units[unitIndex]}`;
}
export default function StorageAnalysisDialog({
open,
onOpenChange,
}: StorageAnalysisDialogProps) {
const { t } = useTranslation();
const [analysis, setAnalysis] = useState<StorageAnalysis | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const loadAnalysis = useCallback(async () => {
setLoading(true);
setError(null);
try {
const result = await backendClient.get<StorageAnalysis>(
'/api/v1/system/storage-analysis',
);
setAnalysis(result);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
if (open) {
loadAnalysis();
}
}, [loadAnalysis, open]);
const totalBytes = useMemo(() => {
return (
analysis?.sections.reduce((sum, item) => sum + item.size_bytes, 0) ?? 0
);
}, [analysis]);
const uploadedCandidateBytes = useMemo(() => {
return (
analysis?.cleanup_candidates.uploaded_files.reduce(
(sum, item) => sum + item.size_bytes,
0,
) ?? 0
);
}, [analysis]);
const logCandidateBytes = useMemo(() => {
return (
analysis?.cleanup_candidates.log_files.reduce(
(sum, item) => sum + item.size_bytes,
0,
) ?? 0
);
}, [analysis]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="!flex h-[86vh] max-h-[86vh] max-w-5xl flex-col gap-0 p-0">
<DialogHeader className="shrink-0 px-6 pt-6">
<DialogTitle className="flex items-center gap-2">
<HardDrive className="size-5 text-blue-500" />
{t('storageAnalysis.dialogTitle')}
</DialogTitle>
<DialogDescription>
{t('storageAnalysis.description')}
</DialogDescription>
</DialogHeader>
<div className="flex shrink-0 items-center justify-between gap-3 border-b px-6 pb-4">
<div className="text-sm text-muted-foreground">
{analysis
? t('storageAnalysis.generatedAt', {
time: new Date(analysis.generated_at).toLocaleString(),
})
: t('storageAnalysis.loading')}
</div>
<Button
onClick={loadAnalysis}
variant="outline"
size="sm"
disabled={loading}
>
<RefreshCw
className={`mr-2 size-4 ${loading ? 'animate-spin' : ''}`}
/>
{t('storageAnalysis.refresh')}
</Button>
</div>
<ScrollArea className="min-h-0 flex-1 overflow-hidden">
<div className="space-y-5 px-6 py-5">
{error && (
<div className="flex items-start gap-2 rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
<AlertCircle className="mt-0.5 size-4 shrink-0" />
<span>{error}</span>
</div>
)}
{analysis && (
<>
<div className="grid grid-cols-1 gap-3 md:grid-cols-4">
<SummaryItem
label={t('storageAnalysis.totalSize')}
value={formatBytes(totalBytes)}
icon={<HardDrive className="size-4" />}
/>
<SummaryItem
label={t('storageAnalysis.binaryStorage')}
value={formatBytes(
analysis.database.binary_storage.size_bytes,
)}
meta={`${analysis.database.binary_storage.count}`}
icon={<Database className="size-4" />}
/>
<SummaryItem
label={t('storageAnalysis.uploadCleanup')}
value={formatBytes(uploadedCandidateBytes)}
meta={`${analysis.cleanup_candidates.uploaded_files.length}`}
icon={<FileWarning className="size-4" />}
/>
<SummaryItem
label={t('storageAnalysis.logCleanup')}
value={formatBytes(logCandidateBytes)}
meta={`${analysis.cleanup_candidates.log_files.length}`}
icon={<FileWarning className="size-4" />}
/>
</div>
<section className="rounded-md border px-3 py-3">
<h2 className="mb-3 flex items-center gap-2 text-sm font-medium">
<Clock className="size-4 text-muted-foreground" />
{t('storageAnalysis.cleanupPolicy')}
</h2>
<div className="grid grid-cols-1 gap-2 text-sm md:grid-cols-3">
<PolicyItem
label={t('storageAnalysis.uploadRetention')}
value={`${analysis.cleanup_policy.uploaded_file_retention_days} ${t('storageAnalysis.days')}`}
/>
<PolicyItem
label={t('storageAnalysis.logRetention')}
value={`${analysis.cleanup_policy.log_retention_days} ${t('storageAnalysis.days')}`}
/>
<PolicyItem
label={t('storageAnalysis.databaseType')}
value={analysis.database.type}
/>
</div>
</section>
<section>
<h2 className="mb-2 text-sm font-medium">
{t('storageAnalysis.sections')}
</h2>
<div className="overflow-hidden rounded-md border">
{analysis.sections.map((section) => (
<div
key={section.key}
className="grid grid-cols-[1fr_auto_auto_auto] gap-3 border-b px-3 py-2 text-sm last:border-b-0"
>
<div className="min-w-0">
<div className="font-medium">
{t(`storageAnalysis.sectionNames.${section.key}`)}
</div>
<div className="break-all text-xs text-muted-foreground">
{section.path || '-'}
</div>
</div>
{section.exists ? (
<span />
) : (
<Badge variant="outline" className="self-center">
{t('storageAnalysis.missing')}
</Badge>
)}
<div className="self-center tabular-nums">
{formatBytes(section.size_bytes)}
</div>
<div className="self-center text-muted-foreground tabular-nums">
{section.file_count}
</div>
</div>
))}
</div>
</section>
<section className="grid grid-cols-1 gap-4 md:grid-cols-2">
<MetricPanel
title={t('storageAnalysis.monitoringTables')}
values={analysis.database.monitoring_counts}
/>
<MetricPanel
title={t('storageAnalysis.runtimeTasks')}
values={analysis.tasks}
/>
</section>
<section className="grid grid-cols-1 gap-4 md:grid-cols-2">
<CandidatePanel
title={t('storageAnalysis.expiredUploads')}
emptyText={t('storageAnalysis.noExpiredUploads')}
candidates={analysis.cleanup_candidates.uploaded_files}
/>
<CandidatePanel
title={t('storageAnalysis.expiredLogs')}
emptyText={t('storageAnalysis.noExpiredLogs')}
candidates={analysis.cleanup_candidates.log_files}
/>
</section>
</>
)}
</div>
</ScrollArea>
</DialogContent>
</Dialog>
);
}
function SummaryItem({
label,
value,
icon,
meta,
}: {
label: string;
value: string;
icon: ReactNode;
meta?: string;
}) {
return (
<div className="rounded-md border px-3 py-3">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
{icon}
{label}
</div>
<div className="mt-2 flex items-end justify-between gap-2">
<span className="text-xl font-semibold tabular-nums">{value}</span>
{meta && <span className="text-xs text-muted-foreground">{meta}</span>}
</div>
</div>
);
}
function PolicyItem({ label, value }: { label: string; value: string }) {
return (
<div className="rounded-md bg-muted/40 px-3 py-2">
<div className="text-xs text-muted-foreground">{label}</div>
<div className="mt-1 font-medium">{value}</div>
</div>
);
}
function MetricPanel({
title,
values,
}: {
title: string;
values: Record<string, number | undefined>;
}) {
return (
<div>
<h2 className="mb-2 text-sm font-medium">{title}</h2>
<div className="rounded-md border">
{Object.entries(values).map(([key, value]) => (
<div
key={key}
className="flex items-center justify-between border-b px-3 py-2 text-sm last:border-b-0"
>
<span className="text-muted-foreground">{key}</span>
<span className="font-medium tabular-nums">{value ?? '-'}</span>
</div>
))}
</div>
</div>
);
}
function CandidatePanel({
title,
emptyText,
candidates,
}: {
title: string;
emptyText: string;
candidates: CleanupCandidate[];
}) {
return (
<div>
<h2 className="mb-2 flex items-center gap-2 text-sm font-medium">
<Archive className="size-4 text-muted-foreground" />
{title}
</h2>
<div className="rounded-md border">
{candidates.length === 0 ? (
<div className="px-3 py-6 text-center text-sm text-muted-foreground">
{emptyText}
</div>
) : (
candidates.slice(0, 8).map((candidate, index) => (
<div
key={`${candidate.key ?? candidate.name}-${index}`}
className="grid grid-cols-[1fr_auto] gap-3 border-b px-3 py-2 text-sm last:border-b-0"
>
<div className="min-w-0">
<div className="truncate font-medium">
{candidate.key ?? candidate.name}
</div>
<div className="text-xs text-muted-foreground">
{candidate.modified_at ?? candidate.date ?? '-'}
</div>
</div>
<div className="self-center tabular-nums">
{formatBytes(candidate.size_bytes)}
</div>
</div>
))
)}
</div>
</div>
);
}

View File

@@ -146,12 +146,7 @@ export default function KBDetailContent({ id }: { id: string }) {
<h1 className="text-xl font-semibold">
{t('knowledge.editKnowledgeBase')}
</h1>
<Button
type="submit"
form="kb-form"
disabled={!formDirty}
className={activeTab !== 'metadata' ? 'invisible' : ''}
>
<Button type="submit" form="kb-form" disabled={!formDirty}>
{t('common.save')}
</Button>
</div>

View File

@@ -44,12 +44,7 @@ import {
} from '@/app/home/plugins/components/plugin-install-task';
// Routes that belong to the "Extensions" section
const EXTENSIONS_ROUTES = [
'/home/plugins',
'/home/market',
'/home/mcp',
'/home/plugin-pages',
];
const EXTENSIONS_ROUTES = ['/home/plugins', '/home/market', '/home/mcp'];
function isExtensionsRoute(pathname: string): boolean {
return EXTENSIONS_ROUTES.some(

View File

@@ -80,12 +80,7 @@ export default function PipelineDetailContent({ id }: { id: string }) {
{/* Sticky Header: title + save button */}
<div className="flex items-center justify-between pb-4 shrink-0">
<h1 className="text-xl font-semibold">{t('pipelines.editPipeline')}</h1>
<Button
type="submit"
form="pipeline-form"
disabled={!formDirty}
className={activeTab !== 'config' ? 'invisible' : ''}
>
<Button type="submit" form="pipeline-form" disabled={!formDirty}>
{t('common.save')}
</Button>
</div>

View File

@@ -1,192 +0,0 @@
import { useSearchParams } from 'react-router-dom';
import { httpClient } from '@/app/infra/http/HttpClient';
import { useEffect, useRef, useState, useCallback } from 'react';
import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext';
import { useTranslation } from 'react-i18next';
import { useTheme } from '@/components/providers/theme-provider';
/**
* Plugin page that renders a plugin-provided HTML page in an iframe.
* URL format: /home/plugin-pages?id=author/name/pageId
*
* The iframe communicates with the parent via postMessage:
*
* Parent → iframe:
* { type: 'langbot:context', theme: 'light'|'dark', language: 'zh-Hans'|'en-US' }
*
* iframe → Parent:
* { type: 'langbot:api', requestId: string, endpoint: string, method: string, body?: any }
*
* Parent → iframe (response):
* { type: 'langbot:api:response', requestId: string, data?: any, error?: string }
*/
export default function PluginPagesPage() {
const [searchParams] = useSearchParams();
const id = searchParams.get('id');
const { t } = useTranslation();
const { setDetailEntityName, pluginPages } = useSidebarData();
// Find the matching page for breadcrumb
const page = pluginPages.find((p) => p.id === id);
useEffect(() => {
setDetailEntityName(page?.name ?? id ?? '');
return () => setDetailEntityName(null);
}, [page, id, setDetailEntityName]);
if (!id) {
return (
<div className="flex items-center justify-center h-full text-muted-foreground">
{t('pluginPages.selectFromSidebar')}
</div>
);
}
// Parse "author/name/pageId"
const parts = id.split('/');
if (parts.length < 3) {
return (
<div className="flex items-center justify-center h-full text-muted-foreground">
{t('pluginPages.invalidPage')}
</div>
);
}
const author = parts[0];
const pluginName = parts[1];
// Use the asset path from the page manifest, not the page ID
const assetPath = page?.path ?? parts.slice(2).join('/');
const pageId = parts.slice(2).join('/');
return (
<PluginPageIframe
author={author}
pluginName={pluginName}
pagePath={assetPath}
pageId={pageId}
/>
);
}
function PluginPageIframe({
author,
pluginName,
pagePath,
pageId,
}: {
author: string;
pluginName: string;
pagePath: string;
pageId: string;
}) {
const iframeRef = useRef<HTMLIFrameElement>(null);
const [loading, setLoading] = useState(true);
const { resolvedTheme } = useTheme();
const { i18n } = useTranslation();
const assetUrl = httpClient.getPluginAssetURL(author, pluginName, pagePath);
// Send context (theme + language) to iframe
// Use '*' as targetOrigin because sandboxed iframe has opaque (null) origin
const sendContext = useCallback(() => {
const iframe = iframeRef.current;
if (iframe?.contentWindow) {
iframe.contentWindow.postMessage(
{
type: 'langbot:context',
theme: resolvedTheme,
language: i18n.language,
},
'*',
);
}
}, [resolvedTheme, i18n.language]);
// Re-send context when theme or language changes
useEffect(() => {
if (!loading) {
sendContext();
}
}, [resolvedTheme, i18n.language, loading, sendContext]);
// Handle messages from iframe (API calls)
useEffect(() => {
const handleMessage = async (event: MessageEvent) => {
// Validate source — only accept messages from our specific iframe window
// This is more secure than origin checking: works with sandboxed (null-origin) iframes
// and prevents spoofing from other windows/iframes
if (event.source !== iframeRef.current?.contentWindow) return;
const data = event.data;
if (!data || typeof data !== 'object') return;
// Validate requestId format to prevent injection
if (data.type === 'langbot:api') {
const { requestId, endpoint, method, body } = data;
if (typeof requestId !== 'string' || typeof endpoint !== 'string')
return;
// Sanitize endpoint — must start with / and not contain ..
if (!endpoint.startsWith('/') || endpoint.includes('..')) return;
const normalizedMethod =
typeof method === 'string' ? method.toUpperCase() : 'POST';
if (
!['GET', 'POST', 'PUT', 'PATCH', 'DELETE'].includes(normalizedMethod)
)
return;
try {
const result = await httpClient.pluginPageApi(
author,
pluginName,
pageId,
endpoint,
normalizedMethod,
body,
);
iframeRef.current?.contentWindow?.postMessage(
{
type: 'langbot:api:response',
requestId,
data: result,
},
'*',
);
} catch (err: unknown) {
const errorMsg = err instanceof Error ? err.message : String(err);
iframeRef.current?.contentWindow?.postMessage(
{
type: 'langbot:api:response',
requestId,
error: errorMsg,
},
'*',
);
}
}
};
window.addEventListener('message', handleMessage);
return () => window.removeEventListener('message', handleMessage);
}, [author, pluginName, pageId]);
return (
<div className="flex flex-col h-full w-full">
{loading && (
<div className="flex items-center justify-center h-full text-muted-foreground">
Loading...
</div>
)}
<iframe
ref={iframeRef}
src={assetUrl}
className="flex-1 w-full border-0 rounded-md"
style={{ display: loading ? 'none' : 'block' }}
onLoad={() => {
setLoading(false);
sendContext();
}}
sandbox="allow-scripts allow-forms"
title={`${author}/${pluginName} - ${pagePath}`}
/>
</div>
);
}

View File

@@ -85,7 +85,7 @@ function StageRow({
return (
<div
className={cn(
'flex items-center gap-2 sm:gap-3 px-2 sm:px-3 py-2 sm:py-2.5 rounded-lg transition-all duration-300',
'flex items-center gap-3 px-3 py-2.5 rounded-lg transition-all duration-300',
isActive &&
!isError &&
'bg-blue-50 dark:bg-blue-950/30 border border-blue-200 dark:border-blue-800',
@@ -154,7 +154,7 @@ function StageRow({
{detail && (
<div
className={cn(
'text-xs mt-0.5 break-words',
'text-xs mt-0.5',
isCompleted
? 'text-green-600/70 dark:text-green-400/70'
: 'text-blue-600/70 dark:text-blue-400/70',
@@ -256,7 +256,7 @@ function TaskProgressContent({ task }: { task: PluginInstallTask }) {
<>
<span>{parts.join(' · ')}</span>
<br />
<span className="opacity-70 break-words">{currentDep}</span>
<span className="opacity-70">{currentDep}</span>
</>
);
}
@@ -277,10 +277,10 @@ function TaskProgressContent({ task }: { task: PluginInstallTask }) {
<div className="space-y-4">
{/* Overall progress bar — always blue */}
<div className="space-y-2">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center justify-between">
<span
className={cn(
'text-sm font-medium shrink-0',
'text-sm font-medium',
isDone
? 'text-green-700 dark:text-green-300'
: 'text-blue-700 dark:text-blue-300',
@@ -360,8 +360,8 @@ function TaskProgressContent({ task }: { task: PluginInstallTask }) {
{/* Done banner */}
{isDone && (
<div className="flex items-center gap-2 px-3 py-2.5 rounded-lg bg-green-50 dark:bg-green-950/30 border border-green-200 dark:border-green-900">
<CheckCircle2 className="w-5 h-5 shrink-0 text-green-600 dark:text-green-400" />
<span className="text-sm text-green-700 dark:text-green-300 font-medium break-words">
<CheckCircle2 className="w-5 h-5 text-green-600 dark:text-green-400" />
<span className="text-sm text-green-700 dark:text-green-300 font-medium">
{t('plugins.installProgress.installComplete')}
</span>
</div>
@@ -406,13 +406,13 @@ export default function PluginInstallProgressDialog() {
return (
<Dialog open={open} onOpenChange={(o) => !o && handleClose()}>
<DialogContent
className="sm:max-w-lg w-[90vw] max-h-[80vh] p-4 sm:p-6 bg-white dark:bg-[#1a1a1e] overflow-y-auto overflow-x-hidden"
className="w-[460px] max-h-[80vh] p-6 bg-white dark:bg-[#1a1a1e] overflow-y-auto"
hideCloseButton
>
<DialogHeader>
<DialogTitle className="flex items-start gap-3">
<Download className="size-5 shrink-0 mt-0.5" />
<span className="break-words">
<DialogTitle className="flex items-center gap-3">
<Download className="size-5" />
<span className="truncate">
{selectedTask
? t('plugins.installProgress.title', {
name: selectedTask.pluginName,

View File

@@ -14,6 +14,7 @@ export interface IPluginCardVO {
components: PluginComponent[];
debug: boolean;
hasUpdate?: boolean;
type?: 'plugin' | 'mcp' | 'skill';
}
export class PluginCardVO implements IPluginCardVO {
@@ -30,6 +31,7 @@ export class PluginCardVO implements IPluginCardVO {
status: string;
components: PluginComponent[];
hasUpdate?: boolean;
type?: 'plugin' | 'mcp' | 'skill';
constructor(prop: IPluginCardVO) {
this.author = prop.author;
@@ -45,5 +47,6 @@ export class PluginCardVO implements IPluginCardVO {
this.install_source = prop.install_source;
this.install_info = prop.install_info;
this.hasUpdate = prop.hasUpdate;
this.type = prop.type;
}
}

View File

@@ -1,12 +1,5 @@
import { TFunction } from 'i18next';
import {
Wrench,
AudioWaveform,
Hash,
Book,
FileText,
PanelTop,
} from 'lucide-react';
import { Wrench, AudioWaveform, Hash, Book, FileText } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
export default function PluginComponentList({
@@ -30,7 +23,6 @@ export default function PluginComponentList({
Command: <Hash className="w-5 h-5" />,
KnowledgeEngine: <Book className="w-5 h-5" />,
Parser: <FileText className="w-5 h-5" />,
Page: <PanelTop className="w-5 h-5" />,
};
const componentKindList = Object.keys(components || {});

View File

@@ -88,6 +88,8 @@ const PluginInstalledComponent = forwardRef<PluginInstalledComponentRef>(
// 转换并比较版本号
const pluginCards = installedPlugins.map((plugin) => {
const marketplaceKey = `${plugin.manifest.manifest.metadata.author}/${plugin.manifest.manifest.metadata.name}`;
const marketplacePlugin = marketplacePluginMap.get(marketplaceKey);
const cardVO = new PluginCardVO({
author: plugin.manifest.manifest.metadata.author ?? '',
label: extractI18nObject(plugin.manifest.manifest.metadata.label),
@@ -106,13 +108,12 @@ const PluginInstalledComponent = forwardRef<PluginInstalledComponentRef>(
priority: plugin.priority,
install_source: plugin.install_source,
install_info: plugin.install_info,
type: marketplacePlugin?.type,
});
// 检查是否来自市场且有更新
if (cardVO.install_source === 'marketplace') {
const marketplaceKey = `${cardVO.author}/${cardVO.name}`;
const marketplacePlugin = marketplacePluginMap.get(marketplaceKey);
if (marketplacePlugin && marketplacePlugin.latest_version) {
if (cardVO.install_source === 'marketplace' && marketplacePlugin) {
if (marketplacePlugin.latest_version) {
cardVO.hasUpdate = isNewerVersion(
marketplacePlugin.latest_version,
cardVO.version,

View File

@@ -60,6 +60,24 @@ export default function PluginCardComponent({
>
v{cardVO.version}
</Badge>
{cardVO.type && (
<Badge
variant="outline"
className={`text-[0.7rem] flex-shrink-0 ${
cardVO.type === 'mcp'
? 'border-sky-500 text-sky-600 dark:border-sky-400 dark:text-sky-300'
: cardVO.type === 'skill'
? 'border-emerald-500 text-emerald-600 dark:border-emerald-400 dark:text-emerald-300'
: 'border-violet-500 text-violet-600 dark:border-violet-400 dark:text-violet-300'
}`}
>
{cardVO.type === 'mcp'
? 'MCP'
: cardVO.type === 'skill'
? t('common.skill')
: t('market.typePlugin')}
</Badge>
)}
{cardVO.debug && (
<Badge
variant="outline"

View File

@@ -0,0 +1,77 @@
import { Fragment } from 'react';
import { TFunction } from 'i18next';
import { Wrench, AudioWaveform, Hash, Book, FileText } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
export default function PluginComponentList({
components,
showComponentName,
showTitle,
useBadge,
t,
responsive = false,
}: {
components: Record<string, number>;
showComponentName: boolean;
showTitle: boolean;
useBadge: boolean;
t: TFunction;
responsive?: boolean;
}) {
const kindIconMap: Record<string, React.ReactNode> = {
Tool: <Wrench className="w-5 h-5" />,
EventListener: <AudioWaveform className="w-5 h-5" />,
Command: <Hash className="w-5 h-5" />,
KnowledgeEngine: <Book className="w-5 h-5" />,
Parser: <FileText className="w-5 h-5" />,
};
const componentKindList = Object.keys(components || {});
return (
<>
{showTitle && <div>{t('market.componentsList')}</div>}
{componentKindList.length > 0 && (
<>
{componentKindList.map((kind) => {
return (
<Fragment key={kind}>
{useBadge && (
<Badge variant="outline" className="flex items-center gap-1">
{kindIconMap[kind]}
{responsive ? (
<span className="hidden md:inline">
{t('market.componentName.' + kind)}
</span>
) : (
showComponentName && t('market.componentName.' + kind)
)}
<span className="ml-1">{components[kind]}</span>
</Badge>
)}
{!useBadge && (
<div
className="flex flex-row items-center justify-start gap-[0.2rem]"
>
{kindIconMap[kind]}
{responsive ? (
<span className="hidden md:inline">
{t('market.componentName.' + kind)}
</span>
) : (
showComponentName && t('market.componentName.' + kind)
)}
<span className="ml-1">{components[kind]}</span>
</div>
)}
</Fragment>
);
})}
</>
)}
{componentKindList.length === 0 && <div>{t('market.noComponents')}</div>}
</>
);
}

View File

@@ -8,15 +8,23 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import { Separator } from '@/components/ui/separator';
import {
ToggleGroup,
ToggleGroupItem,
} from '@/components/ui/toggle-group';
import {
Search,
Wrench,
AudioWaveform,
Hash,
Book,
FileText,
PanelTop,
SlidersHorizontal,
X,
} from 'lucide-react';
import PluginMarketCardComponent from './plugin-market-card/PluginMarketCardComponent';
import { PluginMarketCardVO } from './plugin-market-card/PluginMarketCardVO';
@@ -27,6 +35,7 @@ import { extractI18nObject } from '@/i18n/I18nProvider';
import { toast } from 'sonner';
import { ApiRespMarketplacePlugins } from '@/app/infra/entities/api';
import { LoadingSpinner } from '@/components/ui/loading-spinner';
import { Button } from '@/components/ui/button';
import { TagsFilter } from './TagsFilter';
import { PluginTag } from '@/app/infra/http/CloudServiceClient';
@@ -54,7 +63,15 @@ function MarketPageContent({
'EventListener',
'KnowledgeEngine',
'Parser',
'Page',
];
const validTypes = ['plugin', 'mcp', 'skill'];
const extensionTypeOptions = [
{ value: 'all', label: t('market.filters.allFormats'), icon: null },
{ value: 'plugin', label: t('market.typePlugin'), icon: Wrench },
{ value: 'mcp', label: t('market.typeMCP'), icon: AudioWaveform },
{ value: 'skill', label: t('market.typeSkill'), icon: Book },
];
const [searchQuery, setSearchQuery] = useState('');
@@ -65,6 +82,14 @@ function MarketPageContent({
}
return 'all';
});
const [typeFilter, setTypeFilter] = useState<string>(() => {
const type = searchParams.get('type');
if (type && validTypes.includes(type)) {
return type;
}
return 'all';
});
const activeAdvancedFilters = typeFilter === 'all' ? 0 : 1;
const [selectedTags, setSelectedTags] = useState<string[]>([]);
const [availableTags, setAvailableTags] = useState<PluginTag[]>([]);
const [tagNames, setTagNames] = useState<Record<string, string>>({});
@@ -138,9 +163,44 @@ function MarketPageContent({
version: plugin.latest_version,
components: plugin.components,
tags: plugin.tags || [],
type: plugin.type,
});
}, []);
const transformMCPToVO = useCallback((mcp: any): PluginMarketCardVO => {
return new PluginMarketCardVO({
pluginId: mcp.author + ' / ' + mcp.name,
author: mcp.author,
pluginName: mcp.name,
label: extractI18nObject(mcp.label),
description: extractI18nObject(mcp.description) || t('market.noDescription'),
installCount: mcp.install_count || 0,
iconURL: mcp.icon || getCloudServiceClientSync().getPluginIconURL(mcp.author, mcp.name),
githubURL: mcp.repository,
version: mcp.latest_version,
components: mcp.components || {},
tags: mcp.tags || [],
type: 'mcp',
});
}, [t]);
const transformSkillToVO = useCallback((skill: any): PluginMarketCardVO => {
return new PluginMarketCardVO({
pluginId: skill.author + ' / ' + skill.name,
author: skill.author,
pluginName: skill.name,
label: extractI18nObject(skill.label),
description: extractI18nObject(skill.description) || t('market.noDescription'),
installCount: skill.install_count || 0,
iconURL: skill.icon || getCloudServiceClientSync().getPluginIconURL(skill.author, skill.name),
githubURL: skill.repository,
version: skill.latest_version,
components: skill.components || {},
tags: skill.tags || [],
type: 'skill',
});
}, [t]);
// 获取插件列表
const fetchPlugins = useCallback(
async (page: number, isSearch: boolean = false, reset: boolean = false) => {
@@ -154,30 +214,98 @@ function MarketPageContent({
const { sortBy, sortOrder } = getCurrentSort();
const filterValue =
componentFilter === 'all' ? undefined : componentFilter;
const query = isSearch && searchQuery.trim() ? searchQuery.trim() : '';
// Always use searchMarketplacePlugins to support component filtering and tags filtering
const response =
await getCloudServiceClientSync().searchMarketplacePlugins(
isSearch && searchQuery.trim() ? searchQuery.trim() : '',
let newPlugins: PluginMarketCardVO[] = [];
let total = 0;
if (typeFilter === 'all') {
let pluginsResult: PluginMarketCardVO[] = [];
let mcpsResult: PluginMarketCardVO[] = [];
let skillsResult: PluginMarketCardVO[] = [];
let pluginsTotal = 0;
let mcpsTotal = 0;
let skillsTotal = 0;
try {
const pluginsResponse = await getCloudServiceClientSync().searchMarketplacePlugins(
query,
page,
pageSize,
sortBy,
sortOrder,
filterValue,
selectedTags.length > 0 ? selectedTags : undefined,
'plugin',
);
pluginsResult = pluginsResponse.plugins
.filter((plugin) => {
const keys = Object.keys(plugin.components || {});
return !(keys.length > 0 && keys.every((k) => k === 'KnowledgeRetriever'));
})
.map(transformToVO);
pluginsTotal = pluginsResponse.total || 0;
} catch (e) {
console.warn('Failed to fetch plugins:', e);
}
try {
const mcpsResponse = await getCloudServiceClientSync().searchMarketplacePlugins(
query,
page,
pageSize,
sortBy,
sortOrder,
filterValue,
selectedTags.length > 0 ? selectedTags : undefined,
'mcp',
);
mcpsResult = (mcpsResponse.plugins || []).map(transformMCPToVO);
mcpsTotal = mcpsResponse.total || 0;
} catch (e) {
console.warn('Failed to fetch mcps:', e);
}
try {
const skillsResponse = await getCloudServiceClientSync().searchMarketplacePlugins(
query,
page,
pageSize,
sortBy,
sortOrder,
filterValue,
selectedTags.length > 0 ? selectedTags : undefined,
'skill',
);
skillsResult = (skillsResponse.plugins || []).map(transformSkillToVO);
skillsTotal = skillsResponse.total || 0;
} catch (e) {
console.warn('Failed to fetch skills:', e);
}
newPlugins = [...pluginsResult, ...mcpsResult, ...skillsResult];
total = pluginsTotal + mcpsTotal + skillsTotal;
} else {
const response = await getCloudServiceClientSync().searchMarketplacePlugins(
query,
page,
pageSize,
sortBy,
sortOrder,
filterValue,
selectedTags.length > 0 ? selectedTags : undefined,
typeFilter === 'all' ? undefined : typeFilter,
);
const data: ApiRespMarketplacePlugins = response;
const newPlugins = data.plugins
.filter((plugin) => {
// Hide plugins that only contain deprecated KnowledgeRetriever components
const keys = Object.keys(plugin.components || {});
return !(
keys.length > 0 && keys.every((k) => k === 'KnowledgeRetriever')
);
})
.map(transformToVO);
const total = data.total;
const data: ApiRespMarketplacePlugins = response;
newPlugins = data.plugins
.filter((plugin) => {
const keys = Object.keys(plugin.components || {});
return !(keys.length > 0 && keys.every((k) => k === 'KnowledgeRetriever'));
})
.map(transformToVO);
total = data.total;
}
if (reset || page === 1) {
setPlugins(newPlugins);
@@ -187,8 +315,8 @@ function MarketPageContent({
setTotal(total);
setHasMore(
data.plugins.length === pageSize &&
plugins.length + newPlugins.length < total,
newPlugins.length > 0 &&
(reset || page === 1 ? newPlugins.length : plugins.length + newPlugins.length) < total,
);
} catch (error) {
console.error('Failed to fetch plugins:', error);
@@ -204,8 +332,11 @@ function MarketPageContent({
selectedTags,
pageSize,
transformToVO,
transformMCPToVO,
transformSkillToVO,
plugins.length,
getCurrentSort,
typeFilter,
],
);
@@ -315,10 +446,29 @@ function MarketPageContent({
// fetchPlugins will be called by useEffect when componentFilter changes
}, []);
// Handle type filter change
const handleTypeFilterChange = useCallback((value: string) => {
setTypeFilter(value);
setCurrentPage(1);
setPlugins([]);
// Update URL query param to keep it in sync
const params = new URLSearchParams(window.location.search);
if (value === 'all') {
params.delete('type');
} else {
params.set('type', value);
}
const newUrl = params.toString()
? `${window.location.pathname}?${params.toString()}`
: window.location.pathname;
window.history.replaceState({}, '', newUrl);
}, []);
// 当排序选项或组件筛选变化时重新加载数据
useEffect(() => {
fetchPlugins(1, !!searchQuery.trim(), true);
}, [sortOption, componentFilter]);
}, [sortOption, componentFilter, typeFilter]);
// Tags 筛选变化时重新搜索
useEffect(() => {
@@ -431,9 +581,9 @@ function MarketPageContent({
<div className="h-full flex flex-col">
{/* Fixed header with search and sort controls */}
<div className="flex-shrink-0 space-y-4 px-3 sm:px-4 py-4 sm:py-6">
{/* Search box and Tags filter */}
<div className="flex flex-col sm:flex-row items-center justify-center gap-3">
<div className="relative w-full max-w-2xl">
{/* Search box */}
<div className="flex flex-col lg:flex-row items-stretch lg:items-center justify-center gap-3">
<div className="relative w-full lg:max-w-xl">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 text-muted-foreground h-4 w-4" />
<Input
placeholder={t('market.searchPlaceholder')}
@@ -448,7 +598,6 @@ function MarketPageContent({
}}
onKeyPress={(e) => {
if (e.key === 'Enter') {
// Immediately search, clear debounce timer
if (searchTimeoutRef.current) {
clearTimeout(searchTimeoutRef.current);
}
@@ -459,98 +608,9 @@ function MarketPageContent({
/>
</div>
{/* Tags filter */}
<TagsFilter
availableTags={availableTags}
selectedTags={selectedTags}
onTagsChange={handleTagsChange}
/>
</div>
{/* Component filter and sort */}
<div className="flex flex-col sm:flex-row items-center justify-center gap-3 sm:gap-4 px-3 sm:px-4">
{/* Component filter */}
<div className="flex flex-col sm:flex-row items-center gap-2 min-w-0 max-w-full">
<span className="text-xs sm:text-sm text-muted-foreground whitespace-nowrap">
{t('market.filterByComponent')}:
</span>
<div className="overflow-x-auto max-w-full [&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]">
<ToggleGroup
type="single"
spacing={2}
size="sm"
value={componentFilter}
onValueChange={(value) => {
if (value) handleComponentFilterChange(value);
}}
className="justify-start flex-nowrap"
>
<ToggleGroupItem
value="all"
aria-label="All components"
className="text-xs sm:text-sm cursor-pointer"
>
{t('market.allComponents')}
</ToggleGroupItem>
<ToggleGroupItem
value="Tool"
aria-label="Tool"
className="text-xs sm:text-sm cursor-pointer"
>
<Wrench className="h-4 w-4 mr-1" />
{t('plugins.componentName.Tool')}
</ToggleGroupItem>
<ToggleGroupItem
value="Command"
aria-label="Command"
className="text-xs sm:text-sm cursor-pointer"
>
<Hash className="h-4 w-4 mr-1" />
{t('plugins.componentName.Command')}
</ToggleGroupItem>
<ToggleGroupItem
value="EventListener"
aria-label="EventListener"
className="text-xs sm:text-sm cursor-pointer"
>
<AudioWaveform className="h-4 w-4 mr-1" />
{t('plugins.componentName.EventListener')}
</ToggleGroupItem>
<ToggleGroupItem
value="KnowledgeEngine"
aria-label="KnowledgeEngine"
className="text-xs sm:text-sm cursor-pointer"
>
<Book className="h-4 w-4 mr-1" />
{t('plugins.componentName.KnowledgeEngine')}
</ToggleGroupItem>
<ToggleGroupItem
value="Parser"
aria-label="Parser"
className="text-xs sm:text-sm cursor-pointer"
>
<FileText className="h-4 w-4 mr-1" />
{t('plugins.componentName.Parser')}
</ToggleGroupItem>
<ToggleGroupItem
value="Page"
aria-label="Page"
className="text-xs sm:text-sm cursor-pointer"
>
<PanelTop className="h-4 w-4 mr-1" />
{t('plugins.componentName.Page')}
</ToggleGroupItem>
</ToggleGroup>
</div>
</div>
{/* Sort dropdown */}
<div className="flex items-center gap-2 sm:gap-3">
<span className="text-xs sm:text-sm text-muted-foreground whitespace-nowrap">
{t('market.sortBy')}:
</span>
<div className="flex w-full items-center justify-end gap-2 lg:w-auto">
<Select value={sortOption} onValueChange={handleSortChange}>
<SelectTrigger className="w-40 sm:w-48 text-xs sm:text-sm">
<SelectTrigger className="w-[128px] sm:w-40 text-xs sm:text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
@@ -561,9 +621,96 @@ function MarketPageContent({
))}
</SelectContent>
</Select>
<Popover>
<PopoverTrigger asChild>
<Button variant="outline" className="relative">
<SlidersHorizontal className="h-4 w-4" />
<span className="hidden sm:inline">{t('market.filters.more')}</span>
{activeAdvancedFilters > 0 && (
<span className="absolute -right-1 -top-1 flex h-4 min-w-4 items-center justify-center rounded-full bg-primary px-1 text-[10px] leading-none text-primary-foreground">
{activeAdvancedFilters}
</span>
)}
</Button>
</PopoverTrigger>
<PopoverContent align="end" className="w-[320px] space-y-4">
<div>
<div className="text-sm font-medium">{t('market.filters.advancedTitle')}</div>
<div className="mt-1 text-xs text-muted-foreground">
{t('market.filters.advancedDescription')}
</div>
</div>
<Separator />
<div className="space-y-2">
<div className="text-xs font-medium text-muted-foreground">
{t('market.filters.technicalType')}
</div>
<ToggleGroup
type="single"
spacing={2}
size="sm"
value={typeFilter}
onValueChange={(value) => {
if (value) handleTypeFilterChange(value);
}}
className="flex flex-wrap justify-start gap-2"
>
{extensionTypeOptions.map((option) => {
const Icon = option.icon;
return (
<ToggleGroupItem
key={option.value}
value={option.value}
aria-label={option.label}
className="cursor-pointer text-xs"
>
{Icon && <Icon className="mr-1 h-3.5 w-3.5" />}
{option.label}
</ToggleGroupItem>
);
})}
</ToggleGroup>
</div>
</PopoverContent>
</Popover>
</div>
</div>
{/* Quick tag filter buttons */}
<div className="mx-auto flex w-full max-w-4xl items-center gap-2 overflow-x-auto pb-1 sm:flex-wrap sm:justify-center sm:overflow-visible">
<Button
type="button"
variant={selectedTags.length === 0 ? 'secondary' : 'ghost'}
size="sm"
className="h-8 shrink-0"
onClick={() => handleTagsChange([])}
>
{t('market.allExtensions')}
</Button>
{availableTags.map((tag) => {
const selected = selectedTags.includes(tag.tag);
return (
<Button
key={tag.tag}
type="button"
variant={selected ? 'secondary' : 'ghost'}
size="sm"
className="h-8 shrink-0"
onClick={() => {
const newTags = selected
? selectedTags.filter((t) => t !== tag.tag)
: [...selectedTags, tag.tag];
handleTagsChange(newTags);
}}
>
{tagNames[tag.tag] || tag.tag}
{selected && <X className="h-3.5 w-3.5" />}
</Button>
);
})}
</div>
{/* Search results stats */}
{total > 0 && (
<div className="text-center text-muted-foreground text-sm">

View File

@@ -38,6 +38,7 @@ function pluginToVO(
version: plugin.latest_version,
components: plugin.components,
tags: plugin.tags || [],
type: plugin.type,
});
}

View File

@@ -1,17 +1,15 @@
import { PluginMarketCardVO } from './PluginMarketCardVO';
import { useRef, useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import PluginComponentList from '../PluginComponentList';
import { Badge } from '@/components/ui/badge';
import { Info, Package } from 'lucide-react';
import {
Wrench,
AudioWaveform,
Hash,
Download,
ExternalLink,
Book,
FileText,
} from 'lucide-react';
import { useState, useRef, useEffect } from 'react';
import { Button } from '@/components/ui/button';
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@/components/ui/tooltip';
export default function PluginMarketCardComponent({
cardVO,
@@ -23,11 +21,24 @@ export default function PluginMarketCardComponent({
tagNames?: Record<string, string>;
}) {
const { t } = useTranslation();
const [isHovered, setIsHovered] = useState(false);
const bottomRef = useRef<HTMLDivElement>(null);
const [visibleTags, setVisibleTags] = useState(2);
const [iconFailed, setIconFailed] = useState(!cardVO.iconURL);
const pluginDetailUrl = `https://space.langbot.app/market/${cardVO.author}/${cardVO.pluginName}`;
const isDeprecated = (() => {
if (!cardVO.components) return false;
const keys = Object.keys(cardVO.components);
return keys.length > 0 && keys.every((k) => k === 'KnowledgeRetriever');
})();
const showTypeBadge = cardVO.type;
useEffect(() => {
setIconFailed(!cardVO.iconURL);
}, [cardVO.iconURL]);
// Measure how many tags fit in the bottom row
useEffect(() => {
const tags = cardVO.tags;
if (!bottomRef.current || !tags || tags.length === 0) return;
@@ -43,10 +54,7 @@ export default function PluginMarketCardComponent({
}
const tagWidth = 80;
const plusBadgeWidth = 40;
const maxTags = Math.max(
0,
Math.floor((availableForTags - plusBadgeWidth) / tagWidth),
);
const maxTags = Math.max(0, Math.floor((availableForTags - plusBadgeWidth) / tagWidth));
if (maxTags >= tags.length) {
setVisibleTags(tags.length);
} else {
@@ -62,51 +70,72 @@ export default function PluginMarketCardComponent({
const remainingTags = cardVO.tags ? cardVO.tags.length - visibleTags : 0;
function handleInstallClick(e: React.MouseEvent) {
e.stopPropagation();
if (onInstall) {
onInstall(cardVO.author, cardVO.pluginName);
}
}
function handleViewDetailsClick(e: React.MouseEvent) {
e.stopPropagation();
const detailUrl = `https://space.langbot.app/market/${cardVO.author}/${cardVO.pluginName}`;
window.open(detailUrl, '_blank');
}
const kindIconMap: Record<string, React.ReactNode> = {
Tool: <Wrench className="w-4 h-4" />,
EventListener: <AudioWaveform className="w-4 h-4" />,
Command: <Hash className="w-4 h-4" />,
KnowledgeEngine: <Book className="w-4 h-4" />,
Parser: <FileText className="w-4 h-4" />,
};
return (
<div
className="w-[100%] h-auto min-h-[8rem] sm:min-h-[9rem] bg-white rounded-[10px] border border-[#e4e4e7] dark:border-[#27272a] p-3 sm:p-[1rem] hover:border-[#a1a1aa] dark:hover:border-[#3f3f46] transition-all duration-200 dark:bg-[#1f1f22] relative"
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
<a
href={pluginDetailUrl}
target="_blank"
rel="noopener noreferrer"
className="w-[100%] h-[10rem] bg-white rounded-[10px] shadow-[0px_0px_4px_0_rgba(0,0,0,0.2)] p-3 sm:p-[1rem] cursor-pointer hover:shadow-[0px_2px_8px_0_rgba(0,0,0,0.15)] transition-shadow duration-200 dark:bg-[#1f1f22] dark:shadow-[0px_0px_4px_0_rgba(255,255,255,0.1)] dark:hover:shadow-[0px_2px_8px_0_rgba(255,255,255,0.15)] block"
>
<div className="w-full h-full flex flex-col justify-between gap-3">
{/* 上部分:插件信息 */}
<div className="flex flex-row items-start justify-start gap-2 sm:gap-[1.2rem] min-h-0">
<img
src={cardVO.iconURL}
alt="plugin icon"
className="w-12 h-12 sm:w-16 sm:h-16 flex-shrink-0 rounded-[8%]"
/>
<div className="w-full h-full flex flex-col justify-between">
<div className="flex flex-row items-start justify-start gap-2 sm:gap-[1.2rem] min-h-0 flex-1 overflow-hidden">
{iconFailed ? (
<div className="w-12 h-12 sm:w-16 sm:h-16 flex-shrink-0 rounded-[8%] border bg-muted text-muted-foreground flex items-center justify-center">
<Package className="w-6 h-6 sm:w-8 sm:h-8" />
</div>
) : (
<img
src={cardVO.iconURL}
alt="plugin icon"
className="w-12 h-12 sm:w-16 sm:h-16 flex-shrink-0 rounded-[8%] object-cover"
loading="lazy"
decoding="async"
fetchPriority="low"
onError={() => setIconFailed(true)}
/>
)}
<div className="flex-1 flex flex-col items-start justify-start gap-[0.4rem] sm:gap-[0.6rem] min-w-0 overflow-hidden">
<div className="flex flex-col items-start justify-start w-full min-w-0">
<div className="text-[0.65rem] sm:text-[0.7rem] text-[#666] dark:text-[#999] truncate w-full">
{cardVO.pluginId}
</div>
<div className="text-[0.65rem] sm:text-[0.7rem] text-[#666] dark:text-[#999] truncate w-full">{cardVO.pluginId}</div>
<div className="flex items-center gap-1.5 w-full min-w-0">
<div className="text-base sm:text-[1.2rem] text-black dark:text-[#f0f0f0] truncate">
{cardVO.label}
</div>
<div className="text-base sm:text-[1.2rem] text-black dark:text-[#f0f0f0] truncate">{cardVO.label}</div>
{isDeprecated && (
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild onClick={(e) => e.preventDefault()}>
<Badge
variant="outline"
className="text-[0.6rem] px-1.5 py-0 h-4 flex-shrink-0 border-red-400 text-red-500 dark:border-red-500 dark:text-red-400 gap-0.5 cursor-help"
>
{t('market.deprecated')}
<Info className="w-2.5 h-2.5" />
</Badge>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-[240px] text-xs">
{t('market.deprecatedTooltip')}
</TooltipContent>
</Tooltip>
</TooltipProvider>
)}
{showTypeBadge && (
<Badge
variant="outline"
className={`text-[0.6rem] px-1.5 py-0 h-4 flex-shrink-0 gap-0.5 ${
cardVO.type === 'mcp'
? 'border-sky-500 text-sky-600 dark:border-sky-400 dark:text-sky-300'
: cardVO.type === 'skill'
? 'border-emerald-500 text-emerald-600 dark:border-emerald-400 dark:text-emerald-300'
: 'border-violet-500 text-violet-600 dark:border-violet-400 dark:text-violet-300'
}`}
>
{cardVO.type === 'mcp'
? 'MCP'
: cardVO.type === 'skill'
? t('common.skill')
: t('market.typePlugin')}
</Badge>
)}
</div>
</div>
@@ -118,11 +147,12 @@ export default function PluginMarketCardComponent({
<div className="flex flex-row items-start justify-center gap-[0.4rem] flex-shrink-0">
{cardVO.githubURL && (
<svg
className="w-5 h-5 sm:w-[1.4rem] sm:h-[1.4rem] text-black cursor-pointer hover:text-gray-600 dark:text-[#f0f0f0] flex-shrink-0"
className="w-5 h-5 sm:w-[1.4rem] sm:h-[1.4rem] text-black cursor-pointer hover:text-gray-600 dark:text-[#f0f0f0] dark:hover:text-[#c0c0c0] flex-shrink-0"
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="currentColor"
onClick={(e) => {
e.preventDefault();
e.stopPropagation();
window.open(cardVO.githubURL, '_blank');
}}
@@ -133,13 +163,8 @@ export default function PluginMarketCardComponent({
</div>
</div>
{/* 下部分:下载量、标签和组件列表 */}
<div
ref={bottomRef}
className="w-full flex flex-row items-center justify-between gap-2 px-0 sm:px-[0.4rem] flex-shrink-0 overflow-hidden"
>
<div ref={bottomRef} className="w-full flex flex-row items-center justify-between gap-2 px-0 sm:px-[0.4rem] flex-shrink-0 overflow-hidden">
<div className="flex flex-row items-center justify-start gap-2 min-w-0 overflow-hidden">
{/* 下载数量 */}
<div className="flex flex-row items-center gap-[0.3rem] sm:gap-[0.4rem] flex-shrink-0">
<svg
className="w-4 h-4 sm:w-[1.2rem] sm:h-[1.2rem] text-[#2563eb] dark:text-[#5b8def] flex-shrink-0"
@@ -158,7 +183,6 @@ export default function PluginMarketCardComponent({
</div>
</div>
{/* Tags - adaptive */}
{cardVO.tags && cardVO.tags.length > 0 && visibleTags > 0 && (
<div className="flex flex-row items-center gap-1.5 overflow-hidden flex-shrink min-w-0">
{cardVO.tags.slice(0, visibleTags).map((tag) => (
@@ -180,9 +204,7 @@ export default function PluginMarketCardComponent({
<path d="M20.59 13.41l-7.17 7.17a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z" />
<line x1="7" y1="7" x2="7.01" y2="7" />
</svg>
<span className="truncate max-w-[5rem]">
{tagNames[tag] || tag}
</span>
<span className="truncate max-w-[5rem]">{tagNames[tag] || tag}</span>
</Badge>
))}
{remainingTags > 0 && (
@@ -197,52 +219,20 @@ export default function PluginMarketCardComponent({
)}
</div>
{/* 组件列表 */}
{cardVO.components && Object.keys(cardVO.components).length > 0 && (
<div className="flex flex-row items-center gap-1">
{Object.entries(cardVO.components).map(([kind, count]) => (
<Badge
key={kind}
variant="outline"
className="flex items-center gap-1"
>
{kindIconMap[kind]}
<span className="ml-1">{count}</span>
</Badge>
))}
<div className="flex flex-row items-center gap-1 flex-shrink-0">
<PluginComponentList
components={cardVO.components}
showComponentName={false}
showTitle={false}
useBadge={true}
t={t}
responsive={false}
/>
</div>
)}
</div>
</div>
{/* Hover overlay with action buttons */}
<div
className={`absolute inset-0 bg-gray-100/55 dark:bg-black/35 rounded-[10px] flex items-center justify-center gap-3 transition-all duration-200 ${
isHovered ? 'opacity-100' : 'opacity-0 pointer-events-none'
}`}
>
<Button
onClick={handleInstallClick}
className={`bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg shadow-sm flex items-center gap-2 transition-all duration-200 ${
isHovered ? 'translate-y-0 opacity-100' : 'translate-y-1 opacity-0'
}`}
style={{ transitionDelay: isHovered ? '10ms' : '0ms' }}
>
<Download className="w-4 h-4" />
{t('market.install')}
</Button>
<Button
onClick={handleViewDetailsClick}
variant="outline"
className={`bg-white hover:bg-gray-100 text-gray-900 dark:bg-white dark:hover:bg-gray-100 dark:text-gray-900 px-4 py-2 rounded-lg shadow-sm flex items-center gap-2 transition-all duration-200 ${
isHovered ? 'translate-y-0 opacity-100' : 'translate-y-1 opacity-0'
}`}
style={{ transitionDelay: isHovered ? '20ms' : '0ms' }}
>
<ExternalLink className="w-4 h-4" />
{t('market.viewDetails')}
</Button>
</div>
</div>
</a>
);
}
}

View File

@@ -10,6 +10,7 @@ export interface IPluginMarketCardVO {
version: string;
components?: Record<string, number>;
tags?: string[];
type?: 'plugin' | 'mcp' | 'skill';
}
export class PluginMarketCardVO implements IPluginMarketCardVO {
@@ -24,6 +25,7 @@ export class PluginMarketCardVO implements IPluginMarketCardVO {
version: string;
components?: Record<string, number>;
tags?: string[];
type?: 'plugin' | 'mcp' | 'skill';
constructor(prop: IPluginMarketCardVO) {
this.description = prop.description;
@@ -37,5 +39,6 @@ export class PluginMarketCardVO implements IPluginMarketCardVO {
this.version = prop.version;
this.components = prop.components;
this.tags = prop.tags;
this.type = prop.type;
}
}

View File

@@ -18,7 +18,6 @@ import {
Check,
Bug,
} from 'lucide-react';
import { copyToClipboard } from '@/app/utils/clipboard';
import {
DropdownMenu,
DropdownMenuContent,
@@ -467,13 +466,33 @@ function PluginListView() {
};
const handleCopyDebugInfo = (text: string, type: 'url' | 'key') => {
copyToClipboard(text).catch(() => {});
if (type === 'url') {
setCopiedDebugUrl(true);
setTimeout(() => setCopiedDebugUrl(false), 2000);
} else {
setCopiedDebugKey(true);
setTimeout(() => setCopiedDebugKey(false), 2000);
try {
navigator.clipboard.writeText(text);
if (type === 'url') {
setCopiedDebugUrl(true);
setTimeout(() => setCopiedDebugUrl(false), 2000);
} else {
setCopiedDebugKey(true);
setTimeout(() => setCopiedDebugKey(false), 2000);
}
} catch {
const textArea = document.createElement('textarea');
textArea.value = text;
textArea.style.position = 'fixed';
textArea.style.left = '-999999px';
textArea.style.top = '-999999px';
document.body.appendChild(textArea);
textArea.select();
textArea.setSelectionRange(0, 99999);
const success = document.execCommand('copy');
document.body.removeChild(textArea);
if (success) {
setCopiedDebugUrl(true);
setTimeout(() => setCopiedDebugUrl(false), 2000);
} else {
setCopiedDebugKey(true);
setTimeout(() => setCopiedDebugKey(false), 2000);
}
}
};

View File

@@ -45,7 +45,6 @@ export enum DynamicFormItemType {
BOT_SELECTOR = 'bot-selector',
TOOLS_SELECTOR = 'tools-selector',
WEBHOOK_URL = 'webhook-url',
EMBED_CODE = 'embed-code',
}
export interface IFileConfig {

View File

@@ -42,6 +42,7 @@ export interface PluginV4 {
latest_version: string;
components: Record<string, number>;
status: PluginV4Status;
type?: 'plugin' | 'mcp' | 'skill';
created_at: string;
updated_at: string;
}

View File

@@ -112,7 +112,7 @@ export class BackendClient extends BaseHttpClient {
public scanProviderModels(
uuid: string,
modelType?: 'llm' | 'embedding' | 'rerank',
modelType?: 'llm' | 'embedding',
): Promise<ApiRespScannedProviderModels> {
const params = modelType ? { type: modelType } : {};
return this.get(`/api/v1/provider/providers/${uuid}/scan-models`, params);
@@ -596,27 +596,6 @@ export class BackendClient extends BaseHttpClient {
);
}
public async pluginPageApi(
author: string,
name: string,
pageId: string,
endpoint: string,
method: string = 'POST',
body?: unknown,
): Promise<unknown> {
const resp = await this.instance.request({
url: `/api/v1/plugins/${author}/${name}/page-api`,
method: 'POST',
data: {
page_id: pageId,
endpoint,
method,
body,
},
});
return resp.data?.data;
}
public getPluginIconURL(author: string, name: string): string {
if (this.instance.defaults.baseURL === '/') {
const url = window.location.href;

View File

@@ -38,7 +38,49 @@ export class CloudServiceClient extends BaseHttpClient {
sort_order?: string,
component_filter?: string,
tags_filter?: string[],
type_filter?: string,
): Promise<ApiRespMarketplacePlugins> {
// Use different endpoints based on type_filter
if (type_filter === 'mcp') {
return this.post<{ mcps: PluginV4[]; total: number }>(
'/api/v1/marketplace/mcps/search',
{
query,
page,
page_size,
sort_by,
sort_order,
tags_filter,
},
).then((resp) => ({
plugins: (resp?.mcps || []).map((mcp) => ({
...mcp,
plugin_id: mcp.mcp_id || mcp.plugin_id,
type: 'mcp' as const,
})),
total: resp?.total || 0,
}));
} else if (type_filter === 'skill') {
return this.post<{ skills: PluginV4[]; total: number }>(
'/api/v1/marketplace/skills/search',
{
query,
page,
page_size,
sort_by,
sort_order,
tags_filter,
},
).then((resp) => ({
plugins: (resp?.skills || []).map((skill) => ({
...skill,
plugin_id: skill.skill_id || skill.plugin_id,
type: 'skill' as const,
})),
total: resp?.total || 0,
}));
}
return this.post<ApiRespMarketplacePlugins>(
'/api/v1/marketplace/plugins/search',
{
@@ -49,6 +91,7 @@ export class CloudServiceClient extends BaseHttpClient {
sort_order,
component_filter,
tags_filter,
type_filter,
},
);
}

View File

@@ -1,39 +0,0 @@
/**
* Copy text to clipboard with fallback support
* Tries to use modern Clipboard API first, falls back to execCommand if not available
*
* @param text - The text to copy to clipboard
* @returns Promise<boolean> - true if successful, false otherwise
*/
export async function copyToClipboard(text: string): Promise<boolean> {
// Try modern Clipboard API first
if (navigator.clipboard && navigator.clipboard.writeText) {
try {
await navigator.clipboard.writeText(text);
return true;
} catch (err) {
console.error('[Clipboard] Modern API failed, trying fallback:', err);
// Fall through to legacy method
}
}
// Fallback to legacy execCommand method
try {
const textArea = document.createElement('textarea');
textArea.value = text;
textArea.style.position = 'fixed';
textArea.style.left = '-999999px';
textArea.style.top = '-999999px';
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
const successful = document.execCommand('copy');
document.body.removeChild(textArea);
return successful;
} catch (err) {
console.error('[Clipboard] Fallback method failed:', err);
return false;
}
}

View File

@@ -572,9 +572,9 @@ export default function WizardPage() {
className={cn(
'w-6 h-6 sm:w-7 sm:h-7 rounded-full flex items-center justify-center text-xs font-medium transition-colors',
idx < currentStep
? 'bg-blue-600 text-white'
? 'bg-primary text-primary-foreground'
: idx === currentStep
? 'bg-blue-600 text-white'
? 'bg-primary text-primary-foreground'
: 'bg-muted text-muted-foreground',
)}
>
@@ -588,7 +588,7 @@ export default function WizardPage() {
className={cn(
'text-sm hidden sm:inline',
idx === currentStep
? 'font-medium text-blue-600'
? 'font-medium text-foreground'
: 'text-muted-foreground',
)}
>
@@ -599,7 +599,7 @@ export default function WizardPage() {
<div
className={cn(
'w-4 sm:w-8 h-px',
idx < currentStep ? 'bg-blue-600' : 'bg-border',
idx < currentStep ? 'bg-primary' : 'bg-border',
)}
/>
)}

View File

@@ -5,8 +5,6 @@ const enUS = {
installedPlugins: 'Installed Plugins',
pluginMarket: 'Marketplace',
mcpServers: 'MCP Servers',
pluginPages: 'Plugin Pages',
pluginPagesTooltip: 'Visual pages provided by installed plugins',
quickStart: 'Quick Start',
},
common: {
@@ -38,6 +36,7 @@ const enUS = {
delete: 'Delete',
add: 'Add',
select: 'Select',
skill: 'Skill',
cancel: 'Cancel',
submit: 'Submit',
error: 'Error',
@@ -200,9 +199,6 @@ const enUS = {
string: 'String',
number: 'Number',
boolean: 'Boolean',
object: 'Object',
objectJsonPlaceholder: '{ "type": "disabled" }',
invalidJsonObject: 'Value must be a valid JSON object',
selectModelProvider: 'Select Model Provider',
modelProviderDescription:
'Please fill in the model name provided by the provider',
@@ -493,7 +489,6 @@ const enUS = {
Command: 'Command',
KnowledgeEngine: 'Knowledge Engine',
Parser: 'Parser',
Page: 'Page',
},
uploadLocal: 'Upload Local',
debugging: 'Debugging',
@@ -623,11 +618,24 @@ const enUS = {
markAsReadFailed: 'Mark as read failed',
filterByComponent: 'Component',
allComponents: 'All Components',
filterByType: 'Type',
allTypes: 'All Types',
typePlugin: 'Plugin',
typeMCP: 'MCP',
typeSkill: 'Skill',
requestPlugin: 'Request Plugin',
viewDetails: 'View Details',
deprecated: 'Deprecated',
deprecatedTooltip:
'Please install the corresponding Knowledge Engine plugin.',
filters: {
allFormats: 'All Formats',
more: 'More',
advancedTitle: 'Advanced Filters',
advancedDescription: 'Filter by extension type',
technicalType: 'Technical Type',
},
allExtensions: 'All Extensions',
tags: {
filterByTags: 'Filter by Tags',
selected: 'selected',
@@ -1225,41 +1233,6 @@ const enUS = {
feedback: 'User Feedback',
},
},
storageAnalysis: {
title: 'Storage Analysis',
description: 'Inspect storage usage and cleanup candidates',
openDialog: 'View Analysis',
dialogTitle: 'Storage Analysis',
generatedAt: 'Generated at {{time}}',
loading: 'Loading...',
refresh: 'Refresh',
totalSize: 'Total size',
binaryStorage: 'Binary storage',
uploadCleanup: 'Expired uploads',
logCleanup: 'Expired logs',
sections: 'Storage sections',
monitoringTables: 'Monitoring tables',
runtimeTasks: 'Runtime tasks',
cleanupPolicy: 'Cleanup policy',
uploadRetention: 'Upload retention',
logRetention: 'Log retention',
databaseType: 'Database type',
days: 'days',
missing: 'Missing',
expiredUploads: 'Expired uploads',
expiredLogs: 'Expired logs',
noExpiredUploads: 'No expired uploaded files',
noExpiredLogs: 'No expired log files',
sectionNames: {
database: 'Database',
logs: 'Logs',
storage: 'Uploaded files',
vector_store: 'Vector store',
plugins: 'Plugins',
mcp: 'MCP',
temp: 'Temporary files',
},
},
limitation: {
maxBotsReached:
'Maximum number of bots ({{max}}) reached. Please remove an existing bot before creating a new one.',
@@ -1332,10 +1305,6 @@ const enUS = {
backToWorkbench: 'Back to Workbench',
},
},
pluginPages: {
selectFromSidebar: 'Select a plugin page from the sidebar',
invalidPage: 'Invalid plugin page',
},
};
export default enUS;

View File

@@ -5,9 +5,6 @@ const esES = {
installedPlugins: 'Plugins instalados',
pluginMarket: 'Tienda',
mcpServers: 'Servidores MCP',
pluginPages: 'Páginas de plugins',
pluginPagesTooltip:
'Páginas visuales proporcionadas por los plugins instalados',
quickStart: 'Inicio rápido',
},
common: {
@@ -41,6 +38,7 @@ const esES = {
delete: 'Eliminar',
add: 'Añadir',
select: 'Seleccionar',
skill: 'Habilidad',
cancel: 'Cancelar',
submit: 'Enviar',
error: 'Error',
@@ -205,9 +203,6 @@ const esES = {
string: 'Cadena',
number: 'Número',
boolean: 'Booleano',
object: 'Objeto',
objectJsonPlaceholder: '{ "type": "disabled" }',
invalidJsonObject: 'El valor debe ser un objeto JSON válido',
selectModelProvider: 'Seleccionar proveedor del modelo',
modelProviderDescription:
'Por favor, introduce el nombre del modelo proporcionado por el proveedor',
@@ -506,7 +501,6 @@ const esES = {
Command: 'Comando',
KnowledgeEngine: 'Motor de conocimiento',
Parser: 'Analizador',
Page: 'Página',
},
uploadLocal: 'Subir local',
debugging: 'Depuración',
@@ -637,11 +631,24 @@ const esES = {
markAsReadFailed: 'Error al marcar como leído',
filterByComponent: 'Componente',
allComponents: 'Todos los componentes',
filterByType: 'Tipo',
allTypes: 'Todos los tipos',
typePlugin: 'Plugin',
typeMCP: 'MCP',
typeSkill: 'Habilidad',
requestPlugin: 'Solicitar plugin',
viewDetails: 'Ver detalles',
deprecated: 'Obsoleto',
deprecatedTooltip:
'Por favor, instala el plugin de motor de conocimiento correspondiente.',
filters: {
allFormats: 'Todos los formatos',
more: 'Más',
advancedTitle: 'Filtros avanzados',
advancedDescription: 'Filtrar por tipo de extensión',
technicalType: 'Tipo técnico',
},
allExtensions: 'Todas las extensiones',
tags: {
filterByTags: 'Filtrar por etiquetas',
selected: 'seleccionadas',
@@ -1259,42 +1266,6 @@ const esES = {
feedback: 'Comentarios de usuarios',
},
},
storageAnalysis: {
title: 'Análisis de almacenamiento',
description:
'Inspecciona el uso de almacenamiento y los candidatos de limpieza',
openDialog: 'Ver análisis',
dialogTitle: 'Análisis de almacenamiento',
generatedAt: 'Generado el {{time}}',
loading: 'Cargando...',
refresh: 'Actualizar',
totalSize: 'Tamaño total',
binaryStorage: 'Almacenamiento binario de plugins',
uploadCleanup: 'Subidas caducadas',
logCleanup: 'Registros caducados',
sections: 'Secciones de almacenamiento',
monitoringTables: 'Tablas de monitoreo',
runtimeTasks: 'Tareas en ejecución',
cleanupPolicy: 'Política de limpieza',
uploadRetention: 'Retención de subidas',
logRetention: 'Retención de registros',
databaseType: 'Tipo de base de datos',
days: 'días',
missing: 'Falta',
expiredUploads: 'Subidas caducadas',
expiredLogs: 'Registros caducados',
noExpiredUploads: 'No hay archivos subidos caducados',
noExpiredLogs: 'No hay registros caducados',
sectionNames: {
database: 'Base de datos',
logs: 'Registros',
storage: 'Archivos subidos',
vector_store: 'Almacén vectorial',
plugins: 'Plugins',
mcp: 'MCP',
temp: 'Archivos temporales',
},
},
limitation: {
maxBotsReached:
'Se ha alcanzado el número máximo de Bots ({{max}}). Por favor, elimina un Bot existente antes de crear uno nuevo.',
@@ -1371,10 +1342,6 @@ const esES = {
backToWorkbench: 'Volver al panel de trabajo',
},
},
pluginPages: {
selectFromSidebar: 'Selecciona una página de plugin en la barra lateral',
invalidPage: 'Página de plugin no válida',
},
};
export default esES;

View File

@@ -1,12 +1,10 @@
const jaJP = {
const jaJP = {
sidebar: {
home: 'ホーム',
extensions: '拡張機能',
installedPlugins: 'インストール済みプラグイン',
pluginMarket: 'プラグインマーケット',
mcpServers: 'MCPサーバー',
pluginPages: 'プラグインページ',
pluginPagesTooltip: 'インストール済みプラグインが提供するビジュアルページ',
quickStart: 'クイックスタート',
},
common: {
@@ -39,6 +37,7 @@
delete: '削除',
add: '追加',
select: '選択してください',
skill: 'スキル',
cancel: 'キャンセル',
submit: '送信',
error: 'エラー',
@@ -203,9 +202,6 @@
string: '文字列',
number: '数値',
boolean: 'ブール値',
object: 'オブジェクト',
objectJsonPlaceholder: '{ "type": "disabled" }',
invalidJsonObject: '値は有効なJSONオブジェクトである必要があります',
selectModelProvider: 'モデルプロバイダーを選択',
modelProviderDescription: 'プロバイダーが提供するモデル名をご入力ください',
modelManufacturer: 'モデルメーカー',
@@ -497,7 +493,6 @@
Command: 'コマンド',
KnowledgeEngine: '知識エンジン',
Parser: 'パーサー',
Page: 'ページ',
},
uploadLocal: 'ローカルアップロード',
debugging: 'デバッグ中',
@@ -628,6 +623,11 @@
markAsReadFailed: '既読に設定に失敗しました',
filterByComponent: 'コンポーネント',
allComponents: '全部コンポーネント',
filterByType: 'タイプ',
allTypes: '全部',
typePlugin: 'プラグイン',
typeMCP: 'MCP',
typeSkill: 'スキル',
requestPlugin: 'プラグインをリクエスト',
tags: {
filterByTags: 'タグで絞り込み',
@@ -636,6 +636,14 @@
clearAll: 'クリア',
noTags: 'タグがありません',
},
filters: {
allFormats: 'すべての形式',
more: 'もっと',
advancedTitle: '高度なフィルター',
advancedDescription: '拡張子タイプでフィルター',
technicalType: '技術タイプ',
},
allExtensions: 'すべての拡張機能',
viewDetails: '詳細を表示',
deprecated: '非推奨',
deprecatedTooltip:
@@ -1230,41 +1238,6 @@
feedback: 'ユーザーフィードバック',
},
},
storageAnalysis: {
title: 'ストレージ分析',
description: 'ストレージ使用量とクリーンアップ候補を確認します',
openDialog: '分析を表示',
dialogTitle: 'ストレージ分析',
generatedAt: '生成日時 {{time}}',
loading: '読み込み中...',
refresh: '更新',
totalSize: '合計サイズ',
binaryStorage: 'プラグインバイナリストレージ',
uploadCleanup: '期限切れアップロード',
logCleanup: '期限切れログ',
sections: 'ストレージセクション',
monitoringTables: '監視テーブル',
runtimeTasks: '実行タスク',
cleanupPolicy: 'クリーンアップポリシー',
uploadRetention: 'アップロード保持期間',
logRetention: 'ログ保持期間',
databaseType: 'データベース種別',
days: '日',
missing: 'なし',
expiredUploads: '期限切れアップロード',
expiredLogs: '期限切れログ',
noExpiredUploads: '期限切れのアップロードファイルはありません',
noExpiredLogs: '期限切れのログファイルはありません',
sectionNames: {
database: 'データベース',
logs: 'ログ',
storage: 'アップロードファイル',
vector_store: 'ベクターストア',
plugins: 'プラグイン',
mcp: 'MCP',
temp: '一時ファイル',
},
},
limitation: {
maxBotsReached:
'ボット数が上限({{max}}個)に達しました。新しいボットを作成するには、既存のボットを削除してください。',
@@ -1339,10 +1312,6 @@
backToWorkbench: 'ワークベンチに戻る',
},
},
pluginPages: {
selectFromSidebar: 'サイドバーからプラグインページを選択してください',
invalidPage: '無効なプラグインページ',
},
};
export default jaJP;

View File

@@ -5,9 +5,6 @@ const ruRU = {
installedPlugins: 'Установленные плагины',
pluginMarket: 'Маркетплейс',
mcpServers: 'MCP-серверы',
pluginPages: 'Страницы плагинов',
pluginPagesTooltip:
'Визуальные страницы, предоставляемые установленными плагинами',
quickStart: 'Быстрый старт',
},
common: {
@@ -39,6 +36,7 @@ const ruRU = {
delete: 'Удалить',
add: 'Добавить',
select: 'Выбрать',
skill: 'Навык',
cancel: 'Отмена',
submit: 'Отправить',
error: 'Ошибка',
@@ -202,9 +200,6 @@ const ruRU = {
string: 'Строка',
number: 'Число',
boolean: 'Логический',
object: 'Объект',
objectJsonPlaceholder: '{ "type": "disabled" }',
invalidJsonObject: 'Значение должно быть допустимым объектом JSON',
selectModelProvider: 'Выберите провайдера модели',
modelProviderDescription:
'Пожалуйста, введите название модели, предоставленное провайдером',
@@ -503,7 +498,6 @@ const ruRU = {
Command: 'Команда',
KnowledgeEngine: 'Движок знаний',
Parser: 'Парсер',
Page: 'Страница',
},
uploadLocal: 'Загрузить локально',
debugging: 'Отладка',
@@ -634,11 +628,24 @@ const ruRU = {
markAsReadFailed: 'Не удалось отметить как прочитанное',
filterByComponent: 'Компонент',
allComponents: 'Все компоненты',
filterByType: 'Тип',
allTypes: 'Все типы',
typePlugin: 'Плагин',
typeMCP: 'MCP',
typeSkill: 'Навык',
requestPlugin: 'Запросить плагин',
viewDetails: 'Подробнее',
deprecated: 'Устаревший',
deprecatedTooltip:
'Пожалуйста, установите соответствующий плагин движка знаний.',
filters: {
allFormats: 'Все форматы',
more: 'Ещё',
advancedTitle: 'Расширенные фильтры',
advancedDescription: 'Фильтр по типу расширения',
technicalType: 'Технический тип',
},
allExtensions: 'Все расширения',
tags: {
filterByTags: 'Фильтр по тегам',
selected: 'выбрано',
@@ -1234,41 +1241,6 @@ const ruRU = {
feedback: 'Отзывы пользователей',
},
},
storageAnalysis: {
title: 'Анализ хранилища',
description: 'Проверьте использование хранилища и кандидатов на очистку',
openDialog: 'Открыть анализ',
dialogTitle: 'Анализ хранилища',
generatedAt: 'Создано {{time}}',
loading: 'Загрузка...',
refresh: 'Обновить',
totalSize: 'Общий размер',
binaryStorage: 'Бинарное хранилище плагинов',
uploadCleanup: 'Просроченные загрузки',
logCleanup: 'Просроченные журналы',
sections: 'Разделы хранилища',
monitoringTables: 'Таблицы мониторинга',
runtimeTasks: 'Задачи runtime',
cleanupPolicy: 'Политика очистки',
uploadRetention: 'Хранение загрузок',
logRetention: 'Хранение журналов',
databaseType: 'Тип базы данных',
days: 'дн.',
missing: 'Нет',
expiredUploads: 'Просроченные загрузки',
expiredLogs: 'Просроченные журналы',
noExpiredUploads: 'Нет просроченных загруженных файлов',
noExpiredLogs: 'Нет просроченных журналов',
sectionNames: {
database: 'База данных',
logs: 'Журналы',
storage: 'Загруженные файлы',
vector_store: 'Векторное хранилище',
plugins: 'Плагины',
mcp: 'MCP',
temp: 'Временные файлы',
},
},
limitation: {
maxBotsReached:
'Достигнуто максимальное количество ботов ({{max}}). Удалите существующего бота перед созданием нового.',
@@ -1342,10 +1314,6 @@ const ruRU = {
backToWorkbench: 'Вернуться к рабочей панели',
},
},
pluginPages: {
selectFromSidebar: 'Выберите страницу плагина на боковой панели',
invalidPage: 'Недопустимая страница плагина',
},
};
export default ruRU;

View File

@@ -5,8 +5,6 @@ const thTH = {
installedPlugins: 'ปลั๊กอินที่ติดตั้ง',
pluginMarket: 'ตลาดปลั๊กอิน',
mcpServers: 'เซิร์ฟเวอร์ MCP',
pluginPages: 'หน้าปลั๊กอิน',
pluginPagesTooltip: 'หน้าเว็บที่จัดทำโดยปลั๊กอินที่ติดตั้ง',
quickStart: 'เริ่มต้นอย่างรวดเร็ว',
},
common: {
@@ -38,6 +36,7 @@ const thTH = {
delete: 'ลบ',
add: 'เพิ่ม',
select: 'เลือก',
skill: 'สกิล',
cancel: 'ยกเลิก',
submit: 'ส่ง',
error: 'ข้อผิดพลาด',
@@ -198,9 +197,6 @@ const thTH = {
string: 'สตริง',
number: 'ตัวเลข',
boolean: 'บูลีน',
object: 'อ็อบเจกต์',
objectJsonPlaceholder: '{ "type": "disabled" }',
invalidJsonObject: 'ค่าต้องเป็นอ็อบเจกต์ JSON ที่ถูกต้อง',
selectModelProvider: 'เลือกผู้ให้บริการโมเดล',
modelProviderDescription: 'กรุณากรอกชื่อโมเดลที่ผู้ให้บริการจัดเตรียมไว้',
modelManufacturer: 'ผู้ผลิตโมเดล',
@@ -487,7 +483,6 @@ const thTH = {
Command: 'คำสั่ง',
KnowledgeEngine: 'เครื่องมือความรู้',
Parser: 'ตัวแยกวิเคราะห์',
Page: 'หน้า',
},
uploadLocal: 'อัปโหลดจากเครื่อง',
debugging: 'ดีบัก',
@@ -615,10 +610,23 @@ const thTH = {
markAsReadFailed: 'ทำเครื่องหมายว่าอ่านแล้วล้มเหลว',
filterByComponent: 'ส่วนประกอบ',
allComponents: 'ส่วนประกอบทั้งหมด',
filterByType: 'ประเภท',
allTypes: 'ทุกประเภท',
typePlugin: 'ปลั๊กอิน',
typeMCP: 'MCP',
typeSkill: 'สกิล',
requestPlugin: 'ขอปลั๊กอิน',
viewDetails: 'ดูรายละเอียด',
deprecated: 'เลิกใช้แล้ว',
deprecatedTooltip: 'กรุณาติดตั้งปลั๊กอินเครื่องมือความรู้ที่เกี่ยวข้อง',
filters: {
allFormats: 'ทุกรูปแบบ',
more: 'เพิ่มเติม',
advancedTitle: 'ตัวกรองขั้นสูง',
advancedDescription: 'กรองตามประเภทส่วนขยาย',
technicalType: 'ประเภทเทคนิค',
},
allExtensions: 'ส่วนขยายทั้งหมด',
tags: {
filterByTags: 'กรองตามแท็ก',
selected: 'เลือกแล้ว',
@@ -1205,41 +1213,6 @@ const thTH = {
feedback: 'ความคิดเห็นผู้ใช้',
},
},
storageAnalysis: {
title: 'วิเคราะห์พื้นที่จัดเก็บ',
description: 'ตรวจสอบการใช้พื้นที่จัดเก็บและรายการที่สามารถล้างได้',
openDialog: 'ดูการวิเคราะห์',
dialogTitle: 'วิเคราะห์พื้นที่จัดเก็บ',
generatedAt: 'สร้างเมื่อ {{time}}',
loading: 'กำลังโหลด...',
refresh: 'รีเฟรช',
totalSize: 'ขนาดรวม',
binaryStorage: 'พื้นที่จัดเก็บไบนารีของปลั๊กอิน',
uploadCleanup: 'ไฟล์อัปโหลดที่หมดอายุ',
logCleanup: 'บันทึกที่หมดอายุ',
sections: 'ส่วนพื้นที่จัดเก็บ',
monitoringTables: 'ตารางการตรวจสอบ',
runtimeTasks: 'งาน runtime',
cleanupPolicy: 'นโยบายการล้างข้อมูล',
uploadRetention: 'ระยะเวลาเก็บไฟล์อัปโหลด',
logRetention: 'ระยะเวลาเก็บบันทึก',
databaseType: 'ชนิดฐานข้อมูล',
days: 'วัน',
missing: 'ไม่มี',
expiredUploads: 'ไฟล์อัปโหลดที่หมดอายุ',
expiredLogs: 'บันทึกที่หมดอายุ',
noExpiredUploads: 'ไม่มีไฟล์อัปโหลดที่หมดอายุ',
noExpiredLogs: 'ไม่มีบันทึกที่หมดอายุ',
sectionNames: {
database: 'ฐานข้อมูล',
logs: 'บันทึก',
storage: 'ไฟล์อัปโหลด',
vector_store: 'คลังเวกเตอร์',
plugins: 'ปลั๊กอิน',
mcp: 'MCP',
temp: 'ไฟล์ชั่วคราว',
},
},
limitation: {
maxBotsReached:
'จำนวน Bot สูงสุด ({{max}}) ถึงขีดจำกัดแล้ว กรุณาลบ Bot ที่มีอยู่ก่อนสร้างใหม่',
@@ -1311,10 +1284,6 @@ const thTH = {
backToWorkbench: 'กลับไปหน้าทำงาน',
},
},
pluginPages: {
selectFromSidebar: 'เลือกหน้าปลั๊กอินจากแถบด้านข้าง',
invalidPage: 'หน้าปลั๊กอินไม่ถูกต้อง',
},
};
export default thTH;

View File

@@ -5,9 +5,6 @@ const viVN = {
installedPlugins: 'Plugin đã cài đặt',
pluginMarket: 'Chợ ứng dụng',
mcpServers: 'Máy chủ MCP',
pluginPages: 'Trang plugin',
pluginPagesTooltip:
'Các trang trực quan được cung cấp bởi plugin đã cài đặt',
quickStart: 'Bắt đầu nhanh',
},
common: {
@@ -39,6 +36,7 @@ const viVN = {
delete: 'Xóa',
add: 'Thêm',
select: 'Chọn',
skill: 'Kỹ năng',
cancel: 'Hủy',
submit: 'Gửi',
error: 'Lỗi',
@@ -202,9 +200,6 @@ const viVN = {
string: 'Chuỗi',
number: 'Số',
boolean: 'Boolean',
object: 'Đối tượng',
objectJsonPlaceholder: '{ "type": "disabled" }',
invalidJsonObject: 'Giá trị phải là một đối tượng JSON hợp lệ',
selectModelProvider: 'Chọn nhà cung cấp mô hình',
modelProviderDescription:
'Vui lòng điền tên mô hình do nhà cung cấp cung cấp',
@@ -498,7 +493,6 @@ const viVN = {
Command: 'Lệnh',
KnowledgeEngine: 'Công cụ tri thức',
Parser: 'Trình phân tích',
Page: 'Trang',
},
uploadLocal: 'Tải lên cục bộ',
debugging: 'Gỡ lỗi',
@@ -628,10 +622,23 @@ const viVN = {
markAsReadFailed: 'Đánh dấu đã đọc thất bại',
filterByComponent: 'Thành phần',
allComponents: 'Tất cả thành phần',
filterByType: 'Loại',
allTypes: 'Tất cả loại',
typePlugin: 'Plugin',
typeMCP: 'MCP',
typeSkill: 'Kỹ năng',
requestPlugin: 'Yêu cầu Plugin',
viewDetails: 'Xem chi tiết',
deprecated: 'Không còn hỗ trợ',
deprecatedTooltip: 'Vui lòng cài đặt plugin Công cụ tri thức tương ứng.',
filters: {
allFormats: 'Tất cả định dạng',
more: 'Thêm',
advancedTitle: 'Bộ lọc nâng cao',
advancedDescription: 'Lọc theo loại phần mở rộng',
technicalType: 'Loại kỹ thuật',
},
allExtensions: 'Tất cả phần mở rộng',
tags: {
filterByTags: 'Lọc theo thẻ',
selected: 'đã chọn',
@@ -1227,41 +1234,6 @@ const viVN = {
feedback: 'Phản hồi người dùng',
},
},
storageAnalysis: {
title: 'Phân tích lưu trữ',
description: 'Kiểm tra dung lượng lưu trữ và các mục có thể dọn dẹp',
openDialog: 'Xem phân tích',
dialogTitle: 'Phân tích lưu trữ',
generatedAt: 'Tạo lúc {{time}}',
loading: 'Đang tải...',
refresh: 'Làm mới',
totalSize: 'Tổng dung lượng',
binaryStorage: 'Lưu trữ nhị phân plugin',
uploadCleanup: 'Tệp tải lên hết hạn',
logCleanup: 'Nhật ký hết hạn',
sections: 'Khu vực lưu trữ',
monitoringTables: 'Bảng giám sát',
runtimeTasks: 'Tác vụ runtime',
cleanupPolicy: 'Chính sách dọn dẹp',
uploadRetention: 'Thời gian giữ tệp tải lên',
logRetention: 'Thời gian giữ nhật ký',
databaseType: 'Loại cơ sở dữ liệu',
days: 'ngày',
missing: 'Thiếu',
expiredUploads: 'Tệp tải lên hết hạn',
expiredLogs: 'Nhật ký hết hạn',
noExpiredUploads: 'Không có tệp tải lên hết hạn',
noExpiredLogs: 'Không có nhật ký hết hạn',
sectionNames: {
database: 'Cơ sở dữ liệu',
logs: 'Nhật ký',
storage: 'Tệp tải lên',
vector_store: 'Kho vector',
plugins: 'Plugin',
mcp: 'MCP',
temp: 'Tệp tạm',
},
},
limitation: {
maxBotsReached:
'Đã đạt số lượng Bot tối đa ({{max}}). Vui lòng xóa một Bot hiện có trước khi tạo mới.',
@@ -1333,10 +1305,6 @@ const viVN = {
backToWorkbench: 'Quay lại bàn làm việc',
},
},
pluginPages: {
selectFromSidebar: 'Chọn một trang plugin từ thanh bên',
invalidPage: 'Trang plugin không hợp lệ',
},
};
export default viVN;

View File

@@ -5,8 +5,6 @@ const zhHans = {
installedPlugins: '已安装插件',
pluginMarket: '插件市场',
mcpServers: 'MCP 服务器',
pluginPages: '插件页面',
pluginPagesTooltip: '由已安装的插件提供的可视化页面',
quickStart: '快速开始向导',
},
common: {
@@ -37,6 +35,7 @@ const zhHans = {
delete: '删除',
add: '添加',
select: '请选择',
skill: '技能',
cancel: '取消',
submit: '提交',
error: '错误',
@@ -192,9 +191,6 @@ const zhHans = {
string: '字符串',
number: '数字',
boolean: '布尔值',
object: '对象',
objectJsonPlaceholder: '{ "type": "disabled" }',
invalidJsonObject: '值必须是有效的 JSON 对象',
selectModelProvider: '选择模型供应商',
modelProviderDescription: '请填写供应商向您提供的模型名称',
modelManufacturer: '模型厂商',
@@ -470,7 +466,6 @@ const zhHans = {
Command: '命令',
KnowledgeEngine: '知识引擎',
Parser: '解析器',
Page: '页面',
},
uploadLocal: '本地上传',
debugging: '调试中',
@@ -596,6 +591,11 @@ const zhHans = {
markAsReadFailed: '标记为已读失败',
filterByComponent: '组件',
allComponents: '全部组件',
filterByType: '类型',
allTypes: '全部类型',
typePlugin: '插件',
typeMCP: 'MCP',
typeSkill: '技能',
requestPlugin: '请求插件',
tags: {
filterByTags: '按标签筛选',
@@ -604,6 +604,14 @@ const zhHans = {
clearAll: '清空',
noTags: '暂无标签',
},
filters: {
allFormats: '全部格式',
more: '更多',
advancedTitle: '高级筛选',
advancedDescription: '按扩展类型筛选',
technicalType: '技术类型',
},
allExtensions: '全部扩展',
viewDetails: '查看详情',
deprecated: '已弃用',
deprecatedTooltip: '请安装对应「知识引擎」插件',
@@ -1171,41 +1179,6 @@ const zhHans = {
feedback: '用户反馈',
},
},
storageAnalysis: {
title: '存储分析',
description: '查看存储占用和可清理文件',
openDialog: '查看分析',
dialogTitle: '存储分析',
generatedAt: '生成时间 {{time}}',
loading: '加载中...',
refresh: '刷新',
totalSize: '总占用',
binaryStorage: '插件二进制存储',
uploadCleanup: '过期上传文件',
logCleanup: '过期日志',
sections: '存储分区',
monitoringTables: '监控表',
runtimeTasks: '运行任务',
cleanupPolicy: '清理策略',
uploadRetention: '上传文件保留',
logRetention: '日志保留',
databaseType: '数据库类型',
days: '天',
missing: '不存在',
expiredUploads: '过期上传文件',
expiredLogs: '过期日志',
noExpiredUploads: '暂无过期上传文件',
noExpiredLogs: '暂无过期日志',
sectionNames: {
database: '数据库',
logs: '日志',
storage: '上传文件',
vector_store: '向量库',
plugins: '插件',
mcp: 'MCP',
temp: '临时文件',
},
},
limitation: {
maxBotsReached:
'已达到机器人数量上限({{max}}个)。请先删除已有机器人后再创建新的。',
@@ -1274,10 +1247,6 @@ const zhHans = {
backToWorkbench: '返回工作台',
},
},
pluginPages: {
selectFromSidebar: '从侧边栏选择一个插件页面',
invalidPage: '无效的插件页面',
},
};
export default zhHans;

View File

@@ -5,8 +5,6 @@ const zhHant = {
installedPlugins: '已安裝外掛',
pluginMarket: '外掛市場',
mcpServers: 'MCP 伺服器',
pluginPages: '插件頁面',
pluginPagesTooltip: '由已安裝的插件提供的視覺化頁面',
quickStart: '快速開始',
},
common: {
@@ -37,6 +35,7 @@ const zhHant = {
delete: '刪除',
add: '新增',
select: '請選擇',
skill: '技能',
cancel: '取消',
submit: '提交',
error: '錯誤',
@@ -192,9 +191,6 @@ const zhHant = {
string: '字串',
number: '數字',
boolean: '布林值',
object: '物件',
objectJsonPlaceholder: '{ "type": "disabled" }',
invalidJsonObject: '值必須是有效的 JSON 物件',
selectModelProvider: '選擇模型供應商',
modelProviderDescription: '請填寫供應商向您提供的模型名稱',
modelManufacturer: '模型廠商',
@@ -471,7 +467,6 @@ const zhHant = {
Command: '命令',
KnowledgeEngine: '知識引擎',
Parser: '解析器',
Page: '擴展頁',
},
uploadLocal: '本地上傳',
debugging: '調試中',
@@ -596,6 +591,11 @@ const zhHant = {
markAsReadFailed: '標記為已讀失敗',
filterByComponent: '組件',
allComponents: '全部組件',
filterByType: '類型',
allTypes: '全部類型',
typePlugin: '插件',
typeMCP: 'MCP',
typeSkill: '技能',
requestPlugin: '請求插件',
tags: {
filterByTags: '按標籤篩選',
@@ -604,6 +604,14 @@ const zhHant = {
clearAll: '清空',
noTags: '暫無標籤',
},
filters: {
allFormats: '全部格式',
more: '更多',
advancedTitle: '高級篩選',
advancedDescription: '按擴展類型篩選',
technicalType: '技術類型',
},
allExtensions: '全部擴展',
viewDetails: '查看詳情',
deprecated: '已棄用',
deprecatedTooltip: '請安裝對應「知識引擎」插件',
@@ -1171,41 +1179,6 @@ const zhHant = {
feedback: '使用者回饋',
},
},
storageAnalysis: {
title: '儲存分析',
description: '查看儲存占用和可清理檔案',
openDialog: '查看分析',
dialogTitle: '儲存分析',
generatedAt: '生成時間 {{time}}',
loading: '載入中...',
refresh: '重新整理',
totalSize: '總占用',
binaryStorage: '插件二進位儲存',
uploadCleanup: '過期上傳檔案',
logCleanup: '過期日誌',
sections: '儲存分區',
monitoringTables: '監控表',
runtimeTasks: '執行任務',
cleanupPolicy: '清理策略',
uploadRetention: '上傳檔案保留',
logRetention: '日誌保留',
databaseType: '資料庫類型',
days: '天',
missing: '不存在',
expiredUploads: '過期上傳檔案',
expiredLogs: '過期日誌',
noExpiredUploads: '暫無過期上傳檔案',
noExpiredLogs: '暫無過期日誌',
sectionNames: {
database: '資料庫',
logs: '日誌',
storage: '上傳檔案',
vector_store: '向量庫',
plugins: '插件',
mcp: 'MCP',
temp: '暫存檔案',
},
},
limitation: {
maxBotsReached:
'已達到機器人數量上限({{max}}個)。請先刪除已有機器人後再建立新的。',
@@ -1274,10 +1247,6 @@ const zhHant = {
backToWorkbench: '返回工作台',
},
},
pluginPages: {
selectFromSidebar: '從側邊欄選擇一個插件頁面',
invalidPage: '無效的插件頁面',
},
};
export default zhHant;

View File

@@ -21,7 +21,6 @@ import PluginsPage from '@/app/home/plugins/page';
import MarketPage from '@/app/home/market/page';
import MCPPage from '@/app/home/mcp/page';
import KnowledgePage from '@/app/home/knowledge/page';
import PluginPagesPage from '@/app/home/plugin-pages/page';
const Loading = () => <div>Loading...</div>;
@@ -142,14 +141,4 @@ export const router = createBrowserRouter([
</Suspense>
),
},
{
path: '/home/plugin-pages',
element: (
<Suspense fallback={<Loading />}>
<HomeLayout>
<PluginPagesPage />
</HomeLayout>
</Suspense>
),
},
]);