mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-06-07 14:26:03 +00:00
Compare commits
19 Commits
feat/agent
...
feat/addwe
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
07b90f12a2 | ||
|
|
fd896c6974 | ||
|
|
1fbfa868fb | ||
|
|
ad05819c2e | ||
|
|
0c6f71738c | ||
|
|
af451e7006 | ||
|
|
59f20bcc73 | ||
|
|
e5b3cced1f | ||
|
|
101e04db6d | ||
|
|
b79edda3a7 | ||
|
|
a20d3d11e5 | ||
|
|
3b4c455813 | ||
|
|
c967a2aa82 | ||
|
|
79cc6da96f | ||
|
|
fee7d48dc3 | ||
|
|
8811fb647f | ||
|
|
37b017459d | ||
|
|
4889a3881b | ||
|
|
fe4f95b9a3 |
16
Dockerfile
16
Dockerfile
@@ -14,10 +14,22 @@ COPY . .
|
|||||||
|
|
||||||
COPY --from=node /app/web/dist ./web/dist
|
COPY --from=node /app/web/dist ./web/dist
|
||||||
|
|
||||||
RUN apt update \
|
RUN apt-get update \
|
||||||
&& apt install gcc -y \
|
&& 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 \
|
&& python -m pip install --no-cache-dir uv \
|
||||||
&& uv sync \
|
&& uv sync \
|
||||||
|
&& apt-get purge -y --auto-remove curl gnupg \
|
||||||
|
&& rm -rf /var/lib/apt/lists/* \
|
||||||
&& touch /.dockerenv
|
&& touch /.dockerenv
|
||||||
|
|
||||||
CMD [ "uv", "run", "--no-sync", "main.py" ]
|
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 等平台。
|
- **全平台支持** — 一套代码,覆盖 QQ、微信、企业微信、飞书、钉钉、Discord、Telegram、Slack、LINE、KOOK 等平台。
|
||||||
- **生产就绪** — 访问控制、限速、敏感词过滤、全面监控与异常处理,已被多家企业采用。
|
- **生产就绪** — 访问控制、限速、敏感词过滤、全面监控与异常处理,已被多家企业采用。
|
||||||
- **插件生态** — 数百个插件,跨进程的事件驱动架构,组件扩展,适配 [MCP 协议](https://modelcontextprotocol.io/)。
|
- **插件生态** — 数百个插件,跨进程的事件驱动架构,组件扩展,适配 [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
|
### 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.
|
- **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.
|
- **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/).
|
- **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
|
### 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.
|
- **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.
|
- **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/).
|
- **É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 に対応。
|
- **ユニバーサルIMプラットフォーム対応** — 単一のコードベースで Discord、Telegram、Slack、LINE、QQ、WeChat、WeCom、Lark、DingTalk、KOOK に対応。
|
||||||
- **本番環境対応** — アクセス制御、レート制限、センシティブワードフィルタリング、包括的な監視、例外処理を搭載。エンタープライズの信頼に応える品質。
|
- **本番環境対応** — アクセス制御、レート制限、センシティブワードフィルタリング、包括的な監視、例外処理を搭載。エンタープライズの信頼に応える品質。
|
||||||
- **プラグインエコシステム** — 数百のプラグイン、イベント駆動アーキテクチャ、コンポーネント拡張、[MCPプロトコル](https://modelcontextprotocol.io/)対応。
|
- **プラグインエコシステム** — 数百のプラグイン、イベント駆動アーキテクチャ、コンポーネント拡張、[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 지원.
|
- **유니버설 IM 플랫폼 지원** — 단일 코드베이스로 Discord, Telegram, Slack, LINE, QQ, WeChat, WeCom, Lark, DingTalk, KOOK 지원.
|
||||||
- **프로덕션 레디** — 접근 제어, 속도 제한, 민감어 필터링, 종합 모니터링 및 예외 처리. 기업 환경에서 검증됨.
|
- **프로덕션 레디** — 접근 제어, 속도 제한, 민감어 필터링, 종합 모니터링 및 예외 처리. 기업 환경에서 검증됨.
|
||||||
- **플러그인 생태계** — 수백 개의 플러그인, 이벤트 기반 아키텍처, 컴포넌트 확장, [MCP 프로토콜](https://modelcontextprotocol.io/) 지원.
|
- **플러그인 생태계** — 수백 개의 플러그인, 이벤트 기반 아키텍처, 컴포넌트 확장, [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.
|
- **Универсальная поддержка IM-платформ** — Единая кодовая база для Discord, Telegram, Slack, LINE, QQ, WeChat, WeCom, Lark, DingTalk, KOOK.
|
||||||
- **Готовность к продакшену** — Контроль доступа, ограничение скорости, фильтрация чувствительных слов, комплексный мониторинг и обработка исключений. Проверено в корпоративной среде.
|
- **Готовность к продакшену** — Контроль доступа, ограничение скорости, фильтрация чувствительных слов, комплексный мониторинг и обработка исключений. Проверено в корпоративной среде.
|
||||||
- **Экосистема плагинов** — Сотни плагинов, событийно-ориентированная архитектура, расширения компонентов и поддержка [протокола MCP](https://modelcontextprotocol.io/).
|
- **Экосистема плагинов** — Сотни плагинов, событийно-ориентированная архитектура, расширения компонентов и поддержка [протокола 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 等平台。
|
- **全平台支援** — 一套程式碼,覆蓋 QQ、微信、企業微信、飛書、釘釘、Discord、Telegram、Slack、LINE、KOOK 等平台。
|
||||||
- **生產就緒** — 存取控制、限速、敏感詞過濾、全面監控與異常處理,已被多家企業採用。
|
- **生產就緒** — 存取控制、限速、敏感詞過濾、全面監控與異常處理,已被多家企業採用。
|
||||||
- **外掛生態** — 數百個外掛,事件驅動架構,組件擴展,適配 [MCP 協議](https://modelcontextprotocol.io/)。
|
- **外掛生態** — 數百個外掛,事件驅動架構,組件擴展,適配 [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
|
### 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.
|
- **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.
|
- **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/).
|
- **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,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "langbot"
|
name = "langbot"
|
||||||
version = "4.10.0-beta.2"
|
version = "4.10.0"
|
||||||
description = "Production-grade platform for building agentic IM bots"
|
description = "Production-grade platform for building agentic IM bots"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
license-files = ["LICENSE"]
|
license-files = ["LICENSE"]
|
||||||
@@ -70,7 +70,7 @@ dependencies = [
|
|||||||
"chromadb>=1.0.0,<2.0.0",
|
"chromadb>=1.0.0,<2.0.0",
|
||||||
"qdrant-client (>=1.15.1,<2.0.0)",
|
"qdrant-client (>=1.15.1,<2.0.0)",
|
||||||
"pyseekdb==1.1.0.post3",
|
"pyseekdb==1.1.0.post3",
|
||||||
"langbot-plugin==0.4.0",
|
"langbot-plugin==0.4.1",
|
||||||
"asyncpg>=0.30.0",
|
"asyncpg>=0.30.0",
|
||||||
"line-bot-sdk>=3.19.0",
|
"line-bot-sdk>=3.19.0",
|
||||||
"matrix-nio>=0.25.2",
|
"matrix-nio>=0.25.2",
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
"""LangBot - Production-grade platform for building agentic IM bots"""
|
"""LangBot - Production-grade platform for building agentic IM bots"""
|
||||||
|
|
||||||
__version__ = '4.10.0-beta.2'
|
__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)
|
||||||
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()
|
||||||
@@ -459,7 +459,7 @@ class PluginRuntimeConnector(ManagedRuntimeConnector):
|
|||||||
)
|
)
|
||||||
|
|
||||||
file_bytes = download_resp.content
|
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')
|
file_key = await self.handler.send_file(file_bytes, 'lbpkg')
|
||||||
install_info['plugin_file_key'] = file_key
|
install_info['plugin_file_key'] = file_key
|
||||||
self.ap.logger.info(f'Transfered file {file_key} to plugin runtime')
|
self.ap.logger.info(f'Transfered file {file_key} to plugin runtime')
|
||||||
|
|||||||
@@ -143,49 +143,83 @@ class ModelManager:
|
|||||||
# get the latest models from space
|
# get the latest models from space
|
||||||
space_models = await self.ap.space_service.get_models()
|
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()]
|
# Index existing models by uuid. Space reuses a model's uuid across
|
||||||
exists_embedding_models_uuids = [
|
# renames / re-specs (e.g. the uuid that used to be ``claude-opus-4-6``
|
||||||
m['uuid'] for m in await self.ap.embedding_models_service.get_embedding_models()
|
# 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:
|
for space_model in space_models:
|
||||||
if space_model.category == 'chat':
|
if space_model.category == 'chat':
|
||||||
uuid = space_model.uuid
|
existing = existing_llm_models.get(space_model.uuid)
|
||||||
|
if existing is None:
|
||||||
if uuid in exists_llm_models_uuids:
|
# model will be automatically loaded
|
||||||
continue
|
await self.ap.llm_model_service.create_llm_model(
|
||||||
|
{
|
||||||
# model will be automatically loaded
|
'uuid': space_model.uuid,
|
||||||
await self.ap.llm_model_service.create_llm_model(
|
'name': space_model.model_id,
|
||||||
{
|
'provider_uuid': space_model_provider.uuid,
|
||||||
'uuid': space_model.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,
|
'name': space_model.model_id,
|
||||||
'provider_uuid': space_model_provider.uuid,
|
'provider_uuid': space_model_provider.uuid,
|
||||||
'abilities': space_model.llm_abilities or [],
|
'abilities': space_model.llm_abilities or [],
|
||||||
'extra_args': {},
|
|
||||||
'prefered_ranking': space_model.featured_order,
|
'prefered_ranking': space_model.featured_order,
|
||||||
},
|
}
|
||||||
preserve_uuid=True,
|
if (
|
||||||
auto_set_to_default_pipeline=False,
|
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':
|
elif space_model.category == 'embedding':
|
||||||
uuid = space_model.uuid
|
existing = existing_embedding_models.get(space_model.uuid)
|
||||||
|
if existing is None:
|
||||||
if uuid in exists_embedding_models_uuids:
|
# model will be automatically loaded
|
||||||
continue
|
await self.ap.embedding_models_service.create_embedding_model(
|
||||||
|
{
|
||||||
# model will be automatically loaded
|
'uuid': space_model.uuid,
|
||||||
await self.ap.embedding_models_service.create_embedding_model(
|
'name': space_model.model_id,
|
||||||
{
|
'provider_uuid': space_model_provider.uuid,
|
||||||
'uuid': space_model.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,
|
'name': space_model.model_id,
|
||||||
'provider_uuid': space_model_provider.uuid,
|
'provider_uuid': space_model_provider.uuid,
|
||||||
'extra_args': {},
|
|
||||||
'prefered_ranking': space_model.featured_order,
|
'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(
|
async def init_temporary_runtime_llm_model(
|
||||||
self,
|
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
|
||||||
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
|
return
|
||||||
if attempt >= self._MAX_RETRIES:
|
if attempt >= self._MAX_RETRIES:
|
||||||
self.status = MCPSessionStatus.ERROR
|
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()
|
self._ready_event.set()
|
||||||
return
|
return
|
||||||
delay = self._RETRY_DELAYS[attempt]
|
delay = self._RETRY_DELAYS[attempt]
|
||||||
self.ap.logger.warning(
|
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()
|
await self._cleanup_box_stdio_session()
|
||||||
# Reset status for retry
|
# Reset status for retry
|
||||||
@@ -254,6 +255,30 @@ class RuntimeMCPSession:
|
|||||||
self.error_phase = None
|
self.error_phase = None
|
||||||
await asyncio.sleep(delay)
|
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_POLL_INTERVAL = 5
|
||||||
_MONITOR_MAX_CONSECUTIVE_ERRORS = 3
|
_MONITOR_MAX_CONSECUTIVE_ERRORS = 3
|
||||||
|
|
||||||
|
|||||||
@@ -47,6 +47,14 @@ stages:
|
|||||||
label:
|
label:
|
||||||
en_US: Langflow API
|
en_US: Langflow API
|
||||||
zh_Hans: 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
|
- name: expire-time
|
||||||
label:
|
label:
|
||||||
en_US: Conversation expire time (seconds)
|
en_US: Conversation expire time (seconds)
|
||||||
@@ -653,3 +661,215 @@ stages:
|
|||||||
type: json
|
type: json
|
||||||
required: false
|
required: false
|
||||||
default: '{}'
|
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]]
|
[[package]]
|
||||||
name = "langbot"
|
name = "langbot"
|
||||||
version = "4.10.0b1"
|
version = "4.10.0"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "aiocqhttp" },
|
{ name = "aiocqhttp" },
|
||||||
@@ -2013,7 +2013,7 @@ requires-dist = [
|
|||||||
{ name = "ebooklib", specifier = ">=0.18" },
|
{ name = "ebooklib", specifier = ">=0.18" },
|
||||||
{ name = "gewechat-client", specifier = ">=0.1.5" },
|
{ name = "gewechat-client", specifier = ">=0.1.5" },
|
||||||
{ name = "html2text", specifier = ">=2024.2.26" },
|
{ name = "html2text", specifier = ">=2024.2.26" },
|
||||||
{ name = "langbot-plugin", specifier = "==0.4.0" },
|
{ name = "langbot-plugin", specifier = "==0.4.1" },
|
||||||
{ name = "langchain", specifier = ">=0.2.0" },
|
{ name = "langchain", specifier = ">=0.2.0" },
|
||||||
{ name = "langchain-core", specifier = ">=1.2.28" },
|
{ name = "langchain-core", specifier = ">=1.2.28" },
|
||||||
{ name = "langchain-text-splitters", specifier = ">=1.1.2" },
|
{ name = "langchain-text-splitters", specifier = ">=1.1.2" },
|
||||||
@@ -2076,7 +2076,7 @@ dev = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "langbot-plugin"
|
name = "langbot-plugin"
|
||||||
version = "0.4.0"
|
version = "0.4.1"
|
||||||
source = { registry = "https://pypi.org/simple" }
|
source = { registry = "https://pypi.org/simple" }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "aiofiles" },
|
{ name = "aiofiles" },
|
||||||
@@ -2096,9 +2096,9 @@ dependencies = [
|
|||||||
{ name = "watchdog" },
|
{ name = "watchdog" },
|
||||||
{ name = "websockets" },
|
{ name = "websockets" },
|
||||||
]
|
]
|
||||||
sdist = { url = "https://files.pythonhosted.org/packages/9b/0d/709cb5641b802c4a92a177363675b2bd7d38f6921bd873001b759a98cf05/langbot_plugin-0.4.0.tar.gz", hash = "sha256:e8669676283f3fae434b65a53ffb6ea26ea5665922c88fa72d348bca7a5b2650", size = 288861, upload-time = "2026-06-03T02:53:17.046Z" }
|
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 = [
|
wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/bb/14/17d2e42c54539fdc4a0287bc1975170d597d5f58604e011cfc352b53bd35/langbot_plugin-0.4.0-py3-none-any.whl", hash = "sha256:144cf5c7a4849c3db1485a13a698568fab0211a9ff7de6fc9965d72850aa9ef0", size = 203081, upload-time = "2026-06-03T02:53:15.762Z" },
|
{ 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]]
|
[[package]]
|
||||||
|
|||||||
@@ -119,6 +119,22 @@ function compareVersions(v1: string, v2: string): boolean {
|
|||||||
return false;
|
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
|
// IDs of sidebar entries that have collapsible entity sub-items
|
||||||
const ENTITY_CATEGORY_IDS = [
|
const ENTITY_CATEGORY_IDS = [
|
||||||
'bots',
|
'bots',
|
||||||
@@ -2077,6 +2093,14 @@ export default function HomeSidebar({
|
|||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => {
|
||||||
|
window.open('https://discord.gg/wdNEHETs87', '_blank');
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DiscordIcon className="text-[#5865F2]" />
|
||||||
|
{t('common.joinDiscord')}
|
||||||
|
</DropdownMenuItem>
|
||||||
</DropdownMenuGroup>
|
</DropdownMenuGroup>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import {
|
|||||||
PopoverContent,
|
PopoverContent,
|
||||||
PopoverTrigger,
|
PopoverTrigger,
|
||||||
} from '@/components/ui/popover';
|
} 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 { useTranslation } from 'react-i18next';
|
||||||
import { ScannedProviderModel } from '@/app/infra/entities/api';
|
import { ScannedProviderModel } from '@/app/infra/entities/api';
|
||||||
import {
|
import {
|
||||||
@@ -298,20 +298,8 @@ export default function AddModelPopover({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="overflow-y-auto flex-1 min-h-0">
|
<div className="overflow-y-auto flex-1 min-h-0">
|
||||||
<Tabs
|
{mode === 'manual' ? (
|
||||||
value={mode}
|
<div className="mt-3">
|
||||||
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">
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label>{t('models.modelName')}</Label>
|
<Label>{t('models.modelName')}</Label>
|
||||||
@@ -390,9 +378,9 @@ export default function AddModelPopover({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</div>
|
||||||
|
) : (
|
||||||
<TabsContent value="scan" className="space-y-2 mt-0 pt-0">
|
<div className="space-y-2 mt-3">
|
||||||
{scanLoading ? (
|
{scanLoading ? (
|
||||||
<div className="flex items-center justify-center py-4">
|
<div className="flex items-center justify-center py-4">
|
||||||
<RefreshCw className="h-4 w-4 mr-2 animate-spin text-muted-foreground" />
|
<RefreshCw className="h-4 w-4 mr-2 animate-spin text-muted-foreground" />
|
||||||
@@ -565,8 +553,8 @@ export default function AddModelPopover({
|
|||||||
/>
|
/>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</TabsContent>
|
</div>
|
||||||
</Tabs>
|
)}
|
||||||
</div>
|
</div>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</PopoverContent>
|
</PopoverContent>
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
Download,
|
Download,
|
||||||
Package,
|
Package,
|
||||||
Server,
|
Server,
|
||||||
BookOpen,
|
Sparkles,
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
XCircle,
|
XCircle,
|
||||||
Loader2,
|
Loader2,
|
||||||
@@ -176,7 +176,7 @@ function TaskProgressContent({ task }: { task: PluginInstallTask }) {
|
|||||||
// MCP / Skill don't have the plugin's download + dependency-install stages;
|
// MCP / Skill don't have the plugin's download + dependency-install stages;
|
||||||
// show a single "installing → done/failed" row instead of plugin steps.
|
// show a single "installing → done/failed" row instead of plugin steps.
|
||||||
const isPlugin = task.extensionType === 'plugin';
|
const isPlugin = task.extensionType === 'plugin';
|
||||||
const simpleIcon = task.extensionType === 'mcp' ? Server : BookOpen;
|
const simpleIcon = task.extensionType === 'mcp' ? Server : Sparkles;
|
||||||
const simpleInstallingLabel =
|
const simpleInstallingLabel =
|
||||||
task.extensionType === 'mcp'
|
task.extensionType === 'mcp'
|
||||||
? t('addExtension.installStage.mcpInstalling')
|
? t('addExtension.installStage.mcpInstalling')
|
||||||
|
|||||||
@@ -9,9 +9,9 @@ import {
|
|||||||
Loader2,
|
Loader2,
|
||||||
X,
|
X,
|
||||||
ListTodo,
|
ListTodo,
|
||||||
Wrench,
|
Puzzle,
|
||||||
AudioWaveform,
|
Server,
|
||||||
Book,
|
Sparkles,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
@@ -35,9 +35,9 @@ const STAGE_ICONS: Record<string, React.ElementType> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const EXTENSION_TYPE_ICONS: Record<string, React.ElementType> = {
|
const EXTENSION_TYPE_ICONS: Record<string, React.ElementType> = {
|
||||||
plugin: Wrench,
|
plugin: Puzzle,
|
||||||
mcp: AudioWaveform,
|
mcp: Server,
|
||||||
skill: Book,
|
skill: Sparkles,
|
||||||
};
|
};
|
||||||
|
|
||||||
function TaskQueueItem({
|
function TaskQueueItem({
|
||||||
@@ -54,7 +54,7 @@ function TaskQueueItem({
|
|||||||
const isError = task.stage === InstallStage.ERROR;
|
const isError = task.stage === InstallStage.ERROR;
|
||||||
const isRunning = !isDone && !isError;
|
const isRunning = !isDone && !isError;
|
||||||
const StageIcon = STAGE_ICONS[task.stage] || Download;
|
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 = () => {
|
const getTypeBadgeClass = () => {
|
||||||
switch (task.extensionType) {
|
switch (task.extensionType) {
|
||||||
|
|||||||
@@ -21,8 +21,7 @@ import { extractI18nObject } from '@/i18n/I18nProvider';
|
|||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { useAsyncTask, AsyncTaskStatus } from '@/hooks/useAsyncTask';
|
import { useAsyncTask, AsyncTaskStatus } from '@/hooks/useAsyncTask';
|
||||||
import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext';
|
import { useSidebarData } from '@/app/home/components/home-sidebar/SidebarDataContext';
|
||||||
import { Loader2, Puzzle } from 'lucide-react';
|
import { Loader2, Puzzle, Server, Sparkles } from 'lucide-react';
|
||||||
import { Wrench, AudioWaveform, Book } from 'lucide-react';
|
|
||||||
|
|
||||||
export interface PluginInstalledComponentRef {
|
export interface PluginInstalledComponentRef {
|
||||||
refreshPluginList: () => void;
|
refreshPluginList: () => void;
|
||||||
@@ -44,14 +43,18 @@ export const FilterOptions = [
|
|||||||
{
|
{
|
||||||
value: 'plugin' as FilterType,
|
value: 'plugin' as FilterType,
|
||||||
labelKey: 'market.typePlugin',
|
labelKey: 'market.typePlugin',
|
||||||
icon: Wrench,
|
icon: Puzzle,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: 'mcp' as FilterType,
|
value: 'mcp' as FilterType,
|
||||||
labelKey: 'market.typeMCP',
|
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 {
|
interface PluginInstalledComponentProps {
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ import { Separator } from '@/components/ui/separator';
|
|||||||
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
|
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
|
||||||
import {
|
import {
|
||||||
Search,
|
Search,
|
||||||
|
Puzzle,
|
||||||
|
Server,
|
||||||
|
Sparkles,
|
||||||
Wrench,
|
Wrench,
|
||||||
AudioWaveform,
|
AudioWaveform,
|
||||||
Hash,
|
Hash,
|
||||||
@@ -88,9 +91,9 @@ function MarketPageContent({
|
|||||||
|
|
||||||
const extensionTypeOptions = [
|
const extensionTypeOptions = [
|
||||||
{ value: 'all', label: t('market.filters.allFormats'), icon: null },
|
{ value: 'all', label: t('market.filters.allFormats'), icon: null },
|
||||||
{ value: 'plugin', label: t('market.typePlugin'), icon: Wrench },
|
{ value: 'plugin', label: t('market.typePlugin'), icon: Puzzle },
|
||||||
{ value: 'mcp', label: t('market.typeMCP'), icon: AudioWaveform },
|
{ value: 'mcp', label: t('market.typeMCP'), icon: Server },
|
||||||
{ value: 'skill', label: t('market.typeSkill'), icon: Book },
|
{ value: 'skill', label: t('market.typeSkill'), icon: Sparkles },
|
||||||
];
|
];
|
||||||
|
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
@@ -142,7 +145,7 @@ function MarketPageContent({
|
|||||||
}
|
}
|
||||||
}, [typeFilter, componentFilter, selectedTags, sortOption]);
|
}, [typeFilter, componentFilter, selectedTags, sortOption]);
|
||||||
|
|
||||||
const pageSize = 12; // 每页12个
|
const pageSize = 24; // 每页24个
|
||||||
const searchTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
const searchTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||||
const scrollContainerRef = useRef<HTMLDivElement | null>(null);
|
const scrollContainerRef = useRef<HTMLDivElement | null>(null);
|
||||||
const isComposingRef = useRef(false);
|
const isComposingRef = useRef(false);
|
||||||
|
|||||||
@@ -94,7 +94,7 @@ export default function PluginMarketCardComponent({
|
|||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
aria-label={t('market.installCard', { name: cardVO.label })}
|
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}
|
onClick={handleInstallClick}
|
||||||
onKeyDown={(event) => {
|
onKeyDown={(event) => {
|
||||||
if (event.key === 'Enter' || event.key === ' ') {
|
if (event.key === 'Enter' || event.key === ' ') {
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ const enUS = {
|
|||||||
helpDocs: 'Get Help',
|
helpDocs: 'Get Help',
|
||||||
featureRequest: 'Feature Request',
|
featureRequest: 'Feature Request',
|
||||||
starOnGitHub: 'Star on GitHub',
|
starOnGitHub: 'Star on GitHub',
|
||||||
|
joinDiscord: 'Join our Discord',
|
||||||
create: 'Create',
|
create: 'Create',
|
||||||
edit: 'Edit',
|
edit: 'Edit',
|
||||||
delete: 'Delete',
|
delete: 'Delete',
|
||||||
@@ -631,8 +632,8 @@ const enUS = {
|
|||||||
},
|
},
|
||||||
market: {
|
market: {
|
||||||
searchPlaceholder: 'Search plugins...',
|
searchPlaceholder: 'Search plugins...',
|
||||||
searchResults: 'Found {{count}} plugins',
|
searchResults: 'Found {{count}} extensions',
|
||||||
totalPlugins: 'Total {{count}} plugins',
|
totalPlugins: 'Total {{count}} extensions',
|
||||||
noPlugins: 'No plugins available',
|
noPlugins: 'No plugins available',
|
||||||
noResults: 'No relevant plugins found',
|
noResults: 'No relevant plugins found',
|
||||||
loadingMore: 'Loading more...',
|
loadingMore: 'Loading more...',
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ const esES = {
|
|||||||
helpDocs: 'Obtener ayuda',
|
helpDocs: 'Obtener ayuda',
|
||||||
featureRequest: 'Solicitar función',
|
featureRequest: 'Solicitar función',
|
||||||
starOnGitHub: 'Dar estrella en GitHub',
|
starOnGitHub: 'Dar estrella en GitHub',
|
||||||
|
joinDiscord: 'Únete a Discord',
|
||||||
create: 'Crear',
|
create: 'Crear',
|
||||||
edit: 'Editar',
|
edit: 'Editar',
|
||||||
delete: 'Eliminar',
|
delete: 'Eliminar',
|
||||||
@@ -644,8 +645,8 @@ const esES = {
|
|||||||
},
|
},
|
||||||
market: {
|
market: {
|
||||||
searchPlaceholder: 'Buscar plugins...',
|
searchPlaceholder: 'Buscar plugins...',
|
||||||
searchResults: 'Se encontraron {{count}} plugins',
|
searchResults: 'Se encontraron {{count}} extensiones',
|
||||||
totalPlugins: 'Total {{count}} plugins',
|
totalPlugins: 'Total {{count}} extensiones',
|
||||||
noPlugins: 'No hay plugins disponibles',
|
noPlugins: 'No hay plugins disponibles',
|
||||||
noResults: 'No se encontraron plugins relevantes',
|
noResults: 'No se encontraron plugins relevantes',
|
||||||
loadingMore: 'Cargando más...',
|
loadingMore: 'Cargando más...',
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ const jaJP = {
|
|||||||
helpDocs: 'ヘルプドキュメント',
|
helpDocs: 'ヘルプドキュメント',
|
||||||
featureRequest: '機能リクエスト',
|
featureRequest: '機能リクエスト',
|
||||||
starOnGitHub: 'GitHubでStarする',
|
starOnGitHub: 'GitHubでStarする',
|
||||||
|
joinDiscord: 'Discord に参加',
|
||||||
create: '作成',
|
create: '作成',
|
||||||
edit: '編集',
|
edit: '編集',
|
||||||
delete: '削除',
|
delete: '削除',
|
||||||
@@ -636,8 +637,8 @@ const jaJP = {
|
|||||||
},
|
},
|
||||||
market: {
|
market: {
|
||||||
searchPlaceholder: 'プラグインを検索...',
|
searchPlaceholder: 'プラグインを検索...',
|
||||||
searchResults: '{{count}} 個のプラグインが見つかりました',
|
searchResults: '{{count}} 個の拡張機能が見つかりました',
|
||||||
totalPlugins: '合計 {{count}} 個のプラグイン',
|
totalPlugins: '合計 {{count}} 個の拡張機能',
|
||||||
noPlugins: '利用可能なプラグインがありません',
|
noPlugins: '利用可能なプラグインがありません',
|
||||||
noResults: '関連するプラグインが見つかりません',
|
noResults: '関連するプラグインが見つかりません',
|
||||||
loadingMore: 'さらに読み込み中...',
|
loadingMore: 'さらに読み込み中...',
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ const ruRU = {
|
|||||||
helpDocs: 'Помощь',
|
helpDocs: 'Помощь',
|
||||||
featureRequest: 'Запрос функции',
|
featureRequest: 'Запрос функции',
|
||||||
starOnGitHub: 'Поставить звезду на GitHub',
|
starOnGitHub: 'Поставить звезду на GitHub',
|
||||||
|
joinDiscord: 'Присоединиться к Discord',
|
||||||
create: 'Создать',
|
create: 'Создать',
|
||||||
edit: 'Редактировать',
|
edit: 'Редактировать',
|
||||||
delete: 'Удалить',
|
delete: 'Удалить',
|
||||||
@@ -642,8 +643,8 @@ const ruRU = {
|
|||||||
},
|
},
|
||||||
market: {
|
market: {
|
||||||
searchPlaceholder: 'Поиск плагинов...',
|
searchPlaceholder: 'Поиск плагинов...',
|
||||||
searchResults: 'Найдено {{count}} плагинов',
|
searchResults: 'Найдено {{count}} расширений',
|
||||||
totalPlugins: 'Всего {{count}} плагинов',
|
totalPlugins: 'Всего {{count}} расширений',
|
||||||
noPlugins: 'Нет доступных плагинов',
|
noPlugins: 'Нет доступных плагинов',
|
||||||
noResults: 'Подходящие плагины не найдены',
|
noResults: 'Подходящие плагины не найдены',
|
||||||
loadingMore: 'Загрузка ещё...',
|
loadingMore: 'Загрузка ещё...',
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ const thTH = {
|
|||||||
helpDocs: 'ขอความช่วยเหลือ',
|
helpDocs: 'ขอความช่วยเหลือ',
|
||||||
featureRequest: 'ขอฟีเจอร์ใหม่',
|
featureRequest: 'ขอฟีเจอร์ใหม่',
|
||||||
starOnGitHub: 'ให้ดาวบน GitHub',
|
starOnGitHub: 'ให้ดาวบน GitHub',
|
||||||
|
joinDiscord: 'เข้าร่วม Discord',
|
||||||
create: 'สร้าง',
|
create: 'สร้าง',
|
||||||
edit: 'แก้ไข',
|
edit: 'แก้ไข',
|
||||||
delete: 'ลบ',
|
delete: 'ลบ',
|
||||||
@@ -623,8 +624,8 @@ const thTH = {
|
|||||||
},
|
},
|
||||||
market: {
|
market: {
|
||||||
searchPlaceholder: 'ค้นหาปลั๊กอิน...',
|
searchPlaceholder: 'ค้นหาปลั๊กอิน...',
|
||||||
searchResults: 'พบ {{count}} ปลั๊กอิน',
|
searchResults: 'พบ {{count}} ส่วนขยาย',
|
||||||
totalPlugins: 'ทั้งหมด {{count}} ปลั๊กอิน',
|
totalPlugins: 'ทั้งหมด {{count}} ส่วนขยาย',
|
||||||
noPlugins: 'ไม่มีปลั๊กอินที่พร้อมใช้งาน',
|
noPlugins: 'ไม่มีปลั๊กอินที่พร้อมใช้งาน',
|
||||||
noResults: 'ไม่พบปลั๊กอินที่เกี่ยวข้อง',
|
noResults: 'ไม่พบปลั๊กอินที่เกี่ยวข้อง',
|
||||||
loadingMore: 'กำลังโหลดเพิ่มเติม...',
|
loadingMore: 'กำลังโหลดเพิ่มเติม...',
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ const viVN = {
|
|||||||
helpDocs: 'Trợ giúp',
|
helpDocs: 'Trợ giúp',
|
||||||
featureRequest: 'Yêu cầu tính năng',
|
featureRequest: 'Yêu cầu tính năng',
|
||||||
starOnGitHub: 'Star trên GitHub',
|
starOnGitHub: 'Star trên GitHub',
|
||||||
|
joinDiscord: 'Tham gia Discord',
|
||||||
create: 'Tạo',
|
create: 'Tạo',
|
||||||
edit: 'Chỉnh sửa',
|
edit: 'Chỉnh sửa',
|
||||||
delete: 'Xóa',
|
delete: 'Xóa',
|
||||||
@@ -637,8 +638,8 @@ const viVN = {
|
|||||||
},
|
},
|
||||||
market: {
|
market: {
|
||||||
searchPlaceholder: 'Tìm kiếm plugin...',
|
searchPlaceholder: 'Tìm kiếm plugin...',
|
||||||
searchResults: 'Tìm thấy {{count}} plugin',
|
searchResults: 'Tìm thấy {{count}} tiện ích mở rộng',
|
||||||
totalPlugins: 'Tổng cộng {{count}} plugin',
|
totalPlugins: 'Tổng cộng {{count}} tiện ích mở rộng',
|
||||||
noPlugins: 'Không có plugin nào',
|
noPlugins: 'Không có plugin nào',
|
||||||
noResults: 'Không tìm thấy plugin liên quan',
|
noResults: 'Không tìm thấy plugin liên quan',
|
||||||
loadingMore: 'Đang tải thêm...',
|
loadingMore: 'Đang tải thêm...',
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ const zhHans = {
|
|||||||
helpDocs: '帮助文档',
|
helpDocs: '帮助文档',
|
||||||
featureRequest: '需求建议',
|
featureRequest: '需求建议',
|
||||||
starOnGitHub: '在 GitHub 上 Star',
|
starOnGitHub: '在 GitHub 上 Star',
|
||||||
|
joinDiscord: '加入 Discord 社区',
|
||||||
create: '创建',
|
create: '创建',
|
||||||
edit: '编辑',
|
edit: '编辑',
|
||||||
delete: '删除',
|
delete: '删除',
|
||||||
@@ -605,8 +606,8 @@ const zhHans = {
|
|||||||
},
|
},
|
||||||
market: {
|
market: {
|
||||||
searchPlaceholder: '搜索插件...',
|
searchPlaceholder: '搜索插件...',
|
||||||
searchResults: '搜索到 {{count}} 个插件',
|
searchResults: '搜索到 {{count}} 个扩展',
|
||||||
totalPlugins: '共 {{count}} 个插件',
|
totalPlugins: '共 {{count}} 个扩展',
|
||||||
noPlugins: '暂无插件',
|
noPlugins: '暂无插件',
|
||||||
noResults: '未找到相关插件',
|
noResults: '未找到相关插件',
|
||||||
loadingMore: '加载更多...',
|
loadingMore: '加载更多...',
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ const zhHant = {
|
|||||||
helpDocs: '輔助說明',
|
helpDocs: '輔助說明',
|
||||||
featureRequest: '需求建議',
|
featureRequest: '需求建議',
|
||||||
starOnGitHub: '在 GitHub 上 Star',
|
starOnGitHub: '在 GitHub 上 Star',
|
||||||
|
joinDiscord: '加入 Discord 社群',
|
||||||
create: '建立',
|
create: '建立',
|
||||||
edit: '編輯',
|
edit: '編輯',
|
||||||
delete: '刪除',
|
delete: '刪除',
|
||||||
@@ -605,8 +606,8 @@ const zhHant = {
|
|||||||
},
|
},
|
||||||
market: {
|
market: {
|
||||||
searchPlaceholder: '搜尋插件...',
|
searchPlaceholder: '搜尋插件...',
|
||||||
searchResults: '搜尋到 {{count}} 個插件',
|
searchResults: '搜尋到 {{count}} 個擴展',
|
||||||
totalPlugins: '共 {{count}} 個插件',
|
totalPlugins: '共 {{count}} 個擴展',
|
||||||
noPlugins: '暫無插件',
|
noPlugins: '暫無插件',
|
||||||
noResults: '未找到相關插件',
|
noResults: '未找到相關插件',
|
||||||
loadingMore: '載入更多...',
|
loadingMore: '載入更多...',
|
||||||
|
|||||||
Reference in New Issue
Block a user