mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-06 22:06:03 +00:00
Compare commits
26 Commits
v4.10.0-be
...
feat/addwe
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
07b90f12a2 | ||
|
|
fd896c6974 | ||
|
|
1fbfa868fb | ||
|
|
ad05819c2e | ||
|
|
0c6f71738c | ||
|
|
af451e7006 | ||
|
|
59f20bcc73 | ||
|
|
e5b3cced1f | ||
|
|
101e04db6d | ||
|
|
b79edda3a7 | ||
|
|
a20d3d11e5 | ||
|
|
3b4c455813 | ||
|
|
c967a2aa82 | ||
|
|
79cc6da96f | ||
|
|
fee7d48dc3 | ||
|
|
8811fb647f | ||
|
|
37b017459d | ||
|
|
4889a3881b | ||
|
|
fe4f95b9a3 | ||
|
|
a2817f6524 | ||
|
|
b9560b26ff | ||
|
|
1ad7071aa0 | ||
|
|
96b041846d | ||
|
|
4054ba2a76 | ||
|
|
c7cb42bd79 | ||
|
|
894709d577 |
9
.github/ISSUE_TEMPLATE/bug-report.yml
vendored
9
.github/ISSUE_TEMPLATE/bug-report.yml
vendored
@@ -10,6 +10,15 @@ body:
|
||||
placeholder: 例如:v3.3.0、CentOS x64 Python 3.10.3、Docker
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
attributes:
|
||||
label: 部署版本
|
||||
description: 请选择您使用的 LangBot 部署版本。
|
||||
options:
|
||||
- 社区版
|
||||
- 云服务
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: 异常情况
|
||||
|
||||
9
.github/ISSUE_TEMPLATE/bug-report_en.yml
vendored
9
.github/ISSUE_TEMPLATE/bug-report_en.yml
vendored
@@ -10,6 +10,15 @@ body:
|
||||
placeholder: "For example: v3.3.0, CentOS x64 Python 3.10.3, Docker"
|
||||
validations:
|
||||
required: true
|
||||
- type: dropdown
|
||||
attributes:
|
||||
label: Deployment version
|
||||
description: Please select the LangBot deployment version you are using.
|
||||
options:
|
||||
- Community Edition
|
||||
- Cloud Service
|
||||
validations:
|
||||
required: true
|
||||
- type: textarea
|
||||
attributes:
|
||||
label: Exception
|
||||
|
||||
16
Dockerfile
16
Dockerfile
@@ -14,10 +14,22 @@ COPY . .
|
||||
|
||||
COPY --from=node /app/web/dist ./web/dist
|
||||
|
||||
RUN apt update \
|
||||
&& apt install gcc -y \
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends gcc ca-certificates curl gnupg \
|
||||
# Install the Docker CLI (client only) so the optional langbot_box
|
||||
# service can drive the mounted host Docker socket and create sandbox
|
||||
# containers. The same image powers langbot / plugin_runtime / box; only
|
||||
# box uses the client. Arch-aware via dpkg so multi-arch builds work.
|
||||
&& install -m 0755 -d /etc/apt/keyrings \
|
||||
&& curl -fsSL https://download.docker.com/linux/debian/gpg -o /etc/apt/keyrings/docker.asc \
|
||||
&& chmod a+r /etc/apt/keyrings/docker.asc \
|
||||
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.asc] https://download.docker.com/linux/debian $(. /etc/os-release && echo \"$VERSION_CODENAME\") stable" > /etc/apt/sources.list.d/docker.list \
|
||||
&& apt-get update \
|
||||
&& apt-get install -y --no-install-recommends docker-ce-cli \
|
||||
&& python -m pip install --no-cache-dir uv \
|
||||
&& uv sync \
|
||||
&& apt-get purge -y --auto-remove curl gnupg \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& touch /.dockerenv
|
||||
|
||||
CMD [ "uv", "run", "--no-sync", "main.py" ]
|
||||
@@ -38,7 +38,7 @@ LangBot 是一个**开源的生产级平台**,用于构建 AI 驱动的即时
|
||||
|
||||
### 核心能力
|
||||
|
||||
- **AI 对话与 Agent** — 多轮对话、工具调用、多模态、流式输出。自带 RAG(知识库),深度集成 [Dify](https://dify.ai)、[Coze](https://coze.com)、[n8n](https://n8n.io)、[Langflow](https://langflow.org) 等 LLMOps 平台。
|
||||
- **AI 对话与 Agent** — 多轮对话、工具调用、多模态、流式输出。自带 RAG(知识库),深度集成 [Dify](https://dify.ai)、[Coze](https://coze.com)、[n8n](https://n8n.io)、[Langflow](https://langflow.org)、[Deerflow](https://deerflow.tech)、[Weknora](https://weknora.weixin.qq.com)等 LLMOps 平台。
|
||||
- **全平台支持** — 一套代码,覆盖 QQ、微信、企业微信、飞书、钉钉、Discord、Telegram、Slack、LINE、KOOK 等平台。
|
||||
- **生产就绪** — 访问控制、限速、敏感词过滤、全面监控与异常处理,已被多家企业采用。
|
||||
- **插件生态** — 数百个插件,跨进程的事件驱动架构,组件扩展,适配 [MCP 协议](https://modelcontextprotocol.io/)。
|
||||
|
||||
@@ -37,7 +37,7 @@ LangBot es una **plataforma de código abierto y grado de producción** para con
|
||||
|
||||
### Capacidades Clave
|
||||
|
||||
- **Conversaciones e Agentes IA** — Diálogos de múltiples turnos, llamadas a herramientas, soporte multimodal, salida en streaming. RAG (base de conocimientos) incorporado con integración profunda con [Dify](https://dify.ai), [Coze](https://coze.com), [n8n](https://n8n.io), [Langflow](https://langflow.org).
|
||||
- **Conversaciones e Agentes IA** — Diálogos de múltiples turnos, llamadas a herramientas, soporte multimodal, salida en streaming. RAG (base de conocimientos) incorporado con integración profunda con [Dify](https://dify.ai), [Coze](https://coze.com), [n8n](https://n8n.io), [Langflow](https://langflow.org), [Deerflow](https://deerflow.tech)、[Weknora](https://weknora.weixin.qq.com).
|
||||
- **Soporte Universal de Plataformas de MI** — Un solo código base para Discord, Telegram, Slack, LINE, QQ, WeChat, WeCom, Lark, DingTalk, KOOK.
|
||||
- **Listo para Producción** — Control de acceso, limitación de velocidad, filtrado de palabras sensibles, monitoreo completo y manejo de excepciones. De confianza para empresas.
|
||||
- **Ecosistema de Plugins** — Cientos de plugins, arquitectura basada en eventos, extensiones de componentes y soporte del [protocolo MCP](https://modelcontextprotocol.io/).
|
||||
|
||||
@@ -37,7 +37,7 @@ LangBot est une **plateforme open-source de niveau production** pour créer des
|
||||
|
||||
### Capacités Clés
|
||||
|
||||
- **Conversations IA & Agents** — Dialogues multi-tours, appels d'outils, support multimodal, sortie en streaming. RAG (base de connaissances) intégré avec intégration profonde de [Dify](https://dify.ai), [Coze](https://coze.com), [n8n](https://n8n.io), [Langflow](https://langflow.org).
|
||||
- **Conversations IA & Agents** — Dialogues multi-tours, appels d'outils, support multimodal, sortie en streaming. RAG (base de connaissances) intégré avec intégration profonde de [Dify](https://dify.ai), [Coze](https://coze.com), [n8n](https://n8n.io), [Langflow](https://langflow.org), [Deerflow](https://deerflow.tech), [Weknora](https://weknora.weixin.qq.com).
|
||||
- **Support Universel des Plateformes de MI** — Un seul code pour Discord, Telegram, Slack, LINE, QQ, WeChat, WeCom, Lark, DingTalk, KOOK.
|
||||
- **Prêt pour la Production** — Contrôle d'accès, limitation de débit, filtrage de mots sensibles, surveillance complète et gestion des exceptions. Approuvé par les entreprises.
|
||||
- **Écosystème de Plugins** — Des centaines de plugins, architecture événementielle, extensions de composants, et support du [protocole MCP](https://modelcontextprotocol.io/).
|
||||
|
||||
@@ -37,7 +37,7 @@ LangBot は、AI搭載のインスタントメッセージングボットを構
|
||||
|
||||
### 主な機能
|
||||
|
||||
- **AI対話とエージェント** — マルチターン対話、ツール呼び出し、マルチモーダル対応、ストリーミング出力。RAG(ナレッジベース)を内蔵し、[Dify](https://dify.ai)、[Coze](https://coze.com)、[n8n](https://n8n.io)、[Langflow](https://langflow.org) と深く統合。
|
||||
- **AI対話とエージェント** — マルチターン対話、ツール呼び出し、マルチモーダル対応、ストリーミング出力。RAG(ナレッジベース)を内蔵し、[Dify](https://dify.ai)、[Coze](https://coze.com)、[n8n](https://n8n.io)、[Langflow](https://langflow.org)、[Deerflow](https://deerflow.tech)、[Weknora](https://weknora.weixin.qq.com) と深く統合。
|
||||
- **ユニバーサルIMプラットフォーム対応** — 単一のコードベースで Discord、Telegram、Slack、LINE、QQ、WeChat、WeCom、Lark、DingTalk、KOOK に対応。
|
||||
- **本番環境対応** — アクセス制御、レート制限、センシティブワードフィルタリング、包括的な監視、例外処理を搭載。エンタープライズの信頼に応える品質。
|
||||
- **プラグインエコシステム** — 数百のプラグイン、イベント駆動アーキテクチャ、コンポーネント拡張、[MCPプロトコル](https://modelcontextprotocol.io/)対応。
|
||||
|
||||
@@ -37,7 +37,7 @@ LangBot은 AI 기반 인스턴트 메시징 봇을 구축하기 위한 **오픈
|
||||
|
||||
### 핵심 기능
|
||||
|
||||
- **AI 대화 및 에이전트** — 멀티턴 대화, 도구 호출, 멀티모달 지원, 스트리밍 출력. 내장 RAG(지식 베이스)와 [Dify](https://dify.ai), [Coze](https://coze.com), [n8n](https://n8n.io), [Langflow](https://langflow.org) 심층 통합.
|
||||
- **AI 대화 및 에이전트** — 멀티턴 대화, 도구 호출, 멀티모달 지원, 스트리밍 출력. 내장 RAG(지식 베이스)와 [Dify](https://dify.ai), [Coze](https://coze.com), [n8n](https://n8n.io), [Langflow](https://langflow.org), [Deerflow](https://deerflow.tech), [Weknora](https://weknora.weixin.qq.com) 심층 통합.
|
||||
- **유니버설 IM 플랫폼 지원** — 단일 코드베이스로 Discord, Telegram, Slack, LINE, QQ, WeChat, WeCom, Lark, DingTalk, KOOK 지원.
|
||||
- **프로덕션 레디** — 접근 제어, 속도 제한, 민감어 필터링, 종합 모니터링 및 예외 처리. 기업 환경에서 검증됨.
|
||||
- **플러그인 생태계** — 수백 개의 플러그인, 이벤트 기반 아키텍처, 컴포넌트 확장, [MCP 프로토콜](https://modelcontextprotocol.io/) 지원.
|
||||
|
||||
@@ -37,7 +37,7 @@ LangBot — это **платформа с открытым исходным к
|
||||
|
||||
### Ключевые возможности
|
||||
|
||||
- **ИИ-диалоги и агенты** — Многораундовые диалоги, вызов инструментов, мультимодальная поддержка, потоковый вывод. Встроенная реализация RAG (база знаний) с глубокой интеграцией в [Dify](https://dify.ai), [Coze](https://coze.com), [n8n](https://n8n.io), [Langflow](https://langflow.org).
|
||||
- **ИИ-диалоги и агенты** — Многораундовые диалоги, вызов инструментов, мультимодальная поддержка, потоковый вывод. Встроенная реализация RAG (база знаний) с глубокой интеграцией в [Dify](https://dify.ai), [Coze](https://coze.com), [n8n](https://n8n.io), [Langflow](https://langflow.org), [Deerflow](https://deerflow.tech), [Weknora](https://weknora.weixin.qq.com).
|
||||
- **Универсальная поддержка IM-платформ** — Единая кодовая база для Discord, Telegram, Slack, LINE, QQ, WeChat, WeCom, Lark, DingTalk, KOOK.
|
||||
- **Готовность к продакшену** — Контроль доступа, ограничение скорости, фильтрация чувствительных слов, комплексный мониторинг и обработка исключений. Проверено в корпоративной среде.
|
||||
- **Экосистема плагинов** — Сотни плагинов, событийно-ориентированная архитектура, расширения компонентов и поддержка [протокола MCP](https://modelcontextprotocol.io/).
|
||||
|
||||
@@ -39,7 +39,7 @@ LangBot 是一個**開源的生產級平台**,用於建構 AI 驅動的即時
|
||||
|
||||
### 核心能力
|
||||
|
||||
- **AI 對話與 Agent** — 多輪對話、工具調用、多模態、流式輸出。自帶 RAG(知識庫),深度整合 [Dify](https://dify.ai)、[Coze](https://coze.com)、[n8n](https://n8n.io)、[Langflow](https://langflow.org) 等 LLMOps 平台。
|
||||
- **AI 對話與 Agent** — 多輪對話、工具調用、多模態、流式輸出。自帶 RAG(知識庫),深度整合 [Dify](https://dify.ai)、[Coze](https://coze.com)、[n8n](https://n8n.io)、[Langflow](https://langflow.org)、 [Deerflow](https://deerflow.tech)、[Weknora](https://weknora.weixin.qq.com)等 LLMOps 平台。
|
||||
- **全平台支援** — 一套程式碼,覆蓋 QQ、微信、企業微信、飛書、釘釘、Discord、Telegram、Slack、LINE、KOOK 等平台。
|
||||
- **生產就緒** — 存取控制、限速、敏感詞過濾、全面監控與異常處理,已被多家企業採用。
|
||||
- **外掛生態** — 數百個外掛,事件驅動架構,組件擴展,適配 [MCP 協議](https://modelcontextprotocol.io/)。
|
||||
|
||||
@@ -37,7 +37,7 @@ LangBot là một **nền tảng mã nguồn mở, cấp sản xuất** để x
|
||||
|
||||
### Khả năng chính
|
||||
|
||||
- **Hội thoại AI & Agent** — Đối thoại nhiều lượt, gọi công cụ, hỗ trợ đa phương thức, đầu ra streaming. RAG (cơ sở kiến thức) tích hợp sẵn với tích hợp sâu vào [Dify](https://dify.ai), [Coze](https://coze.com), [n8n](https://n8n.io), [Langflow](https://langflow.org).
|
||||
- **Hội thoại AI & Agent** — Đối thoại nhiều lượt, gọi công cụ, hỗ trợ đa phương thức, đầu ra streaming. RAG (cơ sở kiến thức) tích hợp sẵn với tích hợp sâu vào [Dify](https://dify.ai), [Coze](https://coze.com), [n8n](https://n8n.io), [Langflow](https://langflow.org), [Deerflow](https://deerflow.tech), [Weknora](https://weknora.weixin.qq.com).
|
||||
- **Hỗ trợ đa nền tảng IM** — Một mã nguồn cho Discord, Telegram, Slack, LINE, QQ, WeChat, WeCom, Lark, DingTalk, KOOK.
|
||||
- **Sẵn sàng cho sản xuất** — Kiểm soát truy cập, giới hạn tốc độ, lọc từ nhạy cảm, giám sát toàn diện và xử lý ngoại lệ. Được doanh nghiệp tin dùng.
|
||||
- **Hệ sinh thái Plugin** — Hàng trăm plugin, kiến trúc hướng sự kiện, mở rộng thành phần, và hỗ trợ [giao thức MCP](https://modelcontextprotocol.io/).
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
# Box 系统架构深度分析
|
||||
|
||||
> 更新日期: 2026-05-19
|
||||
> 更新日期: 2026-06-02
|
||||
> 状态更新: 自部署社区版已具备发布条件(box 可选、降级完善、无迁移欠债);工具调用循环上限、配额遍历异步化、`host_path` 挂载白名单等已落地。剩余多租户 / 安全硬化项见 [SaaS 阻塞项清单](./box-issues.md)。
|
||||
> 分支: `feat/sandbox` (LangBot + langbot-plugin-sdk)
|
||||
> 相关文档: [问题清单](./box-issues.md) | [Session 作用域](./box-session-scope.md) | [Runtime 对比](./box-vs-plugin-runtime.md) | [测试覆盖](./box-test-coverage.md) | [toB 分析](./box-tob-analysis.md)
|
||||
> 相关文档: [SaaS 阻塞项](./box-issues.md) | [Session 作用域](./box-session-scope.md) | [Runtime 对比](./box-vs-plugin-runtime.md) | [测试覆盖](./box-test-coverage.md) | [toB 分析](./box-tob-analysis.md)
|
||||
|
||||
---
|
||||
|
||||
@@ -163,7 +164,7 @@ BoxService
|
||||
|
||||
### 2.4 policy.py (`pkg/box/policy.py`, 98 行) — 仍是死代码
|
||||
|
||||
三层安全策略设计(`SandboxPolicy` / `ToolPolicy` / `ElevatedPolicy`),全项目无任何导入或调用。详见 [问题清单 #1](./box-issues.md)。
|
||||
三层安全策略设计(`SandboxPolicy` / `ToolPolicy` / `ElevatedPolicy`),全项目无任何导入或调用。详见 [SaaS 阻塞项 S2](./box-issues.md)。
|
||||
|
||||
### 2.5 SkillManager (`pkg/skill/manager.py`, 186 行)
|
||||
|
||||
@@ -364,7 +365,7 @@ GitHub 安装路径:HTTP 层(`api/http/service/skill.py`)先 `git clone`
|
||||
|
||||
`validate_sandbox_security()`: 黑名单校验 host_path,阻止挂载 `/etc`/`/proc`/`/sys`/`/dev`/`/root`/`/boot` 及 Docker/Podman socket。
|
||||
|
||||
**已知缺陷**: 根路径 `/` 未拦截,用户 home 目录未拦截,是 denylist 而非 allowlist 策略。详见 [问题清单 #5](./box-issues.md)。
|
||||
**已知缺陷**: 根路径 `/` 未拦截,用户 home 目录未拦截,是 denylist 而非 allowlist 策略。详见 [SaaS 阻塞项 S5](./box-issues.md)。
|
||||
|
||||
### 3.9 Errors (`box/errors.py`, 33 行)
|
||||
|
||||
@@ -512,7 +513,7 @@ box:
|
||||
# - skill 列表/读取保持只读可用
|
||||
# BOX__ENABLED 环境变量可覆盖(统一约定)
|
||||
backend: 'local' # 'local' (探测) / 'docker' / 'nsjail' / 'e2b'
|
||||
# BOX_BACKEND 环境变量优先级更高
|
||||
# 由 box.backend / BOX__BACKEND 选择后端
|
||||
runtime:
|
||||
endpoint: '' # 外部 Runtime 的 WS 基地址 'ws://host:5410'
|
||||
# 留空 = 本地自管 Runtime
|
||||
|
||||
@@ -1,157 +1,76 @@
|
||||
# Box 系统架构问题清单
|
||||
# Box 系统 — SaaS 发布前阻塞项
|
||||
|
||||
> 更新日期: 2026-05-19
|
||||
> 更新日期: 2026-06-02
|
||||
> 分支: `feat/sandbox` (LangBot + langbot-plugin-sdk)
|
||||
> 相关文档: [架构分析](./box-architecture.md) | [Session 作用域](./box-session-scope.md) | [Runtime 对比](./box-vs-plugin-runtime.md) | [测试覆盖](./box-test-coverage.md) | [toB 分析](./box-tob-analysis.md)
|
||||
|
||||
## 范围说明
|
||||
|
||||
**自部署社区版已具备发布条件**:默认 stdio 模式、box 为可选项;box 关闭 / 不可用时后端、前端、工具、skill、stdio-MCP 均能干净降级(清晰报错、不崩溃);配置向后兼容(旧 `data/config.yaml` 可直接启动);无新增 ORM 模型、无迁移欠债;市场安装失败不会破坏实例。CI 全绿。
|
||||
|
||||
本清单**只保留发布 SaaS / 多租户 / 公网暴露前必须处理的阻塞项**。社区版(可信、单运营者、内网)不受这些项阻塞——它们的风险面在"不可信调用方能直接触达 Box 控制面"或"多租户共享资源"的场景才成立。
|
||||
|
||||
## 已解决(社区版发布前)
|
||||
|
||||
| 项 | 处理 |
|
||||
|----|------|
|
||||
| 工具调用循环无上限 (原 #13) | `localagent.py` 增加 `MAX_TOOL_CALL_ROUNDS=128`,超限优雅终止(`cafef1a3`) |
|
||||
| 配额校验同步遍历阻塞事件循环 (原 #10) | `_enforce_workspace_quota` 改 async,工作区遍历走 `asyncio.to_thread`(`cafef1a3`) |
|
||||
| `host_path` 挂载白名单 (原 #3 的 LangBot 侧) | `pkg/box/service.py` `allowed_mount_roots` 白名单,空列表时拒绝一切宿主挂载 |
|
||||
| 重复的 `_is_path_under` (原 #12) | 已去重,仅保留一处定义 |
|
||||
| 重连 / 心跳 / Windows 兼容 / nsjail image 字段 / 前端 Box 状态接入 | 见上一轮 review 记录,均已合入 |
|
||||
|
||||
---
|
||||
|
||||
## 已解决(自上一轮 review)
|
||||
## SaaS 阻塞项
|
||||
|
||||
下列原 P0/P1 项在最新分支已被修复,仅作记录:
|
||||
### S1. Box 控制面无认证 — Critical
|
||||
|
||||
| 原编号 | 问题 | 处理 commit / 说明 |
|
||||
|--------|------|---------------------|
|
||||
| #3 | Box 无重连机制 | `_make_connection_callback` 已接入 `runtime_disconnect_callback`;`BoxService._reconnect_loop()` 实现指数退避重连 (`2dfd9d5d`、`c6882cf`) |
|
||||
| #4 | Box 无心跳 | `BoxRuntimeConnector._heartbeat_loop()`,间隔 20s(沿用 Plugin 模式) |
|
||||
| #10 | Windows 兼容 | connector 增加 Windows 分支 (subprocess + WS),backend 适配 Windows Docker (`120817a`、`fafb7a4`) |
|
||||
| #12 | nsjail image 字段冲突 | `_assert_session_compatible()` 在不支持自定义镜像的 backend 跳过 image 字段 |
|
||||
| #22 | 前端无 Box UI | 监控页 `SystemStatusCards.tsx` 已接入 `/api/v1/box/status`;Skill 管理页接入了全部 skill API(sessions/errors API 仍未接入) |
|
||||
- **位置**: SDK `box/server.py` — Action RPC WS (`/rpc/ws`) 与 managed-process relay (`/v1/sessions/{id}/managed-process/{pid}/ws`)
|
||||
- **现状**: 两个 WS handler 在 `ws.prepare` 后直接服务,无任何 token / 鉴权;box 默认绑定 `0.0.0.0:5410`。任何能触达该端口者可发起 `EXEC`、创建 session、attach 任意 session 的 managed-process stdin/stdout、甚至 `SHUTDOWN`。LangBot→box 的 INIT 也未下发任何凭证。
|
||||
- **缓解现状**: 默认 `docker-compose.yaml` 的 `langbot_box` 未把 5410 发布到宿主(爆炸半径限于内网 bridge);但 box 挂载了 `/var/run/docker.sock`,同网络的任意服务(含被攻破的插件)→ 宿主 root。若运营者把 5410 发布到宿主或独立以 `0.0.0.0` 起 box,则完全裸奔。
|
||||
- **要求**: INIT 时下发 token,两个 WS 路由按连接校验(query/header)。这是 SaaS 的**头号**阻塞项。
|
||||
|
||||
---
|
||||
### S2. 无 exec 授权模型(policy.py 死代码) — High
|
||||
|
||||
## P0 — 合并前建议修复
|
||||
- **位置**: LangBot `pkg/box/policy.py`(`SandboxPolicy` / `ToolPolicy` / `ElevatedPolicy` 全项目无引用);`pkg/provider/tools/loaders/native.py`;`pkg/provider/tools/toolmgr.py`
|
||||
- **现状**: 原生工具(`exec/read/write/edit/glob/grep`)按"box 是否可用"全有或全无地暴露,**无 per-pipeline 的 exec 网关 / 工具白名单 / 沙箱模式 / 权限提升控制**。只要 box 可用,任何使用 local-agent + 函数调用模型的 pipeline 都能跑任意 shell。
|
||||
- **要求**: 接入 policy.py(或等价机制),按 pipeline 控制是否暴露 `exec`、可用工具白名单、沙箱网络/只读模式。
|
||||
|
||||
### 1. policy.py 是死代码
|
||||
### S3. 会话资源无界(DoS) — High
|
||||
|
||||
- **位置**: `pkg/box/policy.py` (98 行)
|
||||
- **现状**: `SandboxPolicy`、`ToolPolicy`、`ElevatedPolicy` 三个类已定义,但全项目无任何导入或调用
|
||||
- **影响**: 三层安全策略(沙箱模式 / 工具白名单 / 权限提升)完全未生效。当前实际策略仍是"Box 可用就暴露全部 6 个 native tool,不可用就全部隐藏"
|
||||
- **建议**: 要么删除死代码,要么接入 NativeToolLoader 的工具暴露 / exec 调用链。如果短期不会接入,至少在 `pkg/box/__init__.py` 显式标注其状态
|
||||
- **#5 session 数量无上限**: SDK `box/runtime.py` `_get_or_create_session` 的 `_sessions` dict 无容量限制——可变 `session_id` 的恶意调用可无限创建容器,耗尽宿主 CPU/内存/PID/磁盘。
|
||||
- **#8 无定时回收**: 过期 session 仅在 `_get_or_create_session` 时机会性清理,无独立周期任务;一波创建后转静默会永久泄漏容器。
|
||||
- **要求**: `max_sessions` 上限(拒绝或 LRU),加独立周期 reaper(如 60s)。
|
||||
|
||||
### 2. WebSocket relay 无认证
|
||||
### S4. 工作区配额无内核级限制(TOCTOU) — Med-High
|
||||
|
||||
- **位置**: SDK `box/server.py` — Action RPC 路径 `/rpc/ws` 与 managed-process relay `/v1/sessions/{id}/managed-process/{pid}/ws`
|
||||
- **现状**: 任何能访问 5410 端口的客户端都可以连接,attach 任意 session 的 managed process stdin/stdout,或直接发起 EXEC
|
||||
- **影响**: 容器化 / Docker compose 部署中,若 Box runtime 端口外暴露,网络内的攻击者可直接控制沙箱
|
||||
- **建议**: 至少加 token 认证(INIT 时下发,WS 连接 query string 或 header 校验);多 process 后 attach 面更大,更不能裸奔
|
||||
- **位置**: LangBot `pkg/box/service.py` `_enforce_workspace_quota`(应用层 read-then-check);SDK 侧 `workspace_quota_mb` 仅记录/透传,无 `--storage-opt size=` 等内核/FS 限额
|
||||
- **现状**: 执行前后两次检查之间存在竞态窗口;单条命令(`dd`/`fallocate`)可在检查间隙撑爆磁盘,事后检查只能补救。
|
||||
- **要求**: Docker `--storage-opt size=` 做内核级限制,或 Redis 原子计数预留式配额。
|
||||
|
||||
### 3. security.py 根路径未拦截
|
||||
### S5. 挂载校验缺口 — Med-High
|
||||
|
||||
- **位置**: SDK `box/security.py` `BLOCKED_HOST_PATHS_POSIX`
|
||||
- **现状**: 黑名单中没有 `/`,`host_path="/"` 可通过校验并挂载整个主机文件系统;用户 home 目录、`/var` 等也未拦截
|
||||
- **建议**: 将 `/` 加入黑名单,或改用白名单策略与 LangBot 侧 `allowed_mount_roots` 二次拦截
|
||||
- **位置**: SDK `box/security.py` `_BLOCKED_HOST_PATHS_POSIX`;`box/backend.py` 的 `extra_mounts` 处理
|
||||
- **现状**: ① SDK 黑名单仍不含 `/`(前缀匹配,`host_path="/"` 可通过,挂载整个宿主 fs);用户 home、`/usr`、`/opt`、`/tmp` 也未拦截。② `validate_sandbox_security` 只校验 `spec.host_path`,**从不遍历 `spec.extra_mounts`**——LangBot 侧 `allowed_mount_roots` 也只校验 `host_path`。当前 `extra_mounts` 仅由 `build_skill_extra_mounts` 内部填充(agent 不可达),但缺乏纵深防御:一旦 S1 的无认证 RPC 被触达,extra_mounts 可挂任意宿主路径,两层都不拦。
|
||||
- **要求**: SDK 黑名单加入 `/`(或改白名单);`extra_mounts` 在 SDK 与 LangBot 两侧都纳入挂载校验。
|
||||
|
||||
### 4. INIT 与 backend 初始化的竞态
|
||||
### S6. 容器加固缺失 — Med
|
||||
|
||||
- **位置**: SDK `box/runtime.py` `init()` 在握手后才下发实际配置;`backend` 在 INIT 之前可能已经按默认值实例化
|
||||
- **现状**: commit `5029d9c` 修复了 "init config before backend reuse" 的部分场景,但 backend 重新实例化时若有正在执行的 session,可能命中旧 backend
|
||||
- **建议**: 整理 init/handshake 顺序——要么 INIT 完成前不接受任何业务 action,要么允许 backend 配置变更时显式清理现有 session
|
||||
- **位置**: SDK `box/backend.py` 的 `docker run` 组装
|
||||
- **现状**: 未设置 `--cap-drop=ALL`、`--security-opt=no-new-privileges`、非 root `--user`;叠加挂载 docker.sock,逃逸面偏大。
|
||||
- **要求**: 默认加上上述加固 flag(需回归常用 skill 不被破坏)。
|
||||
|
||||
---
|
||||
### S7. 全局锁内执行慢操作(扩展性) — Med
|
||||
|
||||
## P1 — 合并后优先跟进
|
||||
- **位置**: SDK `box/runtime.py` `_get_or_create_session`:`self._lock` 持有期间调用 `backend.start_session()`(`docker run` / nsjail 启动 / E2B `Sandbox.create`)
|
||||
- **影响**: 冷启动(镜像拉取数秒、E2B >1s)期间串行阻塞所有并发请求——多租户负载下整个 Box runtime 停顿。降级表现是延迟而非失败。
|
||||
- **要求**: 锁内只做状态检查与注册,容器创建移到锁外。
|
||||
|
||||
### 5. Session 数量无上限
|
||||
### S8. 其他硬化 / 跟进 — Low
|
||||
|
||||
- **位置**: SDK `box/runtime.py` `_get_or_create_session()`
|
||||
- **现状**: `_sessions` dict 无容量限制,恶意或异常调用可创建无限 session
|
||||
- **建议**: 加 `max_sessions` 配置项,达到上限时拒绝新建或按 LRU 清理
|
||||
|
||||
### 6. Quota 检查存在 TOCTOU
|
||||
|
||||
- **位置**: `pkg/box/service.py` `_enforce_workspace_quota()`
|
||||
- **现状**: 应用层先读磁盘大小再执行命令,两步之间有竞态窗口
|
||||
- **建议**: 短期用 Docker `--storage-opt size=` 做内核级限制;长期用 Redis 原子计数器做预留式配额
|
||||
|
||||
### 7. 全局锁持有期间执行慢操作
|
||||
|
||||
- **位置**: SDK `box/runtime.py` `_get_or_create_session()` — `self._lock` 下调用 `backend.start_session()` (即 `docker run` / `nsjail` 进程启动 / E2B `Sandbox.create`)
|
||||
- **影响**: `docker run` 可能耗时数秒(含镜像拉取)、E2B 冷启动通常 > 1s,期间阻塞所有并发请求
|
||||
- **建议**: 在 `_lock` 下仅做状态检查和 session 注册,容器创建在锁外执行
|
||||
|
||||
### 8. Session 清理是机会性的
|
||||
|
||||
- **位置**: SDK `box/runtime.py` `_reap_expired_sessions_locked()` — 仅在 `_get_or_create_session()` 时调用
|
||||
- **影响**: 如果长时间无新 session 请求,过期 session(含容器)不会被清理
|
||||
- **建议**: 加一个独立的 `asyncio.create_task` 定时清理(如每 60s 一次)
|
||||
|
||||
### 9. server.py 直接访问 runtime 私有字段
|
||||
|
||||
- **位置**: SDK `box/server.py` — managed-process WS handler 直接读 `runtime._sessions`
|
||||
- **影响**: 绕过锁和封装,在并发场景下可能读到不一致状态
|
||||
- **建议**: 在 BoxRuntime 上增加公共方法(如 `get_session_managed_process(session_id, process_id)`)
|
||||
|
||||
### 10. workspace quota 检查阻塞事件循环
|
||||
|
||||
- **位置**: `pkg/box/service.py` `_get_workspace_size_bytes()` — 使用同步 `os.scandir` 递归遍历
|
||||
- **影响**: 大工作区可能阻塞 asyncio event loop
|
||||
- **建议**: 用 `asyncio.to_thread()` 包装,或用 `aiofiles` 异步扫描
|
||||
|
||||
### 11. extra_mounts 一旦容器创建即固定
|
||||
|
||||
- **位置**: SDK `box/runtime.py` 的兼容性检查;`pkg/box/service.py:build_skill_extra_mounts()`
|
||||
- **现状**: Skill 挂载在容器创建时一次性写入;同一 session 后续 pipeline 切换 skill 列表时,新挂载不会生效(除非销毁重建)
|
||||
- **影响**: 用户长时间共享 session 的场景下,新激活的 skill 可能挂不上
|
||||
- **建议**: 要么在创建时把 pipeline 绑定的所有 skill 都挂上(实际现状)+ 写入文档;要么变更挂载时强制销毁 session 重建(已被 commit `5029d9c` 部分覆盖,需校验)
|
||||
|
||||
---
|
||||
|
||||
## P2 — 后续迭代
|
||||
|
||||
### 12. 重复的 `_is_path_under` 函数
|
||||
|
||||
- **位置**: `pkg/box/service.py` 行 30 附近 — 同名函数定义两次
|
||||
- **建议**: 删除重复定义
|
||||
|
||||
### 13. localagent.py 工具循环无迭代上限
|
||||
|
||||
- **位置**: `pkg/provider/runners/localagent.py` `while pending_tool_calls` 循环
|
||||
- **影响**: 恶意或混乱的 LLM 可无限产生 tool call,消耗资源
|
||||
- **建议**: 加 `max_tool_iterations` 配置项(如默认 50 次)
|
||||
|
||||
### 14. localagent.py 中的死代码
|
||||
|
||||
- **位置**: `pkg/provider/runners/localagent.py:29-35` 附近 — 旧命名 `SANDBOX_EXEC_TOOL_NAME` 和 `SANDBOX_EXEC_SYSTEM_GUIDANCE`
|
||||
- **现状**: 旧命名方案的遗留常量,从未被引用(实际使用 `EXEC_TOOL_NAME` from native.py)
|
||||
- **建议**: 删除
|
||||
|
||||
### 15. @loader_class 装饰器未使用
|
||||
|
||||
- **位置**: `pkg/provider/tools/loader.py` — `preregistered_loaders` 列表和 `@loader_class` 装饰器
|
||||
- **现状**: 各 loader 的 `@loader_class` 多数被注释掉,ToolManager 手动实例化所有 loader
|
||||
- **建议**: 要么启用装饰器自动注册,要么删除未用的机制
|
||||
|
||||
### 16. 工具名冲突风险
|
||||
|
||||
- **位置**: `pkg/provider/tools/toolmgr.py` `execute_func_call()` — 按优先级 native → plugin → mcp → skill → skill_authoring 分发
|
||||
- **影响**: 如果 plugin 或 MCP 有名为 `exec`/`read`/`write`/`edit`/`glob`/`grep`/`activate` 的工具,会被前序 loader 静默遮蔽
|
||||
- **建议**: 加命名空间前缀或冲突检测告警
|
||||
|
||||
### 17. client.py 反序列化不一致
|
||||
|
||||
- **位置**: SDK `box/client.py` — `execute()` 与其他方法对返回值的反序列化方式不统一(部分手动构造 model,部分用 `model_validate`)
|
||||
- **建议**: 统一使用 `model_validate`
|
||||
|
||||
### 18. 错误类型还原基于字符串前缀匹配
|
||||
|
||||
- **位置**: SDK `box/client.py` `_translate_action_error()`
|
||||
- **影响**: 如果 server 端错误消息格式变化,client 会回退到通用 `BoxError`,丢失类型信息
|
||||
- **建议**: 在 ActionResponse 中增加结构化的错误类型字段(如 `error_code` 枚举)
|
||||
|
||||
### 19. 前端只用到了 status
|
||||
|
||||
- **位置**: `web/src/app/home/monitoring/...` 已接入 `/api/v1/box/status`
|
||||
- **现状**: `/api/v1/box/sessions` 与 `/api/v1/box/errors` 后端可用、前端未消费
|
||||
- **建议**: 在监控页或独立 Box 详情页展示活跃 session 列表与最近错误,提升运维体感
|
||||
|
||||
### 20. skill_store 测试覆盖偏薄
|
||||
|
||||
- **位置**: SDK `tests/box/test_skill_store.py` 仅 88 行
|
||||
- **现状**: 相对 `skill_store.py` 的 647 行实现,单测覆盖度不够;GitHub 安装路径、`source_subdir` / `target_suffix` 组合、损坏 zip 的错误处理等场景未覆盖
|
||||
- **建议**: 至少补到核心 path 覆盖(preview/install/list/file CRUD 各 2~3 个 case)
|
||||
|
||||
### 21. 集成测试未进 CI
|
||||
|
||||
- **位置**: LangBot `tests/integration_tests/box/test_box_integration.py`、`test_box_mcp_integration.py`,SDK 端的 E2B 真机测试
|
||||
- **现状**: 容器实际执行、E2B 真实 sandbox、Managed process WS attach 均仅本地能跑
|
||||
- **建议**: 加一个可选的 Docker-in-Docker CI stage,或在合并前手动跑 checklist
|
||||
- **#9** SDK `box/server.py` 直接读 `runtime._sessions` 私有字段、绕过锁,并发下可能读到不一致状态——应加公共访问方法。
|
||||
- **#16** `pkg/provider/tools/toolmgr.py` `execute_func_call` 按优先级分发,plugin/MCP 若有同名 `exec/read/write/...` 工具会被静默遮蔽——应加命名空间或冲突告警。
|
||||
- **#4** SDK `box/runtime.py` INIT/handshake 与 backend 实例化的残留竞态(仅"纯远程 WS box 先启动、LangBot 后连"场景成立;stdio/compose 路径下 config 经 env 在 spawn 时已就位,无竞态)——应在 INIT 完成前拒绝业务 action。
|
||||
- **#11** `extra_mounts` 在容器创建时固定(SDK `runtime.py` 兼容性检查不含 extra_mounts);长生命周期共享 session 后续新激活的 skill 不会挂上(当前缓解:创建时挂上 pipeline 绑定的全部 skill)——动态绑定场景需销毁重建或文档说明。
|
||||
- **#21** 集成测试未进 CI:容器实际执行、E2B 真机、managed-process WS attach 仅本地可跑。安全关键路径缺自动化覆盖——SaaS 前建议加 Docker-in-Docker CI stage 或合并前手动 checklist。
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# Box Session Scope Design
|
||||
|
||||
> Date: 2026-04-18 (last reviewed 2026-05-19)
|
||||
> Date: 2026-04-18 (last reviewed 2026-06-02)
|
||||
> Status (2026-06-02): the self-hosted community edition is release-ready (box optional, clean degradation, no migration debt). Tool-call loop cap, async quota scan, and the host_path mount allowlist have landed. Remaining multi-tenant / security hardening is tracked in [box-issues.md](./box-issues.md).
|
||||
> Branch: `feat/sandbox` (LangBot + langbot-plugin-sdk)
|
||||
> Related: [Box Architecture](./box-architecture.md) | [Box vs Plugin Runtime](./box-vs-plugin-runtime.md)
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# Box 系统测试覆盖分析
|
||||
|
||||
> 更新日期: 2026-05-19
|
||||
> 更新日期: 2026-06-02
|
||||
> 状态更新: 自部署社区版已具备发布条件(box 可选、降级完善、无迁移欠债);工具调用循环上限、配额遍历异步化、`host_path` 挂载白名单等已落地。剩余多租户 / 安全硬化项见 [SaaS 阻塞项清单](./box-issues.md)。
|
||||
> 分支: `feat/sandbox` (LangBot + langbot-plugin-sdk)
|
||||
|
||||
---
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# Box 系统 toB 商业化分析
|
||||
|
||||
> 更新日期: 2026-05-19
|
||||
> 更新日期: 2026-06-02
|
||||
> 状态更新: 自部署社区版已具备发布条件(box 可选、降级完善、无迁移欠债);工具调用循环上限、配额遍历异步化、`host_path` 挂载白名单等已落地。剩余多租户 / 安全硬化项见 [SaaS 阻塞项清单](./box-issues.md)。
|
||||
> 分支: `feat/sandbox` (LangBot + langbot-plugin-sdk)
|
||||
|
||||
---
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# Box Runtime vs Plugin Runtime: 连接架构对比
|
||||
|
||||
> 更新日期: 2026-05-19
|
||||
> 更新日期: 2026-06-02
|
||||
> 状态更新: 自部署社区版已具备发布条件(box 可选、降级完善、无迁移欠债);工具调用循环上限、配额遍历异步化、`host_path` 挂载白名单等已落地。剩余多租户 / 安全硬化项见 [SaaS 阻塞项清单](./box-issues.md)。
|
||||
> 分支: `feat/sandbox` (LangBot + langbot-plugin-sdk)
|
||||
|
||||
---
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "langbot"
|
||||
version = "4.10.0-beta.1"
|
||||
version = "4.10.0"
|
||||
description = "Production-grade platform for building agentic IM bots"
|
||||
readme = "README.md"
|
||||
license-files = ["LICENSE"]
|
||||
@@ -70,7 +70,7 @@ dependencies = [
|
||||
"chromadb>=1.0.0,<2.0.0",
|
||||
"qdrant-client (>=1.15.1,<2.0.0)",
|
||||
"pyseekdb==1.1.0.post3",
|
||||
"langbot-plugin==0.4.0b1",
|
||||
"langbot-plugin==0.4.1",
|
||||
"asyncpg>=0.30.0",
|
||||
"line-bot-sdk>=3.19.0",
|
||||
"matrix-nio>=0.25.2",
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""LangBot - Production-grade platform for building agentic IM bots"""
|
||||
|
||||
__version__ = '4.10.0-beta.1'
|
||||
__version__ = '4.10.0'
|
||||
|
||||
5
src/langbot/libs/deerflow_api/__init__.py
Normal file
5
src/langbot/libs/deerflow_api/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
from .client import AsyncDeerFlowClient
|
||||
from .errors import DeerFlowAPIError
|
||||
from . import stream_utils
|
||||
|
||||
__all__ = ['AsyncDeerFlowClient', 'DeerFlowAPIError', 'stream_utils']
|
||||
204
src/langbot/libs/deerflow_api/client.py
Normal file
204
src/langbot/libs/deerflow_api/client.py
Normal file
@@ -0,0 +1,204 @@
|
||||
"""DeerFlow LangGraph HTTP API 客户端
|
||||
|
||||
参考 astrbot 的 deerflow_api_client 实现,使用 httpx 适配 LangBot 风格。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import codecs
|
||||
import json
|
||||
import typing
|
||||
from collections.abc import AsyncGenerator
|
||||
|
||||
import httpx
|
||||
|
||||
from .errors import DeerFlowAPIError
|
||||
|
||||
|
||||
SSE_MAX_BUFFER_CHARS = 1_048_576
|
||||
|
||||
|
||||
def _normalize_sse_newlines(text: str) -> str:
|
||||
"""规范化 CRLF/CR 为 LF,确保 SSE 块分割稳定"""
|
||||
return text.replace('\r\n', '\n').replace('\r', '\n')
|
||||
|
||||
|
||||
def _parse_sse_data_lines(data_lines: list[str]) -> typing.Any:
|
||||
raw_data = '\n'.join(data_lines)
|
||||
try:
|
||||
return json.loads(raw_data)
|
||||
except json.JSONDecodeError:
|
||||
# 某些 LangGraph 兼容服务端会在单个 SSE 事件中用多个 data 行
|
||||
# 发送多段 JSON 片段(例如 tuple payload)
|
||||
parsed_lines: list[typing.Any] = []
|
||||
can_parse_all = True
|
||||
for line in data_lines:
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
parsed_lines.append(json.loads(line))
|
||||
except json.JSONDecodeError:
|
||||
can_parse_all = False
|
||||
break
|
||||
if can_parse_all and parsed_lines:
|
||||
return parsed_lines[0] if len(parsed_lines) == 1 else parsed_lines
|
||||
return raw_data
|
||||
|
||||
|
||||
def _parse_sse_block(block: str) -> dict[str, typing.Any] | None:
|
||||
if not block.strip():
|
||||
return None
|
||||
|
||||
event_name = 'message'
|
||||
data_lines: list[str] = []
|
||||
for line in block.splitlines():
|
||||
if line.startswith('event:'):
|
||||
event_name = line[6:].strip()
|
||||
elif line.startswith('data:'):
|
||||
data_lines.append(line[5:].lstrip())
|
||||
|
||||
if not data_lines:
|
||||
return None
|
||||
return {'event': event_name, 'data': _parse_sse_data_lines(data_lines)}
|
||||
|
||||
|
||||
class AsyncDeerFlowClient:
|
||||
"""DeerFlow LangGraph HTTP API 客户端"""
|
||||
|
||||
api_base: str
|
||||
headers: dict[str, str]
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
api_base: str = 'http://127.0.0.1:2026',
|
||||
api_key: str = '',
|
||||
auth_header: str = '',
|
||||
) -> None:
|
||||
self.api_base = api_base.rstrip('/')
|
||||
self.headers: dict[str, str] = {}
|
||||
if auth_header:
|
||||
self.headers['Authorization'] = auth_header
|
||||
elif api_key:
|
||||
self.headers['Authorization'] = f'Bearer {api_key}'
|
||||
|
||||
async def create_thread(self, timeout: float = 20) -> dict[str, typing.Any]:
|
||||
"""创建一个新的 LangGraph thread
|
||||
|
||||
Returns:
|
||||
包含 thread_id 等信息的字典
|
||||
"""
|
||||
url = f'{self.api_base}/api/langgraph/threads'
|
||||
payload = {'metadata': {}}
|
||||
|
||||
async with httpx.AsyncClient(
|
||||
trust_env=True,
|
||||
timeout=timeout,
|
||||
) as client:
|
||||
response = await client.post(
|
||||
url,
|
||||
headers=self.headers,
|
||||
json=payload,
|
||||
)
|
||||
if response.status_code not in (200, 201):
|
||||
raise DeerFlowAPIError(
|
||||
operation='create thread',
|
||||
status=response.status_code,
|
||||
body=response.text,
|
||||
url=url,
|
||||
)
|
||||
return response.json()
|
||||
|
||||
async def delete_thread(self, thread_id: str, timeout: float = 20) -> None:
|
||||
"""删除指定 thread"""
|
||||
url = f'{self.api_base}/api/threads/{thread_id}'
|
||||
|
||||
async with httpx.AsyncClient(
|
||||
trust_env=True,
|
||||
timeout=timeout,
|
||||
) as client:
|
||||
response = await client.delete(url, headers=self.headers)
|
||||
if response.status_code not in (200, 202, 204, 404):
|
||||
raise DeerFlowAPIError(
|
||||
operation='delete thread',
|
||||
status=response.status_code,
|
||||
body=response.text,
|
||||
url=url,
|
||||
thread_id=thread_id,
|
||||
)
|
||||
|
||||
async def stream_run(
|
||||
self,
|
||||
thread_id: str,
|
||||
payload: dict[str, typing.Any],
|
||||
timeout: float = 120,
|
||||
) -> AsyncGenerator[dict[str, typing.Any], None]:
|
||||
"""运行一次 LangGraph stream 请求,逐事件 yield
|
||||
|
||||
Yields:
|
||||
事件字典 {'event': event_name, 'data': parsed_data}
|
||||
"""
|
||||
url = f'{self.api_base}/api/langgraph/threads/{thread_id}/runs/stream'
|
||||
|
||||
# 流式请求使用单独的 read timeout 控制
|
||||
stream_timeout = httpx.Timeout(
|
||||
connect=min(timeout, 30),
|
||||
read=timeout,
|
||||
write=timeout,
|
||||
pool=timeout,
|
||||
)
|
||||
|
||||
async with httpx.AsyncClient(
|
||||
trust_env=True,
|
||||
timeout=stream_timeout,
|
||||
) as client:
|
||||
async with client.stream(
|
||||
'POST',
|
||||
url,
|
||||
headers={
|
||||
**self.headers,
|
||||
'Accept': 'text/event-stream',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
json=payload,
|
||||
) as resp:
|
||||
if resp.status_code != 200:
|
||||
body = await resp.aread()
|
||||
raise DeerFlowAPIError(
|
||||
operation='runs/stream request',
|
||||
status=resp.status_code,
|
||||
body=body.decode('utf-8', errors='replace'),
|
||||
url=url,
|
||||
thread_id=thread_id,
|
||||
)
|
||||
|
||||
decoder = codecs.getincrementaldecoder('utf-8')('replace')
|
||||
buffer = ''
|
||||
|
||||
async for chunk in resp.aiter_bytes(8192):
|
||||
buffer += _normalize_sse_newlines(decoder.decode(chunk))
|
||||
|
||||
while '\n\n' in buffer:
|
||||
block, buffer = buffer.split('\n\n', 1)
|
||||
parsed = _parse_sse_block(block)
|
||||
if parsed is not None:
|
||||
yield parsed
|
||||
|
||||
if len(buffer) > SSE_MAX_BUFFER_CHARS:
|
||||
# 缓冲区过大,强制 flush
|
||||
parsed = _parse_sse_block(buffer)
|
||||
if parsed is not None:
|
||||
yield parsed
|
||||
buffer = ''
|
||||
|
||||
# flush 剩余内容
|
||||
buffer += _normalize_sse_newlines(decoder.decode(b'', final=True))
|
||||
while '\n\n' in buffer:
|
||||
block, buffer = buffer.split('\n\n', 1)
|
||||
parsed = _parse_sse_block(block)
|
||||
if parsed is not None:
|
||||
yield parsed
|
||||
if buffer.strip():
|
||||
parsed = _parse_sse_block(buffer)
|
||||
if parsed is not None:
|
||||
yield parsed
|
||||
30
src/langbot/libs/deerflow_api/errors.py
Normal file
30
src/langbot/libs/deerflow_api/errors.py
Normal file
@@ -0,0 +1,30 @@
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
class DeerFlowAPIError(Exception):
|
||||
"""DeerFlow API 请求失败"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
operation: str = '',
|
||||
status: int = 0,
|
||||
body: str = '',
|
||||
url: str = '',
|
||||
thread_id: str | None = None,
|
||||
message: str = '',
|
||||
) -> None:
|
||||
self.operation = operation
|
||||
self.status = status
|
||||
self.body = body
|
||||
self.url = url
|
||||
self.thread_id = thread_id
|
||||
|
||||
if message:
|
||||
super().__init__(message)
|
||||
return
|
||||
|
||||
msg = f'DeerFlow {operation} failed: status={status}, url={url}, body={body}'
|
||||
if thread_id is not None:
|
||||
msg = f'DeerFlow {operation} failed: thread_id={thread_id}, status={status}, url={url}, body={body}'
|
||||
super().__init__(msg)
|
||||
212
src/langbot/libs/deerflow_api/stream_utils.py
Normal file
212
src/langbot/libs/deerflow_api/stream_utils.py
Normal file
@@ -0,0 +1,212 @@
|
||||
"""DeerFlow LangGraph 流式响应解析工具
|
||||
|
||||
参考 astrbot 实现的 deerflow_stream_utils。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import typing
|
||||
from collections.abc import Iterable
|
||||
|
||||
|
||||
def extract_text(content: typing.Any) -> str:
|
||||
"""从消息 content 中提取纯文本"""
|
||||
if isinstance(content, str):
|
||||
return content
|
||||
if isinstance(content, dict):
|
||||
if isinstance(content.get('text'), str):
|
||||
return content['text']
|
||||
if 'content' in content:
|
||||
return extract_text(content.get('content'))
|
||||
if 'kwargs' in content and isinstance(content['kwargs'], dict):
|
||||
return extract_text(content['kwargs'].get('content'))
|
||||
if isinstance(content, list):
|
||||
parts: list[str] = []
|
||||
for item in content:
|
||||
if isinstance(item, str):
|
||||
parts.append(item)
|
||||
elif isinstance(item, dict):
|
||||
item_type = item.get('type')
|
||||
if item_type == 'text' and isinstance(item.get('text'), str):
|
||||
parts.append(item['text'])
|
||||
elif 'content' in item:
|
||||
parts.append(extract_text(item['content']))
|
||||
return '\n'.join([p for p in parts if p]).strip()
|
||||
return str(content) if content is not None else ''
|
||||
|
||||
|
||||
def extract_messages_from_values_data(data: typing.Any) -> list[typing.Any]:
|
||||
"""从 values 事件中提取 messages 列表"""
|
||||
candidates: list[typing.Any] = []
|
||||
if isinstance(data, dict):
|
||||
candidates.append(data)
|
||||
if isinstance(data.get('values'), dict):
|
||||
candidates.append(data['values'])
|
||||
elif isinstance(data, list):
|
||||
candidates.extend([x for x in data if isinstance(x, dict)])
|
||||
|
||||
for item in candidates:
|
||||
messages = item.get('messages')
|
||||
if isinstance(messages, list):
|
||||
return messages
|
||||
return []
|
||||
|
||||
|
||||
def is_ai_message(message: dict[str, typing.Any]) -> bool:
|
||||
"""判断是否为 AI/assistant 消息"""
|
||||
role = str(message.get('role', '')).lower()
|
||||
if role in {'assistant', 'ai'}:
|
||||
return True
|
||||
|
||||
msg_type = str(message.get('type', '')).lower()
|
||||
if msg_type in {'ai', 'assistant', 'aimessage', 'aimessagechunk'}:
|
||||
return True
|
||||
if 'ai' in msg_type and all(token not in msg_type for token in ('human', 'tool', 'system')):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def extract_latest_ai_text(messages: Iterable[typing.Any]) -> str:
|
||||
"""获取最近一条 AI 消息的文本内容"""
|
||||
if isinstance(messages, (list, tuple)):
|
||||
iterable = reversed(messages)
|
||||
else:
|
||||
iterable = reversed(list(messages))
|
||||
|
||||
for msg in iterable:
|
||||
if not isinstance(msg, dict):
|
||||
continue
|
||||
if is_ai_message(msg):
|
||||
text = extract_text(msg.get('content'))
|
||||
if text:
|
||||
return text
|
||||
return ''
|
||||
|
||||
|
||||
def extract_latest_ai_message(messages: Iterable[typing.Any]) -> dict[str, typing.Any] | None:
|
||||
"""获取最近一条 AI 消息对象"""
|
||||
if isinstance(messages, (list, tuple)):
|
||||
iterable = reversed(messages)
|
||||
else:
|
||||
iterable = reversed(list(messages))
|
||||
|
||||
for msg in iterable:
|
||||
if not isinstance(msg, dict):
|
||||
continue
|
||||
if is_ai_message(msg):
|
||||
return msg
|
||||
return None
|
||||
|
||||
|
||||
def is_clarification_tool_message(message: dict[str, typing.Any]) -> bool:
|
||||
"""判断是否为澄清问题工具消息"""
|
||||
msg_type = str(message.get('type', '')).lower()
|
||||
tool_name = str(message.get('name', '')).lower()
|
||||
return msg_type == 'tool' and tool_name == 'ask_clarification'
|
||||
|
||||
|
||||
def extract_latest_clarification_text(messages: Iterable[typing.Any]) -> str:
|
||||
"""提取最近的澄清问题文本"""
|
||||
if isinstance(messages, (list, tuple)):
|
||||
iterable = reversed(messages)
|
||||
else:
|
||||
iterable = reversed(list(messages))
|
||||
|
||||
for msg in iterable:
|
||||
if not isinstance(msg, dict):
|
||||
continue
|
||||
if is_clarification_tool_message(msg):
|
||||
text = extract_text(msg.get('content'))
|
||||
if text:
|
||||
return text
|
||||
return ''
|
||||
|
||||
|
||||
def get_message_id(message: typing.Any) -> str:
|
||||
"""提取消息 ID"""
|
||||
if not isinstance(message, dict):
|
||||
return ''
|
||||
msg_id = message.get('id')
|
||||
return msg_id if isinstance(msg_id, str) else ''
|
||||
|
||||
|
||||
def extract_event_message_obj(data: typing.Any) -> dict[str, typing.Any] | None:
|
||||
"""从事件 data 中提取消息对象"""
|
||||
msg_obj = data
|
||||
if isinstance(data, (list, tuple)) and data:
|
||||
msg_obj = data[0]
|
||||
if isinstance(msg_obj, dict) and isinstance(msg_obj.get('data'), dict):
|
||||
msg_obj = msg_obj['data']
|
||||
return msg_obj if isinstance(msg_obj, dict) else None
|
||||
|
||||
|
||||
def extract_ai_delta_from_event_data(data: typing.Any) -> str:
|
||||
"""从 messages-tuple 事件中提取 AI delta 文本"""
|
||||
msg_obj = extract_event_message_obj(data)
|
||||
if not msg_obj:
|
||||
return ''
|
||||
if is_ai_message(msg_obj):
|
||||
return extract_text(msg_obj.get('content'))
|
||||
return ''
|
||||
|
||||
|
||||
def extract_clarification_from_event_data(data: typing.Any) -> str:
|
||||
"""从事件中提取澄清问题"""
|
||||
msg_obj = extract_event_message_obj(data)
|
||||
if not msg_obj:
|
||||
return ''
|
||||
if is_clarification_tool_message(msg_obj):
|
||||
return extract_text(msg_obj.get('content'))
|
||||
return ''
|
||||
|
||||
|
||||
def _iter_custom_event_items(data: typing.Any) -> list[dict[str, typing.Any]]:
|
||||
items: list[dict[str, typing.Any]] = []
|
||||
if isinstance(data, dict):
|
||||
return [data]
|
||||
if isinstance(data, list):
|
||||
for item in data:
|
||||
if isinstance(item, dict):
|
||||
items.append(item)
|
||||
elif isinstance(item, (list, tuple)):
|
||||
for nested in item:
|
||||
if isinstance(nested, dict):
|
||||
items.append(nested)
|
||||
return items
|
||||
|
||||
|
||||
def extract_task_failures_from_custom_event(data: typing.Any) -> list[str]:
|
||||
"""从 custom 事件中提取子任务失败信息"""
|
||||
failures: list[str] = []
|
||||
for item in _iter_custom_event_items(data):
|
||||
event_type = str(item.get('type', '')).lower()
|
||||
if event_type not in {'task_failed', 'task_timed_out'}:
|
||||
continue
|
||||
|
||||
task_id = str(item.get('task_id', '')).strip()
|
||||
error_text = extract_text(item.get('error')).strip()
|
||||
if task_id and error_text:
|
||||
failures.append(f'{task_id}: {error_text}')
|
||||
elif error_text:
|
||||
failures.append(error_text)
|
||||
elif task_id:
|
||||
failures.append(f'{task_id}: unknown error')
|
||||
else:
|
||||
failures.append('unknown task failure')
|
||||
return failures
|
||||
|
||||
|
||||
def build_task_failure_summary(failures: list[str]) -> str:
|
||||
"""构建任务失败摘要"""
|
||||
if not failures:
|
||||
return ''
|
||||
deduped: list[str] = []
|
||||
seen: set[str] = set()
|
||||
for failure in failures:
|
||||
if failure not in seen:
|
||||
seen.add(failure)
|
||||
deduped.append(failure)
|
||||
if len(deduped) == 1:
|
||||
return f'DeerFlow subtask failed: {deduped[0]}'
|
||||
joined = '\n'.join([f'- {item}' for item in deduped[:5]])
|
||||
return f'DeerFlow subtasks failed:\n{joined}'
|
||||
4
src/langbot/libs/weknora_api/__init__.py
Normal file
4
src/langbot/libs/weknora_api/__init__.py
Normal file
@@ -0,0 +1,4 @@
|
||||
from .client import AsyncWeKnoraClient
|
||||
from .errors import WeKnoraAPIError
|
||||
|
||||
__all__ = ['AsyncWeKnoraClient', 'WeKnoraAPIError']
|
||||
180
src/langbot/libs/weknora_api/client.py
Normal file
180
src/langbot/libs/weknora_api/client.py
Normal file
@@ -0,0 +1,180 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import httpx
|
||||
import typing
|
||||
import json
|
||||
|
||||
from .errors import WeKnoraAPIError
|
||||
|
||||
|
||||
class AsyncWeKnoraClient:
|
||||
"""WeKnora API 客户端"""
|
||||
|
||||
api_key: str
|
||||
base_url: str
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
api_key: str,
|
||||
base_url: str = 'http://localhost:80/api/v1',
|
||||
) -> None:
|
||||
self.api_key = api_key
|
||||
self.base_url = base_url
|
||||
|
||||
async def create_session(
|
||||
self,
|
||||
title: str = '',
|
||||
description: str = '',
|
||||
timeout: float = 30.0,
|
||||
) -> str:
|
||||
"""创建会话,返回 session_id"""
|
||||
async with httpx.AsyncClient(
|
||||
base_url=self.base_url,
|
||||
trust_env=True,
|
||||
timeout=timeout,
|
||||
) as client:
|
||||
payload: dict[str, typing.Any] = {}
|
||||
if title:
|
||||
payload['title'] = title
|
||||
if description:
|
||||
payload['description'] = description
|
||||
|
||||
response = await client.post(
|
||||
'/sessions',
|
||||
headers={
|
||||
'X-API-Key': self.api_key,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
json=payload,
|
||||
)
|
||||
|
||||
if response.status_code not in (200, 201):
|
||||
raise WeKnoraAPIError(f'{response.status_code} {response.text}')
|
||||
|
||||
data = response.json()
|
||||
return data['data']['id']
|
||||
|
||||
async def agent_chat(
|
||||
self,
|
||||
session_id: str,
|
||||
query: str,
|
||||
user: str,
|
||||
agent_id: str = '',
|
||||
knowledge_base_ids: list[str] | None = None,
|
||||
web_search_enabled: bool = False,
|
||||
timeout: float = 120.0,
|
||||
) -> typing.AsyncGenerator[dict[str, typing.Any], None]:
|
||||
"""
|
||||
Agent 智能对话(SSE 流式)
|
||||
|
||||
响应事件类型:
|
||||
- agent_query: Agent 开始处理
|
||||
- thinking: 思考过程
|
||||
- tool_call: 工具调用
|
||||
- tool_result: 工具结果
|
||||
- references: 知识库引用
|
||||
- answer: 回答内容
|
||||
- reflection: 反思
|
||||
- session_title: 会话标题
|
||||
- error: 错误
|
||||
"""
|
||||
if knowledge_base_ids is None:
|
||||
knowledge_base_ids = []
|
||||
|
||||
async with httpx.AsyncClient(
|
||||
base_url=self.base_url,
|
||||
trust_env=True,
|
||||
timeout=timeout,
|
||||
) as client:
|
||||
payload: dict[str, typing.Any] = {
|
||||
'query': query,
|
||||
'agent_enabled': True,
|
||||
'channel': 'im',
|
||||
}
|
||||
if agent_id:
|
||||
payload['agent_id'] = agent_id
|
||||
if knowledge_base_ids:
|
||||
payload['knowledge_base_ids'] = knowledge_base_ids
|
||||
if web_search_enabled:
|
||||
payload['web_search_enabled'] = True
|
||||
|
||||
async with client.stream(
|
||||
'POST',
|
||||
f'/agent-chat/{session_id}',
|
||||
headers={
|
||||
'X-API-Key': self.api_key,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
json=payload,
|
||||
) as r:
|
||||
async for chunk in r.aiter_lines():
|
||||
if r.status_code != 200:
|
||||
raise WeKnoraAPIError(f'{r.status_code} {chunk}')
|
||||
if chunk.strip() == '':
|
||||
continue
|
||||
if chunk.startswith('data:'):
|
||||
try:
|
||||
data = json.loads(chunk[5:].strip())
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
yield data
|
||||
# 收到 error 事件后主动结束流,避免上层未 raise 时持续等待
|
||||
if data.get('response_type') == 'error':
|
||||
return
|
||||
|
||||
async def knowledge_chat(
|
||||
self,
|
||||
session_id: str,
|
||||
query: str,
|
||||
user: str,
|
||||
agent_id: str = 'builtin-quick-answer',
|
||||
knowledge_base_ids: list[str] | None = None,
|
||||
timeout: float = 120.0,
|
||||
) -> typing.AsyncGenerator[dict[str, typing.Any], None]:
|
||||
"""
|
||||
知识库 RAG 问答(SSE 流式)
|
||||
|
||||
响应事件类型:
|
||||
- references: 知识库引用
|
||||
- answer: 回答内容
|
||||
"""
|
||||
if knowledge_base_ids is None:
|
||||
knowledge_base_ids = []
|
||||
|
||||
async with httpx.AsyncClient(
|
||||
base_url=self.base_url,
|
||||
trust_env=True,
|
||||
timeout=timeout,
|
||||
) as client:
|
||||
payload: dict[str, typing.Any] = {
|
||||
'query': query,
|
||||
'channel': 'im',
|
||||
}
|
||||
if agent_id:
|
||||
payload['agent_id'] = agent_id
|
||||
if knowledge_base_ids:
|
||||
payload['knowledge_base_ids'] = knowledge_base_ids
|
||||
|
||||
async with client.stream(
|
||||
'POST',
|
||||
f'/knowledge-chat/{session_id}',
|
||||
headers={
|
||||
'X-API-Key': self.api_key,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
json=payload,
|
||||
) as r:
|
||||
async for chunk in r.aiter_lines():
|
||||
if r.status_code != 200:
|
||||
raise WeKnoraAPIError(f'{r.status_code} {chunk}')
|
||||
if chunk.strip() == '':
|
||||
continue
|
||||
if chunk.startswith('data:'):
|
||||
try:
|
||||
data = json.loads(chunk[5:].strip())
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
yield data
|
||||
# 收到 error 事件后主动结束流,避免上层未 raise 时持续等待
|
||||
if data.get('response_type') == 'error':
|
||||
return
|
||||
6
src/langbot/libs/weknora_api/errors.py
Normal file
6
src/langbot/libs/weknora_api/errors.py
Normal file
@@ -0,0 +1,6 @@
|
||||
class WeKnoraAPIError(Exception):
|
||||
"""WeKnora API 请求失败"""
|
||||
|
||||
def __init__(self, message: str = ''):
|
||||
self.message = message
|
||||
super().__init__(self.message)
|
||||
@@ -43,8 +43,12 @@ 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)
|
||||
# Dashboard pipeline-debug sessions must always run under the
|
||||
# built-in websocket_proxy_bot identity. We deliberately do NOT
|
||||
# resolve a web_page_bot owner here — even if one is bound to
|
||||
# the same pipeline, debug requests must not be attributed to
|
||||
# it. The embed widget path (`/api/v1/embed/<bot>/ws/connect`)
|
||||
# is the one that carries the page-bot identity.
|
||||
|
||||
# 注册连接
|
||||
connection = await ws_connection_manager.add_connection(
|
||||
@@ -73,7 +77,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 +185,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 +210,10 @@ 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)
|
||||
# owner_bot is intentionally NOT passed: the dashboard
|
||||
# debug WebSocket must always run under the proxy bot,
|
||||
# never under a coincidentally-bound web_page_bot.
|
||||
await websocket_adapter.handle_websocket_message(connection, data)
|
||||
|
||||
elif message_type == 'disconnect':
|
||||
# 客户端主动断开
|
||||
|
||||
@@ -179,8 +179,6 @@ class AdaptersRouterGroup(group.RouterGroup):
|
||||
"""Start WeChat QR code login. Returns session_id + QR code data URL."""
|
||||
import uuid
|
||||
import time
|
||||
import io
|
||||
import base64
|
||||
|
||||
from langbot.libs.openclaw_weixin_api.client import OpenClawWeixinClient, DEFAULT_BASE_URL
|
||||
|
||||
@@ -208,60 +206,32 @@ class AdaptersRouterGroup(group.RouterGroup):
|
||||
|
||||
async def run_login():
|
||||
try:
|
||||
import qrcode as qr_lib
|
||||
|
||||
for _attempt in range(3):
|
||||
qr_resp = await client.fetch_qrcode()
|
||||
if not qr_resp.qrcode or not qr_resp.qrcode_img_content:
|
||||
raise Exception('Failed to get QR code from server')
|
||||
|
||||
# Generate QR code image locally
|
||||
qr = qr_lib.QRCode(error_correction=qr_lib.constants.ERROR_CORRECT_L)
|
||||
qr.add_data(qr_resp.qrcode_img_content)
|
||||
qr.make(fit=True)
|
||||
img = qr.make_image(fill_color='black', back_color='white')
|
||||
buf = io.BytesIO()
|
||||
img.save(buf, format='PNG')
|
||||
b64 = base64.b64encode(buf.getvalue()).decode('utf-8')
|
||||
data_url = f'data:image/png;base64,{b64}'
|
||||
|
||||
def _update_qr():
|
||||
session['qr_data_url'] = data_url
|
||||
session['expire_at'] = time.time() + 480 # 8 minutes
|
||||
def on_qrcode(qr_data_url: str, _qr_url: str):
|
||||
def _update():
|
||||
session['qr_data_url'] = qr_data_url
|
||||
session['expire_at'] = time.time() + 180
|
||||
session['status'] = 'waiting'
|
||||
|
||||
loop.call_soon_threadsafe(_update_qr)
|
||||
|
||||
# Poll for scan status
|
||||
deadline = loop.time() + 180
|
||||
while loop.time() < deadline:
|
||||
try:
|
||||
status_resp = await client.poll_qrcode_status(qr_resp.qrcode)
|
||||
except Exception:
|
||||
await asyncio.sleep(2)
|
||||
continue
|
||||
|
||||
if status_resp.status == 'confirmed' and status_resp.bot_token:
|
||||
session['status'] = 'success'
|
||||
session['token'] = status_resp.bot_token
|
||||
session['base_url'] = status_resp.baseurl or client.base_url
|
||||
session['account_id'] = status_resp.ilink_bot_id or ''
|
||||
return
|
||||
|
||||
if status_resp.status == 'expired':
|
||||
break # retry with new QR code
|
||||
|
||||
await asyncio.sleep(1)
|
||||
else:
|
||||
pass # timeout, retry
|
||||
|
||||
# All retries exhausted
|
||||
session['status'] = 'error'
|
||||
session['error'] = 'QR code login failed: max retries exceeded'
|
||||
loop.call_soon_threadsafe(_update)
|
||||
|
||||
result = await client.login(
|
||||
max_retries=1,
|
||||
poll_timeout_ms=180_000,
|
||||
on_qrcode=on_qrcode,
|
||||
)
|
||||
session['status'] = 'success'
|
||||
session['token'] = result.token
|
||||
session['base_url'] = result.base_url
|
||||
session['account_id'] = result.account_id
|
||||
except Exception as e:
|
||||
session['status'] = 'error'
|
||||
session['error'] = str(e)
|
||||
error_message = str(e)
|
||||
if 'expired' in error_message.lower() or 'max retries exceeded' in error_message.lower():
|
||||
session['status'] = 'expired'
|
||||
session['error'] = 'QR code expired'
|
||||
else:
|
||||
session['status'] = 'error'
|
||||
session['error'] = error_message
|
||||
finally:
|
||||
await client.close()
|
||||
|
||||
@@ -295,7 +265,11 @@ class AdaptersRouterGroup(group.RouterGroup):
|
||||
if not session:
|
||||
return self.http_status(404, -1, 'Session not found')
|
||||
|
||||
data = {'status': session['status']}
|
||||
data = {
|
||||
'status': session['status'],
|
||||
'qr_data_url': session['qr_data_url'],
|
||||
'expire_at': session['expire_at'],
|
||||
}
|
||||
|
||||
if session['status'] == 'success':
|
||||
data['token'] = session['token']
|
||||
@@ -305,6 +279,9 @@ class AdaptersRouterGroup(group.RouterGroup):
|
||||
elif session['status'] == 'error':
|
||||
data['error'] = session['error']
|
||||
_weixin_login_sessions.pop(session_id, None)
|
||||
elif session['status'] == 'expired':
|
||||
data['error'] = session['error']
|
||||
_weixin_login_sessions.pop(session_id, None)
|
||||
|
||||
return self.success(data=data)
|
||||
|
||||
|
||||
@@ -168,7 +168,7 @@ class BoxService:
|
||||
f'spec={json.dumps(self._summarize_spec(spec), ensure_ascii=False)}'
|
||||
)
|
||||
try:
|
||||
self._enforce_workspace_quota(spec, phase='before execution')
|
||||
await self._enforce_workspace_quota(spec, phase='before execution')
|
||||
except BoxError as exc:
|
||||
self._record_error(exc, query)
|
||||
raise
|
||||
@@ -178,7 +178,7 @@ class BoxService:
|
||||
self._record_error(exc, query)
|
||||
raise
|
||||
try:
|
||||
self._enforce_workspace_quota(spec, phase='after execution')
|
||||
await self._enforce_workspace_quota(spec, phase='after execution')
|
||||
except BoxError as exc:
|
||||
await self._cleanup_exceeded_session(spec)
|
||||
self._record_error(exc, query)
|
||||
@@ -683,7 +683,7 @@ class BoxService:
|
||||
_walk(root)
|
||||
return total
|
||||
|
||||
def _enforce_workspace_quota(self, spec: BoxSpec, *, phase: str) -> None:
|
||||
async def _enforce_workspace_quota(self, spec: BoxSpec, *, phase: str) -> None:
|
||||
if spec.host_path is None or spec.workspace_quota_mb <= 0:
|
||||
return
|
||||
|
||||
@@ -691,7 +691,10 @@ class BoxService:
|
||||
if not os.path.isdir(host_path):
|
||||
return
|
||||
|
||||
used_bytes = self._get_workspace_size_bytes(host_path)
|
||||
# Walk the workspace off the event loop — this runs on every
|
||||
# quota-enforced exec, and a large tree would otherwise block the whole
|
||||
# asyncio runtime (all bots/pipelines) for the duration of the scan.
|
||||
used_bytes = await asyncio.to_thread(self._get_workspace_size_bytes, host_path)
|
||||
limit_bytes = spec.workspace_quota_mb * _MIB
|
||||
if used_bytes <= limit_bytes:
|
||||
return
|
||||
|
||||
27
src/langbot/pkg/core/migrations/m042_weknora_api.py
Normal file
27
src/langbot/pkg/core/migrations/m042_weknora_api.py
Normal file
@@ -0,0 +1,27 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from .. import migration
|
||||
|
||||
|
||||
@migration.migration_class('weknora-api-config', 42)
|
||||
class WeKnoraAPICfgMigration(migration.Migration):
|
||||
"""WeKnora API 配置迁移"""
|
||||
|
||||
async def need_migrate(self) -> bool:
|
||||
"""判断当前环境是否需要运行此迁移"""
|
||||
return 'weknora-api' not in self.ap.provider_cfg.data
|
||||
|
||||
async def run(self):
|
||||
"""执行迁移"""
|
||||
self.ap.provider_cfg.data['weknora-api'] = {
|
||||
'base-url': 'http://localhost:8080/api/v1',
|
||||
'app-type': 'agent',
|
||||
'api-key': '',
|
||||
'agent-id': 'builtin-smart-reasoning',
|
||||
'knowledge-base-ids': [],
|
||||
'web-search-enabled': False,
|
||||
'timeout': 120,
|
||||
'base-prompt': '请回答用户的问题。',
|
||||
}
|
||||
|
||||
await self.ap.provider_cfg.dump_config()
|
||||
30
src/langbot/pkg/core/migrations/m043_deerflow_api.py
Normal file
30
src/langbot/pkg/core/migrations/m043_deerflow_api.py
Normal file
@@ -0,0 +1,30 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from .. import migration
|
||||
|
||||
|
||||
@migration.migration_class('deerflow-api-config', 43)
|
||||
class DeerFlowAPICfgMigration(migration.Migration):
|
||||
"""DeerFlow API 配置迁移"""
|
||||
|
||||
async def need_migrate(self) -> bool:
|
||||
"""判断当前环境是否需要运行此迁移"""
|
||||
return 'deerflow-api' not in self.ap.provider_cfg.data
|
||||
|
||||
async def run(self):
|
||||
"""执行迁移"""
|
||||
self.ap.provider_cfg.data['deerflow-api'] = {
|
||||
'api-base': 'http://127.0.0.1:2026',
|
||||
'api-key': '',
|
||||
'auth-header': '',
|
||||
'assistant-id': 'lead_agent',
|
||||
'model-name': '',
|
||||
'thinking-enabled': False,
|
||||
'plan-mode': False,
|
||||
'subagent-enabled': False,
|
||||
'max-concurrent-subagents': 3,
|
||||
'timeout': 300,
|
||||
'recursion-limit': 1000,
|
||||
}
|
||||
|
||||
await self.ap.provider_cfg.dump_config()
|
||||
@@ -881,7 +881,8 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
|
||||
bot_account_id = config['bot_name']
|
||||
|
||||
bot = lark_oapi.ws.Client(config['app_id'], config['app_secret'], event_handler=event_handler)
|
||||
domain = self._resolve_domain(config)
|
||||
bot = lark_oapi.ws.Client(config['app_id'], config['app_secret'], event_handler=event_handler, domain=domain)
|
||||
api_client = self.build_api_client(config)
|
||||
cipher = AESCipher(config.get('encrypt-key', ''))
|
||||
self.request_app_ticket(api_client, config)
|
||||
@@ -1014,13 +1015,28 @@ class LarkAdapter(abstract_platform_adapter.AbstractMessagePlatformAdapter):
|
||||
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _resolve_domain(config) -> str:
|
||||
domain = config.get('domain', lark_oapi.FEISHU_DOMAIN)
|
||||
if domain == 'custom':
|
||||
domain = config.get('custom_domain', '')
|
||||
if not domain:
|
||||
raise ValueError('Custom domain is required when domain is set to "custom"')
|
||||
return domain.rstrip('/')
|
||||
|
||||
def build_api_client(self, config):
|
||||
app_id = config['app_id']
|
||||
app_secret = config['app_secret']
|
||||
api_client = lark_oapi.Client.builder().app_id(app_id).app_secret(app_secret).build()
|
||||
domain = self._resolve_domain(config)
|
||||
api_client = lark_oapi.Client.builder().app_id(app_id).app_secret(app_secret).domain(domain).build()
|
||||
if 'isv' == config.get('app_type', 'self'):
|
||||
api_client = (
|
||||
lark_oapi.Client.builder().app_id(app_id).app_secret(app_secret).app_type(lark_oapi.AppType.ISV).build()
|
||||
lark_oapi.Client.builder()
|
||||
.app_id(app_id)
|
||||
.app_secret(app_secret)
|
||||
.app_type(lark_oapi.AppType.ISV)
|
||||
.domain(domain)
|
||||
.build()
|
||||
)
|
||||
return api_client
|
||||
|
||||
|
||||
@@ -23,6 +23,57 @@ spec:
|
||||
en: https://link.langbot.app/en/platforms/lark
|
||||
ja: https://link.langbot.app/ja/platforms/lark
|
||||
config:
|
||||
- name: domain
|
||||
label:
|
||||
en_US: Platform Domain
|
||||
zh_Hans: 平台域名
|
||||
zh_Hant: 平台域名
|
||||
ja_JP: プラットフォームドメイン
|
||||
description:
|
||||
en_US: Select the open platform domain. Use Feishu for Chinese mainland, Lark for international
|
||||
zh_Hans: 选择开放平台域名,国内使用飞书,海外使用 Lark
|
||||
zh_Hant: 選擇開放平台域名,國內使用飛書,海外使用 Lark
|
||||
ja_JP: オープンプラットフォームのドメインを選択。中国国内は飛書、海外は Lark を使用
|
||||
type: select
|
||||
options:
|
||||
- name: https://open.feishu.cn
|
||||
label:
|
||||
en_US: Feishu (open.feishu.cn)
|
||||
zh_Hans: 飞书 (open.feishu.cn)
|
||||
zh_Hant: 飛書 (open.feishu.cn)
|
||||
ja_JP: 飛書 (open.feishu.cn)
|
||||
- name: https://open.larksuite.com
|
||||
label:
|
||||
en_US: Lark (open.larksuite.com)
|
||||
zh_Hans: Lark (open.larksuite.com)
|
||||
zh_Hant: Lark (open.larksuite.com)
|
||||
ja_JP: Lark (open.larksuite.com)
|
||||
- name: custom
|
||||
label:
|
||||
en_US: Custom
|
||||
zh_Hans: 自定义
|
||||
zh_Hant: 自定義
|
||||
ja_JP: カスタム
|
||||
required: false
|
||||
default: https://open.feishu.cn
|
||||
- name: custom_domain
|
||||
label:
|
||||
en_US: Custom Domain
|
||||
zh_Hans: 自定义域名
|
||||
zh_Hant: 自定義域名
|
||||
ja_JP: カスタムドメイン
|
||||
description:
|
||||
en_US: "Enter the full domain URL, e.g. https://open.example.com"
|
||||
zh_Hans: "输入完整的域名 URL,例如 https://open.example.com"
|
||||
zh_Hant: "輸入完整的域名 URL,例如 https://open.example.com"
|
||||
ja_JP: "完全なドメイン URL を入力(例: https://open.example.com)"
|
||||
type: string
|
||||
required: false
|
||||
default: ""
|
||||
show_if:
|
||||
field: domain
|
||||
operator: eq
|
||||
value: custom
|
||||
- name: one-click-create
|
||||
label:
|
||||
en_US: One-Click Create App
|
||||
@@ -140,10 +191,10 @@ spec:
|
||||
zh_Hant: 應用類型
|
||||
ja_JP: アプリタイプ
|
||||
description:
|
||||
en_US: Default to self-built application, refer to https://open.feishu.cn/document/platform-overveiw/overview
|
||||
zh_Hans: 默认为企业自建应用,参考 https://open.feishu.cn/document/platform-overveiw/overview
|
||||
zh_Hant: 預設為企業自建應用,參考 https://open.feishu.cn/document/platform-overveiw/overview
|
||||
ja_JP: デフォルトはカスタムアプリです。詳細は https://open.feishu.cn/document/platform-overveiw/overview を参照してください
|
||||
en_US: "Default to self-built application, refer to https://open.feishu.cn/document/platform-overveiw/overview"
|
||||
zh_Hans: "默认为企业自建应用,参考 https://open.feishu.cn/document/platform-overveiw/overview"
|
||||
zh_Hant: "預設為企業自建應用,參考 https://open.feishu.cn/document/platform-overveiw/overview"
|
||||
ja_JP: "デフォルトはカスタムアプリです。詳細は https://open.feishu.cn/document/platform-overveiw/overview を参照してください"
|
||||
type: select
|
||||
options:
|
||||
- name: self
|
||||
|
||||
@@ -103,6 +103,16 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
|
||||
|
||||
self.handler_task = asyncio.create_task(self.handler.run())
|
||||
_ = await self.handler.ping()
|
||||
# Push the configured marketplace (Space) URL to the runtime so it
|
||||
# downloads plugins from the same Space LangBot is bound to, rather
|
||||
# than relying on the runtime's own env/default.
|
||||
space_url = self.ap.instance_config.data.get('space', {}).get('url', '').rstrip('/')
|
||||
if space_url:
|
||||
try:
|
||||
await self.handler.set_runtime_config(cloud_service_url=space_url)
|
||||
self.ap.logger.info(f'Pushed marketplace URL to plugin runtime: {space_url}')
|
||||
except Exception as e:
|
||||
self.ap.logger.warning(f'Failed to push runtime config: {e}')
|
||||
self.ap.logger.info('Connected to plugin runtime.')
|
||||
await self.handler_task
|
||||
|
||||
@@ -224,30 +234,23 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
|
||||
mcp_data: dict[str, Any],
|
||||
task_context: taskmgr.TaskContext | None = None,
|
||||
):
|
||||
"""Install an MCP server from marketplace data."""
|
||||
"""Install an MCP server from marketplace data.
|
||||
|
||||
Marketplace MCP records carry the runtime-ready ``mode`` and
|
||||
``extra_args`` directly (the same shape LangBot stores in
|
||||
``mcp_servers``), so they are used as-is rather than reconstructed.
|
||||
For ``stdio`` this preserves ``command``/``args``/``env``/``box``;
|
||||
for ``http``/``sse`` it preserves ``url``/``headers``/``timeout``/
|
||||
``ssereadtimeout``.
|
||||
"""
|
||||
from ..entity.persistence import mcp as persistence_mcp
|
||||
import uuid
|
||||
|
||||
config = mcp_data.get('config', {})
|
||||
url = config.get('url', '')
|
||||
mode = mcp_data.get('mode') or 'stdio'
|
||||
extra_args = mcp_data.get('extra_args') or {}
|
||||
# Use __ instead of / to avoid URL routing issues with slashes
|
||||
name = f'{mcp_data.get("author", "")}__{mcp_data.get("name", "")}'
|
||||
|
||||
# Determine mode from URL
|
||||
if 'sse' in url.lower():
|
||||
mode = 'sse'
|
||||
elif url.startswith('http'):
|
||||
mode = 'http'
|
||||
else:
|
||||
mode = 'stdio'
|
||||
|
||||
# Build extra_args from config
|
||||
extra_args = {
|
||||
'url': url,
|
||||
'timeout': config.get('timeout', 30),
|
||||
'sse_read_timeout': config.get('sse_read_timeout', 300),
|
||||
}
|
||||
|
||||
# Check if MCP server already exists
|
||||
existing = await self.ap.persistence_mgr.execute_async(
|
||||
sqlalchemy.select(persistence_mcp.MCPServer).where(persistence_mcp.MCPServer.name == name)
|
||||
@@ -376,15 +379,22 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
|
||||
mcp_resp = await client.get(f'{space_url}/api/v1/marketplace/mcps/{plugin_author}/{plugin_name}')
|
||||
if mcp_resp.status_code == 200:
|
||||
mcp_data = mcp_resp.json().get('data', {}).get('mcp', {})
|
||||
if mcp_data.get('config'):
|
||||
if mcp_data.get('mode'):
|
||||
# It's an MCP - create server locally
|
||||
self.ap.logger.info(f'Installing MCP from marketplace: {plugin_author}/{plugin_name}')
|
||||
if task_context:
|
||||
task_context.set_current_action('installing mcp server')
|
||||
await self._install_mcp_from_marketplace(mcp_data, task_context)
|
||||
# Best-effort install report (bumps marketplace install_count).
|
||||
try:
|
||||
await client.post(
|
||||
f'{space_url}/api/v1/marketplace/mcps/{plugin_author}/{plugin_name}/install'
|
||||
)
|
||||
except Exception as report_err:
|
||||
self.ap.logger.debug(f'Failed to report MCP install: {report_err}')
|
||||
return
|
||||
else:
|
||||
raise Exception(f'MCP {plugin_author}/{plugin_name} has no config')
|
||||
raise Exception(f'MCP {plugin_author}/{plugin_name} has no mode')
|
||||
elif mcp_resp.status_code == 404:
|
||||
# Try skill endpoint - download ZIP and install
|
||||
self.ap.logger.info(f'Trying skill endpoint for: {plugin_author}/{plugin_name}')
|
||||
@@ -449,7 +459,7 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
|
||||
)
|
||||
|
||||
file_bytes = download_resp.content
|
||||
self._extract_deps_metadata(file_bytes, task_context)
|
||||
self._inspect_plugin_package(file_bytes, task_context)
|
||||
file_key = await self.handler.send_file(file_bytes, 'lbpkg')
|
||||
install_info['plugin_file_key'] = file_key
|
||||
self.ap.logger.info(f'Transfered file {file_key} to plugin runtime')
|
||||
|
||||
@@ -779,6 +779,16 @@ class RuntimeConnectionHandler(handler.Handler):
|
||||
timeout=10,
|
||||
)
|
||||
|
||||
async def set_runtime_config(self, cloud_service_url: str) -> dict[str, Any]:
|
||||
"""Push runtime configuration (e.g. marketplace URL) to the runtime."""
|
||||
return await self.call_action(
|
||||
LangBotToRuntimeAction.SET_RUNTIME_CONFIG,
|
||||
{
|
||||
'cloud_service_url': cloud_service_url,
|
||||
},
|
||||
timeout=10,
|
||||
)
|
||||
|
||||
async def install_plugin(
|
||||
self, install_source: str, install_info: dict[str, Any]
|
||||
) -> typing.AsyncGenerator[dict[str, Any], None]:
|
||||
|
||||
@@ -143,49 +143,83 @@ class ModelManager:
|
||||
# get the latest models from space
|
||||
space_models = await self.ap.space_service.get_models()
|
||||
|
||||
exists_llm_models_uuids = [m['uuid'] for m in await self.ap.llm_model_service.get_llm_models()]
|
||||
exists_embedding_models_uuids = [
|
||||
m['uuid'] for m in await self.ap.embedding_models_service.get_embedding_models()
|
||||
]
|
||||
# Index existing models by uuid. Space reuses a model's uuid across
|
||||
# renames / re-specs (e.g. the uuid that used to be ``claude-opus-4-6``
|
||||
# may later become ``claude-opus-4-7``). So for Space-managed models we
|
||||
# upsert: create when the uuid is new, otherwise update name/abilities/
|
||||
# ranking to track Space. Models owned by other providers are never
|
||||
# touched, even on an (unexpected) uuid collision.
|
||||
existing_llm_models = {m['uuid']: m for m in await self.ap.llm_model_service.get_llm_models()}
|
||||
existing_embedding_models = {
|
||||
m['uuid']: m for m in await self.ap.embedding_models_service.get_embedding_models()
|
||||
}
|
||||
|
||||
created = 0
|
||||
updated = 0
|
||||
|
||||
for space_model in space_models:
|
||||
if space_model.category == 'chat':
|
||||
uuid = space_model.uuid
|
||||
|
||||
if uuid in exists_llm_models_uuids:
|
||||
continue
|
||||
|
||||
# model will be automatically loaded
|
||||
await self.ap.llm_model_service.create_llm_model(
|
||||
{
|
||||
'uuid': space_model.uuid,
|
||||
existing = existing_llm_models.get(space_model.uuid)
|
||||
if existing is None:
|
||||
# model will be automatically loaded
|
||||
await self.ap.llm_model_service.create_llm_model(
|
||||
{
|
||||
'uuid': space_model.uuid,
|
||||
'name': space_model.model_id,
|
||||
'provider_uuid': space_model_provider.uuid,
|
||||
'abilities': space_model.llm_abilities or [],
|
||||
'extra_args': {},
|
||||
'prefered_ranking': space_model.featured_order,
|
||||
},
|
||||
preserve_uuid=True,
|
||||
auto_set_to_default_pipeline=False,
|
||||
)
|
||||
created += 1
|
||||
elif existing.get('provider_uuid') == space_model_provider.uuid:
|
||||
desired = {
|
||||
'name': space_model.model_id,
|
||||
'provider_uuid': space_model_provider.uuid,
|
||||
'abilities': space_model.llm_abilities or [],
|
||||
'extra_args': {},
|
||||
'prefered_ranking': space_model.featured_order,
|
||||
},
|
||||
preserve_uuid=True,
|
||||
auto_set_to_default_pipeline=False,
|
||||
)
|
||||
}
|
||||
if (
|
||||
existing.get('name') != desired['name']
|
||||
or list(existing.get('abilities') or []) != list(desired['abilities'])
|
||||
or existing.get('prefered_ranking') != desired['prefered_ranking']
|
||||
):
|
||||
await self.ap.llm_model_service.update_llm_model(space_model.uuid, dict(desired))
|
||||
updated += 1
|
||||
|
||||
elif space_model.category == 'embedding':
|
||||
uuid = space_model.uuid
|
||||
|
||||
if uuid in exists_embedding_models_uuids:
|
||||
continue
|
||||
|
||||
# model will be automatically loaded
|
||||
await self.ap.embedding_models_service.create_embedding_model(
|
||||
{
|
||||
'uuid': space_model.uuid,
|
||||
existing = existing_embedding_models.get(space_model.uuid)
|
||||
if existing is None:
|
||||
# model will be automatically loaded
|
||||
await self.ap.embedding_models_service.create_embedding_model(
|
||||
{
|
||||
'uuid': space_model.uuid,
|
||||
'name': space_model.model_id,
|
||||
'provider_uuid': space_model_provider.uuid,
|
||||
'extra_args': {},
|
||||
'prefered_ranking': space_model.featured_order,
|
||||
},
|
||||
preserve_uuid=True,
|
||||
)
|
||||
created += 1
|
||||
elif existing.get('provider_uuid') == space_model_provider.uuid:
|
||||
desired = {
|
||||
'name': space_model.model_id,
|
||||
'provider_uuid': space_model_provider.uuid,
|
||||
'extra_args': {},
|
||||
'prefered_ranking': space_model.featured_order,
|
||||
},
|
||||
preserve_uuid=True,
|
||||
)
|
||||
}
|
||||
if (
|
||||
existing.get('name') != desired['name']
|
||||
or existing.get('prefered_ranking') != desired['prefered_ranking']
|
||||
):
|
||||
await self.ap.embedding_models_service.update_embedding_model(space_model.uuid, dict(desired))
|
||||
updated += 1
|
||||
|
||||
if created or updated:
|
||||
self.ap.logger.info(f'Synced models from LangBot Space: {created} added, {updated} updated.')
|
||||
|
||||
async def init_temporary_runtime_llm_model(
|
||||
self,
|
||||
|
||||
511
src/langbot/pkg/provider/runners/deerflowapi.py
Normal file
511
src/langbot/pkg/provider/runners/deerflowapi.py
Normal file
@@ -0,0 +1,511 @@
|
||||
"""DeerFlow LangGraph API Runner
|
||||
|
||||
参考 astrbot 的 deerflow_agent_runner 实现,适配 LangBot 的 Runner 接口。
|
||||
|
||||
特点:
|
||||
- 使用 LangGraph HTTP API 接入 deer-flow 后端
|
||||
- 自动管理 thread_id(按 session 隔离)
|
||||
- 支持 SSE 流式响应解析
|
||||
- 支持 streaming/非流式两种输出
|
||||
- 处理 values / messages-tuple / custom 三种事件
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
import typing
|
||||
from collections import deque
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
|
||||
from langbot.pkg.provider import runner
|
||||
from langbot.pkg.core import app
|
||||
import langbot_plugin.api.entities.builtin.provider.message as provider_message
|
||||
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
|
||||
from langbot.libs.deerflow_api import client, errors, stream_utils
|
||||
|
||||
|
||||
_MAX_VALUES_HISTORY = 200
|
||||
|
||||
|
||||
@dataclass
|
||||
class _StreamState:
|
||||
"""流式状态跟踪"""
|
||||
|
||||
latest_text: str = ''
|
||||
prev_text_for_streaming: str = ''
|
||||
clarification_text: str = ''
|
||||
task_failures: list[str] = field(default_factory=list)
|
||||
seen_message_ids: set[str] = field(default_factory=set)
|
||||
seen_message_order: deque[str] = field(default_factory=deque)
|
||||
no_id_message_fingerprints: dict[int, str] = field(default_factory=dict)
|
||||
baseline_initialized: bool = False
|
||||
has_values_text: bool = False
|
||||
run_values_messages: list[dict[str, typing.Any]] = field(default_factory=list)
|
||||
timed_out: bool = False
|
||||
|
||||
|
||||
@runner.runner_class('deerflow-api')
|
||||
class DeerFlowAPIRunner(runner.RequestRunner):
|
||||
"""DeerFlow LangGraph API 对话请求器"""
|
||||
|
||||
deerflow_client: client.AsyncDeerFlowClient
|
||||
|
||||
def __init__(self, ap: app.Application, pipeline_config: dict):
|
||||
super().__init__(ap, pipeline_config)
|
||||
|
||||
cfg = self.pipeline_config['ai']['deerflow-api']
|
||||
|
||||
api_base = cfg.get('api-base', '').strip()
|
||||
if not api_base or not api_base.startswith(('http://', 'https://')):
|
||||
raise errors.DeerFlowAPIError(
|
||||
message='DeerFlow API Base URL 格式错误,必须以 http:// 或 https:// 开头',
|
||||
)
|
||||
|
||||
self.api_base = api_base
|
||||
self.api_key = cfg.get('api-key', '')
|
||||
self.auth_header = cfg.get('auth-header', '')
|
||||
self.assistant_id = cfg.get('assistant-id', 'lead_agent')
|
||||
self.model_name = cfg.get('model-name', '')
|
||||
self.thinking_enabled = bool(cfg.get('thinking-enabled', False))
|
||||
self.plan_mode = bool(cfg.get('plan-mode', False))
|
||||
self.subagent_enabled = bool(cfg.get('subagent-enabled', False))
|
||||
self.max_concurrent_subagents = int(cfg.get('max-concurrent-subagents', 3))
|
||||
self.timeout = int(cfg.get('timeout', 300))
|
||||
self.recursion_limit = int(cfg.get('recursion-limit', 1000))
|
||||
|
||||
self.deerflow_client = client.AsyncDeerFlowClient(
|
||||
api_base=self.api_base,
|
||||
api_key=self.api_key,
|
||||
auth_header=self.auth_header,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 辅助方法
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _fingerprint_message(self, message: dict[str, typing.Any]) -> str:
|
||||
try:
|
||||
raw = json.dumps(message, sort_keys=True, ensure_ascii=False, default=str)
|
||||
except (TypeError, ValueError):
|
||||
raw = repr(message)
|
||||
return hashlib.sha1(raw.encode('utf-8', errors='ignore')).hexdigest()
|
||||
|
||||
def _remember_seen_message_id(self, state: _StreamState, msg_id: str) -> None:
|
||||
if not msg_id or msg_id in state.seen_message_ids:
|
||||
return
|
||||
state.seen_message_ids.add(msg_id)
|
||||
state.seen_message_order.append(msg_id)
|
||||
while len(state.seen_message_order) > _MAX_VALUES_HISTORY:
|
||||
dropped = state.seen_message_order.popleft()
|
||||
state.seen_message_ids.discard(dropped)
|
||||
|
||||
def _extract_new_messages_from_values(
|
||||
self,
|
||||
values_messages: list[typing.Any],
|
||||
state: _StreamState,
|
||||
) -> list[dict[str, typing.Any]]:
|
||||
new_messages: list[dict[str, typing.Any]] = []
|
||||
no_id_indexes_seen: set[int] = set()
|
||||
for idx, msg in enumerate(values_messages):
|
||||
if not isinstance(msg, dict):
|
||||
continue
|
||||
msg_id = stream_utils.get_message_id(msg)
|
||||
if msg_id:
|
||||
if msg_id in state.seen_message_ids:
|
||||
continue
|
||||
self._remember_seen_message_id(state, msg_id)
|
||||
new_messages.append(msg)
|
||||
continue
|
||||
|
||||
no_id_indexes_seen.add(idx)
|
||||
fp = self._fingerprint_message(msg)
|
||||
if state.no_id_message_fingerprints.get(idx) == fp:
|
||||
continue
|
||||
state.no_id_message_fingerprints[idx] = fp
|
||||
new_messages.append(msg)
|
||||
|
||||
for idx in list(state.no_id_message_fingerprints.keys()):
|
||||
if idx not in no_id_indexes_seen:
|
||||
state.no_id_message_fingerprints.pop(idx, None)
|
||||
return new_messages
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 用户输入处理
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _build_user_content(
|
||||
self,
|
||||
prompt: str,
|
||||
image_urls: list[str],
|
||||
) -> typing.Any:
|
||||
"""构建 LangGraph 兼容的 user content(支持多模态)"""
|
||||
if not image_urls:
|
||||
return prompt
|
||||
|
||||
content: list[dict[str, typing.Any]] = []
|
||||
if prompt:
|
||||
content.append({'type': 'text', 'text': prompt})
|
||||
for url in image_urls:
|
||||
if not isinstance(url, str):
|
||||
continue
|
||||
url = url.strip()
|
||||
if not url:
|
||||
continue
|
||||
if url.startswith(('http://', 'https://', 'data:')):
|
||||
content.append({'type': 'image_url', 'image_url': {'url': url}})
|
||||
return content if content else prompt
|
||||
|
||||
def _preprocess_user_message(
|
||||
self,
|
||||
query: pipeline_query.Query,
|
||||
) -> tuple[str, list[str]]:
|
||||
"""提取用户消息的纯文本与图片 URL 列表"""
|
||||
plain_text = ''
|
||||
image_urls: list[str] = []
|
||||
|
||||
if isinstance(query.user_message.content, str):
|
||||
plain_text = query.user_message.content
|
||||
elif isinstance(query.user_message.content, list):
|
||||
for ce in query.user_message.content:
|
||||
if ce.type == 'text':
|
||||
plain_text += ce.text
|
||||
elif ce.type == 'image_base64':
|
||||
# 转换为 data URI 形式
|
||||
b64 = getattr(ce, 'image_base64', '')
|
||||
if b64:
|
||||
if not b64.startswith('data:'):
|
||||
b64 = f'data:image/png;base64,{b64}'
|
||||
image_urls.append(b64)
|
||||
elif ce.type == 'image_url':
|
||||
url = getattr(ce, 'image_url', '')
|
||||
if url:
|
||||
image_urls.append(url)
|
||||
|
||||
return plain_text, image_urls
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 请求构造
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _build_messages(
|
||||
self,
|
||||
prompt: str,
|
||||
image_urls: list[str],
|
||||
system_prompt: str = '',
|
||||
) -> list[dict[str, typing.Any]]:
|
||||
messages: list[dict[str, typing.Any]] = []
|
||||
if system_prompt:
|
||||
messages.append({'role': 'system', 'content': system_prompt})
|
||||
messages.append(
|
||||
{
|
||||
'role': 'user',
|
||||
'content': self._build_user_content(prompt, image_urls),
|
||||
}
|
||||
)
|
||||
return messages
|
||||
|
||||
def _build_runtime_configurable(self, thread_id: str) -> dict[str, typing.Any]:
|
||||
cfg: dict[str, typing.Any] = {
|
||||
'thread_id': thread_id,
|
||||
'thinking_enabled': self.thinking_enabled,
|
||||
'is_plan_mode': self.plan_mode,
|
||||
'subagent_enabled': self.subagent_enabled,
|
||||
}
|
||||
if self.subagent_enabled:
|
||||
cfg['max_concurrent_subagents'] = self.max_concurrent_subagents
|
||||
if self.model_name:
|
||||
cfg['model_name'] = self.model_name
|
||||
return cfg
|
||||
|
||||
def _build_payload(
|
||||
self,
|
||||
thread_id: str,
|
||||
prompt: str,
|
||||
image_urls: list[str],
|
||||
system_prompt: str = '',
|
||||
) -> dict[str, typing.Any]:
|
||||
runtime_configurable = self._build_runtime_configurable(thread_id)
|
||||
return {
|
||||
'assistant_id': self.assistant_id,
|
||||
'input': {
|
||||
'messages': self._build_messages(prompt, image_urls, system_prompt),
|
||||
},
|
||||
'stream_mode': ['values', 'messages-tuple', 'custom'],
|
||||
# DeerFlow 2.0 从 config.configurable 读取运行时覆盖
|
||||
# 同时保留 context 字段做向后兼容
|
||||
'context': dict(runtime_configurable),
|
||||
'config': {
|
||||
'recursion_limit': self.recursion_limit,
|
||||
'configurable': runtime_configurable,
|
||||
},
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Session/Thread 管理
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def _ensure_thread_id(self, query: pipeline_query.Query) -> str:
|
||||
"""从 query.session 取/创建 deerflow thread_id
|
||||
|
||||
LangBot 使用 `query.session.using_conversation.uuid` 持久化 conversation id,
|
||||
我们复用这个字段存储 deerflow thread_id(与 Dify Runner 同样做法)。
|
||||
"""
|
||||
thread_id = query.session.using_conversation.uuid or ''
|
||||
if thread_id:
|
||||
return thread_id
|
||||
|
||||
thread = await self.deerflow_client.create_thread(timeout=min(30, self.timeout))
|
||||
thread_id = thread.get('thread_id', '')
|
||||
if not thread_id:
|
||||
raise errors.DeerFlowAPIError(message=f'DeerFlow create thread 返回数据缺少 thread_id: {thread}')
|
||||
|
||||
query.session.using_conversation.uuid = thread_id
|
||||
return thread_id
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 流式事件处理
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def _handle_values_event(
|
||||
self,
|
||||
data: typing.Any,
|
||||
state: _StreamState,
|
||||
) -> str | None:
|
||||
"""处理 values 事件,返回新的完整文本(增量基础上的全量)"""
|
||||
values_messages = stream_utils.extract_messages_from_values_data(data)
|
||||
if not values_messages:
|
||||
return None
|
||||
|
||||
new_messages: list[dict[str, typing.Any]] = []
|
||||
if not state.baseline_initialized:
|
||||
state.baseline_initialized = True
|
||||
for idx, msg in enumerate(values_messages):
|
||||
if not isinstance(msg, dict):
|
||||
continue
|
||||
new_messages.append(msg)
|
||||
msg_id = stream_utils.get_message_id(msg)
|
||||
if msg_id:
|
||||
self._remember_seen_message_id(state, msg_id)
|
||||
continue
|
||||
state.no_id_message_fingerprints[idx] = self._fingerprint_message(msg)
|
||||
else:
|
||||
new_messages = self._extract_new_messages_from_values(values_messages, state)
|
||||
|
||||
latest_text = ''
|
||||
if new_messages:
|
||||
state.run_values_messages.extend(new_messages)
|
||||
if len(state.run_values_messages) > _MAX_VALUES_HISTORY:
|
||||
state.run_values_messages = state.run_values_messages[-_MAX_VALUES_HISTORY:]
|
||||
latest_text = stream_utils.extract_latest_ai_text(state.run_values_messages)
|
||||
if latest_text:
|
||||
state.has_values_text = True
|
||||
latest_clarification = stream_utils.extract_latest_clarification_text(
|
||||
state.run_values_messages,
|
||||
)
|
||||
if latest_clarification:
|
||||
state.clarification_text = latest_clarification
|
||||
|
||||
return latest_text or None
|
||||
|
||||
def _handle_message_event(
|
||||
self,
|
||||
data: typing.Any,
|
||||
state: _StreamState,
|
||||
) -> str | None:
|
||||
"""处理 messages-tuple 事件,返回增量文本
|
||||
|
||||
当 values 事件已经提供完整文本时,跳过 messages-tuple 的增量
|
||||
"""
|
||||
delta = stream_utils.extract_ai_delta_from_event_data(data)
|
||||
if delta and not state.has_values_text:
|
||||
state.latest_text += delta
|
||||
return delta
|
||||
|
||||
maybe_clar = stream_utils.extract_clarification_from_event_data(data)
|
||||
if maybe_clar:
|
||||
state.clarification_text = maybe_clar
|
||||
return None
|
||||
|
||||
def _build_final_text(self, state: _StreamState) -> str:
|
||||
"""构建最终输出文本"""
|
||||
if state.clarification_text:
|
||||
return state.clarification_text
|
||||
|
||||
# 优先使用最后一条 AI message 的文本
|
||||
latest_ai = stream_utils.extract_latest_ai_message(state.run_values_messages)
|
||||
if latest_ai:
|
||||
text = stream_utils.extract_text(latest_ai.get('content'))
|
||||
if text:
|
||||
if state.timed_out:
|
||||
text += f'\n\nDeerFlow stream 在 {self.timeout}s 后超时,返回部分结果。'
|
||||
return text
|
||||
|
||||
if state.latest_text:
|
||||
text = state.latest_text
|
||||
if state.timed_out:
|
||||
text += f'\n\nDeerFlow stream 在 {self.timeout}s 后超时,返回部分结果。'
|
||||
return text
|
||||
|
||||
# 提取任务失败信息作兜底
|
||||
failure_text = stream_utils.build_task_failure_summary(state.task_failures)
|
||||
if failure_text:
|
||||
return failure_text
|
||||
|
||||
return 'DeerFlow 返回空响应'
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 主流程
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def _stream_messages_chunk(
|
||||
self,
|
||||
query: pipeline_query.Query,
|
||||
) -> typing.AsyncGenerator[provider_message.MessageChunk, None]:
|
||||
"""流式输出生成器"""
|
||||
plain_text, image_urls = self._preprocess_user_message(query)
|
||||
|
||||
system_prompt = ''
|
||||
# LangBot 的 pipeline 通常通过 prompt-preprocess 已注入 system prompt
|
||||
# 这里保持空,让 prompt-preprocess 的内容作为 user message 一并送给 deerflow
|
||||
|
||||
thread_id = await self._ensure_thread_id(query)
|
||||
payload = self._build_payload(
|
||||
thread_id=thread_id,
|
||||
prompt=plain_text or 'continue',
|
||||
image_urls=image_urls,
|
||||
system_prompt=system_prompt,
|
||||
)
|
||||
|
||||
state = _StreamState()
|
||||
prev_text = ''
|
||||
message_idx = 0
|
||||
|
||||
try:
|
||||
async for event in self.deerflow_client.stream_run(
|
||||
thread_id=thread_id,
|
||||
payload=payload,
|
||||
timeout=self.timeout,
|
||||
):
|
||||
event_type = event.get('event')
|
||||
data = event.get('data')
|
||||
|
||||
if event_type == 'values':
|
||||
new_full = self._handle_values_event(data, state)
|
||||
if new_full and new_full != prev_text:
|
||||
delta = new_full[len(prev_text) :] if new_full.startswith(prev_text) else new_full
|
||||
prev_text = new_full
|
||||
if delta:
|
||||
message_idx += 1
|
||||
yield provider_message.MessageChunk(
|
||||
role='assistant',
|
||||
content=new_full,
|
||||
is_final=False,
|
||||
)
|
||||
continue
|
||||
|
||||
if event_type in {'messages-tuple', 'messages', 'message'}:
|
||||
delta = self._handle_message_event(data, state)
|
||||
if delta:
|
||||
prev_text = state.latest_text
|
||||
message_idx += 1
|
||||
yield provider_message.MessageChunk(
|
||||
role='assistant',
|
||||
content=prev_text,
|
||||
is_final=False,
|
||||
)
|
||||
continue
|
||||
|
||||
if event_type == 'custom':
|
||||
state.task_failures.extend(
|
||||
stream_utils.extract_task_failures_from_custom_event(data),
|
||||
)
|
||||
continue
|
||||
|
||||
if event_type == 'error':
|
||||
raise errors.DeerFlowAPIError(message=f'DeerFlow stream error event: {data}')
|
||||
|
||||
if event_type == 'end':
|
||||
break
|
||||
except (asyncio.TimeoutError, TimeoutError):
|
||||
self.ap.logger.warning(f'DeerFlow stream timed out after {self.timeout}s for thread_id={thread_id}')
|
||||
state.timed_out = True
|
||||
|
||||
# 最终消息
|
||||
final_text = self._build_final_text(state)
|
||||
yield provider_message.MessageChunk(
|
||||
role='assistant',
|
||||
content=final_text,
|
||||
is_final=True,
|
||||
)
|
||||
|
||||
async def _messages(
|
||||
self,
|
||||
query: pipeline_query.Query,
|
||||
) -> typing.AsyncGenerator[provider_message.Message, None]:
|
||||
"""非流式聚合输出"""
|
||||
plain_text, image_urls = self._preprocess_user_message(query)
|
||||
|
||||
thread_id = await self._ensure_thread_id(query)
|
||||
payload = self._build_payload(
|
||||
thread_id=thread_id,
|
||||
prompt=plain_text or 'continue',
|
||||
image_urls=image_urls,
|
||||
)
|
||||
|
||||
state = _StreamState()
|
||||
|
||||
try:
|
||||
async for event in self.deerflow_client.stream_run(
|
||||
thread_id=thread_id,
|
||||
payload=payload,
|
||||
timeout=self.timeout,
|
||||
):
|
||||
event_type = event.get('event')
|
||||
data = event.get('data')
|
||||
|
||||
if event_type == 'values':
|
||||
self._handle_values_event(data, state)
|
||||
continue
|
||||
|
||||
if event_type in {'messages-tuple', 'messages', 'message'}:
|
||||
self._handle_message_event(data, state)
|
||||
continue
|
||||
|
||||
if event_type == 'custom':
|
||||
state.task_failures.extend(
|
||||
stream_utils.extract_task_failures_from_custom_event(data),
|
||||
)
|
||||
continue
|
||||
|
||||
if event_type == 'error':
|
||||
raise errors.DeerFlowAPIError(message=f'DeerFlow stream error event: {data}')
|
||||
|
||||
if event_type == 'end':
|
||||
break
|
||||
except (asyncio.TimeoutError, TimeoutError):
|
||||
self.ap.logger.warning(f'DeerFlow stream timed out after {self.timeout}s for thread_id={thread_id}')
|
||||
state.timed_out = True
|
||||
|
||||
final_text = self._build_final_text(state)
|
||||
yield provider_message.Message(
|
||||
role='assistant',
|
||||
content=final_text,
|
||||
)
|
||||
|
||||
async def run(
|
||||
self,
|
||||
query: pipeline_query.Query,
|
||||
) -> typing.AsyncGenerator[provider_message.Message, None]:
|
||||
"""主入口:根据 adapter 是否支持流式输出,选择流式或非流式"""
|
||||
if await query.adapter.is_stream_output_supported():
|
||||
msg_idx = 0
|
||||
async for msg in self._stream_messages_chunk(query):
|
||||
msg_idx += 1
|
||||
msg.msg_sequence = msg_idx
|
||||
yield msg
|
||||
else:
|
||||
async for msg in self._messages(query):
|
||||
yield msg
|
||||
@@ -34,6 +34,13 @@ SANDBOX_EXEC_SYSTEM_GUIDANCE = (
|
||||
)
|
||||
|
||||
|
||||
# Hard cap on tool-call rounds within a single agent turn. A looping or
|
||||
# adversarial model can otherwise emit tool calls indefinitely (each potentially
|
||||
# a sandbox exec), yielding a non-terminating request and runaway cost. Set
|
||||
# generously so it never interrupts legitimate multi-step agentic workflows.
|
||||
MAX_TOOL_CALL_ROUNDS = 128
|
||||
|
||||
|
||||
@runner.runner_class('local-agent')
|
||||
class LocalAgentRunner(runner.RequestRunner):
|
||||
"""Local agent request runner"""
|
||||
@@ -363,7 +370,15 @@ class LocalAgentRunner(runner.RequestRunner):
|
||||
|
||||
# Once a model succeeds, commit to it for the tool call loop
|
||||
# (no fallback mid-conversation — different models may interpret tool results differently)
|
||||
tool_call_round = 0
|
||||
while pending_tool_calls:
|
||||
tool_call_round += 1
|
||||
if tool_call_round > MAX_TOOL_CALL_ROUNDS:
|
||||
self.ap.logger.warning(
|
||||
f'Tool-call loop reached the {MAX_TOOL_CALL_ROUNDS}-round cap '
|
||||
f'(query_id={query.query_id}); stopping to avoid a non-terminating request.'
|
||||
)
|
||||
break
|
||||
for tool_call in pending_tool_calls:
|
||||
try:
|
||||
func = tool_call.function
|
||||
|
||||
351
src/langbot/pkg/provider/runners/weknoraapi.py
Normal file
351
src/langbot/pkg/provider/runners/weknoraapi.py
Normal file
@@ -0,0 +1,351 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import typing
|
||||
import json
|
||||
|
||||
|
||||
from langbot.pkg.provider import runner
|
||||
from langbot.pkg.core import app
|
||||
import langbot_plugin.api.entities.builtin.provider.message as provider_message
|
||||
import langbot_plugin.api.entities.builtin.pipeline.query as pipeline_query
|
||||
from langbot.libs.weknora_api import client, errors
|
||||
|
||||
|
||||
@runner.runner_class('weknora-api')
|
||||
class WeKnoraAPIRunner(runner.RequestRunner):
|
||||
"""WeKnora API 对话请求器"""
|
||||
|
||||
weknora_client: client.AsyncWeKnoraClient
|
||||
|
||||
def __init__(self, ap: app.Application, pipeline_config: dict):
|
||||
super().__init__(ap, pipeline_config)
|
||||
|
||||
valid_app_types = ['chat', 'agent']
|
||||
if self.pipeline_config['ai']['weknora-api']['app-type'] not in valid_app_types:
|
||||
raise errors.WeKnoraAPIError(
|
||||
f'不支持的 WeKnora 应用类型: {self.pipeline_config["ai"]["weknora-api"]["app-type"]}'
|
||||
)
|
||||
|
||||
api_key = self.pipeline_config['ai']['weknora-api'].get('api-key', '').strip()
|
||||
if not api_key:
|
||||
raise errors.WeKnoraAPIError(
|
||||
'WeKnora API Key 未配置,请在流水线的 WeKnora API 配置中填入 API Key '
|
||||
'(从 WeKnora 前端 设置 → API Keys 生成)'
|
||||
)
|
||||
|
||||
base_url = self.pipeline_config['ai']['weknora-api'].get('base-url', '').strip()
|
||||
if not base_url:
|
||||
raise errors.WeKnoraAPIError('WeKnora Base URL 未配置,请填入服务器地址,例如 http://localhost:8080/api/v1')
|
||||
|
||||
self.weknora_client = client.AsyncWeKnoraClient(
|
||||
api_key=api_key,
|
||||
base_url=base_url,
|
||||
)
|
||||
|
||||
async def _extract_plain_text(self, query: pipeline_query.Query) -> str:
|
||||
"""从用户消息中提取纯文本内容"""
|
||||
plain_text = ''
|
||||
if isinstance(query.user_message.content, str):
|
||||
plain_text = query.user_message.content
|
||||
elif isinstance(query.user_message.content, list):
|
||||
for ce in query.user_message.content:
|
||||
if ce.type == 'text':
|
||||
plain_text += ce.text
|
||||
|
||||
if not plain_text:
|
||||
plain_text = self.pipeline_config['ai']['weknora-api'].get('base-prompt', '')
|
||||
|
||||
return plain_text
|
||||
|
||||
async def _ensure_session(self, query: pipeline_query.Query) -> str:
|
||||
"""确保会话存在,如果不存在则创建"""
|
||||
session_id = query.session.using_conversation.uuid or ''
|
||||
|
||||
if not session_id:
|
||||
user_tag = f'{query.session.launcher_type.value}_{query.session.launcher_id}'
|
||||
session_id = await self.weknora_client.create_session(title=f'IM Chat - {user_tag}')
|
||||
query.session.using_conversation.uuid = session_id
|
||||
|
||||
return session_id
|
||||
|
||||
async def _agent_chat_messages(
|
||||
self, query: pipeline_query.Query
|
||||
) -> typing.AsyncGenerator[provider_message.Message, None]:
|
||||
"""调用 Agent 智能对话(非流式聚合输出)"""
|
||||
session_id = await self._ensure_session(query)
|
||||
plain_text = await self._extract_plain_text(query)
|
||||
user_tag = f'{query.session.launcher_type.value}_{query.session.launcher_id}'
|
||||
|
||||
config = self.pipeline_config['ai']['weknora-api']
|
||||
agent_id = config.get('agent-id', 'builtin-smart-reasoning')
|
||||
knowledge_base_ids = config.get('knowledge-base-ids', [])
|
||||
web_search_enabled = config.get('web-search-enabled', False)
|
||||
timeout = config.get('timeout', 120)
|
||||
|
||||
full_answer = ''
|
||||
chunk = None
|
||||
|
||||
async for chunk in self.weknora_client.agent_chat(
|
||||
session_id=session_id,
|
||||
query=plain_text,
|
||||
user=user_tag,
|
||||
agent_id=agent_id,
|
||||
knowledge_base_ids=knowledge_base_ids,
|
||||
web_search_enabled=web_search_enabled,
|
||||
timeout=timeout,
|
||||
):
|
||||
self.ap.logger.debug('weknora-agent-chunk: ' + str(chunk))
|
||||
|
||||
response_type = chunk.get('response_type', '')
|
||||
content = chunk.get('content', '')
|
||||
|
||||
if response_type == 'tool_call':
|
||||
# 工具调用
|
||||
tool_data = chunk.get('data', {})
|
||||
tool_name = tool_data.get('tool_name', '')
|
||||
if tool_name:
|
||||
yield provider_message.Message(
|
||||
role='assistant',
|
||||
tool_calls=[
|
||||
provider_message.ToolCall(
|
||||
id=chunk.get('id', ''),
|
||||
type='function',
|
||||
function=provider_message.FunctionCall(
|
||||
name=tool_name,
|
||||
arguments=json.dumps(tool_data.get('arguments', {})),
|
||||
),
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
elif response_type == 'answer':
|
||||
if content:
|
||||
full_answer += content
|
||||
|
||||
elif response_type == 'error':
|
||||
raise errors.WeKnoraAPIError(f'WeKnora 服务错误: {content}')
|
||||
|
||||
if chunk is None:
|
||||
raise errors.WeKnoraAPIError('WeKnora API 没有返回任何响应,请检查网络连接和API配置')
|
||||
|
||||
if full_answer:
|
||||
yield provider_message.Message(
|
||||
role='assistant',
|
||||
content=full_answer,
|
||||
)
|
||||
|
||||
async def _chat_messages(
|
||||
self, query: pipeline_query.Query
|
||||
) -> typing.AsyncGenerator[provider_message.Message, None]:
|
||||
"""调用知识库 RAG 问答(非流式聚合输出)"""
|
||||
session_id = await self._ensure_session(query)
|
||||
plain_text = await self._extract_plain_text(query)
|
||||
user_tag = f'{query.session.launcher_type.value}_{query.session.launcher_id}'
|
||||
|
||||
config = self.pipeline_config['ai']['weknora-api']
|
||||
agent_id = config.get('agent-id', 'builtin-quick-answer')
|
||||
knowledge_base_ids = config.get('knowledge-base-ids', [])
|
||||
timeout = config.get('timeout', 120)
|
||||
|
||||
full_answer = ''
|
||||
chunk = None
|
||||
|
||||
async for chunk in self.weknora_client.knowledge_chat(
|
||||
session_id=session_id,
|
||||
query=plain_text,
|
||||
user=user_tag,
|
||||
agent_id=agent_id,
|
||||
knowledge_base_ids=knowledge_base_ids,
|
||||
timeout=timeout,
|
||||
):
|
||||
self.ap.logger.debug('weknora-chat-chunk: ' + str(chunk))
|
||||
|
||||
response_type = chunk.get('response_type', '')
|
||||
content = chunk.get('content', '')
|
||||
|
||||
if response_type == 'answer':
|
||||
if content:
|
||||
full_answer += content
|
||||
|
||||
elif response_type == 'error':
|
||||
raise errors.WeKnoraAPIError(f'WeKnora 服务错误: {content}')
|
||||
|
||||
if chunk is None:
|
||||
raise errors.WeKnoraAPIError('WeKnora API 没有返回任何响应,请检查网络连接和API配置')
|
||||
|
||||
if full_answer:
|
||||
yield provider_message.Message(
|
||||
role='assistant',
|
||||
content=full_answer,
|
||||
)
|
||||
|
||||
async def _agent_chat_messages_chunk(
|
||||
self, query: pipeline_query.Query
|
||||
) -> typing.AsyncGenerator[provider_message.MessageChunk, None]:
|
||||
"""调用 Agent 智能对话(流式输出)"""
|
||||
session_id = await self._ensure_session(query)
|
||||
plain_text = await self._extract_plain_text(query)
|
||||
user_tag = f'{query.session.launcher_type.value}_{query.session.launcher_id}'
|
||||
|
||||
config = self.pipeline_config['ai']['weknora-api']
|
||||
agent_id = config.get('agent-id', 'builtin-smart-reasoning')
|
||||
knowledge_base_ids = config.get('knowledge-base-ids', [])
|
||||
web_search_enabled = config.get('web-search-enabled', False)
|
||||
timeout = config.get('timeout', 120)
|
||||
|
||||
pending_answer = ''
|
||||
message_idx = 0
|
||||
is_final = False
|
||||
chunk = None
|
||||
|
||||
async for chunk in self.weknora_client.agent_chat(
|
||||
session_id=session_id,
|
||||
query=plain_text,
|
||||
user=user_tag,
|
||||
agent_id=agent_id,
|
||||
knowledge_base_ids=knowledge_base_ids,
|
||||
web_search_enabled=web_search_enabled,
|
||||
timeout=timeout,
|
||||
):
|
||||
self.ap.logger.debug('weknora-agent-chunk: ' + str(chunk))
|
||||
|
||||
response_type = chunk.get('response_type', '')
|
||||
content = chunk.get('content', '')
|
||||
done = chunk.get('done', False)
|
||||
|
||||
if response_type == 'tool_call':
|
||||
tool_data = chunk.get('data', {})
|
||||
tool_name = tool_data.get('tool_name', '')
|
||||
if tool_name:
|
||||
message_idx += 1
|
||||
yield provider_message.MessageChunk(
|
||||
role='assistant',
|
||||
tool_calls=[
|
||||
provider_message.ToolCall(
|
||||
id=chunk.get('id', ''),
|
||||
type='function',
|
||||
function=provider_message.FunctionCall(
|
||||
name=tool_name,
|
||||
arguments=json.dumps(tool_data.get('arguments', {})),
|
||||
),
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
elif response_type == 'answer':
|
||||
message_idx += 1
|
||||
if content:
|
||||
pending_answer += content
|
||||
|
||||
if done:
|
||||
is_final = True
|
||||
|
||||
# 每 8 个 chunk 输出一次,或最终输出
|
||||
if message_idx % 8 == 0 or is_final:
|
||||
yield provider_message.MessageChunk(
|
||||
role='assistant',
|
||||
content=pending_answer,
|
||||
is_final=is_final,
|
||||
)
|
||||
|
||||
elif response_type == 'error':
|
||||
raise errors.WeKnoraAPIError(f'WeKnora 服务错误: {content}')
|
||||
|
||||
if chunk is None:
|
||||
raise errors.WeKnoraAPIError('WeKnora API 没有返回任何响应,请检查网络连接和API配置')
|
||||
|
||||
# 确保最终消息已发出
|
||||
if not is_final and pending_answer:
|
||||
yield provider_message.MessageChunk(
|
||||
role='assistant',
|
||||
content=pending_answer,
|
||||
is_final=True,
|
||||
)
|
||||
|
||||
async def _chat_messages_chunk(
|
||||
self, query: pipeline_query.Query
|
||||
) -> typing.AsyncGenerator[provider_message.MessageChunk, None]:
|
||||
"""调用知识库 RAG 问答(流式输出)"""
|
||||
session_id = await self._ensure_session(query)
|
||||
plain_text = await self._extract_plain_text(query)
|
||||
user_tag = f'{query.session.launcher_type.value}_{query.session.launcher_id}'
|
||||
|
||||
config = self.pipeline_config['ai']['weknora-api']
|
||||
agent_id = config.get('agent-id', 'builtin-quick-answer')
|
||||
knowledge_base_ids = config.get('knowledge-base-ids', [])
|
||||
timeout = config.get('timeout', 120)
|
||||
|
||||
pending_answer = ''
|
||||
message_idx = 0
|
||||
is_final = False
|
||||
chunk = None
|
||||
|
||||
async for chunk in self.weknora_client.knowledge_chat(
|
||||
session_id=session_id,
|
||||
query=plain_text,
|
||||
user=user_tag,
|
||||
agent_id=agent_id,
|
||||
knowledge_base_ids=knowledge_base_ids,
|
||||
timeout=timeout,
|
||||
):
|
||||
self.ap.logger.debug('weknora-chat-chunk: ' + str(chunk))
|
||||
|
||||
response_type = chunk.get('response_type', '')
|
||||
content = chunk.get('content', '')
|
||||
done = chunk.get('done', False)
|
||||
|
||||
if response_type == 'answer':
|
||||
message_idx += 1
|
||||
if content:
|
||||
pending_answer += content
|
||||
|
||||
if done:
|
||||
is_final = True
|
||||
|
||||
if message_idx % 8 == 0 or is_final:
|
||||
yield provider_message.MessageChunk(
|
||||
role='assistant',
|
||||
content=pending_answer,
|
||||
is_final=is_final,
|
||||
)
|
||||
|
||||
elif response_type == 'error':
|
||||
raise errors.WeKnoraAPIError(f'WeKnora 服务错误: {content}')
|
||||
|
||||
if chunk is None:
|
||||
raise errors.WeKnoraAPIError('WeKnora API 没有返回任何响应,请检查网络连接和API配置')
|
||||
|
||||
if not is_final and pending_answer:
|
||||
yield provider_message.MessageChunk(
|
||||
role='assistant',
|
||||
content=pending_answer,
|
||||
is_final=True,
|
||||
)
|
||||
|
||||
async def run(self, query: pipeline_query.Query) -> typing.AsyncGenerator[provider_message.Message, None]:
|
||||
"""运行请求"""
|
||||
app_type = self.pipeline_config['ai']['weknora-api']['app-type']
|
||||
|
||||
if await query.adapter.is_stream_output_supported():
|
||||
msg_idx = 0
|
||||
if app_type == 'agent':
|
||||
async for msg in self._agent_chat_messages_chunk(query):
|
||||
msg_idx += 1
|
||||
msg.msg_sequence = msg_idx
|
||||
yield msg
|
||||
elif app_type == 'chat':
|
||||
async for msg in self._chat_messages_chunk(query):
|
||||
msg_idx += 1
|
||||
msg.msg_sequence = msg_idx
|
||||
yield msg
|
||||
else:
|
||||
raise errors.WeKnoraAPIError(f'不支持的 WeKnora 应用类型: {app_type}')
|
||||
else:
|
||||
if app_type == 'agent':
|
||||
async for msg in self._agent_chat_messages(query):
|
||||
yield msg
|
||||
elif app_type == 'chat':
|
||||
async for msg in self._chat_messages(query):
|
||||
yield msg
|
||||
else:
|
||||
raise errors.WeKnoraAPIError(f'不支持的 WeKnora 应用类型: {app_type}')
|
||||
@@ -240,12 +240,13 @@ class RuntimeMCPSession:
|
||||
return
|
||||
if attempt >= self._MAX_RETRIES:
|
||||
self.status = MCPSessionStatus.ERROR
|
||||
self.error_message = f'Failed after {self._MAX_RETRIES + 1} attempts: {e}'
|
||||
self.error_message = f'Failed after {self._MAX_RETRIES + 1} attempts: {self._describe_exception(e)}'
|
||||
self._ready_event.set()
|
||||
return
|
||||
delay = self._RETRY_DELAYS[attempt]
|
||||
self.ap.logger.warning(
|
||||
f'MCP session {self.server_name} failed (attempt {attempt + 1}), retrying in {delay}s: {e}'
|
||||
f'MCP session {self.server_name} failed (attempt {attempt + 1}), '
|
||||
f'retrying in {delay}s: {self._describe_exception(e)}'
|
||||
)
|
||||
await self._cleanup_box_stdio_session()
|
||||
# Reset status for retry
|
||||
@@ -254,6 +255,30 @@ class RuntimeMCPSession:
|
||||
self.error_phase = None
|
||||
await asyncio.sleep(delay)
|
||||
|
||||
@staticmethod
|
||||
def _describe_exception(exc: BaseException) -> str:
|
||||
"""Flatten an exception into its underlying leaf messages.
|
||||
|
||||
anyio / the MCP client wrap real failures in a TaskGroup, whose own
|
||||
message is the unhelpful "unhandled errors in a TaskGroup (N
|
||||
sub-exception)". Recurse into ExceptionGroups so the actual cause
|
||||
(e.g. ``httpx.HTTPStatusError: Client error '410 Gone'``) is surfaced.
|
||||
"""
|
||||
leaves: list[str] = []
|
||||
|
||||
def visit(e: BaseException) -> None:
|
||||
sub = getattr(e, 'exceptions', None)
|
||||
if sub: # ExceptionGroup / BaseExceptionGroup
|
||||
for child in sub:
|
||||
visit(child)
|
||||
else:
|
||||
leaves.append(f'{type(e).__name__}: {e}')
|
||||
|
||||
visit(exc)
|
||||
seen: set[str] = set()
|
||||
unique = [m for m in leaves if not (m in seen or seen.add(m))]
|
||||
return '; '.join(unique) if unique else f'{type(exc).__name__}: {exc}'
|
||||
|
||||
_MONITOR_POLL_INTERVAL = 5
|
||||
_MONITOR_MAX_CONSECUTIVE_ERRORS = 3
|
||||
|
||||
|
||||
@@ -112,7 +112,7 @@ box:
|
||||
# skill tool, skill add/edit, and stdio-mode MCP servers. Skills can still
|
||||
# be listed read-only and http/sse MCP servers continue to work.
|
||||
enabled: true
|
||||
backend: 'local' # 'local' (Docker/nsjail), 'docker', 'nsjail', or 'e2b'. BOX_BACKEND env var takes precedence.
|
||||
backend: 'local' # 'local' (Docker/nsjail), 'docker', 'nsjail', or 'e2b'. Can be written via BOX__BACKEND.
|
||||
runtime:
|
||||
endpoint: '' # External Box Runtime base URL, e.g. 'ws://127.0.0.1:5410'. Leave empty for local auto-managed runtime.
|
||||
local:
|
||||
|
||||
@@ -47,6 +47,14 @@ stages:
|
||||
label:
|
||||
en_US: Langflow API
|
||||
zh_Hans: Langflow API
|
||||
- name: weknora-api
|
||||
label:
|
||||
en_US: WeKnora API
|
||||
zh_Hans: WeKnora API
|
||||
- name: deerflow-api
|
||||
label:
|
||||
en_US: DeerFlow API
|
||||
zh_Hans: DeerFlow API
|
||||
- name: expire-time
|
||||
label:
|
||||
en_US: Conversation expire time (seconds)
|
||||
@@ -653,3 +661,215 @@ stages:
|
||||
type: json
|
||||
required: false
|
||||
default: '{}'
|
||||
- name: weknora-api
|
||||
label:
|
||||
en_US: WeKnora API
|
||||
zh_Hans: WeKnora API
|
||||
description:
|
||||
en_US: Configure the WeKnora API of the pipeline
|
||||
zh_Hans: 配置 WeKnora API
|
||||
config:
|
||||
- name: base-url
|
||||
label:
|
||||
en_US: Base URL
|
||||
zh_Hans: 基础 URL
|
||||
description:
|
||||
en_US: The base URL of the WeKnora server (with /api/v1)
|
||||
zh_Hans: WeKnora 服务器的基础 URL(包含 /api/v1)
|
||||
type: string
|
||||
required: true
|
||||
default: 'http://localhost:8080/api/v1'
|
||||
- name: api-key
|
||||
label:
|
||||
en_US: API Key
|
||||
zh_Hans: API 密钥
|
||||
description:
|
||||
en_US: The API key for WeKnora, generated from WeKnora frontend Settings → API Keys
|
||||
zh_Hans: WeKnora 的 API 密钥,从 WeKnora 前端 设置 → API Keys 生成
|
||||
type: string
|
||||
required: true
|
||||
default: ''
|
||||
- name: app-type
|
||||
label:
|
||||
en_US: App Type
|
||||
zh_Hans: 应用类型
|
||||
type: select
|
||||
required: true
|
||||
default: agent
|
||||
options:
|
||||
- name: agent
|
||||
label:
|
||||
en_US: Agent (Smart Reasoning)
|
||||
zh_Hans: Agent(智能推理)
|
||||
- name: chat
|
||||
label:
|
||||
en_US: Chat (Knowledge Base RAG)
|
||||
zh_Hans: 聊天(知识库 RAG)
|
||||
- name: agent-id
|
||||
label:
|
||||
en_US: Agent ID
|
||||
zh_Hans: 智能体 ID
|
||||
description:
|
||||
en_US: The Agent ID to use. Built-in agents include builtin-quick-answer, builtin-smart-reasoning, builtin-data-analyst
|
||||
zh_Hans: 要使用的智能体 ID。内置智能体:builtin-quick-answer、builtin-smart-reasoning、builtin-data-analyst
|
||||
type: string
|
||||
required: true
|
||||
default: 'builtin-smart-reasoning'
|
||||
- name: knowledge-base-ids
|
||||
label:
|
||||
en_US: Knowledge Base IDs
|
||||
zh_Hans: 知识库 ID 列表
|
||||
description:
|
||||
en_US: List of WeKnora knowledge base IDs to use (one per line)
|
||||
zh_Hans: 要使用的 WeKnora 知识库 ID 列表(每行一个)
|
||||
type: array
|
||||
required: false
|
||||
default: []
|
||||
- name: web-search-enabled
|
||||
label:
|
||||
en_US: Enable Web Search
|
||||
zh_Hans: 启用网络搜索
|
||||
description:
|
||||
en_US: Whether to enable web search in agent mode
|
||||
zh_Hans: 在 Agent 模式下是否启用网络搜索
|
||||
type: boolean
|
||||
required: false
|
||||
default: false
|
||||
- name: timeout
|
||||
label:
|
||||
en_US: Timeout
|
||||
zh_Hans: 超时时间
|
||||
description:
|
||||
en_US: Request timeout in seconds
|
||||
zh_Hans: 请求超时时间(秒)
|
||||
type: integer
|
||||
required: false
|
||||
default: 120
|
||||
- name: base-prompt
|
||||
label:
|
||||
en_US: Base Prompt
|
||||
zh_Hans: 基础提示词
|
||||
description:
|
||||
en_US: Default prompt when user message is empty (e.g. only images)
|
||||
zh_Hans: 当用户消息为空(例如仅图片)时使用的默认提示词
|
||||
type: string
|
||||
required: false
|
||||
default: '请回答用户的问题。'
|
||||
- name: deerflow-api
|
||||
label:
|
||||
en_US: DeerFlow API
|
||||
zh_Hans: DeerFlow API
|
||||
description:
|
||||
en_US: Configure the DeerFlow LangGraph API of the pipeline
|
||||
zh_Hans: 配置 DeerFlow LangGraph API
|
||||
config:
|
||||
- name: api-base
|
||||
label:
|
||||
en_US: API Base URL
|
||||
zh_Hans: API 基础 URL
|
||||
description:
|
||||
en_US: The base URL of the DeerFlow server (e.g. http://127.0.0.1:2026)
|
||||
zh_Hans: DeerFlow 服务器的基础 URL(例如 http://127.0.0.1:2026)
|
||||
type: string
|
||||
required: true
|
||||
default: 'http://127.0.0.1:2026'
|
||||
- name: api-key
|
||||
label:
|
||||
en_US: API Key
|
||||
zh_Hans: API 密钥
|
||||
description:
|
||||
en_US: Optional API key for DeerFlow (leave empty if not required)
|
||||
zh_Hans: DeerFlow 的 API 密钥(如果不需要可留空)
|
||||
type: string
|
||||
required: false
|
||||
default: ''
|
||||
- name: auth-header
|
||||
label:
|
||||
en_US: Auth Header Name
|
||||
zh_Hans: 鉴权请求头名称
|
||||
description:
|
||||
en_US: Custom auth header name. Leave empty to use "x-api-key"
|
||||
zh_Hans: 自定义鉴权请求头名称,留空则使用 "x-api-key"
|
||||
type: string
|
||||
required: false
|
||||
default: ''
|
||||
- name: assistant-id
|
||||
label:
|
||||
en_US: Assistant ID
|
||||
zh_Hans: 助手 ID
|
||||
description:
|
||||
en_US: The DeerFlow assistant/graph id (default lead_agent)
|
||||
zh_Hans: DeerFlow 助手/图 ID(默认 lead_agent)
|
||||
type: string
|
||||
required: true
|
||||
default: 'lead_agent'
|
||||
- name: model-name
|
||||
label:
|
||||
en_US: Model Name
|
||||
zh_Hans: 模型名称
|
||||
description:
|
||||
en_US: Optional model override forwarded to DeerFlow configurable
|
||||
zh_Hans: 可选的模型名称覆盖,会作为 configurable 转发给 DeerFlow
|
||||
type: string
|
||||
required: false
|
||||
default: ''
|
||||
- name: thinking-enabled
|
||||
label:
|
||||
en_US: Enable Thinking
|
||||
zh_Hans: 启用思考
|
||||
description:
|
||||
en_US: Whether to enable DeerFlow thinking mode
|
||||
zh_Hans: 是否启用 DeerFlow 思考模式
|
||||
type: boolean
|
||||
required: false
|
||||
default: false
|
||||
- name: plan-mode
|
||||
label:
|
||||
en_US: Plan Mode
|
||||
zh_Hans: 规划模式
|
||||
description:
|
||||
en_US: Whether to enable DeerFlow plan mode
|
||||
zh_Hans: 是否启用 DeerFlow 规划模式
|
||||
type: boolean
|
||||
required: false
|
||||
default: false
|
||||
- name: subagent-enabled
|
||||
label:
|
||||
en_US: Enable Subagents
|
||||
zh_Hans: 启用子代理
|
||||
description:
|
||||
en_US: Whether to enable parallel subagent execution
|
||||
zh_Hans: 是否启用并行子代理执行
|
||||
type: boolean
|
||||
required: false
|
||||
default: false
|
||||
- name: max-concurrent-subagents
|
||||
label:
|
||||
en_US: Max Concurrent Subagents
|
||||
zh_Hans: 最大并发子代理数
|
||||
description:
|
||||
en_US: Maximum number of concurrent subagents (only effective when subagents are enabled)
|
||||
zh_Hans: 最大并发子代理数(仅在启用子代理时生效)
|
||||
type: integer
|
||||
required: false
|
||||
default: 3
|
||||
- name: timeout
|
||||
label:
|
||||
en_US: Timeout
|
||||
zh_Hans: 超时时间
|
||||
description:
|
||||
en_US: Request timeout in seconds (DeerFlow runs may take a long time)
|
||||
zh_Hans: 请求超时时间(秒),DeerFlow 运行可能耗时较长
|
||||
type: integer
|
||||
required: false
|
||||
default: 300
|
||||
- name: recursion-limit
|
||||
label:
|
||||
en_US: Recursion Limit
|
||||
zh_Hans: 递归上限
|
||||
description:
|
||||
en_US: LangGraph recursion limit for a single run
|
||||
zh_Hans: 单次运行的 LangGraph 递归上限
|
||||
type: integer
|
||||
required: false
|
||||
default: 1000
|
||||
|
||||
10
uv.lock
generated
10
uv.lock
generated
@@ -1899,7 +1899,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "langbot"
|
||||
version = "4.10.0b1"
|
||||
version = "4.10.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "aiocqhttp" },
|
||||
@@ -2013,7 +2013,7 @@ requires-dist = [
|
||||
{ name = "ebooklib", specifier = ">=0.18" },
|
||||
{ name = "gewechat-client", specifier = ">=0.1.5" },
|
||||
{ name = "html2text", specifier = ">=2024.2.26" },
|
||||
{ name = "langbot-plugin", specifier = "==0.4.0b1" },
|
||||
{ name = "langbot-plugin", specifier = "==0.4.1" },
|
||||
{ name = "langchain", specifier = ">=0.2.0" },
|
||||
{ name = "langchain-core", specifier = ">=1.2.28" },
|
||||
{ name = "langchain-text-splitters", specifier = ">=1.1.2" },
|
||||
@@ -2076,7 +2076,7 @@ dev = [
|
||||
|
||||
[[package]]
|
||||
name = "langbot-plugin"
|
||||
version = "0.4.0b1"
|
||||
version = "0.4.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "aiofiles" },
|
||||
@@ -2096,9 +2096,9 @@ dependencies = [
|
||||
{ name = "watchdog" },
|
||||
{ name = "websockets" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/8a/e0/4bb2fd08813879d3da390f588b2927ae626edcfd45dca36900e2e54fb23c/langbot_plugin-0.4.0b1.tar.gz", hash = "sha256:f523c197ff9f5aa3db737e29765ebe1f7a8c96f973240ce3769ccccd0bfddde7", size = 216965, upload-time = "2026-05-21T05:23:27.682Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b2/c1/b11ce66fb2537b257ff387b8b5b708e616e5a072ae04440e24807eb3b1cf/langbot_plugin-0.4.1.tar.gz", hash = "sha256:57d3f8cd6b6c33316792ebfa0c907b2240834a84f2b8c8034c6be7721b425059", size = 289249, upload-time = "2026-06-04T05:19:08.747Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/59/9c6df7cd652d3434d1139ee8392e170108e5f980046b9a55bff324e094fe/langbot_plugin-0.4.0b1-py3-none-any.whl", hash = "sha256:b533407296399c7693255678a4d1390be957dabffa21ca2982e56d28a728854b", size = 194310, upload-time = "2026-05-21T05:23:26.215Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/72/e8/335023bb5e1310621c7b7d8ae4fcac179f119709eee9a8ba65b681f66a8e/langbot_plugin-0.4.1-py3-none-any.whl", hash = "sha256:a9c319a4abb6944ae3d9a491edbeb703842a87b42b4e3b1eafba666ec2beeee7", size = 203412, upload-time = "2026-06-04T05:19:09.936Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
FileArchive,
|
||||
Loader2,
|
||||
CircleHelp,
|
||||
Package,
|
||||
} from 'lucide-react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import {
|
||||
@@ -33,6 +34,8 @@ import {
|
||||
import React, { useState, useCallback, useEffect, useRef } from 'react';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { httpClient, systemInfo } from '@/app/infra/http/HttpClient';
|
||||
import { getCloudServiceClientSync } from '@/app/infra/http';
|
||||
import { extractI18nObject } from '@/i18n/I18nProvider';
|
||||
import { toast } from 'sonner';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { PluginV4 } from '@/app/infra/entities/plugin';
|
||||
@@ -151,6 +154,14 @@ function AddExtensionContent() {
|
||||
const navigate = useNavigate();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const { refreshPlugins, refreshMCPServers, refreshSkills } = useSidebarData();
|
||||
|
||||
// Localized label for an extension type, used in the install dialog.
|
||||
const extensionTypeLabel = (type: string) =>
|
||||
type === 'mcp'
|
||||
? t('market.typeMCP')
|
||||
: type === 'skill'
|
||||
? t('market.typeSkill')
|
||||
: t('market.typePlugin');
|
||||
const {
|
||||
addTask,
|
||||
setSelectedTaskId,
|
||||
@@ -166,6 +177,20 @@ function AddExtensionContent() {
|
||||
const [pluginInstallStatus, setPluginInstallStatus] =
|
||||
useState<PluginInstallStatus>(PluginInstallStatus.ASK_CONFIRM);
|
||||
const [installError, setInstallError] = useState<string | null>(null);
|
||||
const [installIconFailed, setInstallIconFailed] = useState(false);
|
||||
|
||||
// Marketplace icon URL for the extension being installed, by type.
|
||||
const installIconURL = (() => {
|
||||
const cloud = getCloudServiceClientSync();
|
||||
const a = installInfo.plugin_author || '';
|
||||
const n = installInfo.plugin_name || '';
|
||||
if (installExtensionType === 'mcp')
|
||||
return cloud.getMCPMarketplaceIconURL(a, n);
|
||||
if (installExtensionType === 'skill')
|
||||
return cloud.getSkillMarketplaceIconURL(a, n);
|
||||
return cloud.getPluginIconURL(a, n);
|
||||
})();
|
||||
|
||||
const [popoverOpen, setPopoverOpen] = useState(false);
|
||||
const [popoverView, setPopoverView] = useState<PopoverView>('menu');
|
||||
const [isDragOver, setIsDragOver] = useState(false);
|
||||
@@ -224,28 +249,88 @@ function AddExtensionContent() {
|
||||
);
|
||||
}, [searchParams, setSearchParams]);
|
||||
|
||||
// One-click install deep link from LangBot Space:
|
||||
// /home/add-extension?install=1&extension_type=mcp&author=X&name=Y&version=Z
|
||||
// Opens the install confirm dialog directly, then strips the params.
|
||||
useEffect(() => {
|
||||
if (searchParams.get('install') !== '1') return;
|
||||
const author = searchParams.get('author');
|
||||
const name = searchParams.get('name');
|
||||
if (!author || !name) return;
|
||||
const rawType =
|
||||
searchParams.get('extension_type') ||
|
||||
searchParams.get('type') ||
|
||||
'plugin';
|
||||
const extType = (
|
||||
['plugin', 'mcp', 'skill'].includes(rawType) ? rawType : 'plugin'
|
||||
) as 'plugin' | 'mcp' | 'skill';
|
||||
const version = searchParams.get('version') || '';
|
||||
|
||||
setInstallInfo({
|
||||
plugin_author: author,
|
||||
plugin_name: name,
|
||||
plugin_version: version,
|
||||
plugin_label: name,
|
||||
});
|
||||
setInstallExtensionType(extType);
|
||||
setPluginInstallStatus(PluginInstallStatus.ASK_CONFIRM);
|
||||
setInstallError(null);
|
||||
setInstallIconFailed(false);
|
||||
setModalOpen(true);
|
||||
|
||||
setSearchParams(
|
||||
(current) => {
|
||||
const next = new URLSearchParams(current);
|
||||
[
|
||||
'install',
|
||||
'extension_type',
|
||||
'type',
|
||||
'author',
|
||||
'name',
|
||||
'version',
|
||||
].forEach((k) => next.delete(k));
|
||||
return next;
|
||||
},
|
||||
{ replace: true },
|
||||
);
|
||||
}, [searchParams, setSearchParams]);
|
||||
|
||||
useEffect(() => {
|
||||
const onComplete = (_taskId: number, success: boolean) => {
|
||||
if (success) {
|
||||
toast.success(t('plugins.installSuccess'));
|
||||
toast.success(t('addExtension.installSuccess'));
|
||||
// Refresh every sidebar extension list so the newly-installed
|
||||
// plugin / MCP / skill shows up immediately, regardless of type.
|
||||
refreshPlugins();
|
||||
refreshMCPServers();
|
||||
refreshSkills();
|
||||
}
|
||||
};
|
||||
registerOnTaskComplete(onComplete);
|
||||
return () => {
|
||||
unregisterOnTaskComplete(onComplete);
|
||||
};
|
||||
}, [registerOnTaskComplete, unregisterOnTaskComplete, refreshPlugins, t]);
|
||||
}, [
|
||||
registerOnTaskComplete,
|
||||
unregisterOnTaskComplete,
|
||||
refreshPlugins,
|
||||
refreshMCPServers,
|
||||
refreshSkills,
|
||||
t,
|
||||
]);
|
||||
|
||||
const handleInstallPlugin = useCallback(async (plugin: PluginV4) => {
|
||||
setInstallInfo({
|
||||
plugin_author: plugin.author,
|
||||
plugin_name: plugin.name,
|
||||
plugin_version: plugin.latest_version,
|
||||
plugin_label: extractI18nObject(plugin.label) || plugin.name,
|
||||
plugin_description: extractI18nObject(plugin.description) || '',
|
||||
});
|
||||
setInstallExtensionType(plugin.type || 'plugin');
|
||||
setPluginInstallStatus(PluginInstallStatus.ASK_CONFIRM);
|
||||
setInstallError(null);
|
||||
setInstallIconFailed(false);
|
||||
setModalOpen(true);
|
||||
}, []);
|
||||
|
||||
@@ -1145,22 +1230,52 @@ function AddExtensionContent() {
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-4">
|
||||
<Download className="size-6" />
|
||||
<span>{t('plugins.installPlugin')}</span>
|
||||
<span>
|
||||
{t('addExtension.installTitle', {
|
||||
type: extensionTypeLabel(installExtensionType),
|
||||
})}
|
||||
</span>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
{pluginInstallStatus === PluginInstallStatus.ASK_CONFIRM && (
|
||||
<div className="mt-4">
|
||||
<p className="mb-2">
|
||||
{installInfo.plugin_version
|
||||
? t('plugins.askConfirm', {
|
||||
name: installInfo.plugin_name,
|
||||
version: installInfo.plugin_version,
|
||||
})
|
||||
: t('plugins.askConfirmNoVersion', {
|
||||
name: installInfo.plugin_name,
|
||||
})}
|
||||
<div className="mt-4 space-y-3">
|
||||
<p>
|
||||
{t('addExtension.installConfirm', {
|
||||
type: extensionTypeLabel(installExtensionType),
|
||||
name: installInfo.plugin_label || installInfo.plugin_name,
|
||||
})}
|
||||
</p>
|
||||
<div className="flex gap-3 rounded-md bg-muted/40 p-3">
|
||||
{installIconFailed ? (
|
||||
<div className="flex size-12 shrink-0 items-center justify-center rounded-lg border bg-background text-muted-foreground">
|
||||
<Package className="size-6" />
|
||||
</div>
|
||||
) : (
|
||||
<img
|
||||
src={installIconURL}
|
||||
alt={installInfo.plugin_name}
|
||||
className="size-12 shrink-0 rounded-lg border bg-background object-cover"
|
||||
onError={() => setInstallIconFailed(true)}
|
||||
/>
|
||||
)}
|
||||
<div className="min-w-0 flex-1 space-y-0.5">
|
||||
<div className="truncate font-medium">
|
||||
{installInfo.plugin_label || installInfo.plugin_name}
|
||||
</div>
|
||||
<div className="truncate text-xs text-muted-foreground">
|
||||
{installInfo.plugin_author}/{installInfo.plugin_name}
|
||||
{installInfo.plugin_version
|
||||
? ` · v${installInfo.plugin_version}`
|
||||
: ''}
|
||||
</div>
|
||||
{installInfo.plugin_description && (
|
||||
<div className="line-clamp-3 pt-0.5 text-xs text-muted-foreground">
|
||||
{installInfo.plugin_description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
HardDrive,
|
||||
Server,
|
||||
Puzzle,
|
||||
RefreshCcw,
|
||||
} from 'lucide-react';
|
||||
import { useTheme } from '@/components/providers/theme-provider';
|
||||
|
||||
@@ -118,6 +119,22 @@ function compareVersions(v1: string, v2: string): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Discord brand glyph (lucide-react has no Discord icon).
|
||||
function DiscordIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
role="img"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={className}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M20.317 4.3698a19.7913 19.7913 0 00-4.8851-1.5152.0741.0741 0 00-.0785.0371c-.211.3753-.4447.8648-.6083 1.2495-1.8447-.2762-3.68-.2762-5.4868 0-.1636-.3933-.4058-.8742-.6177-1.2495a.077.077 0 00-.0785-.037 19.7363 19.7363 0 00-4.8852 1.515.0699.0699 0 00-.0321.0277C.5334 9.0458-.319 13.5799.0992 18.0578a.0824.0824 0 00.0312.0561c2.0528 1.5076 4.0413 2.4228 5.9929 3.0294a.0777.0777 0 00.0842-.0276c.4616-.6304.8731-1.2952 1.226-1.9942a.076.076 0 00-.0416-.1057c-.6528-.2476-1.2743-.5495-1.8722-.8923a.077.077 0 01-.0076-.1277c.1258-.0943.2517-.1923.3718-.2914a.0743.0743 0 01.0776-.0105c3.9278 1.7933 8.18 1.7933 12.0614 0a.0739.0739 0 01.0785.0095c.1202.099.246.1981.3728.2924a.077.077 0 01-.0066.1276 12.2986 12.2986 0 01-1.873.8914.0766.0766 0 00-.0407.1067c.3604.698.7719 1.3628 1.225 1.9932a.076.076 0 00.0842.0286c1.961-.6067 3.9495-1.5219 6.0023-3.0294a.077.077 0 00.0313-.0552c.5004-5.177-.8382-9.6739-3.5485-13.6604a.061.061 0 00-.0312-.0286zM8.02 15.3312c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9555-2.4189 2.157-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.9555 2.4189-2.1569 2.4189zm7.9748 0c-1.1825 0-2.1569-1.0857-2.1569-2.419 0-1.3332.9554-2.4189 2.1569-2.4189 1.2108 0 2.1757 1.0952 2.1568 2.419 0 1.3332-.946 2.4189-2.1568 2.4189Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
// IDs of sidebar entries that have collapsible entity sub-items
|
||||
const ENTITY_CATEGORY_IDS = [
|
||||
'bots',
|
||||
@@ -325,6 +342,23 @@ function NavItems({
|
||||
);
|
||||
// Track popover open state for collapsed sidebar entity categories
|
||||
const [popoverOpen, setPopoverOpen] = useState<Record<string, boolean>>({});
|
||||
// Spin state for the installed-extensions refresh button
|
||||
const [extRefreshing, setExtRefreshing] = useState(false);
|
||||
|
||||
const handleRefreshExtensions = async (e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (extRefreshing) return;
|
||||
setExtRefreshing(true);
|
||||
try {
|
||||
await Promise.all([
|
||||
sidebarData.refreshPlugins(),
|
||||
sidebarData.refreshMCPServers(),
|
||||
sidebarData.refreshSkills(),
|
||||
]);
|
||||
} finally {
|
||||
setExtRefreshing(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Plugin operation state
|
||||
const [showPluginOpModal, setShowPluginOpModal] = useState(false);
|
||||
@@ -694,17 +728,21 @@ function NavItems({
|
||||
</a>
|
||||
</SidebarMenuSubButton>
|
||||
</TooltipTrigger>
|
||||
{item.description && (
|
||||
<TooltipContent
|
||||
side="right"
|
||||
align="center"
|
||||
className="max-w-64"
|
||||
>
|
||||
{item.description.length > 80
|
||||
? item.description.slice(0, 80) + '…'
|
||||
: item.description}
|
||||
</TooltipContent>
|
||||
)}
|
||||
<TooltipContent
|
||||
side="right"
|
||||
align="center"
|
||||
className="max-w-64"
|
||||
>
|
||||
{/* Full name — so truncated sidebar items are readable on hover */}
|
||||
<div className="break-words font-medium">{item.name}</div>
|
||||
{item.description && (
|
||||
<div className="mt-0.5 break-words text-xs text-muted-foreground">
|
||||
{item.description.length > 80
|
||||
? item.description.slice(0, 80) + '…'
|
||||
: item.description}
|
||||
</div>
|
||||
)}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
{/* Plugin context menu - shown on hover (not for debug plugins) */}
|
||||
{itemIsPluginType && !item.debug && (
|
||||
@@ -973,6 +1011,21 @@ function NavItems({
|
||||
{config.name}
|
||||
</span>
|
||||
<div className="ml-auto flex items-center gap-0.5 -mr-1">
|
||||
{isExtensionsCategory && (
|
||||
<button
|
||||
type="button"
|
||||
title={t('common.refresh', '刷新')}
|
||||
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={handleRefreshExtensions}
|
||||
>
|
||||
<RefreshCcw
|
||||
className={cn(
|
||||
'size-3.5',
|
||||
extRefreshing && 'animate-spin',
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
{canCreate &&
|
||||
(isPlugin ? (
|
||||
<DropdownMenu>
|
||||
@@ -1764,7 +1817,21 @@ export default function HomeSidebar({
|
||||
className="size-8 rounded-lg"
|
||||
/>
|
||||
<div className="grid flex-1 text-left text-sm leading-tight">
|
||||
<span className="truncate font-semibold">LangBot</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="truncate font-semibold">LangBot</span>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={`shrink-0 px-1 py-0 h-3.5 text-[0.55rem] font-medium ${
|
||||
systemInfo?.edition === 'cloud'
|
||||
? 'border-transparent bg-blue-500 text-white'
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
{systemInfo?.edition === 'cloud'
|
||||
? t('sidebar.editionCloud')
|
||||
: t('sidebar.editionCommunity')}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="truncate text-xs text-muted-foreground">
|
||||
{systemInfo?.version}
|
||||
@@ -2026,6 +2093,14 @@ export default function HomeSidebar({
|
||||
</Badge>
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
window.open('https://discord.gg/wdNEHETs87', '_blank');
|
||||
}}
|
||||
>
|
||||
<DiscordIcon className="text-[#5865F2]" />
|
||||
{t('common.joinDiscord')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
<DropdownMenuSeparator />
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ import {
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from '@/components/ui/popover';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ScannedProviderModel } from '@/app/infra/entities/api';
|
||||
import {
|
||||
@@ -298,20 +298,8 @@ export default function AddModelPopover({
|
||||
</div>
|
||||
|
||||
<div className="overflow-y-auto flex-1 min-h-0">
|
||||
<Tabs
|
||||
value={mode}
|
||||
onValueChange={(v) => setMode(v as 'manual' | 'scan')}
|
||||
>
|
||||
{!trigger && (
|
||||
<TabsList className="grid w-full grid-cols-2 mt-3">
|
||||
<TabsTrigger value="manual">
|
||||
{t('models.manualAdd')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="scan">{t('models.scanAdd')}</TabsTrigger>
|
||||
</TabsList>
|
||||
)}
|
||||
|
||||
<TabsContent value="manual" className="mt-3">
|
||||
{mode === 'manual' ? (
|
||||
<div className="mt-3">
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-2">
|
||||
<Label>{t('models.modelName')}</Label>
|
||||
@@ -390,9 +378,9 @@ export default function AddModelPopover({
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="scan" className="space-y-2 mt-0 pt-0">
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2 mt-3">
|
||||
{scanLoading ? (
|
||||
<div className="flex items-center justify-center py-4">
|
||||
<RefreshCw className="h-4 w-4 mr-2 animate-spin text-muted-foreground" />
|
||||
@@ -565,8 +553,8 @@ export default function AddModelPopover({
|
||||
/>
|
||||
</Button>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Tabs>
|
||||
</PopoverContent>
|
||||
|
||||
@@ -4,11 +4,16 @@ import {
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Loader2, RefreshCw, CheckCircle2, XCircle } from 'lucide-react';
|
||||
import {
|
||||
Loader2,
|
||||
RefreshCw,
|
||||
RotateCw,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
} from 'lucide-react';
|
||||
import QRCode from 'qrcode';
|
||||
|
||||
export type QrLoginPlatform = 'feishu' | 'weixin' | 'dingtalk' | 'wecombot';
|
||||
@@ -96,7 +101,7 @@ interface QrCodeLoginDialogProps {
|
||||
onSuccess: (credentials: Record<string, string>) => void;
|
||||
}
|
||||
|
||||
type DialogState = 'connecting' | 'waiting' | 'success' | 'error';
|
||||
type DialogState = 'connecting' | 'waiting' | 'expired' | 'success' | 'error';
|
||||
|
||||
const POLL_INTERVAL_MS = 3000;
|
||||
|
||||
@@ -115,8 +120,10 @@ export default function QrCodeLoginDialog({
|
||||
const [errorMessage, setErrorMessage] = useState('');
|
||||
const pollTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const countdownRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const checkExpiredRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
const sessionIdRef = useRef<string | null>(null);
|
||||
const baseUrlRef = useRef('');
|
||||
const cleanedRef = useRef(false);
|
||||
|
||||
const onSuccessRef = useRef(onSuccess);
|
||||
@@ -140,11 +147,14 @@ export default function QrCodeLoginDialog({
|
||||
clearInterval(countdownRef.current);
|
||||
countdownRef.current = null;
|
||||
}
|
||||
if (checkExpiredRef.current) {
|
||||
clearInterval(checkExpiredRef.current);
|
||||
checkExpiredRef.current = null;
|
||||
}
|
||||
if (abortRef.current) {
|
||||
abortRef.current.abort();
|
||||
abortRef.current = null;
|
||||
}
|
||||
// Cancel backend session
|
||||
if (sessionIdRef.current) {
|
||||
const token = localStorage.getItem('token');
|
||||
const baseUrl =
|
||||
@@ -171,6 +181,7 @@ export default function QrCodeLoginDialog({
|
||||
|
||||
const token = localStorage.getItem('token');
|
||||
const baseUrl = import.meta.env.VITE_API_BASE_URL || window.location.origin;
|
||||
baseUrlRef.current = baseUrl;
|
||||
const cfg = platformConfigRef.current;
|
||||
|
||||
try {
|
||||
@@ -191,8 +202,6 @@ export default function QrCodeLoginDialog({
|
||||
const { session_id, qr_data_url, qr_url, expire_at } = json.data;
|
||||
sessionIdRef.current = session_id;
|
||||
|
||||
// qr_data_url is a pre-rendered data URL (WeChat);
|
||||
// qr_url is a plain URL string (Feishu) that needs local QR generation.
|
||||
if (qr_data_url) {
|
||||
setQrDataUrl(qr_data_url);
|
||||
} else if (qr_url) {
|
||||
@@ -204,11 +213,9 @@ export default function QrCodeLoginDialog({
|
||||
}
|
||||
setState('waiting');
|
||||
|
||||
// Calculate remaining seconds
|
||||
const remaining = Math.max(0, Math.floor(expire_at - Date.now() / 1000));
|
||||
setExpireIn(remaining);
|
||||
|
||||
// Start countdown
|
||||
countdownRef.current = setInterval(() => {
|
||||
setExpireIn((prev) => {
|
||||
if (prev <= 1) {
|
||||
@@ -222,7 +229,35 @@ export default function QrCodeLoginDialog({
|
||||
});
|
||||
}, 1000);
|
||||
|
||||
// Start polling
|
||||
// When countdown hits 0, stop polling and show expired state
|
||||
checkExpiredRef.current = setInterval(() => {
|
||||
setExpireIn((current) => {
|
||||
if (current <= 0) {
|
||||
if (checkExpiredRef.current) {
|
||||
clearInterval(checkExpiredRef.current);
|
||||
checkExpiredRef.current = null;
|
||||
}
|
||||
if (pollTimerRef.current) {
|
||||
clearInterval(pollTimerRef.current);
|
||||
pollTimerRef.current = null;
|
||||
}
|
||||
if (sessionIdRef.current) {
|
||||
fetch(
|
||||
`${baseUrlRef.current}${cfg.apiBase}/${sessionIdRef.current}`,
|
||||
{
|
||||
method: 'DELETE',
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
keepalive: true,
|
||||
},
|
||||
).catch(() => {});
|
||||
sessionIdRef.current = null;
|
||||
}
|
||||
setState('expired');
|
||||
}
|
||||
return current;
|
||||
});
|
||||
}, 500);
|
||||
|
||||
pollTimerRef.current = setInterval(async () => {
|
||||
try {
|
||||
const pollRes = await fetch(
|
||||
@@ -237,7 +272,7 @@ export default function QrCodeLoginDialog({
|
||||
const { status, error, ...rest } = pollJson.data;
|
||||
|
||||
if (status === 'success') {
|
||||
sessionIdRef.current = null; // backend already cleaned up
|
||||
sessionIdRef.current = null;
|
||||
cleanup();
|
||||
setState('success');
|
||||
setTimeout(() => {
|
||||
@@ -249,9 +284,14 @@ export default function QrCodeLoginDialog({
|
||||
cleanup();
|
||||
setState('error');
|
||||
setErrorMessage(error || tRef.current(cfg.failedKey));
|
||||
} else if (status === 'expired') {
|
||||
sessionIdRef.current = null;
|
||||
cleanup();
|
||||
setExpireIn(0);
|
||||
setState('expired');
|
||||
}
|
||||
} catch {
|
||||
// ignore poll errors, will retry next interval
|
||||
// ignore poll errors
|
||||
}
|
||||
}, POLL_INTERVAL_MS);
|
||||
} catch (err: unknown) {
|
||||
@@ -323,6 +363,31 @@ export default function QrCodeLoginDialog({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* QR code expired — click overlay to refresh */}
|
||||
{state === 'expired' && qrDataUrl && (
|
||||
<div className="flex flex-col items-center space-y-3">
|
||||
<p className="text-sm text-muted-foreground text-center">
|
||||
{t(platformConfig.scanQRCodeKey)}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
className="relative border rounded-lg p-2 bg-white cursor-pointer group"
|
||||
onClick={() => startLogin()}
|
||||
>
|
||||
<img
|
||||
src={qrDataUrl}
|
||||
alt="QR Code"
|
||||
className="w-56 h-56 opacity-40"
|
||||
/>
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-white/60 rounded-lg group-hover:bg-white/70 transition-colors">
|
||||
<div className="flex items-center justify-center w-16 h-16 rounded-full bg-black/5 group-hover:bg-black/10 transition-colors">
|
||||
<RotateCw className="h-8 w-8 text-muted-foreground" />
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Success */}
|
||||
{state === 'success' && (
|
||||
<div className="flex flex-col items-center space-y-3 py-8">
|
||||
@@ -350,7 +415,7 @@ export default function QrCodeLoginDialog({
|
||||
</div>
|
||||
|
||||
{state === 'error' && (
|
||||
<DialogFooter>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="outline" onClick={() => handleOpenChange(false)}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
@@ -358,7 +423,7 @@ export default function QrCodeLoginDialog({
|
||||
<RefreshCw className="h-4 w-4 mr-1.5" />
|
||||
{t(platformConfig.retryKey)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -71,7 +71,13 @@ function StatusDisplay({
|
||||
);
|
||||
}
|
||||
|
||||
if (runtimeInfo.status === MCPSessionStatus.CONNECTING) {
|
||||
// CONNECTING, or any not-yet-resolved status (initial/null while the box is
|
||||
// still bringing the session up) — show "connecting" rather than failing.
|
||||
if (
|
||||
runtimeInfo.status === MCPSessionStatus.CONNECTING ||
|
||||
(runtimeInfo.status !== MCPSessionStatus.ERROR &&
|
||||
runtimeInfo.error_phase !== 'box_unavailable')
|
||||
) {
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-blue-600">
|
||||
<Loader2 className="size-5 animate-spin" />
|
||||
@@ -258,6 +264,13 @@ function RuntimePanel({
|
||||
|
||||
const isConnected =
|
||||
!mcpTesting && runtimeInfo.status === MCPSessionStatus.CONNECTED;
|
||||
// Only treat an explicit error (or box-unavailable) as failed; while testing,
|
||||
// connecting, or in an initial/unresolved state, show "connecting" so we
|
||||
// don't flash "connection failed" during a normal connection attempt.
|
||||
const isFailed =
|
||||
!mcpTesting &&
|
||||
(runtimeInfo.status === MCPSessionStatus.ERROR ||
|
||||
runtimeInfo.error_phase === 'box_unavailable');
|
||||
const tools = runtimeInfo.tools || [];
|
||||
|
||||
return (
|
||||
@@ -268,7 +281,9 @@ function RuntimePanel({
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{isConnected
|
||||
? t('mcp.toolCount', { count: tools.length })
|
||||
: t('mcp.connectionFailedStatus')}
|
||||
: isFailed
|
||||
? t('mcp.connectionFailedStatus')
|
||||
: t('mcp.connecting')}
|
||||
</p>
|
||||
</div>
|
||||
{isConnected && (
|
||||
|
||||
@@ -10,6 +10,8 @@ import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Download,
|
||||
Package,
|
||||
Server,
|
||||
Sparkles,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
Loader2,
|
||||
@@ -171,6 +173,15 @@ function TaskProgressContent({ task }: { task: PluginInstallTask }) {
|
||||
const isDone = task.stage === InstallStage.DONE;
|
||||
const isError = task.stage === InstallStage.ERROR;
|
||||
|
||||
// MCP / Skill don't have the plugin's download + dependency-install stages;
|
||||
// show a single "installing → done/failed" row instead of plugin steps.
|
||||
const isPlugin = task.extensionType === 'plugin';
|
||||
const simpleIcon = task.extensionType === 'mcp' ? Server : Sparkles;
|
||||
const simpleInstallingLabel =
|
||||
task.extensionType === 'mcp'
|
||||
? t('addExtension.installStage.mcpInstalling')
|
||||
: t('addExtension.installStage.skillInstalling');
|
||||
|
||||
/** Build detail node for a stage */
|
||||
const getStageDetail = (
|
||||
stageKey: InstallStage,
|
||||
@@ -307,42 +318,60 @@ function TaskProgressContent({ task }: { task: PluginInstallTask }) {
|
||||
|
||||
{/* Stage display */}
|
||||
<div className="space-y-1.5">
|
||||
{isDone
|
||||
? /* When done: show all stages with completed style */
|
||||
STAGES.map((stageConfig) => (
|
||||
<StageRow
|
||||
key={stageConfig.key}
|
||||
icon={stageConfig.icon}
|
||||
label={t(stageConfig.i18nKey)}
|
||||
isActive={false}
|
||||
isCompleted={true}
|
||||
isError={false}
|
||||
detail={getStageDetail(stageConfig.key, true)}
|
||||
/>
|
||||
))
|
||||
: isError
|
||||
? /* Error: show the failed stage */
|
||||
currentStageIndex >= 0 && (
|
||||
<StageRow
|
||||
icon={STAGES[currentStageIndex].icon}
|
||||
label={t(STAGES[currentStageIndex].i18nKey)}
|
||||
isActive={true}
|
||||
isCompleted={false}
|
||||
isError={true}
|
||||
detail={task.error}
|
||||
/>
|
||||
)
|
||||
: /* In progress: only show the current active stage */
|
||||
currentStageIndex >= 0 && (
|
||||
<StageRow
|
||||
icon={STAGES[currentStageIndex].icon}
|
||||
label={t(STAGES[currentStageIndex].i18nKey)}
|
||||
isActive={true}
|
||||
isCompleted={false}
|
||||
isError={false}
|
||||
detail={getStageDetail(STAGES[currentStageIndex].key, false)}
|
||||
/>
|
||||
)}
|
||||
{!isPlugin ? (
|
||||
/* MCP / Skill: single installing → done/failed row */
|
||||
<StageRow
|
||||
icon={simpleIcon}
|
||||
label={
|
||||
isDone
|
||||
? t('addExtension.installStage.installed')
|
||||
: isError
|
||||
? t('plugins.installProgress.failed')
|
||||
: simpleInstallingLabel
|
||||
}
|
||||
isActive={!isDone}
|
||||
isCompleted={isDone}
|
||||
isError={isError}
|
||||
detail={isError ? task.error : undefined}
|
||||
/>
|
||||
) : isDone ? (
|
||||
/* When done: show all stages with completed style */
|
||||
STAGES.map((stageConfig) => (
|
||||
<StageRow
|
||||
key={stageConfig.key}
|
||||
icon={stageConfig.icon}
|
||||
label={t(stageConfig.i18nKey)}
|
||||
isActive={false}
|
||||
isCompleted={true}
|
||||
isError={false}
|
||||
detail={getStageDetail(stageConfig.key, true)}
|
||||
/>
|
||||
))
|
||||
) : isError ? (
|
||||
/* Error: show the failed stage */
|
||||
currentStageIndex >= 0 && (
|
||||
<StageRow
|
||||
icon={STAGES[currentStageIndex].icon}
|
||||
label={t(STAGES[currentStageIndex].i18nKey)}
|
||||
isActive={true}
|
||||
isCompleted={false}
|
||||
isError={true}
|
||||
detail={task.error}
|
||||
/>
|
||||
)
|
||||
) : (
|
||||
/* In progress: only show the current active stage */
|
||||
currentStageIndex >= 0 && (
|
||||
<StageRow
|
||||
icon={STAGES[currentStageIndex].icon}
|
||||
label={t(STAGES[currentStageIndex].i18nKey)}
|
||||
isActive={true}
|
||||
isCompleted={false}
|
||||
isError={false}
|
||||
detail={getStageDetail(STAGES[currentStageIndex].key, false)}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Done banner */}
|
||||
|
||||
@@ -9,9 +9,9 @@ import {
|
||||
Loader2,
|
||||
X,
|
||||
ListTodo,
|
||||
Wrench,
|
||||
AudioWaveform,
|
||||
Book,
|
||||
Puzzle,
|
||||
Server,
|
||||
Sparkles,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
@@ -35,9 +35,9 @@ const STAGE_ICONS: Record<string, React.ElementType> = {
|
||||
};
|
||||
|
||||
const EXTENSION_TYPE_ICONS: Record<string, React.ElementType> = {
|
||||
plugin: Wrench,
|
||||
mcp: AudioWaveform,
|
||||
skill: Book,
|
||||
plugin: Puzzle,
|
||||
mcp: Server,
|
||||
skill: Sparkles,
|
||||
};
|
||||
|
||||
function TaskQueueItem({
|
||||
@@ -54,7 +54,7 @@ function TaskQueueItem({
|
||||
const isError = task.stage === InstallStage.ERROR;
|
||||
const isRunning = !isDone && !isError;
|
||||
const StageIcon = STAGE_ICONS[task.stage] || Download;
|
||||
const TypeIcon = EXTENSION_TYPE_ICONS[task.extensionType] || Wrench;
|
||||
const TypeIcon = EXTENSION_TYPE_ICONS[task.extensionType] || Puzzle;
|
||||
|
||||
const getTypeBadgeClass = () => {
|
||||
switch (task.extensionType) {
|
||||
|
||||
@@ -21,8 +21,7 @@ import { extractI18nObject } from '@/i18n/I18nProvider';
|
||||
import { toast } from 'sonner';
|
||||
import { useAsyncTask, AsyncTaskStatus } from '@/hooks/useAsyncTask';
|
||||
import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext';
|
||||
import { Loader2, Puzzle } from 'lucide-react';
|
||||
import { Wrench, AudioWaveform, Book } from 'lucide-react';
|
||||
import { Loader2, Puzzle, Server, Sparkles } from 'lucide-react';
|
||||
|
||||
export interface PluginInstalledComponentRef {
|
||||
refreshPluginList: () => void;
|
||||
@@ -44,14 +43,18 @@ export const FilterOptions = [
|
||||
{
|
||||
value: 'plugin' as FilterType,
|
||||
labelKey: 'market.typePlugin',
|
||||
icon: Wrench,
|
||||
icon: Puzzle,
|
||||
},
|
||||
{
|
||||
value: 'mcp' as FilterType,
|
||||
labelKey: 'market.typeMCP',
|
||||
icon: AudioWaveform,
|
||||
icon: Server,
|
||||
},
|
||||
{
|
||||
value: 'skill' as FilterType,
|
||||
labelKey: 'market.typeSkill',
|
||||
icon: Sparkles,
|
||||
},
|
||||
{ value: 'skill' as FilterType, labelKey: 'market.typeSkill', icon: Book },
|
||||
];
|
||||
|
||||
interface PluginInstalledComponentProps {
|
||||
|
||||
@@ -17,17 +17,32 @@ import { Separator } from '@/components/ui/separator';
|
||||
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
|
||||
import {
|
||||
Search,
|
||||
Puzzle,
|
||||
Server,
|
||||
Sparkles,
|
||||
Wrench,
|
||||
AudioWaveform,
|
||||
Hash,
|
||||
Book,
|
||||
FileText,
|
||||
AppWindow,
|
||||
SlidersHorizontal,
|
||||
X,
|
||||
Info,
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
import PluginMarketCardComponent from './plugin-market-card/PluginMarketCardComponent';
|
||||
import { PluginMarketCardVO } from './plugin-market-card/PluginMarketCardVO';
|
||||
import { getCloudServiceClientSync } from '@/app/infra/http';
|
||||
import { RecommendationLists } from './RecommendationLists';
|
||||
import type { RecommendationList } from './RecommendationLists';
|
||||
import {
|
||||
getCloudServiceClient,
|
||||
getCloudServiceClientSync,
|
||||
} from '@/app/infra/http';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { PluginV4, PluginV4Status } from '@/app/infra/entities/plugin';
|
||||
import { extractI18nObject } from '@/i18n/I18nProvider';
|
||||
@@ -44,6 +59,23 @@ interface SortOption {
|
||||
sortOrder: string;
|
||||
}
|
||||
|
||||
// Persist the market filter conditions (type / component / tags / sort) across
|
||||
// visits via localStorage.
|
||||
const MARKET_FILTERS_KEY = 'langbot_market_filters';
|
||||
interface MarketFilters {
|
||||
typeFilter?: string;
|
||||
componentFilter?: string;
|
||||
selectedTags?: string[];
|
||||
sortOption?: string;
|
||||
}
|
||||
function loadMarketFilters(): MarketFilters {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(MARKET_FILTERS_KEY) || '{}');
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
// 内部组件,用于处理搜索参数
|
||||
function MarketPageContent({
|
||||
installPlugin,
|
||||
@@ -59,34 +91,61 @@ function MarketPageContent({
|
||||
|
||||
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 },
|
||||
{ value: 'plugin', label: t('market.typePlugin'), icon: Puzzle },
|
||||
{ value: 'mcp', label: t('market.typeMCP'), icon: Server },
|
||||
{ value: 'skill', label: t('market.typeSkill'), icon: Sparkles },
|
||||
];
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [componentFilter, setComponentFilter] = useState('all');
|
||||
const [componentFilter, setComponentFilter] = useState<string>(
|
||||
() => loadMarketFilters().componentFilter ?? 'all',
|
||||
);
|
||||
const [typeFilter, setTypeFilter] = useState<string>(() => {
|
||||
const type = searchParams.get('type');
|
||||
if (type && validTypes.includes(type)) {
|
||||
return type;
|
||||
}
|
||||
return 'all';
|
||||
const saved = loadMarketFilters().typeFilter;
|
||||
return saved && validTypes.includes(saved) ? saved : 'all';
|
||||
});
|
||||
const activeAdvancedFilters =
|
||||
(typeFilter === 'all' ? 0 : 1) + (componentFilter === 'all' ? 0 : 1);
|
||||
const [selectedTags, setSelectedTags] = useState<string[]>([]);
|
||||
const [selectedTags, setSelectedTags] = useState<string[]>(
|
||||
() => loadMarketFilters().selectedTags ?? [],
|
||||
);
|
||||
const [availableTags, setAvailableTags] = useState<PluginTag[]>([]);
|
||||
const [tagNames, setTagNames] = useState<Record<string, string>>({});
|
||||
const [recommendationLists, setRecommendationLists] = useState<
|
||||
RecommendationList[]
|
||||
>([]);
|
||||
const [plugins, setPlugins] = useState<PluginMarketCardVO[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
||||
const [hasMore, setHasMore] = useState(true);
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [total, setTotal] = useState(0);
|
||||
const [sortOption, setSortOption] = useState('install_count_desc');
|
||||
const [sortOption, setSortOption] = useState<string>(
|
||||
() => loadMarketFilters().sortOption ?? 'install_count_desc',
|
||||
);
|
||||
|
||||
const pageSize = 12; // 每页12个
|
||||
// Persist filter conditions so they survive navigation / reload.
|
||||
useEffect(() => {
|
||||
try {
|
||||
localStorage.setItem(
|
||||
MARKET_FILTERS_KEY,
|
||||
JSON.stringify({
|
||||
typeFilter,
|
||||
componentFilter,
|
||||
selectedTags,
|
||||
sortOption,
|
||||
}),
|
||||
);
|
||||
} catch {
|
||||
// ignore storage errors
|
||||
}
|
||||
}, [typeFilter, componentFilter, selectedTags, sortOption]);
|
||||
|
||||
const pageSize = 24; // 每页24个
|
||||
const searchTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
const scrollContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
const isComposingRef = useRef(false);
|
||||
@@ -138,6 +197,11 @@ function MarketPageContent({
|
||||
label: t('market.componentName.Parser'),
|
||||
icon: FileText,
|
||||
},
|
||||
{
|
||||
value: 'Page',
|
||||
label: t('market.componentName.Page'),
|
||||
icon: AppWindow,
|
||||
},
|
||||
];
|
||||
|
||||
// 获取当前排序参数
|
||||
@@ -248,11 +312,29 @@ function MarketPageContent({
|
||||
|
||||
// 初始加载
|
||||
useEffect(() => {
|
||||
fetchPlugins(1, false, true);
|
||||
fetchAvailableTags();
|
||||
// Resolve the cloud service base URL (from system info) before any
|
||||
// marketplace fetch — otherwise the sync client may still hold the default
|
||||
// URL and hit space.langbot.app instead of the configured instance.
|
||||
(async () => {
|
||||
await getCloudServiceClient();
|
||||
fetchPlugins(1, false, true);
|
||||
fetchAvailableTags();
|
||||
fetchRecommendationLists();
|
||||
})();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// 获取推荐列表(精选,混合插件/MCP/Skill)
|
||||
const fetchRecommendationLists = async () => {
|
||||
try {
|
||||
const client = await getCloudServiceClient();
|
||||
const { lists } = await client.getRecommendationLists();
|
||||
setRecommendationLists(lists || []);
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch recommendation lists:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// 获取可用标签
|
||||
const fetchAvailableTags = async () => {
|
||||
try {
|
||||
@@ -616,8 +698,22 @@ function MarketPageContent({
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="space-y-2">
|
||||
<div className="text-xs font-medium text-muted-foreground">
|
||||
<div className="flex items-center gap-1 text-xs font-medium text-muted-foreground">
|
||||
{t('market.filterByComponent')}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex text-muted-foreground/70 hover:text-foreground"
|
||||
aria-label={t('market.filterByComponentHint')}
|
||||
>
|
||||
<Info className="size-3.5" />
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-64">
|
||||
{t('market.filterByComponentHint')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
@@ -701,6 +797,18 @@ function MarketPageContent({
|
||||
ref={scrollContainerRef}
|
||||
className="flex-1 overflow-y-auto px-3 sm:px-4 pb-6 container mx-auto"
|
||||
>
|
||||
{/* 推荐列表(仅在无搜索/筛选时展示,混合插件/MCP/Skill) */}
|
||||
{!searchQuery &&
|
||||
typeFilter === 'all' &&
|
||||
componentFilter === 'all' &&
|
||||
selectedTags.length === 0 && (
|
||||
<RecommendationLists
|
||||
lists={recommendationLists}
|
||||
tagNames={tagNames}
|
||||
onInstall={handleInstallPlugin}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<LoadingSpinner text={t('market.loading')} />
|
||||
|
||||
@@ -22,6 +22,15 @@ function pluginToVO(
|
||||
plugin: PluginV4,
|
||||
t: (key: string) => string,
|
||||
): PluginMarketCardVO {
|
||||
const cloudClient = getCloudServiceClientSync();
|
||||
// Recommendation lists are mixed-type; resolve the icon per extension type.
|
||||
const iconURL =
|
||||
plugin.type === 'mcp'
|
||||
? cloudClient.getMCPMarketplaceIconURL(plugin.author, plugin.name)
|
||||
: plugin.type === 'skill'
|
||||
? cloudClient.getSkillMarketplaceIconURL(plugin.author, plugin.name)
|
||||
: cloudClient.getPluginIconURL(plugin.author, plugin.name);
|
||||
|
||||
return new PluginMarketCardVO({
|
||||
pluginId: plugin.author + ' / ' + plugin.name,
|
||||
author: plugin.author,
|
||||
@@ -30,10 +39,7 @@ function pluginToVO(
|
||||
description:
|
||||
extractI18nObject(plugin.description) || t('market.noDescription'),
|
||||
installCount: plugin.install_count,
|
||||
iconURL: getCloudServiceClientSync().getPluginIconURL(
|
||||
plugin.author,
|
||||
plugin.name,
|
||||
),
|
||||
iconURL,
|
||||
githubURL: plugin.repository,
|
||||
version: plugin.latest_version,
|
||||
components: plugin.components,
|
||||
|
||||
@@ -44,7 +44,7 @@ export function TagsFilter({
|
||||
|
||||
return (
|
||||
<Select open={open} onOpenChange={setOpen}>
|
||||
<SelectTrigger className="w-[140px]">
|
||||
<SelectTrigger className="w-[140px] cursor-pointer">
|
||||
<div className="flex items-center gap-2 w-full">
|
||||
<TagIcon className="h-4 w-4 flex-shrink-0" />
|
||||
{selectedTags.length === 0 ? (
|
||||
|
||||
@@ -94,7 +94,7 @@ export default function PluginMarketCardComponent({
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={t('market.installCard', { name: cardVO.label })}
|
||||
className="w-[100%] h-[10rem] cursor-pointer bg-white rounded-[10px] shadow-[0px_0px_4px_0_rgba(0,0,0,0.2)] p-3 sm:p-[1rem] hover:shadow-[0px_2px_8px_0_rgba(0,0,0,0.15)] transition-shadow duration-200 outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 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)] relative"
|
||||
className="w-[100%] h-[10rem] cursor-pointer bg-white rounded-[10px] border border-border shadow-[0px_1px_2px_0_rgba(0,0,0,0.06)] p-3 sm:p-[1rem] hover:shadow-[0px_2px_5px_0_rgba(0,0,0,0.08)] transition-shadow duration-200 outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 dark:bg-[#1f1f22] dark:shadow-[0px_1px_2px_0_rgba(255,255,255,0.04)] dark:hover:shadow-[0px_2px_5px_0_rgba(255,255,255,0.07)] relative"
|
||||
onClick={handleInstallClick}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
|
||||
@@ -129,22 +129,22 @@ export default function MCPCardComponent({
|
||||
{t('mcp.toolCount', { count: toolsCount })}
|
||||
</div>
|
||||
</div>
|
||||
) : status === MCPSessionStatus.CONNECTING ? (
|
||||
// 连接中 - 蓝色加载
|
||||
<div className="flex flex-row items-center gap-[0.4rem]">
|
||||
<Loader2 className="w-4 h-4 text-blue-500 dark:text-blue-400 animate-spin" />
|
||||
<div className="text-sm text-blue-500 dark:text-blue-400 font-medium">
|
||||
{t('mcp.connecting')}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
// 连接失败 - 红色
|
||||
) : status === MCPSessionStatus.ERROR ? (
|
||||
// 连接失败 - 红色(仅在明确报错时)
|
||||
<div className="flex flex-row items-center gap-[0.4rem]">
|
||||
<AlertCircle className="w-4 h-4 text-red-500 dark:text-red-400" />
|
||||
<div className="text-sm text-red-500 dark:text-red-400 font-medium">
|
||||
{t('mcp.connectionFailedStatus')}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
// 连接中 - 蓝色加载(CONNECTING 或初始/未知状态,避免误报失败)
|
||||
<div className="flex flex-row items-center gap-[0.4rem]">
|
||||
<Loader2 className="w-4 h-4 text-blue-500 dark:text-blue-400 animate-spin" />
|
||||
<div className="text-sm text-blue-500 dark:text-blue-400 font-medium">
|
||||
{t('mcp.connecting')}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -10,6 +10,8 @@ const enUS = {
|
||||
pluginPagesTooltip: 'Visual pages provided by installed plugins',
|
||||
quickStart: 'Quick Start',
|
||||
scrollToBottom: 'Scroll to bottom',
|
||||
editionCommunity: 'Community',
|
||||
editionCloud: 'Cloud',
|
||||
},
|
||||
common: {
|
||||
login: 'Login',
|
||||
@@ -35,6 +37,7 @@ const enUS = {
|
||||
helpDocs: 'Get Help',
|
||||
featureRequest: 'Feature Request',
|
||||
starOnGitHub: 'Star on GitHub',
|
||||
joinDiscord: 'Join our Discord',
|
||||
create: 'Create',
|
||||
edit: 'Edit',
|
||||
delete: 'Delete',
|
||||
@@ -454,7 +457,7 @@ const enUS = {
|
||||
noPluginInstalled: 'No plugins installed',
|
||||
noExtensionInstalled: 'No extensions installed',
|
||||
loadingExtensions: 'Loading extensions...',
|
||||
groupByType: 'Group by type',
|
||||
groupByType: 'Group by format',
|
||||
pluginConfig: 'Plugin Configuration',
|
||||
pluginSort: 'Plugin Sort',
|
||||
pluginSortDescription:
|
||||
@@ -629,8 +632,8 @@ const enUS = {
|
||||
},
|
||||
market: {
|
||||
searchPlaceholder: 'Search plugins...',
|
||||
searchResults: 'Found {{count}} plugins',
|
||||
totalPlugins: 'Total {{count}} plugins',
|
||||
searchResults: 'Found {{count}} extensions',
|
||||
totalPlugins: 'Total {{count}} extensions',
|
||||
noPlugins: 'No plugins available',
|
||||
noResults: 'No relevant plugins found',
|
||||
loadingMore: 'Loading more...',
|
||||
@@ -670,8 +673,18 @@ const enUS = {
|
||||
markAsRead: 'Mark as Read',
|
||||
markAsReadSuccess: 'Marked as read',
|
||||
markAsReadFailed: 'Mark as read failed',
|
||||
filterByComponent: 'Component',
|
||||
filterByComponent: 'Plugin Component',
|
||||
filterByComponentHint:
|
||||
'The capability types a plugin provides — Tool, Command, EventListener, etc. — used to extend LangBot in various ways. Filter by component to show only plugins offering that capability.',
|
||||
allComponents: 'All Components',
|
||||
componentName: {
|
||||
Tool: 'Tool',
|
||||
EventListener: 'Event Listener',
|
||||
Command: 'Command',
|
||||
KnowledgeEngine: 'Knowledge Engine',
|
||||
Parser: 'Parser',
|
||||
Page: 'Page',
|
||||
},
|
||||
filterByType: 'Type',
|
||||
allTypes: 'All Types',
|
||||
typePlugin: 'Plugin',
|
||||
@@ -1531,6 +1544,17 @@ const enUS = {
|
||||
},
|
||||
},
|
||||
addExtension: {
|
||||
installTitle: 'Install {{type}}',
|
||||
installConfirm: 'Install {{type}} "{{name}}"?',
|
||||
installInfoType: 'Type',
|
||||
installInfoId: 'ID',
|
||||
installInfoVersion: 'Version',
|
||||
installSuccess: 'Installed successfully',
|
||||
installStage: {
|
||||
mcpInstalling: 'Adding and connecting the MCP server…',
|
||||
skillInstalling: 'Installing the skill…',
|
||||
installed: 'Done',
|
||||
},
|
||||
manualAdd: 'Manual Add',
|
||||
uploadExtension: 'Drag & drop or click to upload',
|
||||
uploadHint: 'Supports .zip (skills) and .lbpkg (plugins) files',
|
||||
|
||||
@@ -11,6 +11,8 @@ const esES = {
|
||||
'Páginas visuales proporcionadas por los plugins instalados',
|
||||
quickStart: 'Inicio rápido',
|
||||
scrollToBottom: 'Desplazar al final',
|
||||
editionCommunity: 'Comunidad',
|
||||
editionCloud: 'Cloud',
|
||||
},
|
||||
common: {
|
||||
login: 'Iniciar sesión',
|
||||
@@ -38,6 +40,7 @@ const esES = {
|
||||
helpDocs: 'Obtener ayuda',
|
||||
featureRequest: 'Solicitar función',
|
||||
starOnGitHub: 'Dar estrella en GitHub',
|
||||
joinDiscord: 'Únete a Discord',
|
||||
create: 'Crear',
|
||||
edit: 'Editar',
|
||||
delete: 'Eliminar',
|
||||
@@ -466,7 +469,7 @@ const esES = {
|
||||
noPluginInstalled: 'No hay plugins instalados',
|
||||
noExtensionInstalled: 'No hay extensiones instaladas',
|
||||
loadingExtensions: 'Cargando extensiones...',
|
||||
groupByType: 'Agrupar por tipo',
|
||||
groupByType: 'Agrupar por formato',
|
||||
pluginConfig: 'Configuración del plugin',
|
||||
pluginSort: 'Orden de plugins',
|
||||
pluginSortDescription:
|
||||
@@ -642,8 +645,8 @@ const esES = {
|
||||
},
|
||||
market: {
|
||||
searchPlaceholder: 'Buscar plugins...',
|
||||
searchResults: 'Se encontraron {{count}} plugins',
|
||||
totalPlugins: 'Total {{count}} plugins',
|
||||
searchResults: 'Se encontraron {{count}} extensiones',
|
||||
totalPlugins: 'Total {{count}} extensiones',
|
||||
noPlugins: 'No hay plugins disponibles',
|
||||
noResults: 'No se encontraron plugins relevantes',
|
||||
loadingMore: 'Cargando más...',
|
||||
@@ -683,8 +686,18 @@ const esES = {
|
||||
markAsRead: 'Marcar como leído',
|
||||
markAsReadSuccess: 'Marcado como leído',
|
||||
markAsReadFailed: 'Error al marcar como leído',
|
||||
filterByComponent: 'Componente',
|
||||
filterByComponent: 'Componente del plugin',
|
||||
filterByComponentHint:
|
||||
'Los tipos de capacidad que ofrece un plugin: herramienta (Tool), comando (Command), escucha de eventos (EventListener), etc., usados para ampliar las capacidades de LangBot. Filtra por componente para ver solo los plugins que ofrecen esa capacidad.',
|
||||
allComponents: 'Todos los componentes',
|
||||
componentName: {
|
||||
Tool: 'Herramienta',
|
||||
EventListener: 'Listener de eventos',
|
||||
Command: 'Comando',
|
||||
KnowledgeEngine: 'Motor de conocimiento',
|
||||
Parser: 'Analizador',
|
||||
Page: 'Página',
|
||||
},
|
||||
filterByType: 'Tipo',
|
||||
allTypes: 'Todos los tipos',
|
||||
typePlugin: 'Plugin',
|
||||
@@ -1638,6 +1651,17 @@ const esES = {
|
||||
saveFileError: 'Error al guardar el archivo: ',
|
||||
},
|
||||
addExtension: {
|
||||
installTitle: 'Instalar {{type}}',
|
||||
installConfirm: '¿Instalar {{type}} "{{name}}"?',
|
||||
installInfoType: 'Tipo',
|
||||
installInfoId: 'ID',
|
||||
installInfoVersion: 'Versión',
|
||||
installSuccess: 'Instalado correctamente',
|
||||
installStage: {
|
||||
mcpInstalling: 'Añadiendo y conectando el servidor MCP…',
|
||||
skillInstalling: 'Instalando la skill…',
|
||||
installed: 'Listo',
|
||||
},
|
||||
manualAdd: 'Añadir manualmente',
|
||||
uploadExtension: 'Arrastra y suelta o haz clic para subir',
|
||||
uploadHint: 'Admite archivos .zip (skills) y .lbpkg (plugins)',
|
||||
|
||||
@@ -10,6 +10,8 @@ const jaJP = {
|
||||
pluginPagesTooltip: 'インストール済みプラグインが提供するビジュアルページ',
|
||||
quickStart: 'クイックスタート',
|
||||
scrollToBottom: '一番下までスクロール',
|
||||
editionCommunity: 'コミュニティ版',
|
||||
editionCloud: 'Cloud',
|
||||
},
|
||||
common: {
|
||||
login: 'ログイン',
|
||||
@@ -36,6 +38,7 @@ const jaJP = {
|
||||
helpDocs: 'ヘルプドキュメント',
|
||||
featureRequest: '機能リクエスト',
|
||||
starOnGitHub: 'GitHubでStarする',
|
||||
joinDiscord: 'Discord に参加',
|
||||
create: '作成',
|
||||
edit: '編集',
|
||||
delete: '削除',
|
||||
@@ -459,7 +462,7 @@ const jaJP = {
|
||||
noPluginInstalled: 'プラグインがインストールされていません',
|
||||
noExtensionInstalled: '拡張機能がインストールされていません',
|
||||
loadingExtensions: '拡張機能を読み込み中...',
|
||||
groupByType: '種類でグループ化',
|
||||
groupByType: '形式でグループ化',
|
||||
pluginConfig: 'プラグイン設定',
|
||||
pluginSort: 'プラグインの並び替え',
|
||||
pluginSortDescription:
|
||||
@@ -634,8 +637,8 @@ const jaJP = {
|
||||
},
|
||||
market: {
|
||||
searchPlaceholder: 'プラグインを検索...',
|
||||
searchResults: '{{count}} 個のプラグインが見つかりました',
|
||||
totalPlugins: '合計 {{count}} 個のプラグイン',
|
||||
searchResults: '{{count}} 個の拡張機能が見つかりました',
|
||||
totalPlugins: '合計 {{count}} 個の拡張機能',
|
||||
noPlugins: '利用可能なプラグインがありません',
|
||||
noResults: '関連するプラグインが見つかりません',
|
||||
loadingMore: 'さらに読み込み中...',
|
||||
@@ -675,8 +678,18 @@ const jaJP = {
|
||||
markAsRead: '既読',
|
||||
markAsReadSuccess: '既読に設定しました',
|
||||
markAsReadFailed: '既読に設定に失敗しました',
|
||||
filterByComponent: 'コンポーネント',
|
||||
filterByComponent: 'プラグインコンポーネント',
|
||||
filterByComponentHint:
|
||||
'プラグインが提供する機能の種類です(ツール、コマンド、イベントリスナーなど)。LangBot のさまざまな機能を拡張するために使われます。コンポーネントで絞り込むと、その機能を提供するプラグインのみを表示できます。',
|
||||
allComponents: '全部コンポーネント',
|
||||
componentName: {
|
||||
Tool: 'ツール',
|
||||
EventListener: 'イベント監視器',
|
||||
Command: 'コマンド',
|
||||
KnowledgeEngine: '知識エンジン',
|
||||
Parser: 'パーサー',
|
||||
Page: 'ページ',
|
||||
},
|
||||
filterByType: 'タイプ',
|
||||
allTypes: '全部',
|
||||
typePlugin: 'プラグイン',
|
||||
@@ -1445,6 +1458,17 @@ const jaJP = {
|
||||
},
|
||||
},
|
||||
addExtension: {
|
||||
installTitle: '{{type}}をインストール',
|
||||
installConfirm: '{{type}}「{{name}}」をインストールしますか?',
|
||||
installInfoType: 'タイプ',
|
||||
installInfoId: 'ID',
|
||||
installInfoVersion: 'バージョン',
|
||||
installSuccess: 'インストールに成功しました',
|
||||
installStage: {
|
||||
mcpInstalling: 'MCPサーバーを追加して接続しています…',
|
||||
skillInstalling: 'スキルをインストールしています…',
|
||||
installed: '完了',
|
||||
},
|
||||
manualAdd: '手動追加',
|
||||
uploadExtension: 'ドラッグ&ドロップまたはクリックしてアップロード',
|
||||
uploadHint: '.zip(スキル)と.lbpkg(プラグイン)ファイルに対応',
|
||||
|
||||
@@ -11,6 +11,8 @@ const ruRU = {
|
||||
'Визуальные страницы, предоставляемые установленными плагинами',
|
||||
quickStart: 'Быстрый старт',
|
||||
scrollToBottom: 'Прокрутить вниз',
|
||||
editionCommunity: 'Сообщество',
|
||||
editionCloud: 'Cloud',
|
||||
},
|
||||
common: {
|
||||
login: 'Войти',
|
||||
@@ -36,6 +38,7 @@ const ruRU = {
|
||||
helpDocs: 'Помощь',
|
||||
featureRequest: 'Запрос функции',
|
||||
starOnGitHub: 'Поставить звезду на GitHub',
|
||||
joinDiscord: 'Присоединиться к Discord',
|
||||
create: 'Создать',
|
||||
edit: 'Редактировать',
|
||||
delete: 'Удалить',
|
||||
@@ -464,7 +467,7 @@ const ruRU = {
|
||||
noPluginInstalled: 'Плагины не установлены',
|
||||
noExtensionInstalled: 'Расширения не установлены',
|
||||
loadingExtensions: 'Загрузка расширений...',
|
||||
groupByType: 'Группировать по типу',
|
||||
groupByType: 'Группировать по формату',
|
||||
pluginConfig: 'Настройка плагина',
|
||||
pluginSort: 'Порядок плагинов',
|
||||
pluginSortDescription:
|
||||
@@ -640,8 +643,8 @@ const ruRU = {
|
||||
},
|
||||
market: {
|
||||
searchPlaceholder: 'Поиск плагинов...',
|
||||
searchResults: 'Найдено {{count}} плагинов',
|
||||
totalPlugins: 'Всего {{count}} плагинов',
|
||||
searchResults: 'Найдено {{count}} расширений',
|
||||
totalPlugins: 'Всего {{count}} расширений',
|
||||
noPlugins: 'Нет доступных плагинов',
|
||||
noResults: 'Подходящие плагины не найдены',
|
||||
loadingMore: 'Загрузка ещё...',
|
||||
@@ -680,8 +683,18 @@ const ruRU = {
|
||||
markAsRead: 'Отметить как прочитанное',
|
||||
markAsReadSuccess: 'Отмечено как прочитанное',
|
||||
markAsReadFailed: 'Не удалось отметить как прочитанное',
|
||||
filterByComponent: 'Компонент',
|
||||
filterByComponent: 'Компонент плагина',
|
||||
filterByComponentHint:
|
||||
'Типы возможностей, которые предоставляет плагин — инструмент (Tool), команда (Command), обработчик событий (EventListener) и т. д., — расширяющие функции LangBot. Фильтруйте по компоненту, чтобы видеть только плагины с нужной возможностью.',
|
||||
allComponents: 'Все компоненты',
|
||||
componentName: {
|
||||
Tool: 'Инструмент',
|
||||
EventListener: 'Обработчик событий',
|
||||
Command: 'Команда',
|
||||
KnowledgeEngine: 'Движок знаний',
|
||||
Parser: 'Парсер',
|
||||
Page: 'Страница',
|
||||
},
|
||||
filterByType: 'Тип',
|
||||
allTypes: 'Все типы',
|
||||
typePlugin: 'Плагин',
|
||||
@@ -1606,6 +1619,17 @@ const ruRU = {
|
||||
saveFileError: 'Не удалось сохранить файл: ',
|
||||
},
|
||||
addExtension: {
|
||||
installTitle: 'Установить {{type}}',
|
||||
installConfirm: 'Установить {{type}} «{{name}}»?',
|
||||
installInfoType: 'Тип',
|
||||
installInfoId: 'ID',
|
||||
installInfoVersion: 'Версия',
|
||||
installSuccess: 'Успешно установлено',
|
||||
installStage: {
|
||||
mcpInstalling: 'Добавление и подключение сервера MCP…',
|
||||
skillInstalling: 'Установка навыка…',
|
||||
installed: 'Готово',
|
||||
},
|
||||
manualAdd: 'Добавить вручную',
|
||||
uploadExtension: 'Перетащите файл сюда или нажмите для загрузки',
|
||||
uploadHint: 'Поддерживаются файлы .zip (навыки) и .lbpkg (плагины)',
|
||||
|
||||
@@ -10,6 +10,8 @@ const thTH = {
|
||||
pluginPagesTooltip: 'หน้าเว็บที่จัดทำโดยปลั๊กอินที่ติดตั้ง',
|
||||
quickStart: 'เริ่มต้นอย่างรวดเร็ว',
|
||||
scrollToBottom: 'เลื่อนไปด้านล่าง',
|
||||
editionCommunity: 'รุ่นชุมชน',
|
||||
editionCloud: 'Cloud',
|
||||
},
|
||||
common: {
|
||||
login: 'เข้าสู่ระบบ',
|
||||
@@ -35,6 +37,7 @@ const thTH = {
|
||||
helpDocs: 'ขอความช่วยเหลือ',
|
||||
featureRequest: 'ขอฟีเจอร์ใหม่',
|
||||
starOnGitHub: 'ให้ดาวบน GitHub',
|
||||
joinDiscord: 'เข้าร่วม Discord',
|
||||
create: 'สร้าง',
|
||||
edit: 'แก้ไข',
|
||||
delete: 'ลบ',
|
||||
@@ -450,7 +453,7 @@ const thTH = {
|
||||
noPluginInstalled: 'ยังไม่มีปลั๊กอินที่ติดตั้ง',
|
||||
noExtensionInstalled: 'ยังไม่มีส่วนขยายที่ติดตั้ง',
|
||||
loadingExtensions: 'กำลังโหลดส่วนขยาย...',
|
||||
groupByType: 'จัดกลุ่มตามประเภท',
|
||||
groupByType: 'จัดกลุ่มตามรูปแบบ',
|
||||
pluginConfig: 'การกำหนดค่าปลั๊กอิน',
|
||||
pluginSort: 'เรียงลำดับปลั๊กอิน',
|
||||
pluginSortDescription:
|
||||
@@ -621,8 +624,8 @@ const thTH = {
|
||||
},
|
||||
market: {
|
||||
searchPlaceholder: 'ค้นหาปลั๊กอิน...',
|
||||
searchResults: 'พบ {{count}} ปลั๊กอิน',
|
||||
totalPlugins: 'ทั้งหมด {{count}} ปลั๊กอิน',
|
||||
searchResults: 'พบ {{count}} ส่วนขยาย',
|
||||
totalPlugins: 'ทั้งหมด {{count}} ส่วนขยาย',
|
||||
noPlugins: 'ไม่มีปลั๊กอินที่พร้อมใช้งาน',
|
||||
noResults: 'ไม่พบปลั๊กอินที่เกี่ยวข้อง',
|
||||
loadingMore: 'กำลังโหลดเพิ่มเติม...',
|
||||
@@ -661,8 +664,18 @@ const thTH = {
|
||||
markAsRead: 'ทำเครื่องหมายว่าอ่านแล้ว',
|
||||
markAsReadSuccess: 'ทำเครื่องหมายว่าอ่านแล้ว',
|
||||
markAsReadFailed: 'ทำเครื่องหมายว่าอ่านแล้วล้มเหลว',
|
||||
filterByComponent: 'ส่วนประกอบ',
|
||||
filterByComponent: 'ส่วนประกอบปลั๊กอิน',
|
||||
filterByComponentHint:
|
||||
'ประเภทความสามารถที่ปลั๊กอินมีให้ เช่น เครื่องมือ (Tool) คำสั่ง (Command) ตัวรับฟังเหตุการณ์ (EventListener) เป็นต้น ใช้เพื่อขยายความสามารถต่าง ๆ ของ LangBot กรองตามส่วนประกอบเพื่อแสดงเฉพาะปลั๊กอินที่มีความสามารถนั้น',
|
||||
allComponents: 'ส่วนประกอบทั้งหมด',
|
||||
componentName: {
|
||||
Tool: 'เครื่องมือ',
|
||||
EventListener: 'ตัวรับฟังเหตุการณ์',
|
||||
Command: 'คำสั่ง',
|
||||
KnowledgeEngine: 'เครื่องมือความรู้',
|
||||
Parser: 'ตัวแยกวิเคราะห์',
|
||||
Page: 'หน้า',
|
||||
},
|
||||
filterByType: 'ประเภท',
|
||||
allTypes: 'ทุกประเภท',
|
||||
typePlugin: 'ปลั๊กอิน',
|
||||
@@ -1569,6 +1582,17 @@ const thTH = {
|
||||
saveFileError: 'บันทึกไฟล์ไม่สำเร็จ: ',
|
||||
},
|
||||
addExtension: {
|
||||
installTitle: 'ติดตั้ง {{type}}',
|
||||
installConfirm: 'ติดตั้ง {{type}} "{{name}}" หรือไม่?',
|
||||
installInfoType: 'ประเภท',
|
||||
installInfoId: 'ID',
|
||||
installInfoVersion: 'เวอร์ชัน',
|
||||
installSuccess: 'ติดตั้งสำเร็จ',
|
||||
installStage: {
|
||||
mcpInstalling: 'กำลังเพิ่มและเชื่อมต่อเซิร์ฟเวอร์ MCP…',
|
||||
skillInstalling: 'กำลังติดตั้งสกิล…',
|
||||
installed: 'เสร็จสิ้น',
|
||||
},
|
||||
manualAdd: 'เพิ่มด้วยตนเอง',
|
||||
uploadExtension: 'ลากแล้ววางหรือคลิกเพื่ออัปโหลด',
|
||||
uploadHint: 'รองรับไฟล์ .zip (สกิล) และ .lbpkg (ปลั๊กอิน)',
|
||||
|
||||
@@ -11,6 +11,8 @@ const viVN = {
|
||||
'Các trang trực quan được cung cấp bởi plugin đã cài đặt',
|
||||
quickStart: 'Bắt đầu nhanh',
|
||||
scrollToBottom: 'Cuộn xuống cuối',
|
||||
editionCommunity: 'Bản cộng đồng',
|
||||
editionCloud: 'Cloud',
|
||||
},
|
||||
common: {
|
||||
login: 'Đăng nhập',
|
||||
@@ -36,6 +38,7 @@ const viVN = {
|
||||
helpDocs: 'Trợ giúp',
|
||||
featureRequest: 'Yêu cầu tính năng',
|
||||
starOnGitHub: 'Star trên GitHub',
|
||||
joinDiscord: 'Tham gia Discord',
|
||||
create: 'Tạo',
|
||||
edit: 'Chỉnh sửa',
|
||||
delete: 'Xóa',
|
||||
@@ -460,7 +463,7 @@ const viVN = {
|
||||
noPluginInstalled: 'Chưa cài đặt plugin nào',
|
||||
noExtensionInstalled: 'Chưa cài đặt tiện ích mở rộng nào',
|
||||
loadingExtensions: 'Đang tải tiện ích mở rộng...',
|
||||
groupByType: 'Nhóm theo loại',
|
||||
groupByType: 'Nhóm theo định dạng',
|
||||
pluginConfig: 'Cấu hình Plugin',
|
||||
pluginSort: 'Sắp xếp Plugin',
|
||||
pluginSortDescription:
|
||||
@@ -635,8 +638,8 @@ const viVN = {
|
||||
},
|
||||
market: {
|
||||
searchPlaceholder: 'Tìm kiếm plugin...',
|
||||
searchResults: 'Tìm thấy {{count}} plugin',
|
||||
totalPlugins: 'Tổng cộng {{count}} plugin',
|
||||
searchResults: 'Tìm thấy {{count}} tiện ích mở rộng',
|
||||
totalPlugins: 'Tổng cộng {{count}} tiện ích mở rộng',
|
||||
noPlugins: 'Không có plugin nào',
|
||||
noResults: 'Không tìm thấy plugin liên quan',
|
||||
loadingMore: 'Đang tải thêm...',
|
||||
@@ -675,8 +678,18 @@ const viVN = {
|
||||
markAsRead: 'Đánh dấu đã đọc',
|
||||
markAsReadSuccess: 'Đã đánh dấu đã đọc',
|
||||
markAsReadFailed: 'Đánh dấu đã đọc thất bại',
|
||||
filterByComponent: 'Thành phần',
|
||||
filterByComponent: 'Thành phần plugin',
|
||||
filterByComponentHint:
|
||||
'Các loại năng lực mà plugin cung cấp — Công cụ (Tool), Lệnh (Command), Trình lắng nghe sự kiện (EventListener), v.v. — dùng để mở rộng các khả năng của LangBot. Lọc theo thành phần để chỉ xem những plugin cung cấp năng lực đó.',
|
||||
allComponents: 'Tất cả thành phần',
|
||||
componentName: {
|
||||
Tool: 'Công cụ',
|
||||
EventListener: 'Trình lắng nghe sự kiện',
|
||||
Command: 'Lệnh',
|
||||
KnowledgeEngine: 'Công cụ tri thức',
|
||||
Parser: 'Trình phân tích',
|
||||
Page: 'Trang',
|
||||
},
|
||||
filterByType: 'Loại',
|
||||
allTypes: 'Tất cả loại',
|
||||
typePlugin: 'Plugin',
|
||||
@@ -1598,6 +1611,17 @@ const viVN = {
|
||||
saveFileError: 'Lưu tệp thất bại: ',
|
||||
},
|
||||
addExtension: {
|
||||
installTitle: 'Cài đặt {{type}}',
|
||||
installConfirm: 'Cài đặt {{type}} "{{name}}"?',
|
||||
installInfoType: 'Loại',
|
||||
installInfoId: 'ID',
|
||||
installInfoVersion: 'Phiên bản',
|
||||
installSuccess: 'Cài đặt thành công',
|
||||
installStage: {
|
||||
mcpInstalling: 'Đang thêm và kết nối máy chủ MCP…',
|
||||
skillInstalling: 'Đang cài đặt kỹ năng…',
|
||||
installed: 'Hoàn tất',
|
||||
},
|
||||
manualAdd: 'Thêm thủ công',
|
||||
uploadExtension: 'Kéo thả hoặc nhấp để tải lên',
|
||||
uploadHint: 'Hỗ trợ tệp .zip (kỹ năng) và .lbpkg (plugin)',
|
||||
|
||||
@@ -10,6 +10,8 @@ const zhHans = {
|
||||
pluginPagesTooltip: '由已安装的插件提供的可视化页面',
|
||||
quickStart: '快速开始向导',
|
||||
scrollToBottom: '滚动到底部',
|
||||
editionCommunity: '社区版',
|
||||
editionCloud: 'Cloud',
|
||||
},
|
||||
common: {
|
||||
login: '登录',
|
||||
@@ -34,6 +36,7 @@ const zhHans = {
|
||||
helpDocs: '帮助文档',
|
||||
featureRequest: '需求建议',
|
||||
starOnGitHub: '在 GitHub 上 Star',
|
||||
joinDiscord: '加入 Discord 社区',
|
||||
create: '创建',
|
||||
edit: '编辑',
|
||||
delete: '删除',
|
||||
@@ -438,7 +441,7 @@ const zhHans = {
|
||||
noPluginInstalled: '暂未安装任何插件',
|
||||
noExtensionInstalled: '暂未安装任何扩展',
|
||||
loadingExtensions: '正在加载扩展...',
|
||||
groupByType: '按类型分组',
|
||||
groupByType: '按格式分组',
|
||||
pluginSort: '插件排序',
|
||||
pluginSortDescription:
|
||||
'插件顺序会影响同一事件内的处理顺序,请拖动插件卡片排序',
|
||||
@@ -603,8 +606,8 @@ const zhHans = {
|
||||
},
|
||||
market: {
|
||||
searchPlaceholder: '搜索插件...',
|
||||
searchResults: '搜索到 {{count}} 个插件',
|
||||
totalPlugins: '共 {{count}} 个插件',
|
||||
searchResults: '搜索到 {{count}} 个扩展',
|
||||
totalPlugins: '共 {{count}} 个扩展',
|
||||
noPlugins: '暂无插件',
|
||||
noResults: '未找到相关插件',
|
||||
loadingMore: '加载更多...',
|
||||
@@ -643,8 +646,18 @@ const zhHans = {
|
||||
markAsRead: '已读',
|
||||
markAsReadSuccess: '已标记为已读',
|
||||
markAsReadFailed: '标记为已读失败',
|
||||
filterByComponent: '组件',
|
||||
filterByComponent: '插件组件',
|
||||
filterByComponentHint:
|
||||
'插件提供的能力类型,如工具(Tool)、命令(Command)、事件监听器(EventListener)等,用于扩展 LangBot 的各项能力。按组件筛选可只看提供对应能力的插件。',
|
||||
allComponents: '全部组件',
|
||||
componentName: {
|
||||
Tool: '工具',
|
||||
EventListener: '事件监听器',
|
||||
Command: '命令',
|
||||
KnowledgeEngine: '知识引擎',
|
||||
Parser: '解析器',
|
||||
Page: '页面',
|
||||
},
|
||||
filterByType: '类型',
|
||||
allTypes: '全部类型',
|
||||
typePlugin: '插件',
|
||||
@@ -1467,6 +1480,17 @@ const zhHans = {
|
||||
},
|
||||
},
|
||||
addExtension: {
|
||||
installTitle: '安装{{type}}',
|
||||
installConfirm: '确定要安装{{type}} "{{name}}" 吗?',
|
||||
installInfoType: '类型',
|
||||
installInfoId: '标识',
|
||||
installInfoVersion: '版本',
|
||||
installSuccess: '安装成功',
|
||||
installStage: {
|
||||
mcpInstalling: '正在添加并连接 MCP 服务器…',
|
||||
skillInstalling: '正在安装技能…',
|
||||
installed: '已完成',
|
||||
},
|
||||
manualAdd: '手动添加',
|
||||
uploadExtension: '拖拽或点击上传扩展包',
|
||||
uploadHint: '支持 .zip(技能)和 .lbpkg(插件)文件',
|
||||
|
||||
@@ -10,6 +10,8 @@ const zhHant = {
|
||||
pluginPagesTooltip: '由已安裝的插件提供的視覺化頁面',
|
||||
quickStart: '快速開始',
|
||||
scrollToBottom: '捲動到底部',
|
||||
editionCommunity: '社區版',
|
||||
editionCloud: 'Cloud',
|
||||
},
|
||||
common: {
|
||||
login: '登入',
|
||||
@@ -34,6 +36,7 @@ const zhHant = {
|
||||
helpDocs: '輔助說明',
|
||||
featureRequest: '需求建議',
|
||||
starOnGitHub: '在 GitHub 上 Star',
|
||||
joinDiscord: '加入 Discord 社群',
|
||||
create: '建立',
|
||||
edit: '編輯',
|
||||
delete: '刪除',
|
||||
@@ -439,7 +442,7 @@ const zhHant = {
|
||||
noPluginInstalled: '暫未安裝任何外掛',
|
||||
noExtensionInstalled: '暫未安裝任何擴充功能',
|
||||
loadingExtensions: '正在載入擴充功能...',
|
||||
groupByType: '依類型分組',
|
||||
groupByType: '依格式分組',
|
||||
pluginSort: '外掛排序',
|
||||
pluginSortDescription:
|
||||
'外掛順序會影響同一事件內的處理順序,請拖曳外掛卡片排序',
|
||||
@@ -603,8 +606,8 @@ const zhHant = {
|
||||
},
|
||||
market: {
|
||||
searchPlaceholder: '搜尋插件...',
|
||||
searchResults: '搜尋到 {{count}} 個插件',
|
||||
totalPlugins: '共 {{count}} 個插件',
|
||||
searchResults: '搜尋到 {{count}} 個擴展',
|
||||
totalPlugins: '共 {{count}} 個擴展',
|
||||
noPlugins: '暫無插件',
|
||||
noResults: '未找到相關插件',
|
||||
loadingMore: '載入更多...',
|
||||
@@ -643,8 +646,18 @@ const zhHant = {
|
||||
markAsRead: '已讀',
|
||||
markAsReadSuccess: '已標記為已讀',
|
||||
markAsReadFailed: '標記為已讀失敗',
|
||||
filterByComponent: '組件',
|
||||
filterByComponent: '插件組件',
|
||||
filterByComponentHint:
|
||||
'插件提供的能力類型,如工具(Tool)、命令(Command)、事件監聽器(EventListener)等,用於擴展 LangBot 的各項能力。按組件篩選可只看提供對應能力的插件。',
|
||||
allComponents: '全部組件',
|
||||
componentName: {
|
||||
Tool: '工具',
|
||||
EventListener: '事件監聽器',
|
||||
Command: '命令',
|
||||
KnowledgeEngine: '知識引擎',
|
||||
Parser: '解析器',
|
||||
Page: '擴展頁',
|
||||
},
|
||||
filterByType: '類型',
|
||||
allTypes: '全部類型',
|
||||
typePlugin: '插件',
|
||||
@@ -1377,6 +1390,17 @@ const zhHant = {
|
||||
},
|
||||
},
|
||||
addExtension: {
|
||||
installTitle: '安裝{{type}}',
|
||||
installConfirm: '確定要安裝{{type}}「{{name}}」嗎?',
|
||||
installInfoType: '類型',
|
||||
installInfoId: 'ID',
|
||||
installInfoVersion: '版本',
|
||||
installSuccess: '安裝成功',
|
||||
installStage: {
|
||||
mcpInstalling: '正在新增並連接 MCP 伺服器…',
|
||||
skillInstalling: '正在安裝技能…',
|
||||
installed: '完成',
|
||||
},
|
||||
manualAdd: '手動新增',
|
||||
uploadExtension: '拖拽或點擊上傳擴充套件',
|
||||
uploadHint: '支援 .zip(技能)和 .lbpkg(插件)檔案',
|
||||
|
||||
Reference in New Issue
Block a user